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) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 01:44:47 +02:00
parent 9647e6b3d6
commit ed3d967526
18 changed files with 866 additions and 9 deletions
+125
View File
@@ -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 <RequireCampaign>{(c) => <Dashboard campaign={c} />}</RequireCampaign>;
}
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 (
<Page>
<PageHeader
title={campaign.name}
subtitle={`${sys.label}${calendar ? ` · in-world day ${calendar.currentDay}` : ''}`}
/>
{campaign.description && <p className="mb-6 max-w-3xl text-sm text-muted">{campaign.description}</p>}
{/* Quick stats */}
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
<Stat label="Characters" value={characters.length} />
<Stat label="Encounters" value={encounters.length} />
<Stat label="Active quests" value={activeQuests} />
<Stat label="NPCs" value={npcs.length} />
<Stat label="Notes" value={notes.length} />
</div>
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
{/* Navigation cards */}
<section>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Jump to</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{LINKS.map((l) => (
<Link
key={l.to}
to={l.to}
className="flex flex-col items-center gap-1 rounded-lg border border-line bg-panel p-4 text-center transition-colors hover:border-accent/50"
>
<span className="text-2xl" aria-hidden>{l.icon}</span>
<span className="text-sm text-ink">{l.label}</span>
</Link>
))}
</div>
{/* Party overview */}
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Party</h2>
{pcs.length === 0 ? (
<p className="text-sm text-muted">No player characters yet.</p>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{pcs.map((c) => (
<Link
key={c.id}
to="/characters/$characterId"
params={{ characterId: c.id }}
className="flex items-center justify-between rounded-md border border-line bg-panel px-3 py-2 text-sm hover:border-accent/40"
>
<span className="truncate text-ink">{c.name} <span className="text-muted">Lv {c.level}</span></span>
<span className="shrink-0 text-muted">
HP {c.hp.current}/{c.hp.max} · AC {sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus })}
</span>
</Link>
))}
</div>
)}
</section>
{/* Recent rolls */}
<aside>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Recent rolls</h2>
{rolls.length === 0 ? (
<p className="text-sm text-muted">No rolls yet.</p>
) : (
<ul className="space-y-1">
{rolls.map((r) => (
<li key={r.id} className="flex items-center justify-between rounded-md border border-line bg-panel px-3 py-1.5 text-sm">
<span className="truncate text-muted">{r.label ? `${r.label}: ` : ''}<span className="font-mono">{r.expression}</span></span>
<span className="font-display font-semibold text-ink">{r.total}</span>
</li>
))}
</ul>
)}
</aside>
</div>
</Page>
);
}
function Stat({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-lg border border-line bg-panel p-4 text-center">
<div className="font-display text-3xl font-bold text-accent">{value}</div>
<div className="text-xs uppercase tracking-wide text-muted">{label}</div>
</div>
);
}