# 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