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,
+19
View File
@@ -0,0 +1,19 @@
-- 002_plot_beats.sql — plot beats for the Plot board and Timeline (CHUNK #5).
CREATE TABLE IF NOT EXISTS plot_beats (
id TEXT NOT NULL,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
act INTEGER NOT NULL DEFAULT 1,
type TEXT NOT NULL DEFAULT 'note',
beat_order INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'draft',
chapter_id TEXT,
entity_ids TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (project_id, id)
);
CREATE INDEX IF NOT EXISTS idx_plot_beats_project ON plot_beats(project_id);
CREATE INDEX IF NOT EXISTS idx_plot_beats_act ON plot_beats(project_id, act, beat_order);
+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 };
+54 -1
View File
@@ -77,6 +77,44 @@ function addBoard(db: any, projectId: string, board: Board): void {
).run(board.id, projectId, JSON.stringify(board.nodes), JSON.stringify(board.edges), t);
}
/** Upsert a single plot beat. */
function addBeat(
db: any,
projectId: string,
beat: {
id: string;
title: string;
body: string;
act: number;
type: string;
order: number;
chapterId?: string;
entityIds?: string[];
},
): void {
const t = new Date().toISOString();
db.prepare(
`INSERT INTO plot_beats (id, project_id, title, body, act, type, beat_order, 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, chapter_id = excluded.chapter_id, " +
"entity_ids = excluded.entity_ids, updated_at = excluded.updated_at",
).run(
beat.id,
projectId,
beat.title,
beat.body,
beat.act,
beat.type,
beat.order,
beat.chapterId ?? null,
JSON.stringify(beat.entityIds ?? []),
t,
t,
);
}
async function main(): Promise<void> {
const db = openDb();
@@ -262,8 +300,23 @@ async function main(): Promise<void> {
],
});
// Plot beats across three acts.
const c1 = "c1";
const c2 = "c2";
const c3 = "c3";
addBeat(db, projectId, { id: "b1", title: "Mara arrives", body: "The ferry carries Mara Vell to the Glass Harbor before dawn.", act: 1, type: "event", order: 0, chapterId: c1 });
addBeat(db, projectId, { id: "b2", title: "The failing light", body: "Three nights without the beam; the fish are turning away.", act: 1, type: "event", order: 1, chapterId: c1 });
addBeat(db, projectId, { id: "b3", title: "The old keeper's silence", body: "Tomas never waves. Mara senses a grief she is not yet allowed to understand.", act: 1, type: "foreshadow", order: 2, chapterId: c1 });
addBeat(db, projectId, { id: "b4", title: "The storm", body: "The storm arrives on the second night, without apology.", act: 2, type: "turn", order: 0, chapterId: c2 });
addBeat(db, projectId, { id: "b5", title: "Mara climbs the lamp", body: "One hundred and fourteen steps; she coaxes the seized gears back to life.", act: 2, type: "event", order: 1, chapterId: c2 });
addBeat(db, projectId, { id: "b6", title: "Fen's warning", body: "They died because they forgot that someone had to keep the light.", act: 2, type: "reveal", order: 2, chapterId: c2 });
addBeat(db, projectId, { id: "b7", title: "The glass in the dark", body: "A shard of living glass — the light is mined, not made.", act: 2, type: "reveal", order: 3, chapterId: c3 });
addBeat(db, projectId, { id: "b8", title: "The inherited duty", body: "The old keeper never waved, never left. Some duties are inherited, like the tide.", act: 3, type: "turn", order: 0, chapterId: c3 });
addBeat(db, projectId, { id: "b9", title: "Mara chooses the light", body: "I want it. She accepts the keepership Tomas is offering.", act: 3, type: "climax", order: 1, chapterId: c3 });
addBeat(db, projectId, { id: "b10", title: "The light answers", body: "The beam sweeps out across the black water. The harbor is kept.", act: 3, type: "resolution", order: 2, chapterId: c3 });
console.log(`seed: created demo project "${DEMO_TITLE}" (id ${projectId})`);
console.log(`seed: 1 book, 3 chapters, 4 characters, 1 world location, 1 board`);
console.log(`seed: 1 book, 3 chapters, 4 characters, 1 world location, 1 board, 10 plot beats`);
console.log(`seed: owner: ${owner.id}`);
console.log(`seed: login with PUBSCRIPT_ADMIN_EMAIL=${process.env.PUBSCRIPT_ADMIN_EMAIL ?? "admin@pubscript.local"}`);
db.close();