From 2a9eb86505cbed67a35167154b662a99aaae74f5 Mon Sep 17 00:00:00 2001
From: pataniaeli
Date: Tue, 15 Sep 2026 20:58:57 -0400
Subject: [PATCH 1/3] feat: add a Meeting Time distinct from the reservation
window (#126)
A booking's start and end times describe the reservation -- the window the room
is held for, which is what CSC is told. They are not when the body meets: a
committee that books 6:00-9:00 to allow for setup still tells its members 6:30.
Every member-facing surface reported the reservation window, so every one of
them answered the wrong question.
Meeting Time is a `time` on the tables that own functional times -- the weekly
series, its per-week occurrences, and one-time and tabling sessions. Nullable
everywhere, with NULL meaning inherit and resolving in the end to start_time,
the same convention weekly_room_occurrences already uses for room, time and
status. Nothing needs backfilling and no insert path can break: a booking with
no meeting time set reads exactly as it did before the column existed.
The Slack committee reminder now states the meeting time alone rather than the
reservation window, as the issue asks. My Rooms leads its cards, list rows and
calendar entries with it, naming the window only when the two differ so the
cards keep the height issue #78 settled on; the detail modal shows both, always.
Both booking emails carry it, and a week whose only edit was to move when it
meets now counts as moved, so its members are told.
Applied to the database as migration `meeting_time`; rollback script included.
Co-Authored-By: Claude Opus 5
---
app/(dashboard)/bookings/bookings-tab.tsx | 5 +
.../bookings/edit-one-time-form.tsx | 18 ++++
.../bookings/edit-tabling-form.tsx | 23 +++-
app/(dashboard)/bookings/edit-weekly-form.tsx | 51 ++++++++-
app/(dashboard)/bookings/one-time-form.tsx | 16 +++
app/(dashboard)/bookings/tabling-form.tsx | 16 +++
.../bookings/weekly-booking-grid.tsx | 3 +
app/(dashboard)/bookings/weekly-form.tsx | 24 +++++
.../my-rooms/booking-detail-modal.tsx | 14 ++-
app/(dashboard)/my-rooms/calendar-view.tsx | 12 ++-
app/(dashboard)/my-rooms/my-rooms-client.tsx | 30 +++++-
app/(dashboard)/my-rooms/shared.ts | 22 ++++
.../administrator/bookings/one-time/route.ts | 19 +++-
app/api/administrator/bookings/route.ts | 8 +-
.../administrator/bookings/tabling/route.ts | 20 +++-
.../administrator/bookings/weekly/route.ts | 46 ++++++--
app/api/cron/slack-reminders/route.ts | 6 +-
lib/emails/booking-created.ts | 24 ++++-
lib/emails/booking-updated.ts | 20 +++-
lib/meeting-reminders.ts | 28 ++++-
lib/meeting-time.ts | 102 ++++++++++++++++++
lib/my-rooms-data.ts | 8 +-
lib/weekly-occurrences.ts | 10 +-
.../20260915000000_meeting_time.sql | 88 +++++++++++++++
.../20260915_meeting_time_rollback.sql | 20 ++++
25 files changed, 595 insertions(+), 38 deletions(-)
create mode 100644 lib/meeting-time.ts
create mode 100644 supabase/migrations/20260915000000_meeting_time.sql
create mode 100644 supabase/migrations/rollback/20260915_meeting_time_rollback.sql
diff --git a/app/(dashboard)/bookings/bookings-tab.tsx b/app/(dashboard)/bookings/bookings-tab.tsx
index dcb30fc..02ea7a7 100644
--- a/app/(dashboard)/bookings/bookings-tab.tsx
+++ b/app/(dashboard)/bookings/bookings-tab.tsx
@@ -53,6 +53,7 @@ interface OneTimeBooking extends BookingBase {
booking_date: string
start_time: string
end_time: string
+ meeting_time: string | null
status: string
reservation_code: string | null
}[] | null
@@ -66,6 +67,7 @@ interface WeeklyBooking extends BookingBase {
end_date: string
start_time: string
end_time: string
+ meeting_time: string | null
status: string
reservation_code: string | null
weekly_room_occurrences: {
@@ -74,6 +76,8 @@ interface WeeklyBooking extends BookingBase {
room_name: string | null
start_time: string | null
end_time: string | null
+ /** Overrides the series meeting time for this date; null inherits (issue #126). */
+ meeting_time: string | null
status: string | null
reservation_code: string | null
senate_type: string | null
@@ -97,6 +101,7 @@ interface TablingBooking extends BookingBase {
session_date: string
start_time: string
end_time: string
+ meeting_time: string | null
status: string
reservation_code: string | null
}[]
diff --git a/app/(dashboard)/bookings/edit-one-time-form.tsx b/app/(dashboard)/bookings/edit-one-time-form.tsx
index 6ee0083..ea81c8b 100644
--- a/app/(dashboard)/bookings/edit-one-time-form.tsx
+++ b/app/(dashboard)/bookings/edit-one-time-form.tsx
@@ -38,6 +38,8 @@ interface OneTimeSession {
booking_date: string
start_time: string
end_time: string
+ /** Blank means the session meets when its reservation starts (issue #126). */
+ meeting_time: string
status: string
reservation_code: string
}
@@ -47,6 +49,7 @@ const emptySession = (): OneTimeSession => ({
booking_date: '',
start_time: '',
end_time: '',
+ meeting_time: '',
status: 'Reserved',
reservation_code: '',
})
@@ -65,6 +68,7 @@ interface EditOneTimeFormProps {
booking_date: string
start_time: string
end_time: string
+ meeting_time: string | null
status: string
reservation_code: string | null
}[] | null
@@ -96,6 +100,7 @@ export default function EditOneTimeForm({ booking, bodies, onClose, onSuccess }:
booking_date: d.booking_date ?? '',
start_time: d.start_time.slice(0, 5) ?? '',
end_time: d.end_time.slice(0, 5) ?? '',
+ meeting_time: d.meeting_time?.slice(0, 5) ?? '',
status: d.status ?? 'Reserved',
reservation_code: d.reservation_code ?? '',
})) ?? [emptySession()]
@@ -221,6 +226,19 @@ export default function EditOneTimeForm({ booking, bodies, onClose, onSuccess }:
+ {/* When the meeting itself starts, as opposed to when the room is
+ held. Tracks the start time until touched; the server stores
+ nothing when the two agree (issue #126). */}
+
+
+
+ updateSession(i, 'meeting_time', v)}
+ />
+
+
+
+ {/* When the meeting itself starts, as opposed to when the room is
+ held. Tracks the start time until touched; the server stores
+ nothing when the two agree (issue #126). */}
+
+
+
+ updateSession(i, 'meeting_time', v)}
+ />
+
+
+
+ {/* See weekly-form.tsx for why this tracks the start time rather than sitting blank (issue #126). */}
+
+
+
+ setForm({ ...form, meeting_time: v })}
+ />
+
+
+ When the meeting itself starts. Leave it on the start time unless the room is held early for setup.
+
+
+
setForm({ ...form, reservation_code: e.target.value })} className={inputCls} />
@@ -267,7 +289,8 @@ export default function EditWeeklyForm({ booking, bodies, initialExpandedOcc, on
{getWeeklyDates(form.start_date, form.end_date).map(date => {
const occ = occurrences.find(o => o.occurrence_date === date)
// `hidden != null` because false is an override, not an absence.
- const hasOverride = occ && (occ.room_name || occ.start_time || occ.end_time || occ.status
+ const hasOverride = occ && (occ.room_name || occ.start_time || occ.end_time
+ || occ.meeting_time || occ.status
|| occ.reservation_code || occ.purpose || occ.hidden != null)
const isExpanded = expandedOcc === date
@@ -320,6 +343,32 @@ export default function EditWeeklyForm({ booking, bodies, initialExpandedOcc, on
+ {/*
+ Shows what this week currently resolves to -- its own meeting
+ time, else the series', else whichever start time applies to
+ it -- and writes an override the moment it is touched, exactly
+ as the room and time overrides above do.
+
+ Not run through meetingTimeForStorage on save: null here means
+ "inherit the series", not "meet at the start time", so a week
+ that genuinely meets at its own start time has to say so
+ explicitly (issue #126).
+ */}
+
+ {/* When the meeting itself starts, as opposed to when the room is
+ held. Tracks the start time until touched; the server stores
+ nothing when the two agree (issue #126). */}
+
+
+
+ updateSession(i, 'meeting_time', v)}
+ />
+
+
+
+ {/* When the meeting itself starts, as opposed to when the room is
+ held. Tracks the start time until touched; the server stores
+ nothing when the two agree (issue #126). */}
+
+
+
+ updateSession(i, 'meeting_time', v)}
+ />
+
+
+
+ {/*
+ Meeting Time: when the body actually meets, as opposed to when the room
+ is held (issue #126). Tracks the start time until it is touched -- an
+ empty picker would invite retyping the start time by hand, and the server
+ collapses a meeting time equal to the start back to "inherit" anyway, so
+ an untouched field stores nothing and keeps following the reservation.
+
+ Its own row rather than a third column: three TimePickers abreast is what
+ issue #24 had to unpick on mobile.
+ */}
+
+
+
+ setForm({ ...form, meeting_time: v })}
+ />
+
+
+ When the meeting itself starts. Leave it on the start time unless the room is held early for setup.
+
+
+
Date
{formatDate(booking.date)}
+ {/*
+ Both rows, always, even when they carry the same time. This is the
+ detail view -- the one place someone comes to find out exactly what
+ was booked -- so it is worth a line to say that the meeting starts at
+ one time and the room is held from another, rather than collapsing
+ them the way the cards do and leaving the distinction unexplained
+ (issue #126).
+ */}
- Time
+ Meeting Time
+ {formatTime(booking.meetingTime)}
+
{b.status}
diff --git a/app/(dashboard)/my-rooms/my-rooms-client.tsx b/app/(dashboard)/my-rooms/my-rooms-client.tsx
index cbbb85c..e23622f 100644
--- a/app/(dashboard)/my-rooms/my-rooms-client.tsx
+++ b/app/(dashboard)/my-rooms/my-rooms-client.tsx
@@ -22,6 +22,7 @@ import {
formatTime,
formatDate,
} from './shared'
+import { meetingTimeMatchesStart } from '@/lib/meeting-time'
function MyRoomsSkeleton() {
return (
@@ -267,8 +268,25 @@ export default function MyRoomsClient({
{b.scopeLabel} · {b.location}
{formatDate(b.date)}
+ {/*
+ The meeting time leads, because it is the one a member acts
+ on -- the reservation window is when the room is held, which
+ is Chambers' concern rather than theirs (issue #126). When no
+ distinct meeting time is set the two are the same, so the
+ line reads exactly as it always did with the start bolded;
+ only a booking that genuinely meets later spends the extra
+ words saying what the room is held for. Either way it is one
+ line, so the card keeps the height issue #78 settled on.
+ */}
`)
.join('')
diff --git a/lib/emails/booking-updated.ts b/lib/emails/booking-updated.ts
index 063471e..f3faf6d 100644
--- a/lib/emails/booking-updated.ts
+++ b/lib/emails/booking-updated.ts
@@ -16,6 +16,12 @@ export interface UpdatedSession {
date: string
startTime: string
endTime: string
+ /**
+ * When the meeting itself starts, already resolved against the series
+ * (issue #126). Required rather than optional so a caller cannot quietly omit
+ * it and send an email that describes only the reservation window.
+ */
+ meetingTime: string
roomOrTable: string
status: string
purpose?: string | null
@@ -31,6 +37,8 @@ interface BookingUpdatedEmailParams {
date: string
startTime: string
endTime: string
+ /** The series' resolved meeting time (issue #126). */
+ meetingTime: string
status: string
recipients: string[]
/**
@@ -83,6 +91,7 @@ function renderSession(session: UpdatedSession): { text: string; html: string }
...(session.purpose ? [{ label: 'Purpose', value: sanitize(session.purpose) }] : []),
{ label: 'Room/Table', value: sanitize(session.roomOrTable) },
{ label: 'Time', value: `${formatTime(session.startTime)} to ${formatTime(session.endTime)}` },
+ { label: 'Meeting time', value: formatTime(session.meetingTime) },
{ label: 'Status', value: sanitize(session.status) },
])
@@ -104,7 +113,7 @@ ${details.text.split('\n').map(l => ` ${l}`).join('\n')}`,
export async function sendBookingUpdatedEmail(params: BookingUpdatedEmailParams) {
const {
- bodyName, roomOrTable, date, startTime, endTime, status, recipients,
+ bodyName, roomOrTable, date, startTime, endTime, meetingTime, status, recipients,
changes = [], sessions = null, purpose = null, invite = null,
} = params
if (!recipients.length) return
@@ -120,7 +129,7 @@ export async function sendBookingUpdatedEmail(params: BookingUpdatedEmailParams)
? buildMultiSession(sBodyName, moved)
: moved.length === 1
? buildSingleSession(sBodyName, moved[0])
- : buildSeries(sBodyName, sPurpose, { roomOrTable, date, startTime, endTime, status }, changes)
+ : buildSeries(sBodyName, sPurpose, { roomOrTable, date, startTime, endTime, meetingTime, status }, changes)
await resend.emails.send({
from: emailFrom(),
@@ -145,7 +154,7 @@ If you have questions, please reach out to sgaOperations@northeastern.edu.`,
function buildSeries(
sBodyName: string,
sPurpose: string | null,
- now: { roomOrTable: string; date: string; startTime: string; endTime: string; status: string },
+ now: { roomOrTable: string; date: string; startTime: string; endTime: string; meetingTime: string; status: string },
changes: BookingChange[]
) {
const rendered = renderChanges(changes)
@@ -154,7 +163,11 @@ function buildSeries(
{ label: 'Body', value: sBodyName },
{ label: 'Room/Table', value: sanitize(now.roomOrTable) },
{ label: 'Date', value: formatDate(now.date) },
+ // Kept next to the reservation window rather than replacing it: the window
+ // is what Chambers holds and what a body needs if it is setting up early,
+ // and the meeting time is the one its members act on (issue #126).
{ label: 'Time', value: `${formatTime(now.startTime)} to ${formatTime(now.endTime)}` },
+ { label: 'Meeting time', value: formatTime(now.meetingTime) },
{ label: 'Status', value: sanitize(now.status) },
])
@@ -179,6 +192,7 @@ function buildSingleSession(sBodyName: string, session: UpdatedSession) {
{ label: 'Room/Table', value: sanitize(session.roomOrTable) },
{ label: 'Session date', value: formatDate(session.date) },
{ label: 'Time', value: `${formatTime(session.startTime)} to ${formatTime(session.endTime)}` },
+ { label: 'Meeting time', value: formatTime(session.meetingTime) },
{ label: 'Status', value: sanitize(session.status) },
...(session.position ? [{ label: 'Session', value: sanitize(session.position) }] : []),
])
diff --git a/lib/meeting-reminders.ts b/lib/meeting-reminders.ts
index dbcf421..b8cf658 100644
--- a/lib/meeting-reminders.ts
+++ b/lib/meeting-reminders.ts
@@ -1,4 +1,5 @@
import { APP_TIME_ZONE } from '@/lib/app-zone'
+import { resolveMeetingTime } from '@/lib/meeting-time'
/**
* Working out which committee meetings the Slack bot should remind a channel
@@ -69,6 +70,7 @@ export interface ReminderCandidate {
room_name: string | null
start_time: string | null
end_time: string | null
+ meeting_time: string | null
status: string | null
hidden: boolean | null
weekly_booking_id: string
@@ -76,6 +78,7 @@ export interface ReminderCandidate {
room_name: string | null
start_time: string | null
end_time: string | null
+ meeting_time: string | null
status: string | null
}
booking: {
@@ -96,6 +99,13 @@ export interface ResolvedMeeting {
roomName: string | null
startTime: string | null
endTime: string | null
+ /**
+ * The time the meeting itself starts, already resolved (issue #126). This is
+ * the only time the reminder prints; startTime and endTime stay on the shape
+ * because the resolution below falls back to startTime and because a future
+ * reminder may want to say what the room is held for.
+ */
+ meetingTime: string | null
status: string
}
@@ -118,14 +128,21 @@ export function resolveMeeting(c: ReminderCandidate): ResolvedMeeting | null {
const status = c.status ?? c.series.status
if (!status || !REMINDED_STATUSES.has(status)) return null
+ const startTime = c.start_time ?? c.series.start_time
+
return {
weeklyBookingId: c.weekly_booking_id,
date: c.occurrence_date,
channelId: c.body.slack_channel_id,
bodyName: c.body.name,
roomName: c.room_name ?? c.series.room_name,
- startTime: c.start_time ?? c.series.start_time,
+ startTime,
endTime: c.end_time ?? c.series.end_time,
+ // Same precedence as everything above it, with one extra level on the end:
+ // a series that has never had a meeting time set falls back to the start
+ // time this week resolved to, so the reminder reads exactly as it did
+ // before the field existed (issue #126).
+ meetingTime: resolveMeetingTime(c.meeting_time, c.series.meeting_time, startTime),
status,
}
}
@@ -174,9 +191,12 @@ export function formatReminder(m: ResolvedMeeting): string {
return [opening, 'Check with your Chair/Director for virtual meeting information.'].join('\n')
}
- const start = formatTime(m.startTime)
- const end = formatTime(m.endTime)
- const when = start && end ? `${start}–${end}` : start ?? 'to be confirmed'
+ // The meeting time alone, not the reservation window (issue #126). The window
+ // is what Chambers holds the room for -- it usually opens before the meeting
+ // does and runs past the end of it -- and printing it here told a channel to
+ // turn up at a time nobody meant. Still labelled "Time", the colloquial
+ // reading the issue asks for.
+ const when = formatTime(m.meetingTime) ?? 'to be confirmed'
const room = m.roomName ? esc(m.roomName) : 'not yet confirmed'
const roomLabel = ALTERNATE_ROOM.has(m.status) ? '*Alternate* Room' : 'Room'
diff --git a/lib/meeting-time.ts b/lib/meeting-time.ts
new file mode 100644
index 0000000..5cb8bdb
--- /dev/null
+++ b/lib/meeting-time.ts
@@ -0,0 +1,102 @@
+/**
+ * Resolving a booking's Meeting Time (issue #126).
+ *
+ * A booking has always been described by the window its room is reserved for --
+ * start_time to end_time. That window is a fact about the reservation, not about
+ * the meeting: a body that books 6:00-9:00 to allow for setup still tells its
+ * members to turn up at 6:30. Meeting Time is that second number, stored
+ * alongside the window rather than replacing it, so the reservation keeps saying
+ * what CSC was told while every member-facing surface can say when to arrive.
+ *
+ * Kept in its own module because the precedence below has five callers across
+ * My Rooms, the admin editors, both booking emails and the Slack reminder, and
+ * an inheritance rule that is applied inconsistently is worse than one that is
+ * wrong everywhere.
+ */
+
+/**
+ * The value that actually applies, given each level from most specific to least.
+ *
+ * NULL at any level means inherit, exactly as it does for the room, status and
+ * time overrides that weekly_room_occurrences already carries, so the first
+ * level that is genuinely set wins. Callers pass their own chain:
+ *
+ * weekly resolveMeetingTime(occ.meeting_time, series.meeting_time, startTime)
+ * one-time resolveMeetingTime(session.meeting_time, session.start_time)
+ * tabling resolveMeetingTime(session.meeting_time, session.start_time)
+ *
+ * Ending the chain with the resolved start_time is what makes this safe to roll
+ * out against a table full of rows that predate the column: a booking with no
+ * meeting time set reads as meeting when its reservation begins, which is the
+ * assumption every one of these surfaces made before Meeting Time existed.
+ *
+ * Variadic rather than a fixed (override, base, fallback) signature because the
+ * chains are genuinely different lengths -- a one-time session has no series
+ * above it to inherit from -- and padding the short ones with nulls would read
+ * as though a level had been forgotten.
+ *
+ * The first overload says that a chain ending in a plain string cannot resolve
+ * to null, so the callers that end theirs with a NOT NULL start_time get
+ * `string` back rather than having to assert away a null that cannot happen.
+ */
+export function resolveMeetingTime(
+ ...levels: [...(string | null | undefined)[], string]
+): string
+export function resolveMeetingTime(
+ ...levels: (string | null | undefined)[]
+): string | null
+export function resolveMeetingTime(
+ ...levels: (string | null | undefined)[]
+): string | null {
+ for (const level of levels) {
+ // Empty string is treated as unset, not as a value. The editors normalise a
+ // cleared field to null before it is written, but a blank that slips through
+ // should inherit rather than render as an empty time.
+ if (level != null && level !== '') return level
+ }
+ return null
+}
+
+/**
+ * True when the meeting starts exactly when the reservation does.
+ *
+ * Postgres hands back `time` as 'HH:MM:SS' while the editors submit 'HH:MM', so
+ * the two are compared at minute precision rather than as raw strings -- without
+ * this, '18:30:00' and '18:30' read as a meeting time that differs from the
+ * start time, and every surface that flags an unusual one would flag all of them.
+ */
+export function meetingTimeMatchesStart(
+ meetingTime: string | null | undefined,
+ startTime: string | null | undefined
+): boolean {
+ if (!meetingTime || !startTime) return true
+ return meetingTime.slice(0, 5) === startTime.slice(0, 5)
+}
+
+/**
+ * What to store for a meeting time whose inheritance chain falls straight
+ * through to `startTime` -- a weekly series, or a one-time or tabling session.
+ *
+ * The editors show this field pre-filled with the start time rather than blank,
+ * because "meets at 6, room held from 6" is the truth for most bookings and an
+ * empty box invites someone to retype it. That means an untouched field submits
+ * a value identical to the start time, and storing it would pin the meeting time
+ * to a number the start time no longer has to agree with: move the reservation
+ * to 7:00 later and the booking would still claim to meet at 6:00.
+ *
+ * Collapsing the two back to NULL keeps them tied together until somebody
+ * genuinely separates them, and loses nothing -- NULL resolves to the start time,
+ * so both forms render identically.
+ *
+ * Deliberately NOT for a weekly occurrence's override, where NULL means "inherit
+ * the series" rather than "meet at the start time". Collapsing there would turn
+ * a week that really does meet at its start time into a week that follows a
+ * series meeting at some other one.
+ */
+export function meetingTimeForStorage(
+ meetingTime: string | null | undefined,
+ startTime: string | null | undefined
+): string | null {
+ if (!meetingTime) return null
+ return meetingTimeMatchesStart(meetingTime, startTime) ? null : meetingTime
+}
diff --git a/lib/my-rooms-data.ts b/lib/my-rooms-data.ts
index 7ff1432..9b5c6e7 100644
--- a/lib/my-rooms-data.ts
+++ b/lib/my-rooms-data.ts
@@ -123,7 +123,7 @@ export async function fetchMyRooms(
.from('bookings')
.select(`
${SELECT_BASE},
- one_time_room_bookings(id, room_name, booking_date, start_time, end_time, status, reservation_code)
+ one_time_room_bookings(id, room_name, booking_date, start_time, end_time, meeting_time, status, reservation_code)
`)
.eq('type', 'One-Time Room')
.eq('semester_id', activeSemesterId)
@@ -134,8 +134,8 @@ export async function fetchMyRooms(
.from('bookings')
.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, purpose, hidden, is_event)
+ weekly_room_bookings(id, room_name, start_time, end_time, meeting_time, status, reservation_code,
+ weekly_room_occurrences(id, occurrence_date, room_name, start_time, end_time, meeting_time, status, reservation_code, senate_type, purpose, hidden, is_event)
)
`)
.eq('type', 'Weekly Room')
@@ -148,7 +148,7 @@ export async function fetchMyRooms(
.select(`
${SELECT_BASE},
tabling_bookings(id, reservation_code,
- tabling_sessions(id, location, session_date, start_time, end_time, status, reservation_code)
+ tabling_sessions(id, location, session_date, start_time, end_time, meeting_time, status, reservation_code)
)
`)
.eq('type', 'Tabling')
diff --git a/lib/weekly-occurrences.ts b/lib/weekly-occurrences.ts
index 8887f1c..a60306a 100644
--- a/lib/weekly-occurrences.ts
+++ b/lib/weekly-occurrences.ts
@@ -20,9 +20,13 @@
* senate_type, hidden and is_event are compared even though the update email has
* no row for them: they still say *which* week an administrator touched, which
* is the question this module exists to answer.
+ *
+ * meeting_time is in the list for the plainer reason that the email does report
+ * it (issue #126), and a week whose only edit was to move when it meets has to
+ * count as moved or nobody is told.
*/
export const OCCURRENCE_FIELDS = [
- 'room_name', 'start_time', 'end_time', 'status',
+ 'room_name', 'start_time', 'end_time', 'meeting_time', 'status',
'reservation_code', 'purpose', 'senate_type', 'hidden', 'is_event',
] as const
@@ -48,7 +52,9 @@ export function normalizeOccurrenceValue(field: OccurrenceField, value: unknown)
if (typeof value === 'boolean') return value ? 'true' : 'false'
const s = String(value).trim()
if (!s) return null
- return field === 'start_time' || field === 'end_time' ? s.slice(0, 5) : s
+ return field === 'start_time' || field === 'end_time' || field === 'meeting_time'
+ ? s.slice(0, 5)
+ : s
}
/**
diff --git a/supabase/migrations/20260915000000_meeting_time.sql b/supabase/migrations/20260915000000_meeting_time.sql
new file mode 100644
index 0000000..9083be7
--- /dev/null
+++ b/supabase/migrations/20260915000000_meeting_time.sql
@@ -0,0 +1,88 @@
+-- Meeting Time, distinct from the reserved start and end times (issue #126).
+--
+-- start_time and end_time describe the *reservation*: the window the room is
+-- held for, which is what CSC is told and what the calendar draws. They are not
+-- the same thing as when the meeting actually begins, and a body that reserves
+-- 6:00–9:00 so it can set up may well tell its members to arrive at 6:30.
+-- Until now the only time anyone could be shown was the reservation window, so
+-- every member-facing surface reported the wrong answer to "when do we meet?".
+--
+-- Meeting Time is a `time`, not free text, so it sorts, formats through the same
+-- helpers as the other two, and cannot drift into "6ish".
+--
+-- ---------------------------------------------------------------------------
+-- Why every column is nullable, and what NULL means
+-- ---------------------------------------------------------------------------
+-- NULL means inherit, resolving in the end to start_time. That is exactly the
+-- convention weekly_room_occurrences already uses for room_name, start_time,
+-- end_time and status, and that 20260829000000_occurrence_purpose_and_hidden.sql
+-- extended to purpose and hidden.
+--
+-- Two things follow from it, both deliberate:
+--
+-- * No backfill is needed. Every existing row keeps behaving exactly as it
+-- does today -- a booking with no meeting time set reads as meeting at its
+-- start time, which is the assumption the whole app made before this column
+-- existed. Nothing has to be migrated, and nothing changes for a body that
+-- never sets one.
+--
+-- * No insert path can break. A NOT NULL column would have had to be supplied
+-- by six write paths across three booking types, and any one of them missed
+-- is a 500 in production rather than a field left blank.
+--
+-- The cost is that a weekly occurrence cannot say "this week, meet at the start
+-- time" while its series says otherwise -- NULL there means inherit, so the
+-- series value wins. Setting the occurrence's meeting time explicitly to the
+-- start time expresses the same thing, which is why this is not worth the third
+-- state that `hidden` needed. (`hidden` had no such escape: a boolean has only
+-- two values and both were already spoken for.)
+--
+-- ---------------------------------------------------------------------------
+-- Why these four tables and not `bookings`
+-- ---------------------------------------------------------------------------
+-- The issue asks for the field on "every booking, session, and occurrence".
+-- Those three map onto the tables that own functional times, and `bookings` is
+-- not one of them -- it carries purpose, scope and visibility, and has never
+-- held a time of any kind:
+--
+-- booking -> weekly_room_bookings (the series; the weekly base value)
+-- session -> one_time_room_bookings (one row per date of a one-time booking)
+-- -> tabling_sessions (one row per date of a tabling booking)
+-- occurrence -> weekly_room_occurrences (per-week override of the series)
+--
+-- One-time and tabling sessions each carry their own date and their own start
+-- and end times, so each is independently overridable by definition and has no
+-- parent time to inherit from. Only the weekly series/occurrence pair needs two
+-- levels, and it gets them.
+
+-- ---------------------------------------------------------------------------
+-- Weekly: the series value, and the per-week override of it
+-- ---------------------------------------------------------------------------
+
+alter table public.weekly_room_bookings
+ add column if not exists meeting_time time;
+
+comment on column public.weekly_room_bookings.meeting_time is
+ 'When the meeting itself starts, as opposed to when the room reservation does. NULL inherits start_time.';
+
+alter table public.weekly_room_occurrences
+ add column if not exists meeting_time time;
+
+comment on column public.weekly_room_occurrences.meeting_time is
+ 'Overrides weekly_room_bookings.meeting_time for this week. NULL inherits, resolving to the series meeting_time and then to start_time.';
+
+-- ---------------------------------------------------------------------------
+-- One-time and tabling: per-session, with nothing above them to inherit from
+-- ---------------------------------------------------------------------------
+
+alter table public.one_time_room_bookings
+ add column if not exists meeting_time time;
+
+comment on column public.one_time_room_bookings.meeting_time is
+ 'When the meeting itself starts, as opposed to when the room reservation does. NULL inherits this session''s start_time.';
+
+alter table public.tabling_sessions
+ add column if not exists meeting_time time;
+
+comment on column public.tabling_sessions.meeting_time is
+ 'When the session itself starts, as opposed to when the table reservation does. NULL inherits this session''s start_time.';
diff --git a/supabase/migrations/rollback/20260915_meeting_time_rollback.sql b/supabase/migrations/rollback/20260915_meeting_time_rollback.sql
new file mode 100644
index 0000000..66ca806
--- /dev/null
+++ b/supabase/migrations/rollback/20260915_meeting_time_rollback.sql
@@ -0,0 +1,20 @@
+-- Rollback for 20260915000000_meeting_time.sql.
+--
+-- Drops all four columns. Any meeting times that had been set are lost, and
+-- every booking falls back to reporting its start_time as the time it meets --
+-- which is precisely what the app did before the migration, so nothing breaks.
+-- The only visible effect is that a body which had set a meeting time distinct
+-- from its reservation window goes back to being announced at the reservation
+-- window.
+
+alter table public.weekly_room_bookings
+ drop column if exists meeting_time;
+
+alter table public.weekly_room_occurrences
+ drop column if exists meeting_time;
+
+alter table public.one_time_room_bookings
+ drop column if exists meeting_time;
+
+alter table public.tabling_sessions
+ drop column if exists meeting_time;
From 93ba6ca4d0cb9ad769b16cb09ea421d863d254ee Mon Sep 17 00:00:00 2001
From: pataniaeli
Date: Fri, 18 Sep 2026 10:52:41 -0400
Subject: [PATCH 2/3] chore: rename the #126 migration to the version
production recorded
Its file carried 20260915000000, the version dev's space_booking_series
migration already uses. Production recorded meeting_time as 20260916005401,
so the file is named after that, as #119 and #140 did for theirs. Contents
unchanged.
Co-Authored-By: Claude Opus 5
---
...5000000_meeting_time.sql => 20260916005401_meeting_time.sql} | 0
...ing_time_rollback.sql => 20260916_meeting_time_rollback.sql} | 2 +-
2 files changed, 1 insertion(+), 1 deletion(-)
rename supabase/migrations/{20260915000000_meeting_time.sql => 20260916005401_meeting_time.sql} (100%)
rename supabase/migrations/rollback/{20260915_meeting_time_rollback.sql => 20260916_meeting_time_rollback.sql} (93%)
diff --git a/supabase/migrations/20260915000000_meeting_time.sql b/supabase/migrations/20260916005401_meeting_time.sql
similarity index 100%
rename from supabase/migrations/20260915000000_meeting_time.sql
rename to supabase/migrations/20260916005401_meeting_time.sql
diff --git a/supabase/migrations/rollback/20260915_meeting_time_rollback.sql b/supabase/migrations/rollback/20260916_meeting_time_rollback.sql
similarity index 93%
rename from supabase/migrations/rollback/20260915_meeting_time_rollback.sql
rename to supabase/migrations/rollback/20260916_meeting_time_rollback.sql
index 66ca806..a0cfea1 100644
--- a/supabase/migrations/rollback/20260915_meeting_time_rollback.sql
+++ b/supabase/migrations/rollback/20260916_meeting_time_rollback.sql
@@ -1,4 +1,4 @@
--- Rollback for 20260915000000_meeting_time.sql.
+-- Rollback for 20260916005401_meeting_time.sql.
--
-- Drops all four columns. Any meeting times that had been set are lost, and
-- every booking falls back to reporting its start_time as the time it meets --
From 19a35c0aa0a89b42460b9aca03cdf703ee00c7bb Mon Sep 17 00:00:00 2001
From: pataniaeli
Date: Fri, 18 Sep 2026 13:12:04 -0400
Subject: [PATCH 3/3] fix: give Start Time its own line on My Rooms cards
instead of bolding it (#126)
Cards led the time line with the meeting time in bold, run together with the
reservation window. That was hard to read. The start time and the
reservation now sit on separate labelled lines, "Start Time" and "Reserved",
with no bold, and both always show so every card has the same shape. The
Senate session badge moves beside the date to make room.
The list and calendar rows lose their emphasis too. Where the two times
differ they are labelled, since two unmarked times in a row do not say which
is which; where they match the row reads as it always did. The detail modal
calls the field Start Time, unbolded, as the cards do.
Co-Authored-By: Claude Opus 5
---
.../my-rooms/booking-detail-modal.tsx | 4 +-
app/(dashboard)/my-rooms/calendar-view.tsx | 8 ++--
app/(dashboard)/my-rooms/my-rooms-client.tsx | 45 +++++++++----------
3 files changed, 28 insertions(+), 29 deletions(-)
diff --git a/app/(dashboard)/my-rooms/booking-detail-modal.tsx b/app/(dashboard)/my-rooms/booking-detail-modal.tsx
index aba297c..285cc30 100644
--- a/app/(dashboard)/my-rooms/booking-detail-modal.tsx
+++ b/app/(dashboard)/my-rooms/booking-detail-modal.tsx
@@ -103,8 +103,8 @@ export default function BookingDetailModal({ booking, isLeadership, onClose, onC
(issue #126).
*/}
- Meeting Time
- {formatTime(booking.meetingTime)}
+ Start Time
+ {formatTime(booking.meetingTime)}
- {/* Meeting time first, reservation window only when it differs (issue #126). */}
+ {/* The reservation window as always; the start time is named only when it differs from it (issue #126). */}
{b.location} ·{' '}
- {formatTime(b.meetingTime)}
{meetingTimeMatchesStart(b.meetingTime, b.startTime) ? (
- <> – {formatTime(b.endTime)}>
+ <>{formatTime(b.startTime)} – {formatTime(b.endTime)}>
) : (
- <> (reserved {formatTime(b.startTime)} – {formatTime(b.endTime)})>
+ // Labelled once they differ: two unmarked times in a row would not say which is which.
+ <>Start Time {formatTime(b.meetingTime)} · Reserved {formatTime(b.startTime)} – {formatTime(b.endTime)}>
)}
diff --git a/app/(dashboard)/my-rooms/my-rooms-client.tsx b/app/(dashboard)/my-rooms/my-rooms-client.tsx
index e23622f..4b1f42e 100644
--- a/app/(dashboard)/my-rooms/my-rooms-client.tsx
+++ b/app/(dashboard)/my-rooms/my-rooms-client.tsx
@@ -267,30 +267,29 @@ export default function MyRoomsClient({
{b.scopeLabel} · {b.location}
-
{formatDate(b.date)}
- {/*
- The meeting time leads, because it is the one a member acts
- on -- the reservation window is when the room is held, which
- is Chambers' concern rather than theirs (issue #126). When no
- distinct meeting time is set the two are the same, so the
- line reads exactly as it always did with the start bolded;
- only a booking that genuinely meets later spends the extra
- words saying what the room is held for. Either way it is one
- line, so the card keeps the height issue #78 settled on.
- */}
-
+ {/*
+ Start Time and the reservation window on lines of their own,
+ each labelled, rather than run together with the start in
+ bold (issue #126). The start time is when members should
+ arrive; the reservation is when the room is held, often
+ earlier for setup. "Start Time" is the name members already
+ use for it, as the issue allows.
+
+ Both lines always show, even when the two start together, so
+ every card in the grid has the same shape.
+ */}
+
)
})}
@@ -396,11 +395,11 @@ export default function MyRoomsClient({
{b.location} · {formatDate(b.date)} ·{' '}
- {formatTime(b.meetingTime)}
{meetingTimeMatchesStart(b.meetingTime, b.startTime) ? (
- <> – {formatTime(b.endTime)}>
+ <>{formatTime(b.startTime)} – {formatTime(b.endTime)}>
) : (
- <> (reserved {formatTime(b.startTime)} – {formatTime(b.endTime)})>
+ // Labelled once they differ: two unmarked times in a row would not say which is which.
+ <>Start Time {formatTime(b.meetingTime)} · Reserved {formatTime(b.startTime)} – {formatTime(b.endTime)}>
)}