P14b: session chat (table + whispers) + saved campaign recap

- Sidebar gains tabs: Chat / Rolls / (GM) Recap. Chat supports table messages and
  private whispers (GM→player via a recipient picker; player→GM via a toggle).
  Server routes table to all and whispers to the target only (+server tests).
- Rolls + chat are persisted to a per-campaign session recap (Dexie v13 sessionLog
  table + sessionLogRepo); the GM's "Recap" tab shows it (survives reloads/sessions).
- wsSync: chat send/receive, optimistic local echo, persistLog for the GM.
- Realtime e2e: GM table message reaches the player; the player's reply reaches the GM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 17:15:38 +02:00
parent 4cb834ad6c
commit e562a270d1
12 changed files with 259 additions and 44 deletions
+18
View File
@@ -11,6 +11,8 @@ import {
calendarSchema,
battleMapSchema,
homebrewSchema,
sessionLogEntrySchema,
type SessionLogEntry,
type HomebrewKind,
type Homebrew,
type Campaign,
@@ -216,6 +218,22 @@ export const diceRepo = {
},
};
// ---------------- Session recap (rolls + chat, GM-persisted) ----------------
export const sessionLogRepo = {
async recent(campaignId: string, limit = 200): Promise<SessionLogEntry[]> {
const all = await db.sessionLog.where('campaignId').equals(campaignId).toArray();
return all.sort((a, b) => a.ts.localeCompare(b.ts)).slice(-limit);
},
async add(entry: Omit<SessionLogEntry, 'id' | 'ts'> & { ts?: string }): Promise<void> {
const record = sessionLogEntrySchema.parse({ ...entry, id: newId(), ts: entry.ts ?? now() });
await db.sessionLog.add(record);
},
async clear(campaignId?: string): Promise<void> {
if (campaignId) await db.sessionLog.where('campaignId').equals(campaignId).delete();
else await db.sessionLog.clear();
},
};
// ---------------- Notes ----------------
export const notesRepo = {
listByCampaign(campaignId: string): Promise<Note[]> {