Completes the issue. The occurrence is the event marker, so a weekly event is
one week rather than a whole series, and it appears in the Events tab in its own
right.
is_event is NOT NULL DEFAULT false on the occurrence, not a nullable override
like the purpose and hidden columns in the previous commit. A series is not an
event that individual weeks opt out of -- one week is the event -- so the
occurrence is authoritative with nothing to inherit. The editor gets a checkbox
rather than a three-way select for the same reason, and "Clear all overrides for
this date" deliberately leaves it alone, because being an event is not an
override of anything.
Nothing needs backfilling. Every booking currently flagged is_event is a
One-Time Room, and the Administrator UI has never offered Mark Event on weekly
bookings at all -- only a badge -- so booking-level is_event stays exactly as it
is for one-time and tabling.
The Events route cannot reach these through its existing query: filtering an
embedded resource narrows the child array without selecting the parent, so a
second query fetches flagged occurrences with their ancestry and folds them in.
Each becomes its own row, with `<bookingId>:<date>` as its id -- the checklist
and the pending-actions highlighting are both keyed by row id, and every flagged
week of one series would otherwise collide on the booking's id. The row carries
occurrence_date so the detail block prints one date instead of a "Sep 1 - Sep 1"
range, and its purpose resolves through the occurrence override.
event_tracking had to change shape. booking_id was the primary key, so a booking
had exactly one checklist and two flagged weeks of the same series would have
shared it -- ticking a form on one would tick it on the other. The target is now
(booking_id, occurrence_date), null meaning the booking itself, which is what
one-time and tabling events keep using and what every existing row already is.
Keyed on the date rather than an occurrence id deliberately: the weekly PATCH
handler regenerates occurrences on every save, deleting them all and reinserting
with fresh ids, and values survive only by being carried across on the date. A
foreign key to weekly_room_occurrences(id) would have dropped every checklist
the next time anyone edited the booking. The date is the identifier that write
model actually preserves.
UNIQUE NULLS NOT DISTINCT is what makes the booking-level row work; a plain
unique index treats NULLs as distinct, so the upsert would insert a new row on
every toggle instead of updating. Both migrations were applied and rolled back
against production to confirm the primary-key swap preserves the existing rows.
Migration 20260829001000 is not applied.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Starts #55. Two of the three overrides, end to end —
purposeandhidden. Draft, because the migration isn't applied andis_eventis deliberately left out (reasoning below).Approach
Occurrences already diverge from their series on room, time, status and reservation code — each a nullable column on
weekly_room_occurrenceswhere NULL means inherit. These follow that convention exactly; the only difference is they inherit from thebookingsrow two levels up rather than fromweekly_room_bookings.Why
hiddenis a nullable booleanIt needs three states, not two: inherit, forced visible, forced hidden.
not null default falsewould collapse inherit into visible, making it impossible to publish a single week of an otherwise hidden series — which is the more interesting half of what the issue asks for.That third state changes how visibility is decided, so the filter in
lib/my-rooms-data.tsis reworked. Previously a booking was dropped whenhidden && !canManage. Now hidden occurrences are stripped first, and a weekly series survives if any occurrence remains:hidden = truehidden = falseOne-time and tabling bookings have no per-occurrence override and keep the old booking-level rule.
The stripping happens server-side, deliberately. Filtering in the client's flatten step would mean sending a hidden occurrence to a browser that isn't allowed to see it and trusting the UI not to draw it — the shape of the leak #29 already had to be fixed once.
Purpose precedence
Resolves with
??, not the||the neighbouring room/time fields use, since only null should mean inherit. Empty input is normalised to null on write, so a cleared field reads as inherit rather than as a booking whose purpose is blank.What's not here
is_event. Storing it is trivial — but the Events tab finds events by queryingbookings where is_event, and its rows are bookings with sessions nested underneath. Teaching it about occurrence-level events changes both that query and the question of how such an event should be presented in a list whose unit is the booking. That's a design decision, not a mechanical addition, so I left it out entirely rather than ship a column and a toggle that nothing reads.Worth deciding before I build it: should an occurrence-level event appear as its own row in the Events tab, or as the parent booking with only the flagged occurrences listed under it?
Before merging
Typecheck, build and lint all pass (lint 3 warnings better than
dev). The compiler was genuinely useful here: it caught two separate duplicateWeeklyOccurrencedeclarations in the admin components that also needed the new fields.Not manually tested — this needs the migration applied and a signed-in admin to exercise the editor.