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:
@@ -1,4 +1,4 @@
|
||||
# TTRPG Initiative Tracker (v0.2.4)
|
||||
# TTRPG Initiative Tracker (v0.2.5)
|
||||
|
||||

|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user