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)
@@ -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`.
+118 -15
View File
@@ -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 (
<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">
@@ -2656,14 +2738,35 @@ function LogsView() {
<p className="text-stone-500 text-sm mb-4">{logs.length} entries newest first</p>
<div className="space-y-1 max-w-5xl">
{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">
{new Date(entry.timestamp).toLocaleString()}
</span>
{entry.encounterName && (
<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>