Refactor useEncounter from useState to useReducer

Replaces 18 useCallback wrappers with a typed action union and
encounterReducer. Undo/redo wrapping is now systematic per-case in
the reducer instead of ad-hoc per operation. Complex cases (undo/redo,
bestiary add, player character add) are extracted into helper functions.

The stat block auto-show on bestiary add now uses lastCreatureId from
reducer state instead of the synchronous return value, with a useEffect
in use-action-bar-state to react to changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-28 18:41:40 +01:00
parent 896fd427ed
commit 80dd68752e
3 changed files with 460 additions and 292 deletions

View File

@@ -1,5 +1,12 @@
import type { CreatureId, PlayerCharacter } from "@initiative/domain";
import { useCallback, useDeferredValue, useMemo, useState } from "react";
import {
useCallback,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type { SearchResult } from "../contexts/bestiary-context.js";
import { useBestiaryContext } from "../contexts/bestiary-context.js";
import { useEncounterContext } from "../contexts/encounter-context.js";
@@ -31,6 +38,7 @@ export function useActionBarState() {
addFromBestiary,
addMultipleFromBestiary,
addFromPlayerCharacter,
lastCreatureId,
} = useEncounterContext();
const { search: bestiarySearch, isLoaded: bestiaryLoaded } =
useBestiaryContext();
@@ -38,6 +46,20 @@ export function useActionBarState() {
const { showBulkImport, showSourceManager, showCreature, panelView } =
useSidePanelContext();
// Auto-show stat block when a bestiary creature is added on desktop
const prevCreatureIdRef = useRef(lastCreatureId);
useEffect(() => {
if (
lastCreatureId &&
lastCreatureId !== prevCreatureIdRef.current &&
panelView.mode === "closed" &&
globalThis.matchMedia("(min-width: 1024px)").matches
) {
showCreature(lastCreatureId);
}
prevCreatureIdRef.current = lastCreatureId;
}, [lastCreatureId, panelView.mode, showCreature]);
const [nameInput, setNameInput] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
@@ -73,13 +95,9 @@ export function useActionBarState() {
const handleAddFromBestiary = useCallback(
(result: SearchResult) => {
const creatureId = addFromBestiary(result);
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
if (creatureId && panelView.mode === "closed" && isDesktop) {
showCreature(creatureId);
}
addFromBestiary(result);
},
[addFromBestiary, panelView.mode, showCreature],
[addFromBestiary],
);
const handleViewStatBlock = useCallback(
@@ -99,21 +117,10 @@ export function useActionBarState() {
if (queued.count === 1) {
handleAddFromBestiary(queued.result);
} else {
const creatureId = addMultipleFromBestiary(queued.result, queued.count);
const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
if (creatureId && panelView.mode === "closed" && isDesktop) {
showCreature(creatureId);
}
addMultipleFromBestiary(queued.result, queued.count);
}
clearInput();
}, [
queued,
handleAddFromBestiary,
addMultipleFromBestiary,
panelView.mode,
showCreature,
clearInput,
]);
}, [queued, handleAddFromBestiary, addMultipleFromBestiary, clearInput]);
const parseNum = (v: string): number | undefined => {
if (v.trim() === "") return undefined;