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>
This commit is contained in:
Lukas
2026-03-26 23:30:33 +01:00
parent 9d81c8ad27
commit 17cc6ed72c
22 changed files with 1127 additions and 61 deletions

View File

@@ -0,0 +1,83 @@
# 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