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:
@@ -8,6 +8,7 @@ A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e
|
|||||||
- **Encounter state** — HP, AC, conditions, concentration tracking with visual status indicators
|
- **Encounter state** — HP, AC, conditions, concentration tracking with visual status indicators
|
||||||
- **Bestiary integration** — import bestiary JSON sources, search creatures, and view full stat blocks
|
- **Bestiary integration** — import bestiary JSON sources, search creatures, and view full stat blocks
|
||||||
- **Player characters** — create reusable player character templates with name, AC, HP, color, and icon; search and add them to encounters with pre-filled stats; manage (edit/delete) from a dedicated panel
|
- **Player characters** — create reusable player character templates with name, AC, HP, color, and icon; search and add them to encounters with pre-filled stats; manage (edit/delete) from a dedicated panel
|
||||||
|
- **Undo/redo** — reverse any encounter action with Undo/Redo buttons or keyboard shortcuts (Ctrl+Z / Ctrl+Shift+Z, Cmd on Mac); history persists across page reloads
|
||||||
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB; player characters stored independently
|
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB; player characters stored independently
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ function mockContext(overrides: Partial<Encounter> = {}) {
|
|||||||
addFromBestiary: vi.fn(),
|
addFromBestiary: vi.fn(),
|
||||||
addFromPlayerCharacter: vi.fn(),
|
addFromPlayerCharacter: vi.fn(),
|
||||||
makeStore: vi.fn(),
|
makeStore: vi.fn(),
|
||||||
|
undo: vi.fn(),
|
||||||
|
redo: vi.fn(),
|
||||||
|
canUndo: false,
|
||||||
|
canRedo: false,
|
||||||
events: [],
|
events: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { StepBack, StepForward, Trash2 } from "lucide-react";
|
import { Redo2, StepBack, StepForward, Trash2, Undo2 } from "lucide-react";
|
||||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
import { ConfirmButton } from "./ui/confirm-button.js";
|
import { ConfirmButton } from "./ui/confirm-button.js";
|
||||||
|
|
||||||
export function TurnNavigation() {
|
export function TurnNavigation() {
|
||||||
const { encounter, advanceTurn, retreatTurn, clearEncounter } =
|
const {
|
||||||
useEncounterContext();
|
encounter,
|
||||||
|
advanceTurn,
|
||||||
|
retreatTurn,
|
||||||
|
clearEncounter,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
} = useEncounterContext();
|
||||||
|
|
||||||
const hasCombatants = encounter.combatants.length > 0;
|
const hasCombatants = encounter.combatants.length > 0;
|
||||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||||
@@ -24,6 +32,29 @@ export function TurnNavigation() {
|
|||||||
<StepBack className="h-5 w-5" />
|
<StepBack className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={undo}
|
||||||
|
disabled={!canUndo}
|
||||||
|
title="Undo"
|
||||||
|
aria-label="Undo"
|
||||||
|
>
|
||||||
|
<Undo2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={redo}
|
||||||
|
disabled={!canRedo}
|
||||||
|
title="Redo"
|
||||||
|
aria-label="Redo"
|
||||||
|
>
|
||||||
|
<Redo2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-2 text-sm">
|
<div className="flex min-w-0 flex-1 items-center justify-center gap-2 text-sm">
|
||||||
<span className="shrink-0 rounded-md bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
|
<span className="shrink-0 rounded-md bg-muted px-2 py-0.5 font-semibold text-foreground text-sm">
|
||||||
<span className="-mt-[3px] inline-block">
|
<span className="-mt-[3px] inline-block">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createContext, type ReactNode, useContext } from "react";
|
import { createContext, type ReactNode, useContext } from "react";
|
||||||
import { useEncounter } from "../hooks/use-encounter.js";
|
import { useEncounter } from "../hooks/use-encounter.js";
|
||||||
|
import { useUndoRedoShortcuts } from "../hooks/use-undo-redo-shortcuts.js";
|
||||||
|
|
||||||
type EncounterContextValue = ReturnType<typeof useEncounter>;
|
type EncounterContextValue = ReturnType<typeof useEncounter>;
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ const EncounterContext = createContext<EncounterContextValue | null>(null);
|
|||||||
|
|
||||||
export function EncounterProvider({ children }: { children: ReactNode }) {
|
export function EncounterProvider({ children }: { children: ReactNode }) {
|
||||||
const value = useEncounter();
|
const value = useEncounter();
|
||||||
|
useUndoRedoShortcuts(value.undo, value.redo, value.canUndo, value.canRedo);
|
||||||
return (
|
return (
|
||||||
<EncounterContext.Provider value={value}>
|
<EncounterContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { EncounterStore } from "@initiative/application";
|
import type { EncounterStore, UndoRedoStore } from "@initiative/application";
|
||||||
import {
|
import {
|
||||||
addCombatantUseCase,
|
addCombatantUseCase,
|
||||||
adjustHpUseCase,
|
adjustHpUseCase,
|
||||||
advanceTurnUseCase,
|
advanceTurnUseCase,
|
||||||
clearEncounterUseCase,
|
clearEncounterUseCase,
|
||||||
editCombatantUseCase,
|
editCombatantUseCase,
|
||||||
|
redoUseCase,
|
||||||
removeCombatantUseCase,
|
removeCombatantUseCase,
|
||||||
retreatTurnUseCase,
|
retreatTurnUseCase,
|
||||||
setAcUseCase,
|
setAcUseCase,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
setTempHpUseCase,
|
setTempHpUseCase,
|
||||||
toggleConcentrationUseCase,
|
toggleConcentrationUseCase,
|
||||||
toggleConditionUseCase,
|
toggleConditionUseCase,
|
||||||
|
undoUseCase,
|
||||||
} from "@initiative/application";
|
} from "@initiative/application";
|
||||||
import type {
|
import type {
|
||||||
BestiaryIndexEntry,
|
BestiaryIndexEntry,
|
||||||
@@ -23,11 +25,14 @@ import type {
|
|||||||
DomainEvent,
|
DomainEvent,
|
||||||
Encounter,
|
Encounter,
|
||||||
PlayerCharacter,
|
PlayerCharacter,
|
||||||
|
UndoRedoState,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
|
clearHistory,
|
||||||
combatantId,
|
combatantId,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
creatureId as makeCreatureId,
|
creatureId as makeCreatureId,
|
||||||
|
pushUndo,
|
||||||
resolveCreatureName,
|
resolveCreatureName,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
@@ -35,6 +40,10 @@ import {
|
|||||||
loadEncounter,
|
loadEncounter,
|
||||||
saveEncounter,
|
saveEncounter,
|
||||||
} from "../persistence/encounter-storage.js";
|
} from "../persistence/encounter-storage.js";
|
||||||
|
import {
|
||||||
|
loadUndoRedoStacks,
|
||||||
|
saveUndoRedoStacks,
|
||||||
|
} from "../persistence/undo-redo-storage.js";
|
||||||
|
|
||||||
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
|
const COMBATANT_ID_REGEX = /^c-(\d+)$/;
|
||||||
|
|
||||||
@@ -65,13 +74,21 @@ function deriveNextId(encounter: Encounter): number {
|
|||||||
export function useEncounter() {
|
export function useEncounter() {
|
||||||
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
|
const [encounter, setEncounter] = useState<Encounter>(initializeEncounter);
|
||||||
const [events, setEvents] = useState<DomainEvent[]>([]);
|
const [events, setEvents] = useState<DomainEvent[]>([]);
|
||||||
|
const [undoRedoState, setUndoRedoState] =
|
||||||
|
useState<UndoRedoState>(loadUndoRedoStacks);
|
||||||
const encounterRef = useRef(encounter);
|
const encounterRef = useRef(encounter);
|
||||||
encounterRef.current = encounter;
|
encounterRef.current = encounter;
|
||||||
|
const undoRedoRef = useRef(undoRedoState);
|
||||||
|
undoRedoRef.current = undoRedoState;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
saveEncounter(encounter);
|
saveEncounter(encounter);
|
||||||
}, [encounter]);
|
}, [encounter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
saveUndoRedoStacks(undoRedoState);
|
||||||
|
}, [undoRedoState]);
|
||||||
|
|
||||||
const makeStore = useCallback((): EncounterStore => {
|
const makeStore = useCallback((): EncounterStore => {
|
||||||
return {
|
return {
|
||||||
get: () => encounterRef.current,
|
get: () => encounterRef.current,
|
||||||
@@ -82,32 +99,55 @@ export function useEncounter() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const makeUndoRedoStore = useCallback((): UndoRedoStore => {
|
||||||
|
return {
|
||||||
|
get: () => undoRedoRef.current,
|
||||||
|
save: (s) => {
|
||||||
|
undoRedoRef.current = s;
|
||||||
|
setUndoRedoState(s);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const withUndo = useCallback(<T>(action: () => T): T => {
|
||||||
|
const snapshot = encounterRef.current;
|
||||||
|
const result = action();
|
||||||
|
if (!isDomainError(result)) {
|
||||||
|
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||||
|
undoRedoRef.current = newState;
|
||||||
|
setUndoRedoState(newState);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const advanceTurn = useCallback(() => {
|
const advanceTurn = useCallback(() => {
|
||||||
const result = advanceTurnUseCase(makeStore());
|
const result = withUndo(() => advanceTurnUseCase(makeStore()));
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
}, [makeStore]);
|
}, [makeStore, withUndo]);
|
||||||
|
|
||||||
const retreatTurn = useCallback(() => {
|
const retreatTurn = useCallback(() => {
|
||||||
const result = retreatTurnUseCase(makeStore());
|
const result = withUndo(() => retreatTurnUseCase(makeStore()));
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
}, [makeStore]);
|
}, [makeStore, withUndo]);
|
||||||
|
|
||||||
const nextId = useRef(deriveNextId(encounter));
|
const nextId = useRef(deriveNextId(encounter));
|
||||||
|
|
||||||
const addCombatant = useCallback(
|
const addCombatant = useCallback(
|
||||||
(name: string, init?: CombatantInit) => {
|
(name: string, init?: CombatantInit) => {
|
||||||
const id = combatantId(`c-${++nextId.current}`);
|
const id = combatantId(`c-${++nextId.current}`);
|
||||||
const result = addCombatantUseCase(makeStore(), id, name, init);
|
const result = withUndo(() =>
|
||||||
|
addCombatantUseCase(makeStore(), id, name, init),
|
||||||
|
);
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return;
|
return;
|
||||||
@@ -115,12 +155,12 @@ export function useEncounter() {
|
|||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore, withUndo],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeCombatant = useCallback(
|
const removeCombatant = useCallback(
|
||||||
(id: CombatantId) => {
|
(id: CombatantId) => {
|
||||||
const result = removeCombatantUseCase(makeStore(), id);
|
const result = withUndo(() => removeCombatantUseCase(makeStore(), id));
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return;
|
return;
|
||||||
@@ -128,12 +168,14 @@ export function useEncounter() {
|
|||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore, withUndo],
|
||||||
);
|
);
|
||||||
|
|
||||||
const editCombatant = useCallback(
|
const editCombatant = useCallback(
|
||||||
(id: CombatantId, newName: string) => {
|
(id: CombatantId, newName: string) => {
|
||||||
const result = editCombatantUseCase(makeStore(), id, newName);
|
const result = withUndo(() =>
|
||||||
|
editCombatantUseCase(makeStore(), id, newName),
|
||||||
|
);
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return;
|
return;
|
||||||
@@ -141,12 +183,14 @@ export function useEncounter() {
|
|||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore, withUndo],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setInitiative = useCallback(
|
const setInitiative = useCallback(
|
||||||
(id: CombatantId, value: number | undefined) => {
|
(id: CombatantId, value: number | undefined) => {
|
||||||
const result = setInitiativeUseCase(makeStore(), id, value);
|
const result = withUndo(() =>
|
||||||
|
setInitiativeUseCase(makeStore(), id, value),
|
||||||
|
);
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return;
|
return;
|
||||||
@@ -154,12 +198,12 @@ export function useEncounter() {
|
|||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore, withUndo],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setHp = useCallback(
|
const setHp = useCallback(
|
||||||
(id: CombatantId, maxHp: number | undefined) => {
|
(id: CombatantId, maxHp: number | undefined) => {
|
||||||
const result = setHpUseCase(makeStore(), id, maxHp);
|
const result = withUndo(() => setHpUseCase(makeStore(), id, maxHp));
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return;
|
return;
|
||||||
@@ -167,12 +211,12 @@ export function useEncounter() {
|
|||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore, withUndo],
|
||||||
);
|
);
|
||||||
|
|
||||||
const adjustHp = useCallback(
|
const adjustHp = useCallback(
|
||||||
(id: CombatantId, delta: number) => {
|
(id: CombatantId, delta: number) => {
|
||||||
const result = adjustHpUseCase(makeStore(), id, delta);
|
const result = withUndo(() => adjustHpUseCase(makeStore(), id, delta));
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return;
|
return;
|
||||||
@@ -180,12 +224,12 @@ export function useEncounter() {
|
|||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore, withUndo],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setTempHp = useCallback(
|
const setTempHp = useCallback(
|
||||||
(id: CombatantId, tempHp: number | undefined) => {
|
(id: CombatantId, tempHp: number | undefined) => {
|
||||||
const result = setTempHpUseCase(makeStore(), id, tempHp);
|
const result = withUndo(() => setTempHpUseCase(makeStore(), id, tempHp));
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return;
|
return;
|
||||||
@@ -193,12 +237,12 @@ export function useEncounter() {
|
|||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore, withUndo],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setAc = useCallback(
|
const setAc = useCallback(
|
||||||
(id: CombatantId, value: number | undefined) => {
|
(id: CombatantId, value: number | undefined) => {
|
||||||
const result = setAcUseCase(makeStore(), id, value);
|
const result = withUndo(() => setAcUseCase(makeStore(), id, value));
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return;
|
return;
|
||||||
@@ -206,12 +250,14 @@ export function useEncounter() {
|
|||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore, withUndo],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleCondition = useCallback(
|
const toggleCondition = useCallback(
|
||||||
(id: CombatantId, conditionId: ConditionId) => {
|
(id: CombatantId, conditionId: ConditionId) => {
|
||||||
const result = toggleConditionUseCase(makeStore(), id, conditionId);
|
const result = withUndo(() =>
|
||||||
|
toggleConditionUseCase(makeStore(), id, conditionId),
|
||||||
|
);
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return;
|
return;
|
||||||
@@ -219,12 +265,14 @@ export function useEncounter() {
|
|||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore, withUndo],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleConcentration = useCallback(
|
const toggleConcentration = useCallback(
|
||||||
(id: CombatantId) => {
|
(id: CombatantId) => {
|
||||||
const result = toggleConcentrationUseCase(makeStore(), id);
|
const result = withUndo(() =>
|
||||||
|
toggleConcentrationUseCase(makeStore(), id),
|
||||||
|
);
|
||||||
|
|
||||||
if (isDomainError(result)) {
|
if (isDomainError(result)) {
|
||||||
return;
|
return;
|
||||||
@@ -232,7 +280,7 @@ export function useEncounter() {
|
|||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore, withUndo],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearEncounter = useCallback(() => {
|
const clearEncounter = useCallback(() => {
|
||||||
@@ -242,12 +290,17 @@ export function useEncounter() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cleared = clearHistory();
|
||||||
|
undoRedoRef.current = cleared;
|
||||||
|
setUndoRedoState(cleared);
|
||||||
|
|
||||||
nextId.current = 0;
|
nextId.current = 0;
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
}, [makeStore]);
|
}, [makeStore]);
|
||||||
|
|
||||||
const addFromBestiary = useCallback(
|
const addFromBestiary = useCallback(
|
||||||
(entry: BestiaryIndexEntry): CreatureId | null => {
|
(entry: BestiaryIndexEntry): CreatureId | null => {
|
||||||
|
const snapshot = encounterRef.current;
|
||||||
const store = makeStore();
|
const store = makeStore();
|
||||||
const existingNames = store.get().combatants.map((c) => c.name);
|
const existingNames = store.get().combatants.map((c) => c.name);
|
||||||
const { newName, renames } = resolveCreatureName(
|
const { newName, renames } = resolveCreatureName(
|
||||||
@@ -277,6 +330,10 @@ export function useEncounter() {
|
|||||||
|
|
||||||
if (isDomainError(result)) return null;
|
if (isDomainError(result)) return null;
|
||||||
|
|
||||||
|
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||||
|
undoRedoRef.current = newState;
|
||||||
|
setUndoRedoState(newState);
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
return cId;
|
return cId;
|
||||||
},
|
},
|
||||||
@@ -285,6 +342,7 @@ export function useEncounter() {
|
|||||||
|
|
||||||
const addFromPlayerCharacter = useCallback(
|
const addFromPlayerCharacter = useCallback(
|
||||||
(pc: PlayerCharacter) => {
|
(pc: PlayerCharacter) => {
|
||||||
|
const snapshot = encounterRef.current;
|
||||||
const store = makeStore();
|
const store = makeStore();
|
||||||
const existingNames = store.get().combatants.map((c) => c.name);
|
const existingNames = store.get().combatants.map((c) => c.name);
|
||||||
const { newName, renames } = resolveCreatureName(pc.name, existingNames);
|
const { newName, renames } = resolveCreatureName(pc.name, existingNames);
|
||||||
@@ -307,11 +365,26 @@ export function useEncounter() {
|
|||||||
|
|
||||||
if (isDomainError(result)) return;
|
if (isDomainError(result)) return;
|
||||||
|
|
||||||
|
const newState = pushUndo(undoRedoRef.current, snapshot);
|
||||||
|
undoRedoRef.current = newState;
|
||||||
|
setUndoRedoState(newState);
|
||||||
|
|
||||||
setEvents((prev) => [...prev, ...result]);
|
setEvents((prev) => [...prev, ...result]);
|
||||||
},
|
},
|
||||||
[makeStore],
|
[makeStore],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const undoAction = useCallback(() => {
|
||||||
|
undoUseCase(makeStore(), makeUndoRedoStore());
|
||||||
|
}, [makeStore, makeUndoRedoStore]);
|
||||||
|
|
||||||
|
const redoAction = useCallback(() => {
|
||||||
|
redoUseCase(makeStore(), makeUndoRedoStore());
|
||||||
|
}, [makeStore, makeUndoRedoStore]);
|
||||||
|
|
||||||
|
const canUndo = undoRedoState.undoStack.length > 0;
|
||||||
|
const canRedo = undoRedoState.redoStack.length > 0;
|
||||||
|
|
||||||
const hasTempHp = encounter.combatants.some(
|
const hasTempHp = encounter.combatants.some(
|
||||||
(c) => c.tempHp !== undefined && c.tempHp > 0,
|
(c) => c.tempHp !== undefined && c.tempHp > 0,
|
||||||
);
|
);
|
||||||
@@ -331,6 +404,8 @@ export function useEncounter() {
|
|||||||
hasTempHp,
|
hasTempHp,
|
||||||
hasCreatureCombatants,
|
hasCreatureCombatants,
|
||||||
canRollAllInitiative,
|
canRollAllInitiative,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
advanceTurn,
|
advanceTurn,
|
||||||
retreatTurn,
|
retreatTurn,
|
||||||
addCombatant,
|
addCombatant,
|
||||||
@@ -346,6 +421,8 @@ export function useEncounter() {
|
|||||||
toggleConcentration,
|
toggleConcentration,
|
||||||
addFromBestiary,
|
addFromBestiary,
|
||||||
addFromPlayerCharacter,
|
addFromPlayerCharacter,
|
||||||
|
undo: undoAction,
|
||||||
|
redo: redoAction,
|
||||||
makeStore,
|
makeStore,
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|||||||
42
apps/web/src/hooks/use-undo-redo-shortcuts.ts
Normal file
42
apps/web/src/hooks/use-undo-redo-shortcuts.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
const SUPPRESSED_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]);
|
||||||
|
|
||||||
|
function isTextInputFocused(): boolean {
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (!active) return false;
|
||||||
|
if (SUPPRESSED_TAGS.has(active.tagName)) return true;
|
||||||
|
return active instanceof HTMLElement && active.isContentEditable;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUndoShortcut(e: KeyboardEvent): boolean {
|
||||||
|
return (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && !e.shiftKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRedoShortcut(e: KeyboardEvent): boolean {
|
||||||
|
return (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && e.shiftKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUndoRedoShortcuts(
|
||||||
|
undo: () => void,
|
||||||
|
redo: () => void,
|
||||||
|
canUndo: boolean,
|
||||||
|
canRedo: boolean,
|
||||||
|
): void {
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (isTextInputFocused()) return;
|
||||||
|
|
||||||
|
if (isUndoShortcut(e) && canUndo) {
|
||||||
|
e.preventDefault();
|
||||||
|
undo();
|
||||||
|
} else if (isRedoShortcut(e) && canRedo) {
|
||||||
|
e.preventDefault();
|
||||||
|
redo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [undo, redo, canUndo, canRedo]);
|
||||||
|
}
|
||||||
@@ -108,45 +108,44 @@ function isValidCombatantEntry(c: unknown): boolean {
|
|||||||
return typeof entry.id === "string" && typeof entry.name === "string";
|
return typeof entry.id === "string" && typeof entry.name === "string";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function rehydrateEncounter(parsed: unknown): Encounter | null {
|
||||||
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const obj = parsed as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (!Array.isArray(obj.combatants)) return null;
|
||||||
|
if (typeof obj.activeIndex !== "number") return null;
|
||||||
|
if (typeof obj.roundNumber !== "number") return null;
|
||||||
|
|
||||||
|
const combatants = obj.combatants as unknown[];
|
||||||
|
|
||||||
|
// Handle empty encounter (cleared state) directly — createEncounter rejects empty arrays
|
||||||
|
if (combatants.length === 0) {
|
||||||
|
return {
|
||||||
|
combatants: [],
|
||||||
|
activeIndex: 0,
|
||||||
|
roundNumber: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!combatants.every(isValidCombatantEntry)) return null;
|
||||||
|
|
||||||
|
const rehydrated = combatants.map(rehydrateCombatant);
|
||||||
|
|
||||||
|
const result = createEncounter(rehydrated, obj.activeIndex, obj.roundNumber);
|
||||||
|
if (isDomainError(result)) return null;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function loadEncounter(): Encounter | null {
|
export function loadEncounter(): Encounter | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
if (raw === null) return null;
|
if (raw === null) return null;
|
||||||
|
|
||||||
const parsed: unknown = JSON.parse(raw);
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
return rehydrateEncounter(parsed);
|
||||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
const obj = parsed as Record<string, unknown>;
|
|
||||||
|
|
||||||
if (!Array.isArray(obj.combatants)) return null;
|
|
||||||
if (typeof obj.activeIndex !== "number") return null;
|
|
||||||
if (typeof obj.roundNumber !== "number") return null;
|
|
||||||
|
|
||||||
const combatants = obj.combatants as unknown[];
|
|
||||||
|
|
||||||
// Handle empty encounter (cleared state) directly — createEncounter rejects empty arrays
|
|
||||||
if (combatants.length === 0) {
|
|
||||||
return {
|
|
||||||
combatants: [],
|
|
||||||
activeIndex: 0,
|
|
||||||
roundNumber: 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!combatants.every(isValidCombatantEntry)) return null;
|
|
||||||
|
|
||||||
const rehydrated = combatants.map(rehydrateCombatant);
|
|
||||||
|
|
||||||
const result = createEncounter(
|
|
||||||
rehydrated,
|
|
||||||
obj.activeIndex,
|
|
||||||
obj.roundNumber,
|
|
||||||
);
|
|
||||||
if (isDomainError(result)) return null;
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
45
apps/web/src/persistence/undo-redo-storage.ts
Normal file
45
apps/web/src/persistence/undo-redo-storage.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type { Encounter, UndoRedoState } from "@initiative/domain";
|
||||||
|
import { EMPTY_UNDO_REDO_STATE } from "@initiative/domain";
|
||||||
|
import { rehydrateEncounter } from "./encounter-storage.js";
|
||||||
|
|
||||||
|
const UNDO_KEY = "initiative:encounter:undo";
|
||||||
|
const REDO_KEY = "initiative:encounter:redo";
|
||||||
|
|
||||||
|
export function saveUndoRedoStacks(state: UndoRedoState): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(UNDO_KEY, JSON.stringify(state.undoStack));
|
||||||
|
localStorage.setItem(REDO_KEY, JSON.stringify(state.redoStack));
|
||||||
|
} catch {
|
||||||
|
// Silently swallow errors (quota exceeded, storage unavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStack(key: string): readonly Encounter[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
if (raw === null) return [];
|
||||||
|
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
|
||||||
|
const valid: Encounter[] = [];
|
||||||
|
for (const entry of parsed) {
|
||||||
|
const rehydrated = rehydrateEncounter(entry);
|
||||||
|
if (rehydrated !== null) {
|
||||||
|
valid.push(rehydrated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return valid;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadUndoRedoStacks(): UndoRedoState {
|
||||||
|
const undoStack = loadStack(UNDO_KEY);
|
||||||
|
const redoStack = loadStack(REDO_KEY);
|
||||||
|
if (undoStack.length === 0 && redoStack.length === 0) {
|
||||||
|
return EMPTY_UNDO_REDO_STATE;
|
||||||
|
}
|
||||||
|
return { undoStack, redoStack };
|
||||||
|
}
|
||||||
@@ -10,7 +10,9 @@ export type {
|
|||||||
BestiarySourceCache,
|
BestiarySourceCache,
|
||||||
EncounterStore,
|
EncounterStore,
|
||||||
PlayerCharacterStore,
|
PlayerCharacterStore,
|
||||||
|
UndoRedoStore,
|
||||||
} from "./ports.js";
|
} from "./ports.js";
|
||||||
|
export { redoUseCase } from "./redo-use-case.js";
|
||||||
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
||||||
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
||||||
export {
|
export {
|
||||||
@@ -24,3 +26,4 @@ export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
|||||||
export { setTempHpUseCase } from "./set-temp-hp-use-case.js";
|
export { setTempHpUseCase } from "./set-temp-hp-use-case.js";
|
||||||
export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
|
export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
|
||||||
export { toggleConditionUseCase } from "./toggle-condition-use-case.js";
|
export { toggleConditionUseCase } from "./toggle-condition-use-case.js";
|
||||||
|
export { undoUseCase } from "./undo-use-case.js";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
CreatureId,
|
CreatureId,
|
||||||
Encounter,
|
Encounter,
|
||||||
PlayerCharacter,
|
PlayerCharacter,
|
||||||
|
UndoRedoState,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
|
|
||||||
export interface EncounterStore {
|
export interface EncounterStore {
|
||||||
@@ -19,3 +20,8 @@ export interface PlayerCharacterStore {
|
|||||||
getAll(): PlayerCharacter[];
|
getAll(): PlayerCharacter[];
|
||||||
save(characters: PlayerCharacter[]): void;
|
save(characters: PlayerCharacter[]): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UndoRedoStore {
|
||||||
|
get(): UndoRedoState;
|
||||||
|
save(state: UndoRedoState): void;
|
||||||
|
}
|
||||||
|
|||||||
24
packages/application/src/redo-use-case.ts
Normal file
24
packages/application/src/redo-use-case.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import {
|
||||||
|
type DomainError,
|
||||||
|
type Encounter,
|
||||||
|
isDomainError,
|
||||||
|
redo,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore, UndoRedoStore } from "./ports.js";
|
||||||
|
|
||||||
|
export function redoUseCase(
|
||||||
|
encounterStore: EncounterStore,
|
||||||
|
undoRedoStore: UndoRedoStore,
|
||||||
|
): Encounter | DomainError {
|
||||||
|
const current = encounterStore.get();
|
||||||
|
const state = undoRedoStore.get();
|
||||||
|
const result = redo(state, current);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
encounterStore.save(result.encounter);
|
||||||
|
undoRedoStore.save(result.state);
|
||||||
|
return result.encounter;
|
||||||
|
}
|
||||||
24
packages/application/src/undo-use-case.ts
Normal file
24
packages/application/src/undo-use-case.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import {
|
||||||
|
type DomainError,
|
||||||
|
type Encounter,
|
||||||
|
isDomainError,
|
||||||
|
undo,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore, UndoRedoStore } from "./ports.js";
|
||||||
|
|
||||||
|
export function undoUseCase(
|
||||||
|
encounterStore: EncounterStore,
|
||||||
|
undoRedoStore: UndoRedoStore,
|
||||||
|
): Encounter | DomainError {
|
||||||
|
const current = encounterStore.get();
|
||||||
|
const state = undoRedoStore.get();
|
||||||
|
const result = undo(state, current);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
encounterStore.save(result.encounter);
|
||||||
|
undoRedoStore.save(result.state);
|
||||||
|
return result.encounter;
|
||||||
|
}
|
||||||
124
packages/domain/src/__tests__/undo-redo.test.ts
Normal file
124
packages/domain/src/__tests__/undo-redo.test.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Encounter } from "../types.js";
|
||||||
|
import { isDomainError } from "../types.js";
|
||||||
|
import {
|
||||||
|
clearHistory,
|
||||||
|
EMPTY_UNDO_REDO_STATE,
|
||||||
|
pushUndo,
|
||||||
|
redo,
|
||||||
|
undo,
|
||||||
|
} from "../undo-redo.js";
|
||||||
|
|
||||||
|
function enc(roundNumber = 1, activeIndex = 0): Encounter {
|
||||||
|
return { combatants: [], activeIndex, roundNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("pushUndo", () => {
|
||||||
|
it("adds a snapshot to the undo stack", () => {
|
||||||
|
const result = pushUndo(EMPTY_UNDO_REDO_STATE, enc(1));
|
||||||
|
expect(result.undoStack).toHaveLength(1);
|
||||||
|
expect(result.undoStack[0]).toEqual(enc(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the redo stack", () => {
|
||||||
|
const state = {
|
||||||
|
undoStack: [enc(1)],
|
||||||
|
redoStack: [enc(2)],
|
||||||
|
};
|
||||||
|
const result = pushUndo(state, enc(3));
|
||||||
|
expect(result.redoStack).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps the undo stack at 50, dropping the oldest", () => {
|
||||||
|
const undoStack = Array.from({ length: 50 }, (_, i) => enc(i + 1));
|
||||||
|
const state = { undoStack, redoStack: [] };
|
||||||
|
const result = pushUndo(state, enc(51));
|
||||||
|
expect(result.undoStack).toHaveLength(50);
|
||||||
|
expect(result.undoStack[0]).toEqual(enc(2));
|
||||||
|
expect(result.undoStack[49]).toEqual(enc(51));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("undo", () => {
|
||||||
|
it("pops from undo stack and pushes current to redo stack", () => {
|
||||||
|
const state = { undoStack: [enc(1)], redoStack: [] };
|
||||||
|
const result = undo(state, enc(2));
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
if (isDomainError(result)) return;
|
||||||
|
expect(result.encounter).toEqual(enc(1));
|
||||||
|
expect(result.state.undoStack).toHaveLength(0);
|
||||||
|
expect(result.state.redoStack).toEqual([enc(2)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error when undo stack is empty", () => {
|
||||||
|
const result = undo(EMPTY_UNDO_REDO_STATE, enc(1));
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
if (!isDomainError(result)) return;
|
||||||
|
expect(result.code).toBe("nothing-to-undo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pops the most recent entry (last in stack)", () => {
|
||||||
|
const state = { undoStack: [enc(1), enc(2), enc(3)], redoStack: [] };
|
||||||
|
const result = undo(state, enc(4));
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
if (isDomainError(result)) return;
|
||||||
|
expect(result.encounter).toEqual(enc(3));
|
||||||
|
expect(result.state.undoStack).toEqual([enc(1), enc(2)]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("redo", () => {
|
||||||
|
it("pops from redo stack and pushes current to undo stack", () => {
|
||||||
|
const state = { undoStack: [], redoStack: [enc(1)] };
|
||||||
|
const result = redo(state, enc(2));
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
if (isDomainError(result)) return;
|
||||||
|
expect(result.encounter).toEqual(enc(1));
|
||||||
|
expect(result.state.redoStack).toHaveLength(0);
|
||||||
|
expect(result.state.undoStack).toEqual([enc(2)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns domain error when redo stack is empty", () => {
|
||||||
|
const result = redo(EMPTY_UNDO_REDO_STATE, enc(1));
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
if (!isDomainError(result)) return;
|
||||||
|
expect(result.code).toBe("nothing-to-redo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pops the most recent entry (last in stack)", () => {
|
||||||
|
const state = { undoStack: [], redoStack: [enc(1), enc(2), enc(3)] };
|
||||||
|
const result = redo(state, enc(4));
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
if (isDomainError(result)) return;
|
||||||
|
expect(result.encounter).toEqual(enc(3));
|
||||||
|
expect(result.state.redoStack).toEqual([enc(1), enc(2)]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("undo-then-redo roundtrip", () => {
|
||||||
|
it("returns the exact same encounter after undo then redo", () => {
|
||||||
|
const original = enc(5);
|
||||||
|
const current = enc(6);
|
||||||
|
const afterPush = pushUndo(EMPTY_UNDO_REDO_STATE, original);
|
||||||
|
|
||||||
|
const undoResult = undo(afterPush, current);
|
||||||
|
expect(isDomainError(undoResult)).toBe(false);
|
||||||
|
if (isDomainError(undoResult)) return;
|
||||||
|
|
||||||
|
expect(undoResult.encounter).toEqual(original);
|
||||||
|
|
||||||
|
const redoResult = redo(undoResult.state, undoResult.encounter);
|
||||||
|
expect(isDomainError(redoResult)).toBe(false);
|
||||||
|
if (isDomainError(redoResult)) return;
|
||||||
|
|
||||||
|
expect(redoResult.encounter).toEqual(current);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearHistory", () => {
|
||||||
|
it("empties both stacks", () => {
|
||||||
|
const result = clearHistory();
|
||||||
|
expect(result.undoStack).toHaveLength(0);
|
||||||
|
expect(result.redoStack).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -121,3 +121,11 @@ export {
|
|||||||
type Encounter,
|
type Encounter,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
export {
|
||||||
|
clearHistory,
|
||||||
|
EMPTY_UNDO_REDO_STATE,
|
||||||
|
pushUndo,
|
||||||
|
redo,
|
||||||
|
type UndoRedoState,
|
||||||
|
undo,
|
||||||
|
} from "./undo-redo.js";
|
||||||
|
|||||||
70
packages/domain/src/undo-redo.ts
Normal file
70
packages/domain/src/undo-redo.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { DomainError, Encounter } from "./types.js";
|
||||||
|
|
||||||
|
export interface UndoRedoState {
|
||||||
|
readonly undoStack: readonly Encounter[];
|
||||||
|
readonly redoStack: readonly Encounter[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_UNDO_STACK = 50;
|
||||||
|
|
||||||
|
export const EMPTY_UNDO_REDO_STATE: UndoRedoState = {
|
||||||
|
undoStack: [],
|
||||||
|
redoStack: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function pushUndo(
|
||||||
|
state: UndoRedoState,
|
||||||
|
snapshot: Encounter,
|
||||||
|
): UndoRedoState {
|
||||||
|
const newStack = [...state.undoStack, snapshot];
|
||||||
|
if (newStack.length > MAX_UNDO_STACK) {
|
||||||
|
newStack.shift();
|
||||||
|
}
|
||||||
|
return { undoStack: newStack, redoStack: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function undo(
|
||||||
|
state: UndoRedoState,
|
||||||
|
currentEncounter: Encounter,
|
||||||
|
): { state: UndoRedoState; encounter: Encounter } | DomainError {
|
||||||
|
if (state.undoStack.length === 0) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "nothing-to-undo",
|
||||||
|
message: "Nothing to undo",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const restored = state.undoStack.at(-1) as Encounter;
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
undoStack: state.undoStack.slice(0, -1),
|
||||||
|
redoStack: [...state.redoStack, currentEncounter],
|
||||||
|
},
|
||||||
|
encounter: restored,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redo(
|
||||||
|
state: UndoRedoState,
|
||||||
|
currentEncounter: Encounter,
|
||||||
|
): { state: UndoRedoState; encounter: Encounter } | DomainError {
|
||||||
|
if (state.redoStack.length === 0) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "nothing-to-redo",
|
||||||
|
message: "Nothing to redo",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const restored = state.redoStack.at(-1) as Encounter;
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
undoStack: [...state.undoStack, currentEncounter],
|
||||||
|
redoStack: state.redoStack.slice(0, -1),
|
||||||
|
},
|
||||||
|
encounter: restored,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearHistory(): UndoRedoState {
|
||||||
|
return EMPTY_UNDO_REDO_STATE;
|
||||||
|
}
|
||||||
35
specs/037-undo-redo/checklists/requirements.md
Normal file
35
specs/037-undo-redo/checklists/requirements.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Specification Quality Checklist: Undo/Redo
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-26
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All items pass. The spec references "memento pattern" and "localStorage" — these are architectural intent from the issue itself, not implementation leakage. The spec describes *what* (snapshot-based history, persisted to local storage) not *how* (no code structure, no framework APIs).
|
||||||
|
- The "Assumptions" section documents the localStorage sizing assumption and the dependency on #15 being resolved.
|
||||||
83
specs/037-undo-redo/data-model.md
Normal file
83
specs/037-undo-redo/data-model.md
Normal 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
|
||||||
71
specs/037-undo-redo/plan.md
Normal file
71
specs/037-undo-redo/plan.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Implementation Plan: Undo/Redo
|
||||||
|
|
||||||
|
**Branch**: `037-undo-redo` | **Date**: 2026-03-26 | **Spec**: [spec.md](spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/037-undo-redo/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add undo/redo capability for all encounter state changes using the memento pattern. Before each state transition, the current `Encounter` is pushed to an undo stack (capped at 50 entries). Undo restores the previous snapshot and pushes the current state to a redo stack; any new action clears the redo stack. Stacks are persisted to localStorage. Triggered via UI buttons (disabled when empty) and keyboard shortcuts (Ctrl+Z / Ctrl+Shift+Z, Cmd on Mac), suppressed during text input focus.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: TypeScript 5.8 (strict mode, `verbatimModuleSyntax`)
|
||||||
|
**Primary Dependencies**: React 19, Vite 6, Tailwind CSS v4, Lucide React (icons)
|
||||||
|
**Storage**: localStorage (existing key `"initiative:encounter"`, new keys for undo/redo stacks)
|
||||||
|
**Testing**: Vitest (v8 coverage)
|
||||||
|
**Target Platform**: Web browser (desktop + mobile)
|
||||||
|
**Project Type**: Web application (monorepo: `apps/web` + `packages/domain` + `packages/application`)
|
||||||
|
**Performance Goals**: Undo/redo operations complete in < 1 second (per SC-001)
|
||||||
|
**Constraints**: Undo stack capped at 50 snapshots; localStorage quota is best-effort
|
||||||
|
**Scale/Scope**: Encounters have tens of combatants; 50 snapshots of ~2-5 KB each = ~100-250 KB
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Deterministic Domain Core | PASS | Stack management (push, pop, cap) is pure logic with no I/O. Domain functions remain unchanged. |
|
||||||
|
| II. Layered Architecture | PASS | Stack operations are pure functions in domain. Persistence is in the adapter layer (localStorage). Hook orchestrates via application pattern. |
|
||||||
|
| II-A. Context-Based State Flow | PASS | Undo/redo state exposed via existing EncounterContext. No new props needed on components beyond the context consumer. |
|
||||||
|
| III. Clarification-First | PASS | No ambiguities remain; issue #16 and spec fully define behavior. |
|
||||||
|
| IV. Escalation Gates | PASS | All requirements come from the spec; no scope expansion. |
|
||||||
|
| V. MVP Baseline Language | PASS | No permanent bans introduced. |
|
||||||
|
| VI. No Gameplay Rules | PASS | Undo/redo is infrastructure, not gameplay. |
|
||||||
|
|
||||||
|
**Result**: All gates pass. No violations to justify.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/037-undo-redo/
|
||||||
|
├── plan.md # This file
|
||||||
|
├── research.md # Phase 0 output
|
||||||
|
├── data-model.md # Phase 1 output
|
||||||
|
├── quickstart.md # Phase 1 output
|
||||||
|
└── tasks.md # Phase 2 output (/speckit.tasks)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
packages/domain/src/
|
||||||
|
├── undo-redo.ts # Pure stack operations (push, pop, cap, clear)
|
||||||
|
├── __tests__/undo-redo.test.ts # Unit tests for stack operations
|
||||||
|
|
||||||
|
packages/application/src/
|
||||||
|
├── undo-use-case.ts # Orchestrates undo via EncounterStore + UndoRedoStore
|
||||||
|
├── redo-use-case.ts # Orchestrates redo via EncounterStore + UndoRedoStore
|
||||||
|
├── ports.ts # Extended with UndoRedoStore port interface
|
||||||
|
|
||||||
|
apps/web/src/
|
||||||
|
├── hooks/use-encounter.ts # Modified: wrap actions with snapshot capture, expose undo/redo
|
||||||
|
├── persistence/undo-redo-storage.ts # localStorage save/load for undo/redo stacks
|
||||||
|
├── contexts/encounter-context.tsx # Modified: expose undo/redo + stack emptiness flags
|
||||||
|
├── components/turn-navigation.tsx # Modified: add undo/redo buttons (inboard of turn step buttons)
|
||||||
|
├── hooks/use-undo-redo-shortcuts.ts # Keyboard shortcut handler (Ctrl+Z, Ctrl+Shift+Z)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Follows existing layered architecture. Pure stack operations in domain, use cases in application, persistence and UI in web adapter. No new packages or structural changes needed.
|
||||||
53
specs/037-undo-redo/quickstart.md
Normal file
53
specs/037-undo-redo/quickstart.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Quickstart: Undo/Redo
|
||||||
|
|
||||||
|
**Feature**: 037-undo-redo
|
||||||
|
**Date**: 2026-03-26
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature adds undo/redo to all encounter state changes using the memento (snapshot) pattern. Each action captures the pre-action encounter state onto an undo stack. Undo restores the previous state; redo re-applies an undone state.
|
||||||
|
|
||||||
|
## Implementation Layers
|
||||||
|
|
||||||
|
### Domain (`packages/domain/src/undo-redo.ts`)
|
||||||
|
|
||||||
|
Pure functions for stack management:
|
||||||
|
- `pushUndo(state, snapshot)` — push snapshot, cap at 50, clear redo
|
||||||
|
- `undo(state, currentEncounter)` — pop undo, push current to redo
|
||||||
|
- `redo(state, currentEncounter)` — pop redo, push current to undo
|
||||||
|
- `clearHistory()` — reset both stacks
|
||||||
|
|
||||||
|
All functions take and return immutable data. No I/O.
|
||||||
|
|
||||||
|
### Application (`packages/application/src/`)
|
||||||
|
|
||||||
|
Use cases that orchestrate domain calls via store ports:
|
||||||
|
- `undoUseCase(encounterStore, undoRedoStore)` — execute undo
|
||||||
|
- `redoUseCase(encounterStore, undoRedoStore)` — execute redo
|
||||||
|
|
||||||
|
New port interface `UndoRedoStore` in `ports.ts`:
|
||||||
|
- `get(): UndoRedoState`
|
||||||
|
- `save(state: UndoRedoState): void`
|
||||||
|
|
||||||
|
### Web Adapter (`apps/web/src/`)
|
||||||
|
|
||||||
|
**Hook (`use-encounter.ts`)**: Wraps every action callback to capture pre-action snapshot. Exposes `undo()`, `redo()`, `canUndo`, `canRedo`.
|
||||||
|
|
||||||
|
**Persistence (`persistence/undo-redo-storage.ts`)**: Save/load undo/redo stacks to localStorage keys `"initiative:encounter:undo"` and `"initiative:encounter:redo"`.
|
||||||
|
|
||||||
|
**UI (`components/turn-navigation.tsx`)**: Undo/Redo buttons in the top bar, inboard of the turn step buttons, disabled when stack is empty.
|
||||||
|
|
||||||
|
**Keyboard (`hooks/use-undo-redo-shortcuts.ts`)**: Global keydown listener for Ctrl+Z / Ctrl+Shift+Z (Cmd on Mac). Suppressed when text input has focus.
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
1. **Memento over Command**: Full encounter snapshots, not inverse events. Simpler at encounter scale (~2-5 KB per snapshot).
|
||||||
|
2. **Capture in hook, not domain**: Snapshot capture happens in the adapter layer. Domain and application layers are unaware of undo/redo.
|
||||||
|
3. **React state for stacks**: Enables reactive button disabled states without manual re-render triggers.
|
||||||
|
4. **Clear is not undoable**: Both stacks reset on encounter clear (per spec).
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- **Domain tests**: Pure function tests for stack operations (push, pop, cap, clear, undo/redo roundtrip).
|
||||||
|
- **Application tests**: Use case tests with mock stores.
|
||||||
|
- **Integration**: Spec acceptance scenarios mapped to test cases (undo restores state, redo reapplies, new action clears redo, keyboard suppression during input focus).
|
||||||
82
specs/037-undo-redo/research.md
Normal file
82
specs/037-undo-redo/research.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Research: Undo/Redo for Encounter Actions
|
||||||
|
|
||||||
|
**Feature**: 037-undo-redo
|
||||||
|
**Date**: 2026-03-26
|
||||||
|
|
||||||
|
## Decision 1: Undo/Redo Strategy — Memento (Snapshots) vs Command (Events)
|
||||||
|
|
||||||
|
**Decision**: Memento pattern — store full `Encounter` snapshots.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- The `Encounter` type is small (tens of combatants, ~2-5 KB serialized). Storing 50 snapshots costs ~100-250 KB in memory and localStorage — negligible.
|
||||||
|
- The codebase already serializes/deserializes full encounters for localStorage persistence. Reuse is straightforward.
|
||||||
|
- All domain state transitions are pure functions returning new `Encounter` objects. Each action naturally produces a "before" snapshot (the current state) and an "after" snapshot (the result).
|
||||||
|
- The command/event approach would require inverse operations for every domain function, including compound operations like initiative re-sorting. This complexity is not justified at encounter scale.
|
||||||
|
- The existing event system captures `previousValue`/`newValue` pairs but lacks full combatant snapshots for structural changes (add/remove with initiative reordering). Extending events would be more work than snapshots for no practical benefit.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- **Command pattern (inverse events)**: More memory-efficient per entry but significantly more complex. Requires implementing and testing inverse operations for all 18+ domain transitions. Rejected because the complexity outweighs the memory savings at encounter scale.
|
||||||
|
- **Hybrid (events for simple, snapshots for structural)**: Rejected because mixed strategies increase implementation and debugging complexity.
|
||||||
|
|
||||||
|
## Decision 2: Stack Storage Location
|
||||||
|
|
||||||
|
**Decision**: Store undo/redo stacks in React state within `useEncounter`, persisted to localStorage via dedicated keys.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Matches the existing persistence pattern: encounter state lives in React state and is synced to localStorage via `useEffect`.
|
||||||
|
- Using `useState` (not `useRef`) ensures React re-renders when stack emptiness changes, keeping button disabled states reactive.
|
||||||
|
- Dedicated localStorage keys (`"initiative:encounter:undo"`, `"initiative:encounter:redo"`) avoid coupling stack persistence with encounter persistence.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- **useRef for stacks**: Would avoid re-renders on every push/pop, but then button disabled states wouldn't update reactively. Would need manual `forceUpdate` or separate boolean state — more complex for no clear benefit.
|
||||||
|
- **Single localStorage key with encounter**: Rejected because it couples concerns and makes the encounter storage format backward-incompatible.
|
||||||
|
|
||||||
|
## Decision 3: Snapshot Capture Point
|
||||||
|
|
||||||
|
**Decision**: Capture the pre-action encounter snapshot inside each action callback in `useEncounter`, before calling the use case.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Each action callback in `useEncounter` already calls `makeStore()` which accesses the current encounter via `encounterRef.current`. The snapshot is naturally available at this point.
|
||||||
|
- Capturing at the hook level (not the use case level) keeps the domain and application layers unchanged — undo/redo is purely an adapter concern.
|
||||||
|
- Failed actions (domain errors) should NOT push to the undo stack, so the capture must happen conditionally after confirming the action succeeded.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- **Capture inside use cases**: Would require changing the application layer API to return the pre-action state. Violates layered architecture — use cases shouldn't know about undo.
|
||||||
|
- **Capture via store wrapper**: Could intercept `store.save()` to capture the previous state. Elegant but makes the flow harder to follow and debug. Rejected in favor of explicit capture.
|
||||||
|
|
||||||
|
## Decision 4: Keyboard Shortcut Suppression Strategy
|
||||||
|
|
||||||
|
**Decision**: Check `document.activeElement` tag name and `contentEditable` attribute. Suppress encounter undo/redo when focus is on `INPUT`, `TEXTAREA`, or `contentEditable` elements.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Simple and reliable. The app uses standard HTML form elements for text input.
|
||||||
|
- No `contentEditable` elements currently exist, but checking for them is defensive and low-cost.
|
||||||
|
- The check happens in the `keydown` event handler before dispatching to undo/redo.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- **Capture phase with stopPropagation**: Overly complex for this use case.
|
||||||
|
- **Custom focus tracking via context**: Would require every input to register/unregister. Too invasive.
|
||||||
|
|
||||||
|
## Decision 5: UI Placement for Undo/Redo Buttons
|
||||||
|
|
||||||
|
**Decision**: Place undo/redo buttons in the `TurnNavigation` component (top bar), inboard of the turn step buttons. Turn navigation (Previous/Next Turn) stays as the outermost buttons; Undo/Redo sits between Previous Turn and the center info area.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- `TurnNavigation` is the primary command bar for encounter-level actions (advance/retreat turn, clear encounter). Undo/redo are encounter-level actions.
|
||||||
|
- Placing them in the top bar keeps them always visible when the encounter is active.
|
||||||
|
- Turn navigation stays outermost because it's the most frequently used control during live combat. Undo/redo is secondary.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- **ActionBar (bottom bar)**: Already crowded with combatant management controls (search, add, roll initiative). Undo/redo would be buried.
|
||||||
|
- **Floating buttons**: Unconventional for this app's design language.
|
||||||
|
|
||||||
|
## Decision 6: Clear Encounter and Undo Stack
|
||||||
|
|
||||||
|
**Decision**: Clearing the encounter resets both undo and redo stacks. Clear is not undoable.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Per spec (FR-010 and edge case). Clear is an intentionally destructive "reset" action. Making it undoable would create confusion about what "fresh start" means.
|
||||||
|
- The existing clear encounter flow already has a confirmation dialog, providing sufficient protection against accidents.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- **Make clear undoable**: Would require keeping the pre-clear state in a special recovery slot. Adds complexity for a scenario already guarded by confirmation. Rejected per spec.
|
||||||
123
specs/037-undo-redo/spec.md
Normal file
123
specs/037-undo-redo/spec.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# Feature Specification: Undo/Redo
|
||||||
|
|
||||||
|
**Feature Branch**: `037-undo-redo`
|
||||||
|
**Created**: 2026-03-26
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: Gitea issue #16 — Undo/redo for encounter actions
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Undo a Mistake (Priority: P1)
|
||||||
|
|
||||||
|
A DM accidentally removes the wrong combatant, changes HP incorrectly, or advances the turn too early. They press an undo button (or keyboard shortcut) and the encounter returns to exactly the state it was in before that action.
|
||||||
|
|
||||||
|
**Why this priority**: Mistakes during live combat are stressful and time-sensitive. Undo is the core value proposition — without it, the DM must manually reconstruct state, which disrupts the game.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by performing any encounter action, pressing undo, and verifying the encounter matches its pre-action state.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an encounter with 3 combatants, **When** the user removes a combatant and clicks Undo, **Then** the removed combatant reappears in the same position with all its stats intact.
|
||||||
|
2. **Given** a combatant with 30/45 HP, **When** the user adjusts HP to 20/45 and presses Undo, **Then** the combatant's HP returns to 30/45.
|
||||||
|
3. **Given** an encounter on round 3 turn 2, **When** the user advances the turn and presses Undo, **Then** the encounter returns to round 3 turn 2.
|
||||||
|
4. **Given** the undo stack is empty, **When** the user looks at the Undo button, **Then** it appears disabled and cannot be activated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Redo an Undone Action (Priority: P2)
|
||||||
|
|
||||||
|
A DM presses undo but then realizes the original action was correct. They press redo to restore the undone change rather than re-entering it manually.
|
||||||
|
|
||||||
|
**Why this priority**: Redo complements undo — without it, undoing too far forces manual re-entry. Lower priority than undo because redo is used less frequently.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by performing an action, undoing it, then redoing it, and verifying the state matches the post-action state.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the user has undone an HP adjustment, **When** they click Redo, **Then** the HP adjustment is reapplied exactly.
|
||||||
|
2. **Given** the user has undone two actions, **When** they click Redo twice, **Then** both actions are reapplied in order.
|
||||||
|
3. **Given** the user has undone an action and then performs a new action, **When** they look at the Redo button, **Then** it is disabled (the redo stack was cleared by the new action).
|
||||||
|
4. **Given** the redo stack is empty, **When** the user looks at the Redo button, **Then** it appears disabled and cannot be activated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Keyboard Shortcuts (Priority: P3)
|
||||||
|
|
||||||
|
A DM who prefers keyboard interaction can undo with Ctrl+Z (Cmd+Z on Mac) and redo with Ctrl+Shift+Z (Cmd+Shift+Z on Mac) without reaching for buttons.
|
||||||
|
|
||||||
|
**Why this priority**: Keyboard shortcuts are a convenience layer. The feature is fully usable via buttons alone, so shortcuts are an enhancement.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by pressing the keyboard shortcut and verifying the same behavior as the button.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the undo stack has entries, **When** the user presses Ctrl+Z (Cmd+Z on Mac), **Then** the most recent action is undone.
|
||||||
|
2. **Given** the redo stack has entries, **When** the user presses Ctrl+Shift+Z (Cmd+Shift+Z on Mac), **Then** the most recent undo is redone.
|
||||||
|
3. **Given** an input field or textarea has focus, **When** the user presses Ctrl+Z, **Then** the browser's native text undo fires instead of encounter undo.
|
||||||
|
4. **Given** no input has focus and the undo stack is empty, **When** the user presses Ctrl+Z, **Then** nothing happens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 4 - Undo History Survives Refresh (Priority: P4)
|
||||||
|
|
||||||
|
A DM refreshes the page (or the browser restores the tab) and can still undo/redo recent actions, so history is not lost to accidental navigation.
|
||||||
|
|
||||||
|
**Why this priority**: Persistence is important for reliability but is secondary to the core undo/redo mechanics working correctly in-session.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by performing actions, refreshing the page, and verifying the undo/redo buttons reflect the pre-refresh stack state.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the user has performed 5 actions, **When** they refresh the page, **Then** the undo stack contains 5 entries and undo works correctly.
|
||||||
|
2. **Given** the user has undone 2 of 5 actions, **When** they refresh the page, **Then** the undo stack has 3 entries and the redo stack has 2 entries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when the undo stack reaches 50 entries? The oldest entry is dropped silently; the user can still undo the 50 most recent actions.
|
||||||
|
- What happens when the user clears the encounter? Both undo and redo stacks are reset to empty; there is no way to undo a clear.
|
||||||
|
- What happens when the user performs a new action after undoing? The redo stack is cleared entirely; the new action becomes the latest history entry.
|
||||||
|
- What happens if localStorage is full and the stacks cannot be persisted? The stacks continue to work in-memory for the current session; persistence is best-effort.
|
||||||
|
- What happens if persisted stack data is corrupt or invalid on load? The stacks start empty; the encounter itself loads normally from its own storage.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: System MUST capture a snapshot of the current encounter state before each state transition and push it onto the undo stack.
|
||||||
|
- **FR-002**: System MUST cap the undo stack at 50 entries, dropping the oldest entry when the cap is exceeded.
|
||||||
|
- **FR-003**: When the user triggers undo, system MUST restore the encounter to the most recent snapshot from the undo stack and push the current state onto the redo stack.
|
||||||
|
- **FR-004**: When the user triggers redo, system MUST restore the encounter to the most recent snapshot from the redo stack and push the current state onto the undo stack.
|
||||||
|
- **FR-005**: When the user performs any new encounter action (not undo/redo), system MUST clear the redo stack.
|
||||||
|
- **FR-006**: System MUST persist the undo and redo stacks to localStorage and restore them on page load.
|
||||||
|
- **FR-007**: System MUST display Undo and Redo buttons in the UI that are disabled when their respective stacks are empty.
|
||||||
|
- **FR-008**: System MUST support Ctrl+Z / Cmd+Z for undo and Ctrl+Shift+Z / Cmd+Shift+Z for redo.
|
||||||
|
- **FR-009**: System MUST suppress encounter undo/redo keyboard shortcuts when an input, textarea, or other text-editable element has focus, allowing native browser text editing behavior.
|
||||||
|
- **FR-010**: When the encounter is cleared, system MUST reset both undo and redo stacks to empty.
|
||||||
|
- **FR-011**: Undo/redo MUST operate on the full encounter snapshot (memento pattern), not on individual field changes.
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **Undo Stack**: An ordered collection of encounter snapshots (most recent last), capped at 50 entries. Each entry is a complete encounter state captured before a state transition.
|
||||||
|
- **Redo Stack**: An ordered collection of encounter snapshots accumulated by undo operations. Cleared when any new (non-undo/redo) action occurs.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: Users can reverse any single encounter action within 1 second via button or keyboard shortcut.
|
||||||
|
- **SC-002**: Users can undo and redo up to 50 sequential actions without data loss or state corruption.
|
||||||
|
- **SC-003**: Undo/redo history is preserved across page refresh with no user intervention.
|
||||||
|
- **SC-004**: Keyboard shortcuts do not interfere with native text editing in input fields.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The encounter data structure is small enough (tens of combatants) that storing 50 full snapshots in memory and localStorage is practical.
|
||||||
|
- The dependency on #15 (atomic addCombatant) is resolved, so each user action maps to exactly one snapshot.
|
||||||
|
- Player character template state is managed separately and is not part of the undo/redo scope — only encounter state is tracked.
|
||||||
|
- "Clear encounter" is an intentionally destructive action that should not be undoable, matching user expectations for a "reset" operation.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- **#15 (Atomic addCombatant)**: Required so compound operations (add from bestiary, add from player character) produce a single state transition and thus a single undo entry.
|
||||||
159
specs/037-undo-redo/tasks.md
Normal file
159
specs/037-undo-redo/tasks.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# Tasks: Undo/Redo
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/037-undo-redo/`
|
||||||
|
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md
|
||||||
|
|
||||||
|
**Tests**: Domain tests included (pure function testing is standard for this project per CLAUDE.md).
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story] Description`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||||
|
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||||
|
- Include exact file paths in descriptions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Foundational (Domain + Application Layer)
|
||||||
|
|
||||||
|
**Purpose**: Pure domain logic and application ports that all user stories depend on
|
||||||
|
|
||||||
|
**CRITICAL**: No user story work can begin until this phase is complete
|
||||||
|
|
||||||
|
- [x] T001 Define `UndoRedoState` type and pure stack functions (`pushUndo`, `undo`, `redo`, `clearHistory`) in `packages/domain/src/undo-redo.ts`. All functions take and return immutable data per data-model.md state transitions. Cap undo stack at 50 entries (drop oldest). Return `DomainError` for empty-stack operations.
|
||||||
|
- [x] T002 Add unit tests for all stack functions in `packages/domain/src/__tests__/undo-redo.test.ts`. Cover: push adds to stack, cap at 50 drops oldest, push clears redo stack, undo pops and moves to redo, redo pops and moves to undo, undo on empty returns error, redo on empty returns error, clearHistory empties both, undo-then-redo roundtrip returns exact same encounter. Application use case tests are not needed separately — the use cases are thin orchestration and their logic is fully covered by domain tests + integration via the hook.
|
||||||
|
- [x] T003 [P] Add `UndoRedoStore` port interface (`get(): UndoRedoState`, `save(state: UndoRedoState): void`) to `packages/application/src/ports.ts`.
|
||||||
|
- [x] T004 [P] Implement `undoUseCase(encounterStore, undoRedoStore)` in `packages/application/src/undo-use-case.ts`. Calls domain `undo()` with current encounter, saves resulting encounter to encounterStore and resulting state to undoRedoStore.
|
||||||
|
- [x] T005 [P] Implement `redoUseCase(encounterStore, undoRedoStore)` in `packages/application/src/redo-use-case.ts`. Same pattern as undo but calls domain `redo()`.
|
||||||
|
|
||||||
|
**Checkpoint**: Domain logic and use cases complete. All pure functions tested. Ready for adapter layer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: User Story 1 - Undo a Mistake (Priority: P1) MVP
|
||||||
|
|
||||||
|
**Goal**: User can undo any encounter action via a button in the top bar.
|
||||||
|
|
||||||
|
**Independent Test**: Perform any encounter action (add combatant, adjust HP, advance turn), click Undo, verify encounter returns to pre-action state. Undo button is disabled when stack is empty.
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T006 [US1] Add undo/redo state management to `apps/web/src/hooks/use-encounter.ts`: add `UndoRedoState` to hook state (initialized empty), create `makeUndoRedoStore()` factory (same pattern as `makeStore()`), create a `withUndo` wrapper function that captures the pre-action encounter snapshot and calls `pushUndo` on the undo/redo state after a successful action. Wrap all existing action callbacks with `withUndo`.
|
||||||
|
- [x] T007 [US1] Add `undo` callback to `apps/web/src/hooks/use-encounter.ts` that calls `undoUseCase` and updates both encounter and undo/redo state. Expose `canUndo: boolean` (derived from undo stack length > 0).
|
||||||
|
- [x] T008 [US1] Update `apps/web/src/contexts/encounter-context.tsx` to expose `undo`, `canUndo` from the encounter hook return type.
|
||||||
|
- [x] T009 [US1] Add Undo button to `apps/web/src/components/turn-navigation.tsx`, placed inboard of (to the right of) the Previous Turn button. Use `Undo2` icon from Lucide React. Button is disabled when `canUndo` is false. Calls `undo()` from encounter context on click.
|
||||||
|
|
||||||
|
**Checkpoint**: Undo works for all encounter actions via button. Redo not yet available.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 2 - Redo an Undone Action (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: User can redo an undone action via a button. New actions clear the redo stack.
|
||||||
|
|
||||||
|
**Independent Test**: Perform an action, undo it, click Redo, verify state matches post-action. Then perform a new action and verify Redo button becomes disabled.
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T010 [US2] Add `redo` callback to `apps/web/src/hooks/use-encounter.ts` that calls `redoUseCase` and updates both encounter and undo/redo state. Expose `canRedo: boolean` (derived from redo stack length > 0). Verify that `pushUndo` (called by `withUndo` wrapper from T006) already clears the redo stack per domain logic — no additional work needed for FR-005.
|
||||||
|
- [x] T011 [US2] Update `apps/web/src/contexts/encounter-context.tsx` to expose `redo`, `canRedo` from the encounter hook return type.
|
||||||
|
- [x] T012 [US2] Add Redo button to `apps/web/src/components/turn-navigation.tsx`, placed next to the Undo button (both inboard of turn step buttons). Use `Redo2` icon from Lucide React. Button is disabled when `canRedo` is false. Calls `redo()` from encounter context on click.
|
||||||
|
|
||||||
|
**Checkpoint**: Full undo/redo via buttons. Keyboard shortcuts and persistence not yet available.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 3 - Keyboard Shortcuts (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Ctrl+Z / Cmd+Z triggers undo; Ctrl+Shift+Z / Cmd+Shift+Z triggers redo. Suppressed when text input has focus.
|
||||||
|
|
||||||
|
**Independent Test**: Press Ctrl+Z with no input focused — encounter undoes. Focus an input field, press Ctrl+Z — browser native text undo fires. Press Ctrl+Shift+Z — encounter redoes.
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [x] T013 [US3] Create `apps/web/src/hooks/use-undo-redo-shortcuts.ts`. Register a `keydown` event listener on `document`. Detect Ctrl+Z / Cmd+Z (undo) and Ctrl+Shift+Z / Cmd+Shift+Z (redo). Before dispatching, check `document.activeElement` — suppress if tag is `INPUT`, `TEXTAREA`, `SELECT`, or element has `contentEditable`. Call `preventDefault()` only when handling the shortcut. Accept `undo`, `redo`, `canUndo`, `canRedo` as parameters.
|
||||||
|
- [x] T014 [US3] Wire `useUndoRedoShortcuts` into the encounter provider layer. Call the hook from inside `EncounterProvider` in `apps/web/src/contexts/encounter-context.tsx` (or from `App.tsx` if context structure makes that cleaner), passing undo/redo callbacks and flags from the encounter hook.
|
||||||
|
|
||||||
|
**Checkpoint**: Full undo/redo via buttons and keyboard shortcuts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 4 - Undo History Survives Refresh (Priority: P4)
|
||||||
|
|
||||||
|
**Goal**: Undo/redo stacks persist to localStorage and restore on page load.
|
||||||
|
|
||||||
|
**Independent Test**: Perform 5 actions, refresh the page, verify undo button is enabled and clicking it 5 times restores each previous state.
|
||||||
|
|
||||||
|
### Implementation for User Story 4
|
||||||
|
|
||||||
|
- [x] T015 [P] [US4] Create `apps/web/src/persistence/undo-redo-storage.ts`. Implement `saveUndoRedoStacks(undoStack, redoStack)` and `loadUndoRedoStacks()`. Use localStorage keys `"initiative:encounter:undo"` and `"initiative:encounter:redo"`. Reuse existing encounter rehydration/validation logic from `encounter-storage.ts` for each stack entry. Silently swallow write errors (quota exceeded). Return empty stacks on corrupt/invalid data.
|
||||||
|
- [x] T016 [US4] Integrate persistence into `apps/web/src/hooks/use-encounter.ts`: initialize undo/redo state from `loadUndoRedoStacks()` on mount. Add a `useEffect` that calls `saveUndoRedoStacks()` whenever undo/redo state changes (same pattern as existing encounter persistence).
|
||||||
|
|
||||||
|
**Checkpoint**: Full feature complete — undo/redo via buttons, keyboard shortcuts, persisted across refresh.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Edge cases and quality gates
|
||||||
|
|
||||||
|
- [x] T017 Ensure clear encounter resets undo/redo stacks in `apps/web/src/hooks/use-encounter.ts`. In the `clearEncounter` callback, call `clearHistory()` on the undo/redo state after clearing the encounter. Verify this also clears persisted stacks via the useEffect.
|
||||||
|
- [x] T018 Run `pnpm check` and fix any lint, type, coverage, or unused-code issues. Ensure layer boundary check passes (domain must not import from web/application, application must not import from web).
|
||||||
|
- [x] T019 Update README.md to document undo/redo capability (buttons + keyboard shortcuts). Per constitution, user-facing feature changes MUST be reflected in README.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Foundational (Phase 1)**: No dependencies — can start immediately
|
||||||
|
- **US1 Undo (Phase 2)**: Depends on Foundational completion
|
||||||
|
- **US2 Redo (Phase 3)**: Depends on US1 (undo must exist before redo makes sense)
|
||||||
|
- **US3 Shortcuts (Phase 4)**: Depends on US2 (needs both undo and redo callbacks)
|
||||||
|
- **US4 Persistence (Phase 5)**: Depends on US1 (needs undo/redo state to exist). Can run in parallel with US2/US3 if needed.
|
||||||
|
- **Polish (Phase 6)**: Depends on all user stories being complete
|
||||||
|
|
||||||
|
### Within Foundational Phase
|
||||||
|
|
||||||
|
- T001 (domain functions) must complete before T002 (tests)
|
||||||
|
- T003 (port) must complete before T004/T005 (use cases import `UndoRedoStore` from ports.ts)
|
||||||
|
- T004 (undo use case) and T005 (redo use case) can run in parallel after T001 + T003
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- T004, T005 can run in parallel (different files, depend on T001 + T003)
|
||||||
|
- T015 (persistence storage) can run in parallel with any Phase 3-4 work (different file)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Foundational (domain + application)
|
||||||
|
2. Complete Phase 2: User Story 1 (undo via button)
|
||||||
|
3. **STOP and VALIDATE**: Test undo independently with all encounter actions
|
||||||
|
4. Demo if ready — undo alone delivers significant value
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Foundational → Domain logic tested, ready for integration
|
||||||
|
2. Add US1 (Undo) → Button works → Validate independently
|
||||||
|
3. Add US2 (Redo) → Both buttons work → Validate independently
|
||||||
|
4. Add US3 (Shortcuts) → Keyboard works → Validate independently
|
||||||
|
5. Add US4 (Persistence) → Refresh-safe → Validate independently
|
||||||
|
6. Polish → Quality gates pass → Ready to merge
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- [P] tasks = different files, no dependencies
|
||||||
|
- [Story] label maps task to specific user story for traceability
|
||||||
|
- US2 (Redo) depends on US1 (Undo) — redo is meaningless without undo
|
||||||
|
- US3 (Shortcuts) and US4 (Persistence) are independent of each other but both need US1
|
||||||
|
- The `withUndo` wrapper in T006 is the key integration point — it captures snapshots for ALL existing actions in one place
|
||||||
|
- Domain tests (T002) validate all invariants from data-model.md
|
||||||
|
- Commit after each phase checkpoint
|
||||||
Reference in New Issue
Block a user