Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36122b500b | ||
|
|
f4355a8675 | ||
|
|
209df13c32 | ||
|
|
4969ed069b | ||
|
|
fba83bebd6 | ||
|
|
f6766b729d | ||
|
|
f10c67a5ba |
@@ -73,6 +73,7 @@ docs/agents/ RPI skill artifacts (research reports, plans)
|
||||
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks.
|
||||
- **Feature specs** live in `specs/NNN-feature-name/` with spec.md (and optionally plan.md, tasks.md for new work). Specs describe features, not individual changes. The project constitution is at `.specify/memory/constitution.md`.
|
||||
- **Component props** — max 8 explicitly declared props per component interface (enforced by `scripts/check-component-props.mjs`). Use React context for shared state; reserve props for per-instance config (data items, layout variants, refs).
|
||||
- **Export format compatibility** — When changing `Encounter`, `Combatant`, `PlayerCharacter`, or `UndoRedoState` types, verify that previously exported JSON files (version 1) still import correctly. If not, bump the `ExportBundle` version and add migration logic in `validateImportBundle()`.
|
||||
- **Quality gates** are enforced at pre-commit via Lefthook's `pnpm check` — the project's single earliest enforcement point. No gate may exist only as a CI step or manual process.
|
||||
|
||||
## Self-Review Checklist
|
||||
@@ -97,6 +98,8 @@ Speckit (`/speckit.*` skills) manages the spec-driven development pipeline. Spec
|
||||
- `rpi-plan` — interactive phased implementation plan in `docs/agents/plans/`
|
||||
- `rpi-implement` — execute a plan file phase by phase with automated + manual verification
|
||||
|
||||
**Research scope**: Research should include a scan for existing patterns similar to what the feature needs (e.g., shared UI primitives, duplicated validation logic, repeated state management patterns). Identify extraction and consolidation opportunities before implementation, not during.
|
||||
|
||||
### Choosing the right workflow by scope
|
||||
|
||||
| Scope | Workflow |
|
||||
@@ -114,6 +117,8 @@ Speckit manages **what** to build (specs as living documents). RPI manages **how
|
||||
- `specs/003-combatant-state/` — HP, AC, conditions, concentration, initiative
|
||||
- `specs/004-bestiary/` — search index, stat blocks, source management, panel UX
|
||||
- `specs/005-player-characters/` — persistent player character templates (CRUD), search & add to encounters, color/icon visual distinction, `PlayerCharacterStore` port
|
||||
- `specs/006-undo-redo/` — undo/redo for encounter state mutations
|
||||
- `specs/007-json-import-export/` — JSON import/export for full encounter state (encounter, undo/redo, player characters)
|
||||
|
||||
## Constitution (key principles)
|
||||
|
||||
@@ -124,4 +129,3 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
|
||||
3. **Clarification-First** — Ask before making non-trivial assumptions.
|
||||
4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans.
|
||||
5. **Spec-driven features** — Features are described in living specs; evolve existing specs via `/integrate-issue`, create new ones via `/speckit.specify`. Bug fixes and tooling changes do not require specs.
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ A local-first initiative tracker and encounter manager for tabletop RPGs (D&D 5e
|
||||
- **Bestiary integration** — import bestiary JSON sources, search creatures, and view full stat blocks
|
||||
- **Player characters** — create reusable player character templates with name, AC, HP, color, and icon; search and add them to encounters with pre-filled stats; manage (edit/delete) from a dedicated panel
|
||||
- **Undo/redo** — reverse any encounter action with Undo/Redo buttons or keyboard shortcuts (Ctrl+Z / Ctrl+Shift+Z, Cmd on Mac); history persists across page reloads
|
||||
- **Import/export** — export the full encounter state (combatants, undo/redo history, player characters) as a JSON file or copy to clipboard; import from file upload or pasted JSON with validation and confirmation
|
||||
- **Persistent** — encounters survive page reloads via localStorage; bestiary data cached in IndexedDB; player characters stored independently
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -30,6 +30,13 @@ export function App() {
|
||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
||||
|
||||
// Close the side panel when the encounter becomes empty
|
||||
useEffect(() => {
|
||||
if (isEmpty) {
|
||||
sidePanel.dismissPanel();
|
||||
}
|
||||
}, [isEmpty, sidePanel.dismissPanel]);
|
||||
|
||||
// Auto-scroll to active combatant when turn changes
|
||||
const activeIndex = encounter.activeIndex;
|
||||
useEffect(() => {
|
||||
|
||||
233
apps/web/src/__tests__/export-import.test.ts
Normal file
233
apps/web/src/__tests__/export-import.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
combatantId,
|
||||
type Encounter,
|
||||
type ExportBundle,
|
||||
type PlayerCharacter,
|
||||
playerCharacterId,
|
||||
type UndoRedoState,
|
||||
} from "@initiative/domain";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assembleExportBundle,
|
||||
bundleToJson,
|
||||
resolveFilename,
|
||||
validateImportBundle,
|
||||
} from "../persistence/export-import.js";
|
||||
|
||||
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
|
||||
const DEFAULT_FILENAME_RE = /^initiative-export-\d{4}-\d{2}-\d{2}\.json$/;
|
||||
|
||||
const encounter: Encounter = {
|
||||
combatants: [
|
||||
{
|
||||
id: combatantId("c-1"),
|
||||
name: "Goblin",
|
||||
initiative: 15,
|
||||
maxHp: 7,
|
||||
currentHp: 7,
|
||||
ac: 15,
|
||||
},
|
||||
{
|
||||
id: combatantId("c-2"),
|
||||
name: "Aria",
|
||||
initiative: 18,
|
||||
maxHp: 45,
|
||||
currentHp: 40,
|
||||
ac: 16,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
playerCharacterId: playerCharacterId("pc-1"),
|
||||
},
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 2,
|
||||
};
|
||||
|
||||
const undoRedoState: UndoRedoState = {
|
||||
undoStack: [
|
||||
{
|
||||
combatants: [{ id: combatantId("c-1"), name: "Goblin", initiative: 15 }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
},
|
||||
],
|
||||
redoStack: [],
|
||||
};
|
||||
|
||||
const playerCharacters: PlayerCharacter[] = [
|
||||
{
|
||||
id: playerCharacterId("pc-1"),
|
||||
name: "Aria",
|
||||
ac: 16,
|
||||
maxHp: 45,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
];
|
||||
|
||||
describe("assembleExportBundle", () => {
|
||||
it("returns a bundle with version 1", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
expect(bundle.version).toBe(1);
|
||||
});
|
||||
|
||||
it("includes an ISO timestamp", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
expect(bundle.exportedAt).toMatch(ISO_TIMESTAMP_RE);
|
||||
});
|
||||
|
||||
it("includes the encounter", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
expect(bundle.encounter).toEqual(encounter);
|
||||
});
|
||||
|
||||
it("includes undo and redo stacks", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
expect(bundle.undoStack).toEqual(undoRedoState.undoStack);
|
||||
expect(bundle.redoStack).toEqual(undoRedoState.redoStack);
|
||||
});
|
||||
|
||||
it("includes player characters", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
expect(bundle.playerCharacters).toEqual(playerCharacters);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assembleExportBundle with includeHistory", () => {
|
||||
it("excludes undo/redo stacks when includeHistory is false", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
false,
|
||||
);
|
||||
expect(bundle.undoStack).toHaveLength(0);
|
||||
expect(bundle.redoStack).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("includes undo/redo stacks when includeHistory is true", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
true,
|
||||
);
|
||||
expect(bundle.undoStack).toEqual(undoRedoState.undoStack);
|
||||
expect(bundle.redoStack).toEqual(undoRedoState.redoStack);
|
||||
});
|
||||
|
||||
it("includes undo/redo stacks by default", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
expect(bundle.undoStack).toEqual(undoRedoState.undoStack);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundleToJson", () => {
|
||||
it("produces valid JSON that round-trips through validateImportBundle", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
const json = bundleToJson(bundle);
|
||||
const parsed: unknown = JSON.parse(json);
|
||||
const result = validateImportBundle(parsed);
|
||||
expect(typeof result).toBe("object");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveFilename", () => {
|
||||
it("uses date-based default when no name provided", () => {
|
||||
const result = resolveFilename();
|
||||
expect(result).toMatch(DEFAULT_FILENAME_RE);
|
||||
});
|
||||
|
||||
it("uses date-based default for empty string", () => {
|
||||
const result = resolveFilename("");
|
||||
expect(result).toMatch(DEFAULT_FILENAME_RE);
|
||||
});
|
||||
|
||||
it("uses date-based default for whitespace-only string", () => {
|
||||
const result = resolveFilename(" ");
|
||||
expect(result).toMatch(DEFAULT_FILENAME_RE);
|
||||
});
|
||||
|
||||
it("appends .json to a custom name", () => {
|
||||
expect(resolveFilename("my-encounter")).toBe("my-encounter.json");
|
||||
});
|
||||
|
||||
it("does not double-append .json", () => {
|
||||
expect(resolveFilename("my-encounter.json")).toBe("my-encounter.json");
|
||||
});
|
||||
|
||||
it("trims whitespace from custom name", () => {
|
||||
expect(resolveFilename(" my-encounter ")).toBe("my-encounter.json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("round-trip: export then import", () => {
|
||||
it("produces identical state after round-trip", () => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
);
|
||||
|
||||
const serialized = JSON.parse(JSON.stringify(bundle));
|
||||
const result = validateImportBundle(serialized);
|
||||
|
||||
expect(typeof result).toBe("object");
|
||||
const imported = result as ExportBundle;
|
||||
expect(imported.version).toBe(bundle.version);
|
||||
expect(imported.encounter).toEqual(bundle.encounter);
|
||||
expect(imported.undoStack).toEqual(bundle.undoStack);
|
||||
expect(imported.redoStack).toEqual(bundle.redoStack);
|
||||
expect(imported.playerCharacters).toEqual(bundle.playerCharacters);
|
||||
});
|
||||
|
||||
it("round-trips an empty encounter", () => {
|
||||
const emptyEncounter: Encounter = {
|
||||
combatants: [],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
const emptyUndoRedo: UndoRedoState = {
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
};
|
||||
const bundle = assembleExportBundle(emptyEncounter, emptyUndoRedo, []);
|
||||
const serialized = JSON.parse(JSON.stringify(bundle));
|
||||
const result = validateImportBundle(serialized);
|
||||
|
||||
expect(typeof result).toBe("object");
|
||||
const imported = result as ExportBundle;
|
||||
expect(imported.encounter.combatants).toHaveLength(0);
|
||||
expect(imported.undoStack).toHaveLength(0);
|
||||
expect(imported.redoStack).toHaveLength(0);
|
||||
expect(imported.playerCharacters).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
249
apps/web/src/__tests__/validate-import-bundle.test.ts
Normal file
249
apps/web/src/__tests__/validate-import-bundle.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import type { ExportBundle } from "@initiative/domain";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateImportBundle } from "../persistence/export-import.js";
|
||||
|
||||
function validBundle(): Record<string, unknown> {
|
||||
return {
|
||||
version: 1,
|
||||
exportedAt: "2026-03-27T12:00:00.000Z",
|
||||
encounter: {
|
||||
combatants: [{ id: "c-1", name: "Goblin", initiative: 15 }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
},
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
playerCharacters: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("validateImportBundle", () => {
|
||||
it("accepts a valid bundle", () => {
|
||||
const result = validateImportBundle(validBundle());
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.version).toBe(1);
|
||||
expect(bundle.encounter.combatants).toHaveLength(1);
|
||||
expect(bundle.encounter.combatants[0].name).toBe("Goblin");
|
||||
});
|
||||
|
||||
it("accepts a valid bundle with empty encounter", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
encounter: { combatants: [], activeIndex: 0, roundNumber: 1 },
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.encounter.combatants).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("accepts a bundle with undo/redo stacks", () => {
|
||||
const enc = {
|
||||
combatants: [{ id: "c-1", name: "Orc" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
const input = {
|
||||
...validBundle(),
|
||||
undoStack: [enc],
|
||||
redoStack: [enc],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.undoStack).toHaveLength(1);
|
||||
expect(bundle.redoStack).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("accepts a bundle with player characters", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
playerCharacters: [
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Aria",
|
||||
ac: 16,
|
||||
maxHp: 45,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.playerCharacters).toHaveLength(1);
|
||||
expect(bundle.playerCharacters[0].name).toBe("Aria");
|
||||
});
|
||||
|
||||
it("rejects non-object input", () => {
|
||||
expect(validateImportBundle(null)).toBe("Invalid file format");
|
||||
expect(validateImportBundle(42)).toBe("Invalid file format");
|
||||
expect(validateImportBundle("string")).toBe("Invalid file format");
|
||||
expect(validateImportBundle([])).toBe("Invalid file format");
|
||||
expect(validateImportBundle(undefined)).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects missing version field", () => {
|
||||
const input = validBundle();
|
||||
delete input.version;
|
||||
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects version 0 or negative", () => {
|
||||
expect(validateImportBundle({ ...validBundle(), version: 0 })).toBe(
|
||||
"Invalid file format",
|
||||
);
|
||||
expect(validateImportBundle({ ...validBundle(), version: -1 })).toBe(
|
||||
"Invalid file format",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown version", () => {
|
||||
expect(validateImportBundle({ ...validBundle(), version: 99 })).toBe(
|
||||
"Invalid file format",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects missing encounter field", () => {
|
||||
const input = validBundle();
|
||||
delete input.encounter;
|
||||
expect(validateImportBundle(input)).toBe("Invalid encounter data");
|
||||
});
|
||||
|
||||
it("rejects invalid encounter data", () => {
|
||||
expect(
|
||||
validateImportBundle({ ...validBundle(), encounter: "not an object" }),
|
||||
).toBe("Invalid encounter data");
|
||||
});
|
||||
|
||||
it("rejects missing undoStack", () => {
|
||||
const input = validBundle();
|
||||
delete input.undoStack;
|
||||
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects missing redoStack", () => {
|
||||
const input = validBundle();
|
||||
delete input.redoStack;
|
||||
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects missing playerCharacters", () => {
|
||||
const input = validBundle();
|
||||
delete input.playerCharacters;
|
||||
expect(validateImportBundle(input)).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects non-string exportedAt", () => {
|
||||
expect(validateImportBundle({ ...validBundle(), exportedAt: 12345 })).toBe(
|
||||
"Invalid file format",
|
||||
);
|
||||
});
|
||||
|
||||
it("drops invalid entries from undo stack", () => {
|
||||
const valid = {
|
||||
combatants: [{ id: "c-1", name: "Orc" }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
const input = {
|
||||
...validBundle(),
|
||||
undoStack: [valid, "invalid", { bad: true }, valid],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.undoStack).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("drops invalid player characters", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
playerCharacters: [
|
||||
{ id: "pc-1", name: "Valid", ac: 10, maxHp: 20 },
|
||||
{ id: "", name: "Bad ID" },
|
||||
"not an object",
|
||||
{ id: "pc-3", name: "Also Valid", ac: 15, maxHp: 30 },
|
||||
],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.playerCharacters).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects JSON array instead of object", () => {
|
||||
expect(validateImportBundle([1, 2, 3])).toBe("Invalid file format");
|
||||
});
|
||||
|
||||
it("rejects encounter that fails rehydration (missing combatant fields)", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
encounter: {
|
||||
combatants: [{ noId: true }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
},
|
||||
};
|
||||
expect(validateImportBundle(input)).toBe("Invalid encounter data");
|
||||
});
|
||||
|
||||
it("strips invalid color/icon from player characters but keeps the character", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
playerCharacters: [
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Test",
|
||||
ac: 10,
|
||||
maxHp: 20,
|
||||
color: "neon-pink",
|
||||
icon: "bazooka",
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
// rehydrateCharacter rejects characters with invalid color/icon members
|
||||
// that are not in the valid sets, so this character is dropped
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.playerCharacters).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps player characters with valid optional color and icon", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
playerCharacters: [
|
||||
{
|
||||
id: "pc-1",
|
||||
name: "Aria",
|
||||
ac: 16,
|
||||
maxHp: 45,
|
||||
color: "blue",
|
||||
icon: "sword",
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.playerCharacters).toHaveLength(1);
|
||||
expect(bundle.playerCharacters[0].color).toBe("blue");
|
||||
expect(bundle.playerCharacters[0].icon).toBe("sword");
|
||||
});
|
||||
|
||||
it("ignores unknown extra fields on the bundle", () => {
|
||||
const input = {
|
||||
...validBundle(),
|
||||
unknownField: "should be ignored",
|
||||
anotherExtra: 42,
|
||||
};
|
||||
const result = validateImportBundle(input);
|
||||
expect(typeof result).toBe("object");
|
||||
const bundle = result as ExportBundle;
|
||||
expect(bundle.version).toBe(1);
|
||||
expect("unknownField" in bundle).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,9 @@ function mockContext(overrides: Partial<Encounter> = {}) {
|
||||
redo: vi.fn(),
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
undoRedoState: { undoStack: [], redoStack: [] },
|
||||
setEncounter: vi.fn(),
|
||||
setUndoRedoState: vi.fn(),
|
||||
events: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import {
|
||||
Check,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Import,
|
||||
@@ -8,13 +9,15 @@ import {
|
||||
Minus,
|
||||
Plus,
|
||||
Settings,
|
||||
Upload,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import React, { type RefObject, useCallback, useState } from "react";
|
||||
import React, { type RefObject, useCallback, useRef, useState } from "react";
|
||||
import type { SearchResult } from "../contexts/bestiary-context.js";
|
||||
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||
import { useEncounterContext } from "../contexts/encounter-context.js";
|
||||
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
||||
import { usePlayerCharactersContext } from "../contexts/player-characters-context.js";
|
||||
import {
|
||||
creatureKey,
|
||||
type QueuedCreature,
|
||||
@@ -23,9 +26,20 @@ import {
|
||||
} from "../hooks/use-action-bar-state.js";
|
||||
import { useLongPress } from "../hooks/use-long-press.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import {
|
||||
assembleExportBundle,
|
||||
bundleToJson,
|
||||
readImportFile,
|
||||
triggerDownload,
|
||||
validateImportBundle,
|
||||
} from "../persistence/export-import.js";
|
||||
import { D20Icon } from "./d20-icon.js";
|
||||
import { ExportMethodDialog } from "./export-method-dialog.js";
|
||||
import { ImportConfirmDialog } from "./import-confirm-prompt.js";
|
||||
import { ImportMethodDialog } from "./import-method-dialog.js";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
|
||||
import { RollModeMenu } from "./roll-mode-menu.js";
|
||||
import { Toast } from "./toast.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js";
|
||||
@@ -345,6 +359,8 @@ function buildOverflowItems(opts: {
|
||||
bestiaryLoaded: boolean;
|
||||
onBulkImport?: () => void;
|
||||
bulkImportDisabled?: boolean;
|
||||
onExportEncounter: () => void;
|
||||
onImportEncounter: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
}): OverflowMenuItem[] {
|
||||
const items: OverflowMenuItem[] = [];
|
||||
@@ -370,6 +386,16 @@ function buildOverflowItems(opts: {
|
||||
disabled: opts.bulkImportDisabled,
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
icon: <Download className="h-4 w-4" />,
|
||||
label: "Export Encounter",
|
||||
onClick: opts.onExportEncounter,
|
||||
});
|
||||
items.push({
|
||||
icon: <Upload className="h-4 w-4" />,
|
||||
label: "Import Encounter",
|
||||
onClick: opts.onImportEncounter,
|
||||
});
|
||||
if (opts.onOpenSettings) {
|
||||
items.push({
|
||||
icon: <Settings className="h-4 w-4" />,
|
||||
@@ -413,6 +439,116 @@ export function ActionBar({
|
||||
} = useActionBarState();
|
||||
|
||||
const { state: bulkImportState } = useBulkImportContext();
|
||||
const {
|
||||
encounter,
|
||||
undoRedoState,
|
||||
isEmpty: encounterIsEmpty,
|
||||
setEncounter,
|
||||
setUndoRedoState,
|
||||
} = useEncounterContext();
|
||||
const { characters: playerCharacters, replacePlayerCharacters } =
|
||||
usePlayerCharactersContext();
|
||||
|
||||
const importFileRef = useRef<HTMLInputElement>(null);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [showExportMethod, setShowExportMethod] = useState(false);
|
||||
const [showImportMethod, setShowImportMethod] = useState(false);
|
||||
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
||||
const pendingBundleRef = useRef<
|
||||
import("@initiative/domain").ExportBundle | null
|
||||
>(null);
|
||||
|
||||
const handleExportDownload = useCallback(
|
||||
(includeHistory: boolean, filename: string) => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
includeHistory,
|
||||
);
|
||||
triggerDownload(bundle, filename);
|
||||
},
|
||||
[encounter, undoRedoState, playerCharacters],
|
||||
);
|
||||
|
||||
const handleExportClipboard = useCallback(
|
||||
(includeHistory: boolean) => {
|
||||
const bundle = assembleExportBundle(
|
||||
encounter,
|
||||
undoRedoState,
|
||||
playerCharacters,
|
||||
includeHistory,
|
||||
);
|
||||
void navigator.clipboard.writeText(bundleToJson(bundle));
|
||||
},
|
||||
[encounter, undoRedoState, playerCharacters],
|
||||
);
|
||||
|
||||
const applyImport = useCallback(
|
||||
(bundle: import("@initiative/domain").ExportBundle) => {
|
||||
setEncounter(bundle.encounter);
|
||||
setUndoRedoState({
|
||||
undoStack: bundle.undoStack,
|
||||
redoStack: bundle.redoStack,
|
||||
});
|
||||
replacePlayerCharacters([...bundle.playerCharacters]);
|
||||
},
|
||||
[setEncounter, setUndoRedoState, replacePlayerCharacters],
|
||||
);
|
||||
|
||||
const handleValidatedBundle = useCallback(
|
||||
(result: import("@initiative/domain").ExportBundle | string) => {
|
||||
if (typeof result === "string") {
|
||||
setImportError(result);
|
||||
return;
|
||||
}
|
||||
if (encounterIsEmpty) {
|
||||
applyImport(result);
|
||||
} else {
|
||||
pendingBundleRef.current = result;
|
||||
setShowImportConfirm(true);
|
||||
}
|
||||
},
|
||||
[encounterIsEmpty, applyImport],
|
||||
);
|
||||
|
||||
const handleImportFile = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (importFileRef.current) importFileRef.current.value = "";
|
||||
|
||||
setImportError(null);
|
||||
handleValidatedBundle(await readImportFile(file));
|
||||
},
|
||||
[handleValidatedBundle],
|
||||
);
|
||||
|
||||
const handleImportClipboard = useCallback(
|
||||
(text: string) => {
|
||||
setImportError(null);
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
handleValidatedBundle(validateImportBundle(parsed));
|
||||
} catch {
|
||||
setImportError("Invalid file format");
|
||||
}
|
||||
},
|
||||
[handleValidatedBundle],
|
||||
);
|
||||
|
||||
const handleImportConfirm = useCallback(() => {
|
||||
if (pendingBundleRef.current) {
|
||||
applyImport(pendingBundleRef.current);
|
||||
pendingBundleRef.current = null;
|
||||
}
|
||||
setShowImportConfirm(false);
|
||||
}, [applyImport]);
|
||||
|
||||
const handleImportCancel = useCallback(() => {
|
||||
pendingBundleRef.current = null;
|
||||
setShowImportConfirm(false);
|
||||
}, []);
|
||||
|
||||
const overflowItems = buildOverflowItems({
|
||||
onManagePlayers,
|
||||
@@ -420,6 +556,8 @@ export function ActionBar({
|
||||
bestiaryLoaded,
|
||||
onBulkImport: showBulkImport,
|
||||
bulkImportDisabled: bulkImportState.status === "loading",
|
||||
onExportEncounter: () => setShowExportMethod(true),
|
||||
onImportEncounter: () => setShowImportMethod(true),
|
||||
onOpenSettings,
|
||||
});
|
||||
|
||||
@@ -501,6 +639,37 @@ export function ActionBar({
|
||||
<RollAllButton />
|
||||
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
||||
</form>
|
||||
<input
|
||||
ref={importFileRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={handleImportFile}
|
||||
/>
|
||||
{!!importError && (
|
||||
<Toast
|
||||
message={importError}
|
||||
onDismiss={() => setImportError(null)}
|
||||
autoDismissMs={5000}
|
||||
/>
|
||||
)}
|
||||
<ExportMethodDialog
|
||||
open={showExportMethod}
|
||||
onDownload={handleExportDownload}
|
||||
onCopyToClipboard={handleExportClipboard}
|
||||
onClose={() => setShowExportMethod(false)}
|
||||
/>
|
||||
<ImportMethodDialog
|
||||
open={showImportMethod}
|
||||
onSelectFile={() => importFileRef.current?.click()}
|
||||
onSubmitClipboard={handleImportClipboard}
|
||||
onClose={() => setShowImportMethod(false)}
|
||||
/>
|
||||
<ImportConfirmDialog
|
||||
open={showImportConfirm}
|
||||
onConfirm={handleImportConfirm}
|
||||
onCancel={handleImportCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { PlayerCharacter } from "@initiative/domain";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ColorPalette } from "./color-palette";
|
||||
import { IconGrid } from "./icon-grid";
|
||||
import { Button } from "./ui/button";
|
||||
import { Dialog } from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
|
||||
interface CreatePlayerModalProps {
|
||||
@@ -25,7 +26,6 @@ export function CreatePlayerModal({
|
||||
onSave,
|
||||
playerCharacter,
|
||||
}: Readonly<CreatePlayerModalProps>) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [ac, setAc] = useState("10");
|
||||
const [maxHp, setMaxHp] = useState("10");
|
||||
@@ -54,34 +54,6 @@ export function CreatePlayerModal({
|
||||
}
|
||||
}, [open, playerCharacter]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) {
|
||||
dialog.showModal();
|
||||
} else if (!open && dialog.open) {
|
||||
dialog.close();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
function handleCancel(e: Event) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === dialog) onClose();
|
||||
}
|
||||
dialog.addEventListener("cancel", handleCancel);
|
||||
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", handleCancel);
|
||||
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
@@ -104,10 +76,7 @@ export function CreatePlayerModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
||||
>
|
||||
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">
|
||||
{isEdit ? "Edit Player" : "Create Player"}
|
||||
@@ -187,6 +156,6 @@ export function CreatePlayerModal({
|
||||
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
105
apps/web/src/components/export-method-dialog.tsx
Normal file
105
apps/web/src/components/export-method-dialog.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Check, ClipboardCopy, Download, X } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Dialog } from "./ui/dialog.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
interface ExportMethodDialogProps {
|
||||
open: boolean;
|
||||
onDownload: (includeHistory: boolean, filename: string) => void;
|
||||
onCopyToClipboard: (includeHistory: boolean) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ExportMethodDialog({
|
||||
open,
|
||||
onDownload,
|
||||
onCopyToClipboard,
|
||||
onClose,
|
||||
}: Readonly<ExportMethodDialogProps>) {
|
||||
const [includeHistory, setIncludeHistory] = useState(false);
|
||||
const [filename, setFilename] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIncludeHistory(false);
|
||||
setFilename("");
|
||||
setCopied(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} className="w-80">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-lg">Export Encounter</h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleClose}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<Input
|
||||
type="text"
|
||||
value={filename}
|
||||
onChange={(e) => setFilename(e.target.value)}
|
||||
placeholder="Filename (optional)"
|
||||
/>
|
||||
</div>
|
||||
<label className="mb-4 flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeHistory}
|
||||
onChange={(e) => setIncludeHistory(e.target.checked)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
<span className="text-foreground">Include undo/redo history</span>
|
||||
</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||
onClick={() => {
|
||||
onDownload(includeHistory, filename);
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
<Download className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">Download file</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Save as a JSON file
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||
onClick={() => {
|
||||
onCopyToClipboard(includeHistory);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-5 w-5 text-green-400" />
|
||||
) : (
|
||||
<ClipboardCopy className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{copied ? "Copied!" : "Copy to clipboard"}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Copy JSON to your clipboard
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
32
apps/web/src/components/import-confirm-prompt.tsx
Normal file
32
apps/web/src/components/import-confirm-prompt.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Dialog } from "./ui/dialog.js";
|
||||
|
||||
interface ImportConfirmDialogProps {
|
||||
open: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ImportConfirmDialog({
|
||||
open,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: Readonly<ImportConfirmDialogProps>) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onCancel}>
|
||||
<h2 className="mb-2 font-semibold text-lg">Replace current encounter?</h2>
|
||||
<p className="mb-4 text-muted-foreground text-sm">
|
||||
Importing will replace your current encounter, undo/redo history, and
|
||||
player characters. This cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={onConfirm}>
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
125
apps/web/src/components/import-method-dialog.tsx
Normal file
125
apps/web/src/components/import-method-dialog.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { ClipboardPaste, FileUp, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Dialog } from "./ui/dialog.js";
|
||||
|
||||
interface ImportMethodDialogProps {
|
||||
open: boolean;
|
||||
onSelectFile: () => void;
|
||||
onSubmitClipboard: (text: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ImportMethodDialog({
|
||||
open,
|
||||
onSelectFile,
|
||||
onSubmitClipboard,
|
||||
onClose,
|
||||
}: Readonly<ImportMethodDialogProps>) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [mode, setMode] = useState<"pick" | "paste">("pick");
|
||||
const [pasteText, setPasteText] = useState("");
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setMode("pick");
|
||||
setPasteText("");
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setMode("pick");
|
||||
setPasteText("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "paste") {
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} className="w-80">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-lg">Import Encounter</h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleClose}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{mode === "pick" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
onSelectFile();
|
||||
}}
|
||||
>
|
||||
<FileUp className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">From file</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Upload a JSON file
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
||||
onClick={() => setMode("paste")}
|
||||
>
|
||||
<ClipboardPaste className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium">Paste content</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Paste JSON content directly
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{mode === "paste" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={pasteText}
|
||||
onChange={(e) => setPasteText(e.target.value)}
|
||||
placeholder="Paste exported JSON here..."
|
||||
className="h-32 w-full resize-none rounded-md border border-border bg-background px-3 py-2 font-mono text-foreground text-xs placeholder:text-muted-foreground focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setMode("pick");
|
||||
setPasteText("");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={pasteText.trim().length === 0}
|
||||
onClick={() => {
|
||||
const text = pasteText;
|
||||
handleClose();
|
||||
onSubmitClipboard(text);
|
||||
}}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||
import { Button } from "./ui/button";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
import { Dialog } from "./ui/dialog";
|
||||
|
||||
interface PlayerManagementProps {
|
||||
open: boolean;
|
||||
@@ -22,41 +22,8 @@ export function PlayerManagement({
|
||||
onDelete,
|
||||
onCreate,
|
||||
}: Readonly<PlayerManagementProps>) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) {
|
||||
dialog.showModal();
|
||||
} else if (!open && dialog.open) {
|
||||
dialog.close();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
function handleCancel(e: Event) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === dialog) onClose();
|
||||
}
|
||||
dialog.addEventListener("cancel", handleCancel);
|
||||
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", handleCancel);
|
||||
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="card-glow m-auto w-full max-w-md rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
||||
>
|
||||
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">
|
||||
Player Characters
|
||||
@@ -128,6 +95,6 @@ export function PlayerManagement({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</dialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { RulesEdition } from "@initiative/domain";
|
||||
import { Monitor, Moon, Sun, X } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { useThemeContext } from "../contexts/theme-context.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Dialog } from "./ui/dialog.js";
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean;
|
||||
@@ -27,40 +27,11 @@ const THEME_OPTIONS: {
|
||||
];
|
||||
|
||||
export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const { edition, setEdition } = useRulesEditionContext();
|
||||
const { preference, setPreference } = useThemeContext();
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) dialog.showModal();
|
||||
else if (!open && dialog.open) dialog.close();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
function handleCancel(e: Event) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === dialog) onClose();
|
||||
}
|
||||
dialog.addEventListener("cancel", handleCancel);
|
||||
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", handleCancel);
|
||||
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="card-glow m-auto w-full max-w-sm rounded-lg border border-border bg-card p-6 backdrop:bg-black/50"
|
||||
>
|
||||
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-sm">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">Settings</h2>
|
||||
<Button
|
||||
@@ -124,6 +95,6 @@ export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
50
apps/web/src/components/ui/dialog.tsx
Normal file
50
apps/web/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
|
||||
interface DialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Dialog({ open, onClose, className, children }: DialogProps) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (open && !dialog.open) dialog.showModal();
|
||||
else if (!open && dialog.open) dialog.close();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
function handleCancel(e: Event) {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === dialog) onClose();
|
||||
}
|
||||
dialog.addEventListener("cancel", handleCancel);
|
||||
dialog.addEventListener("mousedown", handleBackdropClick);
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", handleCancel);
|
||||
dialog.removeEventListener("mousedown", handleBackdropClick);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className={cn(
|
||||
"m-auto rounded-lg border border-border bg-card text-foreground shadow-xl backdrop:bg-black/50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="p-6">{children}</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -444,6 +444,7 @@ export function useEncounter() {
|
||||
|
||||
return {
|
||||
encounter,
|
||||
undoRedoState,
|
||||
events,
|
||||
isEmpty,
|
||||
hasTempHp,
|
||||
@@ -469,6 +470,8 @@ export function useEncounter() {
|
||||
addFromPlayerCharacter,
|
||||
undo: undoAction,
|
||||
redo: redoAction,
|
||||
setEncounter,
|
||||
setUndoRedoState,
|
||||
makeStore,
|
||||
withUndo,
|
||||
} as const;
|
||||
|
||||
@@ -103,6 +103,7 @@ export function usePlayerCharacters() {
|
||||
createCharacter,
|
||||
editCharacter,
|
||||
deleteCharacter,
|
||||
replacePlayerCharacters: setCharacters,
|
||||
makeStore,
|
||||
} as const;
|
||||
}
|
||||
|
||||
118
apps/web/src/persistence/export-import.ts
Normal file
118
apps/web/src/persistence/export-import.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type {
|
||||
Encounter,
|
||||
ExportBundle,
|
||||
PlayerCharacter,
|
||||
UndoRedoState,
|
||||
} from "@initiative/domain";
|
||||
import { rehydrateEncounter } from "./encounter-storage.js";
|
||||
import { rehydrateCharacter } from "./player-character-storage.js";
|
||||
|
||||
function rehydrateStack(raw: unknown[]): Encounter[] {
|
||||
const result: Encounter[] = [];
|
||||
for (const entry of raw) {
|
||||
const rehydrated = rehydrateEncounter(entry);
|
||||
if (rehydrated !== null) {
|
||||
result.push(rehydrated);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function rehydrateCharacters(raw: unknown[]): PlayerCharacter[] {
|
||||
const result: PlayerCharacter[] = [];
|
||||
for (const entry of raw) {
|
||||
const rehydrated = rehydrateCharacter(entry);
|
||||
if (rehydrated !== null) {
|
||||
result.push(rehydrated);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function validateImportBundle(data: unknown): ExportBundle | string {
|
||||
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
||||
return "Invalid file format";
|
||||
}
|
||||
|
||||
const obj = data as Record<string, unknown>;
|
||||
|
||||
if (typeof obj.version !== "number" || obj.version !== 1) {
|
||||
return "Invalid file format";
|
||||
}
|
||||
if (typeof obj.exportedAt !== "string") {
|
||||
return "Invalid file format";
|
||||
}
|
||||
if (!Array.isArray(obj.undoStack) || !Array.isArray(obj.redoStack)) {
|
||||
return "Invalid file format";
|
||||
}
|
||||
if (!Array.isArray(obj.playerCharacters)) {
|
||||
return "Invalid file format";
|
||||
}
|
||||
|
||||
const encounter = rehydrateEncounter(obj.encounter);
|
||||
if (encounter === null) {
|
||||
return "Invalid encounter data";
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
exportedAt: obj.exportedAt,
|
||||
encounter,
|
||||
undoStack: rehydrateStack(obj.undoStack),
|
||||
redoStack: rehydrateStack(obj.redoStack),
|
||||
playerCharacters: rehydrateCharacters(obj.playerCharacters),
|
||||
};
|
||||
}
|
||||
|
||||
export function assembleExportBundle(
|
||||
encounter: Encounter,
|
||||
undoRedoState: UndoRedoState,
|
||||
playerCharacters: readonly PlayerCharacter[],
|
||||
includeHistory = true,
|
||||
): ExportBundle {
|
||||
return {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
encounter,
|
||||
undoStack: includeHistory ? undoRedoState.undoStack : [],
|
||||
redoStack: includeHistory ? undoRedoState.redoStack : [],
|
||||
playerCharacters: [...playerCharacters],
|
||||
};
|
||||
}
|
||||
|
||||
export function bundleToJson(bundle: ExportBundle): string {
|
||||
return JSON.stringify(bundle, null, 2);
|
||||
}
|
||||
|
||||
export function resolveFilename(name?: string): string {
|
||||
const base =
|
||||
name?.trim() ||
|
||||
`initiative-export-${new Date().toISOString().slice(0, 10)}`;
|
||||
return base.endsWith(".json") ? base : `${base}.json`;
|
||||
}
|
||||
|
||||
export function triggerDownload(bundle: ExportBundle, name?: string): void {
|
||||
const blob = new Blob([bundleToJson(bundle)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const filename = resolveFilename(name);
|
||||
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export async function readImportFile(
|
||||
file: File,
|
||||
): Promise<ExportBundle | string> {
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
return validateImportBundle(parsed);
|
||||
} catch {
|
||||
return "Invalid file format";
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ function isValidOptionalMember(
|
||||
return value === undefined || (typeof value === "string" && valid.has(value));
|
||||
}
|
||||
|
||||
function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
||||
export function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
||||
return null;
|
||||
const entry = raw as Record<string, unknown>;
|
||||
|
||||
11
packages/domain/src/export-bundle.ts
Normal file
11
packages/domain/src/export-bundle.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { PlayerCharacter } from "./player-character-types.js";
|
||||
import type { Encounter } from "./types.js";
|
||||
|
||||
export interface ExportBundle {
|
||||
readonly version: number;
|
||||
readonly exportedAt: string;
|
||||
readonly encounter: Encounter;
|
||||
readonly undoStack: readonly Encounter[];
|
||||
readonly redoStack: readonly Encounter[];
|
||||
readonly playerCharacters: readonly PlayerCharacter[];
|
||||
}
|
||||
@@ -71,6 +71,7 @@ export type {
|
||||
TurnAdvanced,
|
||||
TurnRetreated,
|
||||
} from "./events.js";
|
||||
export type { ExportBundle } from "./export-bundle.js";
|
||||
export { deriveHpStatus, type HpStatus } from "./hp-status.js";
|
||||
export {
|
||||
calculateInitiative,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Data Model: Undo/Redo
|
||||
|
||||
**Feature**: 037-undo-redo
|
||||
**Feature**: 006-undo-redo
|
||||
**Date**: 2026-03-26
|
||||
|
||||
## Entities
|
||||
@@ -1,7 +1,7 @@
|
||||
# Implementation Plan: Undo/Redo
|
||||
|
||||
**Branch**: `037-undo-redo` | **Date**: 2026-03-26 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/037-undo-redo/spec.md`
|
||||
**Branch**: `006-undo-redo` | **Date**: 2026-03-26 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/006-undo-redo/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -40,7 +40,7 @@ Add undo/redo capability for all encounter state changes using the memento patte
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/037-undo-redo/
|
||||
specs/006-undo-redo/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
@@ -1,6 +1,6 @@
|
||||
# Quickstart: Undo/Redo
|
||||
|
||||
**Feature**: 037-undo-redo
|
||||
**Feature**: 006-undo-redo
|
||||
**Date**: 2026-03-26
|
||||
|
||||
## Overview
|
||||
@@ -1,6 +1,6 @@
|
||||
# Research: Undo/Redo for Encounter Actions
|
||||
|
||||
**Feature**: 037-undo-redo
|
||||
**Feature**: 006-undo-redo
|
||||
**Date**: 2026-03-26
|
||||
|
||||
## Decision 1: Undo/Redo Strategy — Memento (Snapshots) vs Command (Events)
|
||||
@@ -1,6 +1,6 @@
|
||||
# Feature Specification: Undo/Redo
|
||||
|
||||
**Feature Branch**: `037-undo-redo`
|
||||
**Feature Branch**: `006-undo-redo`
|
||||
**Created**: 2026-03-26
|
||||
**Status**: Draft
|
||||
**Input**: Gitea issue #16 — Undo/redo for encounter actions
|
||||
@@ -1,6 +1,6 @@
|
||||
# Tasks: Undo/Redo
|
||||
|
||||
**Input**: Design documents from `/specs/037-undo-redo/`
|
||||
**Input**: Design documents from `/specs/006-undo-redo/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md
|
||||
|
||||
**Tests**: Domain tests included (pure function testing is standard for this project per CLAUDE.md).
|
||||
34
specs/007-json-import-export/checklists/requirements.md
Normal file
34
specs/007-json-import-export/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: JSON Import/Export
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-27
|
||||
**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`.
|
||||
102
specs/007-json-import-export/data-model.md
Normal file
102
specs/007-json-import-export/data-model.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Data Model: JSON Import/Export
|
||||
|
||||
**Feature**: 007-json-import-export
|
||||
**Date**: 2026-03-27
|
||||
|
||||
## Entities
|
||||
|
||||
### ExportBundle
|
||||
|
||||
The top-level structure written to and read from `.json` files. Contains all exportable application state.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|--------------------|----------------------|----------|--------------------------------------------------|
|
||||
| version | number | Yes | Format version (starts at 1) |
|
||||
| exportedAt | string (ISO 8601) | Yes | Timestamp of when the export was created |
|
||||
| encounter | Encounter | Yes | Current encounter state (combatants + turn info) |
|
||||
| undoStack | Encounter[] | Yes | Undo history (encounter snapshots, max 50) |
|
||||
| redoStack | Encounter[] | Yes | Redo history (encounter snapshots) |
|
||||
| playerCharacters | PlayerCharacter[] | Yes | Player character templates |
|
||||
|
||||
### Encounter (existing)
|
||||
|
||||
Defined in `packages/domain/src/types.ts`. No changes needed — exported as-is via JSON serialization.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|--------------|--------------|----------|--------------------------------------|
|
||||
| combatants | Combatant[] | Yes | Ordered list of combatants |
|
||||
| activeIndex | number | Yes | Index of current turn (0-based) |
|
||||
| roundNumber | number | Yes | Current round (starts at 1) |
|
||||
|
||||
### Combatant (existing)
|
||||
|
||||
Defined in `packages/domain/src/types.ts`. No changes needed.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|--------------------|-------------------|----------|------------------------------------|
|
||||
| id | CombatantId | Yes | Unique identifier ("c-N") |
|
||||
| name | string | Yes | Display name |
|
||||
| initiative | number | No | Initiative roll result |
|
||||
| maxHp | number | No | Maximum hit points (>= 1) |
|
||||
| currentHp | number | No | Current HP (0 to maxHp) |
|
||||
| tempHp | number | No | Temporary hit points |
|
||||
| ac | number | No | Armor class (>= 0) |
|
||||
| conditions | ConditionId[] | No | Active status conditions |
|
||||
| isConcentrating | boolean | No | Concentration flag |
|
||||
| creatureId | CreatureId | No | Link to bestiary creature |
|
||||
| color | string | No | Visual color (from player char) |
|
||||
| icon | string | No | Visual icon (from player char) |
|
||||
| playerCharacterId | PlayerCharacterId | No | Link to player character template |
|
||||
|
||||
### PlayerCharacter (existing)
|
||||
|
||||
Defined in `packages/domain/src/player-character-types.ts`. No changes needed.
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|--------|-------------------|----------|-------------------------------------------|
|
||||
| id | PlayerCharacterId | Yes | Unique identifier ("pc-N") |
|
||||
| name | string | Yes | Character name |
|
||||
| ac | number | Yes | Armor class (>= 0) |
|
||||
| maxHp | number | Yes | Maximum hit points (>= 1) |
|
||||
| color | PlayerColor | No | Visual color (10 options) |
|
||||
| icon | PlayerIcon | No | Visual icon (15 options) |
|
||||
|
||||
## Validation Rules
|
||||
|
||||
### Import Validation
|
||||
|
||||
1. **Top-level structure**: Must be a JSON object with `version`, `encounter`, `undoStack`, `redoStack`, and `playerCharacters` fields.
|
||||
2. **Version check**: `version` must be a number. Unknown versions are rejected.
|
||||
3. **Encounter validation**: Delegated to existing `rehydrateEncounter()` — validates combatant structure, HP ranges, condition IDs, player colors/icons.
|
||||
4. **Undo/redo stack validation**: Each entry in both stacks is validated via `rehydrateEncounter()`. Invalid entries are silently dropped.
|
||||
5. **Player character validation**: Delegated to existing player character rehydration — validates types, ranges, color/icon enums.
|
||||
6. **Graceful degradation**: Invalid optional fields on combatants/characters are stripped (not rejected). Only structurally malformed data (missing required fields, wrong types) causes full rejection.
|
||||
|
||||
## State Transitions
|
||||
|
||||
### Export Flow
|
||||
|
||||
```
|
||||
User triggers export
|
||||
→ Read encounter from EncounterContext
|
||||
→ Read undoRedoState from EncounterContext
|
||||
→ Read playerCharacters from PlayerCharactersContext
|
||||
→ Assemble ExportBundle { version: 1, exportedAt, encounter, undoStack, redoStack, playerCharacters }
|
||||
→ Serialize to JSON
|
||||
→ Trigger browser file download
|
||||
```
|
||||
|
||||
### Import Flow
|
||||
|
||||
```
|
||||
User selects file
|
||||
→ Read file as text
|
||||
→ Parse JSON (reject on parse failure)
|
||||
→ Validate top-level structure (reject on missing fields)
|
||||
→ Validate encounter via rehydrateEncounter() (reject on null)
|
||||
→ Validate undo/redo stacks via rehydrateEncounter() per entry (filter invalid)
|
||||
→ Validate player characters via rehydration (filter invalid)
|
||||
→ If current encounter is non-empty: show confirmation dialog
|
||||
→ On confirm: replace encounter, undo/redo, and player characters in state
|
||||
→ State changes trigger existing useEffect auto-saves to localStorage
|
||||
```
|
||||
111
specs/007-json-import-export/plan.md
Normal file
111
specs/007-json-import-export/plan.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Implementation Plan: JSON Import/Export
|
||||
|
||||
**Branch**: `007-json-import-export` | **Date**: 2026-03-27 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/007-json-import-export/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Add JSON import/export for the full application state (encounter, undo/redo history, player characters). Export creates a downloadable `.json` file; import reads a file, validates it using existing rehydration functions, and replaces the current state after user confirmation. UI is integrated via the action bar overflow menu.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.8 (strict mode, `verbatimModuleSyntax`)
|
||||
**Primary Dependencies**: React 19, Vite 6, Tailwind CSS v4, Lucide React
|
||||
**Storage**: localStorage (encounter, undo/redo, player characters)
|
||||
**Testing**: Vitest (v8 coverage)
|
||||
**Target Platform**: Browser (desktop + mobile)
|
||||
**Project Type**: Web application (SPA)
|
||||
**Performance Goals**: Export/import completes in under 1 second for typical encounters
|
||||
**Constraints**: No server-side component; browser-only file operations
|
||||
**Scale/Scope**: Encounters with up to ~50 combatants, undo stacks of up to 50 snapshots
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| I. Deterministic Domain Core | PASS | No new domain logic needed. ExportBundle type is pure data. Validation reuses existing pure functions. |
|
||||
| II. Layered Architecture | PASS | Type in domain, validation in adapter layer (co-located with existing rehydration functions), file I/O and UI in adapter layer. No reverse dependencies. |
|
||||
| II-A. Context-Based State Flow | PASS | Import reads/writes state via existing contexts. No new props beyond per-instance config. |
|
||||
| III. Clarification-First | PASS | All decisions documented in research.md. No ambiguities remain. |
|
||||
| IV. Escalation Gates | PASS | Feature scope matches spec exactly. No out-of-scope additions. |
|
||||
| V. MVP Baseline Language | PASS | Format versioning and selective import noted as "not included in MVP baseline." |
|
||||
| VI. No Gameplay Rules | PASS | No gameplay mechanics involved. |
|
||||
|
||||
**Post-Phase 1 re-check**: All gates still pass. The ExportBundle type is a pure data structure in the domain layer. The validation logic lives in the adapter layer alongside existing rehydration functions (it depends on adapter-layer `rehydrateEncounter` and `rehydrateCharacter`). File I/O and UI live in the adapter layer.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/007-json-import-export/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
packages/domain/src/
|
||||
├── export-bundle.ts # ExportBundle type definition
|
||||
|
||||
apps/web/src/
|
||||
├── persistence/
|
||||
│ └── export-import.ts # Export assembly + import validation + file handling
|
||||
├── hooks/
|
||||
│ └── use-encounter.ts # Existing — needs import/export state setters exposed
|
||||
├── components/
|
||||
│ ├── action-bar.tsx # Existing — add overflow menu items
|
||||
│ └── import-confirm-prompt.tsx # Confirmation dialog for import
|
||||
```
|
||||
|
||||
**Structure Decision**: Follows existing project structure. New files are minimal — one domain type, one persistence module (validation + export assembly + file handling), one UI component. Most work integrates into existing files.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: ExportBundle Type + Validation Use Case
|
||||
|
||||
**Goal**: Define the export format and validation logic with full test coverage.
|
||||
|
||||
1. Create `ExportBundle` type in `packages/domain/src/export-bundle.ts`
|
||||
2. Export from domain package index
|
||||
3. Create `validateImportBundle()` in `apps/web/src/persistence/export-import.ts` — accepts `unknown`, returns validated `ExportBundle | DomainError`
|
||||
4. Import `rehydrateEncounter()` from `./encounter-storage.ts` for encounter and undo/redo stack validation
|
||||
5. Export `rehydrateCharacter()` from `player-character-storage.ts` and import it for player character validation
|
||||
6. Write tests covering: valid bundles, missing fields, invalid encounter, invalid player characters, empty stacks, unknown version
|
||||
|
||||
### Phase 2: Export Functionality
|
||||
|
||||
**Goal**: Users can download the current state as a `.json` file.
|
||||
|
||||
1. Create `export-import.ts` in `apps/web/src/persistence/`
|
||||
2. Implement `assembleExportBundle(encounter, undoRedoState, playerCharacters)` — pure function returning `ExportBundle`
|
||||
3. Implement `triggerDownload(bundle: ExportBundle)` — creates JSON blob, generates filename with date, triggers browser download
|
||||
4. Add "Export Encounter" item to action bar overflow menu
|
||||
5. Wire button to read from contexts and call export functions
|
||||
|
||||
### Phase 3: Import Functionality + Confirmation
|
||||
|
||||
**Goal**: Users can import a `.json` file with confirmation and error handling.
|
||||
|
||||
1. Add "Import Encounter" item to action bar overflow menu
|
||||
2. Implement file picker trigger (hidden `<input type="file">`)
|
||||
3. On file selected: read text, parse JSON, validate via use case
|
||||
4. If validation fails: show error toast
|
||||
5. If encounter is non-empty: show confirmation dialog
|
||||
6. On confirm (or if encounter is empty): replace encounter, undo/redo, and player characters via context setters
|
||||
7. Write integration test: export → import round-trip produces identical state
|
||||
|
||||
## Notes
|
||||
|
||||
- Import `rehydrateEncounter()` from `apps/web/src/persistence/encounter-storage.ts` for encounter validation — do not duplicate
|
||||
- Export `rehydrateCharacter()` from `apps/web/src/persistence/player-character-storage.ts` so it can be imported for player character validation
|
||||
- Follow existing file picker pattern from `apps/web/src/components/source-fetch-prompt.tsx`
|
||||
- Follow existing overflow menu pattern in `apps/web/src/components/action-bar.tsx`
|
||||
- Follow existing `<dialog>` pattern from `apps/web/src/components/settings-modal.tsx`
|
||||
- Commit after each phase checkpoint
|
||||
52
specs/007-json-import-export/quickstart.md
Normal file
52
specs/007-json-import-export/quickstart.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Quickstart: JSON Import/Export
|
||||
|
||||
**Feature**: 007-json-import-export
|
||||
**Date**: 2026-03-27
|
||||
|
||||
## Overview
|
||||
|
||||
Export and import the full application state (encounter, undo/redo history, player characters) as a JSON file. Enables backup/restore and sharing encounters between DMs.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **ExportBundle**: A JSON object containing `version`, `exportedAt`, `encounter`, `undoStack`, `redoStack`, and `playerCharacters`. This is the file format.
|
||||
- **Full replacement**: Import replaces all existing state — encounter, undo/redo, and player characters. It's not a merge.
|
||||
- **Validation reuse**: Import validation uses the same `rehydrateEncounter()` and player character validation functions that localStorage loading uses.
|
||||
|
||||
## Implementation Layers
|
||||
|
||||
### Domain Layer
|
||||
|
||||
No new domain functions are needed. The existing types (`Encounter`, `PlayerCharacter`, `UndoRedoState`) and validation functions are reused as-is.
|
||||
|
||||
A new `ExportBundle` type is defined in the domain layer as a pure data structure.
|
||||
|
||||
### Application Layer
|
||||
|
||||
A new use case for import validation and bundle assembly:
|
||||
- `validateImportBundle(data: unknown)` — validates and rehydrates an export bundle, returning the validated bundle or an error.
|
||||
|
||||
Export assembly is straightforward enough to live in the adapter layer (it's just reading and packaging existing state).
|
||||
|
||||
### Adapter Layer (Web)
|
||||
|
||||
- **Export**: Read state from contexts, assemble bundle, trigger browser download via `URL.createObjectURL()` + anchor element.
|
||||
- **Import**: File picker input, parse JSON, delegate to application-layer validation, show confirmation dialog if encounter is non-empty, replace state via context setters.
|
||||
- **UI**: Two new overflow menu items in the action bar — "Export Encounter" and "Import Encounter".
|
||||
|
||||
## File Locations
|
||||
|
||||
| Artifact | Path |
|
||||
|----------|------|
|
||||
| ExportBundle type | `packages/domain/src/types.ts` or new file |
|
||||
| Import validation use case | `packages/application/src/` |
|
||||
| Export/import adapter functions | `apps/web/src/persistence/` |
|
||||
| UI integration | `apps/web/src/components/action-bar.tsx` |
|
||||
| Confirmation dialog | `apps/web/src/components/` (new or reuse existing confirm pattern) |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Domain**: No new domain tests needed (existing types unchanged).
|
||||
- **Application**: Test `validateImportBundle()` with valid bundles, invalid bundles, missing fields, wrong types, and edge cases (empty encounter, empty stacks).
|
||||
- **Adapter**: Test export bundle assembly and import file handling.
|
||||
- **Integration**: Round-trip test — export then import should produce identical state.
|
||||
79
specs/007-json-import-export/research.md
Normal file
79
specs/007-json-import-export/research.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Research: JSON Import/Export
|
||||
|
||||
**Feature**: 007-json-import-export
|
||||
**Date**: 2026-03-27
|
||||
|
||||
## Decision 1: Export Bundle Contents
|
||||
|
||||
**Decision**: Export includes encounter, undo/redo stacks, and player characters. Excludes bestiary cache, theme, and rules edition.
|
||||
|
||||
**Rationale**: The spec explicitly includes undo/redo history and player characters. Theme and rules edition are user preferences that should not transfer between DMs. Bestiary cache is large and can be rebuilt from sources.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Include theme/edition settings — rejected because these are personal preferences, not encounter data.
|
||||
- Exclude undo/redo — rejected because the spec explicitly requires it and it enables full session restore.
|
||||
- Include bestiary cache — rejected because it's large, device-specific, and reconstructable from source URLs.
|
||||
|
||||
## Decision 2: Import Strategy — Full Replacement vs Merge
|
||||
|
||||
**Decision**: Full state replacement. Import replaces encounter, undo/redo, and player characters entirely.
|
||||
|
||||
**Rationale**: The spec states "Import replaces all existing state." Merging would require conflict resolution (duplicate IDs, name collisions) which adds significant complexity for unclear benefit.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Merge player characters — rejected because ID conflicts between different sessions would be complex to resolve and the spec doesn't call for it.
|
||||
- Selective import (pick which parts to load) — rejected as out of MVP scope.
|
||||
|
||||
## Decision 3: Validation Approach
|
||||
|
||||
**Decision**: Reuse existing `rehydrateEncounter()` and player character validation from the persistence layer. These functions already handle all field validation, type checking, and graceful degradation for invalid fields.
|
||||
|
||||
**Rationale**: The spec explicitly states "validated using the same rules as localStorage loading." The existing `rehydrateEncounter()` function already validates every combatant field, filters invalid conditions, clamps HP values, and rejects structurally malformed data. Reusing it ensures consistency and avoids duplication.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Write separate import validation — rejected because it would duplicate existing validation logic and risk divergence.
|
||||
- Stricter validation (reject files with any invalid field) — rejected because the existing approach gracefully degrades (strips invalid optional fields) which is more user-friendly.
|
||||
|
||||
## Decision 4: File Download Mechanism
|
||||
|
||||
**Decision**: Use `URL.createObjectURL()` with an anchor element's `download` attribute for triggering the file download.
|
||||
|
||||
**Rationale**: This is the standard browser-native approach that works across all modern browsers without popup blockers interfering. No server-side component needed.
|
||||
|
||||
**Alternatives considered**:
|
||||
- `window.open()` with data URI — rejected because popup blockers can interfere.
|
||||
- FileSaver.js library — rejected because the native approach is sufficient and avoids an additional dependency.
|
||||
|
||||
## Decision 5: File Upload Mechanism
|
||||
|
||||
**Decision**: Use an `<input type="file" accept=".json">` element, consistent with the existing pattern in `source-fetch-prompt.tsx` which uses `file.text()` + `JSON.parse()`.
|
||||
|
||||
**Rationale**: The codebase already has this pattern for bestiary source uploads. Reusing the same approach keeps the UX consistent.
|
||||
|
||||
## Decision 6: UI Placement
|
||||
|
||||
**Decision**: Place export and import actions in the action bar's overflow menu, alongside existing items like "Players", "Manage Sources", and "Settings".
|
||||
|
||||
**Rationale**: The overflow menu already groups secondary actions. Import/export are infrequent operations that don't need primary button placement. The action bar's `buildOverflowItems()` function makes this straightforward to add.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Settings modal — rejected because import/export are actions, not settings.
|
||||
- Dedicated toolbar buttons — rejected because import/export are infrequent and would clutter the primary UI.
|
||||
|
||||
## Decision 7: Export File Naming
|
||||
|
||||
**Decision**: Use a filename pattern like `initiative-export-YYYY-MM-DD.json` with the current date.
|
||||
|
||||
**Rationale**: The date provides context for when the export was created. Including "initiative" in the name makes the file's purpose clear when browsing a downloads folder.
|
||||
|
||||
## Decision 8: State Restoration After Import
|
||||
|
||||
**Decision**: Import must update both React state and localStorage in one operation. The encounter hook's `setEncounter()` triggers a `useEffect` that auto-saves to localStorage, and `setUndoRedoState()` similarly auto-saves. For player characters, the same auto-save pattern applies.
|
||||
|
||||
**Rationale**: Following the existing state flow ensures consistency. Setting React state triggers the existing persistence effects, so no manual localStorage writes are needed for the import path.
|
||||
|
||||
## Decision 9: Export Format Versioning
|
||||
|
||||
**Decision**: Include a `version` field in the export format (e.g., `1`) but do not implement migration logic in MVP.
|
||||
|
||||
**Rationale**: The spec's assumptions state "Future format versioning is not included in MVP baseline." Including the version field costs nothing and enables future migration logic without breaking existing exports.
|
||||
106
specs/007-json-import-export/spec.md
Normal file
106
specs/007-json-import-export/spec.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Feature Specification: JSON Import/Export
|
||||
|
||||
**Feature Branch**: `007-json-import-export`
|
||||
**Created**: 2026-03-27
|
||||
**Status**: Draft
|
||||
**Input**: Gitea issue #17 — JSON import/export for full encounter state
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### Story IE-1 — Export encounter to file (Priority: P1)
|
||||
|
||||
A DM has set up an encounter (combatants, HP, initiative, conditions) and wants to save or share it. They click an export button, choose whether to include undo/redo history, and either download a `.json` file or copy the JSON to their clipboard.
|
||||
|
||||
**Why this priority**: Export is the foundation — without it, import has nothing to work with. It also delivers standalone value as a backup mechanism.
|
||||
|
||||
**Independent Test**: Can be fully tested by creating an encounter, exporting (via download or clipboard), and verifying the output contains all encounter data and player character templates.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the user clicks the export action, **Then** a dialog appears with an option to include undo/redo history (off by default) and two export methods: download file and copy to clipboard.
|
||||
2. **Given** an encounter with combatants (some with HP, AC, conditions, initiative), **When** the user exports, **Then** the output contains the encounter state, player character templates, and optionally undo/redo stacks.
|
||||
3. **Given** an empty encounter with no combatants, **When** the user exports, **Then** the output contains the empty encounter state and any existing player character templates.
|
||||
4. **Given** an encounter with player character combatants (color, icon, linked template), **When** the user exports, **Then** the exported data preserves all player character visual properties and template links.
|
||||
5. **Given** the user chooses "Copy to clipboard", **When** the export completes, **Then** the JSON is copied and a visual confirmation is shown.
|
||||
|
||||
---
|
||||
|
||||
### Story IE-2 — Import encounter from file (Priority: P1)
|
||||
|
||||
A DM receives a `.json` file from another DM (or from their own earlier export) and wants to load it. They click an import button, choose an import method (file upload or clipboard paste), and the application replaces the current state with the imported data.
|
||||
|
||||
**Why this priority**: Import completes the core value proposition — without it, export is just a read-only backup. Both are needed for the feature to be useful.
|
||||
|
||||
**Independent Test**: Can be tested by importing a valid `.json` file (or pasting valid JSON from the clipboard) and verifying the encounter, undo/redo history, and player characters are replaced with the imported data.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the user clicks the import action, **Then** a dialog appears offering two import methods: file upload and clipboard paste.
|
||||
2. **Given** the current encounter is empty, **When** the user imports valid encounter data (via file or clipboard), **Then** the application loads the imported encounter, undo/redo history, and player characters without any confirmation prompt.
|
||||
3. **Given** the current encounter has combatants, **When** the user imports valid encounter data, **Then** a confirmation dialog appears warning that the current encounter will be replaced.
|
||||
4. **Given** the confirmation dialog is shown, **When** the user confirms, **Then** the current encounter, undo/redo history, and player characters are replaced with the imported data.
|
||||
5. **Given** the confirmation dialog is shown, **When** the user cancels, **Then** the current state remains unchanged.
|
||||
|
||||
---
|
||||
|
||||
### Story IE-3 — Reject invalid import files (Priority: P2)
|
||||
|
||||
A DM accidentally selects a wrong file (a non-JSON file, a corrupted export, or a JSON file with the wrong structure). The application rejects it and shows a clear error message without losing the current state.
|
||||
|
||||
**Why this priority**: Error handling is essential for a good user experience but secondary to the core import/export flow.
|
||||
|
||||
**Independent Test**: Can be tested by attempting to import various invalid files and verifying appropriate error messages appear while the current state is preserved.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** the user selects a non-JSON file (e.g., an image), **When** the import attempts to parse it, **Then** a user-facing error message is shown and the current state is unchanged.
|
||||
2. **Given** the user selects a JSON file with missing required fields, **When** the import validates it, **Then** a user-facing error message is shown and the current state is unchanged.
|
||||
3. **Given** the user selects a JSON file with a valid top-level structure but individual combatants with invalid fields (e.g., negative HP, unknown condition IDs), **When** the import validates it, **Then** invalid fields on otherwise valid combatants are dropped/defaulted (same as localStorage rehydration), but if the top-level structure is malformed (missing `encounter` key, wrong types), the file is rejected with an error message.
|
||||
4. **Given** the user chooses clipboard import but the clipboard is empty or contains non-JSON text, **Then** a user-facing error message is shown and the current state is unchanged.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when the exported file is from a newer version of the application with unknown fields? The import ignores unknown fields and loads what it can validate.
|
||||
- What happens when the user imports a file with player characters that conflict with existing player character IDs? The imported player characters replace the existing ones entirely (full state replacement, not merge).
|
||||
- What happens when the undo/redo stacks in the imported file are empty or missing? The system loads with empty undo/redo stacks (same as a fresh session).
|
||||
- What happens when the browser blocks the file download (e.g., popup blocker)? The export uses a direct download mechanism (anchor element with download attribute) that is not subject to popup blocking.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST provide an export action accessible from the UI that lets the user download the application state as a `.json` file or copy it to the clipboard.
|
||||
- **FR-002**: The exported data MUST contain the current encounter (combatants and turn/round state) and player character templates. Undo/redo stacks MUST be includable via a user option (default: excluded).
|
||||
- **FR-003**: The exported file MUST use a human-readable default filename that includes the date. The user MAY optionally specify a custom filename; the `.json` extension is appended automatically if not provided.
|
||||
- **FR-004**: The system MUST provide an import action accessible from the UI that lets the user choose between uploading a `.json` file or pasting from the clipboard.
|
||||
- **FR-005**: On import, the system MUST replace the current encounter, undo/redo history, and player characters with the imported data.
|
||||
- **FR-006**: The system MUST show a confirmation dialog before importing if the current encounter is non-empty (has at least one combatant).
|
||||
- **FR-007**: The system MUST validate imported data using the same rules applied when loading from localStorage — invalid fields are cleaned or dropped, structurally malformed files are rejected entirely.
|
||||
- **FR-008**: The system MUST show a user-facing error message when an imported file is rejected as invalid.
|
||||
- **FR-009**: A failed or cancelled import MUST NOT alter the current application state.
|
||||
- **FR-010**: Export and import actions MUST be accessible from the same location in the UI.
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Export Bundle**: A single JSON structure containing the encounter snapshot, undo stack, redo stack, and player character list. Represents the full application state at the time of export.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Users can export the current state to a downloadable file in one click.
|
||||
- **SC-002**: Users can import a previously exported file and see the full encounter restored, including combatant stats, turn tracking, and player characters.
|
||||
- **SC-003**: Importing an invalid file shows an error message within 1 second without affecting the current state.
|
||||
- **SC-004**: A round-trip (export then import) produces an encounter identical to the original.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- The export format does not need to be backwards-compatible across application versions at this stage. Future format versioning is not included in MVP baseline.
|
||||
- Export/import covers the three main state stores: encounter, undo/redo, and player characters. Bestiary cache and user settings (theme, rules edition) are excluded.
|
||||
- The import is a full state replacement, not a merge. There is no selective import of individual pieces.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Undo/redo system (spec 006) must be implemented so that undo/redo stacks can be included in the export.
|
||||
142
specs/007-json-import-export/tasks.md
Normal file
142
specs/007-json-import-export/tasks.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Tasks: JSON Import/Export
|
||||
|
||||
**Input**: Design documents from `/specs/007-json-import-export/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md
|
||||
|
||||
**Tests**: Domain tests included (pure function testing is standard for this project per CLAUDE.md).
|
||||
|
||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundational (ExportBundle Type + Validation)
|
||||
|
||||
**Purpose**: Define the export format and validation logic that all stories depend on.
|
||||
|
||||
**⚠️ CRITICAL**: Export and import both depend on the ExportBundle type and validation.
|
||||
|
||||
- [x] T001 [P] Create `ExportBundle` type in `packages/domain/src/export-bundle.ts` with fields: `version` (number), `exportedAt` (string), `encounter` (Encounter), `undoStack` (Encounter[]), `redoStack` (Encounter[]), `playerCharacters` (PlayerCharacter[]). Export from `packages/domain/src/index.ts`.
|
||||
- [x] T002 [P] Create `validateImportBundle()` in `apps/web/src/persistence/export-import.ts` — accepts `unknown`, validates top-level structure (version, encounter, undoStack, redoStack, playerCharacters), delegates encounter validation to `rehydrateEncounter()` (imported from `./encounter-storage.ts`) and player character validation to `rehydrateCharacter()` (exported from `./player-character-storage.ts`). Returns validated `ExportBundle` or `DomainError`.
|
||||
- [x] T003 Write tests for `validateImportBundle()` in `apps/web/src/__tests__/validate-import-bundle.test.ts` — valid bundle, missing fields, invalid encounter, invalid player characters, empty stacks, unknown version, non-object input, invalid JSON types for each field.
|
||||
|
||||
**Checkpoint**: ExportBundle type and validation are tested and ready for use by export and import stories.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: User Story 1 — Export Encounter to File (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Users can download the current state as a `.json` file in one click.
|
||||
|
||||
**Independent Test**: Create an encounter with combatants, HP, conditions, and player characters. Click export. Verify the downloaded file contains all state and is valid JSON matching the ExportBundle schema.
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [x] T004 [P] [US1] Create `assembleExportBundle()` function in `apps/web/src/persistence/export-import.ts` — takes encounter, undoRedoState, and playerCharacters, returns an `ExportBundle` with version 1 and current ISO timestamp.
|
||||
- [x] T005 [P] [US1] Create `triggerDownload(bundle: ExportBundle)` function in `apps/web/src/persistence/export-import.ts` — serializes bundle to JSON, creates a Blob, generates filename `initiative-export-YYYY-MM-DD.json`, triggers download via anchor element with `download` attribute.
|
||||
- [x] T006 [US1] Add "Export Encounter" item to the overflow menu in `apps/web/src/components/action-bar.tsx` — wire it to read encounter, undoRedoState, and playerCharacters from contexts, call `assembleExportBundle()`, then `triggerDownload()`. Use a `Download` icon from Lucide.
|
||||
- [x] T007 [US1] Write test for `assembleExportBundle()` in `apps/web/src/__tests__/export-import.test.ts` — verify output shape, version field, timestamp format, and that encounter/stacks/characters are included.
|
||||
|
||||
**Checkpoint**: Export is fully functional. Users can download state as JSON.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 2 — Import Encounter from File (Priority: P1)
|
||||
|
||||
**Goal**: Users can import a `.json` file and replace the current state.
|
||||
|
||||
**Independent Test**: Export a file, clear the encounter, import the file. Verify the encounter is restored with all combatants, HP, conditions, undo/redo history, and player characters.
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T008 [US2] Expose `setEncounter` and `setUndoRedoState` from `useEncounter` hook via `EncounterContext` in `apps/web/src/hooks/use-encounter.ts` and `apps/web/src/contexts/encounter-context.tsx` — these are needed for import to replace state directly (bypassing individual use cases). Also expose a `replacePlayerCharacters` setter from `usePlayerCharacters` hook via `PlayerCharactersContext` in `apps/web/src/hooks/use-player-characters.ts` and `apps/web/src/contexts/player-characters-context.tsx`.
|
||||
- [x] T009 [US2] Create `readImportFile(file: File)` function in `apps/web/src/persistence/export-import.ts` — reads file as text, parses JSON, calls `validateImportUseCase()`, returns validated `ExportBundle` or error string.
|
||||
- [x] T010 [US2] Create `ImportConfirmPrompt` component in `apps/web/src/components/import-confirm-prompt.tsx` — confirmation dialog (using native `<dialog>` element consistent with existing patterns) warning that the current encounter will be replaced. Props: `open`, `onConfirm`, `onCancel`.
|
||||
- [x] T011 [US2] Add "Import Encounter" item to the overflow menu in `apps/web/src/components/action-bar.tsx` — renders a hidden `<input type="file" accept=".json">`, triggers it on menu item click. On file selected: validate via `readImportFile()`, show error toast on failure, show `ImportConfirmPrompt` if encounter is non-empty, replace state on confirm (or directly if encounter is empty). Use an `Upload` icon from Lucide.
|
||||
- [x] T012 [US2] Write round-trip test in `apps/web/src/__tests__/export-import.test.ts` — assemble an export bundle, validate it via the import use case, verify the result matches the original state.
|
||||
|
||||
**Checkpoint**: Import is fully functional. Users can load exported files and restore state.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 3 — Reject Invalid Import Files (Priority: P2)
|
||||
|
||||
**Goal**: Invalid files are rejected with clear error messages while preserving current state.
|
||||
|
||||
**Independent Test**: Attempt to import various invalid files (non-JSON, wrong structure, malformed combatants). Verify error messages appear and current state is unchanged.
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T013 [US3] Add user-facing error toast for import failures in `apps/web/src/components/action-bar.tsx` — use the existing toast/alert pattern in the app. Show specific messages: "Invalid file format" for non-JSON, "Invalid encounter data" for validation failures.
|
||||
- [x] T014 [US3] Write validation edge case tests in `apps/web/src/__tests__/validate-import-bundle.test.ts` — non-JSON text file content, JSON array instead of object, missing version field, version 0 or negative, encounter that fails rehydration, undo stack with mix of valid and invalid entries (valid ones kept, invalid dropped), player characters with invalid color/icon (stripped but character kept). Include a state-preservation test: set up an encounter, attempt import of an invalid file, verify encounter is unchanged after error (FR-009).
|
||||
|
||||
**Checkpoint**: All three stories are complete. Invalid files are handled gracefully.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final cleanup and documentation.
|
||||
|
||||
- [x] T015 Update CLAUDE.md spec listing to describe the feature in `CLAUDE.md`
|
||||
- [x] T016 N/A — no project-level README.md exists
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Foundational (Phase 1)**: No dependencies — can start immediately
|
||||
- **US1 Export (Phase 2)**: Depends on Phase 1 (needs ExportBundle type)
|
||||
- **US2 Import (Phase 3)**: Depends on Phase 1 (needs validation use case) and Phase 2 (needs export for round-trip testing)
|
||||
- **US3 Error Handling (Phase 4)**: Depends on Phase 3 (builds on import flow)
|
||||
- **Polish (Phase 5)**: Depends on all stories being complete
|
||||
|
||||
### Within Each Phase
|
||||
|
||||
- Tasks marked [P] can run in parallel
|
||||
- T001 and T002 are parallel (different files)
|
||||
- T004 and T005 are parallel (different functions, same file but independent)
|
||||
- T008 must complete before T011 (setters must exist before import wiring)
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- Phase 1: T001 and T002 can run in parallel (type definition + validation use case)
|
||||
- Phase 2: T004 and T005 can run in parallel (assemble + download functions)
|
||||
- Phase 2: T007 can run in parallel with T006 (test + UI wiring)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (Export Only)
|
||||
|
||||
1. Complete Phase 1: ExportBundle type + validation
|
||||
2. Complete Phase 2: Export functionality
|
||||
3. **STOP and VALIDATE**: User can download encounter state as JSON
|
||||
4. Continue to Phase 3: Import functionality
|
||||
5. Continue to Phase 4: Error handling polish
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Phase 1 → Foundation ready
|
||||
2. Phase 2 → Export works → Delivers backup value
|
||||
3. Phase 3 → Import works → Delivers full round-trip + sharing value
|
||||
4. Phase 4 → Error handling → Production-ready robustness
|
||||
5. Phase 5 → Documentation updated
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Reuse `rehydrateEncounter()` from `apps/web/src/persistence/encounter-storage.ts` for all encounter validation — do not duplicate
|
||||
- Follow existing file picker pattern from `apps/web/src/components/source-fetch-prompt.tsx`
|
||||
- Follow existing overflow menu pattern in `apps/web/src/components/action-bar.tsx`
|
||||
- Follow existing `<dialog>` pattern from `apps/web/src/components/settings-modal.tsx`
|
||||
- Commit after each phase checkpoint
|
||||
Reference in New Issue
Block a user