+
+ {/* A select rather than a checkbox because there are three
+ states, not two: inherit, forced visible, forced hidden.
+ Forced-visible is what lets a single week of a hidden
+ series be published, and a checkbox cannot express the
+ difference between that and inheriting a visible parent. */}
+
+
+
+
+
{isSenate && (
diff --git a/app/(dashboard)/administrator/weekly-booking-grid.tsx b/app/(dashboard)/administrator/weekly-booking-grid.tsx
index 4c280fb..f689a18 100644
--- a/app/(dashboard)/administrator/weekly-booking-grid.tsx
+++ b/app/(dashboard)/administrator/weekly-booking-grid.tsx
@@ -12,6 +12,12 @@ interface WeeklyOccurrence {
status: string | null
reservation_code: string | null
senate_type: string | null
+ /** Overrides the booking's purpose for this date; null inherits (issue #55). */
+ purpose: string | null
+ /** Overrides the booking's hidden flag; null inherits (issue #55). */
+ hidden: boolean | null
+ /** Marks this single occurrence as an event. Authoritative, not an override (issue #55). */
+ is_event: boolean
}
interface WeeklyBooking {
diff --git a/app/(dashboard)/events/page.tsx b/app/(dashboard)/events/page.tsx
index 750f967..9b6f699 100644
--- a/app/(dashboard)/events/page.tsx
+++ b/app/(dashboard)/events/page.tsx
@@ -6,7 +6,17 @@ import { Skeleton } from '@/app/_components/skeleton'
import { usePendingActionsWatch } from '../pending-actions-watch'
interface EventBooking {
+ /**
+ * A booking event uses the booking's id. A weekly occurrence event uses
+ * `:`, because every flagged week of one series would
+ * otherwise share an id -- and this keys both the checklist state and the
+ * pending-actions highlighting.
+ */
id: string
+ /** The real booking id, set only on occurrence rows where `id` is synthetic. */
+ booking_id?: string
+ /** Set when this row is a single flagged week rather than a whole booking. */
+ occurrence_date: string | null
purpose: string
type: string
created_at: string
@@ -111,7 +121,14 @@ function BookingDetails({ booking }: { booking: EventBooking }) {
{sessions.map((w, i) => (
+ {/* An occurrence event is one week, so a "Dates: Sep 1 – Sep 1" range
+ would be noise. The row carries occurrence_date precisely so this
+ can say Date instead. */}
+ {booking.occurrence_date ? (
+
))}
@@ -167,23 +184,32 @@ export default function EventsPage() {
fetchEvents()
}, [])
- const updateStep = async (bookingId: string, step: 'event_management_form' | 'engage_form', checked: boolean) => {
+ // Takes the row rather than an id: the checklist is keyed by the row's id
+ // (synthetic for an occurrence event) while the save has to address the real
+ // booking plus, for an occurrence, its date.
+ const updateStep = async (row: EventBooking, step: 'event_management_form' | 'engage_form', checked: boolean) => {
+ const key = row.id
// Optimistic update, rolled back below if the save fails.
setChecklist(prev => ({
...prev,
- [bookingId]: { ...prev[bookingId], [step]: checked },
+ [key]: { ...prev[key], [step]: checked },
}))
const res = await fetch('/api/events/checklist', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ booking_id: bookingId, step, checked }),
+ body: JSON.stringify({
+ booking_id: row.booking_id ?? row.id,
+ occurrence_date: row.occurrence_date,
+ step,
+ checked,
+ }),
})
if (!res.ok) {
setChecklist(prev => ({
...prev,
- [bookingId]: { ...prev[bookingId], [step]: !checked },
+ [key]: { ...prev[key], [step]: !checked },
}))
}
}
@@ -261,14 +287,14 @@ export default function EventsPage() {
checked={steps.event_management_form}
dueDate={b.event_management_form_due}
danger={isActionDanger(`event-form:${b.id}:mgmt`)}
- onChange={checked => updateStep(b.id, 'event_management_form', checked)}
+ onChange={checked => updateStep(b, 'event_management_form', checked)}
/>
updateStep(b.id, 'engage_form', checked)}
+ onChange={checked => updateStep(b, 'engage_form', checked)}
/>
diff --git a/app/(dashboard)/my-rooms/shared.ts b/app/(dashboard)/my-rooms/shared.ts
index 13b11db..dead400 100644
--- a/app/(dashboard)/my-rooms/shared.ts
+++ b/app/(dashboard)/my-rooms/shared.ts
@@ -255,7 +255,11 @@ export function flattenMyRooms(data: MyRoomsResponse, today: string): FlatBookin
bookingId: b.id,
type: 'Weekly Room',
bodyName: b.bodies?.name || '',
- purpose: b.purpose,
+ // `??` rather than the `||` used by the fields below: an occurrence may
+ // deliberately override the series purpose, and only null means inherit.
+ // Empty strings are normalised to null when written (see the weekly PATCH
+ // handler), so they cannot reach here and read as an intentional blank.
+ purpose: occ.purpose ?? b.purpose,
location: occ.room_name || w.room_name,
date: occ.occurrence_date,
startTime: occ.start_time || w.start_time,
diff --git a/app/api/administrator/bookings/route.ts b/app/api/administrator/bookings/route.ts
index a13aee5..ceca428 100644
--- a/app/api/administrator/bookings/route.ts
+++ b/app/api/administrator/bookings/route.ts
@@ -56,7 +56,7 @@ export async function GET(request: Request) {
booking_bodies(body_id, bodies(name)),
creator_role,
weekly_room_bookings(id, room_name, start_date, end_date, start_time, end_time, status, reservation_code,
- weekly_room_occurrences(id, occurrence_date, room_name, start_time, end_time, status, reservation_code, senate_type)
+ weekly_room_occurrences(id, occurrence_date, room_name, start_time, end_time, status, reservation_code, senate_type, purpose, hidden, is_event)
)
`)
.eq('type', 'Weekly Room')
diff --git a/app/api/administrator/bookings/weekly/route.ts b/app/api/administrator/bookings/weekly/route.ts
index 8627adf..7b5d6d8 100644
--- a/app/api/administrator/bookings/weekly/route.ts
+++ b/app/api/administrator/bookings/weekly/route.ts
@@ -6,6 +6,7 @@ import { sendBookingUpdatedEmail } from '@/lib/emails/booking-updated'
import { checkRateLimit } from '@/lib/check-rate-limit'
import { getAuthedUserWithLiveRoles } from '@/lib/authorization'
import { waitUntil } from '@vercel/functions'
+
import {
loadScopeContext,
validateScopeSelection,
@@ -14,6 +15,27 @@ import {
type ScopedRow,
} from '@/lib/booking-scope'
+/**
+ * One occurrence as the editor submits it (issue #55).
+ *
+ * Everything but the date and is_event is an override -- of the parent series,
+ * or of the booking above it for purpose and hidden -- where null means inherit.
+ * is_event is not an override: a weekly event is marked on the week it happens,
+ * so the occurrence is authoritative and has nothing to inherit from.
+ */
+interface OccurrenceInput {
+ occurrence_date: string
+ room_name: string | null
+ start_time: string | null
+ end_time: string | null
+ status: string | null
+ reservation_code: string | null
+ senate_type: string | null
+ purpose: string | null
+ hidden: boolean | null
+ is_event: boolean
+}
+
const adminSupabase = createAdminClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
@@ -163,7 +185,7 @@ export async function PATCH(request: Request) {
const dates = getWeeklyDates(start_date, end_date)
const newOccurrences = dates.map(date => {
- const existing = occurrences.find((o: { occurrence_date: string; room_name: string | null; start_time: string | null; end_time: string | null; status: string | null; reservation_code: string | null; senate_type: string | null }) => o.occurrence_date === date)
+ const existing = occurrences.find((o: OccurrenceInput) => o.occurrence_date === date)
return {
weekly_booking_id: weekly_id,
occurrence_date: date,
@@ -173,6 +195,18 @@ export async function PATCH(request: Request) {
status: existing?.status || null,
reservation_code: existing?.reservation_code || null,
senate_type: existing?.senate_type ?? null,
+ // Issue #55. Both inherit from the booking when null.
+ //
+ // purpose is trimmed, and an empty string collapses to null -- clearing the
+ // field in the editor means "inherit", not "this week has a blank purpose".
+ purpose: existing?.purpose?.trim() || null,
+ // `?? null`, not `|| null`: false is meaningful here. It forces an
+ // occurrence visible even when its series is hidden, and `||` would
+ // silently turn that back into inherit.
+ hidden: existing?.hidden ?? null,
+ // Not an override: the occurrence is where a weekly event is marked, so an
+ // absent value is simply "not an event" rather than "inherit".
+ is_event: existing?.is_event ?? false,
}
})
@@ -206,8 +240,11 @@ export async function PATCH(request: Request) {
const recipients = await resolveBookingRecipients(adminSupabase, scopedRow)
if (recipients.length && auditLog) {
+ // `hidden != null` rather than a truthiness test: an occurrence forced
+ // visible (false) has been changed just as much as one forced hidden.
const changedOcc = newOccurrences.find(
o => o.room_name || o.start_time || o.end_time || o.status || o.reservation_code
+ || o.purpose || o.hidden != null
) ?? newOccurrences[0]
await adminSupabase.from('user_alerts').insert(
recipients.map(r => ({
diff --git a/app/api/events/checklist/route.ts b/app/api/events/checklist/route.ts
index ea0686c..246e62d 100644
--- a/app/api/events/checklist/route.ts
+++ b/app/api/events/checklist/route.ts
@@ -20,7 +20,7 @@ export async function PATCH(request: Request) {
const rateLimitRes = await checkRateLimit(user.id)
if (rateLimitRes) return rateLimitRes
- const { booking_id, step, checked } = await request.json()
+ const { booking_id, occurrence_date, step, checked } = await request.json()
if (!booking_id || !step || typeof checked !== 'boolean') {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 })
@@ -30,11 +30,26 @@ export async function PATCH(request: Request) {
return NextResponse.json({ error: 'Invalid step' }, { status: 400 })
}
+ // Weekly events are marked per occurrence (issue #55), so one booking can own
+ // several checklists. null addresses the booking's own, which is what one-time
+ // and tabling events use and what every pre-existing row is.
+ if (occurrence_date != null && !/^\d{4}-\d{2}-\d{2}$/.test(occurrence_date)) {
+ return NextResponse.json({ error: 'Invalid occurrence date' }, { status: 400 })
+ }
+
const { error } = await adminSupabase
.from('event_tracking')
.upsert(
- { booking_id, [step]: checked, updated_at: new Date().toISOString() },
- { onConflict: 'booking_id' }
+ {
+ booking_id,
+ occurrence_date: occurrence_date ?? null,
+ [step]: checked,
+ updated_at: new Date().toISOString(),
+ },
+ // Targets the UNIQUE NULLS NOT DISTINCT constraint the migration adds. A
+ // plain unique index would treat every NULL as distinct and insert a fresh
+ // booking-level row on each toggle instead of updating the existing one.
+ { onConflict: 'booking_id,occurrence_date' }
)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
diff --git a/app/api/events/route.ts b/app/api/events/route.ts
index b47cfa6..cd0f7d8 100644
--- a/app/api/events/route.ts
+++ b/app/api/events/route.ts
@@ -19,6 +19,37 @@ const adminSupabase = createAdminClient(
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
+/** One event_tracking row as embedded above. */
+interface TrackingRow {
+ event_management_form: boolean
+ engage_form: boolean
+ occurrence_date: string | null
+}
+
+/** A flagged weekly occurrence with the series and booking it belongs to. */
+interface EventOccurrenceRow {
+ occurrence_date: string
+ room_name: string | null
+ start_time: string | null
+ end_time: string | null
+ purpose: string | null
+ weekly_room_bookings: {
+ room_name: string
+ start_time: string
+ end_time: string
+ bookings: {
+ id: string
+ purpose: string
+ type: string
+ created_at: string
+ semester_id: string | null
+ bodies: { name: string } | null
+ users: { full_name: string } | null
+ event_tracking: TrackingRow[] | null
+ } | null
+ } | null
+}
+
export async function GET() {
const supabase = await createClient()
@@ -38,41 +69,126 @@ export async function GET() {
if (!activeSemester) return NextResponse.json({ bookings: [] })
- const [{ data: bookings, error }, { data: settingsRow }] = await Promise.all([
- adminSupabase
- .from('bookings')
- .select(`
- id, purpose, type, created_at,
- bodies(name),
- users!bookings_created_by_fkey(full_name),
- one_time_room_bookings(room_name, booking_date, start_time, end_time),
- weekly_room_bookings(room_name, start_date, end_date, start_time, end_time, weekly_room_occurrences(occurrence_date)),
- tabling_bookings(
- tabling_sessions(location, session_date, start_time, end_time)
- ),
- event_tracking(event_management_form, engage_form)
- `)
- .eq('is_event', true)
- .eq('semester_id', activeSemester.id),
- supabase.from('app_settings').select('*').eq('id', 1).maybeSingle(),
- ])
+ const [{ data: bookings, error }, { data: eventOccurrences, error: occError }, { data: settingsRow }] =
+ await Promise.all([
+ adminSupabase
+ .from('bookings')
+ .select(`
+ id, purpose, type, created_at,
+ bodies(name),
+ users!bookings_created_by_fkey(full_name),
+ one_time_room_bookings(room_name, booking_date, start_time, end_time),
+ weekly_room_bookings(room_name, start_date, end_date, start_time, end_time, weekly_room_occurrences(occurrence_date)),
+ tabling_bookings(
+ tabling_sessions(location, session_date, start_time, end_time)
+ ),
+ event_tracking(event_management_form, engage_form, occurrence_date)
+ `)
+ .eq('is_event', true)
+ .eq('semester_id', activeSemester.id),
+
+ // Weekly events are marked on the occurrence and never on the booking, so
+ // they are unreachable from the query above: filtering an embedded resource
+ // narrows the child array, it does not select the parent. Fetched
+ // separately and folded in below. `!inner` drops occurrences whose ancestry
+ // is missing rather than emitting a row with no booking behind it.
+ adminSupabase
+ .from('weekly_room_occurrences')
+ .select(`
+ occurrence_date, room_name, start_time, end_time, purpose,
+ weekly_room_bookings!inner(
+ room_name, start_time, end_time,
+ bookings!inner(
+ id, purpose, type, created_at, semester_id,
+ bodies(name),
+ users!bookings_created_by_fkey(full_name),
+ event_tracking(event_management_form, engage_form, occurrence_date)
+ )
+ )
+ `)
+ .eq('is_event', true),
+
+ supabase.from('app_settings').select('*').eq('id', 1).maybeSingle(),
+ ])
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
+ if (occError) return NextResponse.json({ error: occError.message }, { status: 500 })
// Each form's due date is the near edge of its Danger Range (issue #45) --
// the same settings the "danger" flash (dangerStart) already reads, just the
// other end of the pair.
const s = settingsFromRow(settingsRow as SettingsRow | null)
- const withDueDates = (bookings || []).map(b => {
- const eventDate = minDate(sessionDatesOf(b))
- return {
- ...b,
- event_date: eventDate,
- event_management_form_due: eventDate ? subtractDays(eventDate, s.eventMgmt[1]) : null,
- engage_form_due: eventDate ? subtractDays(eventDate, s.eventEngage[1]) : null,
- }
+
+ /**
+ * event_tracking is now one-to-many -- a booking can have a checklist of its
+ * own plus one per event occurrence -- so the embed returns an array where it
+ * used to return a single row. Pick the entry for this target: the one whose
+ * occurrence_date matches, or the booking-level row (null) for a booking event.
+ */
+ const trackingFor = (rows: TrackingRow[] | null | undefined, date: string | null) =>
+ (rows ?? []).find(t => (t.occurrence_date ?? null) === date) ?? null
+
+ /** Adds the two derived due dates, which depend only on when the event is. */
+ const withDates = (row: T, eventDate: string | null) => ({
+ ...row,
+ event_date: eventDate,
+ event_management_form_due: eventDate ? subtractDays(eventDate, s.eventMgmt[1]) : null,
+ engage_form_due: eventDate ? subtractDays(eventDate, s.eventEngage[1]) : null,
})
+ const bookingEvents = (bookings || []).map(b =>
+ withDates(
+ { ...b, occurrence_date: null, event_tracking: trackingFor(b.event_tracking as TrackingRow[], null) },
+ minDate(sessionDatesOf(b))
+ )
+ )
+
+ /**
+ * One row per flagged occurrence: the occurrence is the event, so it is listed
+ * in its own right rather than nested under its series.
+ *
+ * The id is `:` because the checklist and the pending-actions
+ * highlighting are both keyed by row id, and every flagged week of the same
+ * series would otherwise collide on the booking's id. Its shape mirrors a
+ * booking closely enough for the page to render it unchanged, with
+ * occurrence_date set so the detail block knows to show one date rather than a
+ * range.
+ */
+ const occurrenceEvents = (eventOccurrences || [])
+ .map(o => o as unknown as EventOccurrenceRow)
+ .map(o => {
+ const weekly = o.weekly_room_bookings
+ const booking = weekly?.bookings
+ if (!booking || booking.semester_id !== activeSemester.id) return null
+ return withDates(
+ {
+ id: `${booking.id}:${o.occurrence_date}`,
+ booking_id: booking.id,
+ occurrence_date: o.occurrence_date,
+ // The occurrence's purpose override wins, per issue #55.
+ purpose: o.purpose ?? booking.purpose,
+ type: booking.type,
+ created_at: booking.created_at,
+ bodies: booking.bodies,
+ users: booking.users,
+ one_time_room_bookings: null,
+ tabling_bookings: null,
+ weekly_room_bookings: [{
+ room_name: o.room_name ?? weekly.room_name,
+ start_date: o.occurrence_date,
+ end_date: o.occurrence_date,
+ start_time: o.start_time ?? weekly.start_time,
+ end_time: o.end_time ?? weekly.end_time,
+ }],
+ event_tracking: trackingFor(booking.event_tracking, o.occurrence_date),
+ },
+ o.occurrence_date
+ )
+ })
+ .filter((r): r is NonNullable => r !== null)
+
+ const withDueDates = [...bookingEvents, ...occurrenceEvents]
+
// Issue #48: ordered by the event's own date -- the earliest session date
// across any of its child bookings -- not by when the tracking row was
// created. A booking with no session date at all (shouldn't happen for a
diff --git a/lib/my-rooms-data.ts b/lib/my-rooms-data.ts
index 263f11d..7ff1432 100644
--- a/lib/my-rooms-data.ts
+++ b/lib/my-rooms-data.ts
@@ -27,6 +27,19 @@ interface BookingRow {
booking_bodies: { body_id: string; bodies: { name: string } | null }[] | null
}
+/**
+ * The weekly shape, narrowed enough to reason about per-occurrence visibility.
+ *
+ * Only the fields the hidden filter touches are declared; the rest of the row is
+ * passed through untouched, which is why this widens BookingRow rather than
+ * replacing it.
+ */
+interface WeeklyBookingRow extends BookingRow {
+ weekly_room_bookings?: {
+ weekly_room_occurrences?: { hidden: boolean | null }[] | null
+ }[] | null
+}
+
export interface MyRoomsPayload {
oneTimeBookings: unknown[]
weeklyBookings: unknown[]
@@ -122,7 +135,7 @@ export async function fetchMyRooms(
.select(`
${SELECT_BASE},
weekly_room_bookings(id, room_name, start_time, end_time, status, reservation_code,
- weekly_room_occurrences(id, occurrence_date, room_name, start_time, end_time, status, reservation_code, senate_type)
+ weekly_room_occurrences(id, occurrence_date, room_name, start_time, end_time, status, reservation_code, senate_type, purpose, hidden, is_event)
)
`)
.eq('type', 'Weekly Room')
@@ -173,6 +186,34 @@ export async function fetchMyRooms(
*/
const memberCtx = { ...ctx, isAdmin: false }
+ /**
+ * Drops occurrences the caller should not see (issue #55).
+ *
+ * A weekly occurrence can now override its booking's `hidden`, so visibility is
+ * no longer a property of the booking alone: a visible series can hide one
+ * week, and a hidden series can expose one. `occ.hidden ?? booking.hidden` is
+ * the precedence -- NULL inherits.
+ *
+ * This runs here, server-side, and not in the client's flatten step. Filtering
+ * on the client would mean sending a hidden occurrence to a browser that is not
+ * allowed to see it and trusting the UI not to draw it, which is the shape of
+ * the leak that issue #29 already had to be fixed once.
+ */
+ const stripHiddenOccurrences = (b: BookingRow & { canManage: boolean }) => {
+ if (b.canManage) return b
+ const weekly = (b as WeeklyBookingRow).weekly_room_bookings
+ if (!weekly) return b
+ return {
+ ...b,
+ weekly_room_bookings: weekly.map(w => ({
+ ...w,
+ weekly_room_occurrences: (w.weekly_room_occurrences ?? []).filter(
+ occ => !(occ.hidden ?? b.hidden)
+ ),
+ })),
+ }
+ }
+
const decorate = (rows: BookingRow[] | null) =>
(rows ?? [])
.map(b => ({
@@ -183,7 +224,20 @@ export async function fetchMyRooms(
(b.booking_bodies ?? []).map(x => x.body_id)
),
}))
- .filter(b => !b.hidden || b.canManage)
+ .map(stripHiddenOccurrences)
+ // Visibility is no longer decided by the booking alone. A manageable
+ // booking always stays. Otherwise a weekly series survives if any of its
+ // occurrences is visible -- which is what lets a single week of a hidden
+ // series be published by setting `hidden = false` on it -- and it is
+ // dropped once stripHiddenOccurrences has emptied it. One-time and tabling
+ // bookings have no per-occurrence override, so they keep the original
+ // booking-level rule.
+ .filter(b => {
+ if (b.canManage) return true
+ const weekly = (b as WeeklyBookingRow).weekly_room_bookings
+ if (!weekly) return !b.hidden
+ return weekly.some(w => (w.weekly_room_occurrences ?? []).length > 0)
+ })
return {
oneTimeBookings: decorate(oneTimeBookings as BookingRow[] | null),
diff --git a/supabase/migrations/20260829000000_occurrence_purpose_and_hidden.sql b/supabase/migrations/20260829000000_occurrence_purpose_and_hidden.sql
new file mode 100644
index 0000000..b8c83d3
--- /dev/null
+++ b/supabase/migrations/20260829000000_occurrence_purpose_and_hidden.sql
@@ -0,0 +1,29 @@
+-- Per-occurrence overrides for purpose and hidden (issue #55).
+--
+-- A weekly occurrence can already diverge from its series on room, time, status
+-- and reservation code. Each of those is a nullable column on
+-- weekly_room_occurrences where NULL means "inherit from the parent", and these
+-- follow exactly that convention -- the difference being that they inherit from
+-- the `bookings` row two levels up rather than from weekly_room_bookings.
+--
+-- Nullable boolean for `hidden` is deliberate, and is why this is not
+-- `default false`. The column needs three states: inherit (NULL), forced visible
+-- (false) and forced hidden (true). A NOT NULL default would collapse "inherit"
+-- into "visible" and make it impossible to un-hide a single occurrence of a
+-- hidden series, or to reveal one later by changing the parent.
+--
+-- The issue also asks for a per-occurrence `is_event`. That is not here: storing
+-- it is trivial, but the Events tab finds events by querying `bookings` where
+-- is_event, and teaching it about occurrence-level events changes both that
+-- query and how such an event is presented in a list whose rows are bookings.
+-- Left for a follow-up rather than shipping a column nothing reads.
+
+alter table public.weekly_room_occurrences
+ add column if not exists purpose text,
+ add column if not exists hidden boolean;
+
+comment on column public.weekly_room_occurrences.purpose is
+ 'Overrides bookings.purpose for this occurrence. NULL inherits.';
+
+comment on column public.weekly_room_occurrences.hidden is
+ 'Overrides bookings.hidden for this occurrence. NULL inherits; false forces visible even when the booking is hidden.';
diff --git a/supabase/migrations/20260829001000_occurrence_events.sql b/supabase/migrations/20260829001000_occurrence_events.sql
new file mode 100644
index 0000000..6b11e22
--- /dev/null
+++ b/supabase/migrations/20260829001000_occurrence_events.sql
@@ -0,0 +1,59 @@
+-- Weekly events are marked on the occurrence, not the booking (issue #55).
+--
+-- Separate from 20260829000000 so that migration can be applied independently;
+-- this one does more than add a column.
+--
+-- `is_event` here is NOT NULL DEFAULT false, unlike the purpose/hidden overrides
+-- in the previous migration, because it is not an override. A weekly series is
+-- not "an event" that individual weeks opt out of -- one particular week is the
+-- event. So the occurrence is authoritative and inherits nothing.
+--
+-- Nothing needs backfilling: every booking currently flagged is_event is a
+-- One-Time Room, and the Administrator UI has never offered the Mark Event
+-- control on weekly bookings at all (only a badge). Booking-level is_event stays
+-- exactly as it is for one-time and tabling bookings.
+
+alter table public.weekly_room_occurrences
+ add column if not exists is_event boolean not null default false;
+
+comment on column public.weekly_room_occurrences.is_event is
+ 'Marks this single occurrence as an event. Authoritative, not an override: weekly events are marked per occurrence, never on the parent booking.';
+
+-- ---------------------------------------------------------------------------
+-- event_tracking: one checklist per event, where an event may now be one week
+-- ---------------------------------------------------------------------------
+-- booking_id was the primary key, so a booking had exactly one checklist. With
+-- two occurrences of the same series flagged, ticking a form on one would tick
+-- it on the other.
+--
+-- The new target is (booking_id, occurrence_date), with a NULL occurrence_date
+-- meaning "the booking itself" -- which is what one-time and tabling events keep
+-- using, so their existing rows stay valid untouched.
+--
+-- Keyed on occurrence_date rather than an occurrence id on purpose. The weekly
+-- PATCH handler regenerates its occurrences on every save -- it deletes them all
+-- and reinserts, so their ids change -- and values survive only by being carried
+-- across on the date. A foreign key to weekly_room_occurrences(id) would
+-- therefore drop every checklist the next time anyone edited the booking. The
+-- date is the stable identifier that write model actually preserves.
+--
+-- UNIQUE NULLS NOT DISTINCT (Postgres 15+) is what makes the booking-level row
+-- work: by default NULLs compare distinct, so a plain unique constraint would
+-- happily admit several booking-level rows for the same booking and the upsert
+-- would insert a new one every time instead of updating.
+
+alter table public.event_tracking
+ add column if not exists occurrence_date date;
+
+alter table public.event_tracking
+ add column if not exists id uuid not null default gen_random_uuid();
+
+alter table public.event_tracking drop constraint if exists event_tracking_pkey;
+alter table public.event_tracking add constraint event_tracking_pkey primary key (id);
+
+alter table public.event_tracking drop constraint if exists event_tracking_target_key;
+alter table public.event_tracking add constraint event_tracking_target_key
+ unique nulls not distinct (booking_id, occurrence_date);
+
+comment on column public.event_tracking.occurrence_date is
+ 'Which weekly occurrence this checklist belongs to. NULL means the booking itself, which is how one-time and tabling events are tracked.';
diff --git a/supabase/migrations/rollback/20260829_occurrence_events_rollback.sql b/supabase/migrations/rollback/20260829_occurrence_events_rollback.sql
new file mode 100644
index 0000000..a82edbb
--- /dev/null
+++ b/supabase/migrations/rollback/20260829_occurrence_events_rollback.sql
@@ -0,0 +1,19 @@
+-- Rollback for 20260829001000_occurrence_events.sql.
+--
+-- Deletes occurrence-level checklists before restoring booking_id as the primary
+-- key, since several of them can share a booking_id and the key would not build
+-- otherwise. Those rows are the ones whose occurrence_date is not null; the
+-- booking-level rows that one-time and tabling events use are left alone.
+--
+-- Weekly occurrences also stop being markable as events, so any weekly event
+-- disappears from the Events tab.
+
+delete from public.event_tracking where occurrence_date is not null;
+
+alter table public.event_tracking drop constraint if exists event_tracking_target_key;
+alter table public.event_tracking drop constraint if exists event_tracking_pkey;
+alter table public.event_tracking drop column if exists id;
+alter table public.event_tracking drop column if exists occurrence_date;
+alter table public.event_tracking add constraint event_tracking_pkey primary key (booking_id);
+
+alter table public.weekly_room_occurrences drop column if exists is_event;
diff --git a/supabase/migrations/rollback/20260829_occurrence_purpose_and_hidden_rollback.sql b/supabase/migrations/rollback/20260829_occurrence_purpose_and_hidden_rollback.sql
new file mode 100644
index 0000000..d13b1b1
--- /dev/null
+++ b/supabase/migrations/rollback/20260829_occurrence_purpose_and_hidden_rollback.sql
@@ -0,0 +1,10 @@
+-- Rollback for 20260829000000_occurrence_purpose_and_hidden.sql.
+--
+-- Drops both columns. Any per-occurrence overrides that had been set are lost,
+-- and those occurrences fall back to their booking's purpose and hidden flag --
+-- which is the behaviour that existed before the migration, so nothing breaks,
+-- but a hidden occurrence of a visible series becomes visible again.
+
+alter table public.weekly_room_occurrences
+ drop column if exists purpose,
+ drop column if exists hidden;