From 35990f588e2e4e2048c5108a62130271a9b04482 Mon Sep 17 00:00:00 2001 From: robert Date: Sat, 27 Jun 2026 16:45:07 -0400 Subject: [PATCH] Add per-entry undo buttons to combat log, bump to v0.2.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each logged action now stores a Firestore snapshot of the affected encounter state. The /logs page shows an ↩ Undo button on any entry with undo data; clicking it restores the encounter to its pre-action state and marks the entry as rolled back (greyed out, strikethrough). Covered actions: damage/heal, condition toggle, activate/deactivate, add/remove participant, next turn, start/pause/resume/end combat. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 7 +-- src/App.js | 133 +++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 122 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 2abf83f..528025c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# TTRPG Initiative Tracker (v0.2.4) +# TTRPG Initiative Tracker (v0.2.5) ![Here it is in use](images/in_use.png) @@ -58,7 +58,7 @@ Have you tried it? Got feedback or questions? Discuss here: [https://discourse.d * Player display is opened in a separate window via the "Open Player Window" button in the DM's header. * A **fullscreen button** (top-right corner) toggles the browser into fullscreen mode — ideal for a dedicated second monitor. * A **prevent sleep toggle** (moon/coffee icon, top-right corner) uses the browser Wake Lock API to keep the screen on while active. -* **Combat Action Log:** A running log of combat events (HP changes, turn advances, encounter starts/ends, etc.) is available at `/logs`. Entries are timestamped and tagged with the encounter name. The log can be cleared from that page. +* **Combat Action Log:** A running log of combat events (HP changes, condition changes, turn advances, participant additions/removals, encounter starts/ends, etc.) is available at `/logs`. Entries are timestamped and tagged with the encounter name. Most entries include an **↩ Undo** button that rolls back the action in Firestore (restoring HP, conditions, turn order, etc.). Rolled-back entries are greyed out with a strikethrough. The log can be cleared in bulk from that page. * **Real-time Updates:** Uses Firebase Firestore for real-time synchronization between DM actions and the player display. * **Initiative Tie-Breaking:** DMs can drag-and-drop participants with tied initiative scores (before an encounter starts or while paused) to set a manual order. * **Responsive Design:** Styled with Tailwind CSS. @@ -115,8 +115,9 @@ The TTRPG Initiative Tracker is designed for Dungeon Masters to manage combat en * Use the **prevent sleep toggle** (moon icon, top-right) to keep the screen awake using the browser Wake Lock API. The icon turns amber and switches to a coffee cup when active. 3. **Combat Log (`/logs`):** - * A dedicated page that records all significant combat events: encounter starts and ends, turn advances, HP changes, and death saves. + * A dedicated page that records all significant combat events: encounter starts and ends, turn advances, HP changes, condition changes, participant additions/removals, and activation toggles. * Each entry is timestamped and tagged with the encounter name for easy reference. + * Most entries have an **↩ Undo** button. Clicking it rolls back that specific action in Firestore — restoring HP, conditions, turn order, or participant state to what it was before. The entry is then greyed out and marked "rolled back". * The log can be cleared in bulk from the page (with confirmation). * Accessible via the "Logs" link in the DM header or directly at `/logs`. diff --git a/src/App.js b/src/App.js index b13c242..ba3b706 100644 --- a/src/App.js +++ b/src/App.js @@ -45,7 +45,7 @@ if (typeof document !== 'undefined') { // CONSTANTS // ============================================================================ -const APP_VERSION = 'v0.2.4'; +const APP_VERSION = 'v0.2.5'; const DEFAULT_MAX_HP = 10; const DEFAULT_INIT_MOD = 0; const MONSTER_DEFAULT_INIT_MOD = 2; @@ -157,10 +157,12 @@ const sortParticipantsByInitiative = (participants, originalOrder) => { const LOG_QUERY = [orderBy('timestamp', 'desc'), limit(500)]; -const logAction = async (message, context = {}) => { +const logAction = async (message, context = {}, undoData = null) => { if (!db) return; try { - await addDoc(collection(db, getPath.logs()), { timestamp: Date.now(), message, ...context }); + const entry = { timestamp: Date.now(), message, ...context }; + if (undoData) entry.undo = undoData; + await addDoc(collection(db, getPath.logs()), entry); } catch (err) { console.error('Error writing log:', err); } @@ -898,7 +900,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { await updateDoc(doc(db, encounterPath), { participants: [...participants, newParticipant] }); - logAction(`${nameToAdd} added to encounter (Initiative: ${finalInitiative})`, { encounterName: encounter.name }); + logAction(`${nameToAdd} added to encounter (Initiative: ${finalInitiative})`, { encounterName: encounter.name }, { + encounterPath, + updates: { participants: [...participants] }, + }); setLastRollDetails({ name: nameToAdd, @@ -992,13 +997,23 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { if (!db || !itemToDelete) return; const updatedParticipants = participants.filter(p => p.id !== itemToDelete.id); + const deleteUndoData = { + encounterPath, + updates: { + participants: [...participants], + ...(encounter.isStarted ? { + currentTurnParticipantId: encounter.currentTurnParticipantId, + turnOrderIds: [...(encounter.turnOrderIds || [])], + } : {}), + }, + }; try { await updateDoc(doc(db, encounterPath), { participants: updatedParticipants, ...computeTurnOrderAfterRemoval(encounter, itemToDelete.id, updatedParticipants) }); - logAction(`${itemToDelete.name} removed from encounter`, { encounterName: encounter.name }); + logAction(`${itemToDelete.name} removed from encounter`, { encounterName: encounter.name }, deleteUndoData); } catch (err) { console.error("Error deleting participant:", err); alert("Failed to delete participant. Please try again."); @@ -1025,7 +1040,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { try { await updateDoc(doc(db, encounterPath), { participants: updatedParticipants, ...turnUpdates }); - logAction(`${participant.name} ${newIsActive ? 'reactivated' : 'deactivated'}`, { encounterName: encounter.name }); + logAction(`${participant.name} ${newIsActive ? 'reactivated' : 'deactivated'}`, { encounterName: encounter.name }, { + encounterPath, + updates: { + participants: [...participants], + ...(encounter.isStarted ? { + currentTurnParticipantId: encounter.currentTurnParticipantId, + turnOrderIds: [...(encounter.turnOrderIds || [])], + } : {}), + }, + }); } catch (err) { console.error("Error toggling active state:", err); } @@ -1087,6 +1111,17 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { ? computeTurnOrderAfterAddition(encounter, participantId) : {}; + const hpUndoData = { + encounterPath, + updates: { + participants: [...participants], + ...((isDead && !wasDead) || wasResurrected ? { + currentTurnParticipantId: encounter.currentTurnParticipantId, + turnOrderIds: [...(encounter.turnOrderIds || [])], + } : {}), + }, + }; + try { await updateDoc(doc(db, encounterPath), { participants: updatedParticipants, ...turnUpdates }); setHpChangeValues(prev => ({ ...prev, [participantId]: '' })); @@ -1094,9 +1129,9 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { const deathSuffix = (isDead && !wasDead) ? (participant.type === 'character' ? ' — Unconscious' : ' — Defeated') : ''; const resurSuffix = wasResurrected ? ' — Revived' : ''; if (changeType === 'damage') { - logAction(`${participant.name} took ${amount} damage (${hpLine})${deathSuffix}`, { encounterName: encounter.name }); + logAction(`${participant.name} took ${amount} damage (${hpLine})${deathSuffix}`, { encounterName: encounter.name }, hpUndoData); } else { - logAction(`${participant.name} healed for ${amount} (${hpLine})${resurSuffix}`, { encounterName: encounter.name }); + logAction(`${participant.name} healed for ${amount} (${hpLine})${resurSuffix}`, { encounterName: encounter.name }, hpUndoData); } } catch (err) { console.error("Error applying HP change:", err); @@ -1164,7 +1199,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { await updateDoc(doc(db, encounterPath), { participants: updatedParticipants }); const cond = CONDITIONS.find(c => c.id === conditionId); const condLabel = cond ? `${cond.label} ${cond.emoji}` : conditionId; - logAction(`${participant.name} ${wasActive ? 'lost' : 'gained'} ${condLabel}`, { encounterName: encounter.name }); + logAction(`${participant.name} ${wasActive ? 'lost' : 'gained'} ${condLabel}`, { encounterName: encounter.name }, { + encounterPath, + updates: { participants: [...participants] }, + }); } catch (err) { console.error("Error updating conditions:", err); } @@ -1617,7 +1655,16 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { activeEncounterId: encounter.id }, { merge: true }); - logAction(`Combat started: "${encounter.name}" — ${sortedParticipants[0].name}'s turn (Round 1)`, { encounterName: encounter.name }); + logAction(`Combat started: "${encounter.name}" — ${sortedParticipants[0].name}'s turn (Round 1)`, { encounterName: encounter.name }, { + encounterPath, + updates: { + isStarted: encounter.isStarted ?? false, + isPaused: encounter.isPaused ?? false, + round: encounter.round ?? 0, + currentTurnParticipantId: encounter.currentTurnParticipantId ?? null, + turnOrderIds: [...(encounter.turnOrderIds || [])], + }, + }); console.log("Encounter started and set as active display."); } catch (err) { console.error("Error starting encounter:", err); @@ -1642,7 +1689,13 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { isPaused: newPausedState, turnOrderIds: newTurnOrderIds }); - logAction(`Combat ${newPausedState ? 'paused' : 'resumed'}: "${encounter.name}"`, { encounterName: encounter.name }); + logAction(`Combat ${newPausedState ? 'paused' : 'resumed'}: "${encounter.name}"`, { encounterName: encounter.name }, { + encounterPath, + updates: { + isPaused: encounter.isPaused, + turnOrderIds: [...(encounter.turnOrderIds || [])], + }, + }); } catch (err) { console.error("Error toggling pause state:", err); } @@ -1689,7 +1742,13 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { currentTurnParticipantId: activePsInOrder[nextIndex].id, round: nextRound }); - logAction(`${activePsInOrder[nextIndex].name}'s turn (Round ${nextRound})`, { encounterName: encounter.name }); + logAction(`${activePsInOrder[nextIndex].name}'s turn (Round ${nextRound})`, { encounterName: encounter.name }, { + encounterPath, + updates: { + currentTurnParticipantId: encounter.currentTurnParticipantId, + round: encounter.round, + }, + }); } catch (err) { console.error("Error advancing turn:", err); } @@ -1712,7 +1771,16 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { activeEncounterId: null }, { merge: true }); - logAction(`Combat ended: "${encounter.name}"`, { encounterName: encounter.name }); + logAction(`Combat ended: "${encounter.name}"`, { encounterName: encounter.name }, { + encounterPath, + updates: { + isStarted: encounter.isStarted, + isPaused: encounter.isPaused, + round: encounter.round, + currentTurnParticipantId: encounter.currentTurnParticipantId, + turnOrderIds: [...(encounter.turnOrderIds || [])], + }, + }); console.log("Encounter ended and deactivated from Player Display."); } catch (err) { console.error("Error ending encounter:", err); @@ -2607,6 +2675,7 @@ function DisplayView() { function LogsView() { const { data: logs, isLoading } = useFirestoreCollection(getPath.logs(), LOG_QUERY); const [showClearConfirm, setShowClearConfirm] = useState(false); + const [undoingId, setUndoingId] = useState(null); const handleClearLogs = async () => { if (!db) return; @@ -2623,6 +2692,19 @@ function LogsView() { setShowClearConfirm(false); }; + const handleUndo = async (entry) => { + if (!db || !entry.undo) return; + setUndoingId(entry.id); + try { + await updateDoc(doc(db, entry.undo.encounterPath), entry.undo.updates); + await updateDoc(doc(db, getPath.logs(), entry.id), { undone: true }); + } catch (err) { + console.error('Error undoing action:', err); + alert('Failed to roll back. The encounter may have changed or no longer exists.'); + } + setUndoingId(null); + }; + return (
@@ -2656,14 +2738,35 @@ function LogsView() {

{logs.length} entries — newest first

{logs.map(entry => ( -
+
{new Date(entry.timestamp).toLocaleString()} {entry.encounterName && ( [{entry.encounterName}] )} - {entry.message} + + {entry.message} + + {entry.undone ? ( + rolled back + ) : entry.undo ? ( + + ) : null}
))}