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>
);
}
+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>
);
}
+181
View File
@@ -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 <RequireCampaign>{(c) => <Notes campaign={c} />}</RequireCampaign>;
}
function Notes({ campaign }: { campaign: Campaign }) {
const notes = useNotes(campaign.id);
const [selectedId, setSelectedId] = useState<string | null>(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 (
<Page>
<PageHeader
title="Notes & Wiki"
subtitle={`${campaign.name} · link entries with [[double brackets]]`}
actions={<Button variant="primary" onClick={() => createNote()}>+ New note</Button>}
/>
{notes.length === 0 ? (
<EmptyState
title="No notes yet"
hint="Capture lore, session recaps, and locations. Use [[wiki links]] to connect them."
action={<Button variant="primary" onClick={() => createNote()}>+ New note</Button>}
/>
) : (
<div className="grid gap-4 lg:grid-cols-[280px_1fr]">
<div>
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search notes…" aria-label="Search notes" />
<ul className="mt-2 max-h-[65vh] space-y-1 overflow-auto">
{filtered.map((n) => (
<li key={n.id}>
<button
onClick={() => setSelectedId(n.id)}
className={cn(
'w-full truncate rounded-md border px-3 py-2 text-left text-sm',
selected?.id === n.id ? 'border-accent bg-elevated' : 'border-line bg-panel hover:border-accent/40',
)}
>
{n.title}
</button>
</li>
))}
</ul>
</div>
{selected ? (
<NoteEditor key={selected.id} note={selected} allNotes={notes} onOpenTitle={openByTitle} onSelect={setSelectedId} />
) : (
<div className="rounded-lg border border-line bg-panel p-5 text-sm text-muted">Select a note to edit.</div>
)}
</div>
)}
</Page>
);
}
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<Note>) => 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 (
<div className="rounded-lg border border-line bg-panel p-4">
<div className="mb-3 flex items-center gap-2">
<Input
className="font-display text-lg font-semibold"
value={title}
aria-label="Note title"
onChange={(e) => { setTitle(e.target.value); save({ title: e.target.value }); }}
/>
<Button
variant="ghost"
className="text-danger"
onClick={async () => { await notesRepo.remove(note.id); onSelect(null); }}
>
Delete
</Button>
</div>
<label className="mb-3 block text-xs text-muted">
Tags (comma-separated)
<Input
value={tags}
onChange={(e) => {
setTags(e.target.value);
save({ tags: e.target.value.split(',').map((t) => t.trim()).filter(Boolean) });
}}
placeholder="lore, barovia, npc"
/>
</label>
<textarea
value={body}
onChange={(e) => { setBody(e.target.value); save({ body: e.target.value }); }}
placeholder="Write here… link other notes with [[Note Title]]."
className="min-h-48 w-full resize-y rounded-md border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
/>
{/* Rendered preview with clickable links */}
<div className="mt-4">
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Preview</h3>
<p className="whitespace-pre-wrap rounded-md border border-line bg-surface p-3 text-sm text-ink">
{splitWikiLinks(body).map((part, i) =>
part.link ? (
<button
key={i}
onClick={() => onOpenTitle(part.link!)}
className={cn('underline', titlesLower.has(part.link.toLowerCase()) ? 'text-accent' : 'text-warning')}
title={titlesLower.has(part.link.toLowerCase()) ? 'Open note' : 'Create this note'}
>
{part.text}
</button>
) : (
<span key={i}>{part.text}</span>
),
)}
</p>
</div>
{backlinks.length > 0 && (
<div className="mt-4">
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Linked from</h3>
<div className="flex flex-wrap gap-1">
{backlinks.map((n) => (
<button key={n.id} onClick={() => onSelect(n.id)} className="rounded-md border border-line bg-surface px-2 py-1 text-xs text-ink hover:border-accent/40">
{n.title}
</button>
))}
</div>
</div>
)}
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { useState } from 'react';
import type { Campaign, Npc } from '@/lib/schemas';
import { npcsRepo } from '@/lib/db/repositories';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { useNpcs } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
const STATUS: Npc['status'][] = ['alive', 'dead', 'unknown'];
export function NpcsPage() {
return <RequireCampaign>{(c) => <Npcs campaign={c} />}</RequireCampaign>;
}
function Npcs({ campaign }: { campaign: Campaign }) {
const npcs = useNpcs(campaign.id);
const [query, setQuery] = useState('');
const filtered = npcs
.filter((n) => n.name.toLowerCase().includes(query.toLowerCase()) || n.faction.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => a.name.localeCompare(b.name));
return (
<Page>
<PageHeader
title="NPCs"
subtitle={campaign.name}
actions={<Button variant="primary" onClick={() => npcsRepo.create(campaign.id, 'New NPC')}>+ New NPC</Button>}
/>
{npcs.length === 0 ? (
<EmptyState title="No NPCs yet" hint="Track the people your party meets — allies, villains, shopkeepers." action={<Button variant="primary" onClick={() => npcsRepo.create(campaign.id, 'New NPC')}>+ New NPC</Button>} />
) : (
<>
<Input className="mb-3 max-w-sm" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search NPCs…" aria-label="Search NPCs" />
<div className="grid gap-3 md:grid-cols-2">
{filtered.map((n) => <NpcCard key={n.id} npc={n} />)}
</div>
</>
)}
</Page>
);
}
function NpcCard({ npc }: { npc: Npc }) {
const [n, setN] = useState(npc);
const save = useDebouncedCallback((patch: Partial<Npc>) => void npcsRepo.update(npc.id, patch), 350);
const update = (patch: Partial<Npc>) => { setN((p) => ({ ...p, ...patch })); save(patch); };
return (
<div className="rounded-lg border border-line bg-panel p-4">
<div className="flex items-center gap-2">
<Input className="font-display font-semibold" value={n.name} onChange={(e) => update({ name: e.target.value })} aria-label="NPC name" />
<Select className="w-auto py-1 text-xs" value={n.status} onChange={(e) => update({ status: e.target.value as Npc['status'] })} aria-label="Status">
{STATUS.map((s) => <option key={s} value={s}>{s}</option>)}
</Select>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => npcsRepo.remove(npc.id)} aria-label={`Delete ${n.name}`}></Button>
</div>
<div className="mt-2 grid grid-cols-3 gap-2">
<Input className="text-xs" value={n.role} onChange={(e) => update({ role: e.target.value })} placeholder="Role" aria-label="Role" />
<Input className="text-xs" value={n.location} onChange={(e) => update({ location: e.target.value })} placeholder="Location" aria-label="Location" />
<Input className="text-xs" value={n.faction} onChange={(e) => update({ faction: e.target.value })} placeholder="Faction" aria-label="Faction" />
</div>
<textarea
value={n.description}
onChange={(e) => update({ description: e.target.value })}
placeholder="Appearance, motives, voice, secrets…"
className="mt-2 min-h-20 w-full resize-y rounded-md border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
/>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { useState } from 'react';
import type { Campaign, Quest, Objective } from '@/lib/schemas';
import { questsRepo } from '@/lib/db/repositories';
import { newId } from '@/lib/ids';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { useQuests } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
import { cn } from '@/lib/cn';
const STATUSES: Quest['status'][] = ['active', 'on-hold', 'completed', 'failed'];
const STATUS_COLOR: Record<Quest['status'], string> = {
active: 'text-info', 'on-hold': 'text-muted', completed: 'text-success', failed: 'text-danger',
};
export function QuestsPage() {
return <RequireCampaign>{(c) => <Quests campaign={c} />}</RequireCampaign>;
}
function Quests({ campaign }: { campaign: Campaign }) {
const quests = useQuests(campaign.id);
const order = { active: 0, 'on-hold': 1, completed: 2, failed: 3 };
const sorted = [...quests].sort((a, b) => order[a.status] - order[b.status] || a.title.localeCompare(b.title));
return (
<Page>
<PageHeader
title="Quests"
subtitle={campaign.name}
actions={<Button variant="primary" onClick={() => questsRepo.create(campaign.id, 'New quest')}>+ New quest</Button>}
/>
{quests.length === 0 ? (
<EmptyState title="No quests yet" hint="Track objectives, rewards, and progress." action={<Button variant="primary" onClick={() => questsRepo.create(campaign.id, 'New quest')}>+ New quest</Button>} />
) : (
<div className="space-y-3">
{sorted.map((q) => <QuestCard key={q.id} quest={q} />)}
</div>
)}
</Page>
);
}
function QuestCard({ quest }: { quest: Quest }) {
const [q, setQ] = useState(quest);
const [newObj, setNewObj] = useState('');
const save = useDebouncedCallback((patch: Partial<Quest>) => void questsRepo.update(quest.id, patch), 350);
const update = (patch: Partial<Quest>) => { setQ((p) => ({ ...p, ...patch })); save(patch); };
const setObjective = (id: string, patch: Partial<Objective>) =>
update({ objectives: q.objectives.map((o) => (o.id === id ? { ...o, ...patch } : o)) });
const addObjective = () => {
if (newObj.trim() === '') return;
update({ objectives: [...q.objectives, { id: newId(), text: newObj.trim(), done: false }] });
setNewObj('');
};
const done = q.objectives.filter((o) => o.done).length;
return (
<div className="rounded-lg border border-line bg-panel p-4">
<div className="flex flex-wrap items-center gap-2">
<Input className="flex-1 font-display font-semibold" value={q.title} onChange={(e) => update({ title: e.target.value })} aria-label="Quest title" />
<Select className={cn('w-auto py-1 text-xs font-medium', STATUS_COLOR[q.status])} value={q.status} onChange={(e) => update({ status: e.target.value as Quest['status'] })} aria-label="Quest status">
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</Select>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => questsRepo.remove(quest.id)} aria-label={`Delete ${q.title}`}></Button>
</div>
<textarea
value={q.description}
onChange={(e) => update({ description: e.target.value })}
placeholder="What's this quest about?"
className="mt-2 min-h-16 w-full resize-y rounded-md border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
/>
<label className="mt-2 block text-xs text-muted">
Reward
<Input value={q.reward} onChange={(e) => update({ reward: e.target.value })} placeholder="500 gp, a favour, a magic sword…" />
</label>
<div className="mt-3">
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">
Objectives {q.objectives.length > 0 && `(${done}/${q.objectives.length})`}
</div>
<ul className="space-y-1">
{q.objectives.map((o) => (
<li key={o.id} className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={o.done} onChange={(e) => setObjective(o.id, { done: e.target.checked })} aria-label={o.text} />
<input
className={cn('flex-1 bg-transparent text-sm focus:outline-none', o.done ? 'text-muted line-through' : 'text-ink')}
value={o.text}
onChange={(e) => setObjective(o.id, { text: e.target.value })}
/>
<button className="text-muted hover:text-danger" onClick={() => update({ objectives: q.objectives.filter((x) => x.id !== o.id) })} aria-label="Remove objective"></button>
</li>
))}
</ul>
<div className="mt-1 flex gap-2">
<Input className="h-8 text-sm" value={newObj} onChange={(e) => setNewObj(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addObjective()} placeholder="Add objective…" />
<Button size="sm" onClick={addObjective}>Add</Button>
</div>
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { useLiveQuery } from 'dexie-react-hooks';
import { notesRepo, npcsRepo, questsRepo, calendarRepo } from '@/lib/db/repositories';
import type { Note, Npc, Quest, Calendar } from '@/lib/schemas';
export function useNotes(campaignId: string): Note[] {
return useLiveQuery(() => notesRepo.listByCampaign(campaignId), [campaignId], []);
}
export function useNpcs(campaignId: string): Npc[] {
return useLiveQuery(() => npcsRepo.listByCampaign(campaignId), [campaignId], []);
}
export function useQuests(campaignId: string): Quest[] {
return useLiveQuery(() => questsRepo.listByCampaign(campaignId), [campaignId], []);
}
export function useCalendar(campaignId: string): Calendar | undefined {
return useLiveQuery(() => calendarRepo.get(campaignId), [campaignId], undefined);
}