From 52ef1f6421e335439c11239d2cc0cde6fd9d9ac2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:21:44 +0000 Subject: [PATCH 01/35] Stop open tools writing their old copy over the Data panel With Seating open, import three guests from the Data panel: the panel says "3 new" and the header says 103. Rename a table and the next save writes Seating's own copy of the 100 back, and the couple's names revert with it. Timeline does the same to a date moved in the panel. Reproduced in unit tests and against a production build. Each tool reads the wedding once, on mount, and writes its whole copy back on every save. The generation guard already refused that copy after a whole document was swapped; nothing covered one slice being written from outside, which is what the Data panel does, over whichever tool is open. Each tool's gate now declares the slices it copies, each tool tags its own writes, and a write to a held slice from anywhere else starts a new generation: the tool remounts onto the current document and its stale save is refused. One rule in the store, so it covers every such writer. A panel edit now resets the open tool's undo history, as a restore already does. The e2e test fails with the rule disabled and passes with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KfDv5hZZ3uYeubXH64bFVH --- .../(tools)/delegation/BrigadeClient.tsx | 2 +- .../(tools)/place-cards/PlaqueClient.tsx | 2 +- .../(app)/(tools)/seating/TableauxClient.tsx | 2 +- .../(app)/(tools)/timeline/CadenceClient.tsx | 2 +- suite/apps/brigade/state/sliceBridge.ts | 2 +- suite/apps/cadence/state/sliceBridge.test.ts | 52 +++++++++++++++++++ suite/apps/cadence/state/sliceBridge.ts | 2 +- suite/apps/plaque/state/sliceBridge.ts | 2 +- suite/apps/tableaux/store/sliceBridge.test.ts | 43 +++++++++++++++ suite/apps/tableaux/store/sliceBridge.ts | 2 +- .../shell/WhenDocumentReady.test.tsx | 30 +++++++++++ suite/components/shell/WhenDocumentReady.tsx | 16 +++++- suite/e2e/persistence.spec.ts | 48 ++++++++++++++++- suite/e2e/wedding.ts | 39 ++++++++++++++ suite/lib/store/toolGeneration.test.ts | 46 +++++++++++++++- suite/lib/store/toolGeneration.ts | 24 +++++++++ suite/lib/store/useTrousseauStore.ts | 38 +++++++++++++- 17 files changed, 339 insertions(+), 13 deletions(-) create mode 100644 suite/apps/cadence/state/sliceBridge.test.ts create mode 100644 suite/components/shell/WhenDocumentReady.test.tsx diff --git a/suite/app/(app)/(tools)/delegation/BrigadeClient.tsx b/suite/app/(app)/(tools)/delegation/BrigadeClient.tsx index 372cd3f..1a33d51 100644 --- a/suite/app/(app)/(tools)/delegation/BrigadeClient.tsx +++ b/suite/app/(app)/(tools)/delegation/BrigadeClient.tsx @@ -22,7 +22,7 @@ const App = dynamic(() => import("@/apps/brigade/App").then((m) => m.App), { ssr export function BrigadeApp() { return (
- +
diff --git a/suite/app/(app)/(tools)/place-cards/PlaqueClient.tsx b/suite/app/(app)/(tools)/place-cards/PlaqueClient.tsx index d00e7c7..981a2f1 100644 --- a/suite/app/(app)/(tools)/place-cards/PlaqueClient.tsx +++ b/suite/app/(app)/(tools)/place-cards/PlaqueClient.tsx @@ -31,7 +31,7 @@ const App = dynamic(() => import("@/apps/plaque/App").then((m) => m.App), { ssr: export function PlaqueApp() { return (
- +
diff --git a/suite/app/(app)/(tools)/seating/TableauxClient.tsx b/suite/app/(app)/(tools)/seating/TableauxClient.tsx index 1304432..995beae 100644 --- a/suite/app/(app)/(tools)/seating/TableauxClient.tsx +++ b/suite/app/(app)/(tools)/seating/TableauxClient.tsx @@ -23,7 +23,7 @@ const App = dynamic(() => import("@/apps/tableaux/App"), { ssr: false }); export function TableauxApp() { return (
- +
diff --git a/suite/app/(app)/(tools)/timeline/CadenceClient.tsx b/suite/app/(app)/(tools)/timeline/CadenceClient.tsx index 013c84c..47a9604 100644 --- a/suite/app/(app)/(tools)/timeline/CadenceClient.tsx +++ b/suite/app/(app)/(tools)/timeline/CadenceClient.tsx @@ -25,7 +25,7 @@ const App = dynamic(() => import("@/apps/cadence/App").then((m) => m.App), { ssr export function CadenceApp() { return (
- +
diff --git a/suite/apps/brigade/state/sliceBridge.ts b/suite/apps/brigade/state/sliceBridge.ts index 25bf49e..4b571b1 100644 --- a/suite/apps/brigade/state/sliceBridge.ts +++ b/suite/apps/brigade/state/sliceBridge.ts @@ -76,6 +76,6 @@ export function writeSlice(doc: BrigadeDoc): void { .setSlice( "crew", { teams: doc.teams, people: doc.people, jobs: doc.jobs, budget: doc.budget }, - { label: "the crew", silent: true }, + { label: "the crew", silent: true, by: "brigade" }, ); } diff --git a/suite/apps/cadence/state/sliceBridge.test.ts b/suite/apps/cadence/state/sliceBridge.test.ts new file mode 100644 index 0000000..531d4c2 --- /dev/null +++ b/suite/apps/cadence/state/sliceBridge.test.ts @@ -0,0 +1,52 @@ +import { expect, test, vi } from "vitest"; + +vi.mock("idb-keyval", () => ({ + get: async () => undefined, + set: async () => undefined, + del: async () => undefined, +})); + +const { useTrousseauStore } = await import("@/lib/store/useTrousseauStore"); +const { HOLDS } = await import("@/lib/store/toolGeneration"); +const { emptyTrousseau } = await import("@jfrusher/trousseau"); +const { getDoc, useStore } = await import("./store"); +const { persist, restore } = await import("./persist"); + +/** + * A date moved in the Data panel while Timeline is open must outlive Timeline's + * next save. + * + * Timeline copies the wedding's date and names into its own day when it mounts, + * and mirrors them back into `event` on every write. The Data panel changes + * `event` underneath it, so the next edit to anything on the day — a venue, a + * block — put the old date back. + */ +test("a date set in the Data panel survives the next Timeline edit", () => { + const doc = emptyTrousseau(); + const raw = { ...doc, event: { ...doc.event, date: "2028-06-01", coupleNames: "Alex & Sam" } }; + useTrousseauStore.setState({ + status: "ready", + error: null, + generation: 0, + raw: raw as unknown as Record, + doc: raw, + past: [], + future: [], + }); + + // Timeline opens: the gate declares what it holds, the tool takes its copy. + useTrousseauStore.getState().hold("cadence", HOLDS.cadence); + useStore.getState().loadDoc(restore()); + + // The Data panel, over the top of it. + const shared = useTrousseauStore.getState(); + shared.setSlice("event", { ...shared.doc.event, date: "2029-01-01" }); + + // An unrelated Timeline edit, handed to the autosave. + useStore.getState().setDay({ venueName: "The Old Granary" }); + persist(getDoc(useStore.getState())); + + expect(useTrousseauStore.getState().doc.event.date).toBe("2029-01-01"); + + useTrousseauStore.getState().release("cadence"); +}); diff --git a/suite/apps/cadence/state/sliceBridge.ts b/suite/apps/cadence/state/sliceBridge.ts index af02b65..0d523b4 100644 --- a/suite/apps/cadence/state/sliceBridge.ts +++ b/suite/apps/cadence/state/sliceBridge.ts @@ -58,6 +58,6 @@ export function writeSlice(next: TimelineDoc): void { }, ], ], - { label: "the day", silent: true }, + { label: "the day", silent: true, by: "cadence" }, ); } diff --git a/suite/apps/plaque/state/sliceBridge.ts b/suite/apps/plaque/state/sliceBridge.ts index 000b3bc..9e27886 100644 --- a/suite/apps/plaque/state/sliceBridge.ts +++ b/suite/apps/plaque/state/sliceBridge.ts @@ -36,5 +36,5 @@ export function writeSlice(record: Persisted): void { if (!mayWrite("plaque")) return; useTrousseauStore .getState() - .setSlice("stationery", record, { label: "the stationery", silent: true }); + .setSlice("stationery", record, { label: "the stationery", silent: true, by: "plaque" }); } diff --git a/suite/apps/tableaux/store/sliceBridge.test.ts b/suite/apps/tableaux/store/sliceBridge.test.ts index 6992c47..44b3a1c 100644 --- a/suite/apps/tableaux/store/sliceBridge.test.ts +++ b/suite/apps/tableaux/store/sliceBridge.test.ts @@ -79,3 +79,46 @@ test("every other field on the guest survives untouched", () => { tags: ["usher"], }); }); + +/** + * What the Data panel writes while Seating is open must outlive Seating's next + * autosave. + * + * Seating copies the guests, the room and the wedding's names into its own + * store when it mounts, and writes that copy back after every edit. The Data + * panel sits over it in a dialog and writes the shared wedding directly, so + * Seating's copy never saw the import: the next table rename put the old guest + * list and the old names back, with "3 new" still on screen. + */ +test("a guest import and a rename made in the Data panel survive Seating's next save", async () => { + const { useStore } = await import("./useStore.js"); + const { saveNow } = await import("../hooks/useAutoSave.js"); + const { HOLDS } = await import("@/lib/store/toolGeneration"); + + withGuests({ g1: { id: "g1", firstName: "Ada", lastName: "Test" } }); + useTrousseauStore.getState().setSlice("event", { ...emptyTrousseau().event, coupleNames: "Old Names" }); + + // Seating opens: the gate declares what it holds, the tool takes its copy. + useTrousseauStore.getState().hold("tableaux", HOLDS.tableaux); + useStore.getState().hydrate(readDoc()); + + // The Data panel, over the top of it. + const shared = useTrousseauStore.getState(); + shared.setSlice("guests", { + ...shared.raw["guests"] as Record, + g2: { id: "g2", firstName: "Bea", lastName: "Test" }, + g3: { id: "g3", firstName: "Cy", lastName: "Test" }, + }); + const named = useTrousseauStore.getState(); + named.setSlice("event", { ...named.doc.event, coupleNames: "New Names" }); + + // Back in Seating, one ordinary edit and its autosave. + useStore.getState().addTable({ type: "round", x: 100, y: 100 }); + saveNow({ manual: false }); + + const after = useTrousseauStore.getState().doc; + expect(Object.keys(after.guests).sort()).toEqual(["g1", "g2", "g3"]); + expect(after.event.coupleNames).toBe("New Names"); + + useTrousseauStore.getState().release("tableaux"); +}); diff --git a/suite/apps/tableaux/store/sliceBridge.ts b/suite/apps/tableaux/store/sliceBridge.ts index c65ccb1..52edcec 100644 --- a/suite/apps/tableaux/store/sliceBridge.ts +++ b/suite/apps/tableaux/store/sliceBridge.ts @@ -189,6 +189,6 @@ export function writeDoc(doc: TableauxDoc): void { }, ], ], - { label: 'the room', silent: true }, + { label: 'the room', silent: true, by: 'tableaux' }, ) } diff --git a/suite/components/shell/WhenDocumentReady.test.tsx b/suite/components/shell/WhenDocumentReady.test.tsx new file mode 100644 index 0000000..cee62b6 --- /dev/null +++ b/suite/components/shell/WhenDocumentReady.test.tsx @@ -0,0 +1,30 @@ +import { cleanup, render } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; + +vi.mock("idb-keyval", () => ({ get: async () => undefined, set: async () => undefined })); + +const { useTrousseauStore } = await import("@/lib/store/useTrousseauStore"); +const { HOLDS } = await import("@/lib/store/toolGeneration"); +const { WhenDocumentReady } = await import("./WhenDocumentReady"); + +afterEach(() => { + // No global afterEach in the "suite" project, so Testing Library does not + // unmount by itself — see StoreHydrator.test.tsx. + cleanup(); +}); + +/** + * The gate is what tells the store a tool is open and what it copied, so a + * write from anywhere else can send that tool back to re-read. + */ +test("declares what the tool holds while it is open, and nothing once it closes", () => { + const { unmount } = render( + +

Seating

+
, + ); + expect(useTrousseauStore.getState().held["tableaux"]).toEqual(HOLDS.tableaux); + + unmount(); + expect(useTrousseauStore.getState().held["tableaux"]).toBeUndefined(); +}); diff --git a/suite/components/shell/WhenDocumentReady.tsx b/suite/components/shell/WhenDocumentReady.tsx index 7c266f2..0b135da 100644 --- a/suite/components/shell/WhenDocumentReady.tsx +++ b/suite/components/shell/WhenDocumentReady.tsx @@ -1,7 +1,8 @@ "use client"; -import { Fragment } from "react"; +import { Fragment, useEffect } from "react"; import { useTrousseauStore } from "@/lib/store/useTrousseauStore"; +import { HOLDS, type ToolId } from "@/lib/store/toolGeneration"; /** * Holds a tool back until the stored wedding has actually been read. @@ -27,12 +28,23 @@ import { useTrousseauStore } from "@/lib/store/useTrousseauStore"; * showing the previous wedding. Keying the children on the generation remounts * the tool, which sends it back down the path that already works rather than * teaching each of the four to re-read. + * + * The same remount handles one slice changing rather than all of them. While + * the tool is open the gate tells the store what it copied, so a write to any + * of that from outside — the Data panel, over the top of it — starts a new + * generation too, instead of being overwritten by the tool's next save. */ -export function WhenDocumentReady({ children }: { children: React.ReactNode }) { +export function WhenDocumentReady({ tool, children }: { tool: ToolId; children: React.ReactNode }) { const status = useTrousseauStore((s) => s.status); const error = useTrousseauStore((s) => s.error); const generation = useTrousseauStore((s) => s.generation); + useEffect(() => { + const { hold, release } = useTrousseauStore.getState(); + hold(tool, HOLDS[tool]); + return () => release(tool); + }, [tool]); + if (status === "error") { return (

diff --git a/suite/e2e/persistence.spec.ts b/suite/e2e/persistence.spec.ts index 5c42a4b..0b97b71 100644 --- a/suite/e2e/persistence.spec.ts +++ b/suite/e2e/persistence.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from "@playwright/test"; -import { openSeating, seedExampleWedding } from "./wedding"; +import { openSeating, seedExampleWedding, storedWedding } from "./wedding"; /** * An edit shown on screen is an edit that survives a reload. Seating once @@ -18,3 +18,49 @@ test("a Seating edit survives an immediate reload", async ({ page }) => { await page.reload(); await expect(page.getByRole("button", { name: /^Top table, / })).toBeVisible(); }); + +/** + * The Data panel opens over whichever tool is on screen and writes the shared + * wedding directly. Seating holds its own copy of the guests and the names, and + * its next save once wrote that copy back: an import reported as "3 new" was + * gone after one table rename, and the couple's names reverted with it. + */ +test("a guest import and a rename in the Data panel survive editing in Seating", async ({ page }) => { + await seedExampleWedding(page); + await openSeating(page); + const guests = page.getByTitle(/guests on this device$/); + await expect(guests).toHaveText("100"); + + await page.getByRole("button", { name: "Data" }).click(); + const data = page.getByRole("dialog", { name: "Your data" }); + await data.getByLabel("Names").fill("Robin & Kit"); + await data.locator('input[type="file"][accept^=".csv"]').setInputFiles({ + name: "three.csv", + mimeType: "text/csv", + buffer: Buffer.from("First Name,Last Name\nZelda,Newcomer\nYusuf,Newcomer\nXanthe,Newcomer\n"), + }); + await data.getByRole("button", { name: "Import 3 rows" }).click(); + await expect(data.getByText(/^3 new/)).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(guests).toHaveText("103"); + + // Seating shows what the panel wrote, and its own edits land on top of it. + await expect(page.getByText("103 guests · 103 unassigned")).toBeVisible(); + await page.getByRole("button", { name: /^Table 1, / }).click(); + await page.getByLabel("Table name").fill("Top table"); + await page.keyboard.press("Tab"); + await expect(page.getByRole("button", { name: /^Top table, / })).toBeVisible(); + + // All three stored together: the import, the names, and Seating's own edit. + // Waited for rather than reloaded into, so this is about what was written — + // an edit caught mid-save by a reload is the test above's business. + await expect + .poll(() => storedWedding(page)) + .toMatchObject({ guests: 103, names: "Robin & Kit", tables: expect.arrayContaining(["Top table"]) }); + + await page.reload(); + await expect(page.getByRole("button", { name: /^Top table, / })).toBeVisible(); + await expect(guests).toHaveText("103"); + await page.goto("/"); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Robin & Kit"); +}); diff --git a/suite/e2e/wedding.ts b/suite/e2e/wedding.ts index 8b85799..2722b03 100644 --- a/suite/e2e/wedding.ts +++ b/suite/e2e/wedding.ts @@ -33,3 +33,42 @@ export async function unassignedCount(page: Page): Promise { const text = await page.getByText(/\d+ guests · \d+ unassigned/).first().textContent(); return Number(/(\d+) unassigned/.exec(text ?? "")?.[1]); } + +interface StoredWedding { + guests: number; + tables: string[]; + names: string; +} + +/** + * The wedding as this browser has stored it — what a reload will read. + * + * For waiting until an edit has actually landed, rather than reloading into + * the moment the last one is still on its way to IndexedDB. + */ +export async function storedWedding(page: Page): Promise { + return page.evaluate( + () => + new Promise((resolve, reject) => { + const open = indexedDB.open("keyval-store"); + open.onerror = () => reject(open.error); + open.onsuccess = () => { + const read = open.result + .transaction("keyval") + .objectStore("keyval") + .get("trousseau.document"); + read.onerror = () => reject(read.error); + read.onsuccess = () => { + const wedding = read.result ?? {}; + resolve({ + guests: Object.keys(wedding.guests ?? {}).length, + tables: Object.values(wedding.seating?.tables ?? {}).map( + (table) => (table as { label: string }).label, + ), + names: wedding.event?.coupleNames ?? "", + }); + }; + }; + }), + ); +} diff --git a/suite/lib/store/toolGeneration.test.ts b/suite/lib/store/toolGeneration.test.ts index 05551ab..d65852a 100644 --- a/suite/lib/store/toolGeneration.test.ts +++ b/suite/lib/store/toolGeneration.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { beforeEach, describe, expect, it } from "vitest"; import { useTrousseauStore } from "./useTrousseauStore"; -import { mayWrite, noteRead } from "./toolGeneration"; +import { HOLDS, mayWrite, noteRead } from "./toolGeneration"; import { readDoc, writeDoc } from "@/apps/tableaux/store/sliceBridge"; /** @@ -65,3 +65,47 @@ describe("a tool holding a replaced wedding", () => { expect(guestsIn()).toBe(97); }); }); + +/** + * The same bug, arriving by a slice rather than a whole document: + * + * Open Seating, then import three guests from the Data panel. The panel says + * "3 new" and the header says 103 — and the next table rename in Seating + * writes its own copy of the 100 back over them. + */ +describe("a tool holding a slice somebody else writes", () => { + beforeEach(() => { + useTrousseauStore.getState().release("tableaux"); + }); + + it("is sent back to re-read when anyone else writes a slice it holds", () => { + useTrousseauStore.getState().hold("tableaux", HOLDS.tableaux); + noteRead("tableaux"); + useTrousseauStore.getState().setSlice("guests", { g1: { id: "g1" } }); + expect(mayWrite("tableaux")).toBe(false); + }); + + it("is not sent back by its own write", () => { + useTrousseauStore.getState().hold("tableaux", HOLDS.tableaux); + noteRead("tableaux"); + useTrousseauStore.getState().setSlice("guests", { g1: { id: "g1" } }, { by: "tableaux" }); + expect(mayWrite("tableaux")).toBe(true); + }); + + it("is not sent back by a write to a slice it does not hold", () => { + useTrousseauStore.getState().hold("tableaux", HOLDS.tableaux); + noteRead("tableaux"); + useTrousseauStore.getState().setSlice("shots", { sections: [] }); + expect(mayWrite("tableaux")).toBe(true); + }); + + it("stops holding anything once it has closed", () => { + // Seating visited earlier and since left must not remount whatever tool + // is on screen now every time the Data panel touches the guest list. + useTrousseauStore.getState().hold("tableaux", HOLDS.tableaux); + useTrousseauStore.getState().release("tableaux"); + const before = useTrousseauStore.getState().generation; + useTrousseauStore.getState().setSlice("guests", { g1: { id: "g1" } }); + expect(useTrousseauStore.getState().generation).toBe(before); + }); +}); diff --git a/suite/lib/store/toolGeneration.ts b/suite/lib/store/toolGeneration.ts index 2844101..93c616f 100644 --- a/suite/lib/store/toolGeneration.ts +++ b/suite/lib/store/toolGeneration.ts @@ -1,3 +1,4 @@ +import type { SliceName } from "@jfrusher/trousseau"; import { useTrousseauStore } from "./useTrousseauStore"; /** @@ -19,7 +20,30 @@ import { useTrousseauStore } from "./useTrousseauStore"; * So a tool records which generation it read, and its writes are refused if the * document has moved on. Refused rather than merged: what it is holding is not * a newer version of the wedding, it is an older one that has not noticed. + * + * A whole document is not the only thing that can move underneath a tool. The + * Data panel opens over whichever tool is on screen and writes the guest list + * and the wedding's names directly — and Seating's next save put its own copy + * of both straight back. So the store also starts a new generation when + * anything other than the tool itself writes a slice the tool holds. `HOLDS` + * is what each one holds. + */ + +/** + * The slices each tool copies into its own store when it mounts — everything it + * reads into that copy, whether or not it writes it all back. + * + * Delegation writes only the crew, but shows the day and guests' names from its + * copy, so a change to either would otherwise leave it showing the old ones. */ +export const HOLDS = { + tableaux: ["guests", "seating", "event"], + cadence: ["timeline", "event"], + plaque: ["stationery"], + brigade: ["crew", "day", "guests"], +} as const satisfies Record; + +export type ToolId = keyof typeof HOLDS; const readAt = new Map(); diff --git a/suite/lib/store/useTrousseauStore.ts b/suite/lib/store/useTrousseauStore.ts index eaa9c78..8ac6c29 100644 --- a/suite/lib/store/useTrousseauStore.ts +++ b/suite/lib/store/useTrousseauStore.ts @@ -20,6 +20,7 @@ import { import { fingerprintAllSlices, mergeCloudDocument, type SliceConflict } from "@/lib/documents/mergeCloudDocument"; import { fingerprint } from "@/lib/documents/fingerprint"; import { syncAssets } from "@/lib/documents/assets"; +import type { ToolId } from "./toolGeneration"; /** * The one store the whole suite reads. @@ -77,12 +78,18 @@ export interface WriteOptions { * not make — reconciling a restored document, republishing the resolved day. */ silent?: boolean; + /** + * The tool making this write, when it is one. Its own write is not news to + * the copy it holds; a write to that slice from anywhere else is — see `held`. + */ + by?: ToolId; } export interface TrousseauState { /** * Bumped whenever the whole document is swapped rather than edited — a - * restore from file, or a shared wedding opened from another machine. + * restore from file, or a shared wedding opened from another machine — and + * whenever a slice an open tool holds is written by something else. * * The tools each keep a store of their own, seeded once when they mount, so * replacing the document underneath a tool leaves it holding the previous @@ -92,6 +99,19 @@ export interface TrousseauState { */ generation: number; + /** + * The slices each open tool has copied into a store of its own. + * + * A write to one of them from anywhere but that tool starts a new + * `generation`, for the same reason a restore does: the tool's copy is now + * older than the document, and its next save would write the old one back. + * The Data panel is where that happened — it opens over the tool on screen. + */ + held: Partial>; + /** Called by `WhenDocumentReady` as a tool opens, and `release` as it closes. */ + hold: (tool: ToolId, slices: readonly SliceName[]) => void; + release: (tool: ToolId) => void; + status: StoreStatus; /** Set when the stored bytes could not be read. Writes are refused while it is. */ error: string | null; @@ -154,6 +174,14 @@ function freshDoc(): { raw: Record; doc: Trousseau } { export const useTrousseauStore = create()((set, get) => ({ generation: 0, + held: {}, + hold: (tool, slices) => set((state) => ({ held: { ...state.held, [tool]: slices } })), + release: (tool) => + set((state) => { + const held = { ...state.held }; + delete held[tool]; + return { held }; + }), status: "idle", error: null, savedAt: null, @@ -218,10 +246,18 @@ export const useTrousseauStore = create()((set, get) => ({ (acc, [slice, value]) => mergeSlice(acc, slice, value), state.raw, ); + // An open tool's copy of one of these slices is now out of date — unless + // that tool is the one writing. + const outdated = (Object.keys(state.held) as ToolId[]).some( + (tool) => + tool !== options.by && + entries.some(([slice]) => state.held[tool]?.includes(slice)), + ); set({ raw, doc: migrate(raw), + ...(outdated ? { generation: state.generation + 1 } : {}), ...(options.silent ? {} : { From 506442b8d5fd37660797affe987c98a6e266146b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:22:31 +0000 Subject: [PATCH 02/35] Write down the expansion: planners, setup, and the windows around the tools The maintainer's answers from the 2026-09-28 audit, recorded as decisions: a planner role alongside the two partners, sides named after the partners, a phone-first day-of binder, one importer, a live guest link on the account, and every tool converging on the live document. Also the design language new windows follow, how signing in on a device with its own wedding stays safe, and the phases in order. The roadmap gains subsystems J to O pointing at it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KfDv5hZZ3uYeubXH64bFVH --- docs/PRODUCT-ROADMAP.md | 25 ++ .../specs/2026-09-28-expansion-master-plan.md | 277 ++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-28-expansion-master-plan.md diff --git a/docs/PRODUCT-ROADMAP.md b/docs/PRODUCT-ROADMAP.md index 0af4620..e0f213d 100644 --- a/docs/PRODUCT-ROADMAP.md +++ b/docs/PRODUCT-ROADMAP.md @@ -46,6 +46,12 @@ instead (see subsystem F). | H | Guided tour & example wedding | — | ✅ **built** — [spec](superpowers/specs/2026-09-07-guided-tour-design.md), [plan](superpowers/plans/2026-09-07-guided-tour.md) complete 2026-09-07 | | I | Retention sweep for account weddings | B | ⬜ **not started** — the 24-month sweep covers synced weddings only; the privacy page deliberately does not claim it for accounts | | G | Multi-tenant suite mechanics | A, B | ✅ **built** — [spec](superpowers/specs/2026-09-02-multitenant-mechanics-design.md), [plan](superpowers/plans/2026-09-07-multitenant-mechanics.md) complete 2026-09-07 | +| J | Planner role and many weddings per account | A, G | ⬜ planned — [master plan](superpowers/specs/2026-09-28-expansion-master-plan.md), phases 1 and 3 | +| K | Setup flow and signing in safely | A, B | ⬜ planned — master plan, phase 1 | +| L | Design language and shared kit | — | ⬜ planned — master plan, phase 0 | +| M | Windows around the tools: Overview, Guests, Money, Checklist, Sync & history, palette | E, L | ⬜ planned — master plan, phase 2 | +| N | Day-of binder and vendor links | E, J | ⬜ planned — master plan, phase 3 | +| O | One live document: tools stop keeping copies; real-time sync | — | ⬜ planned — master plan, phase 4 | ## Decisions log @@ -118,6 +124,25 @@ against, not a discussion to reopen without a reason. - **2026-09-02** — Accounts-to-weddings: one active wedding per account for v1, no switcher UI. Additive-safe — can extend to multiple weddings per account later without redesigning the model. +- **2026-09-28** — A **planner role**, alongside the two partners. An account + may be a partner in one wedding and a planner in any number; a wedding has + at most one planner in v1. This is the "extend later" the entry above left + room for. Detail in the + [master plan](superpowers/specs/2026-09-28-expansion-master-plan.md). +- **2026-09-28** — Sides are named after the partners, not "bride" and + "groom". +- **2026-09-28** — Phones get a read-only day-of binder; the editing tools + stay desktop. +- **2026-09-28** — RSVPs stay with Joy and similar services; Trousseau imports + the result through one importer that never unseats or silently deletes. +- **2026-09-28** — The guest link moves onto the account with no passphrase, + and stays current by itself once published. `lib/sync` goes when it does. +- **2026-09-28** — Privacy promise restated: data may leave the device; nobody + reads a couple's plans or their guests' names, and guest data never goes to + a third party. +- **2026-09-28** — Every tool converges on the live document (no private + copies, one undo). Seating goes last and becomes TypeScript as part of it. + Supersedes the incremental-only Tableaux migration for its store layer. ## Subsystem H — Guided tour & example wedding diff --git a/docs/superpowers/specs/2026-09-28-expansion-master-plan.md b/docs/superpowers/specs/2026-09-28-expansion-master-plan.md new file mode 100644 index 0000000..61c42af --- /dev/null +++ b/docs/superpowers/specs/2026-09-28-expansion-master-plan.md @@ -0,0 +1,277 @@ +# Trousseau — the expansion: planners, setup, and the windows around the tools + +Date: 2026-09-28 +Status: direction approved by the maintainer (answers recorded below). Each +phase gets its own dated implementation plan before it is built. +Scope: the whole suite — the setup flow, accounts, a planner role, new views, +and the architecture those need underneath them. + +## Why + +An audit of the suite on 2026-09-28 found that the five tools are individually +strong and that the weak places are all *between* them: where a shared fact is +edited, where a device meets an account, where a new couple starts. The setup +flow runs straight through every one of those seams. + +The audit's findings are listed below with how each was established, because +several turned out differently once tested than they looked when read. + +- **Reproduced** — shown failing in a test or a real browser. +- **Traced** — every caller read; not run. +- **Seen** — visible in a screenshot of a production build. + +### Setup and accounts + +| # | Finding | How established | +|---|---|---| +| S1 | The Data panel's edits (guest import, names, date) were written back over by whichever of Seating or Timeline was open, on that tool's next save. | Reproduced — unit, and Playwright against a production build. **Fixed 2026-09-28**, see below. | +| S2 | A failed local save sets the store's `error`, but both components that read it render it only for a failed *read*. Nothing shows a failed save. | Traced | +| S3 | Creating a wedding or accepting an invite does not start cloud sync until a full reload: `startCloudSync` has one caller, on mount. | Traced | +| S4 | Accepting an invite silently replaces the invitee's local wedding on their next load. | Traced | +| S5 | A magic link opened in the wrong browser sends an invitee to `/account`, whose main button is **Create your wedding** — which then blocks the invite for good (`already-in-a-wedding`). | Traced | +| S6 | Loading the example wedding while signed in pushes it over the shared wedding; the confirmation says it replaces "the wedding in this browser". The emptiness check counts only guests and blocks. | Traced | +| S7 | Names, date and venue have three editors (Data panel, Timeline's Day panel, Seating's write-back). Guest import has two implementations with different rules. | Traced, seen | +| S8 | The guest link needs a second credential — an unrecoverable passphrase — even for a signed-in couple, and goes stale silently when seats change. | Traced, seen | +| S9 | "Take a tour" runs the six-step front-page chapter and stops; the other 23 steps are reachable only one tool at a time. | Traced | +| S10 | The example wedding has 0 of 100 guests seated, no crew, no jobs, no shots and no card design. The promise it exists to demonstrate cannot be shown from it. | Reproduced (fixture counted), seen | + +### Architecture + +- **Six state containers, five undo systems.** The shared store, four tool + stores each seeded once on mount, and Group shots reading live. +- **Every partner change remounts the open tool.** A pulled change swaps the + document, which resets the tool's undo history and selection even when the + partner touched an unrelated slice. Traced. +- **Undo differs per page.** Group shots uses the shared history, which also + holds the Data panel's import — so Undo there can take back an import made + elsewhere. Traced. +- **Conflicts are per whole slice and shown blind.** Two partners editing + different guests conflict on "guests"; the choice has no diff; the state is + visible only inside the Data dialog. The server keeps version history + (`wedding_document_history`) with no UI. Traced. +- **Three `Button`s, four modal patterns, two `window.confirm`s, three CSV + parsers**, and the legacy passphrase sync (`lib/sync`) kept alive only for the + guest link. + +### UX + +The front page has five competing starts and no primary action; the Data +dialog does four unrelated jobs with the most important one last; at the +declared minimum width Seating's Overview covers the canvas; three sidebar +idioms; empty states that claim success ("Every job has somebody" with no +jobs); "Bride's side / Groom's side" hard-coded in seven places; every tool +walled off below 1024px. The tour overlay declares `aria-modal` and never moves +focus. `/seat` — the one page guests use — is not in the axe run. + +## Decisions + +The maintainer's answers, 2026-09-28. Where the answer delegated the choice, +the choice made is recorded with its reason. + +| # | Question | Decision | +|---|---|---| +| 1 | Who uses it | The couple (two partners) **plus a wedding planner role**. Planners are a market in their own right: one account, many client weddings, and a library of their own designs to reuse. | +| 2 | Real usage today | None yet. Migrations may be bold; clarity beats compatibility. | +| 3 | Phones | A phone-first, read-only **day-of binder**. The editing tools stay desktop. | +| 4 | S1 | Fix now. Done — see *S1, fixed*. | +| 5 | Bride/groom | Replace with **sides named after the partners**. | +| 6 | Look | Keep it. | +| 7 | Window types | Delegated. See *Design language*. | +| 8 | Theme | Light only. | +| 9 | Device vs account weddings | Delegated: see *Signing in safely*. Nothing is replaced without a restorable copy. | +| 10 | Create the wedding automatically | Delegated: **yes**, on first sign-in — except when arriving through an invite. Removes S5's trap. | +| 11 | One importer | Delegated: **the suite's `lib/data/guestImport.ts`** is the engine; one dialog with a preview. Adds and updates, never unseats, never deletes silently — people missing from the new file are listed and can be removed there, explicitly. RSVPs arrive this way from Joy and similar, so their exports become golden fixtures. | +| 12 | Guest link | Delegated: **on the account, no passphrase, and live** — once published it republishes itself as seats change, with a visible "updated" time and a take-down. A stale seat link sends a guest to the wrong table, which is worse than no link. | +| 13 | Priorities | RSVP stays with Joy and friends (imported). Build money, checklist, vendor portal, day-of binder and real-time sync. | +| 14 | Privacy | Data may leave the device. The promise is **nobody is reading your plans or your friends' names**: no admin view, no content analytics, guest data never sent to a third party. Venue-level data (an address to find sunset times) may be, and only when the feature is used. | +| 15 | Tableaux | Delegated: **converge every tool on the live document** (see Phase 4). Seating goes last and becomes TypeScript as part of it. | + +## S1, fixed + +A tool reads the wedding once, into a store of its own, and writes that copy +back on every save. The generation guard in `lib/store/toolGeneration.ts` +already stopped a stale copy being written after a *whole* document was +swapped. It did not cover one slice being written from outside — which is +exactly what the Data panel does, over whichever tool is on screen. + +Now each tool's gate (`WhenDocumentReady`) declares what the tool copies +(`HOLDS`), each tool tags its own writes (`by`), and a write to a held slice from +anywhere else starts a new generation. The tool remounts onto the current +document and its stale save is refused. One rule in the store covers every +writer: the Data panel, the post-load reconcile, and the legacy sync client. + +Verified by unit tests on the rule, bridge-level tests for Seating and +Timeline, and an end-to-end test (`e2e/persistence.spec.ts`) that fails with +the rule disabled and passes with it. + +**Cost:** a Data-panel edit remounts the open tool, which resets its undo +history. Phase 4 removes the copies and with them the remount. + +## Design language + +The look stays. What is decided here is its grammar, so that every new window +is one of a small number of known kinds. + +**Paper on a desk.** Parchment is the page, stone is a panel, white is reserved +for what prints or stands for a card. **One display voice**: Marcellus for the +wedding's name and a page's single heading, Lato everywhere else, figures +tabular. **One accent per area**, and semantic colour — ok, warn, danger — +never changes hue between areas. **Quiet until it needs you**: the one loud +thing on a screen is the thing that needs a decision. + +### Kinds of window + +| Kind | Use it when | Examples | Rules | +|---|---|---|---| +| **Page** | Somewhere you work for minutes, or want to link to | The tools, Guests, Money, Checklist, Binder, a planner's Weddings, Setup | Its own route and heading. Reached from the header or the front page. | +| **Slide-over** | Look at or act on something without losing your place | Sync & history, Guest link, one guest from anywhere | Right edge, one width, the page behind inert, Escape closes, addressable by `?panel=`. | +| **Dialog** | A decision, or a short flow that must finish or be cancelled | Confirmations, import, delete, "two weddings" | Native `

`, focus kept inside, one primary action. Replaces every `window.confirm`. | +| **Popover** | Picking something small | Menus, pickers | Anchored; outside click dismisses. | +| **Notice** | State that belongs to a place | Errors, empty states, warnings | Inline, never a toast. | +| **Toast** | Confirming something just done | "Backup written", "Undo" | Never the only record of anything. | + +### Rules that follow + +- **Empty is not success.** A check over nothing says there is nothing yet, and + offers the one action that starts it. +- **One status pill** in the header: *Saved · Syncing · Offline · Needs you*. + "Needs you" opens Sync & history. This is where S2's failed save appears. +- **One kit.** `components/ui` gains `Dialog`, `SlideOver`, `Confirm`, + `EmptyState`, `Pill`, `Table`; the tools' own kits retire as they are touched. +- **Type floor.** Labels go from 11px to 12px across the scale, checked against + screenshots of all five tools before it lands. +- **Navigation.** The header keeps the five tools. The wedding's own pages — + Overview, Guests, Money, Checklist, Binder — sit under the wedding's name, + which for a planner is also the switcher between client weddings. To be + settled with a mock in the Phase 0 plan. + +## Signing in safely + +The rule: **nothing is replaced without a restorable copy, and nothing is +replaced silently when both sides have work in them.** + +- A wedding "has content" if any slice has anything in it — not only guests and + day blocks (fixes S6's check). +- Device empty → take the account's. Account empty → push the device's. Neither + loses anything, so neither asks. +- Both have content and the device last synced with *this* wedding → the + ordinary per-slice merge. +- Both have content and they are different weddings → **stop and ask**, with + what each holds ("This device: Alex & Sam, 100 guests. Your account: Robin & + Kit, 80 guests"). Use the account's, use this device's, or choose per part. + The losing side survives: the account's in its server history, the device's + in a local copy — both restorable from Sync & history. +- The same screen handles accepting an invite (S4) and loading the example + wedding over a synced one (S6), whose confirmation names who else it affects. +- Creating or joining a wedding starts sync immediately (S3). +- Signing out asks whether to keep this wedding on the device or remove it — + shared computers exist. + +## The planner role + +- **Membership gains a role**: `partner` (at most two per wedding, as now) or + `planner` (at most one per wedding in v1). An account may be a partner in one + wedding and a planner in any number. Caps stay in application code, as the + 2026-09-02 identity spec argued. +- **Either direction:** a couple invites their planner, or a planner creates a + wedding for a client and invites the couple. The couple always sees who has + access and can remove the planner; removal takes effect at once through RLS. +- **Weddings page** for planners: each client wedding with its date, what is + left (the same `readiness`, run over each document), money outstanding and + last activity. +- **Library:** a planner saves a card design, a running order, a room or a + checklist from one wedding and applies it to another. Saved without anything + personal — a design without its rows, a day without its date, a room without + its guests. Stored per planner account. +- **Local copies are kept per wedding**, keyed by id, so switching clients is a + document swap and never a merge. + +## Phases + +Each phase ends with the suite's own gate green — typecheck, every Vitest +project, the build, and the Playwright run — and gets its own plan first. + +### Phase 0 — Foundations + +1. **S1** — done. +2. **Status pill** and failed saves shown (S2); conflicts visible outside the + Data dialog. +3. **Design language in code:** the shared kit above; `TourOverlay` onto + `Dialog` so it traps focus; axe extended to `/seat`, `/invite` and open + dialogs. +4. **Sides named after the partners.** The stored values do not change; the + labels come from the couple, across filters, the inspector, import, exports + and group-shot roles. +5. **One editor per fact, one importer** (decision 11), with Joy, Zola and The + Knot exports as fixtures. + +### Phase 1 — Setup and accounts + +1. **Signing in safely** (above): S3, S4, S5, S6; automatic wedding creation. +2. **Roles and many weddings per account**, the switcher, per-wedding local + storage. +3. **Setup** (`/setup`): *the two of you* (names, date, venue) → *guests* + (import, paste, or later) → *the room* (a starting layout, or later) → + *together* (account, partner, planner). Staged as a draft and committed as + one change — one undo step, one push. The front page's primary action until + it is done. +4. **Guest link on the account**, live (decision 12). Then `lib/sync` and its + tables are deleted — nothing else uses them. +5. **Tour and example wedding:** "Take a tour" runs every chapter; the example + gains seats, crew, jobs, shots and a card design so every chapter has + something real to point at. + +### Phase 2 — Windows around the tools + +1. **Overview** — the front page as the wedding's state: progress per area and + one next step, replacing the tool grid that repeats the header. +2. **Guests** — the whole list as a sortable, filterable, bulk-editable table: + RSVP, side, dietary, table, plus-one, tags. Today the list exists only as a + column inside the Seating canvas. +3. **Money** — a view over what the crew slice already holds (cost, deposit, + paid-on, balance due, budget). Adds one field, `balancePaidOn`, because + today nothing records that a balance was paid. Due-soon balances join What + is left. +4. **Checklist** — a view over the jobs with no block, which the 2026-09-08 + design already made general tasks. Adds `dueOn`, and templates relative to + the wedding date. Overdue tasks join What is left. +5. **Sync & history** — what changed and who changed it, conflicts settled with + a real diff, and restore from the server's history. +6. **Command palette** — any guest, table, block, job or page by name. + +### Phase 3 — Beyond the couple + +1. **Planners:** Weddings page and library. +2. **Binder** (`/binder`): now and next against the clock, the run sheet, who to + ring, find a guest's table, the shot list to tick off. Works offline from the + last synced copy — venues have bad signal. +3. **Vendor links:** each supplier gets a link to their own call sheet and a + *Confirm* button that sets `confirmedOn`. The 2026-09-08 design deferred + this "for something a wedding has about eight of"; a planner has eight per + client, which is the reason it is worth building now. + +### Phase 4 — One live document + +Each tool stops keeping a copy: it reads its slices from the shared store and +writes through it, as Group shots already does. That retires, in one move, the +stale-copy class of bug S1 belonged to, the remount on every partner change, +the five undo systems (one history, labelled), and the 400ms window in which a +committed edit lives only in a tool's own store. Tool by tool, smallest first: +Delegation, Place cards, Timeline, then Seating — converted to TypeScript as +part of it. + +**Real-time sync** (Supabase Realtime in place of the 20-second poll, and +presence) lands after this, not before: an instant pull into a tool that still +remounts on every change would make that remount constant. + +Per-record merging — two partners editing different guests do not conflict — +lands with Sync & history, over keyed records (guests, tables, blocks, jobs). + +## Explicitly deferred + +- RSVP collection. Joy and similar do it; Trousseau imports the result. +- Agency teams (more than one planner on a wedding). +- A public page marketing Trousseau to planners. +- A binder link for day-of helpers without an account — it would carry phone + numbers, and deserves its own look at what a link may reveal. +- Editing tools on phones. From 917aa20de80f8b6c19cadf05dfdc8c68453d13c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:44:07 +0000 Subject: [PATCH 03/35] Say when a save fails, and put every dialog on the native one A save that failed looked exactly like one that worked. The store set its error "so it goes on screen", but both things that read it only did so for a wedding that could not be read. Shown with a browser refusing writes: no message anywhere. The two cases are now separate fields, and the Data button carries the state: a dot while all is well, and the problem in its own words ("Not saved", "Needs you", "Offline") when something needs the couple. On the button rather than beside it, because a separate pill pushed Timeline's Present button out of the header at 1440px. The Data panel's native becomes the one Dialog, and Confirm replaces the two window.confirm calls guarding account deletion and loading the example wedding. The account deletion copy said the wedding always goes with the account; it stays with a remaining partner, and now says so. The tour was a positioned div saying aria-modal and doing none of it: focus stayed on the page behind and its arrow keys listened on the whole window. It is a native modal dialog now. Axe also runs on /seat, /invite, the open Data panel and the open tour. Also records in the plan that the flaky immediate-reload test fails on untouched main too (2 in 80 runs), so it is not the S1 fix's. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KfDv5hZZ3uYeubXH64bFVH --- .../specs/2026-09-28-expansion-master-plan.md | 10 +++ suite/app/(app)/account/page.tsx | 12 ++- suite/app/(app)/layout.tsx | 40 ++++----- suite/components/shell/DataButton.tsx | 81 +++++++++++++++++++ suite/components/shell/DataManager.tsx | 40 +++------ suite/components/shell/Header.tsx | 26 ++---- suite/components/shell/TourButtons.tsx | 4 +- suite/components/tour/TourOverlay.tsx | 73 ++++++++++------- suite/components/ui/Confirm.tsx | 80 ++++++++++++++++++ suite/components/ui/Dialog.tsx | 55 +++++++++++++ suite/e2e/a11y.spec.ts | 69 ++++++++++++++++ suite/e2e/status.spec.ts | 30 +++++++ suite/lib/store/persistFailure.test.ts | 8 +- suite/lib/store/saveState.test.ts | 48 +++++++++++ suite/lib/store/saveState.ts | 76 +++++++++++++++++ suite/lib/store/useTrousseauStore.ts | 12 ++- suite/lib/tour/exampleWedding.test.ts | 13 +-- suite/lib/tour/exampleWedding.ts | 14 ++-- 18 files changed, 573 insertions(+), 118 deletions(-) create mode 100644 suite/components/shell/DataButton.tsx create mode 100644 suite/components/ui/Confirm.tsx create mode 100644 suite/components/ui/Dialog.tsx create mode 100644 suite/e2e/status.spec.ts create mode 100644 suite/lib/store/saveState.test.ts create mode 100644 suite/lib/store/saveState.ts diff --git a/docs/superpowers/specs/2026-09-28-expansion-master-plan.md b/docs/superpowers/specs/2026-09-28-expansion-master-plan.md index 61c42af..e376752 100644 --- a/docs/superpowers/specs/2026-09-28-expansion-master-plan.md +++ b/docs/superpowers/specs/2026-09-28-expansion-master-plan.md @@ -107,6 +107,16 @@ the rule disabled and passes with it. **Cost:** a Data-panel edit remounts the open tool, which resets its undo history. Phase 4 removes the copies and with them the remount. +**Found on the way, and not caused by it:** under heavy parallel load the +existing e2e test "a Seating edit survives an immediate reload" fails now and +then. An edit made less than 400ms before a reload is still inside Seating's +autosave delay, so it depends on a save issued as the page unloads — and the +page is sometimes gone before IndexedDB commits it. Measured on untouched +`main` at 2 in 80 runs and with this fix at 5 in 56, which is not a +significant difference; an instrumented failing run showed the new rule never +fired. Phase 4 removes the cause: an edit reaches the shared store, and so +IndexedDB, when it is made. + ## Design language The look stays. What is decided here is its grammar, so that every new window diff --git a/suite/app/(app)/account/page.tsx b/suite/app/(app)/account/page.tsx index b43d914..06573e2 100644 --- a/suite/app/(app)/account/page.tsx +++ b/suite/app/(app)/account/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { Download, LogOut, Trash2, UserPlus } from "lucide-react"; import { browserClient } from "@/lib/accounts/browserClient"; import { Button, TextField } from "@/components/ui/controls"; +import { useConfirm } from "@/components/ui/Confirm"; interface AccountState { signedIn: boolean; @@ -30,6 +31,7 @@ export default function AccountPage() { const [inviteEmail, setInviteEmail] = useState(""); const [notice, setNotice] = useState(null); const client = browserClient(); + const confirm = useConfirm(); const [signinFailed, setSigninFailed] = useState(false); useEffect(() => { setSigninFailed(new URLSearchParams(window.location.search).get("signin") === "failed"); @@ -94,9 +96,13 @@ export default function AccountPage() { } async function deleteAccount() { - if (!window.confirm("Delete your account? Your wedding data goes with it. This cannot be undone.")) { - return; - } + const confirmed = await confirm({ + title: "Delete your account?", + body: "If your partner is still on the wedding, it stays with them. If you are the last one on it, the wedding is deleted too. This cannot be undone.", + action: "Delete my account", + tone: "danger", + }); + if (!confirmed) return; const response = await fetch("/api/accounts/delete", { method: "POST" }); const body = await readJson<{ error?: string }>(response); if (!response.ok) { diff --git a/suite/app/(app)/layout.tsx b/suite/app/(app)/layout.tsx index 0b17b26..d631f5d 100644 --- a/suite/app/(app)/layout.tsx +++ b/suite/app/(app)/layout.tsx @@ -3,6 +3,7 @@ import { Header } from "@/components/shell/Header"; import { StoreHydrator } from "@/lib/store/StoreHydrator"; import { TourProvider } from "@/lib/tour/useTour"; import { TourOverlay } from "@/components/tour/TourOverlay"; +import { ConfirmProvider } from "@/components/ui/Confirm"; /** * The planning application: the header, the tools, and the local document. @@ -18,24 +19,27 @@ export default function AppLayout({ children }: { children: React.ReactNode }) { // to fill what is left of the viewport, so the two can never disagree.
- {/* Above the route content, so a chapter that walks from Seating to - Timeline keeps its place across the navigation. */} - - {/* Before the header, so it is the first thing Tab reaches. */} - - Skip to content - -
- {/* The page's one main landmark. The tools and pages inside render into - it rather than each bringing their own, which put two mains, or - none, on a page. */} -
{children}
-
- - + {/* One confirmation dialog for the whole app — see `components/ui/Confirm`. */} + + {/* Above the route content, so a chapter that walks from Seating to + Timeline keeps its place across the navigation. */} + + {/* Before the header, so it is the first thing Tab reaches. */} + + Skip to content + +
+ {/* The page's one main landmark. The tools and pages inside render into + it rather than each bringing their own, which put two mains, or + none, on a page. */} +
{children}
+
+ + +
); } diff --git a/suite/components/shell/DataButton.tsx b/suite/components/shell/DataButton.tsx new file mode 100644 index 0000000..dc4b2f7 --- /dev/null +++ b/suite/components/shell/DataButton.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { Database } from "lucide-react"; +import { useShallow } from "zustand/shallow"; +import { useTrousseauStore } from "@/lib/store/useTrousseauStore"; +import { saveState, type SaveTone } from "@/lib/store/saveState"; + +/** + * The Data button, which is also where the header says whether the wedding is + * saved. + * + * On the button rather than beside it: the Data panel is where a failed save, + * a conflict with a partner and a lost connection are each dealt with, and the + * header is one row that the tools fill with their own controls. A separate + * pill pushed Timeline's Present button off the end at 1440px. + * + * Quiet while all is well — a dot on the icon, and the tooltip. When something + * needs the couple, the button takes the warning's colour and its words: it + * reads "Not saved" instead of "Data". It used to turn red only for a wedding + * that could not be read, and looked the same after a failed save as after a + * good one. + */ + +const DOT: Record = { + ok: "bg-ok", + busy: "bg-slate animate-pulse", + warn: "bg-warn", + danger: "bg-danger", +}; + +const BUTTON = { + quiet: "border-charcoal/15 text-slate hover:border-gold hover:text-charcoal", + warn: "border-warn/50 bg-warn-soft text-charcoal hover:border-warn", + danger: "border-danger/50 bg-danger-soft text-charcoal hover:border-danger", +}; + +export function DataButton({ onOpen }: { onOpen: () => void }) { + const state = useTrousseauStore( + useShallow((s) => + saveState({ + status: s.status, + error: s.error, + saveError: s.saveError, + savedAt: s.savedAt, + cloudStatus: s.cloudStatus, + cloudError: s.cloudError, + }), + ), + ); + const needsYou = state.tone === "warn" || state.tone === "danger"; + + return ( + <> + + {/* Announced only when it needs someone. "Synced" after every keystroke + would be read aloud after every keystroke. */} + + {needsYou ? `${state.label}. ${state.detail}` : ""} + + + ); +} diff --git a/suite/components/shell/DataManager.tsx b/suite/components/shell/DataManager.tsx index ad932fe..d5c53cf 100644 --- a/suite/components/shell/DataManager.tsx +++ b/suite/components/shell/DataManager.tsx @@ -1,10 +1,11 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { CloudOff, Download, FileUp, RefreshCw, Upload, X } from "lucide-react"; import { migrate, serialise, suggestedFilename } from "@jfrusher/trousseau"; import { useTrousseauStore } from "@/lib/store/useTrousseauStore"; import { Button, Notice, Panel, TextField } from "@/components/ui/controls"; +import { Dialog } from "@/components/ui/Dialog"; import { readGuests } from "@/lib/model/slices"; import { useWriters } from "@/lib/model/useSuite"; import { reconcileLoadedDocument } from "@/lib/seating/normalise"; @@ -26,40 +27,17 @@ import { * and it writes the whole document rather than a slice. */ export function DataManager({ open, onClose }: { open: boolean; onClose: () => void }) { - const dialog = useRef(null); - - // A native modal dialog, because it is the one that behaves as a dialog: - // the page behind goes inert so Tab stays inside, Escape closes it, and - // focus goes back to the Data button afterwards. The hand-built overlay - // this replaced said `aria-modal` and did none of those things. - useEffect(() => { - const element = dialog.current; - if (!element) return; - if (open && !element.open) element.showModal(); - if (!open && element.open) element.close(); - }, [open]); - return ( - { - if (event.target === event.currentTarget) onClose(); - }} - className="mx-auto my-8 w-[calc(100%-2rem)] max-w-2xl rounded-lg border border-charcoal/10 bg-parchment text-slate shadow-2xl backdrop:bg-charcoal/40" - > - {open ? : null} - + + + ); } function Body({ onClose }: { onClose: () => void }) { const status = useTrousseauStore((s) => s.status); const error = useTrousseauStore((s) => s.error); + const saveError = useTrousseauStore((s) => s.saveError); const savedAt = useTrousseauStore((s) => s.savedAt); const replaceDocument = useTrousseauStore((s) => s.replaceDocument); const guestCount = useTrousseauStore((s) => Object.keys(s.doc.guests).length); @@ -165,6 +143,12 @@ function Body({ onClose }: { onClose: () => void }) { {error} Nothing has been written over it — export a backup below and restore a good copy.
) : null} + {saveError ? ( + + {saveError} What is on screen has not been stored on this device — export a backup + below before closing this page. + + ) : null} {problem ? {problem} : null} {notice ? {notice} : null} diff --git a/suite/components/shell/Header.tsx b/suite/components/shell/Header.tsx index 39dcd23..163f7b2 100644 --- a/suite/components/shell/Header.tsx +++ b/suite/components/shell/Header.tsx @@ -4,12 +4,13 @@ import { useState } from "react"; import dynamic from "next/dynamic"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { Database, Users } from "lucide-react"; +import { Users } from "lucide-react"; import { useTrousseauStore } from "@/lib/store/useTrousseauStore"; import { TOOLS } from "@/lib/tools"; import { AccountStatus } from "./AccountStatus"; import { HowThisWorks } from "./TourButtons"; import { ChromeSlot } from "./chrome"; +import { DataButton } from "./DataButton"; const DataManager = dynamic(() => import("./DataManager").then((m) => m.DataManager), { ssr: false, @@ -32,7 +33,10 @@ export function Header() { const [dataEverOpened, setDataEverOpened] = useState(false); const pathname = usePathname(); const guestCount = useTrousseauStore((s) => Object.keys(s.doc.guests).length); - const dirty = useTrousseauStore((s) => s.status === "error"); + const openData = () => { + setDataEverOpened(true); + setDataOpen(true); + }; return ( <> @@ -89,23 +93,7 @@ export function Header() { {guestCount} - + diff --git a/suite/components/shell/TourButtons.tsx b/suite/components/shell/TourButtons.tsx index 6786e99..3da3ba9 100644 --- a/suite/components/shell/TourButtons.tsx +++ b/suite/components/shell/TourButtons.tsx @@ -6,6 +6,7 @@ import { Compass, HelpCircle } from "lucide-react"; import { chapterForRoute } from "@/lib/tour/steps"; import { useTour } from "@/lib/tour/useTour"; import { isWeddingEmpty, loadExampleWedding } from "@/lib/tour/exampleWedding"; +import { useConfirm } from "@/components/ui/Confirm"; /** * The two ways in. @@ -17,12 +18,13 @@ import { isWeddingEmpty, loadExampleWedding } from "@/lib/tour/exampleWedding"; /** The front page: start at the beginning, optionally on the example wedding. */ export function TakeTheTour() { const { start, hasSeenTour } = useTour(); + const confirm = useConfirm(); const [busy, setBusy] = useState(false); async function begin(withExample: boolean) { setBusy(true); try { - if (withExample && (await loadExampleWedding()) === "cancelled") return; + if (withExample && (await loadExampleWedding(confirm)) === "cancelled") return; start("shell"); } finally { setBusy(false); diff --git a/suite/components/tour/TourOverlay.tsx b/suite/components/tour/TourOverlay.tsx index 886c5f3..af271be 100644 --- a/suite/components/tour/TourOverlay.tsx +++ b/suite/components/tour/TourOverlay.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; -import { AnimatePresence, motion } from "framer-motion"; +import { useEffect, useRef, useState } from "react"; +import { motion } from "framer-motion"; import { X } from "lucide-react"; import { useTour } from "@/lib/tour/useTour"; @@ -15,6 +15,12 @@ import { useTour } from "@/lib/tour/useTour"; * A missing anchor is deliberately not an error: the card shows centred with * no ring. A control that moved should cost a slightly worse explanation, not * a broken page. `steps.test.ts` is what makes sure that never ships silently. + * + * A native modal `` covering the viewport, rather than a positioned + * div that said `aria-modal` and did nothing it promised: focus now moves into + * the card and stays there, the page behind is inert, Escape leaves, and the + * arrow keys are heard only while the tour has focus instead of by every text + * field on the page. */ interface Box { @@ -29,6 +35,21 @@ const PADDING = 6; export function TourOverlay() { const { step, chapterTitle, index, total, next, back, stop } = useTour(); const [box, setBox] = useState(null); + const dialog = useRef(null); + const nextButton = useRef(null); + + const open = step !== null; + useEffect(() => { + const element = dialog.current; + if (!element) return; + if (open && !element.open) { + element.showModal(); + // The way forward, rather than the close button that happens to come + // first: someone who asked for a tour is most likely to want the next step. + nextButton.current?.focus(); + } + if (!open && element.open) element.close(); + }, [open]); useEffect(() => { if (!step) { @@ -71,30 +92,25 @@ export function TourOverlay() { }; }, [step]); - useEffect(() => { - if (!step) return; - const onKey = (event: KeyboardEvent) => { - if (event.key === "Escape") stop(); - if (event.key === "ArrowRight") next(); - if (event.key === "ArrowLeft") back(); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [back, next, step, stop]); - return ( - + { + if (event.key === "ArrowRight") next(); + if (event.key === "ArrowLeft") back(); + }} + // The dim around the card is the dialog itself: clicking it leaves the + // tour, as the Data panel's backdrop does. + onClick={(event) => { + if (event.target === event.currentTarget) stop(); + }} + className="m-0 h-dvh max-h-none w-dvw max-w-none bg-transparent p-0 backdrop:bg-charcoal/40" + > {step ? ( - - {/* The scrim. Clicking it leaves the tour, as the Data panel does. */} -
- + <> {box ? (
@@ -144,6 +156,7 @@ export function TourOverlay() { Back
- + ) : null} - +
); } diff --git a/suite/components/ui/Confirm.tsx b/suite/components/ui/Confirm.tsx new file mode 100644 index 0000000..4234a8e --- /dev/null +++ b/suite/components/ui/Confirm.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from "react"; +import { Button } from "./controls"; +import { Dialog } from "./Dialog"; + +/** + * "Are you sure?", as a dialog that looks like the rest of the app. + * + * `window.confirm` was guarding the two most destructive things here — deleting + * an account and replacing a wedding with the example — in the browser's own + * grey box, with "OK" as the button that destroys things. This asks with the + * action named on the button, and Cancel is where focus lands. + * + * Awaitable, so a caller reads as it did before: `if (!(await confirm(…))) return`. + */ + +export interface ConfirmRequest { + title: string; + body: ReactNode; + /** The button that goes ahead, named for what it does: "Delete my account". */ + action: string; + tone?: "primary" | "danger"; +} + +export type Confirm = (request: ConfirmRequest) => Promise; + +const ConfirmContext = createContext(null); + +export function ConfirmProvider({ children }: { children: ReactNode }) { + const [request, setRequest] = useState(null); + // A ref, so the answer is given exactly once however the dialog is closed: + // the button, Escape, the backdrop, and the close event that follows each. + const answer = useRef<((yes: boolean) => void) | null>(null); + + const confirm = useCallback( + (next) => + new Promise((resolve) => { + answer.current?.(false); + answer.current = resolve; + setRequest(next); + }), + [], + ); + + const settle = (yes: boolean) => { + answer.current?.(yes); + answer.current = null; + setRequest(null); + }; + + return ( + + {children} + settle(false)} labelledBy="confirm-title" width="max-w-md"> + {request ? ( +
+

+ {request.title} +

+
{request.body}
+
+ + +
+
+ ) : null} +
+
+ ); +} + +/** Throws outside the provider, which is a wiring mistake rather than a user-facing one. */ +export function useConfirm(): Confirm { + const confirm = useContext(ConfirmContext); + if (!confirm) throw new Error("useConfirm must be used inside "); + return confirm; +} diff --git a/suite/components/ui/Dialog.tsx b/suite/components/ui/Dialog.tsx new file mode 100644 index 0000000..909458b --- /dev/null +++ b/suite/components/ui/Dialog.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useEffect, useRef, type ReactNode } from "react"; + +/** + * A decision, or a short flow that must be finished or cancelled. + * + * A native modal ``, because it is the one that behaves as a dialog + * without being taught to: the page behind goes inert so Tab stays inside, + * Escape closes it, and focus returns to whatever opened it. The hand-built + * overlays this replaces said `aria-modal` and did none of those things. + * + * The body is rendered only while open, so a dialog's own state starts fresh + * each time and nothing inside it runs while it is closed. + */ +export function Dialog({ + open, + onClose, + labelledBy, + width = "max-w-2xl", + children, +}: { + open: boolean; + onClose: () => void; + /** The id of the heading inside that names the dialog. */ + labelledBy: string; + width?: string; + children: ReactNode; +}) { + const dialog = useRef(null); + + useEffect(() => { + const element = dialog.current; + if (!element) return; + if (open && !element.open) element.showModal(); + if (!open && element.open) element.close(); + }, [open]); + + return ( + { + if (event.target === event.currentTarget) onClose(); + }} + className={`mx-auto my-8 w-[calc(100%-2rem)] ${width} rounded-lg border border-charcoal/10 bg-parchment text-slate shadow-2xl backdrop:bg-charcoal/40`} + > + {open ? children : null} + + ); +} diff --git a/suite/e2e/a11y.spec.ts b/suite/e2e/a11y.spec.ts index bc8344c..413f87f 100644 --- a/suite/e2e/a11y.spec.ts +++ b/suite/e2e/a11y.spec.ts @@ -31,3 +31,72 @@ test("the skip link is the first stop and lands on the page's main content", asy await page.keyboard.press("Enter"); await expect(page).toHaveURL(/#main$/); }); + +/** + * The pages nobody planning a wedding opens, and the surfaces that only exist + * once something is open. `/seat` is the one page whose users did not choose + * this software; a link to it that no longer works is still a page they see. + */ +async function noViolations(page: import("@playwright/test").Page) { + const { violations } = await new AxeBuilder({ page }).analyze(); + expect(violations.map((v) => `${v.id} (${v.impact}): ${v.nodes.map((n) => n.target.join(" ")).join(" | ")}`)).toEqual([]); +} + +test("a guest link that does not work has no accessibility violations", async ({ page }) => { + await page.goto("/seat/not-a-real-token#k=nothing"); + await page.waitForLoadState("networkidle"); + await noViolations(page); +}); + +test("an invite page has no accessibility violations", async ({ page }) => { + await page.goto("/invite/not-a-real-token"); + await page.waitForLoadState("networkidle"); + await noViolations(page); +}); + +test("the Data panel, open, has no accessibility violations", async ({ page }) => { + await seedExampleWedding(page); + await page.goto("/"); + await page.getByRole("button", { name: "Data" }).click(); + await expect(page.getByRole("dialog", { name: "Your data" })).toBeVisible(); + await noViolations(page); +}); + +test("the tour, open, has no accessibility violations", async ({ page }) => { + await seedExampleWedding(page); + await page.goto("/"); + await page.getByRole("button", { name: /Take (a|the) tour/ }).click(); + await expect(page.getByRole("dialog", { name: /Your wedding/ })).toBeVisible(); + await noViolations(page); +}); + +test("the tour takes focus, keeps it, and gives it back", async ({ page }) => { + await page.goto("/"); + const start = page.getByRole("button", { name: /Take (a|the) tour/ }); + await start.click(); + + const tour = page.getByRole("dialog", { name: /Your wedding/ }); + await expect(tour.getByRole("button", { name: "Next" })).toBeFocused(); + + // Tab never reaches the page behind the tour. (Past the last control it may + // step out to the browser's own toolbar, which is the platform's business.) + for (let i = 0; i < 5; i++) { + await page.keyboard.press("Tab"); + const behind = await page.evaluate(() => { + const active = document.activeElement; + return Boolean( + active && + (document.querySelector("header")?.contains(active) || + document.getElementById("main")?.contains(active)), + ); + }); + expect(behind).toBe(false); + } + + await page.keyboard.press("ArrowRight"); + await expect(page.getByRole("dialog", { name: /Where things stand/ })).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(start).toBeFocused(); +}); diff --git a/suite/e2e/status.spec.ts b/suite/e2e/status.spec.ts new file mode 100644 index 0000000..41f2eee --- /dev/null +++ b/suite/e2e/status.spec.ts @@ -0,0 +1,30 @@ +import { expect, test } from "@playwright/test"; +import { seedExampleWedding } from "./wedding"; + +/** + * A save that failed has to be said out loud. + * + * The store recorded it — `persist` sets an error "so it goes on screen" — but + * the only two things that read that error show it for a wedding that could + * not be *read*, never for one that could not be written. A full disk or a + * browser refusing storage mid-session left the header, the Data panel and the + * tools all looking exactly as they do after a good save. + */ +test("a change that could not be saved is shown as not saved", async ({ page }) => { + await seedExampleWedding(page); + await page.goto("/"); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Alex & Sam"); + + // From here on the browser refuses every write, as a full disk does. + await page.evaluate(() => { + IDBObjectStore.prototype.put = () => { + throw new DOMException("The quota has been exceeded.", "QuotaExceededError"); + }; + }); + + await page.getByRole("button", { name: "Data" }).click(); + await page.getByRole("dialog", { name: "Your data" }).getByLabel("Names").fill("Robin & Kit"); + await page.keyboard.press("Escape"); + + await expect(page.getByRole("button", { name: /^Not saved/ })).toBeVisible(); +}); diff --git a/suite/lib/store/persistFailure.test.ts b/suite/lib/store/persistFailure.test.ts index 19dc4e1..7965a89 100644 --- a/suite/lib/store/persistFailure.test.ts +++ b/suite/lib/store/persistFailure.test.ts @@ -28,6 +28,7 @@ beforeEach(() => { useTrousseauStore.setState({ status: "ready", error: null, + saveError: null, savedAt: null, raw: doc as unknown as Record, doc, @@ -45,8 +46,11 @@ test("a browser that refuses IndexedDB is reported, not thrown past", () => { // were not guarded. expect(() => vi.advanceTimersByTime(1000)).not.toThrow(); - const { error, savedAt } = useTrousseauStore.getState(); - expect(error).toContain(refused.message); + const { saveError, error, savedAt } = useTrousseauStore.getState(); + expect(saveError).toContain(refused.message); + // A failed write is not an unreadable wedding: writes are still accepted, + // so the next one can land and clear this. + expect(error).toBeNull(); // The point of the message: the user must not be told this was saved. expect(savedAt).toBeNull(); }); diff --git a/suite/lib/store/saveState.test.ts b/suite/lib/store/saveState.test.ts new file mode 100644 index 0000000..29bd8c7 --- /dev/null +++ b/suite/lib/store/saveState.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { saveState } from "./saveState"; + +const settled = { + status: "ready" as const, + error: null, + saveError: null, + savedAt: null, + cloudStatus: "disabled" as const, + cloudError: null, +}; + +describe("what the header says about saving", () => { + it("says saved when there is nothing wrong and no account", () => { + expect(saveState(settled)).toMatchObject({ label: "Saved", tone: "ok" }); + }); + + it("says synced when the account has it too", () => { + expect(saveState({ ...settled, cloudStatus: "idle" })).toMatchObject({ label: "Synced", tone: "ok" }); + }); + + it("says a failed save out loud, with the reason", () => { + const state = saveState({ ...settled, saveError: "The wedding could not be saved: quota exceeded" }); + expect(state).toMatchObject({ label: "Not saved", tone: "danger" }); + expect(state.detail).toContain("quota exceeded"); + }); + + it("puts a failed save ahead of anything the cloud is doing", () => { + // The cloud can only have what this device stored; if the device did not + // store it, "Synced" would be a claim about an edit nobody has. + const state = saveState({ ...settled, saveError: "full", cloudStatus: "idle" }); + expect(state.label).toBe("Not saved"); + }); + + it("puts an unreadable wedding ahead of everything", () => { + const state = saveState({ ...settled, status: "error", error: "Bad bytes.", saveError: "full" }); + expect(state).toMatchObject({ label: "Can't open", tone: "danger" }); + }); + + it("asks for a choice when the cloud has a conflict", () => { + expect(saveState({ ...settled, cloudStatus: "conflict" })).toMatchObject({ label: "Needs you", tone: "warn" }); + }); + + it("tells offline apart from a cloud that answered with a failure", () => { + expect(saveState({ ...settled, cloudStatus: "queued" }).label).toBe("Offline"); + expect(saveState({ ...settled, cloudStatus: "error" }).label).toBe("Not synced"); + }); +}); diff --git a/suite/lib/store/saveState.ts b/suite/lib/store/saveState.ts new file mode 100644 index 0000000..c41964b --- /dev/null +++ b/suite/lib/store/saveState.ts @@ -0,0 +1,76 @@ +import type { TrousseauState } from "./useTrousseauStore"; + +/** + * What the header says about where the wedding is kept, in one word. + * + * Every state the store can be in reduces to one of these, in order of what + * matters most: a wedding that could not be read, then an edit that could not + * be stored, then anything that needs the couple to choose, then the cloud's + * own weather. Only the first match is shown — two warnings side by side in a + * header is one too many to read. + */ + +export type SaveTone = "ok" | "busy" | "warn" | "danger"; + +export interface SaveState { + /** One or two words, for the pill. */ + label: string; + tone: SaveTone; + /** A sentence, for the tooltip and for screen readers. */ + detail: string; +} + +type Inputs = Pick< + TrousseauState, + "status" | "error" | "saveError" | "savedAt" | "cloudStatus" | "cloudError" +>; + +export function saveState(s: Inputs): SaveState { + if (s.status === "error") { + return { + label: "Can't open", + tone: "danger", + detail: `${s.error ?? "The saved wedding could not be read."} Nothing has been written over it.`, + }; + } + if (s.saveError) { + return { + label: "Not saved", + tone: "danger", + detail: `${s.saveError} Your latest changes are on screen but not stored — export a backup from Data.`, + }; + } + + switch (s.cloudStatus) { + case "conflict": + return { + label: "Needs you", + tone: "warn", + detail: "You and someone else changed the same part of the wedding. Choose which to keep.", + }; + case "error": + return { + label: "Not synced", + tone: "warn", + detail: `${s.cloudError ?? "The cloud could not be reached."} Your changes are saved on this device.`, + }; + case "queued": + return { + label: "Offline", + tone: "warn", + detail: "Saved on this device. It syncs when you are back online.", + }; + case "syncing": + return { label: "Syncing", tone: "busy", detail: "Saved on this device, and syncing to your account." }; + case "idle": + return { label: "Synced", tone: "ok", detail: "Saved on this device and to your account." }; + case "disabled": + return { + label: "Saved", + tone: "ok", + detail: s.savedAt + ? `Saved on this device at ${new Date(s.savedAt).toLocaleTimeString()}.` + : "Saved on this device.", + }; + } +} diff --git a/suite/lib/store/useTrousseauStore.ts b/suite/lib/store/useTrousseauStore.ts index 8ac6c29..4e9c776 100644 --- a/suite/lib/store/useTrousseauStore.ts +++ b/suite/lib/store/useTrousseauStore.ts @@ -115,6 +115,13 @@ export interface TrousseauState { status: StoreStatus; /** Set when the stored bytes could not be read. Writes are refused while it is. */ error: string | null; + /** + * Set when the last write to this device failed — a full disk, a browser + * that stopped allowing storage — and cleared by the next one that lands. + * Kept apart from `error`: that one means the wedding could not be read and + * every write is refused; this one means the edit on screen is not stored. + */ + saveError: string | null; /** ISO time of the last successful write. Drives the "saved 13:42" notice. */ savedAt: string | null; /** The stored document, exactly as stored. Never the parsed one. */ @@ -184,6 +191,7 @@ export const useTrousseauStore = create()((set, get) => ({ }), status: "idle", error: null, + saveError: null, savedAt: null, ...freshDoc(), @@ -551,12 +559,12 @@ function persist(raw: Record): void { const noted = (cause: unknown) => // A save the user believes happened and did not is the worst outcome // here, so it goes on screen rather than into the console. - useTrousseauStore.setState({ error: `The wedding could not be saved: ${message(cause)}` }); + useTrousseauStore.setState({ saveError: `The wedding could not be saved: ${message(cause)}` }); try { // `idbSet` opens the database synchronously, so a browser that refuses // one throws here rather than rejecting. void idbSet(STORAGE_KEY, raw).then(() => { - useTrousseauStore.setState({ savedAt: new Date().toISOString(), error: null }); + useTrousseauStore.setState({ savedAt: new Date().toISOString(), saveError: null }); // Only after the local write has landed. Local storage is the record // of what the user has if the cloud is unreachable, so it goes first. scheduleCloudPush(); diff --git a/suite/lib/tour/exampleWedding.test.ts b/suite/lib/tour/exampleWedding.test.ts index ecb09b5..a2d0201 100644 --- a/suite/lib/tour/exampleWedding.test.ts +++ b/suite/lib/tour/exampleWedding.test.ts @@ -33,11 +33,10 @@ afterEach(() => { }); test("an untouched wedding is empty, and loads without asking anything", async () => { - const confirmed = vi.fn(() => false); - vi.stubGlobal("confirm", confirmed); + const confirmed = vi.fn(async () => false); expect(isWeddingEmpty()).toBe(true); - await expect(loadExampleWedding()).resolves.toBe("loaded"); + await expect(loadExampleWedding(confirmed)).resolves.toBe("loaded"); // Nothing to lose, so nothing to ask about. expect(confirmed).not.toHaveBeenCalled(); }); @@ -45,10 +44,8 @@ test("an untouched wedding is empty, and loads without asking anything", async ( test("a wedding with guests in it is never replaced without a yes", async () => { const doc = { ...emptyTrousseau(), guests: { a: { id: "a" } } }; useTrousseauStore.setState({ raw: doc as unknown as Record, doc }); - vi.stubGlobal("confirm", vi.fn(() => false)); - expect(isWeddingEmpty()).toBe(false); - await expect(loadExampleWedding()).resolves.toBe("cancelled"); + await expect(loadExampleWedding(async () => false)).resolves.toBe("cancelled"); // The refusal has to leave the document exactly as it was. expect(Object.keys(useTrousseauStore.getState().doc.guests)).toEqual(["a"]); }); @@ -56,9 +53,7 @@ test("a wedding with guests in it is never replaced without a yes", async () => test("saying yes replaces it, without becoming an undo step", async () => { const doc = { ...emptyTrousseau(), guests: { a: { id: "a" } } }; useTrousseauStore.setState({ raw: doc as unknown as Record, doc, past: [] }); - vi.stubGlobal("confirm", vi.fn(() => true)); - - await expect(loadExampleWedding()).resolves.toBe("loaded"); + await expect(loadExampleWedding(async () => true)).resolves.toBe("loaded"); expect(useTrousseauStore.getState().doc.event.coupleNames).toBe("Alex & Sam"); // Silent: offering to undo would offer to restore what the user was just // warned they were replacing. diff --git a/suite/lib/tour/exampleWedding.ts b/suite/lib/tour/exampleWedding.ts index 19ebad0..67ae32e 100644 --- a/suite/lib/tour/exampleWedding.ts +++ b/suite/lib/tour/exampleWedding.ts @@ -1,5 +1,6 @@ "use client"; +import type { Confirm } from "@/components/ui/Confirm"; import { useTrousseauStore } from "@/lib/store/useTrousseauStore"; /** @@ -16,16 +17,17 @@ export function isWeddingEmpty(): boolean { return Object.keys(doc.guests).length === 0 && (doc.day?.blocks.length ?? 0) === 0; } -export async function loadExampleWedding(): Promise<"loaded" | "cancelled"> { +export async function loadExampleWedding(confirm: Confirm): Promise<"loaded" | "cancelled"> { if (!isWeddingEmpty()) { const { doc } = useTrousseauStore.getState(); const guests = Object.keys(doc.guests).length; const blocks = doc.day?.blocks.length ?? 0; - const confirmed = window.confirm( - `This replaces the wedding in this browser — ${guests} guests and ${blocks} blocks of the day — with the example one.\n\n` + - `Export a backup first from the Data button if you want to keep it. This cannot be undone.\n\n` + - `Load the example wedding?`, - ); + const confirmed = await confirm({ + title: "Replace this wedding with the example?", + body: `This replaces the wedding in this browser — ${guests} guests and ${blocks} blocks of the day — with the example one. Export a backup first from Data if you want to keep it.`, + action: "Replace it", + tone: "danger", + }); if (!confirmed) return "cancelled"; } From bc14c32c8d24f0e5987374e11ab44eb513b021bd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:51:06 +0000 Subject: [PATCH 04/35] Raise the smallest text from 11px to 12px MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design language's floor: nothing a person reads is below 12px. The one scale moves together through --text-xs, and the five labels that set their own 10px or 11px — group counts in Seating, the table palette, Place cards' feature chips and row tags — now read the token instead. The count inside Seating's 16px warnings badge stays at 10px, and says why. Checked by screenshotting all five tools and the front page at 1440px and 1024px before and after. One thing moved: the table palette's fixed 58px items cut "Rectangle" and "Top Table" to an ellipsis at 12px, so the width is now a minimum and each item fits its own label. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KfDv5hZZ3uYeubXH64bFVH --- suite/apps/plaque/ui/RowsDrawer.module.css | 2 +- suite/apps/plaque/ui/panels/InspectorPanel.module.css | 2 +- .../tableaux/components/guestPanel/FamilyBlock.module.css | 2 +- .../tableaux/components/guestPanel/SubgroupBlock.module.css | 2 +- suite/apps/tableaux/components/layout/Toolbar.module.css | 1 + .../tableaux/components/toolbar/TablePalette.module.css | 6 ++++-- suite/lib/design/tokens.css | 2 +- 7 files changed, 10 insertions(+), 7 deletions(-) diff --git a/suite/apps/plaque/ui/RowsDrawer.module.css b/suite/apps/plaque/ui/RowsDrawer.module.css index e288dbf..25f769d 100644 --- a/suite/apps/plaque/ui/RowsDrawer.module.css +++ b/suite/apps/plaque/ui/RowsDrawer.module.css @@ -91,7 +91,7 @@ border-radius: var(--radius); background: var(--accent-soft); color: var(--grey-8); - font-size: 10px; + font-size: var(--text-xs); } .button { diff --git a/suite/apps/plaque/ui/panels/InspectorPanel.module.css b/suite/apps/plaque/ui/panels/InspectorPanel.module.css index 2da562f..6d49a72 100644 --- a/suite/apps/plaque/ui/panels/InspectorPanel.module.css +++ b/suite/apps/plaque/ui/panels/InspectorPanel.module.css @@ -29,7 +29,7 @@ border-radius: var(--radius); background: var(--surface); font-family: ui-monospace, monospace; - font-size: 11px; + font-size: var(--text-xs); color: var(--grey-6); cursor: pointer; } diff --git a/suite/apps/tableaux/components/guestPanel/FamilyBlock.module.css b/suite/apps/tableaux/components/guestPanel/FamilyBlock.module.css index 6c9559e..ebf3507 100644 --- a/suite/apps/tableaux/components/guestPanel/FamilyBlock.module.css +++ b/suite/apps/tableaux/components/guestPanel/FamilyBlock.module.css @@ -89,7 +89,7 @@ .count { flex: none; - font-size: 10px; + font-size: var(--text-xs); color: var(--panel-text-muted); min-width: 14px; text-align: center; diff --git a/suite/apps/tableaux/components/guestPanel/SubgroupBlock.module.css b/suite/apps/tableaux/components/guestPanel/SubgroupBlock.module.css index 5056338..a1b2ac7 100644 --- a/suite/apps/tableaux/components/guestPanel/SubgroupBlock.module.css +++ b/suite/apps/tableaux/components/guestPanel/SubgroupBlock.module.css @@ -83,7 +83,7 @@ .count { flex: none; - font-size: 10px; + font-size: var(--text-xs); color: var(--panel-text-muted); min-width: 14px; text-align: center; diff --git a/suite/apps/tableaux/components/layout/Toolbar.module.css b/suite/apps/tableaux/components/layout/Toolbar.module.css index 7a942bc..fea584c 100644 --- a/suite/apps/tableaux/components/layout/Toolbar.module.css +++ b/suite/apps/tableaux/components/layout/Toolbar.module.css @@ -83,6 +83,7 @@ place-items: center; background: var(--warn); color: #fff; + /* Below the 12px floor on purpose: a count inside a 16px badge, not a label. */ font-size: 10px; font-weight: var(--weight-semibold); border-radius: var(--radius-full); diff --git a/suite/apps/tableaux/components/toolbar/TablePalette.module.css b/suite/apps/tableaux/components/toolbar/TablePalette.module.css index 0628cd8..5f81195 100644 --- a/suite/apps/tableaux/components/toolbar/TablePalette.module.css +++ b/suite/apps/tableaux/components/toolbar/TablePalette.module.css @@ -9,7 +9,9 @@ flex-direction: column; align-items: center; gap: 2px; - width: 58px; + /* A minimum rather than a width, so a label at the 12px floor sets its own: + at a fixed 58px, "Rectangle" and "Top Table" were cut to an ellipsis. */ + min-width: 58px; padding: var(--space-1) var(--space-1) 3px; border-radius: var(--radius-md); border: 1px solid transparent; @@ -37,7 +39,7 @@ } .label { - font-size: 10px; + font-size: var(--text-xs); font-weight: var(--weight-medium); color: var(--ink-soft); letter-spacing: 0.01em; diff --git a/suite/lib/design/tokens.css b/suite/lib/design/tokens.css index d040cb6..040c914 100644 --- a/suite/lib/design/tokens.css +++ b/suite/lib/design/tokens.css @@ -65,7 +65,7 @@ * a tool are the same size. Anything larger is Tailwind's own, used only by * the shell's headings. */ - --text-xs: 0.6875rem; /* 11px: labels, meta */ + --text-xs: 0.75rem; /* 12px: labels, meta — the floor; nothing a person reads is smaller */ --text-sm: 0.8125rem; /* 13px: controls, secondary text */ --text-md: 0.875rem; /* 14px: body text in a panel */ --text-lg: 1rem; /* 16px: panel headings */ From 62b8d04b7456f284faecb508dd4bd20f09a7d9b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:59:36 +0000 Subject: [PATCH 05/35] Give the wedding's names, date and venue one editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The couple's names could be edited in three places — the Data panel, Timeline's Day panel, and a click-to-rename on Seating's title — and the date and venue in two. Each tool wrote its own copy back into `event`, so whichever saved last won. They are edited in the Data panel only now. Timeline shows them, read live from the wedding, with a button that opens the panel; it keeps the curfew, the clocks and the venue's coordinates, which are the schedule's own, and those are all it writes into `event`. Seating shows the names as a plain title and no longer writes `event` at all; its `meta` echo is overlaid from the wedding and never preferred over it. The front page's "Add one in Timeline" becomes a button that opens the panel. The Data panel's open state moves from the header into a small store so anything can open it. Also moves the header restructure, and its overflow at 1024px, into the plan's Overview step, where the header changes shape anyway. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KfDv5hZZ3uYeubXH64bFVH --- .../specs/2026-09-28-expansion-master-plan.md | 7 ++- suite/apps/cadence/state/sliceBridge.test.ts | 31 +++++++++++ suite/apps/cadence/state/sliceBridge.ts | 18 +++--- .../cadence/ui/panels/DayPanel.module.css | 20 +++++++ suite/apps/cadence/ui/panels/DayPanel.tsx | 51 ++++++++++------- .../components/guestPanel/GuestPanel.jsx | 55 +++---------------- .../guestPanel/GuestPanel.module.css | 18 +----- suite/apps/tableaux/store/actions.js | 2 - suite/apps/tableaux/store/sliceBridge.test.ts | 24 +++++++- suite/apps/tableaux/store/sliceBridge.ts | 39 +++++-------- suite/apps/tableaux/store/store.test.js | 7 --- suite/apps/tableaux/store/useStore.js | 2 +- suite/components/shell/Countdown.tsx | 12 +++- suite/components/shell/Header.tsx | 14 ++--- suite/components/shell/dataPanel.ts | 20 +++++++ suite/e2e/facts.spec.ts | 31 +++++++++++ 16 files changed, 210 insertions(+), 141 deletions(-) create mode 100644 suite/components/shell/dataPanel.ts create mode 100644 suite/e2e/facts.spec.ts diff --git a/docs/superpowers/specs/2026-09-28-expansion-master-plan.md b/docs/superpowers/specs/2026-09-28-expansion-master-plan.md index e376752..d83ef99 100644 --- a/docs/superpowers/specs/2026-09-28-expansion-master-plan.md +++ b/docs/superpowers/specs/2026-09-28-expansion-master-plan.md @@ -152,8 +152,11 @@ thing on a screen is the thing that needs a decision. screenshots of all five tools before it lands. - **Navigation.** The header keeps the five tools. The wedding's own pages — Overview, Guests, Money, Checklist, Binder — sit under the wedding's name, - which for a planner is also the switcher between client weddings. To be - settled with a mock in the Phase 0 plan. + which for a planner is also the switcher between client weddings. Built + with Overview (2.1), because that is when the header changes shape anyway. + It must also fix what the header does at 1024px today: the tools' own + controls (Timeline's zoom, Fit day and Present) do not fit and scroll out of + sight inside it. ## Signing in safely diff --git a/suite/apps/cadence/state/sliceBridge.test.ts b/suite/apps/cadence/state/sliceBridge.test.ts index 531d4c2..567a038 100644 --- a/suite/apps/cadence/state/sliceBridge.test.ts +++ b/suite/apps/cadence/state/sliceBridge.test.ts @@ -50,3 +50,34 @@ test("a date set in the Data panel survives the next Timeline edit", () => { useTrousseauStore.getState().release("cadence"); }); + +/** + * One editor per fact. The names, the venue and the date are the Data panel's; + * the curfew and the clocks are Timeline's. Timeline's copy of the first three + * is never written back, whatever it holds — and its own two always are. + */ +test("Timeline writes its curfew into the wedding, and never the names, venue or date", () => { + const doc = emptyTrousseau(); + const raw = { + ...doc, + event: { ...doc.event, date: "2029-01-01", coupleNames: "Robin & Kit", venueName: "The Barn" }, + }; + useTrousseauStore.setState({ + status: "ready", + error: null, + generation: 0, + raw: raw as unknown as Record, + doc: raw, + past: [], + future: [], + }); + useStore.getState().loadDoc(restore()); + + // A copy that disagrees, however it came to — and a curfew edit, which is Timeline's own. + useStore.getState().setDay({ date: "2000-01-01", coupleNames: "Old Names", venueName: "Elsewhere", curfewMin: 1380 }); + persist(getDoc(useStore.getState())); + + const event = useTrousseauStore.getState().doc.event; + expect(event).toMatchObject({ date: "2029-01-01", coupleNames: "Robin & Kit", venueName: "The Barn" }); + expect(event.curfewMin).toBe(1380); +}); diff --git a/suite/apps/cadence/state/sliceBridge.ts b/suite/apps/cadence/state/sliceBridge.ts index 0d523b4..3c7ed00 100644 --- a/suite/apps/cadence/state/sliceBridge.ts +++ b/suite/apps/cadence/state/sliceBridge.ts @@ -23,11 +23,16 @@ import type { TimelineDoc } from "../core/model/types"; * delegation board reads the second, so leaving it stale would have it * handing out yesterday's times. * - * - Five fields are mirrored back into `event`. The date, the couple, the - * venue, the curfew and the UTC offset belong to the wedding rather than to - * Cadence, which keeps an echo of them for its own resolver. The envelope's - * copy wins on read, so without this the Day panel would appear to accept an - * edit and then quietly revert to the old date on the next load. + * - The curfew and the UTC offset are mirrored back into `event`. They + * belong to the wedding rather than to Cadence, which keeps an echo of them + * for its own resolver, and Timeline is where they are edited. The + * envelope's copy wins on read, so without this the Day panel would appear + * to accept an edit and then quietly revert on the next load. + * + * The date, the couple and the venue are not written back. They used to be — + * Timeline had its own fields for them — and so did Seating, and each tool's + * copy wrote itself over the others. They are edited in the Data panel only, + * and Timeline shows them. */ /** The day as Cadence wants it, with the envelope's own fields already applied. */ @@ -50,9 +55,6 @@ export function writeSlice(next: TimelineDoc): void { "event", { ...doc.event, - date: next.day.date, - coupleNames: next.day.coupleNames, - venueName: next.day.venueName, curfewMin: next.day.curfewMin, utcOffsetMin: next.day.utcOffsetMin, }, diff --git a/suite/apps/cadence/ui/panels/DayPanel.module.css b/suite/apps/cadence/ui/panels/DayPanel.module.css index 1ac3134..c02e49a 100644 --- a/suite/apps/cadence/ui/panels/DayPanel.module.css +++ b/suite/apps/cadence/ui/panels/DayPanel.module.css @@ -13,3 +13,23 @@ line-height: 1.5; color: var(--text-muted); } + +.facts { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--sp-1); + margin-bottom: var(--sp-3); +} + +.couple { + margin: 0; + font-size: var(--text-md); + color: var(--text); +} + +.where { + margin: 0; + font-size: var(--text-sm); + color: var(--text-muted); +} diff --git a/suite/apps/cadence/ui/panels/DayPanel.tsx b/suite/apps/cadence/ui/panels/DayPanel.tsx index 1155e6f..8ad9691 100644 --- a/suite/apps/cadence/ui/panels/DayPanel.tsx +++ b/suite/apps/cadence/ui/panels/DayPanel.tsx @@ -1,6 +1,8 @@ import { formatClock } from "../../core/time/minutes"; import { getDoc, selectSchedule, useStore } from "../../state/store"; -import { Field, NumberField, Panel, Row, TextField, TimeField } from "@/components/ui/fields"; +import { Button, Field, NumberField, Panel, Row, TimeField } from "@/components/ui/fields"; +import { useDataPanel } from "@/components/shell/dataPanel"; +import { useTrousseauStore } from "@/lib/store/useTrousseauStore"; import styles from "./DayPanel.module.css"; const OFFSETS = [ @@ -11,33 +13,34 @@ const OFFSETS = [ { value: -240, label: "UTC-4 (EDT)" }, ]; +/** + * The day's settings: the facts of the wedding, shown, and Timeline's own + * inputs, edited. + * + * The couple, the venue and the date belong to the wedding, and are changed in + * one place — the Data panel. They used to be editable here as well, and in + * Seating, and each copy wrote itself back over the others. The curfew, the + * clocks and the venue's coordinates are the schedule's alone, and stay here. + */ export function DayPanel() { const doc = useStore(getDoc); const setDay = useStore((state) => state.setDay); const sun = useStore(selectSchedule).sun; + // Live from the wedding rather than from Timeline's copy of it. + const event = useTrousseauStore((state) => state.doc.event); + const showData = useDataPanel((state) => state.show); return ( - setDay({ coupleNames })} - placeholder="Charis & Jacob" - /> - setDay({ venueName })} - placeholder="Oving Jubilee Hall" - /> - - setDay({ date: event.target.value })} - /> - +
+

{event.coupleNames || "No names yet"}

+

+ {[event.venueName, event.date ? longDate(event.date) : "No date yet"].filter(Boolean).join(" · ")} +

+ +
); } + +function longDate(iso: string): string { + const when = new Date(`${iso}T00:00:00`); + if (Number.isNaN(when.getTime())) return iso; + return when.toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" }); +} diff --git a/suite/apps/tableaux/components/guestPanel/GuestPanel.jsx b/suite/apps/tableaux/components/guestPanel/GuestPanel.jsx index 36aa058..d4e2897 100644 --- a/suite/apps/tableaux/components/guestPanel/GuestPanel.jsx +++ b/suite/apps/tableaux/components/guestPanel/GuestPanel.jsx @@ -1,4 +1,4 @@ -import { useMemo, useState, useRef, useEffect } from 'react' +import { useMemo, useState } from 'react' import { useStore } from '../../store/useStore.js' import Icon from '../ui/Icon.jsx' import IconButton from '../ui/IconButton.jsx' @@ -11,55 +11,14 @@ import ContextMenu from '../ui/ContextMenu.jsx' import { matchesSearch, matchesFilters } from '../../utils/guestFilters.js' import styles from './GuestPanel.module.css' +/** + * The wedding's names, shown. They are changed in the Data panel only: this + * used to rename the wedding in place, and so did Timeline and the Data panel, + * each writing its own copy back over the others. + */ function WeddingName() { const name = useStore((s) => s.meta.weddingName) - const updateMeta = useStore((s) => s.updateMeta) - const [editing, setEditing] = useState(false) - const [draft, setDraft] = useState(name) - const inputRef = useRef(null) - - useEffect(() => { - if (editing) inputRef.current?.select() - }, [editing]) - - const commit = () => { - const v = draft.trim() - if (v) updateMeta({ weddingName: v }) - else setDraft(name) - setEditing(false) - } - - if (editing) { - return ( - setDraft(e.target.value)} - onBlur={commit} - onKeyDown={(e) => { - if (e.key === 'Enter') commit() - if (e.key === 'Escape') { - setDraft(name) - setEditing(false) - } - }} - /> - ) - } - return ( - - ) + return

{name}

} export default function GuestPanel() { diff --git a/suite/apps/tableaux/components/guestPanel/GuestPanel.module.css b/suite/apps/tableaux/components/guestPanel/GuestPanel.module.css index bce02c4..275ca2b 100644 --- a/suite/apps/tableaux/components/guestPanel/GuestPanel.module.css +++ b/suite/apps/tableaux/components/guestPanel/GuestPanel.module.css @@ -30,28 +30,12 @@ font-weight: var(--weight-semibold); color: var(--panel-text); line-height: 1.1; - text-align: left; - border-radius: var(--radius-sm); + margin: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.name:hover { - color: #fff; -} - -.nameInput { - font-family: var(--font-display); - font-size: 24px; - font-weight: var(--weight-semibold); - color: #fff; - background: rgba(255, 255, 255, 0.1); - border: 1px solid var(--accent); - border-radius: var(--radius-sm); - padding: 0 var(--space-2); - width: 100%; -} .stats { margin-top: var(--space-1); diff --git a/suite/apps/tableaux/store/actions.js b/suite/apps/tableaux/store/actions.js index fb1dac3..2f91fcb 100644 --- a/suite/apps/tableaux/store/actions.js +++ b/suite/apps/tableaux/store/actions.js @@ -1852,7 +1852,6 @@ const patchSingleton = (key, type, label) => (patch) => (state) => { return { type, label, payload: { [key]: patch }, inverse: { [key]: inverse } } } -export const updateMeta = patchSingleton('meta', 'UPDATE_META', 'Edit plan details') export const updateSettings = patchSingleton('settings', 'UPDATE_SETTINGS', 'Change settings') // `constraints` is a whole-array slice in applyPatch, so the inverse is simply @@ -1947,7 +1946,6 @@ export const actionCreators = { updateWallElement, addPillar, removePillar, - updateMeta, updateSettings, addConstraint, removeConstraint, diff --git a/suite/apps/tableaux/store/sliceBridge.test.ts b/suite/apps/tableaux/store/sliceBridge.test.ts index 44b3a1c..f11ed5e 100644 --- a/suite/apps/tableaux/store/sliceBridge.test.ts +++ b/suite/apps/tableaux/store/sliceBridge.test.ts @@ -7,7 +7,7 @@ vi.mock("idb-keyval", () => ({ })); const { useTrousseauStore } = await import("@/lib/store/useTrousseauStore"); -const { readDoc } = await import("./sliceBridge"); +const { readDoc, writeDoc } = await import("./sliceBridge"); const { emptyTrousseau } = await import("@jfrusher/trousseau"); /** @@ -122,3 +122,25 @@ test("a guest import and a rename made in the Data panel survive Seating's next useTrousseauStore.getState().release("tableaux"); }); + +/** + * One editor per fact: Seating shows the wedding's names, venue and date and + * never writes them. Its `meta` copy, however stale, stays in Seating. + */ +test("Seating never writes the wedding's names, venue or date", () => { + withGuests({}); + useTrousseauStore + .getState() + .setSlice("event", { ...emptyTrousseau().event, coupleNames: "Robin & Kit", venueName: "The Barn", date: "2029-01-01" }); + + const doc = readDoc(); + expect(doc.meta).toMatchObject({ weddingName: "Robin & Kit", venue: "The Barn", date: "2029-01-01" }); + + writeDoc({ ...doc, meta: { ...doc.meta, weddingName: "Old Names", venue: "Elsewhere", date: "2000-01-01" } }); + + expect(useTrousseauStore.getState().doc.event).toMatchObject({ + coupleNames: "Robin & Kit", + venueName: "The Barn", + date: "2029-01-01", + }); +}); diff --git a/suite/apps/tableaux/store/sliceBridge.ts b/suite/apps/tableaux/store/sliceBridge.ts index 52edcec..68f890e 100644 --- a/suite/apps/tableaux/store/sliceBridge.ts +++ b/suite/apps/tableaux/store/sliceBridge.ts @@ -52,9 +52,9 @@ type _DateExists = Assert<'date' extends EventKeys ? true : false> * called, where it is and when — the same three facts the `event` slice holds * for everything else, and they had already drifted apart: Tableaux showed "Our * Wedding" while Cadence showed the couple's names. There is one answer to each - * of those questions, so `meta` is overlaid from `event` on the way in and - * written back on the way out, and Tableaux's copy is an echo rather than a - * second opinion. + * of those questions, so `meta` is overlaid from `event` on the way in, and + * Tableaux's copy is an echo rather than a second opinion. It is not written + * back: those facts are edited in the Data panel and nowhere else. */ /** Everything Tableaux keeps out of its own document, minus the guest list. */ @@ -142,11 +142,13 @@ export function readDoc(): TableauxDoc { guests, meta: { ...meta, - // These three names are held to the contract by the assertions at the - // top of this file, not by `Event` itself — see the comment there. - weddingName: doc.event.coupleNames || meta.weddingName || UNNAMED, - venue: doc.event.venueName || meta.venue || '', - date: doc.event.date || meta.date || '', + // The wedding's facts, from the wedding: Seating shows them and no + // longer edits them, so an older copy kept in `meta` is never preferred. + // The three names are held to the contract by the assertions at the top + // of this file, not by `Event` itself — see the comment there. + weddingName: doc.event.coupleNames || UNNAMED, + venue: doc.event.venueName, + date: doc.event.date, }, } } @@ -166,28 +168,13 @@ export function writeDoc(doc: TableauxDoc): void { if (doc[key] !== undefined) seating[key] = doc[key] } - // `meta` was overlaid from `event` on the way in, so writing it back is a - // no-op unless it was edited here — in which case the edit is meant, and the - // rest of the suite should see it. - const { event } = useTrousseauStore.getState().doc - const meta = isRecord(doc.meta) ? doc.meta : {} - // The `typeof` check is what the JavaScript's truthiness already meant: a - // non-empty string. Made explicit because `meta` is a Record of `unknown`. - const named = typeof meta.weddingName === 'string' && meta.weddingName !== UNNAMED - + // Not `event`: the names, venue and date are edited in the Data panel only. + // Seating used to write its copy of them back here, over whatever the panel + // had just set. useTrousseauStore.getState().setSlices( [ ['guests', doc.guests ?? {}], ['seating', seating], - [ - 'event', - { - ...event, - coupleNames: named ? meta.weddingName : event.coupleNames, - venueName: meta.venue || event.venueName, - date: meta.date || event.date, - }, - ], ], { label: 'the room', silent: true, by: 'tableaux' }, ) diff --git a/suite/apps/tableaux/store/store.test.js b/suite/apps/tableaux/store/store.test.js index b5f2544..4b92cec 100644 --- a/suite/apps/tableaux/store/store.test.js +++ b/suite/apps/tableaux/store/store.test.js @@ -137,13 +137,6 @@ describe('plan details, settings and seating rules', () => { expect(s()._history.past).toHaveLength(before) }) - it('undoes a wedding-name edit', () => { - s().updateMeta({ weddingName: 'Renamed' }) - expect(s().meta.weddingName).toBe('Renamed') - s().undo() - expect(s().meta.weddingName).toBe('Test') - }) - it('adds and removes a seating rule undoably', () => { const id = s().addConstraint({ kind: 'apart', guestIds: ['g1', 'g2'] }).meta.newConstraintId expect(s().constraints).toHaveLength(1) diff --git a/suite/apps/tableaux/store/useStore.js b/suite/apps/tableaux/store/useStore.js index 8df8e51..d1a2a17 100644 --- a/suite/apps/tableaux/store/useStore.js +++ b/suite/apps/tableaux/store/useStore.js @@ -282,7 +282,7 @@ export const useStore = create( // call it once per pointermove and dispatch a single EDIT_SPACE // command on pointer-up (RoomSpaces.jsx). Routing either through // dispatch would push one undo entry per animation frame. - // updateMeta/updateSettings/addConstraint/removeConstraint are now + // updateSettings/addConstraint/removeConstraint are now // undoable action creators in actions.js. updateRoom: (patch) => get()._touch({ room: { ...get().room, ...patch } }), setCanvas: (patch) => get()._touch({ canvas: { ...get().canvas, ...patch } }), diff --git a/suite/components/shell/Countdown.tsx b/suite/components/shell/Countdown.tsx index 9330990..c4841bb 100644 --- a/suite/components/shell/Countdown.tsx +++ b/suite/components/shell/Countdown.tsx @@ -2,6 +2,7 @@ import { useTrousseauStore } from "@/lib/store/useTrousseauStore"; import { KO_FI_URL } from "@/lib/support"; +import { useDataPanel } from "./dataPanel"; /** * The wedding, and how long there is. @@ -19,6 +20,7 @@ export function Countdown() { const venue = useTrousseauStore((s) => s.doc.event.venueName); const date = useTrousseauStore((s) => s.doc.event.date); const status = useTrousseauStore((s) => s.status); + const showData = useDataPanel((s) => s.show); if (status !== "ready") { return
; @@ -33,7 +35,15 @@ export function Countdown() { {venue && {venue}} {venue && date && ·} {date && {longDate(date)}} - {!venue && !date && No date set yet. Add one in Timeline.} + {!venue && !date && ( + + )}

{date &&

{howLong(date)}

} {date && isPast(date) && ( diff --git a/suite/components/shell/Header.tsx b/suite/components/shell/Header.tsx index 163f7b2..e75ce45 100644 --- a/suite/components/shell/Header.tsx +++ b/suite/components/shell/Header.tsx @@ -11,6 +11,7 @@ import { AccountStatus } from "./AccountStatus"; import { HowThisWorks } from "./TourButtons"; import { ChromeSlot } from "./chrome"; import { DataButton } from "./DataButton"; +import { useDataPanel } from "./dataPanel"; const DataManager = dynamic(() => import("./DataManager").then((m) => m.DataManager), { ssr: false, @@ -24,19 +25,18 @@ const DataManager = dynamic(() => import("./DataManager").then((m) => m.DataMana * seating changes is the cheapest possible proof that they are. */ export function Header() { - const [dataOpen, setDataOpen] = useState(false); + const dataOpen = useDataPanel((s) => s.open); + const showData = useDataPanel((s) => s.show); + const hideData = useDataPanel((s) => s.hide); // DataManager's chunk (CSV and guest-import parsing, the guest link panel) // is dynamically imported — keep it out of the tree entirely until the user // has opened it once, so the chunk isn't fetched on every route's first // render. Once opened, its dialog element stays mounted and is opened and // closed in place. const [dataEverOpened, setDataEverOpened] = useState(false); + if (dataOpen && !dataEverOpened) setDataEverOpened(true); const pathname = usePathname(); const guestCount = useTrousseauStore((s) => Object.keys(s.doc.guests).length); - const openData = () => { - setDataEverOpened(true); - setDataOpen(true); - }; return ( <> @@ -93,14 +93,14 @@ export function Header() { {guestCount} - +
- {dataEverOpened ? setDataOpen(false)} /> : null} + {dataEverOpened ? : null} ); } diff --git a/suite/components/shell/dataPanel.ts b/suite/components/shell/dataPanel.ts new file mode 100644 index 0000000..452211b --- /dev/null +++ b/suite/components/shell/dataPanel.ts @@ -0,0 +1,20 @@ +import { create } from "zustand"; + +/** + * Whether the Data panel is open, for anything that needs to open it. + * + * It lived in the header's own state, which was fine while the header's button + * was the only way in. The wedding's names, date and venue are edited there + * and nowhere else now, so Timeline and the front page point at it too. + */ +interface DataPanel { + open: boolean; + show: () => void; + hide: () => void; +} + +export const useDataPanel = create()((set) => ({ + open: false, + show: () => set({ open: true }), + hide: () => set({ open: false }), +})); diff --git a/suite/e2e/facts.spec.ts b/suite/e2e/facts.spec.ts new file mode 100644 index 0000000..160496f --- /dev/null +++ b/suite/e2e/facts.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from "@playwright/test"; +import { openSeating, seedExampleWedding } from "./wedding"; + +/** + * The wedding's names, venue and date are edited in one place — the Data + * panel. Timeline and Seating each had their own fields for them, and each + * wrote its copy back over the others. + */ +test("Timeline shows the wedding's facts and sends you to Data to change them", async ({ page }) => { + await seedExampleWedding(page); + await page.goto("/timeline"); + + await expect(page.getByText("Alex & Sam", { exact: true })).toBeVisible(); + await expect(page.getByText("The Old Granary · 1 June 2028")).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Couple", exact: true })).toHaveCount(0); + + await page.getByRole("button", { name: "Change names, date or venue" }).click(); + const data = page.getByRole("dialog", { name: "Your data" }); + await data.getByLabel("Names").fill("Robin & Kit"); + await page.keyboard.press("Escape"); + + await expect(page.getByText("Robin & Kit", { exact: true })).toBeVisible(); +}); + +test("Seating shows the wedding's names without offering to rename it", async ({ page }) => { + await seedExampleWedding(page); + await openSeating(page); + + await expect(page.getByText("Alex & Sam", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Alex & Sam" })).toHaveCount(0); +}); From 9a5f069865d8e4b1c820007eb054c681d97958ea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:19:49 +0000 Subject: [PATCH 06/35] One guest importer, and one meaning for a dietary requirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seating had its own importer beside the Data panel's, with different rules: it could replace the whole list, matched by email, and stored diets as keys, where the other matched by name only and stored the file's words. A list imported one way looked different to the tools than one imported the other. Shown in a real browser: the example wedding's thirteen vegetarians were invisible to Seating's Vegetarian filter, and "None" was listed as a diet. Now there is one importer, opened from the Data panel and from Seating's own buttons. It keeps the better rule from each: a guest is found by email, then by a name nobody else on the list shares, never when two emails disagree. Each row is a person. A name shared by several guests is held back and shown rather than guessed at, so neither Sarah Smith is edited and re-importing does not add them again. RSVP answers are shown for the couple to say what each means. Nothing is written until the preview, which lists who is new, updated, unchanged, and on the list but not in the file — removed only if ticked, and then from their table, groups and seating rules as well. Nobody is ever unseated. A CSV's Table column is no longer offered, since it was never used. A diet is a key in `dietary` and the guest's own words in `dietaryRaw`, defined once in lib/model/dietary (moved from Seating). Documents stored the old way are converted as they load. Place cards, the reports and search show the guest's words. The header guesser now reads "Dietary Restrictions", and no longer takes Joy's "Party" (a household) as a side. Checked against fixtures shaped after Joy's and Zola's documented exports — not real files; the hosts were not reachable from here. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KfDv5hZZ3uYeubXH64bFVH --- .../specs/2026-09-28-expansion-master-plan.md | 1 + suite/apps/offline.test.ts | 5 +- suite/apps/plaque/state/fromRoom.ts | 4 +- .../components/guestPanel/GuestCard.jsx | 2 +- .../components/guestPanel/GuestPanel.jsx | 7 +- .../components/guestPanel/ImportModal.jsx | 388 ------------------ .../guestPanel/ImportModal.module.css | 275 ------------- .../tableaux/components/layout/ModalRoot.jsx | 5 +- .../components/sidebar/GuestInspector.jsx | 2 +- .../components/sidebar/StatsPanel.jsx | 2 +- suite/apps/tableaux/store/sliceBridge.test.ts | 22 +- suite/apps/tableaux/store/sliceBridge.ts | 6 +- suite/apps/tableaux/store/store.test.js | 95 ----- suite/apps/tableaux/store/useStore.js | 143 ------- suite/apps/tableaux/utils/csv.js | 92 ----- suite/apps/tableaux/utils/csvParser.js | 140 ------- suite/apps/tableaux/utils/csvParser.test.js | 76 ---- suite/apps/tableaux/utils/dietary.js | 37 -- suite/apps/tableaux/utils/dietary.test.js | 35 -- suite/apps/tableaux/utils/exportReports.js | 2 +- suite/apps/tableaux/utils/importCsv.js | 21 - suite/components/shell/DataManager.tsx | 129 +----- suite/components/shell/GuestImport.tsx | 316 ++++++++++++++ suite/components/shell/Header.tsx | 9 + suite/components/shell/guestImportPanel.ts | 18 + suite/e2e/guests.spec.ts | 58 +++ suite/e2e/persistence.spec.ts | 10 +- suite/fixtures/joy-shaped.csv | 5 + suite/fixtures/zola-shaped.csv | 5 + suite/lib/data/guestImport.exports.test.ts | 66 +++ suite/lib/data/guestImport.test.ts | 164 +++++++- suite/lib/data/guestImport.ts | 202 ++++++--- suite/lib/ensemble/exports.test.ts | 1 + suite/lib/ensemble/propose.test.ts | 1 + .../lib/ensemble/render/pdf/shotSheet.test.ts | 1 + suite/lib/ensemble/resolve.test.ts | 1 + suite/lib/model/dietary.test.ts | 51 +++ suite/lib/model/dietary.ts | 79 ++++ suite/lib/model/factories.ts | 1 + suite/lib/model/slices.ts | 31 +- suite/lib/model/types.ts | 3 + suite/lib/seating/exports.ts | 3 +- suite/lib/seating/normalise.ts | 15 +- suite/lib/seating/organise.ts | 3 +- suite/lib/seating/removeGuests.test.ts | 45 ++ suite/lib/seating/removeGuests.ts | 85 ++++ suite/lib/seating/stats.ts | 6 +- 47 files changed, 1139 insertions(+), 1529 deletions(-) delete mode 100644 suite/apps/tableaux/components/guestPanel/ImportModal.jsx delete mode 100644 suite/apps/tableaux/components/guestPanel/ImportModal.module.css delete mode 100644 suite/apps/tableaux/utils/csv.js delete mode 100644 suite/apps/tableaux/utils/csvParser.js delete mode 100644 suite/apps/tableaux/utils/csvParser.test.js delete mode 100644 suite/apps/tableaux/utils/dietary.js delete mode 100644 suite/apps/tableaux/utils/dietary.test.js delete mode 100644 suite/apps/tableaux/utils/importCsv.js create mode 100644 suite/components/shell/GuestImport.tsx create mode 100644 suite/components/shell/guestImportPanel.ts create mode 100644 suite/e2e/guests.spec.ts create mode 100644 suite/fixtures/joy-shaped.csv create mode 100644 suite/fixtures/zola-shaped.csv create mode 100644 suite/lib/data/guestImport.exports.test.ts create mode 100644 suite/lib/model/dietary.test.ts create mode 100644 suite/lib/model/dietary.ts create mode 100644 suite/lib/seating/removeGuests.test.ts create mode 100644 suite/lib/seating/removeGuests.ts diff --git a/docs/superpowers/specs/2026-09-28-expansion-master-plan.md b/docs/superpowers/specs/2026-09-28-expansion-master-plan.md index d83ef99..6e7f648 100644 --- a/docs/superpowers/specs/2026-09-28-expansion-master-plan.md +++ b/docs/superpowers/specs/2026-09-28-expansion-master-plan.md @@ -33,6 +33,7 @@ several turned out differently once tested than they looked when read. | S7 | Names, date and venue have three editors (Data panel, Timeline's Day panel, Seating's write-back). Guest import has two implementations with different rules. | Traced, seen | | S8 | The guest link needs a second credential — an unrecoverable passphrase — even for a signed-in couple, and goes stale silently when seats change. | Traced, seen | | S9 | "Take a tour" runs the six-step front-page chapter and stops; the other 23 steps are reachable only one tool at a time. | Traced | +| S11 | Found while merging the importers: the Data panel's importer stored diets as the file's words ("Vegetarian", "None") where Seating reads a key ("vegetarian"). Seating's Vegetarian filter found none of the example wedding's thirteen vegetarians, and its breakdown listed "None" as a diet. | Reproduced — Playwright. **Fixed 2026-09-28** with the one importer. | | S10 | The example wedding has 0 of 100 guests seated, no crew, no jobs, no shots and no card design. The promise it exists to demonstrate cannot be shown from it. | Reproduced (fixture counted), seen | ### Architecture diff --git a/suite/apps/offline.test.ts b/suite/apps/offline.test.ts index 91f895d..fe3b971 100644 --- a/suite/apps/offline.test.ts +++ b/suite/apps/offline.test.ts @@ -72,7 +72,10 @@ describe("the tools reach no further than this device", () => { it("finds the tools to check", () => { // A rename that emptied this list would make every assertion below vacuous. - expect(sources.length).toBeGreaterThan(250); + // A floor well under the real count, not the count itself: the tools shed + // files as their duplicates of shared code are retired — Seating's own + // importer and dietary module went on 2026-09-28, taking it to 249. + expect(sources.length).toBeGreaterThan(200); }); it.each(NETWORK_APIS)("no tool calls %s", (api) => { diff --git a/suite/apps/plaque/state/fromRoom.ts b/suite/apps/plaque/state/fromRoom.ts index 0f56bc3..521c345 100644 --- a/suite/apps/plaque/state/fromRoom.ts +++ b/suite/apps/plaque/state/fromRoom.ts @@ -1,4 +1,5 @@ import { guestName, readGuests, readSeating } from "@/lib/model/slices"; +import { dietaryText } from "@/lib/model/dietary"; import { useTrousseauStore } from "@/lib/store/useTrousseauStore"; import type { RowIssue, GuestRow } from "../core/data/rows"; @@ -57,7 +58,8 @@ export function rowsFromRoom(only?: ReadonlySet): RoomRows { "Last Name": guest.lastName, Name: guestName(guest), Table: guest.assignedTableId ? (tableLabel.get(guest.assignedTableId) ?? "") : "", - Dietary: guest.dietary, + // What the guest said, so the card reads "Coeliac" rather than "gluten-free". + Dietary: dietaryText(guest), Side: guest.side, })); diff --git a/suite/apps/tableaux/components/guestPanel/GuestCard.jsx b/suite/apps/tableaux/components/guestPanel/GuestCard.jsx index ee56b2e..4a6876f 100644 --- a/suite/apps/tableaux/components/guestPanel/GuestCard.jsx +++ b/suite/apps/tableaux/components/guestPanel/GuestCard.jsx @@ -4,7 +4,7 @@ import clsx from 'clsx' import { useStore } from '../../store/useStore.js' import Icon from '../ui/Icon.jsx' import { useGuestWarnings } from '../../store/warningsContext.jsx' -import { dietaryMeta } from '../../utils/dietary.js' +import { dietaryMeta } from '@/lib/model/dietary' import styles from './GuestCard.module.css' const SIDE = { diff --git a/suite/apps/tableaux/components/guestPanel/GuestPanel.jsx b/suite/apps/tableaux/components/guestPanel/GuestPanel.jsx index d4e2897..6911175 100644 --- a/suite/apps/tableaux/components/guestPanel/GuestPanel.jsx +++ b/suite/apps/tableaux/components/guestPanel/GuestPanel.jsx @@ -8,6 +8,7 @@ import GroupBlock from './GroupBlock.jsx' import FamilyBlock from './FamilyBlock.jsx' import GuestSearch from './GuestSearch.jsx' import ContextMenu from '../ui/ContextMenu.jsx' +import { useGuestImport } from '@/components/shell/guestImportPanel' import { matchesSearch, matchesFilters } from '../../utils/guestFilters.js' import styles from './GuestPanel.module.css' @@ -22,6 +23,8 @@ function WeddingName() { } export default function GuestPanel() { + // The suite's one importer — Seating's own was removed with its different rules. + const showImport = useGuestImport((s) => s.show) const guests = useStore((s) => s.guests) const groups = useStore((s) => s.groups) const subgroups = useStore((s) => s.subgroups) @@ -285,7 +288,7 @@ export default function GuestPanel() { icon="upload" label="Import guests" onDark - onClick={() => openModal('import')} + onClick={showImport} /> @@ -311,7 +314,7 @@ export default function GuestPanel() {

Start by importing your guest list

-

diff --git a/suite/apps/tableaux/components/guestPanel/ImportModal.jsx b/suite/apps/tableaux/components/guestPanel/ImportModal.jsx deleted file mode 100644 index e8c7129..0000000 --- a/suite/apps/tableaux/components/guestPanel/ImportModal.jsx +++ /dev/null @@ -1,388 +0,0 @@ -import { useState, useRef, useMemo, useEffect } from 'react' -import clsx from 'clsx' -import { useStore } from '../../store/useStore.js' -import { readCsvFile } from '../../utils/importCsv.js' -import Modal from '../ui/Modal.jsx' -import Button from '../ui/Button.jsx' -import Icon from '../ui/Icon.jsx' -import { dietaryMeta } from '../../utils/dietary.js' -import { - guessMapping, - uniqueValues, - defaultComingValues, - buildGuests, -} from '../../utils/csvParser.js' -import styles from './ImportModal.module.css' - -// TODO(ux-audit): RSVP is only "recommended," not required, with no inline -// warning if left unmapped (unlike firstName's error below) -- if skipped, -// every row silently defaults to rsvpStatus: 'confirmed' (csvParser.js), so -// a guest who actually declined in the source sheet gets imported as -// confirmed. See tmp/ux-audit.md #G12. -const FIELDS = [ - { key: 'firstName', label: 'First name', required: true }, - { key: 'lastName', label: 'Last name', recommended: true }, - { key: 'rsvp', label: 'RSVP / Attending', recommended: true }, - { key: 'dietary', label: 'Dietary', recommended: true }, - { key: 'email', label: 'Email' }, - { key: 'side', label: 'Side' }, - { key: 'notes', label: 'Notes' }, -] - -const SIDE_LABEL = { bride: "Bride's", groom: "Groom's", both: 'Both' } - -export default function ImportModal() { - const closeModal = useStore((s) => s.closeModal) - const importGuests = useStore((s) => s.importGuests) - const addToast = useStore((s) => s.addToast) - const existingCount = useStore((s) => Object.keys(s.guests).length) - - const steps = useMemo( - () => (existingCount > 0 ? ['upload', 'map', 'preview', 'merge'] : ['upload', 'map', 'preview']), - [existingCount] - ) - - const [stepIndex, setStepIndex] = useState(0) - const [parsed, setParsed] = useState(null) // { filename, headers, rows, rowCount } - const [mapping, setMapping] = useState({}) - const [comingValues, setComingValues] = useState([]) - const [deselected, setDeselected] = useState(() => new Set()) - const [strategy, setStrategy] = useState('update') - const [uploading, setUploading] = useState(false) - const [error, setError] = useState(null) - const [dragOver, setDragOver] = useState(false) - const inputRef = useRef(null) - - const step = steps[stepIndex] - - // Recompute RSVP "coming" defaults whenever the mapped RSVP column changes. - const rsvpValues = useMemo( - () => (parsed ? uniqueValues(parsed.rows, mapping.rsvp) : []), - [parsed, mapping.rsvp] - ) - useEffect(() => { - setComingValues(defaultComingValues(rsvpValues)) - }, [rsvpValues]) - - const allGuests = useMemo( - () => (parsed ? buildGuests(parsed.rows, mapping, comingValues) : []), - [parsed, mapping, comingValues] - ) - const confirmedGuests = useMemo( - () => allGuests.filter((g) => g.rsvpStatus === 'confirmed'), - [allGuests] - ) - const notComingCount = allGuests.length - confirmedGuests.length - const importCount = confirmedGuests.length - deselected.size - - async function handleFile(file) { - if (!file) return - if (!file.name.toLowerCase().endsWith('.csv')) { - setError('Please choose a .csv file.') - return - } - setError(null) - setUploading(true) - try { - const data = await readCsvFile(file) - if (!data.rowCount) { - setError('That file looks empty — no rows found.') - setParsed(null) - } else { - setParsed(data) - setMapping(guessMapping(data.headers)) - setDeselected(new Set()) - } - } catch (e) { - setError(e.message) - } finally { - setUploading(false) - } - } - - const canNext = - (step === 'upload' && !!parsed) || - (step === 'map' && !!mapping.firstName) || - (step === 'preview' && importCount > 0) || - step === 'merge' - - const isLastStep = stepIndex === steps.length - 1 - - function finish() { - const chosen = confirmedGuests.filter((_, i) => !deselected.has(i)) - const useStrategy = existingCount > 0 ? strategy : 'replace' - importGuests(chosen, useStrategy) - addToast({ - type: 'success', - message: `${chosen.length} ${chosen.length === 1 ? 'guest' : 'guests'} imported.`, - }) - closeModal() - } - - const next = () => (isLastStep ? finish() : setStepIndex((i) => i + 1)) - const back = () => setStepIndex((i) => Math.max(0, i - 1)) - - const setField = (key, value) => setMapping((m) => ({ ...m, [key]: value || null })) - const toggleComing = (v) => - setComingValues((cv) => (cv.includes(v) ? cv.filter((x) => x !== v) : [...cv, v])) - const toggleRow = (i) => - setDeselected((d) => { - const n = new Set(d) - if (n.has(i)) n.delete(i) - else n.add(i) - return n - }) - - return ( - - - Step {stepIndex + 1} of {steps.length} - -

- {stepIndex > 0 && ( - - )} - -
- - } - > - {step === 'upload' && ( -
-
inputRef.current?.click()} - onDragOver={(e) => { - e.preventDefault() - setDragOver(true) - }} - onDragLeave={() => setDragOver(false)} - onDrop={(e) => { - e.preventDefault() - setDragOver(false) - handleFile(e.dataTransfer.files[0]) - }} - role="button" - tabIndex={0} - onKeyDown={(e) => e.key === 'Enter' && inputRef.current?.click()} - > - handleFile(e.target.files[0])} - /> - -

- {uploading ? 'Reading…' : 'Drag a CSV here, or click to browse'} -

-

.csv files only

-
- {parsed && ( -
- - {parsed.filename} - - {parsed.rowCount} {parsed.rowCount === 1 ? 'row' : 'rows'} ·{' '} - {parsed.headers.length} columns - -
- )} - {error &&

{error}

} -
- )} - - {step === 'map' && parsed && ( -
-
- {FIELDS.map((f) => { - const unmatched = !mapping[f.key] - return ( - - ) - })} -
- - {!mapping.firstName && ( -

- We couldn’t find a column for First name. Please select one above. -

- )} - {/* TODO(ux-audit): canNext only checks mapping.firstName is set, - not that it actually produces any non-blank names - (buildGuests/csvParser.js filters out rows with empty - fullName). If First name is mapped to a blank/wrong column, - the preview step below shows an empty table and a disabled - Import button with no message explaining "0 rows had a usable - name." See tmp/ux-audit.md #G11. */} - - {mapping.rsvp && rsvpValues.length > 0 && ( -
-

Which values mean “coming”?

-
- {rsvpValues.map((v) => ( - - ))} -
-
- )} - -
-

Preview · first {Math.min(5, parsed.rows.length)} rows

-
- - - - {parsed.headers.map((h) => ( - - ))} - - - - {parsed.rows.slice(0, 5).map((r, i) => ( - - {parsed.headers.map((h) => ( - - ))} - - ))} - -
{h}
{r[h]}
-
-
-
- )} - - {step === 'preview' && ( -
-

- Importing {importCount} {importCount === 1 ? 'guest' : 'guests'}. - {notComingCount > 0 && ( - - {' '} - {notComingCount} declined or pending {notComingCount === 1 ? 'guest is' : 'guests are'}{' '} - not shown. - - )} -

-
- - - - - - - - - - - {confirmedGuests.map((g, i) => { - const diet = g.dietary ? dietaryMeta(g.dietary) : null - const included = !deselected.has(i) - return ( - toggleRow(i)} - > - - - - - - ) - })} - -
NameSideDietary
- - {included && } - - {g.fullName}{SIDE_LABEL[g.side] || '—'}{diet ? diet.label : —}
-
-
- )} - - {step === 'merge' && ( -
-

- You already have {existingCount} guests. How should we merge? -

-
- {[ - { - value: 'update', - title: 'Update existing guests', - desc: 'Match by email then name, refresh their details, and add anyone new. Keeps seating.', - }, - { - value: 'add', - title: 'Add new only', - desc: 'Skip guests that already exist; only add people not in your list.', - }, - { - value: 'replace', - title: 'Replace all guests', - desc: 'Remove the current guest list and start fresh. Clears groups and seating.', - }, - ].map((opt) => ( - - ))} -
-
- )} - - ) -} diff --git a/suite/apps/tableaux/components/guestPanel/ImportModal.module.css b/suite/apps/tableaux/components/guestPanel/ImportModal.module.css deleted file mode 100644 index 522d113..0000000 --- a/suite/apps/tableaux/components/guestPanel/ImportModal.module.css +++ /dev/null @@ -1,275 +0,0 @@ -.step { - display: flex; - flex-direction: column; - gap: var(--space-4); - min-height: 280px; -} - -/* Upload */ -.dropzone { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: var(--space-2); - padding: var(--space-8) var(--space-4); - border: 1.5px dashed var(--border); - border-radius: var(--radius-lg); - background: var(--surface); - cursor: pointer; - transition: - border-color var(--transition-fast), - background var(--transition-fast); -} -.dropzone:hover, -.dragOver { - border-color: var(--accent); - background: var(--accent-light); -} - -.dropIcon { - color: var(--accent); -} -.dropTitle { - font-size: var(--text-md); - font-weight: var(--weight-medium); - color: var(--ink); -} -.dropHint { - font-size: var(--text-xs); - color: var(--ink-muted); -} - -.fileInfo { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-3); - background: var(--surface-subtle); - border-radius: var(--radius-md); -} -.fileOk { - color: var(--ok); - flex: none; -} -.fileName { - font-weight: var(--weight-medium); - font-size: var(--text-sm); -} -.fileMeta { - font-size: var(--text-sm); - color: var(--ink-muted); - margin-left: auto; -} - -.error { - font-size: var(--text-sm); - color: var(--danger); -} - -/* Mapping */ -.mapGrid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--space-3); -} -.mapRow { - display: flex; - flex-direction: column; - gap: var(--space-1); -} -.mapLabel { - font-size: var(--text-xs); - font-weight: var(--weight-semibold); - letter-spacing: var(--tracking-label); - text-transform: uppercase; - color: var(--ink-soft); -} -.req { - color: var(--danger); -} -.select { - height: 34px; - padding: 0 var(--space-2); - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--surface-raised); - font-size: var(--text-sm); - color: var(--ink); -} -.select:focus { - border-color: var(--accent); - outline: none; -} -.selectError { - border-color: var(--danger); - background: var(--danger-light); -} - -.comingBlock { - display: flex; - flex-direction: column; - gap: var(--space-2); -} -.subLabel { - font-size: var(--text-xs); - font-weight: var(--weight-semibold); - letter-spacing: var(--tracking-label); - text-transform: uppercase; - color: var(--ink-soft); -} - -.chips { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); -} -.chip { - display: inline-flex; - align-items: center; - gap: 4px; - height: 28px; - padding: 0 var(--space-3); - border-radius: var(--radius-full); - border: 1px solid var(--border); - background: var(--surface-raised); - font-size: var(--text-sm); - color: var(--ink-soft); -} -.chipActive { - background: var(--accent); - border-color: var(--accent); - color: #fff; -} - -/* Preview tables */ -.previewWrap { - display: flex; - flex-direction: column; - gap: var(--space-2); -} -.tableScroll { - max-height: 320px; - overflow: auto; - border: 1px solid var(--border); - border-radius: var(--radius-md); -} -.previewTable { - width: 100%; - border-collapse: collapse; - font-size: var(--text-sm); -} -.previewTable th { - position: sticky; - top: 0; - background: var(--surface-subtle); - text-align: left; - padding: var(--space-2) var(--space-3); - font-size: var(--text-xs); - font-weight: var(--weight-semibold); - color: var(--ink-soft); - white-space: nowrap; - border-bottom: 1px solid var(--border); -} -.previewTable td { - padding: var(--space-2) var(--space-3); - border-bottom: 1px solid var(--border); - white-space: nowrap; - color: var(--ink); -} -.previewTable tbody tr:last-child td { - border-bottom: none; -} - -.importLine { - font-size: var(--text-md); - color: var(--ink); -} -.muted { - color: var(--ink-muted); -} - -/* Preview selectable rows */ -.previewTable tbody tr { - cursor: pointer; -} -.previewTable tbody tr:hover { - background: var(--surface-subtle); -} -.rowExcluded td:not(.checkCol) { - opacity: 0.4; - text-decoration: line-through; -} -.checkCol { - width: 40px; - text-align: center; -} -.check { - display: inline-flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - border-radius: var(--radius-sm); - border: 1.5px solid var(--border); - color: #fff; -} -.checkOn { - background: var(--accent); - border-color: var(--accent); -} - -/* Merge strategy */ -.strategyList { - display: flex; - flex-direction: column; - gap: var(--space-2); -} -.strategy { - display: flex; - align-items: flex-start; - gap: var(--space-3); - padding: var(--space-3); - border: 1px solid var(--border); - border-radius: var(--radius-md); - cursor: pointer; -} -.strategy:hover { - border-color: var(--ink-muted); -} -.strategyActive { - border-color: var(--accent); - background: var(--accent-light); -} -.strategy input { - margin-top: 3px; - accent-color: var(--accent); -} -.strategyTitle { - display: block; - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - color: var(--ink); -} -.strategyDesc { - display: block; - font-size: var(--text-sm); - color: var(--ink-soft); - margin-top: 2px; -} - -/* Footer */ -.footer { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; -} -.stepCount { - font-size: var(--text-xs); - color: var(--ink-muted); -} -.footerActions { - display: flex; - gap: var(--space-3); -} diff --git a/suite/apps/tableaux/components/layout/ModalRoot.jsx b/suite/apps/tableaux/components/layout/ModalRoot.jsx index e5b750b..3209d1d 100644 --- a/suite/apps/tableaux/components/layout/ModalRoot.jsx +++ b/suite/apps/tableaux/components/layout/ModalRoot.jsx @@ -1,6 +1,5 @@ import { useStore } from '../../store/useStore.js' import ConfirmDialog from '../ui/ConfirmDialog.jsx' -import ImportModal from '../guestPanel/ImportModal.jsx' import WarningsPanel from './WarningsPanel.jsx' import ConstraintsModal from './ConstraintsModal.jsx' import SnapshotsModal from './SnapshotsModal.jsx' @@ -12,7 +11,7 @@ import PrintModal from './PrintModal.jsx' /** * Renders the single store-driven modal. New modal types are added to the - * switch as their features are built (import, settings, snapshots, …). + * switch as their features are built (settings, snapshots, …). */ export default function ModalRoot() { const modal = useStore((s) => s.modal) @@ -22,8 +21,6 @@ export default function ModalRoot() { const { name, props = {} } = modal switch (name) { - case 'import': - return case 'warnings': return case 'constraints': diff --git a/suite/apps/tableaux/components/sidebar/GuestInspector.jsx b/suite/apps/tableaux/components/sidebar/GuestInspector.jsx index 0125e6d..d207103 100644 --- a/suite/apps/tableaux/components/sidebar/GuestInspector.jsx +++ b/suite/apps/tableaux/components/sidebar/GuestInspector.jsx @@ -1,7 +1,7 @@ import { useMemo, useState } from 'react' import clsx from 'clsx' import { useStore } from '../../store/useStore.js' -import { DIETARY_META, normaliseDietary } from '../../utils/dietary.js' +import { DIETARY_META, normaliseDietary } from '@/lib/model/dietary' import Icon from '../ui/Icon.jsx' import IconButton from '../ui/IconButton.jsx' import Button from '../ui/Button.jsx' diff --git a/suite/apps/tableaux/components/sidebar/StatsPanel.jsx b/suite/apps/tableaux/components/sidebar/StatsPanel.jsx index e01ffc5..39d21ef 100644 --- a/suite/apps/tableaux/components/sidebar/StatsPanel.jsx +++ b/suite/apps/tableaux/components/sidebar/StatsPanel.jsx @@ -1,6 +1,6 @@ import { useMemo } from 'react' import { useStore } from '../../store/useStore.js' -import { DIETARY_META, dietaryLabel } from '../../utils/dietary.js' +import { DIETARY_META, dietaryLabel } from '@/lib/model/dietary' import { fillColour } from '../../utils/seatPositions.js' import styles from './StatsPanel.module.css' diff --git a/suite/apps/tableaux/store/sliceBridge.test.ts b/suite/apps/tableaux/store/sliceBridge.test.ts index f11ed5e..bc6236f 100644 --- a/suite/apps/tableaux/store/sliceBridge.test.ts +++ b/suite/apps/tableaux/store/sliceBridge.test.ts @@ -70,16 +70,34 @@ test("a guest with no name at all is still listed rather than dropped", () => { test("every other field on the guest survives untouched", () => { withGuests({ - g1: { id: "g1", firstName: "Tobias", lastName: "Wright", dietary: "Vegetarian", tags: ["usher"] }, + g1: { id: "g1", firstName: "Tobias", lastName: "Wright", dietary: "vegetarian", tags: ["usher"], assignedSeatId: "seat-2" }, }); expect(readDoc().guests["g1"]).toMatchObject({ id: "g1", - dietary: "Vegetarian", + dietary: "vegetarian", tags: ["usher"], + assignedSeatId: "seat-2", }); }); +/** + * The old Data-panel importer stored the file's words as the diet. Seating's + * filters and badges read a key, so "Vegetarian" matched nothing and "None" + * was counted as a diet. Read through the suite's one definition of a guest, + * those become the key they mean, and the words are kept as what was said. + */ +test("a diet stored as the file's words arrives as the key it means", () => { + withGuests({ + g1: { id: "g1", firstName: "Beatrix", lastName: "Lindqvist", dietary: "Vegetarian" }, + g2: { id: "g2", firstName: "Priya", lastName: "Castellanos", dietary: "None" }, + }); + + const { guests } = readDoc(); + expect(guests["g1"]).toMatchObject({ dietary: "vegetarian", dietaryRaw: "Vegetarian" }); + expect(guests["g2"]).toMatchObject({ dietary: "", dietaryRaw: "None" }); +}); + /** * What the Data panel writes while Seating is open must outlive Seating's next * autosave. diff --git a/suite/apps/tableaux/store/sliceBridge.ts b/suite/apps/tableaux/store/sliceBridge.ts index 68f890e..87bc059 100644 --- a/suite/apps/tableaux/store/sliceBridge.ts +++ b/suite/apps/tableaux/store/sliceBridge.ts @@ -1,4 +1,5 @@ import { eventSchema } from '@jfrusher/trousseau' +import { coerceGuests } from '@/lib/model/slices' import { mayWrite, noteRead } from '@/lib/store/toolGeneration' import { useTrousseauStore } from '@/lib/store/useTrousseauStore' import type { Guest, TableEntity } from './planSchema' @@ -134,7 +135,10 @@ export function readDoc(): TableauxDoc { noteRead('tableaux') const { raw, doc } = useTrousseauStore.getState() const seating = isRecord(raw.seating) ? raw.seating : {} - const guests = named(isRecord(raw.guests) ? (raw.guests as Record) : {}) + // Through the suite's one definition of a guest, which keeps every field it + // has no opinion about and puts the dietary fields in the shape the filters + // and badges here expect. + const guests = named(coerceGuests(raw.guests) as unknown as Record) const meta = isRecord(seating.meta) ? seating.meta : {} return { diff --git a/suite/apps/tableaux/store/store.test.js b/suite/apps/tableaux/store/store.test.js index 4b92cec..3613354 100644 --- a/suite/apps/tableaux/store/store.test.js +++ b/suite/apps/tableaux/store/store.test.js @@ -177,101 +177,6 @@ describe('groups', () => { }) }) -describe('import', () => { - it('replace strategy resets the guest list', () => { - s().importGuests( - [{ firstName: 'New', lastName: 'Person', fullName: 'New Person', rsvpStatus: 'confirmed' }], - 'replace' - ) - expect(Object.keys(s().guests)).toHaveLength(1) - expect(Object.values(s().guests)[0].fullName).toBe('New Person') - }) - - it('is undoable and leaves earlier history intact', () => { - s().addTable({ type: 'round', x: 3, y: 3 }) // a pre-import edit - expect(countTables()).toBe(2) - - s().importGuests([{ firstName: 'New', lastName: 'Person' }], 'replace') - expect(Object.keys(s().guests)).toHaveLength(1) - - s().undo() // undo the import itself - expect(Object.keys(s().guests).sort()).toEqual(['g1', 'g2']) - - s().undo() // the pre-import edit is still on the stack - expect(countTables()).toBe(1) - }) - - it('keeps grouping, tags and plus-ones when re-importing over a guest', () => { - useStore.setState({ - guests: { - ...s().guests, - g1: { - ...s().guests.g1, - email: 'a@x.com', - subgroupId: 'sg1', - familyId: 'fam1', - plusOneOf: 'g2', - tags: ['top table'], - }, - }, - }) - s().assignGuest('g1', 't1') - - s().importGuests( - [{ firstName: 'A', lastName: 'X', fullName: 'A X', email: 'a@x.com', rsvpStatus: 'declined' }], - 'update' - ) - - const g1 = s().guests.g1 - expect(g1.rsvpStatus).toBe('declined') // the CSV still wins on its own fields - expect(g1.subgroupId).toBe('sg1') - expect(g1.familyId).toBe('fam1') - expect(g1.plusOneOf).toBe('g2') - expect(g1.tags).toEqual(['top table']) - expect(g1.assignedTableId).toBe('t1') - }) - - it('refuses to guess between two existing guests sharing a name', () => { - // Two "A X"s: an update import must not silently edit whichever the index - // happened to write last. - useStore.setState({ - guests: { ...s().guests, g2: { ...s().guests.g2, firstName: 'A', lastName: 'X', fullName: 'A X' } }, - }) - - s().importGuests([{ firstName: 'A', lastName: 'X', fullName: 'A X', notes: 'from csv' }], 'update') - - expect(s().guests.g1.notes).toBe('') - expect(s().guests.g2.notes).toBe('') - expect(Object.keys(s().guests)).toHaveLength(3) // landed as a visible new row - }) - - it('does not let two incoming rows claim the same existing guest', () => { - s().importGuests( - [ - { firstName: 'A', lastName: 'X', fullName: 'A X', notes: 'first' }, - { firstName: 'A', lastName: 'X', fullName: 'A X', notes: 'second' }, - ], - 'update' - ) - expect(s().guests.g1.notes).toBe('first') - expect(Object.keys(s().guests)).toHaveLength(3) - }) - - it('restores seating and groups when a replace import is undone', () => { - s().assignGuest('g1', 't1') - s().createGroup(['g1', 'g2'], { name: 'Fam' }) - - s().importGuests([{ firstName: 'New', lastName: 'Person' }], 'replace') - expect(Object.keys(s().groups)).toHaveLength(0) - expect(s().tables.t1.assignedGuestIds).toHaveLength(0) - - s().undo() - expect(Object.keys(s().groups)).toHaveLength(1) - expect(s().guests.g1.assignedTableId).toBe('t1') - expect(s().tables.t1.assignedGuestIds).toContain('g1') - }) -}) - describe('snapshots', () => { it('captures and restores a point-in-time copy', () => { s().addTable({ type: 'round', x: 1, y: 1 }) // now 2 tables diff --git a/suite/apps/tableaux/store/useStore.js b/suite/apps/tableaux/store/useStore.js index d1a2a17..ceffd32 100644 --- a/suite/apps/tableaux/store/useStore.js +++ b/suite/apps/tableaux/store/useStore.js @@ -213,26 +213,6 @@ const ensureFamilyShape = (f, id) => ({ memberIds: Array.isArray(f.memberIds) ? f.memberIds : [], }) -const ensureGuestShape = (g, id) => ({ - id, - firstName: g.firstName || '', - lastName: g.lastName || '', - fullName: g.fullName || `${g.firstName || ''} ${g.lastName || ''}`.trim(), - email: g.email || '', - dietary: g.dietary || '', - dietaryRaw: g.dietaryRaw || '', - side: g.side ?? null, - rsvpStatus: g.rsvpStatus || 'confirmed', - plusOneOf: g.plusOneOf ?? null, - groupId: g.groupId ?? null, - subgroupId: g.subgroupId ?? null, - familyId: g.familyId ?? null, - assignedTableId: g.assignedTableId ?? null, - assignedSeatId: g.assignedSeatId ?? null, - notes: g.notes || '', - tags: Array.isArray(g.tags) ? g.tags : [], -}) - export const useStore = create( withHistory( (set, get) => { @@ -295,129 +275,6 @@ export const useStore = create( if (!entity) return get()._touch({ [collection]: { ...coll, [id]: { ...entity, ...patch } } }) }, - // ── CSV import (replaces / merges the guest list) ────────────────── - importGuests: (incoming, strategy = 'replace') => { - const state = get() - const existing = state.guests - const byEmail = {} - const byName = {} - Object.values(existing).forEach((g) => { - if (g.email) byEmail[g.email.toLowerCase()] = g - if (g.fullName) { - const key = g.fullName.toLowerCase() - ;(byName[key] = byName[key] || []).push(g) - } - }) - - // Email is the only reliable key. A name shared by two existing - // guests (two "Sarah Smith"s — plausible at a wedding) is ambiguous, - // so it matches nothing rather than guessing: the row lands as a new - // guest the couple can see and merge, instead of silently editing the - // wrong person. `used` stops two incoming rows claiming one guest. - const used = new Set() - const findMatch = (g) => { - const byMail = g.email && byEmail[g.email.toLowerCase()] - if (byMail) return used.has(byMail.id) ? null : byMail - const named = byName[(g.fullName || '').toLowerCase()] - if (!named || named.length !== 1) return null - return used.has(named[0].id) ? null : named[0] - } - - let guests - let extra = {} - let extraInverse = {} - - // TODO(family-ux): 'replace' resets groups/tables below but leaves - // subgroups AND families untouched — every incoming guest gets a - // fresh id, so old subgroup/family memberIds end up pointing at - // guest ids that no longer exist. Pre-existing gap for subgroups; - // family inherits it. See tmp/family-ux-followups.md #4. - if (strategy === 'replace') { - guests = {} - incoming.forEach((g) => { - const id = makeId('g') - guests[id] = ensureGuestShape(g, id) - }) - // Drop now-invalid assignments and group memberships. - const tables = {} - Object.entries(state.tables).forEach(([tid, t]) => { - tables[tid] = { - ...t, - assignedGuestIds: [], - } - }) - // Patches merge by id, so "drop every group" means nulling each one. - const groups = {} - Object.keys(state.groups).forEach((gid) => { - groups[gid] = null - }) - extra = { groups, tables } - extraInverse = { groups: state.groups, tables: state.tables } - } else if (strategy === 'add') { - guests = { ...existing } - incoming.forEach((g) => { - const match = findMatch(g) - if (match) { - used.add(match.id) - return - } - const id = makeId('g') - guests[id] = ensureGuestShape(g, id) - }) - } else { - // update existing (by email then name), add the rest - guests = { ...existing } - incoming.forEach((g) => { - const match = findMatch(g) - if (match) { - used.add(match.id) - guests[match.id] = { - ...ensureGuestShape(g, match.id), - // A CSV row carries none of the structure built up in the - // app, so ensureGuestShape would blank it all. "Update" - // promises to keep seating — that has to mean the grouping, - // tags and plus-one links too, not just the table. - assignedTableId: match.assignedTableId, - assignedSeatId: match.assignedSeatId, - groupId: match.groupId, - subgroupId: match.subgroupId, - familyId: match.familyId, - plusOneOf: match.plusOneOf, - tags: match.tags, - } - } else { - const id = makeId('g') - guests[id] = ensureGuestShape(g, id) - } - }) - } - - // The whole import is one undoable command. Guests dropped by a - // 'replace' must be nulled explicitly, since entity patches merge by - // id rather than replacing the collection. - const guestsPayload = {} - const guestsInverse = {} - Object.keys(existing).forEach((id) => { - guestsPayload[id] = null - }) - Object.entries(guests).forEach(([id, g]) => { - guestsPayload[id] = g - }) - Object.keys(guests).forEach((id) => { - guestsInverse[id] = null - }) - Object.entries(existing).forEach(([id, g]) => { - guestsInverse[id] = g - }) - - get().dispatch({ - type: 'IMPORT_GUESTS', - label: 'Import guests', - payload: { guests: guestsPayload, ...extra }, - inverse: { guests: guestsInverse, ...extraInverse }, - }) - return Object.keys(guests).length - }, // ── snapshots (kept in the document, persisted via normal save) ──── diff --git a/suite/apps/tableaux/utils/csv.js b/suite/apps/tableaux/utils/csv.js deleted file mode 100644 index ceaecce..0000000 --- a/suite/apps/tableaux/utils/csv.js +++ /dev/null @@ -1,92 +0,0 @@ -/** - * A small, dependency-free CSV reader. Handles quoted fields, escaped quotes - * (""), embedded commas/newlines, and CRLF or LF endings. - * - * This ran on Tableaux's server, which is why the import used to be an upload. - * It is the same code, moved rather than rewritten: it is the only thing that - * knows a stray quote should be an error naming the line rather than a file - * silently swallowed into one giant record, and the import flow shows that - * message. A guest list is also exactly the kind of thing that should not need - * to leave the device to be read. - */ - -/** - * @throws if a quoted field is never closed. A stray `"` in a pasted notes - * field otherwise swallows the rest of the file into one giant record, which - * previews as plausible-looking but silently wrong data — far worse than a - * message telling the user which line to fix. - */ -export function parseCsv(text) { - const clean = String(text).replace(/^\uFEFF/, '') // strip BOM - const rows = [] - let field = '' - let record = [] - let inQuotes = false - let quoteOpenedAtLine = 0 - let line = 1 - - for (let i = 0; i < clean.length; i++) { - const ch = clean[i] - if (ch === '\n') line++ - if (inQuotes) { - if (ch === '"') { - if (clean[i + 1] === '"') { - field += '"' - i++ - } else { - inQuotes = false - } - } else { - field += ch - } - } else if (ch === '"') { - inQuotes = true - quoteOpenedAtLine = line - } else if (ch === ',') { - record.push(field) - field = '' - } else if (ch === '\n') { - record.push(field) - rows.push(record) - record = [] - field = '' - } else if (ch === '\r') { - // swallow; \r\n handled by the \n branch - } else { - field += ch - } - } - if (inQuotes) { - const err = new Error( - `Unterminated quote starting on line ${quoteOpenedAtLine}. Check for a stray " in that row.` - ) - err.status = 400 - throw err - } - - if (field.length > 0 || record.length > 0) { - record.push(field) - rows.push(record) - } - - if (rows.length === 0) return { headers: [], rows: [] } - - const headers = rows[0].map((h) => h.trim()) - const dataRows = rows - .slice(1) - .filter((r) => r.some((c) => c.trim() !== '')) // skip blank lines - .map((r) => { - const obj = {} - // TODO(ux-audit): duplicate column headers (e.g. two "Notes" columns - // from a merged export) silently overwrite here: the second write wins - // for every row, no dedup/rename, no warning. The client's mapping - // dropdown also renders indistinguishable duplicate