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
+99 -6
View File
@@ -5,11 +5,19 @@ import {
characterSchema,
encounterSchema,
diceRollSchema,
noteSchema,
npcSchema,
questSchema,
calendarSchema,
type Campaign,
type CampaignDraft,
type Character,
type Encounter,
type DiceRoll,
type Note,
type Npc,
type Quest,
type Calendar,
} from '@/lib/schemas';
import { getSystem } from '@/lib/rules';
@@ -47,12 +55,20 @@ export const campaignsRepo = {
/** Delete a campaign and ALL its children atomically (real cascade). */
async remove(id: string): Promise<void> {
await db.transaction('rw', db.campaigns, db.characters, db.encounters, db.diceRolls, async () => {
await db.characters.where('campaignId').equals(id).delete();
await db.encounters.where('campaignId').equals(id).delete();
await db.diceRolls.where('campaignId').equals(id).delete();
await db.campaigns.delete(id);
});
await db.transaction(
'rw',
[db.campaigns, db.characters, db.encounters, db.diceRolls, db.notes, db.npcs, db.quests, db.calendars],
async () => {
await db.characters.where('campaignId').equals(id).delete();
await db.encounters.where('campaignId').equals(id).delete();
await db.diceRolls.where('campaignId').equals(id).delete();
await db.notes.where('campaignId').equals(id).delete();
await db.npcs.where('campaignId').equals(id).delete();
await db.quests.where('campaignId').equals(id).delete();
await db.calendars.delete(id);
await db.campaigns.delete(id);
},
);
},
};
@@ -171,3 +187,80 @@ export const diceRepo = {
else await db.diceRolls.clear();
},
};
// ---------------- Notes ----------------
export const notesRepo = {
listByCampaign(campaignId: string): Promise<Note[]> {
return db.notes.where('campaignId').equals(campaignId).toArray();
},
get(id: string): Promise<Note | undefined> {
return db.notes.get(id);
},
async create(campaignId: string, title: string): Promise<Note> {
const ts = now();
const note = noteSchema.parse({ id: newId(), campaignId, title, body: '', tags: [], createdAt: ts, updatedAt: ts });
await db.notes.add(note);
return note;
},
async update(id: string, patch: Partial<Omit<Note, 'id' | 'campaignId' | 'createdAt'>>): Promise<void> {
await db.notes.update(id, { ...patch, updatedAt: now() });
},
async remove(id: string): Promise<void> {
await db.notes.delete(id);
},
};
// ---------------- NPCs ----------------
export const npcsRepo = {
listByCampaign(campaignId: string): Promise<Npc[]> {
return db.npcs.where('campaignId').equals(campaignId).toArray();
},
async create(campaignId: string, name: string): Promise<Npc> {
const ts = now();
const npc = npcSchema.parse({
id: newId(), campaignId, name, role: '', location: '', faction: '',
status: 'alive', description: '', createdAt: ts, updatedAt: ts,
});
await db.npcs.add(npc);
return npc;
},
async update(id: string, patch: Partial<Omit<Npc, 'id' | 'campaignId' | 'createdAt'>>): Promise<void> {
await db.npcs.update(id, { ...patch, updatedAt: now() });
},
async remove(id: string): Promise<void> {
await db.npcs.delete(id);
},
};
// ---------------- Quests ----------------
export const questsRepo = {
listByCampaign(campaignId: string): Promise<Quest[]> {
return db.quests.where('campaignId').equals(campaignId).toArray();
},
async create(campaignId: string, title: string): Promise<Quest> {
const ts = now();
const quest = questSchema.parse({
id: newId(), campaignId, title, status: 'active', description: '', reward: '',
objectives: [], createdAt: ts, updatedAt: ts,
});
await db.quests.add(quest);
return quest;
},
async update(id: string, patch: Partial<Omit<Quest, 'id' | 'campaignId' | 'createdAt'>>): Promise<void> {
await db.quests.update(id, { ...patch, updatedAt: now() });
},
async remove(id: string): Promise<void> {
await db.quests.delete(id);
},
};
// ---------------- Calendar (one per campaign) ----------------
export const calendarRepo = {
/** Read-only — safe to call inside a Dexie liveQuery. Missing = undefined. */
get(campaignId: string): Promise<Calendar | undefined> {
return db.calendars.get(campaignId);
},
async save(calendar: Calendar): Promise<void> {
await db.calendars.put(calendarSchema.parse(calendar));
},
};