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
+63
View File
@@ -12,6 +12,7 @@ import {
toBook,
toChapter,
toBoard,
toPlotBeat,
toAISession,
toEntity,
entityToRow,
@@ -23,6 +24,7 @@ import {
type ChapterRow,
type EntityRow,
type BoardRow,
type PlotBeatRow,
type AISessionRow,
} from "./mapping.js";
@@ -75,6 +77,17 @@ const boardPatch = z.object({
nodes: z.array(z.any()).optional(),
edges: z.array(z.any()).optional(),
});
const beatCreate = z.object({
id: z.string().optional(),
title: z.string().default(""),
body: z.string().default(""),
act: z.number().int().min(1).default(1),
type: z.enum(["setup", "conflict", "turn", "climax", "resolution", "scene", "note"]).default("note"),
order: z.number().int().min(0).default(0),
status: z.enum(["draft", "revised", "locked"]).default("draft"),
chapterId: z.string().optional(),
entityIds: z.array(z.string()).optional(),
});
const aiSessionCreate = z.object({
id: z.string().optional(),
role: z.string().min(1),
@@ -459,6 +472,56 @@ export function registerRoutes(app: FastifyInstance): void {
return { ok: true };
});
// ---------- Plot beats ----------
app.get("/api/projects/:id/beats", async (req) => {
const { id } = req.params as { id: string };
requireProjectAccess(app, req, id);
const rows = db
.prepare("SELECT * FROM plot_beats WHERE project_id = ? ORDER BY act, beat_order, id")
.all(id) as PlotBeatRow[];
return { beats: rows.map(toPlotBeat) };
});
app.post("/api/projects/:id/beats", async (req) => {
const { id } = req.params as { id: string };
requireProjectAccess(app, req, id, true);
const body = beatCreate.parse(req.body);
const bid = body.id ?? ulid();
const t = now();
db.prepare(`INSERT INTO plot_beats (id, project_id, title, body, act, type, beat_order, status, chapter_id, entity_ids, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(project_id, id) DO UPDATE SET
title = excluded.title, body = excluded.body, act = excluded.act, type = excluded.type,
beat_order = excluded.beat_order, status = excluded.status, chapter_id = excluded.chapter_id,
entity_ids = excluded.entity_ids, updated_at = excluded.updated_at`).run(
bid,
id,
body.title,
body.body,
body.act,
body.type,
body.order,
body.status ?? "draft",
body.chapterId ?? null,
JSON.stringify(body.entityIds ?? []),
t,
t,
);
const row = db.prepare("SELECT * FROM plot_beats WHERE id = ? AND project_id = ?").get(bid, id) as PlotBeatRow;
return { beat: toPlotBeat(row) };
});
app.delete("/api/projects/:id/beats/:beatId", async (req) => {
const { id, beatId } = req.params as { id: string; beatId: string };
requireProjectAccess(app, req, id, true);
const row = db.prepare("SELECT * FROM plot_beats WHERE id = ? AND project_id = ?").get(beatId, id) as
| PlotBeatRow
| undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" });
db.prepare("DELETE FROM plot_beats WHERE id = ? AND project_id = ?").run(beatId, id);
return { ok: true };
});
// ---------- AI sessions ----------
app.get("/api/projects/:id/ai/sessions", async (req) => {
const { id } = req.params as { id: string };