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