Compare commits
3 Commits
99d1ba1bcd
...
94d125d9c4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94d125d9c4 | ||
|
|
c323adc343 | ||
|
|
91120d7c82 |
@@ -81,6 +81,9 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
|
|||||||
- N/A (no storage changes — existing localStorage persistence handles initiative via `setInitiativeUseCase`) (026-roll-initiative)
|
- N/A (no storage changes — existing localStorage persistence handles initiative via `setInitiativeUseCase`) (026-roll-initiative)
|
||||||
- N/A (no storage changes — purely presentational) (027-ui-polish)
|
- N/A (no storage changes — purely presentational) (027-ui-polish)
|
||||||
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Tailwind CSS v4, Vite 6 + Tailwind CSS v4 (CSS-native `@theme` theming), Lucide React (icons) (028-semantic-hover-tokens)
|
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Tailwind CSS v4, Vite 6 + Tailwind CSS v4 (CSS-native `@theme` theming), Lucide React (icons) (028-semantic-hover-tokens)
|
||||||
|
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Tailwind CSS v4, Lucide React (icons), idb (IndexedDB wrapper) (029-on-demand-bestiary)
|
||||||
|
- IndexedDB for cached source data (new); localStorage for encounter persistence (existing, unchanged) (029-on-demand-bestiary)
|
||||||
|
- IndexedDB for cached source data (existing via `bestiary-cache.ts`); localStorage for encounter persistence (existing, unchanged) (030-bulk-import-sources)
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 003-remove-combatant: Added TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite
|
- 003-remove-combatant: Added TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@initiative/domain": "workspace:*",
|
"@initiative/domain": "workspace:*",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"idb": "^8.0.3",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { ActionBar } from "./components/action-bar";
|
import { ActionBar } from "./components/action-bar";
|
||||||
import { CombatantRow } from "./components/combatant-row";
|
import { CombatantRow } from "./components/combatant-row";
|
||||||
|
import { SourceManager } from "./components/source-manager";
|
||||||
import { StatBlockPanel } from "./components/stat-block-panel";
|
import { StatBlockPanel } from "./components/stat-block-panel";
|
||||||
|
import { Toast } from "./components/toast";
|
||||||
import { TurnNavigation } from "./components/turn-navigation";
|
import { TurnNavigation } from "./components/turn-navigation";
|
||||||
import { useBestiary } from "./hooks/use-bestiary";
|
import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
|
||||||
|
import { useBulkImport } from "./hooks/use-bulk-import";
|
||||||
import { useEncounter } from "./hooks/use-encounter";
|
import { useEncounter } from "./hooks/use-encounter";
|
||||||
|
|
||||||
function rollDice(): number {
|
function rollDice(): number {
|
||||||
@@ -34,44 +37,46 @@ export function App() {
|
|||||||
makeStore,
|
makeStore,
|
||||||
} = useEncounter();
|
} = useEncounter();
|
||||||
|
|
||||||
const { search, getCreature, isLoaded } = useBestiary();
|
const {
|
||||||
|
search,
|
||||||
|
getCreature,
|
||||||
|
isLoaded,
|
||||||
|
isSourceCached,
|
||||||
|
fetchAndCacheSource,
|
||||||
|
uploadAndCacheSource,
|
||||||
|
refreshCache,
|
||||||
|
} = useBestiary();
|
||||||
|
|
||||||
const [selectedCreature, setSelectedCreature] = useState<Creature | null>(
|
const bulkImport = useBulkImport();
|
||||||
null,
|
|
||||||
);
|
const [selectedCreatureId, setSelectedCreatureId] =
|
||||||
const [suggestions, setSuggestions] = useState<Creature[]>([]);
|
useState<CreatureId | null>(null);
|
||||||
|
const [bulkImportMode, setBulkImportMode] = useState(false);
|
||||||
|
const [sourceManagerOpen, setSourceManagerOpen] = useState(false);
|
||||||
|
|
||||||
|
const selectedCreature: Creature | null = selectedCreatureId
|
||||||
|
? (getCreature(selectedCreatureId) ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
const handleAddFromBestiary = useCallback(
|
const handleAddFromBestiary = useCallback(
|
||||||
(creature: Creature) => {
|
(result: SearchResult) => {
|
||||||
addFromBestiary(creature);
|
addFromBestiary(result);
|
||||||
setSelectedCreature(creature);
|
// Derive the creature ID so stat block panel can try to show it
|
||||||
|
const slug = result.name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/(^-|-$)/g, "");
|
||||||
|
setSelectedCreatureId(
|
||||||
|
`${result.source.toLowerCase()}:${slug}` as CreatureId,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[addFromBestiary],
|
[addFromBestiary],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleShowStatBlock = useCallback((creature: Creature) => {
|
const handleCombatantStatBlock = useCallback((creatureId: string) => {
|
||||||
setSelectedCreature(creature);
|
setSelectedCreatureId(creatureId as CreatureId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCombatantStatBlock = useCallback(
|
|
||||||
(creatureId: string) => {
|
|
||||||
const creature = getCreature(creatureId as CreatureId);
|
|
||||||
if (creature) setSelectedCreature(creature);
|
|
||||||
},
|
|
||||||
[getCreature],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSearchChange = useCallback(
|
|
||||||
(query: string) => {
|
|
||||||
if (!isLoaded || query.length < 2) {
|
|
||||||
setSuggestions([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSuggestions(search(query));
|
|
||||||
},
|
|
||||||
[isLoaded, search],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRollInitiative = useCallback(
|
const handleRollInitiative = useCallback(
|
||||||
(id: CombatantId) => {
|
(id: CombatantId) => {
|
||||||
rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature);
|
rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature);
|
||||||
@@ -83,6 +88,28 @@ export function App() {
|
|||||||
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
||||||
}, [makeStore, getCreature]);
|
}, [makeStore, getCreature]);
|
||||||
|
|
||||||
|
const handleBulkImport = useCallback(() => {
|
||||||
|
setBulkImportMode(true);
|
||||||
|
setSelectedCreatureId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleStartBulkImport = useCallback(
|
||||||
|
(baseUrl: string) => {
|
||||||
|
bulkImport.startImport(
|
||||||
|
baseUrl,
|
||||||
|
fetchAndCacheSource,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[bulkImport.startImport, fetchAndCacheSource, isSourceCached, refreshCache],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBulkImportDone = useCallback(() => {
|
||||||
|
setBulkImportMode(false);
|
||||||
|
bulkImport.reset();
|
||||||
|
}, [bulkImport.reset]);
|
||||||
|
|
||||||
// Auto-scroll to the active combatant when the turn changes
|
// Auto-scroll to the active combatant when the turn changes
|
||||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -102,9 +129,8 @@ export function App() {
|
|||||||
if (!window.matchMedia("(min-width: 1024px)").matches) return;
|
if (!window.matchMedia("(min-width: 1024px)").matches) return;
|
||||||
const active = encounter.combatants[encounter.activeIndex];
|
const active = encounter.combatants[encounter.activeIndex];
|
||||||
if (!active?.creatureId || !isLoaded) return;
|
if (!active?.creatureId || !isLoaded) return;
|
||||||
const creature = getCreature(active.creatureId as CreatureId);
|
setSelectedCreatureId(active.creatureId as CreatureId);
|
||||||
if (creature) setSelectedCreature(creature);
|
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
|
||||||
}, [encounter.activeIndex, encounter.combatants, getCreature, isLoaded]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-screen flex-col">
|
||||||
@@ -117,9 +143,16 @@ export function App() {
|
|||||||
onRetreatTurn={retreatTurn}
|
onRetreatTurn={retreatTurn}
|
||||||
onClearEncounter={clearEncounter}
|
onClearEncounter={clearEncounter}
|
||||||
onRollAllInitiative={handleRollAllInitiative}
|
onRollAllInitiative={handleRollAllInitiative}
|
||||||
|
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{sourceManagerOpen && (
|
||||||
|
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
|
||||||
|
<SourceManager onCacheCleared={refreshCache} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Scrollable area — combatant list */}
|
{/* Scrollable area — combatant list */}
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
<div className="flex flex-col pb-2">
|
<div className="flex flex-col pb-2">
|
||||||
@@ -163,18 +196,56 @@ export function App() {
|
|||||||
onAddFromBestiary={handleAddFromBestiary}
|
onAddFromBestiary={handleAddFromBestiary}
|
||||||
bestiarySearch={search}
|
bestiarySearch={search}
|
||||||
bestiaryLoaded={isLoaded}
|
bestiaryLoaded={isLoaded}
|
||||||
suggestions={suggestions}
|
onBulkImport={handleBulkImport}
|
||||||
onSearchChange={handleSearchChange}
|
bulkImportDisabled={bulkImport.state.status === "loading"}
|
||||||
onShowStatBlock={handleShowStatBlock}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stat Block Panel */}
|
{/* Stat Block Panel */}
|
||||||
<StatBlockPanel
|
<StatBlockPanel
|
||||||
|
creatureId={selectedCreatureId}
|
||||||
creature={selectedCreature}
|
creature={selectedCreature}
|
||||||
onClose={() => setSelectedCreature(null)}
|
isSourceCached={isSourceCached}
|
||||||
|
fetchAndCacheSource={fetchAndCacheSource}
|
||||||
|
uploadAndCacheSource={uploadAndCacheSource}
|
||||||
|
refreshCache={refreshCache}
|
||||||
|
onClose={() => {
|
||||||
|
setSelectedCreatureId(null);
|
||||||
|
setBulkImportMode(false);
|
||||||
|
}}
|
||||||
|
bulkImportMode={bulkImportMode}
|
||||||
|
bulkImportState={bulkImport.state}
|
||||||
|
onStartBulkImport={handleStartBulkImport}
|
||||||
|
onBulkImportDone={handleBulkImportDone}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Toast for bulk import progress when panel is closed */}
|
||||||
|
{bulkImport.state.status === "loading" && !bulkImportMode && (
|
||||||
|
<Toast
|
||||||
|
message={`Loading sources... ${bulkImport.state.completed + bulkImport.state.failed}/${bulkImport.state.total}`}
|
||||||
|
progress={
|
||||||
|
bulkImport.state.total > 0
|
||||||
|
? (bulkImport.state.completed + bulkImport.state.failed) /
|
||||||
|
bulkImport.state.total
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
onDismiss={() => {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{bulkImport.state.status === "complete" && !bulkImportMode && (
|
||||||
|
<Toast
|
||||||
|
message="All sources loaded"
|
||||||
|
onDismiss={bulkImport.reset}
|
||||||
|
autoDismissMs={3000}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{bulkImport.state.status === "partial-failure" && !bulkImportMode && (
|
||||||
|
<Toast
|
||||||
|
message={`Loaded ${bulkImport.state.completed}/${bulkImport.state.total} sources (${bulkImport.state.failed} failed)`}
|
||||||
|
onDismiss={bulkImport.reset}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
40
apps/web/src/__tests__/bestiary-index-helpers.test.ts
Normal file
40
apps/web/src/__tests__/bestiary-index-helpers.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getAllSourceCodes,
|
||||||
|
getDefaultFetchUrl,
|
||||||
|
} from "../adapters/bestiary-index-adapter.js";
|
||||||
|
|
||||||
|
describe("getAllSourceCodes", () => {
|
||||||
|
it("returns all keys from the index sources object", () => {
|
||||||
|
const codes = getAllSourceCodes();
|
||||||
|
expect(codes.length).toBeGreaterThan(0);
|
||||||
|
expect(Array.isArray(codes)).toBe(true);
|
||||||
|
for (const code of codes) {
|
||||||
|
expect(typeof code).toBe("string");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getDefaultFetchUrl", () => {
|
||||||
|
it("returns the default URL when no baseUrl is provided", () => {
|
||||||
|
const url = getDefaultFetchUrl("XMM");
|
||||||
|
expect(url).toBe(
|
||||||
|
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/bestiary-xmm.json",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("constructs URL from baseUrl with trailing slash", () => {
|
||||||
|
const url = getDefaultFetchUrl("PHB", "https://example.com/data/");
|
||||||
|
expect(url).toBe("https://example.com/data/bestiary-phb.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes baseUrl without trailing slash", () => {
|
||||||
|
const url = getDefaultFetchUrl("PHB", "https://example.com/data");
|
||||||
|
expect(url).toBe("https://example.com/data/bestiary-phb.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lowercases the source code in the filename", () => {
|
||||||
|
const url = getDefaultFetchUrl("MM", "https://example.com/");
|
||||||
|
expect(url).toBe("https://example.com/bestiary-mm.json");
|
||||||
|
});
|
||||||
|
});
|
||||||
235
apps/web/src/__tests__/use-bulk-import.test.ts
Normal file
235
apps/web/src/__tests__/use-bulk-import.test.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import * as indexAdapter from "../adapters/bestiary-index-adapter.js";
|
||||||
|
|
||||||
|
// We test the bulk import logic by extracting and exercising the async flow.
|
||||||
|
// Since useBulkImport is a thin React wrapper around async logic,
|
||||||
|
// we test the core behavior via a direct simulation.
|
||||||
|
|
||||||
|
vi.mock("../adapters/bestiary-index-adapter.js", async () => {
|
||||||
|
const actual = await vi.importActual<
|
||||||
|
typeof import("../adapters/bestiary-index-adapter.js")
|
||||||
|
>("../adapters/bestiary-index-adapter.js");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getAllSourceCodes: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockGetAllSourceCodes = vi.mocked(indexAdapter.getAllSourceCodes);
|
||||||
|
|
||||||
|
interface BulkImportState {
|
||||||
|
status: "idle" | "loading" | "complete" | "partial-failure";
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
failed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simulate the core bulk import logic extracted from the hook */
|
||||||
|
async function runBulkImport(
|
||||||
|
baseUrl: string,
|
||||||
|
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>,
|
||||||
|
isSourceCached: (sourceCode: string) => Promise<boolean>,
|
||||||
|
refreshCache: () => Promise<void>,
|
||||||
|
): Promise<BulkImportState> {
|
||||||
|
const allCodes = indexAdapter.getAllSourceCodes();
|
||||||
|
const total = allCodes.length;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (uncached.length === 0) {
|
||||||
|
return { status: "complete", total, completed: total, failed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let completed = alreadyCached;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
await Promise.allSettled(
|
||||||
|
uncached.map(async ({ code }) => {
|
||||||
|
const url = indexAdapter.getDefaultFetchUrl(code, baseUrl);
|
||||||
|
try {
|
||||||
|
await fetchAndCacheSource(code, url);
|
||||||
|
completed++;
|
||||||
|
} catch {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await refreshCache();
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: failed > 0 ? "partial-failure" : "complete",
|
||||||
|
total,
|
||||||
|
completed,
|
||||||
|
failed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("bulk import logic", () => {
|
||||||
|
it("skips already-cached sources and counts them into completed", async () => {
|
||||||
|
mockGetAllSourceCodes.mockReturnValue(["SRC1", "SRC2", "SRC3"]);
|
||||||
|
|
||||||
|
const fetchAndCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const isSourceCached = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((code: string) =>
|
||||||
|
Promise.resolve(code === "SRC1" || code === "SRC3"),
|
||||||
|
);
|
||||||
|
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await runBulkImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCache,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fetchAndCache).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchAndCache).toHaveBeenCalledWith(
|
||||||
|
"SRC2",
|
||||||
|
"https://example.com/bestiary-src2.json",
|
||||||
|
);
|
||||||
|
expect(result.completed).toBe(3);
|
||||||
|
expect(result.status).toBe("complete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments completed on successful fetch", async () => {
|
||||||
|
mockGetAllSourceCodes.mockReturnValue(["SRC1"]);
|
||||||
|
|
||||||
|
const fetchAndCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||||
|
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await runBulkImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCache,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.completed).toBe(1);
|
||||||
|
expect(result.failed).toBe(0);
|
||||||
|
expect(result.status).toBe("complete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments failed on rejected fetch", async () => {
|
||||||
|
mockGetAllSourceCodes.mockReturnValue(["SRC1"]);
|
||||||
|
|
||||||
|
const fetchAndCache = vi.fn().mockRejectedValue(new Error("fail"));
|
||||||
|
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||||
|
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await runBulkImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCache,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.failed).toBe(1);
|
||||||
|
expect(result.completed).toBe(0);
|
||||||
|
expect(result.status).toBe("partial-failure");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transitions to complete when all succeed", async () => {
|
||||||
|
mockGetAllSourceCodes.mockReturnValue(["A", "B"]);
|
||||||
|
|
||||||
|
const fetchAndCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||||
|
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await runBulkImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCache,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe("complete");
|
||||||
|
expect(result.completed).toBe(2);
|
||||||
|
expect(result.failed).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transitions to partial-failure when any fetch fails", async () => {
|
||||||
|
mockGetAllSourceCodes.mockReturnValue(["A", "B"]);
|
||||||
|
|
||||||
|
const fetchAndCache = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(undefined)
|
||||||
|
.mockRejectedValueOnce(new Error("fail"));
|
||||||
|
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||||
|
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await runBulkImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCache,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe("partial-failure");
|
||||||
|
expect(result.failed).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("immediately transitions to complete when all sources are cached", async () => {
|
||||||
|
mockGetAllSourceCodes.mockReturnValue(["A", "B", "C"]);
|
||||||
|
|
||||||
|
const fetchAndCache = vi.fn();
|
||||||
|
const isSourceCached = vi.fn().mockResolvedValue(true);
|
||||||
|
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await runBulkImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCache,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe("complete");
|
||||||
|
expect(result.total).toBe(3);
|
||||||
|
expect(result.completed).toBe(3);
|
||||||
|
expect(fetchAndCache).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls refreshCache exactly once when all settle", async () => {
|
||||||
|
mockGetAllSourceCodes.mockReturnValue(["A", "B"]);
|
||||||
|
|
||||||
|
const fetchAndCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const isSourceCached = vi.fn().mockResolvedValue(false);
|
||||||
|
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await runBulkImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCache,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(refreshCache).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call refreshCache when all sources are already cached", async () => {
|
||||||
|
mockGetAllSourceCodes.mockReturnValue(["A"]);
|
||||||
|
|
||||||
|
const fetchAndCache = vi.fn();
|
||||||
|
const isSourceCached = vi.fn().mockResolvedValue(true);
|
||||||
|
const refreshCache = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await runBulkImport(
|
||||||
|
"https://example.com/",
|
||||||
|
fetchAndCache,
|
||||||
|
isSourceCached,
|
||||||
|
refreshCache,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(refreshCache).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
import { normalizeBestiary } from "../bestiary-adapter.js";
|
import {
|
||||||
|
normalizeBestiary,
|
||||||
|
setSourceDisplayNames,
|
||||||
|
} from "../bestiary-adapter.js";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
setSourceDisplayNames({ XMM: "MM 2024" });
|
||||||
|
});
|
||||||
|
|
||||||
describe("normalizeBestiary", () => {
|
describe("normalizeBestiary", () => {
|
||||||
it("normalizes a simple creature", () => {
|
it("normalizes a simple creature", () => {
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import { expect, it } from "vitest";
|
|
||||||
import rawData from "../../../../../data/bestiary/xmm.json";
|
|
||||||
import { normalizeBestiary } from "../bestiary-adapter.js";
|
|
||||||
|
|
||||||
it("normalizes all 503 monsters without error", () => {
|
|
||||||
const creatures = normalizeBestiary(
|
|
||||||
rawData as unknown as Parameters<typeof normalizeBestiary>[0],
|
|
||||||
);
|
|
||||||
expect(creatures.length).toBe(503);
|
|
||||||
for (const c of creatures) {
|
|
||||||
expect(c.name).toBeTruthy();
|
|
||||||
expect(c.id).toBeTruthy();
|
|
||||||
expect(c.ac).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(c.hp.average).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -17,8 +17,8 @@ interface RawMonster {
|
|||||||
size: string[];
|
size: string[];
|
||||||
type: string | { type: string; tags?: string[]; swarmSize?: string };
|
type: string | { type: string; tags?: string[]; swarmSize?: string };
|
||||||
alignment?: string[];
|
alignment?: string[];
|
||||||
ac: (number | { ac: number; from?: string[] })[];
|
ac: (number | { ac: number; from?: string[] } | { special: string })[];
|
||||||
hp: { average: number; formula: string };
|
hp: { average?: number; formula?: string; special?: string };
|
||||||
speed: Record<
|
speed: Record<
|
||||||
string,
|
string,
|
||||||
number | { number: number; condition?: string } | boolean
|
number | { number: number; condition?: string } | boolean
|
||||||
@@ -38,7 +38,7 @@ interface RawMonster {
|
|||||||
vulnerable?: (string | { special: string })[];
|
vulnerable?: (string | { special: string })[];
|
||||||
conditionImmune?: string[];
|
conditionImmune?: string[];
|
||||||
languages?: string[];
|
languages?: string[];
|
||||||
cr: string | { cr: string };
|
cr?: string | { cr: string };
|
||||||
trait?: RawEntry[];
|
trait?: RawEntry[];
|
||||||
action?: RawEntry[];
|
action?: RawEntry[];
|
||||||
bonus?: RawEntry[];
|
bonus?: RawEntry[];
|
||||||
@@ -81,9 +81,11 @@ interface RawSpellcasting {
|
|||||||
|
|
||||||
// --- Source mapping ---
|
// --- Source mapping ---
|
||||||
|
|
||||||
const SOURCE_DISPLAY_NAMES: Record<string, string> = {
|
let sourceDisplayNames: Record<string, string> = {};
|
||||||
XMM: "MM 2024",
|
|
||||||
};
|
export function setSourceDisplayNames(names: Record<string, string>): void {
|
||||||
|
sourceDisplayNames = names;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Size mapping ---
|
// --- Size mapping ---
|
||||||
|
|
||||||
@@ -138,7 +140,12 @@ function formatType(
|
|||||||
|
|
||||||
let result = baseType;
|
let result = baseType;
|
||||||
if (type.tags && type.tags.length > 0) {
|
if (type.tags && type.tags.length > 0) {
|
||||||
result += ` (${type.tags.map(capitalize).join(", ")})`;
|
const tagStrs = type.tags
|
||||||
|
.filter((t): t is string => typeof t === "string")
|
||||||
|
.map(capitalize);
|
||||||
|
if (tagStrs.length > 0) {
|
||||||
|
result += ` (${tagStrs.join(", ")})`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (type.swarmSize) {
|
if (type.swarmSize) {
|
||||||
const swarmSizeLabel = SIZE_MAP[type.swarmSize] ?? type.swarmSize;
|
const swarmSizeLabel = SIZE_MAP[type.swarmSize] ?? type.swarmSize;
|
||||||
@@ -159,6 +166,14 @@ function extractAc(ac: RawMonster["ac"]): {
|
|||||||
if (typeof first === "number") {
|
if (typeof first === "number") {
|
||||||
return { value: first };
|
return { value: first };
|
||||||
}
|
}
|
||||||
|
if ("special" in first) {
|
||||||
|
// Variable AC (e.g. spell summons) — parse leading number if possible
|
||||||
|
const match = first.special.match(/^(\d+)/);
|
||||||
|
return {
|
||||||
|
value: match ? Number(match[1]) : 0,
|
||||||
|
source: first.special,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
value: first.ac,
|
value: first.ac,
|
||||||
source: first.from ? stripTags(first.from.join(", ")) : undefined,
|
source: first.from ? stripTags(first.from.join(", ")) : undefined,
|
||||||
@@ -337,7 +352,8 @@ function normalizeLegendary(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractCr(cr: string | { cr: string }): string {
|
function extractCr(cr: string | { cr: string } | undefined): string {
|
||||||
|
if (cr === undefined) return "—";
|
||||||
return typeof cr === "string" ? cr : cr.cr;
|
return typeof cr === "string" ? cr : cr.cr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +369,32 @@ function makeCreatureId(source: string, name: string): CreatureId {
|
|||||||
* Normalizes raw 5etools bestiary JSON into domain Creature[].
|
* Normalizes raw 5etools bestiary JSON into domain Creature[].
|
||||||
*/
|
*/
|
||||||
export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
|
export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
|
||||||
return raw.monster.map((m) => {
|
// Filter out _copy entries (reference another source's monster) and
|
||||||
|
// monsters missing required fields (ac, hp, size, type)
|
||||||
|
const monsters = raw.monster.filter((m) => {
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: raw JSON may have _copy field
|
||||||
|
if ((m as any)._copy) return false;
|
||||||
|
return (
|
||||||
|
Array.isArray(m.ac) &&
|
||||||
|
m.ac.length > 0 &&
|
||||||
|
m.hp !== undefined &&
|
||||||
|
Array.isArray(m.size) &&
|
||||||
|
m.size.length > 0 &&
|
||||||
|
m.type !== undefined
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const creatures: Creature[] = [];
|
||||||
|
for (const m of monsters) {
|
||||||
|
try {
|
||||||
|
creatures.push(normalizeMonster(m));
|
||||||
|
} catch {
|
||||||
|
// Skip monsters with unexpected data shapes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return creatures;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMonster(m: RawMonster): Creature {
|
||||||
const crStr = extractCr(m.cr);
|
const crStr = extractCr(m.cr);
|
||||||
const ac = extractAc(m.ac);
|
const ac = extractAc(m.ac);
|
||||||
|
|
||||||
@@ -361,13 +402,16 @@ export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
|
|||||||
id: makeCreatureId(m.source, m.name),
|
id: makeCreatureId(m.source, m.name),
|
||||||
name: m.name,
|
name: m.name,
|
||||||
source: m.source,
|
source: m.source,
|
||||||
sourceDisplayName: SOURCE_DISPLAY_NAMES[m.source] ?? m.source,
|
sourceDisplayName: sourceDisplayNames[m.source] ?? m.source,
|
||||||
size: formatSize(m.size),
|
size: formatSize(m.size),
|
||||||
type: formatType(m.type),
|
type: formatType(m.type),
|
||||||
alignment: formatAlignment(m.alignment),
|
alignment: formatAlignment(m.alignment),
|
||||||
ac: ac.value,
|
ac: ac.value,
|
||||||
acSource: ac.source,
|
acSource: ac.source,
|
||||||
hp: { average: m.hp.average, formula: m.hp.formula },
|
hp: {
|
||||||
|
average: m.hp.average ?? 0,
|
||||||
|
formula: m.hp.formula ?? m.hp.special ?? "",
|
||||||
|
},
|
||||||
speed: formatSpeed(m.speed),
|
speed: formatSpeed(m.speed),
|
||||||
abilities: {
|
abilities: {
|
||||||
str: m.str,
|
str: m.str,
|
||||||
@@ -402,5 +446,4 @@ export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
|
|||||||
legendaryActions: normalizeLegendary(m.legendary, m),
|
legendaryActions: normalizeLegendary(m.legendary, m),
|
||||||
spellcasting: normalizeSpellcasting(m.spellcasting),
|
spellcasting: normalizeSpellcasting(m.spellcasting),
|
||||||
};
|
};
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
139
apps/web/src/adapters/bestiary-cache.ts
Normal file
139
apps/web/src/adapters/bestiary-cache.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import type { Creature, CreatureId } from "@initiative/domain";
|
||||||
|
import { type IDBPDatabase, openDB } from "idb";
|
||||||
|
|
||||||
|
const DB_NAME = "initiative-bestiary";
|
||||||
|
const STORE_NAME = "sources";
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
|
||||||
|
export interface CachedSourceInfo {
|
||||||
|
readonly sourceCode: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly creatureCount: number;
|
||||||
|
readonly cachedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CachedSourceRecord {
|
||||||
|
sourceCode: string;
|
||||||
|
displayName: string;
|
||||||
|
creatures: Creature[];
|
||||||
|
cachedAt: number;
|
||||||
|
creatureCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let db: IDBPDatabase | null = null;
|
||||||
|
let dbFailed = false;
|
||||||
|
|
||||||
|
// In-memory fallback when IndexedDB is unavailable
|
||||||
|
const memoryStore = new Map<string, CachedSourceRecord>();
|
||||||
|
|
||||||
|
async function getDb(): Promise<IDBPDatabase | null> {
|
||||||
|
if (db) return db;
|
||||||
|
if (dbFailed) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
db = await openDB(DB_NAME, DB_VERSION, {
|
||||||
|
upgrade(database) {
|
||||||
|
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
database.createObjectStore(STORE_NAME, {
|
||||||
|
keyPath: "sourceCode",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return db;
|
||||||
|
} catch {
|
||||||
|
dbFailed = true;
|
||||||
|
console.warn(
|
||||||
|
"IndexedDB unavailable — bestiary cache will not persist across sessions.",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cacheSource(
|
||||||
|
sourceCode: string,
|
||||||
|
displayName: string,
|
||||||
|
creatures: Creature[],
|
||||||
|
): Promise<void> {
|
||||||
|
const record: CachedSourceRecord = {
|
||||||
|
sourceCode,
|
||||||
|
displayName,
|
||||||
|
creatures,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
creatureCount: creatures.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const database = await getDb();
|
||||||
|
if (database) {
|
||||||
|
await database.put(STORE_NAME, record);
|
||||||
|
} else {
|
||||||
|
memoryStore.set(sourceCode, record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isSourceCached(sourceCode: string): Promise<boolean> {
|
||||||
|
const database = await getDb();
|
||||||
|
if (database) {
|
||||||
|
const record = await database.get(STORE_NAME, sourceCode);
|
||||||
|
return record !== undefined;
|
||||||
|
}
|
||||||
|
return memoryStore.has(sourceCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCachedSources(): Promise<CachedSourceInfo[]> {
|
||||||
|
const database = await getDb();
|
||||||
|
if (database) {
|
||||||
|
const all: CachedSourceRecord[] = await database.getAll(STORE_NAME);
|
||||||
|
return all.map((r) => ({
|
||||||
|
sourceCode: r.sourceCode,
|
||||||
|
displayName: r.displayName,
|
||||||
|
creatureCount: r.creatureCount,
|
||||||
|
cachedAt: r.cachedAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return [...memoryStore.values()].map((r) => ({
|
||||||
|
sourceCode: r.sourceCode,
|
||||||
|
displayName: r.displayName,
|
||||||
|
creatureCount: r.creatureCount,
|
||||||
|
cachedAt: r.cachedAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearSource(sourceCode: string): Promise<void> {
|
||||||
|
const database = await getDb();
|
||||||
|
if (database) {
|
||||||
|
await database.delete(STORE_NAME, sourceCode);
|
||||||
|
} else {
|
||||||
|
memoryStore.delete(sourceCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearAll(): Promise<void> {
|
||||||
|
const database = await getDb();
|
||||||
|
if (database) {
|
||||||
|
await database.clear(STORE_NAME);
|
||||||
|
} else {
|
||||||
|
memoryStore.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAllCachedCreatures(): Promise<
|
||||||
|
Map<CreatureId, Creature>
|
||||||
|
> {
|
||||||
|
const map = new Map<CreatureId, Creature>();
|
||||||
|
const database = await getDb();
|
||||||
|
|
||||||
|
let records: CachedSourceRecord[];
|
||||||
|
if (database) {
|
||||||
|
records = await database.getAll(STORE_NAME);
|
||||||
|
} else {
|
||||||
|
records = [...memoryStore.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
for (const creature of record.creatures) {
|
||||||
|
map.set(creature.id, creature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
96
apps/web/src/adapters/bestiary-index-adapter.ts
Normal file
96
apps/web/src/adapters/bestiary-index-adapter.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import type { BestiaryIndex, BestiaryIndexEntry } from "@initiative/domain";
|
||||||
|
|
||||||
|
import rawIndex from "../../../../data/bestiary/index.json";
|
||||||
|
|
||||||
|
interface CompactCreature {
|
||||||
|
readonly n: string;
|
||||||
|
readonly s: string;
|
||||||
|
readonly ac: number;
|
||||||
|
readonly hp: number;
|
||||||
|
readonly dx: number;
|
||||||
|
readonly cr: string;
|
||||||
|
readonly ip: number;
|
||||||
|
readonly sz: string;
|
||||||
|
readonly tp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompactIndex {
|
||||||
|
readonly sources: Record<string, string>;
|
||||||
|
readonly creatures: readonly CompactCreature[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCreature(c: CompactCreature): BestiaryIndexEntry {
|
||||||
|
return {
|
||||||
|
name: c.n,
|
||||||
|
source: c.s,
|
||||||
|
ac: c.ac,
|
||||||
|
hp: c.hp,
|
||||||
|
dex: c.dx,
|
||||||
|
cr: c.cr,
|
||||||
|
initiativeProficiency: c.ip,
|
||||||
|
size: c.sz,
|
||||||
|
type: c.tp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source codes whose filename on the remote differs from a simple lowercase.
|
||||||
|
// Plane Shift sources use a hyphen: PSA -> ps-a, etc.
|
||||||
|
const FILENAME_OVERRIDES: Record<string, string> = {
|
||||||
|
PSA: "ps-a",
|
||||||
|
PSD: "ps-d",
|
||||||
|
PSI: "ps-i",
|
||||||
|
PSK: "ps-k",
|
||||||
|
PSX: "ps-x",
|
||||||
|
PSZ: "ps-z",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Source codes with no corresponding remote bestiary file.
|
||||||
|
// Excluded from the index entirely so creatures aren't searchable
|
||||||
|
// without a fetchable source.
|
||||||
|
const EXCLUDED_SOURCES = new Set<string>([]);
|
||||||
|
|
||||||
|
let cachedIndex: BestiaryIndex | undefined;
|
||||||
|
|
||||||
|
export function loadBestiaryIndex(): BestiaryIndex {
|
||||||
|
if (cachedIndex) return cachedIndex;
|
||||||
|
|
||||||
|
const compact = rawIndex as unknown as CompactIndex;
|
||||||
|
const sources = Object.fromEntries(
|
||||||
|
Object.entries(compact.sources).filter(
|
||||||
|
([code]) => !EXCLUDED_SOURCES.has(code),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
cachedIndex = {
|
||||||
|
sources,
|
||||||
|
creatures: compact.creatures
|
||||||
|
.filter((c) => !EXCLUDED_SOURCES.has(c.s))
|
||||||
|
.map(mapCreature),
|
||||||
|
};
|
||||||
|
return cachedIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllSourceCodes(): string[] {
|
||||||
|
const index = loadBestiaryIndex();
|
||||||
|
return Object.keys(index.sources).filter((c) => !EXCLUDED_SOURCES.has(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceCodeToFilename(sourceCode: string): string {
|
||||||
|
return FILENAME_OVERRIDES[sourceCode] ?? sourceCode.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultFetchUrl(
|
||||||
|
sourceCode: string,
|
||||||
|
baseUrl?: string,
|
||||||
|
): string {
|
||||||
|
const filename = `bestiary-${sourceCodeToFilename(sourceCode)}.json`;
|
||||||
|
if (baseUrl !== undefined) {
|
||||||
|
const normalized = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||||
|
return `${normalized}${filename}`;
|
||||||
|
}
|
||||||
|
return `https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/${filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSourceDisplayName(sourceCode: string): string {
|
||||||
|
const index = loadBestiaryIndex();
|
||||||
|
return index.sources[sourceCode] ?? sourceCode;
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ const ATKR_MAP: Record<string, string> = {
|
|||||||
* Handles 15+ tag types per research.md R-002 tag resolution rules.
|
* Handles 15+ tag types per research.md R-002 tag resolution rules.
|
||||||
*/
|
*/
|
||||||
export function stripTags(text: string): string {
|
export function stripTags(text: string): string {
|
||||||
|
if (typeof text !== "string") return String(text);
|
||||||
// Process special tags with specific output formats first
|
// Process special tags with specific output formats first
|
||||||
let result = text;
|
let result = text;
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import type { Creature } from "@initiative/domain";
|
import { Import, Search } from "lucide-react";
|
||||||
import { Search } from "lucide-react";
|
|
||||||
import { type FormEvent, useState } from "react";
|
import { type FormEvent, useState } from "react";
|
||||||
|
import type { SearchResult } from "../hooks/use-bestiary.js";
|
||||||
import { BestiarySearch } from "./bestiary-search.js";
|
import { BestiarySearch } from "./bestiary-search.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
interface ActionBarProps {
|
interface ActionBarProps {
|
||||||
onAddCombatant: (name: string) => void;
|
onAddCombatant: (name: string) => void;
|
||||||
onAddFromBestiary: (creature: Creature) => void;
|
onAddFromBestiary: (result: SearchResult) => void;
|
||||||
bestiarySearch: (query: string) => Creature[];
|
bestiarySearch: (query: string) => SearchResult[];
|
||||||
bestiaryLoaded: boolean;
|
bestiaryLoaded: boolean;
|
||||||
suggestions: Creature[];
|
onBulkImport?: () => void;
|
||||||
onSearchChange: (query: string) => void;
|
bulkImportDisabled?: boolean;
|
||||||
onShowStatBlock?: (creature: Creature) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActionBar({
|
export function ActionBar({
|
||||||
@@ -20,12 +19,12 @@ export function ActionBar({
|
|||||||
onAddFromBestiary,
|
onAddFromBestiary,
|
||||||
bestiarySearch,
|
bestiarySearch,
|
||||||
bestiaryLoaded,
|
bestiaryLoaded,
|
||||||
suggestions,
|
onBulkImport,
|
||||||
onSearchChange,
|
bulkImportDisabled,
|
||||||
onShowStatBlock,
|
|
||||||
}: ActionBarProps) {
|
}: ActionBarProps) {
|
||||||
const [nameInput, setNameInput] = useState("");
|
const [nameInput, setNameInput] = useState("");
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||||
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
||||||
|
|
||||||
const handleAdd = (e: FormEvent) => {
|
const handleAdd = (e: FormEvent) => {
|
||||||
@@ -33,28 +32,30 @@ export function ActionBar({
|
|||||||
if (nameInput.trim() === "") return;
|
if (nameInput.trim() === "") return;
|
||||||
onAddCombatant(nameInput);
|
onAddCombatant(nameInput);
|
||||||
setNameInput("");
|
setNameInput("");
|
||||||
onSearchChange("");
|
setSuggestions([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNameChange = (value: string) => {
|
const handleNameChange = (value: string) => {
|
||||||
setNameInput(value);
|
setNameInput(value);
|
||||||
setSuggestionIndex(-1);
|
setSuggestionIndex(-1);
|
||||||
onSearchChange(value);
|
if (value.length >= 2) {
|
||||||
|
setSuggestions(bestiarySearch(value));
|
||||||
|
} else {
|
||||||
|
setSuggestions([]);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectCreature = (creature: Creature) => {
|
const handleSelectCreature = (result: SearchResult) => {
|
||||||
onAddFromBestiary(creature);
|
onAddFromBestiary(result);
|
||||||
setSearchOpen(false);
|
setSearchOpen(false);
|
||||||
setNameInput("");
|
setNameInput("");
|
||||||
onSearchChange("");
|
setSuggestions([]);
|
||||||
onShowStatBlock?.(creature);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectSuggestion = (creature: Creature) => {
|
const handleSelectSuggestion = (result: SearchResult) => {
|
||||||
onAddFromBestiary(creature);
|
onAddFromBestiary(result);
|
||||||
setNameInput("");
|
setNameInput("");
|
||||||
onSearchChange("");
|
setSuggestions([]);
|
||||||
onShowStatBlock?.(creature);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
@@ -71,7 +72,7 @@ export function ActionBar({
|
|||||||
handleSelectSuggestion(suggestions[suggestionIndex]);
|
handleSelectSuggestion(suggestions[suggestionIndex]);
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
setSuggestionIndex(-1);
|
setSuggestionIndex(-1);
|
||||||
onSearchChange("");
|
setSuggestions([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -100,8 +101,8 @@ export function ActionBar({
|
|||||||
{suggestions.length > 0 && (
|
{suggestions.length > 0 && (
|
||||||
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
|
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
|
||||||
<ul className="max-h-48 overflow-y-auto py-1">
|
<ul className="max-h-48 overflow-y-auto py-1">
|
||||||
{suggestions.map((creature, i) => (
|
{suggestions.map((result, i) => (
|
||||||
<li key={creature.id}>
|
<li key={`${result.source}:${result.name}`}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
||||||
@@ -109,12 +110,12 @@ export function ActionBar({
|
|||||||
? "bg-accent/20 text-foreground"
|
? "bg-accent/20 text-foreground"
|
||||||
: "text-foreground hover:bg-hover-neutral-bg"
|
: "text-foreground hover:bg-hover-neutral-bg"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleSelectSuggestion(creature)}
|
onClick={() => handleSelectSuggestion(result)}
|
||||||
onMouseEnter={() => setSuggestionIndex(i)}
|
onMouseEnter={() => setSuggestionIndex(i)}
|
||||||
>
|
>
|
||||||
<span>{creature.name}</span>
|
<span>{result.name}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{creature.sourceDisplayName}
|
{result.sourceDisplayName}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -127,6 +128,7 @@ export function ActionBar({
|
|||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
{bestiaryLoaded && (
|
{bestiaryLoaded && (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -135,6 +137,18 @@ export function ActionBar({
|
|||||||
>
|
>
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
{onBulkImport && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onBulkImport}
|
||||||
|
disabled={bulkImportDisabled}
|
||||||
|
>
|
||||||
|
<Import className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { Creature } from "@initiative/domain";
|
|
||||||
import { Search, X } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
@@ -7,12 +6,13 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import type { SearchResult } from "../hooks/use-bestiary.js";
|
||||||
import { Input } from "./ui/input.js";
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
interface BestiarySearchProps {
|
interface BestiarySearchProps {
|
||||||
onSelectCreature: (creature: Creature) => void;
|
onSelectCreature: (result: SearchResult) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
searchFn: (query: string) => Creature[];
|
searchFn: (query: string) => SearchResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BestiarySearch({
|
export function BestiarySearch({
|
||||||
@@ -101,8 +101,8 @@ export function BestiarySearch({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="max-h-60 overflow-y-auto py-1">
|
<ul className="max-h-60 overflow-y-auto py-1">
|
||||||
{results.map((creature, i) => (
|
{results.map((result, i) => (
|
||||||
<li key={creature.id}>
|
<li key={`${result.source}:${result.name}`}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
||||||
@@ -110,12 +110,12 @@ export function BestiarySearch({
|
|||||||
? "bg-accent/20 text-foreground"
|
? "bg-accent/20 text-foreground"
|
||||||
: "text-foreground hover:bg-hover-neutral-bg"
|
: "text-foreground hover:bg-hover-neutral-bg"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => onSelectCreature(creature)}
|
onClick={() => onSelectCreature(result)}
|
||||||
onMouseEnter={() => setHighlightIndex(i)}
|
onMouseEnter={() => setHighlightIndex(i)}
|
||||||
>
|
>
|
||||||
<span>{creature.name}</span>
|
<span>{result.name}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{creature.sourceDisplayName}
|
{result.sourceDisplayName}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
115
apps/web/src/components/bulk-import-prompt.tsx
Normal file
115
apps/web/src/components/bulk-import-prompt.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { getAllSourceCodes } from "../adapters/bestiary-index-adapter.js";
|
||||||
|
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||||
|
import { Button } from "./ui/button.js";
|
||||||
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URL =
|
||||||
|
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
|
||||||
|
|
||||||
|
interface BulkImportPromptProps {
|
||||||
|
importState: BulkImportState;
|
||||||
|
onStartImport: (baseUrl: string) => void;
|
||||||
|
onDone: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BulkImportPrompt({
|
||||||
|
importState,
|
||||||
|
onStartImport,
|
||||||
|
onDone,
|
||||||
|
}: BulkImportPromptProps) {
|
||||||
|
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
|
||||||
|
const totalSources = getAllSourceCodes().length;
|
||||||
|
|
||||||
|
if (importState.status === "complete") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="rounded-md border border-green-500/50 bg-green-500/10 px-3 py-2 text-sm text-green-400">
|
||||||
|
All sources loaded
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={onDone}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importState.status === "partial-failure") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="rounded-md border border-yellow-500/50 bg-yellow-500/10 px-3 py-2 text-sm text-yellow-400">
|
||||||
|
Loaded {importState.completed}/{importState.total} sources (
|
||||||
|
{importState.failed} failed)
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={onDone}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importState.status === "loading") {
|
||||||
|
const processed = importState.completed + importState.failed;
|
||||||
|
const pct =
|
||||||
|
importState.total > 0
|
||||||
|
? Math.round((processed / importState.total) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading sources... {processed}/{importState.total}
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// idle state
|
||||||
|
const isDisabled = !baseUrl.trim() || importState.status !== "idle";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
|
Bulk Import Sources
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Load stat block data for all {totalSources} sources at once. This will
|
||||||
|
download approximately 12.5 MB of data.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label
|
||||||
|
htmlFor="bulk-base-url"
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
Base URL
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="bulk-base-url"
|
||||||
|
type="url"
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={(e) => setBaseUrl(e.target.value)}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onStartImport(baseUrl)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
Load All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
apps/web/src/components/source-fetch-prompt.tsx
Normal file
131
apps/web/src/components/source-fetch-prompt.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { Download, Loader2, Upload } from "lucide-react";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { getDefaultFetchUrl } from "../adapters/bestiary-index-adapter.js";
|
||||||
|
import { Button } from "./ui/button.js";
|
||||||
|
import { Input } from "./ui/input.js";
|
||||||
|
|
||||||
|
interface SourceFetchPromptProps {
|
||||||
|
sourceCode: string;
|
||||||
|
sourceDisplayName: string;
|
||||||
|
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
||||||
|
onSourceLoaded: () => void;
|
||||||
|
onUploadSource: (sourceCode: string, jsonData: unknown) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SourceFetchPrompt({
|
||||||
|
sourceCode,
|
||||||
|
sourceDisplayName,
|
||||||
|
fetchAndCacheSource,
|
||||||
|
onSourceLoaded,
|
||||||
|
onUploadSource,
|
||||||
|
}: SourceFetchPromptProps) {
|
||||||
|
const [url, setUrl] = useState(() => getDefaultFetchUrl(sourceCode));
|
||||||
|
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
|
||||||
|
const [error, setError] = useState<string>("");
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFetch = async () => {
|
||||||
|
setStatus("fetching");
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await fetchAndCacheSource(sourceCode, url);
|
||||||
|
onSourceLoaded();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus("error");
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to fetch source data");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setStatus("fetching");
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
await onUploadSource(sourceCode, json);
|
||||||
|
onSourceLoaded();
|
||||||
|
} catch (err) {
|
||||||
|
setStatus("error");
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Failed to process uploaded file",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset file input
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
|
Load {sourceDisplayName}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Stat block data for this source needs to be loaded. Enter a URL or
|
||||||
|
upload a JSON file.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="source-url" className="text-xs text-muted-foreground">
|
||||||
|
Source URL
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="source-url"
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
disabled={status === "fetching"}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleFetch}
|
||||||
|
disabled={status === "fetching" || !url}
|
||||||
|
>
|
||||||
|
{status === "fetching" ? (
|
||||||
|
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="mr-1 h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{status === "fetching" ? "Loading..." : "Load"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<span className="text-xs text-muted-foreground">or</span>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={status === "fetching"}
|
||||||
|
>
|
||||||
|
<Upload className="mr-1 h-3 w-3" />
|
||||||
|
Upload file
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === "error" && (
|
||||||
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
apps/web/src/components/source-manager.tsx
Normal file
81
apps/web/src/components/source-manager.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Database, Trash2 } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { CachedSourceInfo } from "../adapters/bestiary-cache.js";
|
||||||
|
import * as bestiaryCache from "../adapters/bestiary-cache.js";
|
||||||
|
import { Button } from "./ui/button.js";
|
||||||
|
|
||||||
|
interface SourceManagerProps {
|
||||||
|
onCacheCleared: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SourceManager({ onCacheCleared }: SourceManagerProps) {
|
||||||
|
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
||||||
|
|
||||||
|
const loadSources = useCallback(async () => {
|
||||||
|
const cached = await bestiaryCache.getCachedSources();
|
||||||
|
setSources(cached);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSources();
|
||||||
|
}, [loadSources]);
|
||||||
|
|
||||||
|
const handleClearSource = async (sourceCode: string) => {
|
||||||
|
await bestiaryCache.clearSource(sourceCode);
|
||||||
|
await loadSources();
|
||||||
|
onCacheCleared();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearAll = async () => {
|
||||||
|
await bestiaryCache.clearAll();
|
||||||
|
await loadSources();
|
||||||
|
onCacheCleared();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sources.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-8 text-center">
|
||||||
|
<Database className="h-8 w-8 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">No cached sources</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-semibold text-foreground">
|
||||||
|
Cached Sources
|
||||||
|
</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={handleClearAll}>
|
||||||
|
<Trash2 className="mr-1 h-3 w-3" />
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{sources.map((source) => (
|
||||||
|
<li
|
||||||
|
key={source.sourceCode}
|
||||||
|
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-foreground">
|
||||||
|
{source.displayName}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">
|
||||||
|
{source.creatureCount} creatures
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleClearSource(source.sourceCode)}
|
||||||
|
className="text-muted-foreground hover:text-hover-danger"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,53 @@
|
|||||||
import type { Creature } from "@initiative/domain";
|
import type { Creature, CreatureId } from "@initiative/domain";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { getSourceDisplayName } from "../adapters/bestiary-index-adapter.js";
|
||||||
|
import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
||||||
|
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
||||||
|
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
||||||
import { StatBlock } from "./stat-block.js";
|
import { StatBlock } from "./stat-block.js";
|
||||||
|
|
||||||
interface StatBlockPanelProps {
|
interface StatBlockPanelProps {
|
||||||
|
creatureId: CreatureId | null;
|
||||||
creature: Creature | null;
|
creature: Creature | null;
|
||||||
|
isSourceCached: (sourceCode: string) => Promise<boolean>;
|
||||||
|
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
||||||
|
uploadAndCacheSource: (
|
||||||
|
sourceCode: string,
|
||||||
|
jsonData: unknown,
|
||||||
|
) => Promise<void>;
|
||||||
|
refreshCache: () => Promise<void>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
bulkImportMode?: boolean;
|
||||||
|
bulkImportState?: BulkImportState;
|
||||||
|
onStartBulkImport?: (baseUrl: string) => void;
|
||||||
|
onBulkImportDone?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatBlockPanel({ creature, onClose }: StatBlockPanelProps) {
|
function extractSourceCode(cId: CreatureId): string {
|
||||||
|
const colonIndex = cId.indexOf(":");
|
||||||
|
if (colonIndex === -1) return "";
|
||||||
|
return cId.slice(0, colonIndex).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatBlockPanel({
|
||||||
|
creatureId,
|
||||||
|
creature,
|
||||||
|
isSourceCached,
|
||||||
|
fetchAndCacheSource,
|
||||||
|
uploadAndCacheSource,
|
||||||
|
refreshCache,
|
||||||
|
onClose,
|
||||||
|
bulkImportMode,
|
||||||
|
bulkImportState,
|
||||||
|
onStartBulkImport,
|
||||||
|
onBulkImportDone,
|
||||||
|
}: StatBlockPanelProps) {
|
||||||
const [isDesktop, setIsDesktop] = useState(
|
const [isDesktop, setIsDesktop] = useState(
|
||||||
() => window.matchMedia("(min-width: 1024px)").matches,
|
() => window.matchMedia("(min-width: 1024px)").matches,
|
||||||
);
|
);
|
||||||
|
const [needsFetch, setNeedsFetch] = useState(false);
|
||||||
|
const [checkingCache, setCheckingCache] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mq = window.matchMedia("(min-width: 1024px)");
|
const mq = window.matchMedia("(min-width: 1024px)");
|
||||||
@@ -20,14 +56,90 @@ export function StatBlockPanel({ creature, onClose }: StatBlockPanelProps) {
|
|||||||
return () => mq.removeEventListener("change", handler);
|
return () => mq.removeEventListener("change", handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!creature) return null;
|
// When creatureId changes, check if we need to show the fetch prompt
|
||||||
|
useEffect(() => {
|
||||||
|
if (!creatureId || creature) {
|
||||||
|
setNeedsFetch(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceCode = extractSourceCode(creatureId);
|
||||||
|
if (!sourceCode) {
|
||||||
|
setNeedsFetch(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCheckingCache(true);
|
||||||
|
isSourceCached(sourceCode).then((cached) => {
|
||||||
|
// If source is cached but creature not found, it's an edge case
|
||||||
|
// If source is not cached, show fetch prompt
|
||||||
|
setNeedsFetch(!cached);
|
||||||
|
setCheckingCache(false);
|
||||||
|
});
|
||||||
|
}, [creatureId, creature, isSourceCached]);
|
||||||
|
|
||||||
|
if (!creatureId && !bulkImportMode) return null;
|
||||||
|
|
||||||
|
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
||||||
|
|
||||||
|
const handleSourceLoaded = async () => {
|
||||||
|
await refreshCache();
|
||||||
|
setNeedsFetch(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (
|
||||||
|
bulkImportMode &&
|
||||||
|
bulkImportState &&
|
||||||
|
onStartBulkImport &&
|
||||||
|
onBulkImportDone
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<BulkImportPrompt
|
||||||
|
importState={bulkImportState}
|
||||||
|
onStartImport={onStartBulkImport}
|
||||||
|
onDone={onBulkImportDone}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkingCache) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 text-sm text-muted-foreground">Loading...</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (creature) {
|
||||||
|
return <StatBlock creature={creature} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsFetch && sourceCode) {
|
||||||
|
return (
|
||||||
|
<SourceFetchPrompt
|
||||||
|
sourceCode={sourceCode}
|
||||||
|
sourceDisplayName={getSourceDisplayName(sourceCode)}
|
||||||
|
fetchAndCacheSource={fetchAndCacheSource}
|
||||||
|
onSourceLoaded={handleSourceLoaded}
|
||||||
|
onUploadSource={uploadAndCacheSource}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 text-sm text-muted-foreground">
|
||||||
|
No stat block available
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const panelTitle = bulkImportMode ? "Bulk Import" : "Stat Block";
|
||||||
|
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed top-0 right-0 bottom-0 flex w-[400px] flex-col border-l border-border bg-card">
|
<div className="fixed top-0 right-0 bottom-0 flex w-[400px] flex-col border-l border-border bg-card">
|
||||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||||
<span className="text-sm font-semibold text-muted-foreground">
|
<span className="text-sm font-semibold text-muted-foreground">
|
||||||
Stat Block
|
{panelTitle}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -37,9 +149,7 @@ export function StatBlockPanel({ creature, onClose }: StatBlockPanelProps) {
|
|||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">{renderContent()}</div>
|
||||||
<StatBlock creature={creature} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -58,7 +168,7 @@ export function StatBlockPanel({ creature, onClose }: StatBlockPanelProps) {
|
|||||||
<div className="absolute top-0 right-0 bottom-0 w-[85%] max-w-md animate-slide-in-right border-l border-border bg-card shadow-xl">
|
<div className="absolute top-0 right-0 bottom-0 w-[85%] max-w-md animate-slide-in-right border-l border-border bg-card shadow-xl">
|
||||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
||||||
<span className="text-sm font-semibold text-muted-foreground">
|
<span className="text-sm font-semibold text-muted-foreground">
|
||||||
Stat Block
|
{panelTitle}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -69,7 +179,7 @@ export function StatBlockPanel({ creature, onClose }: StatBlockPanelProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
|
<div className="h-[calc(100%-41px)] overflow-y-auto p-4">
|
||||||
<StatBlock creature={creature} />
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
47
apps/web/src/components/toast.tsx
Normal file
47
apps/web/src/components/toast.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { X } from "lucide-react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
interface ToastProps {
|
||||||
|
message: string;
|
||||||
|
progress?: number;
|
||||||
|
onDismiss: () => void;
|
||||||
|
autoDismissMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toast({
|
||||||
|
message,
|
||||||
|
progress,
|
||||||
|
onDismiss,
|
||||||
|
autoDismissMs,
|
||||||
|
}: ToastProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoDismissMs === undefined) return;
|
||||||
|
const timer = setTimeout(onDismiss, autoDismissMs);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [autoDismissMs, onDismiss]);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2">
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg">
|
||||||
|
<span className="text-sm text-foreground">{message}</span>
|
||||||
|
{progress !== undefined && (
|
||||||
|
<div className="h-2 w-24 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
|
style={{ width: `${Math.round(progress * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="text-muted-foreground hover:text-hover-neutral"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Encounter } from "@initiative/domain";
|
import type { Encounter } from "@initiative/domain";
|
||||||
import { StepBack, StepForward, Trash2 } from "lucide-react";
|
import { Settings, StepBack, StepForward, Trash2 } from "lucide-react";
|
||||||
import { D20Icon } from "./d20-icon";
|
import { D20Icon } from "./d20-icon";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ interface TurnNavigationProps {
|
|||||||
onRetreatTurn: () => void;
|
onRetreatTurn: () => void;
|
||||||
onClearEncounter: () => void;
|
onClearEncounter: () => void;
|
||||||
onRollAllInitiative: () => void;
|
onRollAllInitiative: () => void;
|
||||||
|
onOpenSourceManager: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TurnNavigation({
|
export function TurnNavigation({
|
||||||
@@ -17,6 +18,7 @@ export function TurnNavigation({
|
|||||||
onRetreatTurn,
|
onRetreatTurn,
|
||||||
onClearEncounter,
|
onClearEncounter,
|
||||||
onRollAllInitiative,
|
onRollAllInitiative,
|
||||||
|
onOpenSourceManager,
|
||||||
}: TurnNavigationProps) {
|
}: TurnNavigationProps) {
|
||||||
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;
|
||||||
@@ -62,6 +64,16 @@ export function TurnNavigation({
|
|||||||
>
|
>
|
||||||
<D20Icon className="h-6 w-6" />
|
<D20Icon className="h-6 w-6" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-hover-neutral"
|
||||||
|
onClick={onOpenSourceManager}
|
||||||
|
title="Manage cached sources"
|
||||||
|
aria-label="Manage cached sources"
|
||||||
|
>
|
||||||
|
<Settings className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -1,62 +1,126 @@
|
|||||||
import type { Creature, CreatureId } from "@initiative/domain";
|
import type {
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
BestiaryIndexEntry,
|
||||||
import { normalizeBestiary } from "../adapters/bestiary-adapter.js";
|
Creature,
|
||||||
|
CreatureId,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
normalizeBestiary,
|
||||||
|
setSourceDisplayNames,
|
||||||
|
} from "../adapters/bestiary-adapter.js";
|
||||||
|
import * as bestiaryCache from "../adapters/bestiary-cache.js";
|
||||||
|
import {
|
||||||
|
getSourceDisplayName,
|
||||||
|
loadBestiaryIndex,
|
||||||
|
} from "../adapters/bestiary-index-adapter.js";
|
||||||
|
|
||||||
|
export interface SearchResult extends BestiaryIndexEntry {
|
||||||
|
readonly sourceDisplayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface BestiaryHook {
|
interface BestiaryHook {
|
||||||
search: (query: string) => Creature[];
|
search: (query: string) => SearchResult[];
|
||||||
getCreature: (id: CreatureId) => Creature | undefined;
|
getCreature: (id: CreatureId) => Creature | undefined;
|
||||||
allCreatures: Creature[];
|
|
||||||
isLoaded: boolean;
|
isLoaded: boolean;
|
||||||
|
isSourceCached: (sourceCode: string) => Promise<boolean>;
|
||||||
|
fetchAndCacheSource: (sourceCode: string, url: string) => Promise<void>;
|
||||||
|
uploadAndCacheSource: (
|
||||||
|
sourceCode: string,
|
||||||
|
jsonData: unknown,
|
||||||
|
) => Promise<void>;
|
||||||
|
refreshCache: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBestiary(): BestiaryHook {
|
export function useBestiary(): BestiaryHook {
|
||||||
const [creatures, setCreatures] = useState<Creature[]>([]);
|
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
const creatureMapRef = useRef<Map<string, Creature>>(new Map());
|
const creatureMapRef = useRef<Map<CreatureId, Creature>>(new Map());
|
||||||
const loadAttempted = useRef(false);
|
const [, setTick] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadAttempted.current) return;
|
const index = loadBestiaryIndex();
|
||||||
loadAttempted.current = true;
|
setSourceDisplayNames(index.sources as Record<string, string>);
|
||||||
|
if (index.creatures.length > 0) {
|
||||||
import("../../../../data/bestiary/xmm.json")
|
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: raw JSON shape varies per entry
|
|
||||||
.then((mod: any) => {
|
|
||||||
const raw = mod.default ?? mod;
|
|
||||||
try {
|
|
||||||
const normalized = normalizeBestiary(raw);
|
|
||||||
const map = new Map<string, Creature>();
|
|
||||||
for (const c of normalized) {
|
|
||||||
map.set(c.id, c);
|
|
||||||
}
|
|
||||||
creatureMapRef.current = map;
|
|
||||||
setCreatures(normalized);
|
|
||||||
setIsLoaded(true);
|
setIsLoaded(true);
|
||||||
} catch {
|
|
||||||
// Normalization failed — bestiary unavailable
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.catch(() => {
|
bestiaryCache.loadAllCachedCreatures().then((map) => {
|
||||||
// Import failed — bestiary unavailable
|
creatureMapRef.current = map;
|
||||||
|
setTick((t) => t + 1);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const search = useMemo(() => {
|
const search = useCallback((query: string): SearchResult[] => {
|
||||||
return (query: string): Creature[] => {
|
|
||||||
if (query.length < 2) return [];
|
if (query.length < 2) return [];
|
||||||
const lower = query.toLowerCase();
|
const lower = query.toLowerCase();
|
||||||
return creatures
|
const index = loadBestiaryIndex();
|
||||||
|
return index.creatures
|
||||||
.filter((c) => c.name.toLowerCase().includes(lower))
|
.filter((c) => c.name.toLowerCase().includes(lower))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
.slice(0, 10);
|
.slice(0, 10)
|
||||||
};
|
.map((c) => ({
|
||||||
}, [creatures]);
|
...c,
|
||||||
|
sourceDisplayName: getSourceDisplayName(c.source),
|
||||||
const getCreature = useMemo(() => {
|
}));
|
||||||
return (id: CreatureId): Creature | undefined => {
|
|
||||||
return creatureMapRef.current.get(id);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { search, getCreature, allCreatures: creatures, isLoaded };
|
const getCreature = useCallback((id: CreatureId): Creature | undefined => {
|
||||||
|
return creatureMapRef.current.get(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isSourceCachedFn = useCallback(
|
||||||
|
(sourceCode: string): Promise<boolean> => {
|
||||||
|
return bestiaryCache.isSourceCached(sourceCode);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchAndCacheSource = useCallback(
|
||||||
|
async (sourceCode: string, url: string): Promise<void> => {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const json = await response.json();
|
||||||
|
const creatures = normalizeBestiary(json);
|
||||||
|
const displayName = getSourceDisplayName(sourceCode);
|
||||||
|
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
||||||
|
for (const c of creatures) {
|
||||||
|
creatureMapRef.current.set(c.id, c);
|
||||||
|
}
|
||||||
|
setTick((t) => t + 1);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadAndCacheSource = useCallback(
|
||||||
|
async (sourceCode: string, jsonData: unknown): Promise<void> => {
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: raw JSON shape varies
|
||||||
|
const creatures = normalizeBestiary(jsonData as any);
|
||||||
|
const displayName = getSourceDisplayName(sourceCode);
|
||||||
|
await bestiaryCache.cacheSource(sourceCode, displayName, creatures);
|
||||||
|
for (const c of creatures) {
|
||||||
|
creatureMapRef.current.set(c.id, c);
|
||||||
|
}
|
||||||
|
setTick((t) => t + 1);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshCache = useCallback(async (): Promise<void> => {
|
||||||
|
const map = await bestiaryCache.loadAllCachedCreatures();
|
||||||
|
creatureMapRef.current = map;
|
||||||
|
setTick((t) => t + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
search,
|
||||||
|
getCreature,
|
||||||
|
isLoaded,
|
||||||
|
isSourceCached: isSourceCachedFn,
|
||||||
|
fetchAndCacheSource,
|
||||||
|
uploadAndCacheSource,
|
||||||
|
refreshCache,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
120
apps/web/src/hooks/use-bulk-import.ts
Normal file
120
apps/web/src/hooks/use-bulk-import.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
getAllSourceCodes,
|
||||||
|
getDefaultFetchUrl,
|
||||||
|
} from "../adapters/bestiary-index-adapter.js";
|
||||||
|
|
||||||
|
const BATCH_SIZE = 6;
|
||||||
|
|
||||||
|
export 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 [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 = getAllSourceCodes();
|
||||||
|
const total = allCodes.length;
|
||||||
|
|
||||||
|
countersRef.current = { completed: 0, failed: 0 };
|
||||||
|
setState({ status: "loading", total, completed: 0, failed: 0 });
|
||||||
|
|
||||||
|
(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 }));
|
||||||
|
|
||||||
|
for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
|
||||||
|
const batch = uncached.slice(i, i + BATCH_SIZE);
|
||||||
|
await Promise.allSettled(
|
||||||
|
batch.map(async ({ code }) => {
|
||||||
|
const url = 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,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshCache();
|
||||||
|
|
||||||
|
const { completed, failed } = countersRef.current;
|
||||||
|
setState({
|
||||||
|
status: failed > 0 ? "partial-failure" : "complete",
|
||||||
|
total,
|
||||||
|
completed,
|
||||||
|
failed,
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setState(IDLE_STATE);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { state, startImport, reset };
|
||||||
|
}
|
||||||
@@ -14,9 +14,9 @@ import {
|
|||||||
toggleConditionUseCase,
|
toggleConditionUseCase,
|
||||||
} from "@initiative/application";
|
} from "@initiative/application";
|
||||||
import type {
|
import type {
|
||||||
|
BestiaryIndexEntry,
|
||||||
CombatantId,
|
CombatantId,
|
||||||
ConditionId,
|
ConditionId,
|
||||||
Creature,
|
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
Encounter,
|
Encounter,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
combatantId,
|
combatantId,
|
||||||
createEncounter,
|
createEncounter,
|
||||||
isDomainError,
|
isDomainError,
|
||||||
|
creatureId as makeCreatureId,
|
||||||
resolveCreatureName,
|
resolveCreatureName,
|
||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
@@ -240,11 +241,11 @@ export function useEncounter() {
|
|||||||
}, [makeStore]);
|
}, [makeStore]);
|
||||||
|
|
||||||
const addFromBestiary = useCallback(
|
const addFromBestiary = useCallback(
|
||||||
(creature: Creature) => {
|
(entry: BestiaryIndexEntry) => {
|
||||||
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(
|
||||||
creature.name,
|
entry.name,
|
||||||
existingNames,
|
existingNames,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -262,25 +263,32 @@ export function useEncounter() {
|
|||||||
if (isDomainError(addResult)) return;
|
if (isDomainError(addResult)) return;
|
||||||
|
|
||||||
// Set HP
|
// Set HP
|
||||||
const hpResult = setHpUseCase(makeStore(), id, creature.hp.average);
|
const hpResult = setHpUseCase(makeStore(), id, entry.hp);
|
||||||
if (!isDomainError(hpResult)) {
|
if (!isDomainError(hpResult)) {
|
||||||
setEvents((prev) => [...prev, ...hpResult]);
|
setEvents((prev) => [...prev, ...hpResult]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set AC
|
// Set AC
|
||||||
if (creature.ac > 0) {
|
if (entry.ac > 0) {
|
||||||
const acResult = setAcUseCase(makeStore(), id, creature.ac);
|
const acResult = setAcUseCase(makeStore(), id, entry.ac);
|
||||||
if (!isDomainError(acResult)) {
|
if (!isDomainError(acResult)) {
|
||||||
setEvents((prev) => [...prev, ...acResult]);
|
setEvents((prev) => [...prev, ...acResult]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Derive creatureId from source + name
|
||||||
|
const slug = entry.name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/(^-|-$)/g, "");
|
||||||
|
const cId = makeCreatureId(`${entry.source.toLowerCase()}:${slug}`);
|
||||||
|
|
||||||
// Set creatureId on the combatant
|
// Set creatureId on the combatant
|
||||||
const currentEncounter = store.get();
|
const currentEncounter = store.get();
|
||||||
const updated = {
|
const updated = {
|
||||||
...currentEncounter,
|
...currentEncounter,
|
||||||
combatants: currentEncounter.combatants.map((c) =>
|
combatants: currentEncounter.combatants.map((c) =>
|
||||||
c.id === id ? { ...c, creatureId: creature.id } : c,
|
c.id === id ? { ...c, creatureId: cId } : c,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
setEncounter(updated);
|
setEncounter(updated);
|
||||||
|
|||||||
36540
data/bestiary/index.json
Normal file
36540
data/bestiary/index.json
Normal file
File diff suppressed because it is too large
Load Diff
63266
data/bestiary/xmm.json
63266
data/bestiary/xmm.json
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ export { adjustHpUseCase } from "./adjust-hp-use-case.js";
|
|||||||
export { advanceTurnUseCase } from "./advance-turn-use-case.js";
|
export { advanceTurnUseCase } from "./advance-turn-use-case.js";
|
||||||
export { clearEncounterUseCase } from "./clear-encounter-use-case.js";
|
export { clearEncounterUseCase } from "./clear-encounter-use-case.js";
|
||||||
export { editCombatantUseCase } from "./edit-combatant-use-case.js";
|
export { editCombatantUseCase } from "./edit-combatant-use-case.js";
|
||||||
export type { EncounterStore } from "./ports.js";
|
export type { BestiarySourceCache, EncounterStore } from "./ports.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 { rollAllInitiativeUseCase } from "./roll-all-initiative-use-case.js";
|
export { rollAllInitiativeUseCase } from "./roll-all-initiative-use-case.js";
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { Encounter } from "@initiative/domain";
|
import type { Creature, CreatureId, Encounter } from "@initiative/domain";
|
||||||
|
|
||||||
export interface EncounterStore {
|
export interface EncounterStore {
|
||||||
get(): Encounter;
|
get(): Encounter;
|
||||||
save(encounter: Encounter): void;
|
save(encounter: Encounter): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BestiarySourceCache {
|
||||||
|
getCreature(creatureId: CreatureId): Creature | undefined;
|
||||||
|
isSourceCached(sourceCode: string): boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,6 +75,23 @@ export interface Creature {
|
|||||||
readonly spellcasting?: readonly SpellcastingBlock[];
|
readonly spellcasting?: readonly SpellcastingBlock[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BestiaryIndexEntry {
|
||||||
|
readonly name: string;
|
||||||
|
readonly source: string;
|
||||||
|
readonly ac: number;
|
||||||
|
readonly hp: number;
|
||||||
|
readonly dex: number;
|
||||||
|
readonly cr: string;
|
||||||
|
readonly initiativeProficiency: number;
|
||||||
|
readonly size: string;
|
||||||
|
readonly type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BestiaryIndex {
|
||||||
|
readonly sources: Readonly<Record<string, string>>;
|
||||||
|
readonly creatures: readonly BestiaryIndexEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
/** Maps a CR string to the corresponding proficiency bonus. */
|
/** Maps a CR string to the corresponding proficiency bonus. */
|
||||||
export function proficiencyBonus(cr: string): number {
|
export function proficiencyBonus(cr: string): number {
|
||||||
const numericCr = cr.includes("/")
|
const numericCr = cr.includes("/")
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export {
|
|||||||
VALID_CONDITION_IDS,
|
VALID_CONDITION_IDS,
|
||||||
} from "./conditions.js";
|
} from "./conditions.js";
|
||||||
export {
|
export {
|
||||||
|
type BestiaryIndex,
|
||||||
|
type BestiaryIndexEntry,
|
||||||
type BestiarySource,
|
type BestiarySource,
|
||||||
type Creature,
|
type Creature,
|
||||||
type CreatureId,
|
type CreatureId,
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -41,6 +41,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
idb:
|
||||||
|
specifier: ^8.0.3
|
||||||
|
version: 8.0.3
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.577.0
|
specifier: ^0.577.0
|
||||||
version: 0.577.0(react@19.2.4)
|
version: 0.577.0(react@19.2.4)
|
||||||
@@ -1096,6 +1099,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==}
|
resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==}
|
||||||
engines: {node: '>=8.12.0'}
|
engines: {node: '>=8.12.0'}
|
||||||
|
|
||||||
|
idb@8.0.3:
|
||||||
|
resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==}
|
||||||
|
|
||||||
is-core-module@2.16.1:
|
is-core-module@2.16.1:
|
||||||
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
|
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2615,6 +2621,8 @@ snapshots:
|
|||||||
|
|
||||||
human-signals@1.1.1: {}
|
human-signals@1.1.1: {}
|
||||||
|
|
||||||
|
idb@8.0.3: {}
|
||||||
|
|
||||||
is-core-module@2.16.1:
|
is-core-module@2.16.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
|
|||||||
167
scripts/generate-bestiary-index.mjs
Normal file
167
scripts/generate-bestiary-index.mjs
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
// Usage: node scripts/generate-bestiary-index.mjs <path-to-5etools-src>
|
||||||
|
//
|
||||||
|
// Requires a local clone/checkout of https://github.com/5etools-mirror-3/5etools-src
|
||||||
|
// with at least data/bestiary/, data/books.json, and data/adventures.json.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// git clone --depth 1 --sparse https://github.com/5etools-mirror-3/5etools-src.git /tmp/5etools
|
||||||
|
// cd /tmp/5etools && git sparse-checkout set data/bestiary data
|
||||||
|
// node scripts/generate-bestiary-index.mjs /tmp/5etools
|
||||||
|
|
||||||
|
const TOOLS_ROOT = process.argv[2];
|
||||||
|
if (!TOOLS_ROOT) {
|
||||||
|
console.error(
|
||||||
|
"Usage: node scripts/generate-bestiary-index.mjs <5etools-src-path>",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROJECT_ROOT = join(import.meta.dirname, "..");
|
||||||
|
const BESTIARY_DIR = join(TOOLS_ROOT, "data/bestiary");
|
||||||
|
const BOOKS_PATH = join(TOOLS_ROOT, "data/books.json");
|
||||||
|
const ADVENTURES_PATH = join(TOOLS_ROOT, "data/adventures.json");
|
||||||
|
const OUTPUT_PATH = join(PROJECT_ROOT, "data/bestiary/index.json");
|
||||||
|
|
||||||
|
// --- Build source display name map from books.json + adventures.json ---
|
||||||
|
|
||||||
|
function buildSourceMap() {
|
||||||
|
const map = {};
|
||||||
|
|
||||||
|
const books = JSON.parse(readFileSync(BOOKS_PATH, "utf-8"));
|
||||||
|
for (const book of books.book ?? []) {
|
||||||
|
if (book.source && book.name) {
|
||||||
|
map[book.source] = book.name;
|
||||||
|
}
|
||||||
|
// Some books use "id" instead of "source"
|
||||||
|
if (book.id && book.name && !map[book.id]) {
|
||||||
|
map[book.id] = book.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const adventures = JSON.parse(readFileSync(ADVENTURES_PATH, "utf-8"));
|
||||||
|
for (const adv of adventures.adventure ?? []) {
|
||||||
|
if (adv.source && adv.name) {
|
||||||
|
map[adv.source] = adv.name;
|
||||||
|
}
|
||||||
|
if (adv.id && adv.name && !map[adv.id]) {
|
||||||
|
map[adv.id] = adv.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual additions for sources missing from books.json / adventures.json
|
||||||
|
const manual = {
|
||||||
|
ESK: "Essentials Kit",
|
||||||
|
MCV1SC: "Monstrous Compendium Volume 1: Spelljammer Creatures",
|
||||||
|
MCV2DC: "Monstrous Compendium Volume 2: Dragonlance Creatures",
|
||||||
|
MCV3MC: "Monstrous Compendium Volume 3: Minecraft Creatures",
|
||||||
|
MCV4EC: "Monstrous Compendium Volume 4: Eldraine Creatures",
|
||||||
|
MFF: "Mordenkainen's Fiendish Folio",
|
||||||
|
MisMV1: "Misplaced Monsters: Volume 1",
|
||||||
|
SADS: "Sapphire Anniversary Dice Set",
|
||||||
|
TftYP: "Tales from the Yawning Portal",
|
||||||
|
VD: "Vecna Dossier",
|
||||||
|
};
|
||||||
|
for (const [k, v] of Object.entries(manual)) {
|
||||||
|
if (!map[k]) map[k] = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Extract type string from raw type field ---
|
||||||
|
|
||||||
|
function extractType(type) {
|
||||||
|
if (typeof type === "string") return type;
|
||||||
|
if (typeof type?.type === "string") return type.type;
|
||||||
|
if (typeof type?.type === "object" && Array.isArray(type.type.choose)) {
|
||||||
|
return type.type.choose.join("/");
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Extract AC from raw ac field ---
|
||||||
|
|
||||||
|
function extractAc(ac) {
|
||||||
|
if (!Array.isArray(ac) || ac.length === 0) return 0;
|
||||||
|
const first = ac[0];
|
||||||
|
if (typeof first === "number") return first;
|
||||||
|
if (typeof first === "object" && typeof first.ac === "number")
|
||||||
|
return first.ac;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Extract CR from raw cr field ---
|
||||||
|
|
||||||
|
function extractCr(cr) {
|
||||||
|
if (typeof cr === "string") return cr;
|
||||||
|
if (typeof cr === "object" && typeof cr.cr === "string") return cr.cr;
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Main ---
|
||||||
|
|
||||||
|
const sourceMap = buildSourceMap();
|
||||||
|
const files = readdirSync(BESTIARY_DIR).filter(
|
||||||
|
(f) => f.startsWith("bestiary-") && f.endsWith(".json"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const creatures = [];
|
||||||
|
const unmappedSources = new Set();
|
||||||
|
|
||||||
|
for (const file of files.sort()) {
|
||||||
|
const raw = JSON.parse(readFileSync(join(BESTIARY_DIR, file), "utf-8"));
|
||||||
|
const monsters = raw.monster ?? [];
|
||||||
|
|
||||||
|
for (const m of monsters) {
|
||||||
|
// Skip creatures that are copies/references (no actual stats)
|
||||||
|
if (m._copy || m.hp == null || m.ac == null) continue;
|
||||||
|
|
||||||
|
const source = m.source ?? "";
|
||||||
|
if (source && !sourceMap[source]) {
|
||||||
|
unmappedSources.add(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
creatures.push({
|
||||||
|
n: m.name,
|
||||||
|
s: source,
|
||||||
|
ac: extractAc(m.ac),
|
||||||
|
hp: m.hp.average ?? 0,
|
||||||
|
dx: m.dex ?? 10,
|
||||||
|
cr: extractCr(m.cr),
|
||||||
|
ip: m.initiative?.proficiency ?? 0,
|
||||||
|
sz: Array.isArray(m.size) ? m.size[0] : (m.size ?? "M"),
|
||||||
|
tp: extractType(m.type),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by name then source for stable output
|
||||||
|
creatures.sort((a, b) => a.n.localeCompare(b.n) || a.s.localeCompare(b.s));
|
||||||
|
|
||||||
|
// Filter sourceMap to only include sources that appear in the index
|
||||||
|
const usedSources = new Set(creatures.map((c) => c.s));
|
||||||
|
const filteredSourceMap = {};
|
||||||
|
for (const [key, value] of Object.entries(sourceMap)) {
|
||||||
|
if (usedSources.has(key)) {
|
||||||
|
filteredSourceMap[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
sources: filteredSourceMap,
|
||||||
|
creatures,
|
||||||
|
};
|
||||||
|
|
||||||
|
writeFileSync(OUTPUT_PATH, JSON.stringify(output));
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const rawSize = Buffer.byteLength(JSON.stringify(output));
|
||||||
|
console.log(`Sources: ${Object.keys(filteredSourceMap).length}`);
|
||||||
|
console.log(`Creatures: ${creatures.length}`);
|
||||||
|
console.log(`Output size: ${(rawSize / 1024).toFixed(1)} KB`);
|
||||||
|
if (unmappedSources.size > 0) {
|
||||||
|
console.log(`Unmapped sources: ${[...unmappedSources].sort().join(", ")}`);
|
||||||
|
}
|
||||||
36
specs/029-on-demand-bestiary/checklists/requirements.md
Normal file
36
specs/029-on-demand-bestiary/checklists/requirements.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Specification Quality Checklist: On-Demand Bestiary with Pre-Indexed Search
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-10
|
||||||
|
**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 validation. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||||
|
- FR-009 originally mentioned "IndexedDB" explicitly; updated to technology-agnostic "client-side storage" language.
|
||||||
|
- The spec covers 4 user stories with clear priority ordering: search (P1), stat block fetch (P2), file upload (P3), cache management (P4).
|
||||||
66
specs/029-on-demand-bestiary/contracts/bestiary-port.md
Normal file
66
specs/029-on-demand-bestiary/contracts/bestiary-port.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Contract: BestiarySourceCache Port
|
||||||
|
|
||||||
|
**Feature**: 029-on-demand-bestiary
|
||||||
|
**Layer**: Application (port interface, implemented by web adapter)
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Defines the interface for caching and retrieving full bestiary source data. The application layer uses this port to look up full creature stat blocks. The web adapter implements it using IndexedDB.
|
||||||
|
|
||||||
|
## Interface: BestiarySourceCache
|
||||||
|
|
||||||
|
### getCreature(creatureId: CreatureId): Creature | undefined
|
||||||
|
|
||||||
|
Look up a full creature by its ID from the cache.
|
||||||
|
|
||||||
|
- **Input**: `creatureId` — branded string in format `{source}:{slug}`
|
||||||
|
- **Output**: Full `Creature` object if the creature's source is cached, `undefined` otherwise
|
||||||
|
- **Side effects**: None (read-only)
|
||||||
|
|
||||||
|
### isSourceCached(sourceCode: string): boolean
|
||||||
|
|
||||||
|
Check whether a source's data has been cached.
|
||||||
|
|
||||||
|
- **Input**: `sourceCode` — source identifier (e.g., "XMM")
|
||||||
|
- **Output**: `true` if the source has been fetched and cached, `false` otherwise
|
||||||
|
|
||||||
|
### cacheSource(sourceCode: string, displayName: string, creatures: Creature[]): Promise\<void>
|
||||||
|
|
||||||
|
Store a full source's worth of normalized creature data.
|
||||||
|
|
||||||
|
- **Input**: source code, display name, array of normalized creatures
|
||||||
|
- **Output**: Resolves when data is persisted
|
||||||
|
- **Behavior**: Overwrites any existing cache for this source
|
||||||
|
|
||||||
|
### getCachedSources(): CachedSourceInfo[]
|
||||||
|
|
||||||
|
List all cached sources for the management UI.
|
||||||
|
|
||||||
|
- **Output**: Array of `{ sourceCode: string, displayName: string, creatureCount: number, cachedAt: number }`
|
||||||
|
|
||||||
|
### clearSource(sourceCode: string): Promise\<void>
|
||||||
|
|
||||||
|
Remove a single source's cached data.
|
||||||
|
|
||||||
|
- **Input**: source code to clear
|
||||||
|
- **Output**: Resolves when data is removed
|
||||||
|
|
||||||
|
### clearAll(): Promise\<void>
|
||||||
|
|
||||||
|
Remove all cached source data.
|
||||||
|
|
||||||
|
- **Output**: Resolves when all data is removed
|
||||||
|
|
||||||
|
## Index Adapter (no port)
|
||||||
|
|
||||||
|
The index adapter (`bestiary-index-adapter.ts`) exposes plain exported functions consumed directly by the web adapter hooks. No application-layer port is needed because the index is a static build-time asset with no I/O variability. See `apps/web/src/adapters/bestiary-index-adapter.ts` for the implementation.
|
||||||
|
|
||||||
|
Exported functions: `loadBestiaryIndex()`, `getSourceDisplayName(sourceCode)`, `getDefaultFetchUrl(sourceCode)`.
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
1. `getCreature` MUST return `undefined` for any creature whose source is not cached — never throw.
|
||||||
|
2. `cacheSource` MUST be idempotent — calling it twice with the same data produces the same result.
|
||||||
|
3. `clearSource` MUST NOT affect other cached sources.
|
||||||
|
4. `search` MUST return an empty array for queries shorter than 2 characters.
|
||||||
|
5. The index adapter MUST be available synchronously after app initialization — no async loading state for search.
|
||||||
133
specs/029-on-demand-bestiary/data-model.md
Normal file
133
specs/029-on-demand-bestiary/data-model.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# Data Model: On-Demand Bestiary with Pre-Indexed Search
|
||||||
|
|
||||||
|
**Feature**: 029-on-demand-bestiary
|
||||||
|
**Date**: 2026-03-10
|
||||||
|
|
||||||
|
## Domain Types
|
||||||
|
|
||||||
|
### BestiaryIndexEntry (NEW)
|
||||||
|
|
||||||
|
A lightweight creature record from the pre-shipped search index. Contains only mechanical facts — no copyrightable prose.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| name | string | Creature name (e.g., "Goblin Warrior") |
|
||||||
|
| source | string | Source code (e.g., "XMM") |
|
||||||
|
| ac | number | Armor class |
|
||||||
|
| hp | number | Average hit points |
|
||||||
|
| dex | number | Dexterity ability score |
|
||||||
|
| cr | string | Challenge rating (e.g., "1/4", "10") |
|
||||||
|
| initiativeProficiency | number | Initiative proficiency multiplier (0, 1, or 2) |
|
||||||
|
| size | string | Size code (e.g., "M", "L", "T") |
|
||||||
|
| type | string | Creature type (e.g., "humanoid", "fiend") |
|
||||||
|
|
||||||
|
**Uniqueness**: name + source (same creature name may appear in different sources)
|
||||||
|
|
||||||
|
**Derivable fields** (not stored, calculated at use):
|
||||||
|
- CreatureId: `{source.toLowerCase()}:{slugify(name)}`
|
||||||
|
- Source display name: resolved from BestiaryIndex.sources map
|
||||||
|
- Proficiency bonus: derived from CR via existing `proficiencyBonus(cr)` function
|
||||||
|
- Initiative modifier: derived from DEX + proficiency calculation
|
||||||
|
|
||||||
|
### BestiaryIndex (NEW)
|
||||||
|
|
||||||
|
The complete pre-shipped index loaded from `data/bestiary/index.json`.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| sources | Record<string, string> | Map of source code → display name (e.g., "XMM" → "Monster Manual (2025)") |
|
||||||
|
| creatures | BestiaryIndexEntry[] | All indexed creatures (3,312 entries) |
|
||||||
|
|
||||||
|
### Creature (EXISTING, unchanged)
|
||||||
|
|
||||||
|
Full stat block data — available only after source data is fetched and cached. See `packages/domain/src/creature-types.ts` for complete definition. Key fields: id, name, source, sourceDisplayName, size, type, alignment, ac, acSource, hp (average + formula), speed, abilities, cr, initiativeProficiency, proficiencyBonus, passive, traits, actions, bonusActions, reactions, legendaryActions, spellcasting.
|
||||||
|
|
||||||
|
### Combatant (EXISTING, unchanged)
|
||||||
|
|
||||||
|
Encounter participant. Links to creature via `creatureId?: CreatureId`. All combatant data (name, HP, AC, initiative) is stored independently from the creature — clearing the cache does not affect in-encounter combatants.
|
||||||
|
|
||||||
|
## Adapter Types
|
||||||
|
|
||||||
|
### CachedSourceRecord (NEW, adapter-layer only)
|
||||||
|
|
||||||
|
Stored in IndexedDB. One record per imported source.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| sourceCode | string | Primary key (e.g., "XMM") |
|
||||||
|
| displayName | string | Human-readable source name |
|
||||||
|
| creatures | Creature[] | Full normalized creature array |
|
||||||
|
| cachedAt | number | Unix timestamp of when source was cached |
|
||||||
|
| creatureCount | number | Number of creatures in this source (for management UI) |
|
||||||
|
|
||||||
|
### SourceFetchState (NEW, adapter-layer only)
|
||||||
|
|
||||||
|
UI state for the source fetch/upload prompt.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| sourceCode | string | Source being fetched |
|
||||||
|
| displayName | string | Display name for the prompt |
|
||||||
|
| defaultUrl | string | Pre-filled URL for this source |
|
||||||
|
| status | "idle" \| "fetching" \| "error" \| "success" | Current fetch state |
|
||||||
|
| error | string \| undefined | Error message if fetch failed |
|
||||||
|
|
||||||
|
## State Transitions
|
||||||
|
|
||||||
|
### Source Cache Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
UNCACHED → FETCHING → CACHED
|
||||||
|
↘ ERROR → (retry) → FETCHING
|
||||||
|
↘ (change URL) → FETCHING
|
||||||
|
↘ (upload file) → CACHED
|
||||||
|
CACHED → CLEARED → UNCACHED
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stat Block View Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User clicks creature → stat block panel opens
|
||||||
|
2. Check: creatureId exists on combatant?
|
||||||
|
NO → show "No stat block available" (custom combatant)
|
||||||
|
YES → continue
|
||||||
|
3. Check: source cached in IndexedDB?
|
||||||
|
YES → lookup creature by ID → render stat block
|
||||||
|
NO → show SourceFetchPrompt for this source
|
||||||
|
4. After successful fetch → creature available → render stat block
|
||||||
|
```
|
||||||
|
|
||||||
|
## Storage Layout
|
||||||
|
|
||||||
|
### IndexedDB Database: "initiative-bestiary"
|
||||||
|
|
||||||
|
**Object Store: "sources"**
|
||||||
|
- Key path: `sourceCode`
|
||||||
|
- Records: `CachedSourceRecord`
|
||||||
|
- Expected size: 1-3 MB per source, ~150 MB if all 102 sources cached (unlikely)
|
||||||
|
|
||||||
|
### localStorage (unchanged)
|
||||||
|
|
||||||
|
- Key: `"initiative:encounter"` — encounter state with combatant creatureId links
|
||||||
|
|
||||||
|
### Shipped Static Asset
|
||||||
|
|
||||||
|
- `data/bestiary/index.json` — imported at build time, included in JS bundle (~52 KB gzipped)
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
```
|
||||||
|
BestiaryIndex (shipped, static)
|
||||||
|
├── sources: {sourceCode → displayName}
|
||||||
|
└── creatures: BestiaryIndexEntry[]
|
||||||
|
│
|
||||||
|
├──[search]──→ BestiarySearch UI (displays name + source)
|
||||||
|
├──[add]──→ Combatant (name, HP, AC, creatureId)
|
||||||
|
└──[view stat block]──→ CachedSourceRecord?
|
||||||
|
│
|
||||||
|
├── YES → Creature (full stat block)
|
||||||
|
└── NO → SourceFetchPrompt
|
||||||
|
│
|
||||||
|
├── fetch URL → normalizeBestiary() → CachedSourceRecord
|
||||||
|
└── upload file → normalizeBestiary() → CachedSourceRecord
|
||||||
|
```
|
||||||
94
specs/029-on-demand-bestiary/plan.md
Normal file
94
specs/029-on-demand-bestiary/plan.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Implementation Plan: On-Demand Bestiary with Pre-Indexed Search
|
||||||
|
|
||||||
|
**Branch**: `029-on-demand-bestiary` | **Date**: 2026-03-10 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/029-on-demand-bestiary/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Replace the bundled `data/bestiary/xmm.json` (503 creatures, ~1.3 MB, one source) with a two-tier architecture: a pre-shipped lightweight search index (`data/bestiary/index.json`, 3,312 creatures, 102 sources, ~52 KB gzipped) for instant multi-source search and combatant creation, plus on-demand full stat block data fetched from user-provided URLs and cached in IndexedDB per source. This removes copyrighted prose from the distributed app, expands coverage from 1 source to 102, and preserves the existing normalization pipeline.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: TypeScript 5.8 (strict mode, verbatimModuleSyntax)
|
||||||
|
**Primary Dependencies**: React 19, Vite 6, Tailwind CSS v4, Lucide React (icons), idb (IndexedDB wrapper)
|
||||||
|
**Storage**: IndexedDB for cached source data (new); localStorage for encounter persistence (existing, unchanged)
|
||||||
|
**Testing**: Vitest (existing)
|
||||||
|
**Target Platform**: Modern browsers (Chrome, Firefox, Safari, Edge)
|
||||||
|
**Project Type**: Web application (monorepo: domain, application, web adapter)
|
||||||
|
**Performance Goals**: Search results <100ms, stat block display <200ms after cache, source fetch <5s on broadband
|
||||||
|
**Constraints**: Zero copyrighted prose in shipped bundle; offline-capable for cached data; single-user local-first
|
||||||
|
**Scale/Scope**: 3,312 creatures across 102 sources; index ~320 KB raw / ~52 KB gzipped
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Deterministic Domain Core | PASS | Index data is static. No I/O, randomness, or clocks in domain. Fetch/cache operations are purely adapter-layer. |
|
||||||
|
| II. Layered Architecture | PASS | New `BestiaryIndex` type in domain (pure data). IndexedDB cache and fetch logic in adapter layer. Application layer orchestrates via port interfaces. |
|
||||||
|
| III. Agent Boundary | N/A | No agent layer changes. |
|
||||||
|
| IV. Clarification-First | PASS | Spec is comprehensive with zero NEEDS CLARIFICATION markers. All decisions documented in assumptions. |
|
||||||
|
| V. Escalation Gates | PASS | Implementation follows spec → plan → tasks pipeline. |
|
||||||
|
| VI. MVP Baseline Language | PASS | Cache management UI (P4) is included but scoped as MVP. No permanent bans. |
|
||||||
|
| VII. No Gameplay Rules | PASS | No gameplay mechanics in plan — only data loading and caching architecture. |
|
||||||
|
| Merge Gate | PASS | All changes must pass `pnpm check` before commit. |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/029-on-demand-bestiary/
|
||||||
|
├── spec.md
|
||||||
|
├── plan.md # This file
|
||||||
|
├── research.md # Phase 0 output
|
||||||
|
├── data-model.md # Phase 1 output
|
||||||
|
├── quickstart.md # Phase 1 output
|
||||||
|
├── contracts/ # Phase 1 output
|
||||||
|
│ └── bestiary-port.md
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
└── tasks.md # Phase 2 output (via /speckit.tasks)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
packages/domain/src/
|
||||||
|
├── creature-types.ts # Extended: BestiaryIndexEntry, BestiaryIndex types
|
||||||
|
└── index.ts # Updated exports
|
||||||
|
|
||||||
|
packages/application/src/
|
||||||
|
├── ports.ts # Extended: BestiarySourceCache port interface
|
||||||
|
├── index.ts # Updated exports
|
||||||
|
└── (no new use cases — orchestration stays in adapter hooks)
|
||||||
|
|
||||||
|
apps/web/src/
|
||||||
|
├── adapters/
|
||||||
|
│ ├── bestiary-adapter.ts # Unchanged (processes fetched data as before)
|
||||||
|
│ ├── strip-tags.ts # Unchanged
|
||||||
|
│ ├── bestiary-index-adapter.ts # NEW: loads/parses index.json, converts to domain types
|
||||||
|
│ └── bestiary-cache.ts # NEW: IndexedDB cache adapter implementing BestiarySourceCache
|
||||||
|
├── hooks/
|
||||||
|
│ ├── use-bestiary.ts # REWRITTEN: search from index, getCreature from cache
|
||||||
|
│ └── use-encounter.ts # MODIFIED: addFromBestiary uses index entry instead of full Creature
|
||||||
|
├── components/
|
||||||
|
│ ├── bestiary-search.tsx # MODIFIED: show source display name in results
|
||||||
|
│ ├── stat-block.tsx # Unchanged
|
||||||
|
│ ├── stat-block-panel.tsx # MODIFIED: trigger source fetch prompt when creature not cached
|
||||||
|
│ ├── source-fetch-prompt.tsx # NEW: fetch/upload prompt dialog
|
||||||
|
│ └── source-manager.tsx # NEW: cached source management UI
|
||||||
|
└── persistence/
|
||||||
|
└── encounter-storage.ts # Unchanged
|
||||||
|
|
||||||
|
data/bestiary/
|
||||||
|
├── index.json # Existing (shipped with app)
|
||||||
|
└── xmm.json # REMOVED from repo
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Follows existing monorepo layering. New adapter files for index loading and IndexedDB caching. New components for source fetch prompt and cache management. Domain extended with lightweight index types only — no I/O.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
> No constitution violations to justify.
|
||||||
76
specs/029-on-demand-bestiary/quickstart.md
Normal file
76
specs/029-on-demand-bestiary/quickstart.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Quickstart: On-Demand Bestiary with Pre-Indexed Search
|
||||||
|
|
||||||
|
**Feature**: 029-on-demand-bestiary
|
||||||
|
**Date**: 2026-03-10
|
||||||
|
|
||||||
|
## What This Feature Does
|
||||||
|
|
||||||
|
Replaces the bundled full bestiary file (one source, ~1.3 MB of copyrighted content) with:
|
||||||
|
1. A pre-shipped lightweight index (102 sources, 3,312 creatures, ~52 KB gzipped) for instant search and combatant creation
|
||||||
|
2. On-demand fetching of full stat block data per source, cached in IndexedDB
|
||||||
|
|
||||||
|
## Key Changes
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- `data/bestiary/xmm.json` — no longer shipped with the app
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
- `apps/web/src/adapters/bestiary-index-adapter.ts` — loads and parses the shipped index, converts compact format to domain types
|
||||||
|
- `apps/web/src/adapters/bestiary-cache.ts` — IndexedDB cache adapter for fetched source data
|
||||||
|
- `apps/web/src/components/source-fetch-prompt.tsx` — dialog prompting user to fetch/upload source data
|
||||||
|
- `apps/web/src/components/source-manager.tsx` — UI for viewing and clearing cached sources
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
- `apps/web/src/hooks/use-bestiary.ts` — rewritten to search from index and look up creatures from cache
|
||||||
|
- `apps/web/src/hooks/use-encounter.ts` — `addFromBestiary` accepts index entries (no fetch needed to add)
|
||||||
|
- `apps/web/src/components/bestiary-search.tsx` — shows source display name in results
|
||||||
|
- `apps/web/src/components/stat-block-panel.tsx` — triggers source fetch prompt when creature not cached
|
||||||
|
- `packages/domain/src/creature-types.ts` — new `BestiaryIndexEntry` and `BestiaryIndex` types
|
||||||
|
|
||||||
|
### Unchanged
|
||||||
|
- `apps/web/src/adapters/bestiary-adapter.ts` — normalization pipeline processes fetched data exactly as before
|
||||||
|
- `apps/web/src/adapters/strip-tags.ts` — tag stripping unchanged
|
||||||
|
- `apps/web/src/components/stat-block.tsx` — stat block rendering unchanged
|
||||||
|
- `apps/web/src/persistence/encounter-storage.ts` — encounter persistence unchanged
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
index.json (shipped, static)
|
||||||
|
↓ Vite JSON import
|
||||||
|
bestiary-index-adapter.ts
|
||||||
|
↓ BestiaryIndexEntry[]
|
||||||
|
use-bestiary.ts (search, add)
|
||||||
|
↓
|
||||||
|
bestiary-search.tsx → use-encounter.ts (addFromBestiary)
|
||||||
|
↓
|
||||||
|
stat-block-panel.tsx
|
||||||
|
↓ creatureId → source not cached?
|
||||||
|
source-fetch-prompt.tsx
|
||||||
|
↓ fetch URL or upload file
|
||||||
|
bestiary-adapter.ts (normalizeBestiary — unchanged)
|
||||||
|
↓ Creature[]
|
||||||
|
bestiary-cache.ts (IndexedDB)
|
||||||
|
↓
|
||||||
|
stat-block.tsx (renders full stat block)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm check # Must pass before every commit
|
||||||
|
pnpm test # Run all tests
|
||||||
|
pnpm typecheck # TypeScript type checking
|
||||||
|
pnpm --filter web dev # Dev server at localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- **Domain tests**: Pure function tests for new `BestiaryIndexEntry` type and any utility functions
|
||||||
|
- **Adapter tests**: Test index parsing (compact → readable format), test IndexedDB cache operations (mock IndexedDB via fake-indexeddb)
|
||||||
|
- **Component tests**: Not in scope (existing pattern — components tested via manual verification)
|
||||||
|
- **Integration**: Verify search returns multi-source results, verify add-from-index flow, verify fetch→cache→display flow
|
||||||
|
|
||||||
|
## New Dependency
|
||||||
|
|
||||||
|
- `idb` — Promise-based IndexedDB wrapper (~1.5 KB gzipped). Used only in `bestiary-cache.ts`.
|
||||||
113
specs/029-on-demand-bestiary/research.md
Normal file
113
specs/029-on-demand-bestiary/research.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# Research: On-Demand Bestiary with Pre-Indexed Search
|
||||||
|
|
||||||
|
**Feature**: 029-on-demand-bestiary
|
||||||
|
**Date**: 2026-03-10
|
||||||
|
|
||||||
|
## R-001: IndexedDB for Source Data Caching
|
||||||
|
|
||||||
|
**Decision**: Use IndexedDB via the `idb` wrapper library for caching fetched/uploaded bestiary source data.
|
||||||
|
|
||||||
|
**Rationale**: IndexedDB is the only browser storage API with sufficient capacity (hundreds of MB) for full bestiary JSON files (~1-3 MB per source). localStorage is limited to ~5-10 MB total and already used for encounter persistence. The `idb` library provides a promise-based wrapper that simplifies IndexedDB usage without adding significant bundle size (~1.5 KB gzipped).
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- **localStorage**: Insufficient capacity. A single source can be 1-3 MB; 102 sources would far exceed the ~5 MB limit.
|
||||||
|
- **Cache API (Service Worker)**: More complex setup, designed for HTTP response caching rather than structured data. Overkill for this use case.
|
||||||
|
- **Raw IndexedDB**: Viable but verbose callback-based API. The `idb` wrapper is minimal and well-maintained.
|
||||||
|
- **OPFS (Origin Private File System)**: Newer API with good capacity but less browser support and more complex access patterns.
|
||||||
|
|
||||||
|
## R-002: Index Loading Strategy
|
||||||
|
|
||||||
|
**Decision**: Import `index.json` as a static asset via Vite's JSON import, parsed at build time and included in the JS bundle.
|
||||||
|
|
||||||
|
**Rationale**: The index is ~320 KB raw / ~52 KB gzipped — small enough to include in the bundle. This ensures instant availability on app load with zero additional network requests. Vite's JSON import treeshakes unused fields and benefits from standard bundling optimizations (code splitting, compression).
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- **Fetch at runtime**: Adds a network request and loading state. Unnecessary for a 52 KB file that every session needs.
|
||||||
|
- **Web Worker**: Overhead of worker setup not justified for a single synchronous parse of 52 KB.
|
||||||
|
- **Lazy import**: Could defer initial load, but search is the primary interaction — it must be available immediately.
|
||||||
|
|
||||||
|
## R-003: Index-to-Domain Type Mapping
|
||||||
|
|
||||||
|
**Decision**: Create a `BestiaryIndexEntry` domain type that maps the compact index fields (n, s, ac, hp, dx, cr, ip, sz, tp) to readable properties. The adapter converts index entries to this type on load.
|
||||||
|
|
||||||
|
**Rationale**: The index uses abbreviated keys for size optimization. The domain should work with readable, typed properties. The adapter boundary is the right place for this translation, consistent with how `normalizeBestiary()` translates raw 5etools format to `Creature`.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- **Use index abbreviations in domain**: Violates readability conventions. Domain types should be self-documenting.
|
||||||
|
- **Convert index entries to full `Creature` objects**: Would require fabricating missing fields (traits, actions, speed, etc.). Better to have a distinct lightweight type.
|
||||||
|
|
||||||
|
## R-004: Search Architecture with Multi-Source Index
|
||||||
|
|
||||||
|
**Decision**: Search operates on an in-memory array of `BestiaryIndexEntry` objects, loaded once from the shipped index. Results include the source display name resolved from the index's source map. The search algorithm remains unchanged (case-insensitive substring, min 2 chars, max 10 results, alphabetical sort).
|
||||||
|
|
||||||
|
**Rationale**: 3,312 entries is trivially searchable in-memory with no performance concern. The existing algorithm scales linearly and completes in <1ms for this dataset size. No indexing data structure (trie, inverted index) is needed.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- **Fuse.js / MiniSearch**: Fuzzy search libraries. Overhead not justified — exact substring matching is the specified behavior and works well for creature name lookup.
|
||||||
|
- **Pre-sorted index**: Could avoid sort on each search, but 10-element sort is negligible. Simplicity wins.
|
||||||
|
|
||||||
|
## R-005: Source Fetch URL Pattern
|
||||||
|
|
||||||
|
**Decision**: Default fetch URLs follow the pattern `https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/bestiary-{source-code-lowercase}.json`. The URL is pre-filled but editable, allowing users to point to mirrors, forks, or local servers.
|
||||||
|
|
||||||
|
**Rationale**: This pattern matches the 5etools repository structure. Making the URL editable addresses mirror availability, corporate firewalls, and self-hosted scenarios. The app makes no guarantees about URL availability — this is explicitly a user responsibility per the spec.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- **Hardcoded URL per source**: Too rigid. Mirror URLs change, and some users need local hosting.
|
||||||
|
- **No default URL**: Bad UX — most users will use the standard mirror. Pre-filling saves effort.
|
||||||
|
- **Source-to-URL mapping file**: Over-engineering. The pattern is consistent across all sources; special cases can be handled by editing the URL.
|
||||||
|
|
||||||
|
## R-006: Fallback for Unavailable IndexedDB
|
||||||
|
|
||||||
|
**Decision**: If IndexedDB is unavailable (private browsing in some browsers, storage quota exceeded), fall back to an in-memory `Map<string, Creature[]>` for the current session. Show a non-blocking warning that cached data will not persist.
|
||||||
|
|
||||||
|
**Rationale**: The app should degrade gracefully. In-memory caching still provides the core stat block functionality for the session — only cross-session persistence is lost. This matches the existing pattern where localStorage failures are handled silently.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- **Block the feature entirely**: Too disruptive. Users in private browsing should still be able to use stat blocks.
|
||||||
|
- **Fall back to localStorage**: Capacity is still limited and would compete with encounter persistence.
|
||||||
|
|
||||||
|
## R-007: Stat Block Lookup Flow Redesign
|
||||||
|
|
||||||
|
**Decision**: `useBestiary.getCreature(creatureId)` becomes an async operation that:
|
||||||
|
1. Checks the in-memory cache (populated from IndexedDB on mount)
|
||||||
|
2. If found, returns the `Creature` immediately
|
||||||
|
3. If not found, returns `undefined` and the component shows the source fetch prompt
|
||||||
|
|
||||||
|
The `StatBlockPanel` component handles the transition: it renders a `SourceFetchPrompt` when `getCreature` returns `undefined` for a combatant with a `creatureId`. After successful fetch, the creature data is available in cache and the stat block renders.
|
||||||
|
|
||||||
|
**Rationale**: This keeps the lookup interface simple while adding the fetch-on-demand layer. The component tree already handles loading states. The prompt appears at the point of need (stat block view), not preemptively.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- **Auto-fetch without prompting**: Violates spec requirement (user must confirm). Also risks unwanted network requests.
|
||||||
|
- **Pre-fetch all sources on app load**: Defeats the purpose of on-demand loading. Would download hundreds of MB.
|
||||||
|
|
||||||
|
## R-008: File Upload Processing
|
||||||
|
|
||||||
|
**Decision**: The source fetch prompt includes an "Upload file" button that opens a native file picker (`<input type="file" accept=".json">`). The uploaded file is read via `FileReader`, parsed as JSON, and processed through the same `normalizeBestiary()` pipeline as fetched data.
|
||||||
|
|
||||||
|
**Rationale**: Reuses the existing normalization pipeline. Native file picker is the simplest, most accessible approach. No drag-and-drop complexity needed for a secondary flow.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- **Drag-and-drop zone**: Additional UI complexity for a fallback feature. Can be added later if needed.
|
||||||
|
- **Paste JSON**: Impractical for multi-MB files.
|
||||||
|
|
||||||
|
## R-009: Cache Keying Strategy
|
||||||
|
|
||||||
|
**Decision**: IndexedDB object store uses the source code (e.g., "XMM") as the key. Each record stores the full array of normalized `Creature` objects for that source. A separate metadata store tracks cached source codes and timestamps for the management UI.
|
||||||
|
|
||||||
|
**Rationale**: Source-level granularity matches the fetch-per-source model. One key per source keeps the cache simple to query and clear. Storing normalized `Creature` objects avoids re-normalization on every load.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- **Creature-level keys**: More granular but adds complexity. No use case requires per-creature cache operations.
|
||||||
|
- **Store raw JSON**: Requires re-normalization on each load. Wastes CPU for no benefit.
|
||||||
|
|
||||||
|
## R-010: addFromBestiary Adaptation
|
||||||
|
|
||||||
|
**Decision**: `addFromBestiary` in `use-encounter.ts` will accept either a full `Creature` (for cached sources) or a `BestiaryIndexEntry` (for uncached sources). When given an index entry, it constructs a minimal combatant with name, HP, AC, and initiative data. The `creatureId` is set using the same `{source}:{slug}` pattern as before, derived from the index entry's source and name.
|
||||||
|
|
||||||
|
**Rationale**: The index contains all data needed for combatant creation (name, HP, AC, DEX, CR, initiative proficiency). No fetch is needed to add a creature. The `creatureId` enables later stat block lookup when the source is cached.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- **Always require full Creature**: Would force a source fetch before adding, contradicting the spec's "no fetch needed for adding" requirement.
|
||||||
|
- **New use case in application layer**: The operation is adapter-level orchestration, not domain logic. Keeping it in the hook is consistent with the existing pattern.
|
||||||
131
specs/029-on-demand-bestiary/spec.md
Normal file
131
specs/029-on-demand-bestiary/spec.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# Feature Specification: On-Demand Bestiary with Pre-Indexed Search
|
||||||
|
|
||||||
|
**Feature Branch**: `029-on-demand-bestiary`
|
||||||
|
**Created**: 2026-03-10
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Replace the bundled bestiary JSON with a two-tier architecture: a lightweight search index shipped with the app and on-demand full stat block data fetched at runtime and cached client-side."
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Search All Creatures Instantly (Priority: P1)
|
||||||
|
|
||||||
|
A DM searches for a creature by name. The search operates against a pre-shipped index of 3,312 creatures across 102 sources. Results appear instantly with the creature name and source display name (e.g., "Goblin (Monster Manual (2025))"). The DM selects a creature to add it to the encounter. Name, HP, AC, and initiative data are prefilled directly from the index — no network fetch required.
|
||||||
|
|
||||||
|
**Why this priority**: This is the core interaction loop. Every session starts with adding creatures. Expanding from 1 source to 102 sources dramatically increases the app's usefulness, and doing it without any fetch latency preserves the current snappy UX.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by typing a creature name, seeing multi-source results, and adding a creature to verify HP/AC/initiative are populated correctly.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the app is loaded, **When** the DM types "gob" in the search field, **Then** results include goblins from multiple sources, each labeled with the source display name, sorted alphabetically, limited to 10 results.
|
||||||
|
2. **Given** search results are visible, **When** the DM selects "Goblin (Monster Manual (2025))", **Then** a combatant is added with the correct name, HP, AC, and initiative modifier — no network request is made.
|
||||||
|
3. **Given** the app is loaded, **When** the DM types a single character, **Then** no results appear (minimum 2 characters required).
|
||||||
|
4. **Given** the app is loaded, **When** the DM searches for a creature that exists only in an obscure source (e.g., "Muk" from "Adventure with Muk"), **Then** the creature appears in results with its source name.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - View Full Stat Block via On-Demand Source Fetch (Priority: P2)
|
||||||
|
|
||||||
|
A DM clicks to view the stat block of a creature whose source data has not been loaded yet. The app displays a prompt: "Load [Source Display Name] bestiary data?" with a pre-filled URL pointing to the raw source file. The DM confirms, the app fetches the JSON, normalizes it, and caches all creatures from that source. The stat block then displays. For any subsequent creature from the same source, the stat block appears instantly without prompting.
|
||||||
|
|
||||||
|
**Why this priority**: Stat blocks are essential for running combat but are secondary to adding creatures. This story also addresses the legal motivation — no copyrighted prose is shipped with the app.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by adding a creature, opening its stat block, confirming the fetch prompt, and verifying the stat block renders. Then opening another creature from the same source to verify no prompt appears.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a creature from an uncached source is in the encounter, **When** the DM opens its stat block, **Then** a prompt appears asking to load the source data with an editable URL field pre-filled with the correct raw file URL.
|
||||||
|
2. **Given** the fetch prompt is visible, **When** the DM confirms the fetch, **Then** the app downloads the JSON, normalizes it, caches all creatures from that source, and displays the stat block.
|
||||||
|
3. **Given** source data for "Monster Manual (2025)" has been cached, **When** the DM opens the stat block for any other creature from that source, **Then** the stat block displays instantly with no prompt.
|
||||||
|
4. **Given** the fetch prompt is visible, **When** the DM edits the URL to point to a mirror or local server, **Then** the app fetches from the edited URL instead.
|
||||||
|
5. **Given** a creature is in the encounter, **When** the DM opens its stat block and the source is not cached, **Then** the creature's index data (HP, AC, etc.) remains visible in the combatant row regardless of whether the fetch succeeds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Manual File Upload as Fetch Alternative (Priority: P3)
|
||||||
|
|
||||||
|
A DM who cannot access the URL (corporate firewall, offline use) uses a file upload option to load bestiary data from a local JSON file. The file is processed identically to a fetched file — normalized and cached by source.
|
||||||
|
|
||||||
|
**Why this priority**: Important for accessibility and offline scenarios, but most users will use the URL fetch.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by selecting a local JSON file in the upload dialog and verifying the stat blocks become available.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the source fetch prompt is visible, **When** the DM chooses "Upload file" and selects a valid bestiary JSON from their filesystem, **Then** the app normalizes and caches the data, and stat blocks become available.
|
||||||
|
2. **Given** the DM uploads an invalid or malformed JSON file, **When** the upload completes, **Then** the app shows a user-friendly error message and allows retry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 4 - Manage Cached Sources (Priority: P4)
|
||||||
|
|
||||||
|
A DM wants to see which sources are cached, clear a specific source's cache, or clear all cached data. A settings/management UI provides this visibility and control.
|
||||||
|
|
||||||
|
**Why this priority**: Cache management is a housekeeping task, not part of the core combat flow. Important for long-term usability but not needed for initial sessions.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by caching one or more sources, opening the management UI, verifying the list, and clearing individual or all caches.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** two sources have been cached, **When** the DM opens the source management UI, **Then** both sources are listed with their display names.
|
||||||
|
2. **Given** the source management UI is open, **When** the DM clears a single source, **Then** that source's data is removed and stat blocks for its creatures require re-fetching, while other cached sources remain.
|
||||||
|
3. **Given** the source management UI is open, **When** the DM clears all cached data, **Then** all source data is removed and all stat blocks require re-fetching.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when the DM searches for a creature name that appears in multiple sources? Results show all matches, each labeled with the source display name, sorted alphabetically.
|
||||||
|
- What happens when a network fetch fails mid-download? The app shows an error with the option to retry or change the URL. The creature remains in the encounter with its index data intact.
|
||||||
|
- What happens when the DM adds a creature, caches its source, then clears the cache? The creature remains in the encounter with its index data. Opening the stat block triggers the fetch prompt again.
|
||||||
|
- What happens when the fetched JSON does not match the expected format? The normalization adapter handles format variations as it does today. If normalization fails entirely, an error is shown.
|
||||||
|
- What happens when persistent client-side storage is unavailable (private browsing, storage full)? The app falls back to in-memory caching for the current session and warns the user that data will not persist.
|
||||||
|
- What happens when the browser is offline? The fetch prompt is shown but the fetch fails. The DM can use the file upload alternative. Previously cached sources remain available.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The app MUST ship a pre-generated search index containing creature name, source code, AC, HP average, DEX score, CR, initiative proficiency multiplier, size code, and creature type for all indexed creatures.
|
||||||
|
- **FR-002**: The app MUST include a source display name map that translates source codes to human-readable names (e.g., "XMM" to "Monster Manual (2025)").
|
||||||
|
- **FR-003**: Search MUST operate against the full shipped index — case-insensitive substring match on creature name, minimum 2 characters, maximum 10 results, sorted alphabetically.
|
||||||
|
- **FR-004**: Search results MUST display the source display name alongside the creature name.
|
||||||
|
- **FR-005**: Adding a creature from search MUST populate name, HP, AC, and initiative data directly from the index without any network fetch.
|
||||||
|
- **FR-006**: When a user views a stat block for a creature whose source is not cached, the app MUST display a prompt to load the source data.
|
||||||
|
- **FR-007**: The source fetch prompt MUST include an editable URL field pre-filled with the default URL for that source's raw data file.
|
||||||
|
- **FR-008**: On confirmation, the app MUST fetch the JSON, normalize it through the existing normalization pipeline, and cache all creatures from that source.
|
||||||
|
- **FR-009**: Cached source data MUST persist across browser sessions using client-side storage.
|
||||||
|
- **FR-010**: The app MUST provide a file upload option as an alternative to URL fetching, processing the uploaded file identically.
|
||||||
|
- **FR-011**: The app MUST provide a management UI showing cached sources with options to clear individual sources or all cached data.
|
||||||
|
- **FR-012**: The bundled full bestiary data file MUST be removed from the distributed app.
|
||||||
|
- **FR-013**: The existing normalization adapter and tag-stripping utility MUST remain unchanged — they process fetched data exactly as before.
|
||||||
|
- **FR-014**: If a fetch or upload fails, the app MUST show a user-friendly error message with options to retry or change the URL. The creature's index data MUST remain intact in the encounter.
|
||||||
|
- **FR-015**: The source fetch prompt MUST appear once per source, not once per creature. After fetching a source, all its creatures' stat blocks become available.
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **Search Index**: Pre-shipped lightweight dataset containing mechanical facts (name, source, AC, HP, DEX, CR, initiative proficiency, size, type) for all creatures. Keyed by name + source for uniqueness.
|
||||||
|
- **Source**: A D&D publication identified by a code (e.g., "XMM") with a display name (e.g., "Monster Manual (2025)"). Contains multiple creatures. Caching and fetching operate at the source level.
|
||||||
|
- **Cached Source Data**: The full normalized bestiary data for a single source, stored in persistent client-side storage. Contains complete creature stat blocks including traits, actions, and descriptions.
|
||||||
|
- **Creature (Index Entry)**: A lightweight record from the search index — sufficient for adding a combatant but insufficient for rendering a full stat block.
|
||||||
|
- **Creature (Full)**: A complete creature record with all stat block data (traits, actions, legendary actions, etc.), available only after source data is fetched/uploaded and cached.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: All 3,312 indexed creatures are searchable immediately on app load, with search results appearing within 100ms of typing.
|
||||||
|
- **SC-002**: Adding a creature from search to the encounter completes without any network request and within 200ms.
|
||||||
|
- **SC-003**: After a source is cached, stat blocks for any creature from that source display within 200ms with no additional prompt.
|
||||||
|
- **SC-004**: The app ships zero copyrighted prose content — only mechanical facts and creature names in the index.
|
||||||
|
- **SC-005**: Source data import (fetch or upload) for a typical source completes and becomes usable within 5 seconds on a standard broadband connection.
|
||||||
|
- **SC-006**: Cached data persists across browser sessions — closing and reopening the browser does not require re-fetching previously loaded sources.
|
||||||
|
- **SC-007**: The shipped app bundle size decreases compared to the current approach (removing the bundled full bestiary data, replacing with the lightweight index).
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The pre-generated search index (`data/bestiary/index.json`) is already available in the repository and maintained separately via the generation script.
|
||||||
|
- The default fetch URLs follow a predictable pattern based on the source code, allowing the app to pre-fill the URL for each source.
|
||||||
|
- Persistent client-side storage (e.g., IndexedDB) is available in all target browsers. Private browsing mode may limit persistence, handled as an edge case with in-memory fallback.
|
||||||
|
- The existing normalization adapter can handle bestiary JSON from any of the 102 sources, not just the currently bundled one. If source-specific variations exist, adapter updates are implementation concerns.
|
||||||
|
- Users are responsible for sourcing their own bestiary data files — the app provides the fetch mechanism but makes no guarantees about URL availability.
|
||||||
198
specs/029-on-demand-bestiary/tasks.md
Normal file
198
specs/029-on-demand-bestiary/tasks.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# Tasks: On-Demand Bestiary with Pre-Indexed Search
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/029-on-demand-bestiary/`
|
||||||
|
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||||
|
|
||||||
|
**Tests**: No test tasks included — not explicitly requested in the feature specification.
|
||||||
|
|
||||||
|
**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: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: Install dependencies and add domain types that all stories depend on
|
||||||
|
|
||||||
|
- [x] T001 Install `idb` dependency in `apps/web/package.json` via `pnpm --filter web add idb`
|
||||||
|
- [x] T002 Add `BestiaryIndexEntry` and `BestiaryIndex` readonly interfaces to `packages/domain/src/creature-types.ts` — `BestiaryIndexEntry` has fields: name (string), source (string), ac (number), hp (number), dex (number), cr (string), initiativeProficiency (number), size (string), type (string). `BestiaryIndex` has fields: sources (Record<string, string>), creatures (readonly BestiaryIndexEntry[])
|
||||||
|
- [x] T003 Export `BestiaryIndexEntry` and `BestiaryIndex` types from `packages/domain/src/index.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Core adapters and port interfaces that MUST be complete before ANY user story can be implemented
|
||||||
|
|
||||||
|
**CRITICAL**: No user story work can begin until this phase is complete
|
||||||
|
|
||||||
|
- [x] T004 [P] Create `apps/web/src/adapters/bestiary-index-adapter.ts` — import `data/bestiary/index.json` as a Vite static JSON import; export a `loadBestiaryIndex()` function that maps compact index fields (n→name, s→source, ac→ac, hp→hp, dx→dex, cr→cr, ip→initiativeProficiency, sz→size, tp→type) to `BestiaryIndex` domain type; export `getDefaultFetchUrl(sourceCode: string)` returning `https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/bestiary-{sourceCode-lowercase}.json`; export `getSourceDisplayName(sourceCode: string)` resolving from the sources map
|
||||||
|
- [x] T005 [P] Create `apps/web/src/adapters/bestiary-cache.ts` — implement IndexedDB cache adapter using `idb` library; database name `"initiative-bestiary"`, object store `"sources"` with keyPath `"sourceCode"`; implement functions: `getCreature(creatureId)` extracts source from ID prefix, looks up source record, finds creature by ID; `isSourceCached(sourceCode)` checks store; `cacheSource(sourceCode, displayName, creatures)` stores `CachedSourceRecord` with cachedAt timestamp and creatureCount; `getCachedSources()` returns all records' metadata; `clearSource(sourceCode)` deletes one record; `clearAll()` clears store; `loadAllCachedCreatures()` returns a `Map<CreatureId, Creature>` from all cached sources for in-memory lookup; include in-memory `Map` fallback if IndexedDB open fails (private browsing)
|
||||||
|
- [x] T006 [P] Add `BestiarySourceCache` port interface to `packages/application/src/ports.ts` — define interface with methods: `getCreature(creatureId: CreatureId): Creature | undefined`, `isSourceCached(sourceCode: string): boolean`; export from `packages/application/src/index.ts`
|
||||||
|
|
||||||
|
**Checkpoint**: Foundation ready — index adapter parses shipped data, cache adapter persists fetched data, port interface defined
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 — Search All Creatures Instantly (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Replace the single-source bundled bestiary with multi-source index search. All 3,312 creatures searchable instantly. Adding a creature populates HP/AC/initiative from the index without any network fetch.
|
||||||
|
|
||||||
|
**Independent Test**: Type a creature name → see results from multiple sources with display names → select one → combatant added with correct HP, AC, initiative modifier. No network requests made.
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T007 [US1] Rewrite `apps/web/src/hooks/use-bestiary.ts` — replace the `xmm.json` dynamic import with `loadBestiaryIndex()` from bestiary-index-adapter; store `BestiaryIndex` in state; rewrite `search(query)` to filter `index.creatures` by case-insensitive substring on name (min 2 chars, max 10 results, sorted alphabetically), returning results with `sourceDisplayName` resolved from `index.sources`; keep `getCreature(id)` for stat block lookup (initially returns undefined for all — cache integration comes in US2); export `searchIndex` function and `sourceDisplayName` resolver; update `BestiaryHook` interface to expose search results as index entries with source display names
|
||||||
|
- [x] T008 [US1] Update `apps/web/src/components/bestiary-search.tsx` — modify search result rendering to show source display name alongside creature name (e.g., "Goblin (Monster Manual (2025))"); update the type of items from `Creature` to index-based search result type; ensure `onSelectCreature` callback receives the index entry data needed by addFromBestiary
|
||||||
|
- [x] T009 [US1] Update `addFromBestiary` in `apps/web/src/hooks/use-encounter.ts` — accept a `BestiaryIndexEntry` (or compatible object with name, hp, ac, dex, cr, initiativeProficiency, source) instead of requiring a full `Creature`; derive `creatureId` from `{source.toLowerCase()}:{slugify(name)}` using existing slug logic; call `addCombatantUseCase`, `setHpUseCase(hp)`, `setAcUseCase(ac)`; set `creatureId` on the combatant for later stat block lookup
|
||||||
|
- [x] T010 [US1] Remove `data/bestiary/xmm.json` from the repository (git rm); verify no remaining imports reference it; update any test files that imported xmm.json to use test fixtures or the index instead
|
||||||
|
|
||||||
|
**Checkpoint**: Search works across all 102 sources. Creatures can be added from any source with correct stats. No bundled copyrighted content. Stat blocks not yet available (US2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 — View Full Stat Block via On-Demand Source Fetch (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: When a stat block is opened for a creature whose source is not cached, prompt the user to fetch the source data from a URL. After fetching, normalize and cache all creatures from that source in IndexedDB. Subsequent lookups for any creature from that source are instant.
|
||||||
|
|
||||||
|
**Independent Test**: Add a creature → open its stat block → see fetch prompt with pre-filled URL → confirm → stat block renders. Open another creature from same source → stat block renders instantly with no prompt.
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T011 [P] [US2] Create `apps/web/src/components/source-fetch-prompt.tsx` — dialog/card component that displays "Load [sourceDisplayName] bestiary data?" with an editable URL input pre-filled via `getDefaultFetchUrl(sourceCode)` from bestiary-index-adapter; "Load" button triggers fetch; show loading spinner during fetch; on success call `onSourceLoaded` callback; on error show error message with retry option and option to change URL; include error state for network failures and normalization failures
|
||||||
|
- [x] T012 [US2] Integrate cache into `apps/web/src/hooks/use-bestiary.ts` — on mount, call `loadAllCachedCreatures()` from bestiary-cache to populate an in-memory `Map<CreatureId, Creature>`; update `getCreature(id)` to look up from this map; export `isSourceCached(sourceCode)` delegating to bestiary-cache; export `fetchAndCacheSource(sourceCode, url)` that fetches the URL, parses JSON, calls `normalizeBestiary()` from bestiary-adapter, calls `cacheSource()` from bestiary-cache, and updates the in-memory creature map; return cache loading state
|
||||||
|
- [x] T013 [US2] Update `apps/web/src/components/stat-block-panel.tsx` — when `getCreature(creatureId)` returns `undefined` for a combatant that has a `creatureId`, extract the source code from the creatureId prefix; if `isSourceCached(source)` is false, render `SourceFetchPrompt` instead of the stat block; after `onSourceLoaded` callback fires, re-lookup the creature and render the stat block; if source is cached but creature not found (edge case), show appropriate message
|
||||||
|
|
||||||
|
**Checkpoint**: Full stat block flow works end-to-end. Source data fetched once per source, cached in IndexedDB, persists across sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 — Manual File Upload as Fetch Alternative (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Add a file upload option to the source fetch prompt so users can load bestiary data from a local JSON file when the URL is inaccessible.
|
||||||
|
|
||||||
|
**Independent Test**: Open source fetch prompt → click "Upload file" → select a local bestiary JSON → stat blocks become available.
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [x] T014 [US3] Add file upload to `apps/web/src/components/source-fetch-prompt.tsx` — add an "Upload file" button/link below the URL fetch section; clicking opens a native file picker (`<input type="file" accept=".json">`); on file selection, read via FileReader, parse JSON, call `normalizeBestiary()`, call `cacheSource()`, and invoke `onSourceLoaded` callback; handle errors (invalid JSON, normalization failure) with user-friendly messages and retry option
|
||||||
|
|
||||||
|
**Checkpoint**: Both URL fetch and file upload paths work. Users in offline/restricted environments can still load stat blocks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: User Story 4 — Manage Cached Sources (Priority: P4)
|
||||||
|
|
||||||
|
**Goal**: Provide a UI for viewing which sources are cached and clearing individual or all cached data.
|
||||||
|
|
||||||
|
**Independent Test**: Cache one or more sources → open management UI → see cached sources listed → clear one → verify it requires re-fetch → clear all → verify all require re-fetch.
|
||||||
|
|
||||||
|
### Implementation for User Story 4
|
||||||
|
|
||||||
|
- [x] T015 [P] [US4] Create `apps/web/src/components/source-manager.tsx` — component showing a list of cached sources via `getCachedSources()` from bestiary-cache; each row shows source display name, creature count, and a "Clear" button calling `clearSource(sourceCode)`; include a "Clear All" button calling `clearAll()`; show empty state when no sources are cached; after clearing, update the in-memory creature map in use-bestiary
|
||||||
|
- [x] T016 [US4] Wire source manager into the app UI — add a settings/gear icon button (Lucide `Settings` icon) in the top bar or an accessible location that opens the `SourceManager` component in a dialog or panel; ensure it integrates with the existing layout without disrupting the encounter flow
|
||||||
|
|
||||||
|
**Checkpoint**: Cache management fully functional. Users can inspect and clear cached data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Edge cases, fallbacks, and merge gate validation
|
||||||
|
|
||||||
|
- [x] T017 Verify IndexedDB-unavailable fallback in `apps/web/src/adapters/bestiary-cache.ts` — ensure that when IndexedDB open fails (e.g., private browsing), the adapter silently falls back to in-memory storage; show a non-blocking warning via console or UI toast that cached data will not persist across sessions
|
||||||
|
- [x] T018 Run `pnpm check` (knip + format + lint + typecheck + test) and fix all issues — ensure no unused exports from removed xmm.json references; verify layer boundary checks pass; fix any TypeScript errors from type changes; ensure Biome formatting is correct
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies — can start immediately
|
||||||
|
- **Foundational (Phase 2)**: Depends on Phase 1 completion — BLOCKS all user stories
|
||||||
|
- **US1 (Phase 3)**: Depends on Phase 2 — core search and add flow
|
||||||
|
- **US2 (Phase 4)**: Depends on Phase 3 — stat block fetch needs working search/add
|
||||||
|
- **US3 (Phase 5)**: Depends on Phase 4 — file upload extends the fetch prompt from US2
|
||||||
|
- **US4 (Phase 6)**: Depends on US2 (Phase 4) — T015 needs the in-memory creature map from T012
|
||||||
|
- **Polish (Phase 7)**: Depends on all user stories being complete
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1 (P1)**: Requires Foundational (Phase 2) — no other story dependencies
|
||||||
|
- **US2 (P2)**: Requires US1 (uses rewritten use-bestiary hook and creatureId links)
|
||||||
|
- **US3 (P3)**: Requires US2 (extends source-fetch-prompt.tsx created in US2)
|
||||||
|
- **US4 (P4)**: Requires US2 (T012 establishes the in-memory creature map that T015 must update after clearing)
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Adapter/model tasks before hook integration tasks
|
||||||
|
- Hook integration before component tasks
|
||||||
|
- Core implementation before edge cases
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- **Phase 2**: T004, T005, T006 can all run in parallel (different files, no dependencies)
|
||||||
|
- **Phase 3**: T007 first, then T008/T009 can parallelize (different files), T010 after all
|
||||||
|
- **Phase 4**: T011 can parallelize with T012 (different files), T013 after both
|
||||||
|
- **Phase 6**: T015 depends on T012 (US2); T016 after T015
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: Phase 2 (Foundational)
|
||||||
|
|
||||||
|
```text
|
||||||
|
# All three foundational tasks can run simultaneously:
|
||||||
|
Task T004: "Create bestiary-index-adapter.ts" (apps/web/src/adapters/)
|
||||||
|
Task T005: "Create bestiary-cache.ts" (apps/web/src/adapters/)
|
||||||
|
Task T006: "Add BestiarySourceCache port" (packages/application/src/ports.ts)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```text
|
||||||
|
# After T007 (rewrite use-bestiary.ts):
|
||||||
|
Task T008: "Update bestiary-search.tsx" (apps/web/src/components/)
|
||||||
|
Task T009: "Update addFromBestiary" (apps/web/src/hooks/use-encounter.ts)
|
||||||
|
# Then T010 after both complete
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup (T001–T003)
|
||||||
|
2. Complete Phase 2: Foundational (T004–T006)
|
||||||
|
3. Complete Phase 3: User Story 1 (T007–T010)
|
||||||
|
4. **STOP and VALIDATE**: Search works across 102 sources, creatures add with correct stats, no bundled copyrighted content
|
||||||
|
5. Deploy/demo if ready — app is fully functional for adding creatures; stat blocks unavailable until US2
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Setup + Foundational → Foundation ready
|
||||||
|
2. US1 → Multi-source search and add (MVP!)
|
||||||
|
3. US2 → On-demand stat blocks via URL fetch
|
||||||
|
4. US3 → File upload alternative
|
||||||
|
5. US4 → Cache management
|
||||||
|
6. Polish → Edge cases and merge gate
|
||||||
|
7. Each story adds value without breaking previous stories
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- [P] tasks = different files, no dependencies
|
||||||
|
- [Story] label maps task to specific user story for traceability
|
||||||
|
- US4 (cache management) is independent of US2/US3 and can be built in parallel if desired
|
||||||
|
- The existing `normalizeBestiary()` and `stripTags()` are unchanged — no tasks needed for them
|
||||||
|
- The existing `stat-block.tsx` rendering component is unchanged
|
||||||
|
- The existing `encounter-storage.ts` persistence is unchanged
|
||||||
|
- Commit after each task or logical group
|
||||||
|
- Stop at any checkpoint to validate story independently
|
||||||
34
specs/030-bulk-import-sources/checklists/requirements.md
Normal file
34
specs/030-bulk-import-sources/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Specification Quality Checklist: Bulk Import All Sources
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-10
|
||||||
|
**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. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||||
42
specs/030-bulk-import-sources/data-model.md
Normal file
42
specs/030-bulk-import-sources/data-model.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Data Model: Bulk Import All Sources
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
### BulkImportState
|
||||||
|
|
||||||
|
Tracks the progress and outcome of a bulk import operation.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| status | "idle" / "loading" / "complete" / "partial-failure" | Current phase of the import operation |
|
||||||
|
| total | number | Total number of sources to fetch (excludes already-cached) |
|
||||||
|
| completed | number | Number of sources successfully fetched and cached |
|
||||||
|
| failed | number | Number of sources that failed to fetch |
|
||||||
|
|
||||||
|
**State Transitions**:
|
||||||
|
- `idle` → `loading`: User clicks "Load All"
|
||||||
|
- `loading` → `complete`: All sources fetched successfully (failed === 0)
|
||||||
|
- `loading` → `partial-failure`: Some sources failed (failed > 0)
|
||||||
|
- `complete` / `partial-failure` → `idle`: User dismisses or starts a new import
|
||||||
|
|
||||||
|
### ToastNotification
|
||||||
|
|
||||||
|
Lightweight notification data for the toast component.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| message | string | Primary text to display (e.g., "Loading sources... 34/102") |
|
||||||
|
| progress | number (0-1) or undefined | Optional progress bar value |
|
||||||
|
| dismissible | boolean | Whether the toast shows a dismiss button |
|
||||||
|
| autoDismissMs | number or undefined | Auto-dismiss delay in ms; undefined means persistent |
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
- **BulkImportState** drives the content of both the side panel progress UI and the toast notification.
|
||||||
|
- **ToastNotification** is derived from BulkImportState when the side panel is closed during an active import.
|
||||||
|
- Both consume state from the `useBulkImport` hook.
|
||||||
|
|
||||||
|
## Existing Entities (unchanged)
|
||||||
|
|
||||||
|
- **CachedSourceRecord** (bestiary-cache.ts): Stores normalized creature data per source in IndexedDB. No schema changes.
|
||||||
|
- **BestiaryIndex** (domain): Read-only index with source codes and compact creature entries. No changes.
|
||||||
67
specs/030-bulk-import-sources/plan.md
Normal file
67
specs/030-bulk-import-sources/plan.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Implementation Plan: Bulk Import All Sources
|
||||||
|
|
||||||
|
**Branch**: `030-bulk-import-sources` | **Date**: 2026-03-10 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/030-bulk-import-sources/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a "Bulk Import All Sources" button to the top bar that opens the stat block side panel with a bulk import prompt. The user confirms a base URL, and the app fetches all bestiary source files concurrently, normalizes each, and caches them in IndexedDB. Progress is shown via a counter and progress bar in the side panel; if the panel is closed mid-import, a lightweight toast notification takes over progress display.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: TypeScript 5.8 (strict mode, verbatimModuleSyntax)
|
||||||
|
**Primary Dependencies**: React 19, Vite 6, Tailwind CSS v4, Lucide React (icons), idb (IndexedDB wrapper)
|
||||||
|
**Storage**: IndexedDB for cached source data (existing via `bestiary-cache.ts`); localStorage for encounter persistence (existing, unchanged)
|
||||||
|
**Testing**: Vitest
|
||||||
|
**Target Platform**: Browser (desktop + mobile)
|
||||||
|
**Project Type**: Web application (React SPA)
|
||||||
|
**Performance Goals**: Non-blocking async import; UI remains responsive during ~12.5 MB download
|
||||||
|
**Constraints**: All fetches fire concurrently (browser connection pooling); no third-party toast library
|
||||||
|
**Scale/Scope**: All sources from bestiary index (currently ~102–104), ~12.5 MB total data
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Deterministic Domain Core | PASS | No domain changes. All fetch/cache logic is in the adapter layer. |
|
||||||
|
| II. Layered Architecture | PASS | Bulk import logic lives entirely in the adapter/UI layer (hooks + components). No domain or application layer changes needed. |
|
||||||
|
| III. Agent Boundary | N/A | No agent layer involvement. |
|
||||||
|
| IV. Clarification-First | PASS | Feature description was comprehensive; no ambiguities remain. |
|
||||||
|
| V. Escalation Gates | PASS | All functionality is within spec scope. |
|
||||||
|
| VI. MVP Baseline Language | PASS | No permanent bans introduced. |
|
||||||
|
| VII. No Gameplay Rules | PASS | No gameplay mechanics involved. |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/030-bulk-import-sources/
|
||||||
|
├── 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
|
||||||
|
apps/web/src/
|
||||||
|
├── adapters/
|
||||||
|
│ ├── bestiary-cache.ts # Existing — add isSourceCached batch check
|
||||||
|
│ └── bestiary-index-adapter.ts # Existing — add getAllSourceCodes()
|
||||||
|
├── components/
|
||||||
|
│ ├── action-bar.tsx # Existing — add Import button
|
||||||
|
│ ├── stat-block-panel.tsx # Existing — add bulk import mode
|
||||||
|
│ ├── bulk-import-prompt.tsx # NEW — bulk import UI (description + URL + progress)
|
||||||
|
│ └── toast.tsx # NEW — lightweight toast notification component
|
||||||
|
├── hooks/
|
||||||
|
│ ├── use-bestiary.ts # Existing — add bulkImport method
|
||||||
|
│ └── use-bulk-import.ts # NEW — bulk import state management hook
|
||||||
|
└── App.tsx # Existing — wire toast + bulk import panel mode
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Follows existing patterns. New components for bulk import prompt and toast. New hook for import state management. Minimal changes to existing files (button + wiring).
|
||||||
44
specs/030-bulk-import-sources/quickstart.md
Normal file
44
specs/030-bulk-import-sources/quickstart.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Quickstart: Bulk Import All Sources
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 20+, pnpm
|
||||||
|
- Feature 029 (on-demand bestiary) merged and working
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout 030-bulk-import-sources
|
||||||
|
pnpm install
|
||||||
|
pnpm --filter web dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|------|------|
|
||||||
|
| `apps/web/src/hooks/use-bulk-import.ts` | NEW — bulk import state + logic |
|
||||||
|
| `apps/web/src/components/bulk-import-prompt.tsx` | NEW — side panel bulk import UI |
|
||||||
|
| `apps/web/src/components/toast.tsx` | NEW — lightweight toast notification |
|
||||||
|
| `apps/web/src/components/action-bar.tsx` | MODIFIED — Import button added |
|
||||||
|
| `apps/web/src/components/stat-block-panel.tsx` | MODIFIED — bulk import mode |
|
||||||
|
| `apps/web/src/App.tsx` | MODIFIED — wiring |
|
||||||
|
| `apps/web/src/hooks/use-bestiary.ts` | MODIFIED — expose fetchAndCacheSource for bulk use |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test # All tests
|
||||||
|
pnpm vitest run apps/web/src # Web app tests only
|
||||||
|
pnpm check # Full merge gate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Verification
|
||||||
|
|
||||||
|
1. Open app at `localhost:5173`
|
||||||
|
2. Click Import button (top bar) — side panel opens with bulk import prompt
|
||||||
|
3. Verify base URL is pre-filled, editable
|
||||||
|
4. Click "Load All" — observe progress counter and bar
|
||||||
|
5. Close side panel mid-import — toast appears at bottom-center
|
||||||
|
6. Wait for completion — toast shows success/failure message
|
||||||
|
7. Search for any creature — stat block displays without fetch prompt
|
||||||
70
specs/030-bulk-import-sources/research.md
Normal file
70
specs/030-bulk-import-sources/research.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Research: Bulk Import All Sources
|
||||||
|
|
||||||
|
## R1: Source Code List Availability
|
||||||
|
|
||||||
|
**Decision**: Use the existing bestiary index's `sources` object keys to enumerate all source codes for bulk fetching.
|
||||||
|
|
||||||
|
**Rationale**: The `loadBestiaryIndex()` function already returns a `BestiaryIndex` with a `sources: Record<string, string>` mapping all ~104 source codes to display names. This is the single source of truth.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Hardcoded source list: Rejected — would drift from index and require manual maintenance.
|
||||||
|
- Fetching a remote manifest: Rejected — adds complexity and an extra network call.
|
||||||
|
|
||||||
|
## R2: URL Construction Pattern
|
||||||
|
|
||||||
|
**Decision**: Construct fetch URLs by appending `bestiary-{sourceCode.toLowerCase()}.json` to a user-provided base URL, matching the existing `getDefaultFetchUrl()` pattern.
|
||||||
|
|
||||||
|
**Rationale**: The `getDefaultFetchUrl` helper in `bestiary-index-adapter.ts` already implements this pattern. The bulk import reuses it with a configurable base URL prefix.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Per-source URL customization: Rejected — too complex for bulk operation; single base URL is sufficient.
|
||||||
|
|
||||||
|
## R3: Concurrent Fetch Strategy
|
||||||
|
|
||||||
|
**Decision**: Fire all fetch requests via `Promise.allSettled()` and let the browser handle HTTP/2 connection multiplexing and connection pooling (typically 6 concurrent connections per origin for HTTP/1.1).
|
||||||
|
|
||||||
|
**Rationale**: `Promise.allSettled()` (not `Promise.all()`) ensures that individual failures don't abort the entire operation. The browser naturally throttles concurrent connections, so no manual batching is needed.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Manual batching (e.g., 10 at a time): Rejected — adds complexity; browser pooling handles this naturally.
|
||||||
|
- Sequential fetching: Rejected — too slow for 104 sources.
|
||||||
|
|
||||||
|
## R4: Progress State Management
|
||||||
|
|
||||||
|
**Decision**: Create a dedicated `useBulkImport` hook that manages import state (total, completed, failed, status) and exposes it to both the side panel component and the toast component.
|
||||||
|
|
||||||
|
**Rationale**: The import state needs to survive the side panel closing (toast takes over). Lifting state to a hook that lives in App.tsx ensures both UI targets can consume the same progress data.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Context provider: Rejected — overkill for a single piece of state consumed by 2 components.
|
||||||
|
- Global state (zustand/jotai): Rejected — project doesn't use external state management; unnecessary dependency.
|
||||||
|
|
||||||
|
## R5: Toast Implementation
|
||||||
|
|
||||||
|
**Decision**: Build a minimal custom toast component using a React portal rendered at `document.body` level, positioned at bottom-center via fixed positioning.
|
||||||
|
|
||||||
|
**Rationale**: The spec requires no third-party toast library. A portal ensures the toast renders above all other content. The component needs only: text, progress bar, optional dismiss button, and auto-dismiss timer.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Third-party library (react-hot-toast, sonner): Rejected — spec explicitly requires custom component.
|
||||||
|
- Non-portal approach: Rejected — would require careful z-index management and DOM nesting.
|
||||||
|
|
||||||
|
## R6: Skip-Already-Cached Strategy
|
||||||
|
|
||||||
|
**Decision**: Before firing fetches, check each source against `isSourceCached()` and build a filtered list of uncached sources. Update the total count to reflect only uncached sources.
|
||||||
|
|
||||||
|
**Rationale**: This avoids unnecessary network requests and gives accurate progress counts. The existing `isSourceCached()` function supports this directly.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Fetch all and overwrite: Rejected — wastes bandwidth and time.
|
||||||
|
- Check during fetch (lazy): Rejected — harder to show accurate total count upfront.
|
||||||
|
|
||||||
|
## R7: Integration with Existing Bestiary Hook
|
||||||
|
|
||||||
|
**Decision**: The `useBulkImport` hook calls the existing `fetchAndCacheSource` from `useBestiary` for each source. After all sources complete, a single `refreshCache()` call reloads the creature map.
|
||||||
|
|
||||||
|
**Rationale**: Reuses the existing normalization + caching pipeline. Calling `refreshCache()` once at the end (instead of after each source) avoids O(N) full map rebuilds.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Inline the fetch/normalize/cache logic in the bulk import hook: Rejected — duplicates code.
|
||||||
|
- Call refreshCache after each source: Rejected — expensive O(N) rebuild on each call.
|
||||||
128
specs/030-bulk-import-sources/spec.md
Normal file
128
specs/030-bulk-import-sources/spec.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# Feature Specification: Bulk Import All Sources
|
||||||
|
|
||||||
|
**Feature Branch**: `030-bulk-import-sources`
|
||||||
|
**Created**: 2026-03-10
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Add a Bulk Import All Sources feature to the on-demand bestiary system"
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Bulk Load All Sources (Priority: P1)
|
||||||
|
|
||||||
|
The user wants to pre-load all bestiary sources at once so that every creature's stat block is instantly available without per-source fetch prompts during gameplay. They click an import button (Import icon) in the top bar, which opens the stat block side panel. The panel shows a description of what will happen (including the dynamic source count from the bestiary index), a pre-filled base URL they can edit, and a "Load All" confirmation button. On confirmation, the app fetches all source files concurrently, normalizes them, and caches them in IndexedDB. Already-cached sources are skipped.
|
||||||
|
|
||||||
|
**Why this priority**: This is the core feature — enabling one-click loading of the entire bestiary is the primary user value.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by clicking the import button, confirming the load, and verifying that all sources become available for stat block lookups.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** no sources are cached, **When** the user clicks the import button in the top bar, **Then** the stat block side panel opens showing a descriptive explanation, an editable pre-filled base URL, and a "Load All" button.
|
||||||
|
2. **Given** the side panel is showing the bulk import prompt, **When** the user clicks "Load All", **Then** the app fires fetch requests for all sources concurrently (appending `bestiary-{sourceCode}.json` to the base URL), normalizes each response, and caches results in IndexedDB.
|
||||||
|
3. **Given** some sources are already cached, **When** the user initiates a bulk import, **Then** already-cached sources are skipped and only uncached sources are fetched.
|
||||||
|
4. **Given** the user has edited the base URL, **When** they click "Load All", **Then** the app uses their custom base URL for all fetches.
|
||||||
|
5. **Given** all fetches complete successfully, **When** the operation finishes, **Then** all creature stat blocks are immediately available for lookup without additional fetch prompts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Progress Feedback in Side Panel (Priority: P1)
|
||||||
|
|
||||||
|
While the bulk import is in progress, the user sees a text counter ("Loading sources... 34/102") and a progress bar in the side panel, giving them confidence the operation is proceeding.
|
||||||
|
|
||||||
|
**Why this priority**: Without progress feedback, the user has no way to know if the operation is working or stalled, especially for a ~12.5 MB download.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by initiating a bulk import and observing the counter and progress bar update as sources complete.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a bulk import is in progress, **When** the user views the side panel, **Then** they see a text counter showing completed/total (e.g., "Loading sources... 34/102") and a visual progress bar.
|
||||||
|
2. **Given** sources complete at different times, **When** each source finishes loading, **Then** the counter and progress bar update immediately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Toast Notification on Panel Close (Priority: P2)
|
||||||
|
|
||||||
|
If the user closes the side panel while a bulk import is still in progress, a persistent toast notification appears at the bottom-center of the screen showing the same progress text and progress bar, so the user can continue using the app without losing visibility into the import status.
|
||||||
|
|
||||||
|
**Why this priority**: Allows the user to multitask while the import runs, which is important for a potentially long operation. Lower priority because the side panel already shows progress.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by starting a bulk import, closing the side panel, and verifying the toast appears with progress information.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a bulk import is in progress, **When** the user closes the side panel, **Then** a toast notification appears at the bottom-center of the screen showing the progress counter and progress bar.
|
||||||
|
2. **Given** the toast is visible, **When** all sources finish loading successfully, **Then** the toast shows "All sources loaded" and auto-dismisses after a few seconds.
|
||||||
|
3. **Given** the toast is visible, **When** some sources fail to load, **Then** the toast shows "Loaded N/T sources (F failed)" with actual counts (e.g., "Loaded 99/102 sources (3 failed)") and remains visible until the user dismisses it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 4 - Completion and Failure Reporting (Priority: P2)
|
||||||
|
|
||||||
|
On completion, the user sees a clear success or partial-failure message. Partial failures report how many sources succeeded and how many failed, so the user knows if they need to retry.
|
||||||
|
|
||||||
|
**Why this priority**: Essential for the user to know the outcome, but slightly lower than the core loading and progress functionality.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by simulating network failures for some sources and verifying the correct counts appear.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** all sources load successfully, **When** the operation completes, **Then** the side panel (if open) or toast (if panel closed) shows "All sources loaded".
|
||||||
|
2. **Given** some sources fail to load, **When** the operation completes, **Then** the message shows "Loaded X/T sources (Y failed)" with accurate counts (where T is the total number of sources in the index).
|
||||||
|
3. **Given** completion message is in the toast, **When** the result is success, **Then** the toast auto-dismisses after a few seconds.
|
||||||
|
4. **Given** completion message is in the toast, **When** the result is partial failure, **Then** the toast stays visible until manually dismissed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when the user clicks "Load All" while a bulk import is already in progress? The button should be disabled during an active import.
|
||||||
|
- What happens when all sources are already cached? The operation should complete immediately and report "All sources loaded" (0 fetches needed).
|
||||||
|
- What happens when the network is completely unavailable? All fetches fail and the result shows "Loaded 0/T sources (T failed)" where T is the total source count.
|
||||||
|
- What happens when the user navigates away or refreshes during import? Partially completed caches persist; the user can re-run to pick up remaining sources.
|
||||||
|
- What happens if the base URL is empty or invalid? The "Load All" button should be disabled when the URL field is empty.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: System MUST display an import button (Lucide Import icon) in the top bar that opens the stat block side panel with the bulk import prompt.
|
||||||
|
- **FR-002**: System MUST show a descriptive text explaining the bulk import operation, including approximate data volume (~12.5 MB) and the dynamic number of sources (derived from the bestiary index at runtime).
|
||||||
|
- **FR-003**: System MUST pre-fill a base URL (`https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/`) that the user can edit.
|
||||||
|
- **FR-004**: System MUST construct individual fetch URLs by appending `bestiary-{sourceCode}.json` to the base URL for each source.
|
||||||
|
- **FR-005**: System MUST fire all fetch requests concurrently (browser handles connection pooling).
|
||||||
|
- **FR-006**: System MUST skip sources that are already cached in IndexedDB.
|
||||||
|
- **FR-007**: System MUST normalize fetched data using the existing normalization pipeline before caching.
|
||||||
|
- **FR-008**: System MUST show a text counter ("Loading sources... N/T") and progress bar during the operation.
|
||||||
|
- **FR-009**: System MUST show a toast notification with progress when the user closes the side panel during an active import.
|
||||||
|
- **FR-010**: System MUST auto-dismiss the success toast after a few seconds.
|
||||||
|
- **FR-011**: System MUST keep the partial-failure toast visible until the user dismisses it.
|
||||||
|
- **FR-012**: The toast system MUST be a lightweight custom component — no third-party toast library.
|
||||||
|
- **FR-013**: The bulk import MUST run asynchronously and not block the rest of the app.
|
||||||
|
- **FR-014**: The user MUST explicitly provide/confirm the URL before any fetches occur (app never auto-fetches copyrighted content).
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **Bulk Import Operation**: Tracks total sources, completed count, failed count, and current status (idle/loading/complete/partial-failure).
|
||||||
|
- **Toast Notification**: Lightweight UI element at bottom-center of screen with text, optional progress bar, and optional dismiss button.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: Users can load all bestiary sources with a single confirmation action.
|
||||||
|
- **SC-002**: Users see real-time progress during the bulk import (counter updates as each source completes).
|
||||||
|
- **SC-003**: Users can close the side panel during import without losing progress visibility (toast appears).
|
||||||
|
- **SC-004**: Already-cached sources are skipped, reducing redundant data transfer on repeat imports.
|
||||||
|
- **SC-005**: The rest of the app remains fully interactive during the import operation.
|
||||||
|
- **SC-006**: Users receive clear outcome reporting distinguishing full success from partial failure.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The existing bestiary index contains all source codes needed to construct fetch URLs (the count is dynamic, currently ~102–104).
|
||||||
|
- The existing normalization pipeline handles all source file formats without modification.
|
||||||
|
- The existing per-source fetch-and-cache logic serves as the reference implementation for individual source loading.
|
||||||
|
- The base URL default matches the pattern already used for single-source fetches.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Feature 029 (on-demand bestiary): bestiary cache, bestiary index adapter, normalization pipeline, bestiary hook.
|
||||||
175
specs/030-bulk-import-sources/tasks.md
Normal file
175
specs/030-bulk-import-sources/tasks.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
# Tasks: Bulk Import All Sources
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/030-bulk-import-sources/`
|
||||||
|
**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.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 (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Adapter helpers and core hook that all user stories depend on
|
||||||
|
|
||||||
|
- [x] T001 Add `getAllSourceCodes()` helper to `apps/web/src/adapters/bestiary-index-adapter.ts` that returns all source codes from the bestiary index's `sources` object as a `string[]`
|
||||||
|
- [x] T002 Refactor `getDefaultFetchUrl` in `apps/web/src/adapters/bestiary-index-adapter.ts` to accept an optional `baseUrl` parameter: `getDefaultFetchUrl(sourceCode: string, baseUrl?: string): string`. When `baseUrl` is provided, construct `{baseUrl}bestiary-{sourceCode.toLowerCase()}.json` (with trailing-slash normalization). When omitted, use the existing hardcoded default. Update existing call sites (no behavior change for current callers).
|
||||||
|
- [x] T003 Create `apps/web/src/hooks/use-bulk-import.ts` — the `useBulkImport` hook managing `BulkImportState` with a `startImport(baseUrl: string, fetchAndCacheSource, isSourceCached, refreshCache)` method. State shape: `{ status: 'idle' | 'loading' | 'complete' | 'partial-failure', total: number, completed: number, failed: number }` where `total` = ALL sources in the index (including pre-cached), `completed` = successfully loaded (fetched + pre-cached/skipped), `failed` = fetch failures. On start: set `total` to `getAllSourceCodes().length`, immediately count already-cached sources into `completed`, fire remaining fetches concurrently via `Promise.allSettled()`, increment completed/failed as each settles, call `refreshCache()` once when all settle, transition status to `complete` (failed === 0) or `partial-failure`.
|
||||||
|
|
||||||
|
- [x] T003a [P] Write tests in `apps/web/src/__tests__/bestiary-index-helpers.test.ts` for: `getAllSourceCodes()` returns all keys from the index's `sources` object; `getDefaultFetchUrl(sourceCode, baseUrl)` constructs correct URL with trailing-slash normalization and lowercases the source code in the filename.
|
||||||
|
- [x] T003b [P] Write tests in `apps/web/src/__tests__/use-bulk-import.test.ts` for: starts with `idle` status; skips already-cached sources (counts them into `completed`); increments `completed` on successful fetch; increments `failed` on rejected fetch; transitions to `complete` when all succeed; transitions to `partial-failure` when any fetch fails; all-cached edge case immediately transitions to `complete` with 0 fetches; calls `refreshCache()` exactly once when all settle.
|
||||||
|
|
||||||
|
**Checkpoint**: Foundation ready — user story implementation can begin
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: User Story 1 — Bulk Load All Sources (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: User clicks an Import button in the top bar, sees a bulk import prompt in the side panel with editable base URL, and can load all sources with one click.
|
||||||
|
|
||||||
|
**Independent Test**: Click Import button → side panel opens with prompt → click "Load All" → all uncached sources are fetched, normalized, and cached in IndexedDB.
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T004 [US1] Create `apps/web/src/components/bulk-import-prompt.tsx` — component showing descriptive text ("Load stat block data for all {totalSources} sources at once. This will download approximately 12.5 MB..." where `totalSources` is derived from `getAllSourceCodes().length`), an editable Input pre-filled with `https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/`, and a "Load All" Button (disabled when URL is empty/whitespace-only or import is active). On click, calls a provided `onStartImport(baseUrl)` callback.
|
||||||
|
- [x] T005 [US1] Add Import button (Lucide `Import` icon) to `apps/web/src/components/action-bar.tsx` in the top bar area. Clicking it calls a new `onBulkImport` callback prop.
|
||||||
|
- [x] T006 [US1] Add bulk import mode to `apps/web/src/components/stat-block-panel.tsx` — when a new `bulkImportMode` prop is true, render `BulkImportPrompt` instead of the normal stat block or source fetch prompt content. Pass through `onStartImport` and bulk import state props.
|
||||||
|
- [x] T007 [US1] Wire bulk import in `apps/web/src/App.tsx` — add `bulkImportMode` state, pass `onBulkImport` to ActionBar (sets mode + opens panel), pass `bulkImportMode` and `useBulkImport` state to StatBlockPanel, call `useBulkImport.startImport()` with `fetchAndCacheSource`, `isSourceCached`, and `refreshCache` from `useBestiary`.
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 1 fully functional — Import button opens panel, "Load All" fetches all sources, cached sources skipped
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 2 — Progress Feedback in Side Panel (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: During bulk import, the side panel shows a text counter ("Loading sources... 34/102") and a progress bar that updates in real time.
|
||||||
|
|
||||||
|
**Independent Test**: Start bulk import → observe counter and progress bar updating as each source completes.
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T008 [US2] Add progress display to `apps/web/src/components/bulk-import-prompt.tsx` — when import status is `loading`, replace the "Load All" button area with a text counter ("Loading sources... {completed}/{total}") and a Tailwind-styled progress bar (`<div>` with percentage width based on `(completed + failed) / total`). The component receives `BulkImportState` as a prop.
|
||||||
|
|
||||||
|
**Checkpoint**: User Stories 1 AND 2 work together — full import flow with live progress
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 3 — Toast Notification on Panel Close (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: If the user closes the side panel during an active import, a toast notification appears at bottom-center showing progress counter and bar.
|
||||||
|
|
||||||
|
**Independent Test**: Start bulk import → close side panel → toast appears with progress → toast updates as sources complete.
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [x] T009 [P] [US3] Create `apps/web/src/components/toast.tsx` — lightweight toast component using `ReactDOM.createPortal` to `document.body`. Renders at bottom-center with fixed positioning. Shows: message text, optional progress bar, optional dismiss button (X). Accepts `onDismiss` callback. Styled with Tailwind (dark background, rounded, shadow, z-50).
|
||||||
|
- [x] T010 [US3] Wire toast visibility in `apps/web/src/App.tsx` — show the toast when bulk import status is `loading` AND the stat block panel is closed (or `bulkImportMode` is false). Derive toast message from `BulkImportState`: "Loading sources... {completed}/{total}" with progress value `(completed + failed) / total`. Hide toast when panel reopens in bulk import mode.
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 3 functional — closing panel during import shows toast with progress
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 4 — Completion and Failure Reporting (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: On completion, show "All sources loaded" (auto-dismiss) or "Loaded X/Y sources (Z failed)" (persistent until dismissed).
|
||||||
|
|
||||||
|
**Independent Test**: Complete a successful import → see success message auto-dismiss. Simulate failures → see partial-failure message persist until dismissed.
|
||||||
|
|
||||||
|
### Implementation for User Story 4
|
||||||
|
|
||||||
|
- [x] T011 [US4] Add completion states to `apps/web/src/components/bulk-import-prompt.tsx` — when status is `complete`, show "All sources loaded" success message. When status is `partial-failure`, show "Loaded {completed}/{total} sources ({failed} failed)" message. Include a "Done" button to reset bulk import mode.
|
||||||
|
- [x] T012 [US4] Add completion behavior to toast in `apps/web/src/App.tsx` — when status is `complete`, show "All sources loaded" toast with `autoDismissMs` (e.g., 3000ms) and auto-hide via `setTimeout`. When status is `partial-failure`, show count message with dismiss button, no auto-dismiss. On dismiss, reset bulk import state to `idle`.
|
||||||
|
- [x] T013 [US4] Add auto-dismiss support to `apps/web/src/components/toast.tsx` — accept optional `autoDismissMs` prop. When set, start a `setTimeout` on mount that calls `onDismiss` after the delay. Clear timeout on unmount.
|
||||||
|
|
||||||
|
**Checkpoint**: All user stories complete — full flow with progress, toast, and completion reporting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Edge cases and cleanup
|
||||||
|
|
||||||
|
- [x] T014 Disable Import button in `apps/web/src/components/action-bar.tsx` while bulk import status is `loading` to prevent double-trigger
|
||||||
|
- [x] T015 Handle all-cached edge case in `apps/web/src/hooks/use-bulk-import.ts` — if all sources are already cached (0 to fetch), immediately transition to `complete` status without firing any fetches
|
||||||
|
- [x] T016 Run `pnpm check` (knip + format + lint + typecheck + test) and fix any issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Foundational (Phase 1)**: No dependencies — can start immediately
|
||||||
|
- **US1 (Phase 2)**: Depends on Phase 1 completion
|
||||||
|
- **US2 (Phase 3)**: Depends on Phase 2 (extends `bulk-import-prompt.tsx` from US1)
|
||||||
|
- **US3 (Phase 4)**: Depends on Phase 1 (uses `BulkImportState`); toast component (T009) can be built in parallel with US1/US2
|
||||||
|
- **US4 (Phase 5)**: Depends on Phase 2 and Phase 4 (extends both panel and toast)
|
||||||
|
- **Polish (Phase 6)**: Depends on all story phases complete
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1 (P1)**: Depends only on Foundational
|
||||||
|
- **US2 (P1)**: Depends on US1 (extends same component)
|
||||||
|
- **US3 (P2)**: Toast component (T009) is independent; wiring (T010) depends on US1
|
||||||
|
- **US4 (P2)**: Depends on US1 (panel completion) and US3 (toast completion behavior)
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- T001 and T002 can run in parallel (different functions, same file)
|
||||||
|
- T003a and T003b can run in parallel with each other and with T003 (different files)
|
||||||
|
- T009 (toast component) can run in parallel with T004–T008 (different files)
|
||||||
|
- T011 and T013 can run in parallel (different files)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: Phase 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# These foundational tasks modify the same file, so run T001+T002 together then T003:
|
||||||
|
Task: T001 + T002 "Add getAllSourceCodes and getBulkFetchUrl helpers"
|
||||||
|
Task: T003 "Create useBulkImport hook" (independent file)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: US1 + Toast
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Toast component can be built while US1 is in progress:
|
||||||
|
Task: T004 "Create bulk-import-prompt.tsx" (US1)
|
||||||
|
Task: T009 "Create toast.tsx" (US3) — runs in parallel, different file
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Stories 1 + 2)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Foundational helpers + hook
|
||||||
|
2. Complete Phase 2: US1 — Import button, prompt, fetch logic
|
||||||
|
3. Complete Phase 3: US2 — Progress counter + bar in panel
|
||||||
|
4. **STOP and VALIDATE**: Full import flow works with progress feedback
|
||||||
|
5. This delivers the core user value with tasks T001–T008 (plus T003a, T003b tests)
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Foundational → helpers and hook ready
|
||||||
|
2. US1 + US2 → Full import with progress (MVP!)
|
||||||
|
3. US3 → Toast notification on panel close
|
||||||
|
4. US4 → Completion/failure reporting
|
||||||
|
5. Polish → Edge cases and merge gate
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- [P] tasks = different files, no dependencies
|
||||||
|
- [Story] label maps task to specific user story
|
||||||
|
- US1 and US2 share `bulk-import-prompt.tsx` — US2 extends the component from US1
|
||||||
|
- US3's toast component (T009) is fully independent and can be built any time
|
||||||
|
- US4 adds completion behavior to both panel (from US1) and toast (from US3)
|
||||||
|
- Test tasks (T003a, T003b) cover foundational helpers and hook logic
|
||||||
Reference in New Issue
Block a user