From 0fa3928bf811060081fb2ceb3fd3d69107712aaa Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:41:34 -0400 Subject: [PATCH] Track encounter start/end timestamps; display on card startEncounter sets startedAt (clears endedAt on restart). endEncounter sets endedAt. snapshotOf includes both for undo/redo fidelity. expandUndo split start/end cases to restore old timestamps. Display: encounter card shows Started/Ended independently (endedAt shows even without startedAt). Undo sets null (can't delete via merge), test snap strips only startedAt/endedAt null for compare. Reviewed by pi gpt-5.5: stale endedAt on restart fixed, snap null-strip narrowed. Medium-low risk. --- shared/tests/turn.undo.test.js | 8 +++++++- shared/turn.js | 22 +++++++++++++++++----- src/App.js | 5 +++++ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/shared/tests/turn.undo.test.js b/shared/tests/turn.undo.test.js index ecc84f3..6e3f306 100644 --- a/shared/tests/turn.undo.test.js +++ b/shared/tests/turn.undo.test.js @@ -29,7 +29,13 @@ function enc(ps) { return { name:'t', participants:ps, isStarted:false, isPaused:false, round:0, currentTurnParticipantId:null, turnOrderIds:[] }; } -const snap = (e) => JSON.parse(JSON.stringify(e)); +const snap = (e) => { + const o = JSON.parse(JSON.stringify(e)); + // normalize nullable timestamp fields: null = absent (undo clears via null) + if (o.startedAt === null) delete o.startedAt; + if (o.endedAt === null) delete o.endedAt; + return o; +}; describe('undo + redo roundtrip', () => { test('startEncounter', async () => { diff --git a/shared/turn.js b/shared/turn.js index ea17394..1352209 100644 --- a/shared/turn.js +++ b/shared/turn.js @@ -102,6 +102,8 @@ function snapshotOf(enc) { currentTurnParticipantId: enc.currentTurnParticipantId ?? null, turnOrderIds: [...(enc.turnOrderIds || [])], activeIds: (enc.participants || []).filter(p => p.isActive).map(p => p.id), + startedAt: enc.startedAt ?? null, + endedAt: enc.endedAt ?? null, }; } @@ -305,12 +307,18 @@ function expandUndo(entry, currentEnc) { redo: { currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [] }, }; } - case 'start_encounter': + case 'start_encounter': { + return { + encounterPath: encPath, + updates: { ...u, startedAt: u.startedAt ?? null }, + redo: { isStarted: true, isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [], startedAt: snap.startedAt ?? Date.now() }, + }; + } case 'end_encounter': { return { encounterPath: encPath, - updates: u, - redo: { isStarted: entry.type === 'start_encounter', isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [] }, + updates: { ...u, endedAt: u.endedAt ?? null }, + redo: { isStarted: false, isPaused: false, currentTurnParticipantId: snap.currentTurnParticipantId, round: snap.round, turnOrderIds: snap.turnOrderIds || [], endedAt: snap.endedAt ?? Date.now() }, }; } default: @@ -391,6 +399,8 @@ async function startEncounter(encounter, ctx) { participants: sortedParticipants, currentTurnParticipantId: firstActive.id, turnOrderIds: sortedParticipants.map(p => p.id), + startedAt: Date.now(), + endedAt: null, }; const log = { type: 'start_encounter', @@ -403,6 +413,8 @@ async function startEncounter(encounter, ctx) { round: encounter.round ?? 0, currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, turnOrderIds: [...(encounter.turnOrderIds || [])], + startedAt: encounter.startedAt ?? null, + endedAt: encounter.endedAt ?? null, }, }; return commit(encounter, patch, log, ctx); @@ -1003,12 +1015,12 @@ async function reorderParticipants(encounter, draggedId, targetId, ctx) { } async function endEncounter(encounter, ctx) { - const patch = { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: 0, turnOrderIds: [] }; + const patch = { isStarted: false, isPaused: false, currentTurnParticipantId: null, round: 0, turnOrderIds: [], endedAt: Date.now() }; const log = { type: 'end_encounter', message: `Combat ended: "${encounter.name}"`, delta: {}, - undo: { isStarted: encounter.isStarted ?? false, isPaused: encounter.isPaused ?? false, round: encounter.round ?? 0, currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, turnOrderIds: [...(encounter.turnOrderIds || [])] }, + undo: { isStarted: encounter.isStarted ?? false, isPaused: encounter.isPaused ?? false, round: encounter.round ?? 0, currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, turnOrderIds: [...(encounter.turnOrderIds || [])], endedAt: encounter.endedAt ?? null }, }; return commit(encounter, patch, log, ctx); } diff --git a/src/App.js b/src/App.js index debef6e..7be5c77 100644 --- a/src/App.js +++ b/src/App.js @@ -2258,6 +2258,11 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
{encounter.createdAt && `${new Date(encounter.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })} · `}Participants: {encounter.participants?.length || 0}
+ {(encounter.startedAt || encounter.endedAt) && ( ++ {encounter.startedAt && `⚔ Started: ${new Date(encounter.startedAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })}`}{encounter.startedAt && encounter.endedAt && ' · '}{encounter.endedAt && `Ended: ${new Date(encounter.endedAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false })}`} +
+ )} {isLive && ( LIVE ON PLAYER DISPLAY