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?
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.
- Undo = revert latest non-undone log. Redo = re-apply latest undone.
- 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.
- 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)
### 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)
- Single source: turnOrderIds === participants.map(id). No re-sort after
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,
Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon,
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';
// ----- UI feedback: toast (transient) + info modal (persistent) -----
@@ -956,9 +957,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
try {
const { patch, log } = addParticipant(encounter, newParticipant);
await storage.updateDoc(encounterPath, patch);
logAction(log.message, { encounterName: encounter.name }, {
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath,
updates: log.undo,
redo: patch,
});
setLastRollDetails({
@@ -1020,9 +1022,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const { patch, log } = addParticipants(encounter, newParticipants);
await storage.updateDoc(encounterPath, patch);
if (log) {
logAction(log.message, { encounterName: encounter.name }, {
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath,
updates: log.undo,
redo: patch,
});
}
} catch (err) {
@@ -1035,9 +1038,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const { patch, log } = updateParticipant(encounter, editingParticipant.id, updatedData);
await storage.updateDoc(encounterPath, patch);
if (log) {
logAction(log.message, { encounterName: encounter.name }, {
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath,
updates: log.undo,
redo: patch,
});
}
setEditingParticipant(null);
@@ -1057,9 +1061,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const { patch, log } = updateParticipant(encounter, participantId, { initiative: n });
await storage.updateDoc(encounterPath, patch);
if (log) {
logAction(log.message, { encounterName: encounter.name }, {
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath,
updates: log.undo,
redo: patch,
});
}
} catch (err) {
@@ -1077,9 +1082,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
try {
const { patch, log } = removeParticipant(encounter, itemToDelete.id);
await storage.updateDoc(encounterPath, patch);
logAction(log.message, { encounterName: encounter.name }, {
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath,
updates: log.undo,
redo: patch,
});
} catch (err) {
showToast("Failed to delete participant. Please try again."); }
@@ -1092,9 +1098,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
try {
const { patch, log } = combatToggleActive(encounter, participantId);
await storage.updateDoc(encounterPath, patch);
logAction(log.message, { encounterName: encounter.name }, {
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath,
updates: log.undo,
redo: patch,
});
} catch (err) {
// fall through silently
@@ -1115,9 +1122,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
if (patch) {
await storage.updateDoc(encounterPath, patch);
if (log) {
logAction(log.message, { encounterName: encounter.name }, {
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath,
updates: log.undo,
redo: patch,
});
}
}
@@ -1150,9 +1158,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
await storage.updateDoc(encounterPath, patch);
const cond = allConditions.find(c => c.id === 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,
updates: log.undo,
redo: patch,
});
} catch (err) {
// fall through silently
@@ -1176,8 +1185,9 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
try {
const { patch, log } = combatToggleCondition(encounter, participantId, trimmed);
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,
redo: patch,
});
} catch (err) {}
}
@@ -1204,9 +1214,10 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const { patch, log } = reorderParticipants(encounter, draggedItemId, targetId);
await storage.updateDoc(encounterPath, patch);
if (log) {
logAction(log.message, { encounterName: encounter.name }, {
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath,
updates: log.undo,
redo: patch,
});
}
} catch (err) {
@@ -1674,9 +1685,40 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
const { showToast, showInfo } = useUIFeedback();
const [showEndConfirm, setShowEndConfirm] = useState(false);
const { data: activeDisplayData } = useFirestoreDocument(getPath.activeDisplay());
const { data: logsData } = useFirestoreCollection(getPath.logs(), LOG_QUERY);
const hidePlayerHp = activeDisplayData?.hidePlayerHp ?? true;
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 () => {
if (!db) return;
try {
@@ -1704,9 +1746,10 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
const { patch, log } = startEncounter(encounter);
await storage.updateDoc(encounterPath, patch);
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,
updates: log.undo,
redo: patch,
});
} catch (err) {
showToast(err.message || "Failed to start encounter. Please try again."); }
@@ -1717,9 +1760,10 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
try {
const { patch, log } = togglePause(encounter);
await storage.updateDoc(encounterPath, patch);
logAction(log.message, { encounterName: encounter.name }, {
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath,
updates: log.undo,
redo: patch,
});
} catch (err) {
// fall through silently
@@ -1733,9 +1777,10 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
try {
const { patch, log } = nextTurn(encounter);
await storage.updateDoc(encounterPath, patch);
logAction(log.message, { encounterName: encounter.name }, {
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath,
updates: log.undo,
redo: patch,
});
} catch (err) {
// nextTurn throws if no active participants — auto-end combat
@@ -1755,9 +1800,10 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
const { patch, log } = endEncounter(encounter);
await storage.updateDoc(encounterPath, patch);
await storage.setDoc(getPath.activeDisplay(), clearDisplay().patch, { merge: true });
logAction(log.message, { encounterName: encounter.name }, {
logAction(log.message, { encounterName: encounter.name, encounterPath }, {
encounterPath,
updates: log.undo,
redo: patch,
});
} catch (err) {
showToast("Failed to end encounter. Please try again."); }
@@ -1817,6 +1863,26 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
)}
</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 */}
<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>