From ed3d967526ea23563e37c78090428c47a9cf7acf Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 01:44:47 +0200 Subject: [PATCH] Phase 5: campaign management & worldbuilding - New entities (note/npc/quest/calendar): schemas, Dexie v4 tables, repos, hooks; campaign cascade-delete now covers them all - Dashboard hub: campaign stats, navigation cards, party overview, recent rolls; opening a campaign lands here - Notes/Wiki: tags, [[wiki-links]] with click-to-open/create, backlinks panel, full-text search, autosave - NPC manager (role/location/faction/status) and Quest tracker (status + objectives checklist + reward) - In-world Calendar: integer day counter + events (no real Date -> no timezone bug) - Fix: calendarRepo.get is read-only (creating-on-read crashed inside liveQuery) 8 new unit tests (wikilinks + extended cascade), worldbuilding e2e. Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e/worldbuilding.spec.ts | 50 +++++++ src/app/RootLayout.tsx | 1 + src/features/campaigns/CampaignsPage.tsx | 2 +- src/features/world/CalendarPage.tsx | 65 ++++++++ src/features/world/DashboardPage.tsx | 125 ++++++++++++++++ src/features/world/NotesPage.tsx | 181 +++++++++++++++++++++++ src/features/world/NpcsPage.tsx | 71 +++++++++ src/features/world/QuestsPage.tsx | 105 +++++++++++++ src/features/world/hooks.ts | 16 ++ src/lib/db/db.ts | 13 ++ src/lib/db/repositories.test.ts | 10 +- src/lib/db/repositories.ts | 105 ++++++++++++- src/lib/schemas/index.ts | 1 + src/lib/schemas/world.ts | 68 +++++++++ src/lib/wikilinks.test.ts | 22 +++ src/lib/wikilinks.ts | 23 +++ src/router.tsx | 15 ++ tsconfig.app.tsbuildinfo | 2 +- 18 files changed, 866 insertions(+), 9 deletions(-) create mode 100644 e2e/worldbuilding.spec.ts create mode 100644 src/features/world/CalendarPage.tsx create mode 100644 src/features/world/DashboardPage.tsx create mode 100644 src/features/world/NotesPage.tsx create mode 100644 src/features/world/NpcsPage.tsx create mode 100644 src/features/world/QuestsPage.tsx create mode 100644 src/features/world/hooks.ts create mode 100644 src/lib/schemas/world.ts create mode 100644 src/lib/wikilinks.test.ts create mode 100644 src/lib/wikilinks.ts diff --git a/e2e/worldbuilding.spec.ts b/e2e/worldbuilding.spec.ts new file mode 100644 index 0000000..ec4481d --- /dev/null +++ b/e2e/worldbuilding.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.evaluate(async () => { + indexedDB.deleteDatabase('ttrpg-manager'); + localStorage.clear(); + }); + await page.reload(); +}); + +test('worldbuilding: dashboard, note with wiki link, npc, quest, calendar', async ({ page }) => { + // Create campaign, then open its dashboard + await page.getByRole('button', { name: '+ New campaign' }).first().click(); + await page.locator('input[data-autofocus]').fill('Barovia'); + await page.getByRole('button', { name: 'Create' }).click(); + await page.getByRole('link', { name: 'Dashboard' }).click(); + await expect(page.getByRole('heading', { name: 'Barovia' })).toBeVisible(); + + // Notes: create two and link them + await page.getByRole('link', { name: 'Notes & Wiki' }).click(); + await page.getByRole('button', { name: '+ New note' }).first().click(); + await page.getByLabel('Note title').fill('Strahd'); + await page.getByPlaceholder(/Write here/).fill('Lord of [[Castle Ravenloft]].'); + // The link is "missing" until created; click it to create the target note + await page.getByRole('button', { name: 'Castle Ravenloft' }).click(); + await expect(page.getByLabel('Note title')).toHaveValue('Castle Ravenloft'); + // Backlink from Strahd should appear + await expect(page.getByText('Linked from')).toBeVisible(); + + // NPC + await page.getByRole('link', { name: 'Dashboard' }).click(); + await page.getByRole('link', { name: 'NPCs' }).click(); + await page.getByRole('button', { name: '+ New NPC' }).first().click(); + await expect(page.getByLabel('NPC name')).toBeVisible(); + + // Quest with an objective + await page.getByRole('link', { name: 'Dashboard' }).click(); + await page.getByRole('link', { name: 'Quests' }).click(); + await page.getByRole('button', { name: '+ New quest' }).first().click(); + await page.getByPlaceholder('Add objective…').fill('Find the Tome'); + await page.getByRole('button', { name: 'Add', exact: true }).click(); + await expect(page.locator('input[value="Find the Tome"]')).toBeVisible(); + + // Calendar advances days + await page.getByRole('link', { name: 'Dashboard' }).click(); + await page.getByRole('link', { name: 'Calendar' }).click(); + await page.getByRole('button', { name: '+1 week' }).click(); + await expect(page.getByText('7', { exact: true })).toBeVisible(); +}); diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx index 207c4f3..718d041 100644 --- a/src/app/RootLayout.tsx +++ b/src/app/RootLayout.tsx @@ -9,6 +9,7 @@ import { Button } from '@/components/ui/Button'; const NAV = [ { to: '/', label: 'Campaigns', exact: true }, + { to: '/dashboard', label: 'Dashboard', exact: false }, { to: '/characters', label: 'Characters', exact: false }, { to: '/combat', label: 'Combat', exact: false }, { to: '/dice', label: 'Dice', exact: false }, diff --git a/src/features/campaigns/CampaignsPage.tsx b/src/features/campaigns/CampaignsPage.tsx index 0cc2367..04667b9 100644 --- a/src/features/campaigns/CampaignsPage.tsx +++ b/src/features/campaigns/CampaignsPage.tsx @@ -59,7 +59,7 @@ function CampaignCard({ campaign, onEdit }: { campaign: Campaign; onEdit: () => const open = () => { setActive(campaign.id); - void navigate({ to: '/characters' }); + void navigate({ to: '/dashboard' }); }; return ( diff --git a/src/features/world/CalendarPage.tsx b/src/features/world/CalendarPage.tsx new file mode 100644 index 0000000..c3b0fbd --- /dev/null +++ b/src/features/world/CalendarPage.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react'; +import type { Calendar, Campaign } from '@/lib/schemas'; +import { calendarRepo } from '@/lib/db/repositories'; +import { newId } from '@/lib/ids'; +import { useCalendar } from './hooks'; +import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; + +export function CalendarPage() { + return {(c) => }; +} + +function CalendarView({ campaign }: { campaign: Campaign }) { + const stored = useCalendar(campaign.id); + const [eventTitle, setEventTitle] = useState(''); + + // The row is created on first save; until then we work with an in-memory default. + const calendar: Calendar = stored ?? { campaignId: campaign.id, currentDay: 0, events: [] }; + + const save = (next: Calendar) => void calendarRepo.save(next); + const advance = (delta: number) => save({ ...calendar, currentDay: calendar.currentDay + delta }); + const addEvent = () => { + if (eventTitle.trim() === '') return; + save({ ...calendar, events: [...calendar.events, { id: newId(), day: calendar.currentDay, title: eventTitle.trim() }] }); + setEventTitle(''); + }; + + const upcoming = [...calendar.events].sort((a, b) => a.day - b.day); + + return ( + + + +
+ +
+
Day
+
{calendar.currentDay}
+
+ + +
+ +
+ setEventTitle(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addEvent()} placeholder={`Add event on day ${calendar.currentDay}…`} aria-label="Event title" /> + +
+ + {upcoming.length === 0 ? ( +

No events yet.

+ ) : ( +
    + {upcoming.map((ev) => ( +
  • + Day {ev.day} + {ev.title} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/features/world/DashboardPage.tsx b/src/features/world/DashboardPage.tsx new file mode 100644 index 0000000..5741d1b --- /dev/null +++ b/src/features/world/DashboardPage.tsx @@ -0,0 +1,125 @@ +import { Link } from '@tanstack/react-router'; +import { useLiveQuery } from 'dexie-react-hooks'; +import type { Campaign } from '@/lib/schemas'; +import { diceRepo } from '@/lib/db/repositories'; +import { getSystem } from '@/lib/rules'; +import { useCharacters } from '@/features/characters/hooks'; +import { useEncounters } from '@/features/combat/hooks'; +import { useNotes, useNpcs, useQuests, useCalendar } from './hooks'; +import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page'; + +export function DashboardPage() { + return {(c) => }; +} + +const LINKS = [ + { to: '/characters', label: 'Characters', icon: '🧝' }, + { to: '/combat', label: 'Combat', icon: '⚔' }, + { to: '/notes', label: 'Notes & Wiki', icon: '📜' }, + { to: '/npcs', label: 'NPCs', icon: '🎭' }, + { to: '/quests', label: 'Quests', icon: '🗺' }, + { to: '/calendar', label: 'Calendar', icon: '📅' }, + { to: '/compendium', label: 'Compendium', icon: '📚' }, + { to: '/dice', label: 'Dice', icon: '🎲' }, +] as const; + +function Dashboard({ campaign }: { campaign: Campaign }) { + const characters = useCharacters(campaign.id); + const encounters = useEncounters(campaign.id); + const notes = useNotes(campaign.id); + const npcs = useNpcs(campaign.id); + const quests = useQuests(campaign.id); + const calendar = useCalendar(campaign.id); + const rolls = useLiveQuery(() => diceRepo.recent(campaign.id, 8), [campaign.id], []); + + const sys = getSystem(campaign.system); + const pcs = characters.filter((c) => c.kind === 'pc'); + const activeQuests = quests.filter((q) => q.status === 'active').length; + + return ( + + + + {campaign.description &&

{campaign.description}

} + + {/* Quick stats */} +
+ + + + + +
+ +
+ {/* Navigation cards */} +
+

Jump to

+
+ {LINKS.map((l) => ( + + {l.icon} + {l.label} + + ))} +
+ + {/* Party overview */} +

Party

+ {pcs.length === 0 ? ( +

No player characters yet.

+ ) : ( +
+ {pcs.map((c) => ( + + {c.name} Lv {c.level} + + HP {c.hp.current}/{c.hp.max} · AC {sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus })} + + + ))} +
+ )} +
+ + {/* Recent rolls */} + +
+
+ ); +} + +function Stat({ label, value }: { label: string; value: number }) { + return ( +
+
{value}
+
{label}
+
+ ); +} diff --git a/src/features/world/NotesPage.tsx b/src/features/world/NotesPage.tsx new file mode 100644 index 0000000..a2689a2 --- /dev/null +++ b/src/features/world/NotesPage.tsx @@ -0,0 +1,181 @@ +import { useMemo, useState } from 'react'; +import type { Campaign, Note } from '@/lib/schemas'; +import { notesRepo } from '@/lib/db/repositories'; +import { useDebouncedCallback } from '@/lib/useDebouncedCallback'; +import { extractWikiLinks, splitWikiLinks } from '@/lib/wikilinks'; +import { useNotes } from './hooks'; +import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { cn } from '@/lib/cn'; + +export function NotesPage() { + return {(c) => }; +} + +function Notes({ campaign }: { campaign: Campaign }) { + const notes = useNotes(campaign.id); + const [selectedId, setSelectedId] = useState(null); + const [query, setQuery] = useState(''); + + const selected = notes.find((n) => n.id === selectedId) ?? null; + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + const list = q + ? notes.filter((n) => n.title.toLowerCase().includes(q) || n.body.toLowerCase().includes(q) || n.tags.some((t) => t.toLowerCase().includes(q))) + : notes; + return [...list].sort((a, b) => a.title.localeCompare(b.title)); + }, [notes, query]); + + const createNote = async (title = 'Untitled') => { + const note = await notesRepo.create(campaign.id, title); + setSelectedId(note.id); + }; + + const openByTitle = async (title: string) => { + const existing = notes.find((n) => n.title.toLowerCase() === title.toLowerCase()); + if (existing) setSelectedId(existing.id); + else await createNote(title); + }; + + return ( + + createNote()}>+ New note} + /> + + {notes.length === 0 ? ( + createNote()}>+ New note} + /> + ) : ( +
+
+ setQuery(e.target.value)} placeholder="Search notes…" aria-label="Search notes" /> +
    + {filtered.map((n) => ( +
  • + +
  • + ))} +
+
+ + {selected ? ( + + ) : ( +
Select a note to edit.
+ )} +
+ )} +
+ ); +} + +function NoteEditor({ + note, + allNotes, + onOpenTitle, + onSelect, +}: { + note: Note; + allNotes: Note[]; + onOpenTitle: (title: string) => void; + onSelect: (id: string | null) => void; +}) { + const [title, setTitle] = useState(note.title); + const [body, setBody] = useState(note.body); + const [tags, setTags] = useState(note.tags.join(', ')); + + const save = useDebouncedCallback((patch: Partial) => void notesRepo.update(note.id, patch), 350); + + const backlinks = allNotes.filter( + (n) => n.id !== note.id && extractWikiLinks(n.body).some((t) => t.toLowerCase() === note.title.toLowerCase()), + ); + const titlesLower = new Set(allNotes.map((n) => n.title.toLowerCase())); + + return ( +
+
+ { setTitle(e.target.value); save({ title: e.target.value }); }} + /> + +
+ + + +