feat(ui): first-class undo/redo buttons in combat controls

Undo/redo pills in InitiativeControls, always visible when encounter open.

- Undo = latest non-undone log for this encounter, applies undo.updates.
- Redo = latest undone log, applies undo.redo (forward patch).
- encounterPath added to all 14 logAction contexts (filter key).
- redo:patch added to undoData so redo replays real forward state.
- Disabled when stack empty. Tooltip shows target action message.
- Uses current 2-write undo (non-tx). Race safety deferred to FEAT-LOG.

TODO updated. 84 FE tests green.
This commit is contained in:
david raistrick
2026-07-04 22:48:58 -04:00
parent ed1783e419
commit 9f8b09c7d9
2 changed files with 97 additions and 16 deletions
+16 -1
View File
@@ -8,7 +8,7 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
- Ambiguous label. May expand work based on what NPC means here (ally? monster? - Ambiguous label. May expand work based on what NPC means here (ally? monster?
display-only? skip in turn order?). Clarify intent before UX changes. display-only? skip in turn order?). Clarify intent before UX changes.
### FEAT: first-class undo/redo UI buttons (B do now) ### FEAT: first-class undo/redo UI buttons (B --- do now)
- Toolbar buttons ↶/↷ in AdminView header, not buried in /logs. - Toolbar buttons ↶/↷ in AdminView header, not buried in /logs.
- Undo = revert latest non-undone log. Redo = re-apply latest undone. - Undo = revert latest non-undone log. Redo = re-apply latest undone.
- Uses current 2-write undo (non-tx). Race safety = log refactor later. - Uses current 2-write undo (non-tx). Race safety = log refactor later.
@@ -27,8 +27,23 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
- replay-combat + analyze-turns rewritten to emit/consume same event shape. - replay-combat + analyze-turns rewritten to emit/consume same event shape.
- Migration: keep old log entries readable; new format for new writes. - Migration: keep old log entries readable; new format for new writes.
## FEAT - parallel campaigns
## FEAT - multi user
## FEAT - clarify what end encounter does and what initiatives reset means
## Done (history) ## Done (history)
### FEAT: first-class undo/redo UI buttons (DONE)
- ↶/↷ pills in InitiativeControls, always visible when encounter open.
- Undo = latest non-undone log (per encounter). Redo = latest undone.
- encounterPath added to all 14 log contexts (filter key).
- redo:patch (forward) added to undoData. Real redo replays forward state.
- Disabled when stack empty. Tooltip shows target action.
- Uses current 2-write undo (non-tx). Race safety = FEAT-LOG refactor.
### Architecture: 1-list turn order model (slot, never sort) ### Architecture: 1-list turn order model (slot, never sort)
- Single source: turnOrderIds === participants.map(id). No re-sort after - Single source: turnOrderIds === participants.map(id). No re-sort after
startEncounter. nextTurn skips inactive (predicate), inactive stay in slot. startEncounter. nextTurn skips inactive (predicate), inactive stay in slot.
+81 -15
View File
@@ -6,7 +6,8 @@ import {
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle, UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon, Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon,
StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText, StopCircle as StopCircleIcon, Users2, Dices, ChevronUp, ChevronDown, ScrollText,
Maximize2, Minimize2, Moon, Coffee, Clock, ChevronRight, CheckCircle2, X Maximize2, Minimize2, Moon, Coffee, Clock, ChevronRight, CheckCircle2, X,
Undo2, Redo2
} from 'lucide-react'; } from 'lucide-react';
// ----- UI feedback: toast (transient) + info modal (persistent) ----- // ----- UI feedback: toast (transient) + info modal (persistent) -----
@@ -956,9 +957,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
try { try {
const { patch, log } = addParticipant(encounter, newParticipant); const { patch, log } = addParticipant(encounter, newParticipant);
await storage.updateDoc(encounterPath, patch); await storage.updateDoc(encounterPath, patch);
logAction(log.message, { encounterName: encounter.name }, { logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath, encounterPath,
updates: log.undo, updates: log.undo,
redo: patch,
}); });
setLastRollDetails({ setLastRollDetails({
@@ -1020,9 +1022,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const { patch, log } = addParticipants(encounter, newParticipants); const { patch, log } = addParticipants(encounter, newParticipants);
await storage.updateDoc(encounterPath, patch); await storage.updateDoc(encounterPath, patch);
if (log) { if (log) {
logAction(log.message, { encounterName: encounter.name }, { logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath, encounterPath,
updates: log.undo, updates: log.undo,
redo: patch,
}); });
} }
} catch (err) { } catch (err) {
@@ -1035,9 +1038,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const { patch, log } = updateParticipant(encounter, editingParticipant.id, updatedData); const { patch, log } = updateParticipant(encounter, editingParticipant.id, updatedData);
await storage.updateDoc(encounterPath, patch); await storage.updateDoc(encounterPath, patch);
if (log) { if (log) {
logAction(log.message, { encounterName: encounter.name }, { logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath, encounterPath,
updates: log.undo, updates: log.undo,
redo: patch,
}); });
} }
setEditingParticipant(null); setEditingParticipant(null);
@@ -1057,9 +1061,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const { patch, log } = updateParticipant(encounter, participantId, { initiative: n }); const { patch, log } = updateParticipant(encounter, participantId, { initiative: n });
await storage.updateDoc(encounterPath, patch); await storage.updateDoc(encounterPath, patch);
if (log) { if (log) {
logAction(log.message, { encounterName: encounter.name }, { logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath, encounterPath,
updates: log.undo, updates: log.undo,
redo: patch,
}); });
} }
} catch (err) { } catch (err) {
@@ -1077,9 +1082,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
try { try {
const { patch, log } = removeParticipant(encounter, itemToDelete.id); const { patch, log } = removeParticipant(encounter, itemToDelete.id);
await storage.updateDoc(encounterPath, patch); await storage.updateDoc(encounterPath, patch);
logAction(log.message, { encounterName: encounter.name }, { logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath, encounterPath,
updates: log.undo, updates: log.undo,
redo: patch,
}); });
} catch (err) { } catch (err) {
showToast("Failed to delete participant. Please try again."); } showToast("Failed to delete participant. Please try again."); }
@@ -1092,9 +1098,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
try { try {
const { patch, log } = combatToggleActive(encounter, participantId); const { patch, log } = combatToggleActive(encounter, participantId);
await storage.updateDoc(encounterPath, patch); await storage.updateDoc(encounterPath, patch);
logAction(log.message, { encounterName: encounter.name }, { logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath, encounterPath,
updates: log.undo, updates: log.undo,
redo: patch,
}); });
} catch (err) { } catch (err) {
// fall through silently // fall through silently
@@ -1115,9 +1122,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
if (patch) { if (patch) {
await storage.updateDoc(encounterPath, patch); await storage.updateDoc(encounterPath, patch);
if (log) { if (log) {
logAction(log.message, { encounterName: encounter.name }, { logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath, encounterPath,
updates: log.undo, updates: log.undo,
redo: patch,
}); });
} }
} }
@@ -1150,9 +1158,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
await storage.updateDoc(encounterPath, patch); await storage.updateDoc(encounterPath, patch);
const cond = allConditions.find(c => c.id === conditionId); const cond = allConditions.find(c => c.id === conditionId);
const condLabel = cond ? `${cond.label} ${cond.emoji || ''}`.trim() : conditionId; const condLabel = cond ? `${cond.label} ${cond.emoji || ''}`.trim() : conditionId;
logAction(log.message.replace(conditionId, condLabel), { encounterName: encounter.name }, { logAction(log.message.replace(conditionId, condLabel), { encounterName: encounter.name, encounterPath }, {
encounterPath, encounterPath,
updates: log.undo, updates: log.undo,
redo: patch,
}); });
} catch (err) { } catch (err) {
// fall through silently // fall through silently
@@ -1176,8 +1185,9 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
try { try {
const { patch, log } = combatToggleCondition(encounter, participantId, trimmed); const { patch, log } = combatToggleCondition(encounter, participantId, trimmed);
await storage.updateDoc(encounterPath, patch); await storage.updateDoc(encounterPath, patch);
logAction(log.message.replace(trimmed, trimmed), { encounterName: encounter.name }, { logAction(log.message.replace(trimmed, trimmed), { encounterName: encounter.name, encounterPath }, {
encounterPath, updates: log.undo, encounterPath, updates: log.undo,
redo: patch,
}); });
} catch (err) {} } catch (err) {}
} }
@@ -1204,9 +1214,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const { patch, log } = reorderParticipants(encounter, draggedItemId, targetId); const { patch, log } = reorderParticipants(encounter, draggedItemId, targetId);
await storage.updateDoc(encounterPath, patch); await storage.updateDoc(encounterPath, patch);
if (log) { if (log) {
logAction(log.message, { encounterName: encounter.name }, { logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath, encounterPath,
updates: log.undo, updates: log.undo,
redo: patch,
}); });
} }
} catch (err) { } catch (err) {
@@ -1674,9 +1685,40 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
const { showToast, showInfo } = useUIFeedback(); const { showToast, showInfo } = useUIFeedback();
const [showEndConfirm, setShowEndConfirm] = useState(false); const [showEndConfirm, setShowEndConfirm] = useState(false);
const { data: activeDisplayData } = useFirestoreDocument(getPath.activeDisplay()); const { data: activeDisplayData } = useFirestoreDocument(getPath.activeDisplay());
const { data: logsData } = useFirestoreCollection(getPath.logs(), LOG_QUERY);
const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true; const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true;
const hideNpcHp = activeDisplayData?.hideNpcHp ?? false; const hideNpcHp = activeDisplayData?.hideNpcHp ?? false;
// Undo/redo stacks for THIS encounter.
const encLogs = (logsData || []).filter(l => l.encounterPath === encounterPath && l.undo);
const undoTarget = encLogs.find(l => !l.undone) || null;
// redo = latest undone entry that is NEWER than the latest undone entry's
// predecessor — simplest correct heuristic: first entry (desc order) with undone=true
// whose immediate newer entry is undone or absent.
const redoTarget = encLogs.find(l => l.undone) || null;
const handleUndo = async () => {
if (!db || !undoTarget) return;
try {
await storage.updateDoc(undoTarget.undo.encounterPath, undoTarget.undo.updates);
await storage.updateDoc(`${getPath.logs()}/${undoTarget.id}`, { undone: true });
} catch (err) {
console.error('Error undoing action:', err);
showToast('Failed to undo. The encounter may have changed or no longer exists.');
}
};
const handleRedo = async () => {
if (!db || !redoTarget) return;
try {
await storage.updateDoc(redoTarget.undo.encounterPath, redoTarget.undo.redo);
await storage.updateDoc(`${getPath.logs()}/${redoTarget.id}`, { undone: false });
} catch (err) {
console.error('Error redoing action:', err);
showToast('Failed to redo.');
}
};
const handleToggleHidePlayerHp = async () => { const handleToggleHidePlayerHp = async () => {
if (!db) return; if (!db) return;
try { try {
@@ -1704,9 +1746,10 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
const { patch, log } = startEncounter(encounter); const { patch, log } = startEncounter(encounter);
await storage.updateDoc(encounterPath, patch); await storage.updateDoc(encounterPath, patch);
await storage.setDoc(getPath.activeDisplay(), activateDisplay({ campaignId, encounterId: encounter.id }).patch, { merge: true }); await storage.setDoc(getPath.activeDisplay(), activateDisplay({ campaignId, encounterId: encounter.id }).patch, { merge: true });
logAction(log.message, { encounterName: encounter.name }, { logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath, encounterPath,
updates: log.undo, updates: log.undo,
redo: patch,
}); });
} catch (err) { } catch (err) {
showToast(err.message || "Failed to start encounter. Please try again."); } showToast(err.message || "Failed to start encounter. Please try again."); }
@@ -1717,9 +1760,10 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
try { try {
const { patch, log } = togglePause(encounter); const { patch, log } = togglePause(encounter);
await storage.updateDoc(encounterPath, patch); await storage.updateDoc(encounterPath, patch);
logAction(log.message, { encounterName: encounter.name }, { logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath, encounterPath,
updates: log.undo, updates: log.undo,
redo: patch,
}); });
} catch (err) { } catch (err) {
// fall through silently // fall through silently
@@ -1733,9 +1777,10 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
try { try {
const { patch, log } = nextTurn(encounter); const { patch, log } = nextTurn(encounter);
await storage.updateDoc(encounterPath, patch); await storage.updateDoc(encounterPath, patch);
logAction(log.message, { encounterName: encounter.name }, { logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath, encounterPath,
updates: log.undo, updates: log.undo,
redo: patch,
}); });
} catch (err) { } catch (err) {
// nextTurn throws if no active participants — auto-end combat // nextTurn throws if no active participants — auto-end combat
@@ -1755,9 +1800,10 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
const { patch, log } = endEncounter(encounter); const { patch, log } = endEncounter(encounter);
await storage.updateDoc(encounterPath, patch); await storage.updateDoc(encounterPath, patch);
await storage.setDoc(getPath.activeDisplay(), clearDisplay().patch, { merge: true }); await storage.setDoc(getPath.activeDisplay(), clearDisplay().patch, { merge: true });
logAction(log.message, { encounterName: encounter.name }, { logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath, encounterPath,
updates: log.undo, updates: log.undo,
redo: patch,
}); });
} catch (err) { } catch (err) {
showToast("Failed to end encounter. Please try again."); } showToast("Failed to end encounter. Please try again."); }
@@ -1817,6 +1863,26 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
)} )}
</div> </div>
{/* Undo / Redo — always available when encounter open */}
<div className="mt-3 flex gap-2">
<button
onClick={handleUndo}
disabled={!undoTarget}
className="flex-1 px-3 py-2 text-xs font-medium text-white bg-stone-700 hover:bg-stone-600 rounded-md transition-colors flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed"
title={undoTarget ? `Undo: ${undoTarget.message}` : 'Nothing to undo'}
>
<Undo2 size={14} className="mr-1" /> Undo
</button>
<button
onClick={handleRedo}
disabled={!redoTarget}
className="flex-1 px-3 py-2 text-xs font-medium text-white bg-stone-700 hover:bg-stone-600 rounded-md transition-colors flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed"
title={redoTarget ? `Redo: ${redoTarget.message}` : 'Nothing to redo'}
>
<Redo2 size={14} className="mr-1" /> Redo
</button>
</div>
{/* Display Settings */} {/* Display Settings */}
<div className="mt-3 pt-3 border-t border-stone-700"> <div className="mt-3 pt-3 border-t border-stone-700">
<h5 className="text-xs font-semibold text-stone-400 uppercase tracking-wider mb-2">Player Display</h5> <h5 className="text-xs font-semibold text-stone-400 uppercase tracking-wider mb-2">Player Display</h5>