diff --git a/TODO.md b/TODO.md index e1b82b5..c6b0a89 100644 --- a/TODO.md +++ b/TODO.md @@ -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. diff --git a/src/App.js b/src/App.js index 2b9a170..ef1e225 100644 --- a/src/App.js +++ b/src/App.js @@ -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 }) { )} + {/* Undo / Redo — always available when encounter open */} +
+ + +
+ {/* Display Settings */}
Player Display