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
+65
View File
@@ -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 <RequireCampaign>{(c) => <CalendarView campaign={c} />}</RequireCampaign>;
}
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 (
<Page>
<PageHeader title="Calendar" subtitle={`${campaign.name} · in-world timeline`} />
<div className="mb-6 flex items-center gap-3 rounded-lg border border-line bg-panel p-4">
<Button variant="secondary" onClick={() => advance(-1)} aria-label="Previous day">1 day</Button>
<div className="text-center">
<div className="text-xs uppercase tracking-wide text-muted">Day</div>
<div className="font-display text-3xl font-bold text-accent">{calendar.currentDay}</div>
</div>
<Button variant="secondary" onClick={() => advance(1)} aria-label="Next day">+1 day</Button>
<Button variant="ghost" onClick={() => advance(7)}>+1 week</Button>
</div>
<div className="mb-3 flex gap-2">
<Input value={eventTitle} onChange={(e) => setEventTitle(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addEvent()} placeholder={`Add event on day ${calendar.currentDay}`} aria-label="Event title" />
<Button variant="primary" onClick={addEvent}>Add event</Button>
</div>
{upcoming.length === 0 ? (
<p className="text-sm text-muted">No events yet.</p>
) : (
<ul className="space-y-1">
{upcoming.map((ev) => (
<li key={ev.id} className="flex items-center gap-3 rounded-md border border-line bg-panel px-3 py-2 text-sm">
<span className={'w-16 shrink-0 font-mono ' + (ev.day === calendar.currentDay ? 'text-accent' : 'text-muted')}>Day {ev.day}</span>
<span className="flex-1 text-ink">{ev.title}</span>
<button className="text-muted hover:text-danger" onClick={() => save({ ...calendar, events: calendar.events.filter((e) => e.id !== ev.id) })} aria-label={`Remove ${ev.title}`}></button>
</li>
))}
</ul>
)}
</Page>
);
}