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