Add per-entry undo buttons to combat log, bump to v0.2.5

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 16:45:07 -04:00
parent b11fbe4715
commit 35990f588e
2 changed files with 122 additions and 18 deletions
+4 -3
View File
@@ -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) ![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. * 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 **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. * 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. * **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. * **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. * **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. * 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`):** 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. * 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). * 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`. * Accessible via the "Logs" link in the DM header or directly at `/logs`.
+118 -15
View File
@@ -45,7 +45,7 @@ if (typeof document !== 'undefined') {
// CONSTANTS // CONSTANTS
// ============================================================================ // ============================================================================
const APP_VERSION = 'v0.2.4'; const APP_VERSION = 'v0.2.5';
const DEFAULT_MAX_HP = 10; const DEFAULT_MAX_HP = 10;
const DEFAULT_INIT_MOD = 0; const DEFAULT_INIT_MOD = 0;
const MONSTER_DEFAULT_INIT_MOD = 2; const MONSTER_DEFAULT_INIT_MOD = 2;
@@ -157,10 +157,12 @@ const sortParticipantsByInitiative = (participants, originalOrder) => {
const LOG_QUERY = [orderBy('timestamp', 'desc'), limit(500)]; const LOG_QUERY = [orderBy('timestamp', 'desc'), limit(500)];
const logAction = async (message, context = {}) => { const logAction = async (message, context = {}, undoData = null) => {
if (!db) return; if (!db) return;
try { 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) { } catch (err) {
console.error('Error writing log:', err); console.error('Error writing log:', err);
} }
@@ -898,7 +900,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
await updateDoc(doc(db, encounterPath), { await updateDoc(doc(db, encounterPath), {
participants: [...participants, newParticipant] 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({ setLastRollDetails({
name: nameToAdd, name: nameToAdd,
@@ -992,13 +997,23 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
if (!db || !itemToDelete) return; if (!db || !itemToDelete) return;
const updatedParticipants = participants.filter(p => p.id !== itemToDelete.id); const updatedParticipants = participants.filter(p => p.id !== itemToDelete.id);
const deleteUndoData = {
encounterPath,
updates: {
participants: [...participants],
...(encounter.isStarted ? {
currentTurnParticipantId: encounter.currentTurnParticipantId,
turnOrderIds: [...(encounter.turnOrderIds || [])],
} : {}),
},
};
try { try {
await updateDoc(doc(db, encounterPath), { await updateDoc(doc(db, encounterPath), {
participants: updatedParticipants, participants: updatedParticipants,
...computeTurnOrderAfterRemoval(encounter, itemToDelete.id, 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) { } catch (err) {
console.error("Error deleting participant:", err); console.error("Error deleting participant:", err);
alert("Failed to delete participant. Please try again."); alert("Failed to delete participant. Please try again.");
@@ -1025,7 +1040,16 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
try { try {
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants, ...turnUpdates }); 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) { } catch (err) {
console.error("Error toggling active state:", err); console.error("Error toggling active state:", err);
} }
@@ -1087,6 +1111,17 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
? computeTurnOrderAfterAddition(encounter, participantId) ? computeTurnOrderAfterAddition(encounter, participantId)
: {}; : {};
const hpUndoData = {
encounterPath,
updates: {
participants: [...participants],
...((isDead && !wasDead) || wasResurrected ? {
currentTurnParticipantId: encounter.currentTurnParticipantId,
turnOrderIds: [...(encounter.turnOrderIds || [])],
} : {}),
},
};
try { try {
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants, ...turnUpdates }); await updateDoc(doc(db, encounterPath), { participants: updatedParticipants, ...turnUpdates });
setHpChangeValues(prev => ({ ...prev, [participantId]: '' })); setHpChangeValues(prev => ({ ...prev, [participantId]: '' }));
@@ -1094,9 +1129,9 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const deathSuffix = (isDead && !wasDead) ? (participant.type === 'character' ? ' — Unconscious' : ' — Defeated') : ''; const deathSuffix = (isDead && !wasDead) ? (participant.type === 'character' ? ' — Unconscious' : ' — Defeated') : '';
const resurSuffix = wasResurrected ? ' — Revived' : ''; const resurSuffix = wasResurrected ? ' — Revived' : '';
if (changeType === 'damage') { 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 { } 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) { } catch (err) {
console.error("Error applying HP change:", err); console.error("Error applying HP change:", err);
@@ -1164,7 +1199,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
await updateDoc(doc(db, encounterPath), { participants: updatedParticipants }); await updateDoc(doc(db, encounterPath), { participants: updatedParticipants });
const cond = CONDITIONS.find(c => c.id === conditionId); const cond = CONDITIONS.find(c => c.id === conditionId);
const condLabel = cond ? `${cond.label} ${cond.emoji}` : 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) { } catch (err) {
console.error("Error updating conditions:", err); console.error("Error updating conditions:", err);
} }
@@ -1617,7 +1655,16 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
activeEncounterId: encounter.id activeEncounterId: encounter.id
}, { merge: true }); }, { 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."); console.log("Encounter started and set as active display.");
} catch (err) { } catch (err) {
console.error("Error starting encounter:", err); console.error("Error starting encounter:", err);
@@ -1642,7 +1689,13 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
isPaused: newPausedState, isPaused: newPausedState,
turnOrderIds: newTurnOrderIds 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) { } catch (err) {
console.error("Error toggling pause state:", err); console.error("Error toggling pause state:", err);
} }
@@ -1689,7 +1742,13 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
currentTurnParticipantId: activePsInOrder[nextIndex].id, currentTurnParticipantId: activePsInOrder[nextIndex].id,
round: nextRound 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) { } catch (err) {
console.error("Error advancing turn:", err); console.error("Error advancing turn:", err);
} }
@@ -1712,7 +1771,16 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
activeEncounterId: null activeEncounterId: null
}, { merge: true }); }, { 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."); console.log("Encounter ended and deactivated from Player Display.");
} catch (err) { } catch (err) {
console.error("Error ending encounter:", err); console.error("Error ending encounter:", err);
@@ -2607,6 +2675,7 @@ function DisplayView() {
function LogsView() { function LogsView() {
const { data: logs, isLoading } = useFirestoreCollection(getPath.logs(), LOG_QUERY); const { data: logs, isLoading } = useFirestoreCollection(getPath.logs(), LOG_QUERY);
const [showClearConfirm, setShowClearConfirm] = useState(false); const [showClearConfirm, setShowClearConfirm] = useState(false);
const [undoingId, setUndoingId] = useState(null);
const handleClearLogs = async () => { const handleClearLogs = async () => {
if (!db) return; if (!db) return;
@@ -2623,6 +2692,19 @@ function LogsView() {
setShowClearConfirm(false); 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 ( return (
<div className="min-h-screen bg-stone-950 text-stone-100 font-garamond"> <div className="min-h-screen bg-stone-950 text-stone-100 font-garamond">
<header className="bg-stone-950 p-4 shadow-lg border-b border-amber-900"> <header className="bg-stone-950 p-4 shadow-lg border-b border-amber-900">
@@ -2656,14 +2738,35 @@ function LogsView() {
<p className="text-stone-500 text-sm mb-4">{logs.length} entries newest first</p> <p className="text-stone-500 text-sm mb-4">{logs.length} entries newest first</p>
<div className="space-y-1 max-w-5xl"> <div className="space-y-1 max-w-5xl">
{logs.map(entry => ( {logs.map(entry => (
<div key={entry.id} className="flex gap-3 p-3 bg-stone-900 rounded-md border border-stone-800 text-sm"> <div
key={entry.id}
className={`flex gap-3 items-center p-3 rounded-md border text-sm transition-opacity ${
entry.undone
? 'bg-stone-900/50 border-stone-800/50 opacity-50'
: 'bg-stone-900 border-stone-800'
}`}
>
<span className="text-stone-500 whitespace-nowrap font-mono shrink-0"> <span className="text-stone-500 whitespace-nowrap font-mono shrink-0">
{new Date(entry.timestamp).toLocaleString()} {new Date(entry.timestamp).toLocaleString()}
</span> </span>
{entry.encounterName && ( {entry.encounterName && (
<span className="text-amber-600 whitespace-nowrap shrink-0">[{entry.encounterName}]</span> <span className="text-amber-600 whitespace-nowrap shrink-0">[{entry.encounterName}]</span>
)} )}
<span className="text-stone-100">{entry.message}</span> <span className={`flex-1 ${entry.undone ? 'line-through text-stone-500' : 'text-stone-100'}`}>
{entry.message}
</span>
{entry.undone ? (
<span className="shrink-0 text-xs text-stone-600 italic">rolled back</span>
) : entry.undo ? (
<button
onClick={() => handleUndo(entry)}
disabled={undoingId === entry.id}
className="shrink-0 px-2 py-0.5 text-xs rounded bg-stone-700 hover:bg-amber-800 text-stone-300 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Roll back this action"
>
{undoingId === entry.id ? '…' : '↩ Undo'}
</button>
) : null}
</div> </div>
))} ))}
</div> </div>