diff --git a/docs/PRODUCT-ROADMAP.md b/docs/PRODUCT-ROADMAP.md index e0a1948a..a95edaf9 100644 --- a/docs/PRODUCT-ROADMAP.md +++ b/docs/PRODUCT-ROADMAP.md @@ -52,6 +52,7 @@ instead (see subsystem F). | 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 | +| P | The toolbox, and travel, ceremony, boxes and bar | L, O | ✅ **built** — [spec](superpowers/specs/2026-09-29-toolbox-and-new-tools-design.md), plans for [the toolbox](superpowers/plans/2026-09-29-toolbox.md), [travel and calendars](superpowers/plans/2026-09-29-timeline-travel-and-calendars.md), [the cast and Ceremony](superpowers/plans/2026-09-29-cast-and-ceremony.md), [Boxes](superpowers/plans/2026-09-29-boxes.md) and [the Bar](superpowers/plans/2026-09-29-bar.md), 2026-09-29; the proposals joining tools to each other wait on the maintainer | ## Decisions log diff --git a/docs/superpowers/plans/2026-09-29-bar.md b/docs/superpowers/plans/2026-09-29-bar.md new file mode 100644 index 00000000..6ce37f0a --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-bar.md @@ -0,0 +1,146 @@ +# Bar Implementation Plan + +**Goal:** A couple buying their own drinks — a marquee, a barn, a venue that +charges corkage — learn how much of each to buy, in the units a UK shop sells +it in, and what it will roughly cost. Every figure is on screen, can be +changed, and can be put back. + +**Architecture:** A slice of its own, `bar`, holding only what the couple +chose: the kind of bar, the crowd, and each figure they changed from its +default. Nothing the calculator works out is stored: who is coming is read +live from the guest list, and the amounts are worked out from that and the +figures every time, so a guest saying no changes the wine. Merged as one part, +as Ceremony is. Built as Boxes was: pure sums, a page that reads and writes the +wedding on its one history, a tool off until added. + +**Tech Stack:** TypeScript, the suite's kit, pdf-lib for the shopping list. +**No new dependencies.** + +**Spec:** [Phase 4 of the toolbox spec](../specs/2026-09-29-toolbox-and-new-tools-design.md#phase-4--bar) + +## The sum + +1. **Who.** The people coming are the guests who have not said no, or a + number the couple types instead (and the page says so while it is typed). + Evening-only guests are added for the evening. A share of everyone is not + drinking alcohol — children, drivers, anyone who does not — and drinks soft + drinks instead, at the same rate. +2. **Four parts of the day**, each drinks per head of those drinking: + the drinks reception (hours × drinks an hour), the toast (glasses), + the meal (glasses of wine), the evening bar (hours × drinks an hour). + A part the couple is not buying for — a venue's paying evening bar — is + set to nothing. +3. **What is poured in each part.** The toast is fizz and the meal is wine, + split red and white. The reception and the evening pour a mix — fizz, + wine, beer, spirits — as shares, set by the kind of bar and each one + changeable. +4. **A lighter or heavier crowd** takes a fifth off or puts a fifth on, except + the toast, which is one glass whoever is raising it. +5. **Into what a shop sells**: glasses to 75cl bottles by the glass size, beer + and cider one bottle or can a drink in cases of 24, spirits to 70cl + bottles by the measure, mixers and soft drinks to litres, ice to kilos. +6. **Less what they already have**, then rounded up to whole cases when they + are buying on sale or return (fizz and wine in sixes, beer in 24s). +7. **The spend**: each line priced by the couple, per bottle, case, litre or + kilo; the lines with no price are left out of the estimate and it says so. + +## The kinds of bar + +| Kind | Reception | Evening | +|---|---|---| +| Full bar | fizz 70%, beer 30% | beer 40%, wine 40%, spirits 20% | +| Beer and wine | fizz 70%, beer 30% | beer 50%, wine 50% | +| Signature cocktails, beer and wine | cocktails 70%, beer 30% | beer 40%, wine 40%, cocktails 20% | +| No and low | as beer and wine, every line alcohol-free, no spirits | | + +A cocktail is bought as a spirit measure and a mixer. Choosing a kind puts the +mix back to that kind's; it is one step on the history. + +## The defaults + +**Agreed by the maintainer, 2026-09-29, before building**, as they stand: + +| Figure | Default | Why | +|---|---|---| +| Not drinking alcohol | 20% of guests | Roughly one UK adult in five does not drink, and children and drivers are in the count. | +| Evening-only guests | 0 | Typed by the couple. | +| Drinks reception | 2 hours × 1.5 an hour = 3 each | UK guidance: two in the first hour, one an hour after. | +| Toast | 1 glass | Universal. | +| The meal | 2 glasses of wine (175ml) — just under half a bottle | "Half a bottle a head" is the UK rule of thumb. | +| Evening bar | 4 hours × 1 an hour | "One drink an hour" after the first. | +| Red of the wine | 50% | | +| Fizz glass | 125ml — 6 to a bottle | | +| Wine glass | 175ml | The UK's standard medium glass. | +| Spirit measure | 25ml — 28 to a 70cl bottle | The UK's single measure. | +| Mixer | 150ml a spirit or cocktail | | +| Soft drink | 250ml | | +| Ice | 1kg a person | UK ice suppliers: 1kg a head for drinks, 1.5kg to chill bottles too. | +| Crowd | Usual; lighter −20%, heavier +20% | | +| Whole cases | On | Sale or return costs nothing to round up. | +| Where bought | Fizz and wine: wine merchant. Beer and spirits: cash and carry. Mixers, soft drinks, ice: supermarket. | | +| Prices | None until typed | Prices date quickly and differ by shop. | + +For 100 coming with the defaults: 42 bottles of fizz, 36 white and 36 red, +9 cases of beer, 3 bottles of spirits, 10 litres of mixers, 50 of soft drinks, +100kg of ice. + +## 4a — The sum and the page + +- [x] `bar` joins the contract's slices: `{ kind, crowd, people, figures, + mix, lines, wholeCases }`, only what the couple chose. Merged as one part. +- [x] Pure sums (`lib/bar/sum.ts`): who, the parts, the lines, the spend; + every default in one table (`lib/bar/defaults.ts`). +- [x] Pure actions: change a figure, put it back, choose a kind (resets the + mix), a line's price, what they have, where it is bought. +- [x] The page, `/bar`: the figures on the left, what to buy on the right. + Each changed figure shows its default and puts it back. Registered as a + tool, off until added. The example wedding has a bar. +- [x] The front page's area: what is bought for how many, and the estimate. + +## 4b — On paper + +- [x] The shopping list, grouped by where each thing is bought, as a PDF and + as CSV. It joins the wedding pack. + +## 4c — Planners + +- [x] The library keeps bar settings: the kind, the crowd, the figures, the + mix, the prices and where each is bought — no guest count and nothing a + couple already has. Using it replaces the wedding's settings. A migration + widens the database's kinds, proved against PGlite. + +## Not in this phase + +The proposals in the spec that join Bar to other tools — hours from the +Timeline's blocks, the spend in Money, "buy the drinks" on the Checklist, +crates as Boxes — each wait for the maintainer to accept them. + +## Status + +**Complete — 4a, 4b and 4c, 2026-09-29.** 1,865 suite tests and 116 in the +contract package, typecheck and build clean, all 109 Playwright tests green. +No new dependencies. **One migration**, `20260929000006_library_bar`, widening +the library's kinds again: apply it, with Phases 2 and 3's, before deploying. + +**What executing it found.** + +- The guest list does not say who is invited for the evening only, which a UK + wedding's evening bar depends on, so evening-only guests are a figure of the + Bar's, typed, drinking in the evening and not before. +- The amounts exist for every wedding with guests, whether or not it uses the + Bar, so the wedding pack prints the drinks only while the Bar is shown — + otherwise every pack would have gained a page nobody asked for. +- A figure's field and a price's needed decimals the whole-number field did + not allow; it became one `NumberInput` with a step, used by Money, Boxes, + the Timeline's journeys and the Bar, rather than a second field. +- Putting a figure back and changing it shared a label, and the history + folds same-labelled edits made within 700ms into one step, so undoing the + one undid both; putting back is its own step now. +- The panel headings came before any heading of their level; the figures + have a heading of their own, as What to buy does. +- The page, the shopping list and the CSV say "42 bottles, 7 cases of 6" from + one function, so they cannot disagree. +- As built, where it differs from the spec: the toast is fizz and the meal + wine, with only the reception and the evening pouring a mix; a cocktail is + bought as a spirit measure and a mixer; prices start empty; and "no and + low" is a kind of bar, naming every drink alcohol-free. diff --git a/docs/superpowers/plans/2026-09-29-boxes.md b/docs/superpowers/plans/2026-09-29-boxes.md new file mode 100644 index 00000000..c1c5dc01 --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-boxes.md @@ -0,0 +1,75 @@ +# Boxes Implementation Plan + +**Goal:** The couple pack for the day in boxes: what is in each, found in a +moment ("where are my shoes?"), and each box tied to the part of the day it is +needed for — "this box has my shoes in it and needs to be at the house for 9". + +**Architecture:** A slice of its own, `boxes`, merged box by box as guests and +jobs are, since two people pack at once. A box's where and when are its +block's — the location and the resolved start — read from the Timeline, never +typed onto the box, so moving the block moves the box. Who takes it is people +from the crew, named as Delegation names them. Built as Ceremony was: pure +actions, a page that reads and writes the wedding on its one history, a tool +off until added. + +**Tech Stack:** TypeScript, the suite's kit, pdf-lib for the labels and the +list. **No new dependencies.** + +**Spec:** [Phase 3 of the toolbox spec](../specs/2026-09-29-toolbox-and-new-tools-design.md#phase-3--boxes) + +## 3a — The boxes + +- [x] `boxes` joins the contract's slices: `{ boxes: Box[] }`, a box being a + number, a name, items (a label, how many, packed or not), the block it is + needed for or none, who takes it, and notes. Merged per box. +- [x] Pure actions: add, change, remove a box; add, change, tick, remove and + move an item to another box. Numbers go on from the highest. +- [x] *Add the usual boxes*, UK first, matched by name so it never doubles up: + the rings and paperwork, getting ready, the day's odds and ends, overnight. +- [x] Finding: one search over every box's items and names. +- [x] Checks. What is left: a box needed for a block the day no longer has; + things still to pack in the last week. The page: a box nobody is taking, and + somebody taking it who has left the crew. +- [x] The page, `/boxes`, and the front page's area. Registered as a tool, off + until added. The example wedding packs for its day. + +## 3b — On paper + +- [x] A label for each box, four to a sheet: the number large, the name, where + and by when, who is taking it, and what is in it. +- [x] The packing list, every box and every item with a box to tick, as a PDF + and as CSV. The list joins the wedding pack. + +## 3c — Planners + +- [x] The library keeps a set of boxes: their names and what goes in each, + without who takes them or when they are needed. The database's kinds are + widened by a migration, proved against PGlite. + +## Status + +**Complete — 3a, 3b and 3c, 2026-09-29.** 1,840 suite tests and 113 in the +contract package, typecheck and build clean, all 103 Playwright tests green. +No new dependencies. **One migration**, `20260929000005_library_boxes`, +widening the library's kinds again: apply it, with Phase 2's, before +deploying. + +**What executing it found.** + +- A box's where and when are its block's, read through `dayPlaces` — the + timeline's blocks with their resolved starts — so nothing about a place or + a time is stored on a box, and a block moved moves every box needed at it. +- What is left had a local `places` of its own (the room's space names), which + the new helper's first name shadowed; the compiler said so, and the helper + is `dayPlaces`. +- "Nobody is taking it" is a problem only for a box with somewhere to be; the + honeymoon bag, not for the day, is the couple's own. +- Delegation named a crew member who is a guest by the guest list, inline; + Boxes needed the same rule, so it is one `personName` both use. +- The slice merges box by box, as guests and jobs do, proved by two people + ticking in two boxes and both ticks surviving the merge. +- As built, where it differs from the spec: a thing moves to another box from + a menu on its row rather than by dragging — reachable from a keyboard, and + one way to do it; items have no notes of their own, the box has them; and a + library set of boxes adds what a wedding has none of by name, as the usual + boxes and the checklist do, rather than replacing its boxes. diff --git a/docs/superpowers/plans/2026-09-29-cast-and-ceremony.md b/docs/superpowers/plans/2026-09-29-cast-and-ceremony.md new file mode 100644 index 00000000..bf9d1301 --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-cast-and-ceremony.md @@ -0,0 +1,90 @@ +# One Cast, and Ceremony Implementation Plan + +**Goal:** The people a wedding is built around — the couple, their parents +and grandparents, their wedding parties, and roles of the couple's own +devising — are recorded once and shared by Group shots and a new Ceremony tool, +which plans the processional: who walks, in what order, how, and to what music. + +**Architecture:** The cast leaves the `shots` slice for a slice of its own, +`cast`, since two tools now edit it and a tool writes only its own slice. It is +moved by the load-time pass that already converts older shapes, and read from +either place until then, so a document the server reads before any browser +has loaded it still has its cast. Ceremony is built as Group shots was: a +slice, `ceremony`; pure actions over it; a page that reads and writes the +wedding directly, on its one history. A processional group's members are the +same kinds a shot's are, resolved by the same code. + +**Tech Stack:** TypeScript, the suite's kit, pdf-lib for the page. **No new +dependencies.** + +**Spec:** [Phase 2 of the toolbox spec](../specs/2026-09-29-toolbox-and-new-tools-design.md#phase-2--one-cast-and-ceremony) + +## 2a — One cast + +- [x] `cast` joins the contract's slices. `CastSlice { roles: Cast; customRoles }`. +- [x] `readCast(doc)`: the `cast` slice, or — for a document not yet converted — + the cast and custom roles still inside `shots`, old role names included. + `Shots` keeps only its sections. +- [x] The load-time pass writes the cast to its own slice and takes it out of + `shots`, silently, as it converts "bride" and "groom". +- [x] Each partner's grandparents join the fixed roles, as a party role. +- [x] Group shots reads and writes the cast there; "Who's who" edits `cast`. + Readiness, the front page, the prints and the validator follow. + +## 2b — Ceremony + +- [x] `ceremony` joins the contract's slices: `{ processional: WalkGroup[] }`, + a group being a label, members (the shot's member kinds), how they walk + (alone, in pairs, in threes), which side they go to, and a cue — the music, + and when it changes. +- [x] Pure actions: add, change, move, remove a group; add and remove members. +- [x] *Suggest an order* from the cast: the officiant, grandparents, parents, + the wedding parties, then the couple — a starting point, every part of it + editable, and nothing in it assuming who walks with whom. +- [x] Checks: somebody walking who has declined; a role nobody has been cast + in. What is left and the front page say so, when Ceremony is shown. +- [x] The page, `/ceremony`: the order on the left, the picked group on the + right, with the member picker Group shots uses (moved to be shared). + Registered as a tool, off until added. +- [x] The example wedding gets a processional, since it shows every tool. +- Not done, on purpose: a tour chapter. The tour is held to under thirty steps + and has twenty-nine; Ceremony's list carries the anchor one would point at + (`ceremony.order`) for when a chapter elsewhere is trimmed. +- The resolver both tools use moved to `lib/cast/resolve` as `resolveMembers`, + taking any group with a label and members; its words no longer say "shot". + +## 2c — Paper, and planners + +- [x] One page for the officiant and whoever runs the day, and the order as + plain text to paste into an email. The page joins the wedding pack. +- [x] The library keeps a processional: its groups, roles and cues, with no + guest named. + +## Status + +**Complete — 2a, 2b and 2c, 2026-09-29.** 1,816 suite tests and 110 in the +contract package, typecheck and build clean, all 96 Playwright tests green. +No new dependencies. **One migration**, `20260929000004_library_processional`, +widening the library's kinds: apply it before deploying, as every migration. + +**What executing it found.** + +- The cast is read from either place, not only after the load pass has + moved it, because the planner's Weddings page runs What is left on the + server over stored documents nobody has opened since. +- After parsing, the contract fills an absent slice with `{}`, so "this + wedding has its own cast" is decided by what the slice holds, not by its + being there. +- Group shots' resolver was already the right shape for a processional — a + label and members — so it moved to `lib/cast/resolve` rather than being + copied; "Who's in it" became one `MemberPicker`; Money's nullable number + had already gone to the kit in Phase 1, and the guest picker went to + `components/cast`. +- The processional's page and its email text are made from one list of rows, + so they cannot disagree. +- The library's kinds are fixed in the database as well as in the code; the + migration test proves a processional is kept and an unknown kind still is + not. +- The officiant is words ("The officiant", "The registrar") rather than a + crew member: nothing yet needs the link, and words were already a member + kind. diff --git a/docs/superpowers/plans/2026-09-29-timeline-travel-and-calendars.md b/docs/superpowers/plans/2026-09-29-timeline-travel-and-calendars.md new file mode 100644 index 00000000..e79d2eeb --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-timeline-travel-and-calendars.md @@ -0,0 +1,99 @@ +# Timeline: Travel and Calendars Implementation Plan + +**Goal:** The couple types how long it takes to get between two places, and +the Timeline says when somebody is due somewhere before they could get there. +The day, or one supplier's part of it, downloads as a calendar file. + +**Architecture:** One new field on Cadence's own document, `travel`, beside +`tagDetails`. One walk over the resolved day finds every *move* — a tag at one +place, then its next block somewhere else — and both the new advisory and the +new panel read it, so what is checked and what is listed cannot disagree. The +calendar is written in the browser from the resolved day, as the PDFs are. + +**Tech Stack:** TypeScript, Cadence's core and store, the suite's field kit. +**No new dependencies.** + +**Spec:** [Phase 1 of the toolbox spec](../specs/2026-09-29-toolbox-and-new-tools-design.md#phase-1--timeline-travel-between-places-and-calendars) + +## Decided while planning, from the code + +- **Who moves is a tag, not a lane.** The spec said "a lane or a tag". The + example wedding says otherwise: its *Suppliers* lane holds the florist, the + caterer and the band, and *Transport* holds the cars and the guest coach — + a lane is a kind of activity, not a party, and checking a lane would say the + coach cannot get from where the cars were. Tags are already who is where: + the double-booking check reads them and nothing else, so the couple is only + ever caught being in two places at once if their blocks carry a tag. Travel + follows the same rule, and the panel says so: tag your own blocks — "couple" + — to have your own journeys checked. +- **A block ends where the run sheet says it does**, `contentEndMin`, before + its contingency buffer. Hair and make-up printed as ending at 13:00, with the + first look at 13:05 and fifteen minutes away, is ten minutes short; measured + from after a buffer, it would read as an overlap and be skipped. +- **Overlaps are not travel's to report.** A tag in two places at once is + already a double booking. +- **Places match as the couple would**: trimmed, and regardless of case. +- **A calendar is refused while the day has clashes**, for the reason the PDFs + are. The travel advisory never blocks anything. +- **A calendar is on the wedding's date, in the venue's local time.** Found + while building task 4, so recorded here and in the spec: the timeline's own + date is a placeholder (20 June 2026) until the wedding has one, and its UTC + offset reads as British Summer Time until somebody sets it — the Day panel + shows that fallback as if it were chosen, and choosing it again changes + nothing. UTC built on that would put a winter wedding an hour out. Local + times with no zone are right whatever the offset. + +## Tasks + +- [x] **1. The field.** `Journey { between: [string, string]; minutes }` and + `TimelineDoc.travel`; `emptyDoc` gives `[]`; `readTimeline` coerces it, + dropping anything without two places and a positive time. Let the compiler + list every other place a `TimelineDoc` is built. +- [x] **2. The walk and the check** (`core/schedule/travel.ts`): `moves`, + `journeyMinutes`, and the `no-travel-time` advisory, one per pair of blocks + naming every tag making that move. Joined into `conflicts()`, so the screen, + the warnings list and the announcer have it with no change of their own. +- [x] **3. The panel.** *Travel*, after *Tags*: every pair of places the day + moves between, each with its minutes (blank: not checked), and journeys the + day no longer makes, to forget. One store action, `setJourney`, one undo + step. +- [x] **4. Calendars** (`render/ics/calendar.ts`): RFC 5545 — CRLF, lines + folded at 75 octets without splitting a character, text escaped, times as + local times on the wedding's date (past midnight included), an end only + where there is a length, a UID stable per block and particular to the + wedding. The export bar gains the whole day or one tag, and *Download + calendar*. +- [x] **5. End to end.** Type a journey, see the advisory; download a + calendar and read it. + +## Status + +**Complete — all 5 tasks, 2026-09-29.** 1,785 suite tests and 102 in the +contract package, typecheck and build clean, all 90 Playwright tests green. +No new dependencies. + +**What executing it found.** + +- The travel list and the advisory are one walk (`moves`), so the panel lists + exactly the journeys that are checked. On the example wedding it lists the + rooms the photographer and the caterer move between; a blank is right for + two rooms next door, and nothing is flagged until a time is typed. +- Money already had the control the panel needed — a whole number or nothing, + written on leaving the field — as a private function. It moved to the kit as + `WholeNumberInput`, taking its styling from the caller, and both use it. +- The compiler found the three places a `TimelineDoc` is built; a drift test + found the fourth, the sample day kept on disk beside `sampleDoc()`. +- It also found a mistake in the previous commit: the font-engine guard used a + regular-expression flag the suite's target does not allow. The flag did + nothing — `[^;]` already crosses lines — and is gone; the guard was proved + again to fail with the old import put back. + +**Fixed after, at the maintainer's request: the clocks.** Reproduced first: a +wedding with no offset, one block added in Timeline, and the event said 60 — +`writeSlice` echoes the timeline's offset into `event` on every edit, and the +timeline's offset was the BST fallback. The offset is now `number | null` in +Cadence's day, the contract's published day and Delegation's reader, with no +fallback anywhere; the Day panel offers *Not set*; `sunForDay` gives nothing +without clocks, so no sunset or golden-hour advisory is worked out from a +guess. The Binder's own rule — the phone's clock when the wedding has none — +now gets to apply. diff --git a/docs/superpowers/plans/2026-09-29-toolbox.md b/docs/superpowers/plans/2026-09-29-toolbox.md new file mode 100644 index 00000000..fe42d6c5 --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-toolbox.md @@ -0,0 +1,71 @@ +# The Toolbox Implementation Plan + +**Goal:** Let a wedding add and remove tools, the choice shared by everyone on +it: the five on by default, each removable, and new tools added from a +slide-over at the right edge. + +**Architecture:** One new slice, `tools` — `{ shown: string[] }` — in the +contract, so it syncs and merges as a part of its own. The registry gains ids; +one cached reader says which tools are shown; everything that listed the five +lists the shown ones instead. Nothing a removed tool made is touched. + +**Tech Stack:** TypeScript, Next.js (suite), Zustand, the kit's `SlideOver`. +**No new dependencies.** + +**Spec:** [docs/superpowers/specs/2026-09-29-toolbox-and-new-tools-design.md](../specs/2026-09-29-toolbox-and-new-tools-design.md) + +## Tasks + +- [x] **1. Contract.** `tools` joins `SLICE_NAMES` and `trousseauSchema` as a + `looseObject` slice, like `shots`. `envelope.test.ts` names it; + `preservation.test.ts` covers it through `SLICE_NAMES` unchanged. +- [x] **2. Registry and reader.** `Tool` gains `id`. `lib/model/toolbox.ts` + has `shownTools(doc)` (cached per document; the five when nothing is stored; + unknown ids ignored) and `withTool(raw, id, shown)`, which writes the list + keeping ids this build does not know. Tests: default, order, unknown ids + kept on write, referential stability in `selectors.test.ts`. +- [x] **3. Everything that listed the five.** The header's tabs; `overview`'s + areas; `readiness`'s items by where their fix is; the tour's walk; the + palette's pages. Tests on `overview`, `readiness` and `entries` with a tool + removed. +- [x] **4. The panel.** `components/shell/ToolsPanel.tsx` on `SlideOver`, + opened by the header's **+** and by `?panel=tools`; Add and Remove, each one + labelled step on the history. `describe.ts` names the part for Sync & + history. +- [x] **5. End to end.** Remove a tool: its tab goes, the change survives a + reload, undo brings it back. Axe on the open panel. + +## Status + +**Complete — all 5 tasks, 2026-09-29.** 1,752 suite tests and 102 in the +contract package, typecheck and build clean, all 85 Playwright tests green — +the 1024px header check included, with the **+** now in the row. No new +dependencies. + +**What executing it found.** The compiler found nothing to change when the +ninth slice arrived: nothing in the suite enumerated the slices except the +merge, and the merge was already right about a slice absent from a stored +document (no part, nothing invented). The work was entirely in the five places +that listed the tools' work: the header and the palette, which read `TOOLS`; +the front page's areas, What is left and the tour, each with its own list +keyed by the same ids, which is why a tool's id is its address. + +Sync & history's open state and the new panel's were the same thirty lines, so +the `?panel=` addressing moved into one factory, `addressablePanel`, which both +use. The store refuses a write before the wedding is read, so the panel's +buttons are off until then rather than silently doing nothing. + +**Second round, the same day** (spec decisions 16–19). Guests became the +header's first tab and never leaves it; Money, Checklist and Binder became +tools, off until added, and the menu under the wedding's name kept only +Overview. `Tool` gained `defaultOn`. Four readiness tests and the fixture's +front-page counts fell silent at once, which was the change working: a +wedding without Money is not told about balances. They now show every tool +explicitly, beside a test that pins the silence. + +Two things the gate found. The header test at 1024px, with the example +wedding showing all eight tools, put four of Timeline's controls out of +sight: measured, the tabs took 392px and left the controls 244 of 323. The +controls now never shrink below what the tool put there, and the tabs scroll +instead. And Binder, now a tool, has no header of its own to test, being a +page for a phone. 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 0d672caf..93aeaa2d 100644 --- a/docs/superpowers/specs/2026-09-28-expansion-master-plan.md +++ b/docs/superpowers/specs/2026-09-28-expansion-master-plan.md @@ -169,6 +169,12 @@ thing on a screen is the thing that needs a decision. controls (Timeline's zoom, Fit day and Present) do not fit and scroll out of sight inside it. + **Superseded 2026-09-29** by the + [toolbox spec](2026-09-29-toolbox-and-new-tools-design.md) (decisions 17–19): + Guests is the header's first tab, Money, Checklist and Binder are tools a + wedding adds from the toolbox, and only Overview stays under the wedding's + name. + ## Signing in safely The rule: **nothing is replaced without a restorable copy, and nothing is diff --git a/docs/superpowers/specs/2026-09-29-toolbox-and-new-tools-design.md b/docs/superpowers/specs/2026-09-29-toolbox-and-new-tools-design.md new file mode 100644 index 00000000..1ee93171 --- /dev/null +++ b/docs/superpowers/specs/2026-09-29-toolbox-and-new-tools-design.md @@ -0,0 +1,265 @@ +# Trousseau — the toolbox, and four new pieces: travel, ceremony, boxes, bar + +Date: 2026-09-29 +Status: direction approved by the maintainer (answers recorded below). Phase 0 +is built with this spec; every later phase gets its own dated plan first. +Scope: a way to add and remove tools, and four additions proposed by the +"Modular Workspace" PRD — a processional planner, a bar calculator, a packing +tool and a timeline collision checker. + +## Why + +The PRD proposed an opt-in workspace ("only the basics until you add more") +and four modules. It was read against the suite before anything was designed, +and a good part of it turned out to exist already. + +- **Reproduced** — shown in a test, a build or a real browser. +- **Traced** — every caller read; not run. + +| # | Finding | How established | +|---|---|---| +| T1 | The PRD's "shared data bus", local-first storage and lazy loading all exist: one document with one owner per slice, IndexedDB with no account needed, and every tool behind `next/dynamic`, with dialogs mounted on first open. | Traced | +| T2 | Module 4's "hard collision" is Timeline's `tag-double-booked`: the same supplier or party in two places at once. Buffers (`bufferMin`), slack and curfew are there too. **Missing:** travel time between locations, and a calendar file. There is no `.ics` anywhere. | Traced (`apps/cadence/core/schedule/conflicts.ts`); searched | +| T3 | Module 3's assignment exists — Checklist tasks have `personIds`, `dueOn` and `status`, and people are linked to guests. Packing as the maintainer describes it does not: boxes, what is in each, and where and when each must be. | Traced | +| T4 | Module 1's people exist: Group shots' cast (each partner, their parents, their wedding party, custom roles) mapped to guests. Grandparents, readers, ring bearer and flower party are not roles. | Traced (`lib/model/types.ts`) | +| T5 | A top-level key the contract does not list survives a sync, but is assembled local-over-server with no conflict (`mergeCloudDocument.ts`, lines 107 and 163), so a partner's edit to it is silently lost on the next push. **Every new slice goes into `SLICE_NAMES`.** | Traced | +| T6 | Every page loads the same 522 KB of gzipped JavaScript; a tool's own code adds 7 KB (Checklist) to 76 KB (Seating). 144 KB of the shared part is fontkit, reached statically from the store: `useTrousseauStore → documents/assets → portableAssets → plaque/syncAssets → plaque/sliceBridge → plaque/design → template/defaults → text/fit → text/measure`. The PRD's 100 KB per module is already met; the weight is underneath every module. | Reproduced — production build, each route loaded in Chromium and its JS summed; fontkit matched by its shaper tables against `node_modules/fontkit`; the chain traced by static imports | +| T7 | Ctrl/⌘ K is the command palette. | Traced | + +## Decisions + +The maintainer's answers, 2026-09-29. Where the answer delegated the choice, +the choice made is recorded with its reason. + +| # | Question | Decision | +|---|---|---| +| 1 | The PRD against the 2026-09-28 master plan | **The master plan wins.** The header, not a left sidebar; partners' names, not "bride"; one document, not micro-frontends. | +| 2 | What is opt-in | **The five stay, on by default, and can be removed** by someone who really wants to. New tools are added from the toolbox. | +| 3 | Where "which tools" lives | **In the wedding** — the partner and the planner see the same set. | +| 4 | The toolbox | **A slide-over at the right edge, with Add.** No keyboard shortcut of its own. | +| 5 | Public calculator pages for search | **Not intended.** Everything here is inside the planning app. | +| 6 | Module 4 | **Timeline features.** Travel times are typed by the couple: no lookup, no new `connect-src`. | +| 7 | Packing | **Its own tool**: boxes, what is in each, found easily, and each box tied to a part of the day — "this box has my shoes in it and needs to be at the house for 9". | +| 8 | Processional and group shots | **They share their people.** | +| 9 | Bar | **UK first, and every number adjustable.** | +| 10 | Tools feeding each other | Delegated: **proposed below**, each for the maintainer to accept on its own. | +| 11 | The wedding pack | **New printed pieces join it.** | +| 12 | Planners | **New tools' work can be kept in the library.** | +| 13 | Where the set is stored | Delegated: **a slice of its own, `tools`.** `event` is the wedding's who, where and when, and merges as one part: a list kept there would conflict whenever one partner changed the date while the other added a tool. A slice of its own merges on its own. | +| 14 | What removing a tool does | Delegated: **hides it, deletes nothing.** Its tab, its area on the front page, its entries in What is left, its tour chapter and its palette entry go. Its work is kept, the removal is one undo step, and adding it back brings it back as it was. Its page still opens from its address. | +| 15 | Where the shared people live | Delegated, for Phase 2: **a slice of their own, `cast`**, moved out of `shots` on load. A tool rewrites only its own slice; once two tools edit the cast, it is neither tool's. | + +The maintainer's second answers, the same day, after Phase 0 was built: + +| # | Question | Decision | +|---|---|---| +| 16 | Boxes as designed in Phase 3 | **Yes**: each box on a block of the day, its where and when the block's. | +| 17 | Guests | **A tab of its own at the top**, first. Delegated: **never removable**, because every tool is built on the guest list, so it is not in the toolbox. | +| 18 | Money, Checklist, Binder | **In the toolbox**, as tools. Delegated: **off until added** — the words were "in the tool box" against "a tab on the top", and a header of Guests and the five is the clean start the PRD asked for. This replaces the master plan's rule that the wedding's own pages sit under its name; only Overview is left there. | +| 19 | Room in the header | Delegated: **the tool on screen keeps its controls; the tabs give way and scroll.** Measured at 1024px with every tool added: the tabs took 392px and left Timeline's controls 244 of the 323 they need. Denser tabs would have bought about 90px, enough today and not once Phase 1–4 add tools. | + +## Phase 0 — the toolbox + +**Built 2026-09-29**, with [its plan](../plans/2026-09-29-toolbox.md). + +- **The `tools` slice** is `{ shown: string[] }`. With nothing stored, the tools + marked `defaultOn` are shown — the five — which is every wedding today and + every new one; Money, Checklist and Binder wait in the toolbox. Once somebody + adds or removes a tool the list is stored as written, and it keeps ids this + build does not know: a tool a newer version added is never removed by an + older one writing its own change. +- **The registry** (`lib/tools.ts`) gives each tool a stable `id`, which is + what is stored; an address could be renamed. `shownTools(doc)` gives the + shown tools in the registry's order, cached per document like every other + derived view. +- **The header** shows Guests, always first, then the shown tools, and ends the + row with **+**, which opens + **Tools** (`?panel=tools`): every tool with what it is for, and Add or + Remove. A removal says the work is kept, and is one step on the wedding's + history ("Undo removing Seating"). +- **Not shown means out of sight everywhere at once**, whether removed or + never added: the header, the front page's areas, What is left (in + `readiness` itself, so the planner's Weddings page, which runs it on the + server, agrees), "Take a tour", and the palette's pages. Records in the + palette — a table, a block — still open where they live. So a wedding that + has not added Money hears nothing of balances falling due. +- **The example wedding shows every tool**, held there by a test: it exists to + show what Trousseau does, and the tour and front page point at all of it. + +## Phase 1 — Timeline: travel between places, and calendars + +**Built 2026-09-29**, with [its plan](../plans/2026-09-29-timeline-travel-and-calendars.md). +Two things below were corrected by the code before they were built, and are +written here as built: who moves is a **tag**, not a lane; and calendar times +are the **venue's clock**, not UTC. + +- **Travel times** the couple types: `timeline.travel`, a list of + `{ between: [placeA, placeB], minutes }`, the same either way. The panel lists + the pairs the day actually moves between (from the blocks' locations), so + there is nothing to invent, only blanks to fill. +- **A new advisory, `no-travel-time`**: a tag that ends one block at one place + and starts its next somewhere else sooner than the typed time allows, + measured from where the run sheet says the block ends (before its + contingency buffer) — "Hair and make-up ends at 13:00 at the house; the first look starts + at 13:05 at the venue, 15 minutes away." A pair with no time typed is never + guessed at and never flagged. Advisory, because a person running is not a + print run that should stop. *Not a lane:* the example's Suppliers lane holds + the florist, the caterer and the band, and Transport the cars and the guest + coach — a lane is a kind of activity, and checking one would say the coach + cannot get from where the cars were. Tags are already who is where; the + panel tells a couple to tag their own blocks ("couple") to be checked. +- **Calendar files**: the day as `.ics`, whole or for one supplier (a tag's + blocks), written in the browser, on the wedding's own date and refused + without one — the timeline's `day.date` is a placeholder, 20 June 2026, until + a date is set. Times are **local, with no zone**, so 13:30 at the venue is + 13:30 on every phone, as in the Binder. Not UTC: that needs the day's offset, + and an offset nobody entered reads as British Summer Time in the Day panel, + indistinguishable from one chosen — a winter wedding would be an hour out in + every calendar. (This spec first said UTC would show "the venue's clock"; + UTC shows the phone's.) Refused while the day has clashes, as the PDFs are. +- **The clocks, fixed the same day.** The fallback went further than the Day + panel: every Timeline edit — renaming a block was enough — wrote it into the + wedding, where the Binder then trusted it over the phone's own clock + (reproduced in a test before the fix). The offset is now null until chosen, + in the timeline, the published day and Delegation's reader; the panel says + *Not set*, any choice is a change, and sunset and golden hour wait for it. + +## Phase 2 — one cast, and Ceremony + +**Built 2026-09-29**, with [its plan](../plans/2026-09-29-cast-and-ceremony.md). +As built, where it differs from below: the suggested order ends with the +couple walking in together, since who walks with whom is theirs to say; the +officiant is words rather than a crew member; and there is no tour chapter, +the tour being held under thirty steps. + +- **`cast`** takes Group shots' cast and custom roles, moved on load by the + existing load-time pass. The fixed roles gain each partner's grandparents + (a party role, as the wedding party is). Readers, ring bearer, flower party + and the like are custom roles, as "Me and my family" already is. Group shots + reads it exactly as before. +- **Ceremony** (`/ceremony`, slice `ceremony`, added from the toolbox): the + processional as an ordered list of groups. Each group names who walks — + a role, a custom role, a guest, a family, a crew person (the officiant is + usually a supplier, not a guest) or free text — how they walk (alone, in + pairs, in threes), which side they go to (named after the partners), and a + cue: the music and the moment it changes. +- **"Suggest an order"** from the cast, as Group shots' `propose` does: a + starting point in the usual UK order, entirely editable. +- **Checks**: someone in the processional who has declined; a role nobody has + been cast in. Both through the cast's own resolver, as group shots do now. +- **Prints**: one page for the officiant and whoever runs the day, and plain + text to paste into an email. The page joins the wedding pack. + +## Phase 3 — Boxes + +**Built 2026-09-29**, with [its plan](../plans/2026-09-29-boxes.md). As built, +where it differs from below: a thing moves between boxes from a menu on its +row rather than by dragging; items carry no notes of their own; a box not for +the day needs nobody to take it; and the library adds boxes to a wedding +rather than replacing its own. + +The maintainer's words: boxes, what is in each, "in a good intuitive way", and +each box attached to a part of the day. The design below was confirmed as it +stands (decision 16). + +```ts +interface Box { + id: string; + /** Printed large on its label: "3". */ + number: number; + name: string; // "Getting ready — Alex" + items: Item[]; + /** The part of the day it is needed for: where and when come from the block. */ + blockId: string | null; // null: not for the day — the honeymoon case + personIds: string[]; // who gets it there + notes: string; +} +interface Item { id: string; label: string; quantity: number; packed: boolean; notes: string } +``` + +- **Where and when are the block's.** "Needs to be at the house for 9" is a box + on the block "Getting ready", at the house, at 09:00; move the block and the + box moves with it. A drop-off that is not a block of its own is a moment + (a block with no length), which Timeline already has. One way to say where + and when, not a second one typed on the box. +- **Finding things**: one search over every box ("shoes" → Box 3), items moved + between boxes by dragging, and each box's packed count on its card. +- **Checks**: a box whose block is gone (as a job's can be); unpacked items + close to the day; a box nobody is taking. +- **Prints**: a label per box — its number, name, where and when, and what is + in it — and a packing list, as PDF and CSV. The list joins the wedding pack. +- **Starter boxes** are UK first: rings, the paperwork the ceremony needs, + supplier envelopes, the emergency kit, the guest book, and so on — a list to + edit, as "Add the usual tasks" is. + +## Phase 4 — Bar + +**Built 2026-09-29**, with [its plan](../plans/2026-09-29-bar.md), whose +defaults the maintainer agreed before it was built. As built, where it differs +from below: evening-only guests are a figure of the Bar's own, since the guest +list does not say who is invited for the evening; the toast is fizz and the +meal is wine, and only the reception and the evening pour a mix; and prices +have no defaults. + +UK first, and every figure on screen, editable, and resettable to its default. + +- **Who is drinking**: the guests coming (`isComing`), live, less a share not + drinking. The count can be overridden, and says so while it is. +- **By part of the day**, because a UK wedding drinks in parts: the drinks + reception, the toast, the meal's wine, and the evening bar. Each part has its + own hours and per-head rate. +- **UK units**: wine and fizz in 75cl bottles, 125ml or 175ml glasses; beer in + 330ml bottles, 500ml cans, cases of 24; spirits in 70cl bottles, 25ml + measures; soft drinks and mixers in litres; ice in kilos. +- **Bar types**: full bar; beer and wine; signature cocktails with beer and + wine; low and no alcohol. +- **Adjusting**: a lighter or heavier crowd; prices per bottle or case, giving + an estimated spend; "we already have" per line; round up to whole cases for + sale or return, which UK merchants commonly offer. +- **The shopping list** grouped by where it is bought — supermarket, wine + merchant, cash and carry — editable. +- **The defaults are the product's credibility**, and are agreed with the + maintainer in Phase 4's plan before they are built. + +## Tools feeding each other — proposals + +None of these is built without the maintainer accepting it. Each keeps the +rule that a tool writes only its own slice and reads the others'. + +| From | To | What | +|---|---|---| +| Boxes | Delegation | A person taking a box sees "Box 3 to the house by 09:00" on their job sheet. Derived from the box, never stored as a job, so it follows the block. | +| Boxes | Binder | Find a box or an item on the day: "where are the rings?" | +| Ceremony | Timeline | The processional's cues shown inside the ceremony block, read-only. | +| Ceremony | Binder | The order of walking, on the phone, on the day. | +| Bar | Timeline | Reception, meal and evening hours read from the blocks the couple picks, rather than typed twice. | +| Bar | Money | The estimated spend shown against the budget as planned, not paid. | +| Bar | Checklist | "Buy the drinks" and "Collect the ice", dated back from the day. | +| Bar | Boxes | Crates as boxes, attached to the bar's block. | +| Timeline | Supplier links | Each supplier's calendar file on their own call sheet. | + +## Library + +A planner keeps, as the existing kinds are kept, without anything personal: +a **processional** as roles and formations with no guest named; a **set of +boxes** with their items and no people or blocks; **bar settings** — rates, +units and prices — with no guest count. + +## Open questions + +1. **T6**: ~~take fontkit off every page~~ — **done 2026-09-29**: one constant, + `DEFAULT_FIT`, moved beside the other template defaults; 522 KB shared to + 393 KB, and a test that walks both layouts' imports and names the chain if + the engine comes back. Still open: a CI check on each page's own JavaScript, + and its limit. +2. **S18** (from the master plan), since the PRD says "zero tracking": remove + Vercel Analytics, or say in the Privacy Policy what it counts? +3. **Cues on the day**: is the Ceremony → Timeline proposal wanted, or do the + cues stay in Ceremony? +4. ~~**Order of phases**~~ — **all four built, 2026-09-29**, in the order + proposed: Timeline, Ceremony, Boxes, Bar. + +## Explicitly deferred + +- Public calculator pages for search (decision 5). +- A keyboard shortcut for the toolbox (decision 4). diff --git a/scripts/validate-wedding.mjs b/scripts/validate-wedding.mjs index bc8afd3f..3a738a83 100644 --- a/scripts/validate-wedding.mjs +++ b/scripts/validate-wedding.mjs @@ -156,10 +156,15 @@ export function check(doc) { ...Object.keys(doc.seating?.groups ?? {}), ...Object.keys(doc.seating?.subgroups ?? {}), ]); + // The cast has a slice of its own, shared with Ceremony. A document written + // before it had one keeps it inside the shots, and is read from there. + const ownCast = isObj(doc.cast) && ("roles" in doc.cast || "customRoles" in doc.cast); + const castRoles = ownCast ? doc.cast.roles : doc.shots.cast; + const castCustomRoles = ownCast ? doc.cast.customRoles : doc.shots.customRoles; const customRoleIds = new Set( - (Array.isArray(doc.shots.customRoles) ? doc.shots.customRoles : []).filter(isObj).map((r) => r.id), + (Array.isArray(castCustomRoles) ? castCustomRoles : []).filter(isObj).map((r) => r.id), ); - const cast = isObj(doc.shots.cast) ? doc.shots.cast : {}; + const cast = isObj(castRoles) ? castRoles : {}; for (const [role, ids] of Object.entries(cast)) { for (const id of Array.isArray(ids) ? ids : []) { diff --git a/scripts/validate-wedding.test.mjs b/scripts/validate-wedding.test.mjs index c919278a..32cd8720 100644 --- a/scripts/validate-wedding.test.mjs +++ b/scripts/validate-wedding.test.mjs @@ -183,6 +183,17 @@ describe("shots slice", () => { expect(check(doc).errors).toEqual([expect.stringContaining("bride")]); }); + it("reads the cast from its own slice, and a custom role there names a shot's member", () => { + const doc = { + ...base, + cast: { roles: { "a-mother": ["ghost"] }, customRoles: [{ id: "crole-1", name: "Readers", guestIds: [] }] }, + shots: { + sections: [{ id: "s1", name: "Family", shots: [{ id: "sh1", label: "x", members: [{ kind: "customRole", ref: "crole-1" }] }] }], + }, + }; + expect(check(doc).errors).toEqual([expect.stringContaining("a-mother")]); + }); + it("catches a shot member naming a custom role that does not exist", () => { const doc = { ...base, diff --git a/src/day.test.ts b/src/day.test.ts index 5812a672..730acbdd 100644 --- a/src/day.test.ts +++ b/src/day.test.ts @@ -37,6 +37,16 @@ describe("daySchema", () => { expect(daySchema.safeParse(future).success).toBe(true); }); + it("takes a day whose clocks nobody has set", () => { + const parsed = daySchema.parse({ + kind: "cadence.day", + version: 1, + day: { date: "", coupleNames: "", venueName: "", curfewMin: 0, utcOffsetMin: null }, + blocks: [], + }); + expect(parsed.day.utcOffsetMin).toBeNull(); + }); + it("preserves unknown keys on the day, a block and a team", () => { const parsed = daySchema.parse({ kind: "cadence.day", diff --git a/src/day.ts b/src/day.ts index 7e20610e..2fc8ce70 100644 --- a/src/day.ts +++ b/src/day.ts @@ -85,7 +85,12 @@ export const daySchema = z.looseObject({ coupleNames: z.string(), venueName: z.string(), curfewMin: z.number(), - utcOffsetMin: z.number(), + /** + * The day's offset from UTC in minutes, or null until somebody enters it. + * Never filled in with a likely value: a guess here is a wedding an hour + * out in every clock that reads it. + */ + utcOffsetMin: z.number().nullable(), }), lanes: stringList(), blocks: z.array(dayBlockSchema), diff --git a/src/envelope.test.ts b/src/envelope.test.ts index 2af0b322..6e2ba783 100644 --- a/src/envelope.test.ts +++ b/src/envelope.test.ts @@ -25,7 +25,7 @@ describe("emptyTrousseau", () => { }); describe("SLICE_NAMES", () => { - it("lists exactly the eight publishable slices", () => { + it("lists exactly the thirteen publishable slices", () => { expect([...SLICE_NAMES]).toEqual([ "event", "guests", @@ -35,6 +35,11 @@ describe("SLICE_NAMES", () => { "stationery", "shots", "timeline", + "tools", + "cast", + "ceremony", + "boxes", + "bar", ]); }); diff --git a/src/envelope.ts b/src/envelope.ts index e88f0712..8972e276 100644 --- a/src/envelope.ts +++ b/src/envelope.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { daySchema } from "./day.js"; import { eventSchema } from "./event.js"; -import { crewSchema, guestsSchema, seatingSchema, shotsSchema, stationerySchema, timelineSchema } from "./slices.js"; +import { barSchema, boxesSchema, castSchema, ceremonySchema, crewSchema, guestsSchema, seatingSchema, shotsSchema, stationerySchema, timelineSchema, toolsSchema } from "./slices.js"; export const TROUSSEAU_KIND = "trousseau"; export const TROUSSEAU_VERSION = 1; @@ -20,6 +20,11 @@ export const SLICE_NAMES = [ "stationery", "shots", "timeline", + "tools", + "cast", + "ceremony", + "boxes", + "bar", ] as const; export type SliceName = (typeof SLICE_NAMES)[number]; @@ -44,6 +49,11 @@ export const trousseauSchema = z.looseObject({ stationery: stationerySchema, shots: shotsSchema, timeline: timelineSchema, + tools: toolsSchema, + cast: castSchema, + ceremony: ceremonySchema, + boxes: boxesSchema, + bar: barSchema, /** Native documents, keyed by app name. Present only in an exported file. */ sources: z.record(z.string(), z.unknown()).default(() => ({})), }); diff --git a/src/index.ts b/src/index.ts index 65d136db..4e33026c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,18 +22,28 @@ export { type Trousseau, } from "./envelope.js"; export { + barSchema, + boxesSchema, + castSchema, + ceremonySchema, crewSchema, guestsSchema, seatingSchema, shotsSchema, stationerySchema, timelineSchema, + toolsSchema, + type BarSlice, + type BoxesSlice, + type CastSlice, + type CeremonySlice, type Crew, type Guests, type Seating, type Shots, type Stationery, type TimelineSlice, + type Tools, } from "./slices.js"; export { TROUSSEAU_EXTENSION, diff --git a/src/slices.ts b/src/slices.ts index 6cdd1ea3..11dd2a42 100644 --- a/src/slices.ts +++ b/src/slices.ts @@ -21,6 +21,24 @@ export const crewSchema = z.looseObject({}).default(() => ({})); export const stationerySchema = z.looseObject({}).default(() => ({})); export const shotsSchema = z.looseObject({}).default(() => ({})); export const timelineSchema = z.looseObject({}).default(() => ({})); +/** + * Who is who — the couple, their families, their wedding parties — shared by + * every tool that names people by what they are to the couple rather than by + * name. Its own slice because more than one tool edits it. + */ +export const castSchema = z.looseObject({}).default(() => ({})); +/** The ceremony: the processional, in the order it walks. Ceremony's own. */ +export const ceremonySchema = z.looseObject({}).default(() => ({})); +/** What is packed in which box, and the part of the day each is needed for. Boxes' own. */ +export const boxesSchema = z.looseObject({}).default(() => ({})); +/** What the couple chose for the drinks they buy: the kind of bar and every figure they changed. Bar's own. */ +export const barSchema = z.looseObject({}).default(() => ({})); +/** + * Which tools the wedding shows. Owned by the launcher rather than any tool, + * as `event` is, and its own slice so that adding a tool never conflicts with + * a partner changing the date. + */ +export const toolsSchema = z.looseObject({}).default(() => ({})); export type Guests = z.infer; export type Seating = z.infer; @@ -28,3 +46,8 @@ export type Crew = z.infer; export type Stationery = z.infer; export type Shots = z.infer; export type TimelineSlice = z.infer; +export type CastSlice = z.infer; +export type CeremonySlice = z.infer; +export type BoxesSlice = z.infer; +export type BarSlice = z.infer; +export type Tools = z.infer; diff --git a/suite/app/(app)/(tools)/bar/BarClient.tsx b/suite/app/(app)/(tools)/bar/BarClient.tsx new file mode 100644 index 00000000..79b93588 --- /dev/null +++ b/suite/app/(app)/(tools)/bar/BarClient.tsx @@ -0,0 +1,14 @@ +"use client"; + +import dynamic from "next/dynamic"; + +/** + * The Bar, split out of the main bundle, as Boxes is: this file exists only + * because `next/dynamic(..., { ssr: false })` is a Client Component API and + * `page.tsx` has to stay a Server Component to keep its `metadata`. + */ +const BarBoard = dynamic(() => import("@/components/bar/BarBoard").then((m) => m.BarBoard), { ssr: false }); + +export function BarClient() { + return ; +} diff --git a/suite/app/(app)/(tools)/bar/page.tsx b/suite/app/(app)/(tools)/bar/page.tsx new file mode 100644 index 00000000..f8a2c418 --- /dev/null +++ b/suite/app/(app)/(tools)/bar/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next"; +import { BarClient } from "./BarClient"; + +export const metadata: Metadata = { + title: "Bar", + description: "How much drink to buy, in bottles and cases, and roughly what it costs.", +}; + +export default function BarPage() { + return ; +} diff --git a/suite/app/(app)/(tools)/boxes/BoxesClient.tsx b/suite/app/(app)/(tools)/boxes/BoxesClient.tsx new file mode 100644 index 00000000..9428219e --- /dev/null +++ b/suite/app/(app)/(tools)/boxes/BoxesClient.tsx @@ -0,0 +1,14 @@ +"use client"; + +import dynamic from "next/dynamic"; + +/** + * Boxes, split out of the main bundle, as Ceremony is: this file exists only + * because `next/dynamic(..., { ssr: false })` is a Client Component API and + * `page.tsx` has to stay a Server Component to keep its `metadata`. + */ +const BoxesBoard = dynamic(() => import("@/components/boxes/BoxesBoard").then((m) => m.BoxesBoard), { ssr: false }); + +export function BoxesClient() { + return ; +} diff --git a/suite/app/(app)/(tools)/boxes/page.tsx b/suite/app/(app)/(tools)/boxes/page.tsx new file mode 100644 index 00000000..3fbc4c5d --- /dev/null +++ b/suite/app/(app)/(tools)/boxes/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next"; +import { BoxesClient } from "./BoxesClient"; + +export const metadata: Metadata = { + title: "Boxes", + description: "What is packed in which box, and where each has to be, by when.", +}; + +export default function BoxesPage() { + return ; +} diff --git a/suite/app/(app)/(tools)/ceremony/CeremonyClient.tsx b/suite/app/(app)/(tools)/ceremony/CeremonyClient.tsx new file mode 100644 index 00000000..2997a3ef --- /dev/null +++ b/suite/app/(app)/(tools)/ceremony/CeremonyClient.tsx @@ -0,0 +1,16 @@ +"use client"; + +import dynamic from "next/dynamic"; + +/** + * Ceremony, split out of the main bundle, as Group shots is: this file exists + * only because `next/dynamic(..., { ssr: false })` is a Client Component API + * and `page.tsx` has to stay a Server Component to keep its `metadata`. + */ +const CeremonyBoard = dynamic(() => import("@/components/ceremony/CeremonyBoard").then((m) => m.CeremonyBoard), { + ssr: false, +}); + +export function CeremonyClient() { + return ; +} diff --git a/suite/app/(app)/(tools)/ceremony/page.tsx b/suite/app/(app)/(tools)/ceremony/page.tsx new file mode 100644 index 00000000..049366b6 --- /dev/null +++ b/suite/app/(app)/(tools)/ceremony/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next"; +import { CeremonyClient } from "./CeremonyClient"; + +export const metadata: Metadata = { + title: "Ceremony", + description: "The processional: who walks, in what order, how, and to what music.", +}; + +export default function CeremonyPage() { + return ; +} diff --git a/suite/apps/brigade/core/import/day.test.ts b/suite/apps/brigade/core/import/day.test.ts new file mode 100644 index 00000000..781dfd26 --- /dev/null +++ b/suite/apps/brigade/core/import/day.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { parseDay } from "./day"; + +const day = (utcOffsetMin: unknown) => + JSON.stringify({ + kind: "cadence.day", + version: 1, + appVersion: "", + day: { date: "2028-12-12", coupleNames: "Alex & Sam", venueName: "", curfewMin: 1440, utcOffsetMin }, + lanes: ["Main day"], + blocks: [], + }); + +describe("reading the day's clocks", () => { + it("takes a day whose clocks nobody has set, as unset", () => { + const result = parseDay(day(null)); + expect(result.error).toBeUndefined(); + expect(result.day?.utcOffsetMin).toBeNull(); + }); + + it("takes a day whose clocks are set", () => { + expect(parseDay(day(0)).day?.utcOffsetMin).toBe(0); + }); + + it("refuses a day with no word on its clocks at all", () => { + expect(parseDay(day(undefined))).toEqual({ error: "The day is missing utcOffsetMin." }); + }); +}); diff --git a/suite/apps/brigade/core/import/day.ts b/suite/apps/brigade/core/import/day.ts index 2a57d3c4..a634cb1a 100644 --- a/suite/apps/brigade/core/import/day.ts +++ b/suite/apps/brigade/core/import/day.ts @@ -52,8 +52,10 @@ export function parseDay(json: string): DayResult { for (const field of ["date", "coupleNames", "venueName"]) { if (typeof settings[field] !== "string") return { error: `The day is missing ${field}.` }; } - for (const field of ["curfewMin", "utcOffsetMin"]) { - if (typeof settings[field] !== "number") return { error: `The day is missing ${field}.` }; + if (typeof settings["curfewMin"] !== "number") return { error: "The day is missing curfewMin." }; + // Null is a day whose clocks nobody has set yet, not a broken one. + if (typeof settings["utcOffsetMin"] !== "number" && settings["utcOffsetMin"] !== null) { + return { error: "The day is missing utcOffsetMin." }; } const rawBlocks = entry["blocks"]; @@ -97,7 +99,7 @@ export function parseDay(json: string): DayResult { coupleNames: settings["coupleNames"] as string, venueName: settings["venueName"] as string, curfewMin: settings["curfewMin"] as number, - utcOffsetMin: settings["utcOffsetMin"] as number, + utcOffsetMin: settings["utcOffsetMin"] as number | null, lanes: Array.isArray(entry["lanes"]) ? entry["lanes"].filter(isString) : [], blocks, }, diff --git a/suite/apps/brigade/core/model/types.ts b/suite/apps/brigade/core/model/types.ts index 32d8ff75..35aa205c 100644 --- a/suite/apps/brigade/core/model/types.ts +++ b/suite/apps/brigade/core/model/types.ts @@ -26,7 +26,8 @@ export interface ImportedDay { coupleNames: string; venueName: string; curfewMin: number; - utcOffsetMin: number; + /** Null until somebody sets the clocks. */ + utcOffsetMin: number | null; lanes: string[]; blocks: DayBlock[]; } diff --git a/suite/apps/brigade/state/sliceBridge.ts b/suite/apps/brigade/state/sliceBridge.ts index c4cc3ddf..116ae6aa 100644 --- a/suite/apps/brigade/state/sliceBridge.ts +++ b/suite/apps/brigade/state/sliceBridge.ts @@ -1,5 +1,5 @@ import type { Trousseau } from "@jfrusher/trousseau"; -import { guestName, readCrew, readGuests } from "@/lib/model/slices"; +import { personName, readCrew, readGuests } from "@/lib/model/slices"; import { parseDay } from "../core/import/day"; import { emptyDoc } from "../core/model/defaults"; import type { BrigadeDoc } from "../core/model/types"; @@ -44,11 +44,7 @@ export function readSlice(doc: Trousseau): BrigadeDoc { * had, which is better than a job sheet that suddenly reads "Someone". */ const guests = readGuests(doc); - const people = crew.people.map((person) => { - if (!person.guestId) return person; - const guest = guests[person.guestId]; - return guest ? { ...person, name: guestName(guest) || person.name } : person; - }); + const people = crew.people.map((person) => ({ ...person, name: personName(person, guests) })); return { schemaVersion: base.schemaVersion, diff --git a/suite/apps/cadence/core/model/defaults.ts b/suite/apps/cadence/core/model/defaults.ts index 0f87a079..b00cd4c5 100644 --- a/suite/apps/cadence/core/model/defaults.ts +++ b/suite/apps/cadence/core/model/defaults.ts @@ -49,7 +49,7 @@ export function defaultDay(): DaySettings { venueName: "", latitude: 51.5074, longitude: -0.1278, - utcOffsetMin: 60, + utcOffsetMin: null, curfewMin: 1500, logoKey: null, }; @@ -63,6 +63,7 @@ export function emptyDoc(): TimelineDoc { lanes: [...DEFAULT_LANES], blocks: [], tagDetails: [], + travel: [], outputs: DEFAULT_OUTPUTS.map((output) => ({ ...output })), styles: defaultStyles(), fonts: [], @@ -169,6 +170,7 @@ export function sampleDoc(): TimelineDoc { { tag: "transport", displayName: "County Cars", phone: "07700 900506", arrivalMin: 690, notes: "" }, { tag: "registrar", displayName: "County Registrar", phone: "07700 900610", arrivalMin: 780, notes: "" }, ], + travel: [], outputs: DEFAULT_OUTPUTS.map((output) => ({ ...output })), styles: defaultStyles(), fonts: [], diff --git a/suite/apps/cadence/core/model/types.ts b/suite/apps/cadence/core/model/types.ts index b2aecd72..a86b6f0c 100644 --- a/suite/apps/cadence/core/model/types.ts +++ b/suite/apps/cadence/core/model/types.ts @@ -54,6 +54,16 @@ export interface TagDetail { notes?: string; } +/** + * How long it takes to get between two places, typed by the couple. The same + * either way. Never guessed: two places with no journey typed are never + * checked against each other. + */ +export interface Journey { + between: [string, string]; + minutes: number; +} + export interface DaySettings { /** ISO `YYYY-MM-DD`. Used for display and the solar calculation only. */ date: string; @@ -61,8 +71,11 @@ export interface DaySettings { venueName: string; latitude: number; longitude: number; - /** The day's offset from UTC in minutes. BST is 60. Entered, never inferred. */ - utcOffsetMin: number; + /** + * The day's offset from UTC in minutes. BST is 60. Entered, never inferred: + * null until somebody chooses it, and whatever needs it says so meanwhile. + */ + utcOffsetMin: number | null; /** Minutes-from-00:00. May exceed 1440. */ curfewMin: number; /** Blob store key for the logo, or null. */ @@ -98,6 +111,7 @@ export interface TimelineDoc { lanes: string[]; blocks: Block[]; tagDetails: TagDetail[]; + travel: Journey[]; outputs: OutputSpec[]; styles: Record; fonts: UploadedFont[]; diff --git a/suite/apps/cadence/core/project/day.ts b/suite/apps/cadence/core/project/day.ts index cf6920ef..a07b8a92 100644 --- a/suite/apps/cadence/core/project/day.ts +++ b/suite/apps/cadence/core/project/day.ts @@ -56,7 +56,8 @@ export interface ResolvedDay { coupleNames: string; venueName: string; curfewMin: number; - utcOffsetMin: number; + /** Null until somebody sets the clocks. */ + utcOffsetMin: number | null; }; lanes: string[]; blocks: DayBlock[]; diff --git a/suite/apps/cadence/core/schedule/conflicts.ts b/suite/apps/cadence/core/schedule/conflicts.ts index eab0a84c..11f6fa69 100644 --- a/suite/apps/cadence/core/schedule/conflicts.ts +++ b/suite/apps/cadence/core/schedule/conflicts.ts @@ -1,6 +1,7 @@ import { formatClock, formatDuration } from "../time/minutes"; import { isMoment, type TimelineDoc } from "../model/types"; import { blocksById, byId, byLane, type ResolvedBlock } from "./resolve"; +import { travelShortfalls } from "./travel"; export type ConflictKind = | "lane-overlap" @@ -8,7 +9,8 @@ export type ConflictKind = | "anchor-collision" | "curfew-overrun" | "past-golden-hour" - | "squeezed"; + | "squeezed" + | "no-travel-time"; /** Conflicts block export. Advisories never do. */ export type Severity = "conflict" | "advisory"; @@ -102,6 +104,7 @@ export function conflicts( } found.push(...tagDoubleBookings(resolved, doc, label)); + found.push(...travelShortfalls(resolved, doc)); const goldenHourEndMin = options.goldenHourEndMin; if (goldenHourEndMin !== undefined) { diff --git a/suite/apps/cadence/core/schedule/travel.test.ts b/suite/apps/cadence/core/schedule/travel.test.ts new file mode 100644 index 00000000..2df0a1c5 --- /dev/null +++ b/suite/apps/cadence/core/schedule/travel.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from "vitest"; +import { emptyDoc } from "../model/defaults"; +import type { Block, Journey, TagDetail, TimelineDoc } from "../model/types"; +import { blockingConflicts, conflicts } from "./conflicts"; +import { resolve } from "./resolve"; +import { journeyMinutes, moves, travelPairs, travelShortfalls, withJourney } from "./travel"; + +function block(id: string, lane: string, at: string, minutes: number, location: string, tags: string[], extra: Partial = {}): Block { + const [h, m] = at.split(":").map(Number); + return { + id, + label: id, + durationMin: minutes, + anchorMin: h! * 60 + m!, + gapMin: 0, + bufferMin: 0, + lane, + tags, + location, + notes: "", + outputs: [], + ...extra, + }; +} + +function day(blocks: Block[], travel: Journey[] = [], tagDetails: TagDetail[] = []): TimelineDoc { + return { ...emptyDoc(), lanes: ["Main day", "Photos", "Suppliers"], blocks, travel, tagDetails }; +} + +const short = (doc: TimelineDoc) => travelShortfalls(resolve(doc), doc); +const HOUSE_TO_VENUE: Journey[] = [{ between: ["The house", "The venue"], minutes: 15 }]; + +describe("travel between places", () => { + it("says when somebody is due somewhere before they could get there", () => { + // The PRD's own case: hair and make-up ends at 13:00, the first look is + // at 13:05, and the venue is fifteen minutes away. + const doc = day( + [ + block("Hair and make-up", "Main day", "11:00", 120, "The house", ["couple"]), + block("First look", "Main day", "13:05", 20, "The venue", ["couple"]), + ], + HOUSE_TO_VENUE, + ); + expect(short(doc)).toEqual([ + { + kind: "no-travel-time", + severity: "advisory", + blockIds: ["Hair and make-up", "First look"], + message: + "couple finishes Hair and make-up at 13:00 at The house, and is due at First look at 13:05 at The venue, 15 minutes away: 10 minutes short.", + }, + ]); + }); + + it("measures from where the run sheet says a block ends, not after its buffer", () => { + // The buffer is for overrunning. Spent on the journey, it is gone. + const doc = day( + [ + block("Hair and make-up", "Main day", "11:00", 120, "The house", ["couple"], { bufferMin: 10 }), + block("First look", "Photos", "13:12", 20, "The venue", ["couple"]), + ], + HOUSE_TO_VENUE, + ); + expect(short(doc).map((found) => found.message)).toEqual([ + "couple finishes Hair and make-up at 13:00 at The house, and is due at First look at 13:12 at The venue, 15 minutes away: 3 minutes short.", + ]); + }); + + it("says nothing when there is time, or when no time was typed", () => { + const blocks = [ + block("Hair and make-up", "Main day", "11:00", 120, "The house", ["couple"]), + block("First look", "Main day", "13:15", 20, "The venue", ["couple"]), + ]; + expect(short(day(blocks, HOUSE_TO_VENUE))).toEqual([]); + const tight = [blocks[0]!, { ...blocks[1]!, anchorMin: 13 * 60 + 5 }]; + expect(short(day(tight, []))).toEqual([]); + }); + + it("matches places whatever their case and spacing, and a journey either way round", () => { + const doc = day( + [ + block("First look", "Main day", "13:00", 20, "the venue ", ["couple"]), + block("Back for the dress", "Main day", "13:25", 20, "THE HOUSE", ["couple"]), + ], + HOUSE_TO_VENUE, + ); + expect(journeyMinutes(doc, " the venue", "the house")).toBe(15); + expect(short(doc)).toHaveLength(1); + }); + + it("carries where somebody was across a block with no location", () => { + const doc = day( + [ + block("Hair and make-up", "Main day", "11:00", 120, "The house", ["couple"]), + block("In the car", "Main day", "13:00", 5, "", ["couple"]), + block("First look", "Main day", "13:05", 20, "The venue", ["couple"]), + ], + HOUSE_TO_VENUE, + ); + expect(short(doc).map((found) => found.blockIds)).toEqual([["Hair and make-up", "First look"]]); + }); + + it("names everybody making the same move once, by the name they were given", () => { + const doc = day( + [ + block("Getting ready", "Main day", "11:00", 120, "The house", ["photographer", "couple"]), + block("First look", "Main day", "13:05", 20, "The venue", ["photographer", "couple"]), + ], + HOUSE_TO_VENUE, + [{ tag: "photographer", displayName: "Eleanor Vane Photography" }], + ); + const found = short(doc); + expect(found).toHaveLength(1); + expect(found[0]?.message).toMatch(/^Eleanor Vane Photography and couple finish Getting ready at 13:00 .* and are due at First look/); + }); + + it("does not treat a lane as somebody: two suppliers one after the other are not a journey", () => { + const doc = day( + [ + block("Florist", "Suppliers", "11:00", 60, "The house", ["florist"]), + block("Caterer", "Suppliers", "12:05", 60, "The venue", ["caterer"]), + ], + HOUSE_TO_VENUE, + ); + expect(moves(resolve(doc), doc)).toEqual([]); + expect(short(doc)).toEqual([]); + }); + + it("leaves somebody in two places at once to the double-booking check", () => { + const doc = day( + [ + block("Hair and make-up", "Main day", "11:00", 150, "The house", ["couple"]), + block("First look", "Photos", "13:05", 20, "The venue", ["couple"]), + ], + HOUSE_TO_VENUE, + ); + const found = conflicts(resolve(doc), doc); + expect(found.map((entry) => entry.kind)).toEqual(["tag-double-booked"]); + }); + + it("is advisory: it is on the screen with the rest, and never holds up a print", () => { + const doc = day( + [ + block("Hair and make-up", "Main day", "11:00", 120, "The house", ["couple"]), + block("First look", "Main day", "13:05", 20, "The venue", ["couple"]), + ], + HOUSE_TO_VENUE, + ); + const found = conflicts(resolve(doc), doc); + expect(found.map((entry) => entry.kind)).toContain("no-travel-time"); + expect(blockingConflicts(found)).toEqual([]); + }); +}); + +describe("moves", () => { + it("lists every change of place, per tag, in time order", () => { + const doc = day([ + block("Getting ready", "Main day", "08:00", 180, "The suite", ["photographer"]), + block("Ceremony", "Main day", "13:30", 45, "Orangery", ["photographer", "registrar"]), + block("Rings", "Main day", "13:15", 0, "Orangery", ["registrar"]), + block("Confetti", "Main day", "14:15", 15, "Front steps", ["photographer"]), + ]); + expect(moves(resolve(doc), doc).map((move) => `${move.tag}: ${move.fromPlace} → ${move.toPlace}`)).toEqual([ + "photographer: The suite → Orangery", + "photographer: Orangery → Front steps", + ]); + }); +}); + +describe("travelPairs", () => { + const doc = day( + [ + block("Getting ready", "Main day", "08:00", 180, "The suite", ["photographer", "couple"]), + block("Ceremony", "Main day", "13:30", 45, "Orangery", ["photographer", "couple"]), + block("Back to change", "Main day", "15:00", 30, "the suite", ["couple"]), + ], + [ + { between: ["Orangery", "The suite"], minutes: 5 }, + { between: ["Church", "The suite"], minutes: 20 }, + ], + [{ tag: "photographer", displayName: "Eleanor Vane Photography" }], + ); + + it("lists each pair of places once, with its time and everybody who makes the journey", () => { + expect(travelPairs(resolve(doc), doc).used).toEqual([ + { between: ["The suite", "Orangery"], minutes: 5, who: ["Eleanor Vane Photography", "couple"] }, + ]); + }); + + it("keeps the journeys the day no longer makes apart, to be forgotten", () => { + expect(travelPairs(resolve(doc), doc).unused).toEqual([{ between: ["Church", "The suite"], minutes: 20 }]); + }); +}); + +describe("withJourney", () => { + const travel: Journey[] = [{ between: ["The house", "The venue"], minutes: 15 }]; + + it("adds, changes and takes away, matching a pair either way round", () => { + expect(withJourney([], ["The house", "The venue"], 15)).toEqual(travel); + expect(withJourney(travel, ["the venue", "the house"], 20)).toEqual([{ between: ["The house", "The venue"], minutes: 20 }]); + expect(withJourney(travel, ["The venue", "The house"], null)).toEqual([]); + expect(withJourney(travel, ["The venue", "The house"], 0)).toEqual([]); + }); + + it("hands back the same list when nothing changes", () => { + expect(withJourney(travel, ["The house", "The venue"], 15)).toBe(travel); + expect(withJourney(travel, ["Church", "The venue"], null)).toBe(travel); + }); +}); diff --git a/suite/apps/cadence/core/schedule/travel.ts b/suite/apps/cadence/core/schedule/travel.ts new file mode 100644 index 00000000..0b64ea75 --- /dev/null +++ b/suite/apps/cadence/core/schedule/travel.ts @@ -0,0 +1,158 @@ +import { tagLabel } from "../model/tags"; +import { formatClock } from "../time/minutes"; +import type { Journey, TimelineDoc } from "../model/types"; +import type { Conflict } from "./conflicts"; +import { blocksById, byId, type ResolvedBlock } from "./resolve"; + +/** + * Who has to get from one place to another during the day, and whether the + * day leaves them long enough to. + * + * Somebody is a tag — the photographer, the band, the two of you once your own + * blocks carry "couple" — as the double-booking check already reads it. Not a + * lane: the Suppliers lane holds the florist and then the caterer, and nobody + * travels from one to the other. + */ + +/** A place as the couple would match it: trimmed, whatever the case. */ +export const placeKey = (place: string): string => place.trim().toLowerCase(); + +/** Two places, either way round, as one key. */ +export function pairKey(a: string, b: string): string { + return [placeKey(a), placeKey(b)].sort().join("\u0000"); +} + +export interface Move { + tag: string; + from: ResolvedBlock; + to: ResolvedBlock; + fromPlace: string; + toPlace: string; +} + +/** + * Every move the day makes: for each tag, in time order, a block somewhere + * other than where that tag last was. A block with no location moves nobody, + * so where they last were carries across it. + */ +export function moves(resolved: ResolvedBlock[], doc: TimelineDoc): Move[] { + const positions = byId(resolved); + const stopsByTag = new Map>(); + for (const block of doc.blocks) { + const entry = positions.get(block.id); + const place = block.location.trim(); + if (!entry || !place) continue; + for (const tag of block.tags) { + const stops = stopsByTag.get(tag); + if (stops) stops.push({ entry, place }); + else stopsByTag.set(tag, [{ entry, place }]); + } + } + + const found: Move[] = []; + for (const [tag, stops] of stopsByTag) { + stops.sort((a, b) => a.entry.startMin - b.entry.startMin || a.entry.contentEndMin - b.entry.contentEndMin); + for (let i = 1; i < stops.length; i += 1) { + const from = stops[i - 1]!; + const to = stops[i]!; + if (placeKey(from.place) === placeKey(to.place)) continue; + found.push({ tag, from: from.entry, to: to.entry, fromPlace: from.place, toPlace: to.place }); + } + } + return found; +} + +/** The typed time between two places, or null: never a guess. */ +export function journeyMinutes(doc: TimelineDoc, a: string, b: string): number | null { + const key = pairKey(a, b); + return doc.travel.find((journey) => pairKey(...journey.between) === key)?.minutes ?? null; +} + +export interface TravelPair { + between: [string, string]; + /** The typed time, or null: not checked. */ + minutes: number | null; + /** Who makes this journey, by name. */ + who: string[]; +} + +/** + * The pairs of places the day moves between, in the order it first does, each + * with its typed time; and the journeys typed that the day no longer makes. The + * same walk the advisory uses, so what is listed is exactly what is checked. + */ +export function travelPairs(resolved: ResolvedBlock[], doc: TimelineDoc): { used: TravelPair[]; unused: Journey[] } { + const pairs = new Map(); + const inOrder = [...moves(resolved, doc)].sort((a, b) => a.to.startMin - b.to.startMin); + for (const move of inOrder) { + const key = pairKey(move.fromPlace, move.toPlace); + const who = tagLabel(doc, move.tag); + const pair = pairs.get(key); + if (!pair) { + pairs.set(key, { between: [move.fromPlace, move.toPlace], minutes: journeyMinutes(doc, move.fromPlace, move.toPlace), who: [who] }); + } else if (!pair.who.includes(who)) { + pair.who.push(who); + } + } + return { + used: [...pairs.values()], + unused: doc.travel.filter((journey) => !pairs.has(pairKey(...journey.between))), + }; +} + +/** + * The travel list with one journey's time set, or taken away when there is + * none. The same list back when nothing changed, so no empty step lands on the + * history. + */ +export function withJourney(travel: Journey[], between: [string, string], minutes: number | null): Journey[] { + const key = pairKey(...between); + const existing = travel.find((journey) => pairKey(...journey.between) === key); + if (minutes === null || minutes <= 0) return existing ? travel.filter((journey) => journey !== existing) : travel; + if (existing?.minutes === minutes) return travel; + return existing + ? travel.map((journey) => (journey === existing ? { ...journey, minutes } : journey)) + : [...travel, { between, minutes }]; +} + +const listOf = (names: string[]): string => + names.length <= 1 ? (names[0] ?? "") : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`; + +/** + * Somebody due somewhere before they could get there. Measured from where the + * run sheet says a block ends — before its contingency buffer, which is for + * overrunning, not for travelling. One advisory per pair of blocks, naming + * everybody making that move. An overlap is not travel's to report: somebody + * in two places at once is a double booking already. + */ +export function travelShortfalls(resolved: ResolvedBlock[], doc: TimelineDoc): Conflict[] { + const blocks = blocksById(doc); + const label = (id: string) => blocks.get(id)?.label ?? id; + const short = new Map(); + + for (const move of moves(resolved, doc)) { + const minutes = journeyMinutes(doc, move.fromPlace, move.toPlace); + if (minutes === null) continue; + const gap = move.to.startMin - move.from.contentEndMin; + if (gap < 0 || gap >= minutes) continue; + const key = `${move.from.id}>${move.to.id}`; + const who = tagLabel(doc, move.tag); + const seen = short.get(key); + if (seen) seen.who.push(who); + else short.set(key, { move, minutes, who: [who] }); + } + + return [...short.values()].map(({ move, minutes, who }) => { + const one = who.length === 1; + const gap = move.to.startMin - move.from.contentEndMin; + return { + kind: "no-travel-time" as const, + severity: "advisory" as const, + blockIds: [move.from.id, move.to.id], + message: + `${listOf(who)} ${one ? "finishes" : "finish"} ${label(move.from.id)} at ${formatClock(move.from.contentEndMin)} ` + + `at ${move.fromPlace}, and ${one ? "is" : "are"} due at ${label(move.to.id)} at ${formatClock(move.to.startMin)} ` + + `at ${move.toPlace}, ${minutes} minutes away: ${minutes - gap} minutes short.`, + }; + }); +} diff --git a/suite/apps/cadence/core/sun/solar.test.ts b/suite/apps/cadence/core/sun/solar.test.ts index 1f6925aa..4752f3d9 100644 --- a/suite/apps/cadence/core/sun/solar.test.ts +++ b/suite/apps/cadence/core/sun/solar.test.ts @@ -76,4 +76,10 @@ describe("sunForDay", () => { sunTimes("2026-06-20", 51.5074, -0.1278, 60)?.sunsetMin, ); }); + + it("gives nothing until the clocks are set, rather than a sunset an hour out", async () => { + const { sunForDay } = await import("./solar"); + const { sampleDoc } = await import("../model/defaults"); + expect(sunForDay({ ...sampleDoc().day, utcOffsetMin: null })).toBeNull(); + }); }); diff --git a/suite/apps/cadence/core/sun/solar.ts b/suite/apps/cadence/core/sun/solar.ts index c04025b3..9ad9f2bb 100644 --- a/suite/apps/cadence/core/sun/solar.ts +++ b/suite/apps/cadence/core/sun/solar.ts @@ -97,12 +97,17 @@ export function sunTimes( }; } -/** Convenience for the document's own day settings. */ +/** + * Convenience for the document's own day settings. Nothing until the clocks + * are set: sunset on the venue's clock needs the venue's offset, and one + * guessed would print a sunset an hour out. + */ export function sunForDay(day: { date: string; latitude: number; longitude: number; - utcOffsetMin: number; + utcOffsetMin: number | null; }): SunTimes | null { + if (day.utcOffsetMin === null) return null; return sunTimes(day.date, day.latitude, day.longitude, day.utcOffsetMin); } diff --git a/suite/apps/cadence/render/ics/calendar.test.ts b/suite/apps/cadence/render/ics/calendar.test.ts new file mode 100644 index 00000000..bb6e6d59 --- /dev/null +++ b/suite/apps/cadence/render/ics/calendar.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { emptyDoc } from "../../core/model/defaults"; +import type { Block, TimelineDoc } from "../../core/model/types"; +import { calendar, escapeText, fold } from "./calendar"; + +function block(id: string, label: string, anchorMin: number, durationMin: number, extra: Partial = {}): Block { + return { id, label, durationMin, anchorMin, gapMin: 0, bufferMin: 0, lane: "Main day", tags: [], location: "", notes: "", outputs: [], ...extra }; +} + +const doc: TimelineDoc = { + ...emptyDoc(), + day: { ...emptyDoc().day, coupleNames: "Alex & Sam" }, + blocks: [ + block("blk-ceremony", "Ceremony", 13 * 60 + 30, 45, { location: "Orangery", tags: ["registrar", "photographer"], bufferMin: 10 }), + block("blk-rings", "Rings to the best man", 13 * 60 + 15, 0, { location: "Orangery", tags: ["registrar"] }), + block("blk-carriages", "Carriages", 24 * 60 + 40, 20, { location: "Front drive" }), + ], + tagDetails: [{ tag: "registrar", displayName: "County Registrar" }], +}; + +const NOW = new Date("2028-05-01T09:08:07.123Z"); +const make = (tag?: string) => calendar(doc, { date: "2028-06-01", now: NOW, ...(tag === undefined ? {} : { tag }) }); +const lines = (ics: string) => ics.split("\r\n"); +const unfold = (ics: string) => ics.replace(/\r\n /g, ""); + +describe("the day as a calendar", () => { + it("is a calendar of the day's blocks in time order, every line ended with CRLF", () => { + const ics = make(); + expect(ics.startsWith("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n")).toBe(true); + expect(ics.endsWith("END:VCALENDAR\r\n")).toBe(true); + expect(ics.replace(/\r\n/g, "")).not.toMatch(/[\r\n]/); + expect(lines(ics).filter((line) => line.startsWith("SUMMARY:"))).toEqual([ + "SUMMARY:Rings to the best man", + "SUMMARY:Ceremony", + "SUMMARY:Carriages", + ]); + expect(ics).toContain("X-WR-CALNAME:Alex & Sam\r\n"); + }); + + it("writes the venue's clock, ends a block where the run sheet does, and puts past midnight on the next day", () => { + const ics = make(); + // The ceremony's ten-minute buffer is contingency, not the ceremony. + expect(ics).toContain("DTSTART:20280601T133000\r\nDTEND:20280601T141500\r\n"); + expect(ics).toContain("DTSTART:20280602T004000\r\nDTEND:20280602T010000\r\n"); + expect(ics).not.toMatch(/DTSTART:\d{8}T\d{6}Z/); + }); + + it("gives a moment a start and no end", () => { + const rings = unfold(make()).split("BEGIN:VEVENT")[1]!; + expect(rings).toContain("DTSTART:20280601T131500"); + expect(rings).not.toContain("DTEND"); + }); + + it("gives each event a stamp and an id that is stable, and particular to this wedding", () => { + const ics = make(); + expect(ics).toContain("UID:blk-ceremony.2028-06-01.alex-and-sam@trousseau\r\n"); + expect(ics).toContain("DTSTAMP:20280501T090807Z\r\n"); + expect(make()).toBe(ics); + }); + + it("gives one tag its own blocks, under its name", () => { + const ics = make("registrar"); + expect(lines(ics).filter((line) => line.startsWith("SUMMARY:"))).toEqual(["SUMMARY:Rings to the best man", "SUMMARY:Ceremony"]); + expect(ics).toContain("X-WR-CALNAME:Alex & Sam — County Registrar\r\n"); + }); + + it("refuses a day without the wedding's date rather than inventing one", () => { + expect(() => calendar(doc, { date: "", now: NOW })).toThrow("the wedding's date"); + }); +}); + +describe("calendar text", () => { + it("escapes what the format reserves", () => { + expect(escapeText("Drinks; canapés, and a toast\\speech\nthen dinner")).toBe("Drinks\\; canapés\\, and a toast\\\\speech\\nthen dinner"); + }); + + it("folds at 75 octets without splitting a character, and unfolds to what it was", () => { + const line = `DESCRIPTION:${"Rosé and café — ".repeat(12)}`; + const folded = fold(line); + for (const physical of folded.split("\r\n")) expect(new TextEncoder().encode(physical).length).toBeLessThanOrEqual(75); + expect(folded.split("\r\n").slice(1).every((physical) => physical.startsWith(" "))).toBe(true); + expect(folded.replace(/\r\n /g, "")).toBe(line); + }); + + it("leaves a short line alone", () => { + expect(fold("SUMMARY:Ceremony")).toBe("SUMMARY:Ceremony"); + }); +}); diff --git a/suite/apps/cadence/render/ics/calendar.ts b/suite/apps/cadence/render/ics/calendar.ts new file mode 100644 index 00000000..1f4d049f --- /dev/null +++ b/suite/apps/cadence/render/ics/calendar.ts @@ -0,0 +1,117 @@ +import { tagLabel } from "../../core/model/tags"; +import type { TimelineDoc } from "../../core/model/types"; +import { resolve } from "../../core/schedule/resolve"; + +/** + * The day as a calendar file (RFC 5545): every block, or one tag's — a + * supplier's own part of the day, for their own calendar. + * + * Times are the venue's clock, written as local times with no zone ("floating" + * in the RFC's word): the ceremony at 13:30 is at 13:30 on every phone, as it + * is in the Binder. Not UTC, because turning the venue's clock into UTC needs + * the day's offset, which a wedding may not have set — and the venue's clock + * is the one everybody there on the day is reading. + */ + +export interface CalendarOptions { + /** The wedding's date, `YYYY-MM-DD`: the wedding's own, never the timeline's placeholder. */ + date: string; + /** One tag's blocks; every block when absent. */ + tag?: string; + /** When the file was made. Every event carries it. */ + now: Date; +} + +const CRLF = "\r\n"; + +/** Text as a calendar reads it: backslashes, semicolons, commas and line breaks escaped. */ +export function escapeText(text: string): string { + return text.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\r?\n/g, "\\n"); +} + +/** A line folded at 75 octets, as the RFC asks, and never through a character. */ +export function fold(line: string): string { + const encoder = new TextEncoder(); + const lines: string[] = []; + let current = ""; + let octets = 0; + for (const char of line) { + const size = encoder.encode(char).length; + // A continuation starts with a space, which counts. + const limit = lines.length === 0 ? 75 : 74; + if (octets + size > limit) { + lines.push(current); + current = ""; + octets = 0; + } + current += char; + octets += size; + } + lines.push(current); + return lines.join(`${CRLF} `); +} + +const pad = (n: number, width = 2) => String(n).padStart(width, "0"); + +/** A minute of the day, on the wedding's date, as a local time: past midnight is the next day. */ +function localTime(date: string, minutes: number): string { + const [year, month, day] = date.split("-").map(Number); + const at = new Date(Date.UTC(year!, month! - 1, day!, 0, minutes)); + return `${at.getUTCFullYear()}${pad(at.getUTCMonth() + 1)}${pad(at.getUTCDate())}T${pad(at.getUTCHours())}${pad(at.getUTCMinutes())}00`; +} + +const stamp = (now: Date) => now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, ""); + +const slug = (text: string) => + text + .toLowerCase() + .replace(/&/g, "and") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + +export function calendar(doc: TimelineDoc, options: CalendarOptions): string { + if (!/^\d{4}-\d{2}-\d{2}$/.test(options.date)) throw new Error("A calendar needs the wedding's date."); + + const positions = new Map(resolve(doc).map((entry) => [entry.id, entry])); + const couple = doc.day.coupleNames.trim(); + const title = [couple || "The wedding", options.tag === undefined ? "" : tagLabel(doc, options.tag)].filter(Boolean).join(" — "); + // Stable, so importing the file again updates the events rather than doubling + // them; and particular to this wedding, because two weddings' days can share + // block ids when one was started from the other's running order. + const uidSuffix = `${options.date}.${slug(couple) || "wedding"}@trousseau`; + + const events = doc.blocks + .filter((block) => options.tag === undefined || block.tags.includes(options.tag)) + .flatMap((block) => { + const at = positions.get(block.id); + return at ? [{ block, at }] : []; + }) + .sort((a, b) => a.at.startMin - b.at.startMin) + .flatMap(({ block, at }) => [ + "BEGIN:VEVENT", + `UID:${block.id}.${uidSuffix}`, + `DTSTAMP:${stamp(options.now)}`, + `DTSTART:${localTime(options.date, at.startMin)}`, + // An end only where there is a length: the RFC wants it after the start, + // and an event with only a start is exactly what a moment is. + ...(at.contentEndMin > at.startMin ? [`DTEND:${localTime(options.date, at.contentEndMin)}`] : []), + `SUMMARY:${escapeText(block.label)}`, + ...(block.location.trim() ? [`LOCATION:${escapeText(block.location.trim())}`] : []), + ...(block.notes.trim() ? [`DESCRIPTION:${escapeText(block.notes.trim())}`] : []), + "END:VEVENT", + ]); + + return [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//Trousseau//Timeline//EN", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + `X-WR-CALNAME:${escapeText(title)}`, + ...events, + "END:VCALENDAR", + ] + .map(fold) + .join(CRLF) + .concat(CRLF); +} diff --git a/suite/apps/cadence/state/sliceBridge.test.ts b/suite/apps/cadence/state/sliceBridge.test.ts index 8fea8507..f0ee4f90 100644 --- a/suite/apps/cadence/state/sliceBridge.test.ts +++ b/suite/apps/cadence/state/sliceBridge.test.ts @@ -72,3 +72,23 @@ test("every edit republishes the resolved day, for Delegation and the Binder to const day = useTrousseauStore.getState().doc.day; expect(day?.blocks.map((block) => [block.label, block.startMin])).toEqual([["Ceremony", 810]]); }); + +/** + * Clocks nobody has set stay unset. + * + * The Timeline used to fill a missing offset with British Summer Time and + * write that back into the wedding with its next edit — renaming a block was + * enough — so a guess became the wedding's clocks, shown in the Day panel as + * if chosen, and trusted by the Binder over the phone's own. + */ +test("an unset offset stays unset through a Timeline edit, and one set is kept", () => { + open({ date: "2028-12-12" }); + expect(useTrousseauStore.getState().doc.event.utcOffsetMin).toBeNull(); + + useStore.getState().addBlock("Main day", { label: "Ceremony" }); + expect(useTrousseauStore.getState().doc.event.utcOffsetMin).toBeNull(); + expect(currentDoc().day.utcOffsetMin).toBeNull(); + + useStore.getState().setDay({ utcOffsetMin: 0 }); + expect(useTrousseauStore.getState().doc.event.utcOffsetMin).toBe(0); +}); diff --git a/suite/apps/cadence/state/store.test.ts b/suite/apps/cadence/state/store.test.ts index 77108f8a..16087ff4 100644 --- a/suite/apps/cadence/state/store.test.ts +++ b/suite/apps/cadence/state/store.test.ts @@ -117,6 +117,26 @@ describe("document actions", () => { expect(doc().styles["order-of-day"].fontFamily).toBe("Crimson Text"); }); + it("sets a journey's time, changes it from either end, and takes it away", () => { + state().setJourney(["The suite", "Orangery"], 5); + expect(doc().travel).toEqual([{ between: ["The suite", "Orangery"], minutes: 5 }]); + expect(history().past.at(-1)?.label).toBe("a journey's time"); + + state().setJourney(["orangery", "the suite"], 8); + expect(doc().travel).toEqual([{ between: ["The suite", "Orangery"], minutes: 8 }]); + + state().setJourney(["Orangery", "The suite"], null); + expect(doc().travel).toEqual([]); + }); + + it("puts nothing on the history for taking away a journey nobody typed", () => { + const before = doc(); + const steps = history().past.length; + state().setJourney(["The suite", "Lawn"], null); + expect(doc()).toBe(before); + expect(history().past).toHaveLength(steps); + }); + it("adds a lane once", () => { state().addLane("Children"); state().addLane("Children"); diff --git a/suite/apps/cadence/state/store.ts b/suite/apps/cadence/state/store.ts index 94db7ae3..0ab3605e 100644 --- a/suite/apps/cadence/state/store.ts +++ b/suite/apps/cadence/state/store.ts @@ -21,6 +21,7 @@ import { type Conflict, } from "../core/schedule/conflicts"; import { byId, resolve, type ResolvedBlock } from "../core/schedule/resolve"; +import { withJourney } from "../core/schedule/travel"; import { slack as computeSlack, type SlackReport } from "../core/schedule/slack"; import { whatIf, type Change, type WhatIf } from "../core/schedule/whatIf"; import { sunForDay, type SunTimes } from "../core/sun/solar"; @@ -100,6 +101,8 @@ export interface StoreState { setDay: (patch: Partial) => void; setTagDetail: (detail: TagDetail) => void; removeTagDetail: (tag: string) => void; + /** Minutes between two places, or null to stop checking them. */ + setJourney: (between: [string, string], minutes: number | null) => void; setStyle: (output: OutputId, patch: Partial) => void; addFont: (font: UploadedFont) => void; removeFont: (blobKey: string) => void; @@ -255,6 +258,12 @@ export const useStore = create((set, get) => { }; }), + setJourney: (between, minutes) => + edit("a journey's time", (doc) => { + const travel = withJourney(doc.travel, between, minutes); + return travel === doc.travel ? doc : { ...doc, travel }; + }), + removeTagDetail: (tag) => edit("removing a supplier's details", (doc) => ({ ...doc, diff --git a/suite/apps/cadence/ui/ExportBar.module.css b/suite/apps/cadence/ui/ExportBar.module.css index ea9a20f3..805b611f 100644 --- a/suite/apps/cadence/ui/ExportBar.module.css +++ b/suite/apps/cadence/ui/ExportBar.module.css @@ -1,6 +1,6 @@ .bar { display: flex; - align-items: center; + flex-direction: column; gap: var(--sp-2); flex: none; padding: var(--sp-3); @@ -8,6 +8,13 @@ max-width: 420px; } +/* The printed pieces on one line, the calendar on the next. */ +.group { + display: flex; + align-items: center; + gap: var(--sp-2); +} + .select { padding: 5px var(--sp-2); border: 1px solid var(--border); diff --git a/suite/apps/cadence/ui/ExportBar.tsx b/suite/apps/cadence/ui/ExportBar.tsx index f5f17d12..ef040d4d 100644 --- a/suite/apps/cadence/ui/ExportBar.tsx +++ b/suite/apps/cadence/ui/ExportBar.tsx @@ -1,10 +1,13 @@ import { useState } from "react"; import type { OutputId } from "../core/model/types"; +import { tagLabel, usedTags } from "../core/model/tags"; import { blockingConflicts } from "../core/schedule/conflicts"; +import { calendar } from "../render/ics/calendar"; import { browserFontSource } from "../render/pdf/fontSource"; import { getBlob } from "../state/blobStore"; import { useSchedule, useStore, useTimelineDoc } from "../state/store"; import { Button } from "@/components/ui/fields"; +import { useTrousseauStore } from "@/lib/store/useTrousseauStore"; import styles from "./ExportBar.module.css"; const FILENAMES: Record = { @@ -21,6 +24,11 @@ export function ExportBar() { const setUi = useStore((state) => state.setUi); const setNotice = useStore((state) => state.setNotice); const [busy, setBusy] = useState(false); + // "" is the whole day; otherwise one tag's part of it. + const [calendarTag, setCalendarTag] = useState(""); + // The wedding's own date. The timeline's has a placeholder until one is set, + // and a calendar on the wrong day is worse than none. + const weddingDate = useTrousseauStore((state) => state.doc.event.date); const blocking = blockingConflicts(schedule.conflicts); const empty = doc.blocks.length === 0; @@ -30,6 +38,37 @@ export function ExportBar() { ? `Fix ${blocking.length} clash${blocking.length === 1 ? "" : "es"} first — a printed sheet that contradicts itself is worse than none.` : null; + const slug = (text: string) => + text + .toLowerCase() + .replace(/&/g, "and") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + const fileStem = slug(doc.day.coupleNames) || "cadence"; + + const save = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.append(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + }; + + const calendarReason = reason ?? (weddingDate ? null : "Set the wedding's date first: a calendar needs the day it is on."); + const tags = usedTags(doc); + + const downloadCalendar = () => { + const tag = calendarTag === "" ? undefined : calendarTag; + const text = calendar(doc, { date: weddingDate, now: new Date(), ...(tag === undefined ? {} : { tag }) }); + save( + new Blob([text], { type: "text/calendar;charset=utf-8" }), + `${fileStem}-${tag === undefined ? "the-day" : slug(tagLabel(doc, tag)) || "tag"}.ics`, + ); + }; + const download = async (piece: OutputId | "timeline") => { setBusy(true); try { @@ -52,20 +91,10 @@ export function ExportBar() { ? await (await import("../render/pdf/orderOfDay")).renderOrderOfDay(doc, { fontSource }) : await (await import("../render/pdf/contactSheet")).renderContactSheet(doc, { fontSource, generatedOn }); - const slug = (doc.day.coupleNames || "cadence") - .toLowerCase() - .replace(/&/g, "and") - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); - - const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type: "application/pdf" })); - const link = document.createElement("a"); - link.href = url; - link.download = `${slug}-${piece === "timeline" ? "timeline" : FILENAMES[piece]}.pdf`; - document.body.append(link); - link.click(); - link.remove(); - URL.revokeObjectURL(url); + save( + new Blob([bytes as BlobPart], { type: "application/pdf" }), + `${fileStem}-${piece === "timeline" ? "timeline" : FILENAMES[piece]}.pdf`, + ); } catch (error) { setNotice(`The PDF could not be made: ${error instanceof Error ? error.message : "unknown problem"}`); } finally { @@ -75,37 +104,62 @@ export function ExportBar() { return (
- +
+ + + - + +
- +
+ + +
- {reason && {reason}} + {(reason ?? calendarReason) && {reason ?? calendarReason}}
); } diff --git a/suite/apps/cadence/ui/Sidebar.tsx b/suite/apps/cadence/ui/Sidebar.tsx index df2aee51..04a7128c 100644 --- a/suite/apps/cadence/ui/Sidebar.tsx +++ b/suite/apps/cadence/ui/Sidebar.tsx @@ -4,6 +4,7 @@ import { FontsPanel } from "./panels/FontsPanel"; import { InspectorPanel } from "./panels/InspectorPanel"; import { StylePanel } from "./panels/StylePanel"; import { TagsPanel } from "./panels/TagsPanel"; +import { TravelPanel } from "./panels/TravelPanel"; import styles from "./Sidebar.module.css"; export function Sidebar() { @@ -13,6 +14,7 @@ export function Sidebar() { + diff --git a/suite/apps/cadence/ui/panels/DayPanel.tsx b/suite/apps/cadence/ui/panels/DayPanel.tsx index d4278a27..a8eebf82 100644 --- a/suite/apps/cadence/ui/panels/DayPanel.tsx +++ b/suite/apps/cadence/ui/panels/DayPanel.tsx @@ -71,9 +71,14 @@ export function DayPanel() { onChange(setShop(bar, line.line, event.target.value as typeof line.shop), "where it is bought")} + className={CONTROL} + > + {SHOPS.map((shop) => ( + + ))} + + + + ); +} diff --git a/suite/components/binder/Binder.tsx b/suite/components/binder/Binder.tsx index 6919c7e3..12c5adde 100644 --- a/suite/components/binder/Binder.tsx +++ b/suite/components/binder/Binder.tsx @@ -5,8 +5,8 @@ import { useEffect, useMemo, useState } from "react"; import { Phone } from "lucide-react"; import { useTrousseauStore } from "@/lib/store/useTrousseauStore"; import { contacts, dayClock, findGuests, nowAndNext, runningOrder, takenKey, type BinderBlock } from "@/lib/binder/binder"; -import { resolveShot } from "@/lib/ensemble/resolve"; -import { readGuests, readSeating, readShots } from "@/lib/model/slices"; +import { resolveMembers } from "@/lib/cast/resolve"; +import { readCast, readGuests, readSeating, readShots } from "@/lib/model/slices"; import { formatClock } from "@/apps/cadence/core/time/minutes"; import { longDate } from "@/lib/dates"; @@ -253,6 +253,7 @@ function Shots() { const sections = useMemo(() => { const shots = readShots(doc); + const cast = readCast(doc); const guests = readGuests(doc); const seating = readSeating(doc); return shots.sections.map((section) => ({ @@ -260,7 +261,7 @@ function Shots() { name: section.name, shots: section.shots.map((shot) => ({ id: shot.id, - ...resolveShot(shot, guests, seating, shots.cast, shots.customRoles, doc.event), + ...resolveMembers(shot, guests, seating, cast.roles, cast.customRoles, doc.event), })), })); }, [doc]); diff --git a/suite/components/boxes/BoxesBoard.tsx b/suite/components/boxes/BoxesBoard.tsx new file mode 100644 index 00000000..6611663f --- /dev/null +++ b/suite/components/boxes/BoxesBoard.tsx @@ -0,0 +1,363 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { FileSpreadsheet, ListChecks, PackagePlus, Plus, Tag, Trash2, X } from "lucide-react"; +import { formatClock } from "@/apps/cadence/core/time/minutes"; +import { Button, Empty, IconButton, NumberField, Panel, SelectField, TextArea, TextField } from "@/components/ui/controls"; +import { NumberInput } from "@/components/ui/NumberInput"; +import { ToolUndo } from "@/components/shell/ToolUndo"; +import { addBox, addItem, moveItem, patchBox, patchItem, removeBox, removeItem, USUAL_BOXES, withUsualBoxes } from "@/lib/boxes/actions"; +import { dayPlaces, find, neededAt, packing, whereBy, type Place } from "@/lib/boxes/view"; +import { boxesCsv, boxRows } from "@/lib/boxes/rows"; +import { download } from "@/lib/data/file"; +import { personName } from "@/lib/model/slices"; +import { useBoxes, useCrew, useEvent, useGuests, useStatus, useWriters } from "@/lib/model/useSuite"; +import { useTrousseauStore } from "@/lib/store/useTrousseauStore"; +import type { Box, Boxes, Crew, Guest } from "@/lib/model/types"; + +const CONTROL = "rounded border border-charcoal/15 bg-parchment px-2 py-1 text-sm text-charcoal focus:border-gold"; + +/** + * The boxes: what is in each, found in a moment, and where each has to be by + * when — the where and the when being the Timeline's, for the block a box is + * needed for. It keeps no copy: it reads the wedding and writes it, on the + * wedding's one history. + */ +export function BoxesBoard() { + const status = useStatus(); + const boxes = useBoxes(); + const crew = useCrew(); + const guests = useGuests(); + const event = useEvent(); + const places = useTrousseauStore((s) => dayPlaces(s.doc)); + const { setBoxes } = useWriters(); + const [selectedId, setSelectedId] = useState(null); + const [query, setQuery] = useState(""); + const [note, setNote] = useState(null); + const found = useMemo(() => find(boxes, query), [boxes, query]); + + if (status !== "ready") return null; + + const selected = boxes.boxes.find((box) => box.id === selectedId) ?? null; + const have = new Set(boxes.boxes.map((box) => box.name.trim().toLowerCase())); + const usualMissing = USUAL_BOXES.some((usual) => !have.has(usual.name.toLowerCase())); + + const stem = (event.coupleNames || "wedding").toLowerCase().replace(/&/g, "and").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "wedding"; + const rows = () => boxRows(boxes, places, crew, guests); + + const print = async (what: "labels" | "list") => { + setNote(null); + try { + const { browserFontSource } = await import("@/apps/brigade/render/pdf/fontSource"); + const fontSource = browserFontSource(); + const bytes = + what === "labels" + ? await (await import("@/lib/boxes/render/pdf/labels")).renderBoxLabels(rows(), { fontSource }) + : await (await import("@/lib/boxes/render/pdf/packingList")).renderPackingList(rows(), { + fontSource, + coupleNames: event.coupleNames, + generatedOn: `Made with Trousseau, ${new Date().toLocaleDateString()}`, + }); + download(`${stem}-${what === "labels" ? "box-labels" : "packing-list"}.pdf`, new Blob([bytes as BlobPart], { type: "application/pdf" })); + } catch (cause) { + setNote(cause instanceof Error ? cause.message : "The page could not be made."); + } + }; + + const add = () => { + const next = addBox(boxes); + setBoxes(next, { label: "adding a box" }); + setSelectedId(next.boxes[next.boxes.length - 1]!.id); + }; + + return ( +
+ +
+
+ + {usualMissing && ( + + )} +
+ {boxes.boxes.length > 0 && ( +
+ + + +
+ )} + {note && ( +

+ {note} +

+ )} +
+ +
+ + {query.trim() ? ( +
    + {found.length === 0 &&
  • Not in any box.
  • } + {found.map(({ box, item }) => ( +
  • + +
  • + ))} +
+ ) : boxes.boxes.length === 0 ? ( +
+ + No boxes yet. Add the usual ones — the rings and the paperwork, getting ready, the day’s odds and + ends, overnight — and change anything in them, or add boxes one at a time. + +
+ ) : ( +
    + {boxes.boxes.map((box) => { + const { place, lost } = neededAt(box, places); + const { packed, total } = packing(box); + // Nobody taking it matters only for a box that has somewhere to be. + const trouble = lost || (box.blockId !== null && box.personIds.length === 0); + return ( +
  1. + +
  2. + ); + })} +
+ )} +
+ +
+ {selected ? ( + setSelectedId(null)} + /> + ) : ( +
+ {boxes.boxes.length === 0 ? "The boxes appear on the left." : "Pick a box on the left."} +
+ )} +
+
+ ); +} + +function BoxInspector({ + box, + boxes, + crew, + guests, + places, + onChange, + onRemoved, +}: { + box: Box; + boxes: Boxes; + crew: Crew; + guests: Record; + places: ReadonlyMap; + onChange: (next: Boxes, options: { label: string }) => void; + onRemoved: () => void; +}) { + const [newItem, setNewItem] = useState(""); + const { lost } = neededAt(box, places); + const patch = (change: Partial>, label: string) => onChange(patchBox(boxes, box.id, change), { label }); + const blocks = [...places.entries()].sort(([, a], [, b]) => a.startMin - b.startMin); + const others = boxes.boxes.filter((other) => other.id !== box.id); + const people = new Map(crew.people.map((person) => [person.id, person])); + const addable = crew.people.filter((person) => !box.personIds.includes(person.id)); + + const addNew = () => { + const next = addItem(boxes, box.id, newItem); + if (next !== boxes) onChange(next, { label: "what is in a box" }); + setNewItem(""); + }; + + return ( +
+ +
+
+ patch({ number }, "a box's number")} /> +
+
+ patch({ name }, "a box's name")} placeholder="e.g. Getting ready — Alex" /> +
+
+
+ + + {lost && ( +

The part of the day it was needed for is no longer on the Timeline. Say where it is needed now.

+ )} + patch({ blockId: blockId || null }, "where a box is needed")} + options={[ + { value: "", label: "Not for the day" }, + ...blocks.map(([id, place]) => ({ + value: id, + label: `${formatClock(place.startMin)} · ${place.label}${place.location ? ` · ${place.location}` : ""}`, + })), + ]} + /> +
+ + +
    + {box.personIds.map((id) => { + const person = people.get(id); + const name = person ? personName(person, guests) : "Someone no longer in the crew"; + return ( +
  • + {name} + +
  • + ); + })} + {box.personIds.length === 0 && + (box.blockId === null ? ( +
  • Nobody named: fine for a box that is not for the day.
  • + ) : ( +
  • Nobody is taking it there yet.
  • + ))} +
+ {crew.people.length === 0 ? ( +

Add the people helping on the day in Delegation, and they can be picked here.

+ ) : ( + addable.length > 0 && ( + + ) + )} +
+ + +
    + {box.items.map((item) => ( +
  • + onChange(patchItem(boxes, box.id, item.id, { packed: event.target.checked }), { label: "packing" })} + className="accent-[var(--color-gold)]" + /> + onChange(patchItem(boxes, box.id, item.id, { label: event.target.value }), { label: "what is in a box" })} + className={`${CONTROL} min-w-0 flex-1 ${item.packed ? "text-slate line-through" : ""}`} + /> + onChange(patchItem(boxes, box.id, item.id, { quantity: Math.max(1, quantity ?? 1) }), { label: "what is in a box" })} + className={`${CONTROL} w-14`} + /> + {others.length > 0 && ( + + )} + onChange(removeItem(boxes, box.id, item.id), { label: "what is in a box" })} /> +
  • + ))} + {box.items.length === 0 &&
  • Nothing in it yet.
  • } +
+
+ + +
+
+ + +