feat(plot): Chunk #5 story whiteboard, timeline & plot beats

Story whiteboard built on React Flow v11: free-form canvas of typed
nodes (character, world, chapter, note, image, group) with draggable
leads-to/contrasts/causes edges, cross-link selectors (chapter/entity),
linked-node badges, and an "Add beats to board" import from the plot.

Plot beats feature set:
- Status/locking (draft/revised/locked) with a progress bar
- Beat templates (three-act, save-the-cat, seven-point, custom)
- Timeline route with search/chapter/character filters
- Entity cross-links (entityIds) on beats and board nodes
- getBoard added to the StorageAdapter contract (local + server) and
  REST endpoints; loadBoard now restores persisted board state

Storage & server:
- PlotBeat type (status, entityIds) across core types, Dexie schema,
  and server mapping/routes/migration (002_plot_beats.sql)
- SQLite-safe column names (beat_order, entity_ids)
- Server seed data for plot beats

UI: accent Button variant, Badge variants, plot/timeline/whiteboard
stylesheets. Tests: templates module, plot-beat store round-trip,
board getBoard. typecheck, lint, test, and build all green.
This commit is contained in:
Schwitzz
2026-10-26 19:00:07 -05:00
parent 301e842c5c
commit 45f5c505c9
28 changed files with 3488 additions and 52 deletions
+44
View File
@@ -7,6 +7,7 @@ import type {
Character,
Entity,
Note,
PlotBeat,
Project,
ProjectMeta,
User,
@@ -69,6 +70,21 @@ export interface BoardRow {
edges: string;
updated_at: string;
}
export interface PlotBeatRow {
id: string;
project_id: string;
title: string;
body: string;
act: number;
type: string;
beat_order: number;
status: string;
chapter_id: string | null;
entity_ids: string;
created_at: string;
updated_at: string;
}
export interface AISessionRow {
id: string;
project_id: string;
@@ -160,6 +176,34 @@ export function toBoard(r: BoardRow): Board {
};
}
export function toPlotBeat(r: PlotBeatRow): PlotBeat {
return {
id: r.id,
projectId: r.project_id,
title: r.title,
body: r.body,
act: r.act,
type: r.type as PlotBeat["type"],
order: r.beat_order,
status: (r.status as PlotBeat["status"]) ?? "draft",
chapterId: r.chapter_id ?? undefined,
entityIds: parseEntityIds(r.entity_ids),
createdAt: r.created_at,
updatedAt: r.updated_at,
};
}
/** Parse the JSON `entity_ids` column into a string[] (empty when blank). */
function parseEntityIds(raw: string): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === "string") : [];
} catch {
return [];
}
}
export function toAISession(r: AISessionRow): AISession {
return {
id: r.id,