Files
initiative/apps/web/src/hooks/use-bulk-import.ts
Lukas e62c49434c
All checks were successful
CI / check (push) Successful in 2m21s
CI / build-image (push) Successful in 24s
Add Pathfinder 2e game system mode
Implements PF2e as an alternative game system alongside D&D 5e/5.5e.
Settings modal "Game System" selector switches conditions, bestiary,
stat block layout, and initiative calculation between systems.

- Valued conditions with increment/decrement UX (Clumsy 2, Frightened 3)
- 2,502 PF2e creatures from bundled search index (77 sources)
- PF2e stat block: level, traits, Perception, Fort/Ref/Will, ability mods
- Perception-based initiative rolling
- System-scoped source cache (D&D and PF2e sources don't collide)
- Backwards-compatible condition rehydration (ConditionId[] → ConditionEntry[])
- Difficulty indicator hidden in PF2e mode (excluded from MVP)

Closes dostulata/initiative#19

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 01:26:22 +02:00

130 lines
3.4 KiB
TypeScript

import { useCallback, useRef, useState } from "react";
import { useAdapters } from "../contexts/adapter-context.js";
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
const BATCH_SIZE = 6;
interface BulkImportState {
readonly status: "idle" | "loading" | "complete" | "partial-failure";
readonly total: number;
readonly completed: number;
readonly failed: number;
}
const IDLE_STATE: BulkImportState = {
status: "idle",
total: 0,
completed: 0,
failed: 0,
};
interface BulkImportHook {
state: BulkImportState;
startImport: (
baseUrl: string,
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
isSourceCached: (sourceCode: string) => Promise<boolean>,
refreshCache: () => Promise<void>,
) => void;
reset: () => void;
}
export function useBulkImport(): BulkImportHook {
const { bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
const { edition } = useRulesEditionContext();
const indexPort = edition === "pf2e" ? pf2eBestiaryIndex : bestiaryIndex;
const [state, setState] = useState<BulkImportState>(IDLE_STATE);
const countersRef = useRef({ completed: 0, failed: 0 });
const startImport = useCallback(
(
baseUrl: string,
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
isSourceCached: (sourceCode: string) => Promise<boolean>,
refreshCache: () => Promise<void>,
) => {
const allCodes = indexPort.getAllSourceCodes();
const total = allCodes.length;
countersRef.current = { completed: 0, failed: 0 };
setState({ status: "loading", total, completed: 0, failed: 0 });
void (async () => {
const cacheChecks = await Promise.all(
allCodes.map(async (code) => ({
code,
cached: await isSourceCached(code),
})),
);
const alreadyCached = cacheChecks.filter((c) => c.cached).length;
const uncached = cacheChecks.filter((c) => !c.cached);
countersRef.current.completed = alreadyCached;
if (uncached.length === 0) {
setState({
status: "complete",
total,
completed: total,
failed: 0,
});
return;
}
setState((s) => ({ ...s, completed: alreadyCached }));
const batches: { code: string }[][] = [];
for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
batches.push(uncached.slice(i, i + BATCH_SIZE));
}
await batches.reduce(
(chain, batch) =>
chain.then(() =>
Promise.allSettled(
batch.map(async ({ code }) => {
const url = indexPort.getDefaultFetchUrl(code, baseUrl);
try {
await fetchAndCacheSource(code, url);
countersRef.current.completed++;
} catch (err) {
countersRef.current.failed++;
console.warn(
`[bulk-import] FAILED ${code} (${url}):`,
err instanceof Error ? err.message : err,
);
}
setState({
status: "loading",
total,
completed: countersRef.current.completed,
failed: countersRef.current.failed,
});
}),
),
),
Promise.resolve() as Promise<unknown>,
);
await refreshCache();
const { completed, failed } = countersRef.current;
setState({
status: failed > 0 ? "partial-failure" : "complete",
total,
completed,
failed,
});
})();
},
[indexPort],
);
const reset = useCallback(() => {
setState(IDLE_STATE);
}, []);
return { state, startImport, reset };
}