Files
initiative/specs/037-undo-redo/data-model.md
Lukas 17cc6ed72c Add undo/redo for all encounter actions
Memento-based undo/redo with full encounter snapshots. Undo stack
capped at 50 entries, persisted to localStorage. Triggered via
buttons in the top bar (inboard of turn navigation) and keyboard
shortcuts (Ctrl+Z / Ctrl+Shift+Z, Cmd on Mac, case-insensitive key
matching). Clear encounter resets both stacks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 23:30:33 +01:00

3.4 KiB

Data Model: Undo/Redo

Feature: 037-undo-redo Date: 2026-03-26

Entities

UndoRedoState

Represents the complete undo/redo history for an encounter session.

Field Type Description
undoStack Encounter[] Ordered list of encounter snapshots, most recent last. Max 50 entries.
redoStack Encounter[] Ordered list of encounter snapshots accumulated by undo operations. Cleared on any new action.

Encounter (existing, unchanged)

Each stack entry is a full Encounter snapshot as defined in packages/domain/src/types.ts. No schema changes to the encounter type.

Field Type Description
combatants Combatant[] Ordered list of combatants
activeIndex number Index of the active combatant
roundNumber number Current round number

State Transitions

pushUndo(state, snapshot) -> UndoRedoState

Push a snapshot onto the undo stack. If the stack exceeds 50 entries, drop the oldest (index 0). Clear the redo stack.

Precondition: snapshot is a valid Encounter Postcondition: undoStack length <= 50, redoStack is empty

undo(state, currentEncounter) -> { state: UndoRedoState, encounter: Encounter } | DomainError

Pop the most recent snapshot from the undo stack. Push the current encounter onto the redo stack. Return the popped snapshot as the new current encounter.

Precondition: undoStack is non-empty Postcondition: undoStack length decremented by 1, redoStack length incremented by 1 Error: "nothing-to-undo" if undoStack is empty

redo(state, currentEncounter) -> { state: UndoRedoState, encounter: Encounter } | DomainError

Pop the most recent snapshot from the redo stack. Push the current encounter onto the undo stack. Return the popped snapshot as the new current encounter.

Precondition: redoStack is non-empty Postcondition: redoStack length decremented by 1, undoStack length incremented by 1 Error: "nothing-to-redo" if redoStack is empty

clearHistory() -> UndoRedoState

Reset both stacks to empty. Used when the encounter is cleared.

Postcondition: undoStack and redoStack are both empty

Persistence

Storage Keys

Key Content Format
initiative:encounter:undo Undo stack JSON array of serialized Encounter objects
initiative:encounter:redo Redo stack JSON array of serialized Encounter objects

Serialization

Stacks are serialized as JSON arrays of Encounter objects, identical to the existing encounter serialization format. On load, each entry is validated using the same rehydration logic as loadEncounter().

Failure Modes

  • localStorage quota exceeded: Stacks continue in-memory; persistence is best-effort. Silently swallow write errors (matching existing encounter persistence pattern).
  • Corrupt data on load: Start with empty stacks. Log no error (matching existing pattern).
  • Schema mismatch after upgrade: Invalid entries are dropped during rehydration; stacks may be shorter than persisted but never contain invalid data.

Invariants

  1. undoStack.length <= 50 at all times
  2. redoStack is empty after any non-undo/redo action
  3. undoStack.length + redoStack.length represents the total history depth (not capped as a whole — redo can grow up to 50 if all actions are undone)
  4. Each stack entry is a valid, complete Encounter snapshot
  5. Undo followed by redo returns the encounter to the exact same state