From 11b4256cb9b46dc781f77aefbf9613bf46a52401 Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Wed, 9 Sep 2026 18:00:51 -0400 Subject: [PATCH 1/2] fix: point weekly update emails and alerts at the week that moved (#91) The weekly PATCH picked the session to describe with "the first occurrence carrying any override". That is a different question from "which week did this edit move", and usually a different week: an override set on week 1 months ago is still an override today, so every later edit to a series was reported against week 1 -- with an empty "What changed" table, because week 1 had not in fact moved. Occurrences are deleted and reinserted on every save, so the answer has to come from a comparison against the rows that were there before. lib/weekly-occurrences.ts does that, normalising the shapes Postgres and the editor disagree about (18:30:00 vs 18:30, null vs '') so a value that only changed form is not read as a change. is_event is compared as a plain boolean rather than an override, since an absent value there means "not an event" and not "inherit". The user_alerts rows now point at the earliest week that moved, and the email describes every week that moved rather than one of them -- the weekly editor saves all weeks at once, so a single save can move several, and naming only the first meant the rest changed silently. Co-Authored-By: Claude Opus 5 --- .../administrator/bookings/weekly/route.ts | 110 ++++++---- lib/emails/booking-updated.ts | 207 +++++++++++++----- lib/emails/changes.ts | 11 +- lib/weekly-occurrences.ts | 68 ++++++ 4 files changed, 295 insertions(+), 101 deletions(-) create mode 100644 lib/weekly-occurrences.ts diff --git a/app/api/administrator/bookings/weekly/route.ts b/app/api/administrator/bookings/weekly/route.ts index 096ecfd..0c15bb2 100644 --- a/app/api/administrator/bookings/weekly/route.ts +++ b/app/api/administrator/bookings/weekly/route.ts @@ -5,6 +5,7 @@ import { sendMissedReservationEmail, formatDateLong } from '@/lib/emails/missed- import { sendBookingUpdatedEmail } from '@/lib/emails/booking-updated' import { sendBookingCreatedEmail } from '@/lib/emails/booking-created' import { changed, collectChanges, formatDate, formatTime } from '@/lib/emails/changes' +import { occurrenceMoved } from '@/lib/weekly-occurrences' import { checkRateLimit } from '@/lib/check-rate-limit' import { getAuthedUserWithLiveRoles } from '@/lib/authorization' import { waitUntil } from '@vercel/functions' @@ -43,6 +44,20 @@ const adminSupabase = createAdminClient( process.env.SUPABASE_SERVICE_ROLE_KEY! ) +/** One stored occurrence as read back before the regeneration below. */ +interface PrevOccurrenceRow { + occurrence_date: string + room_name: string | null + start_time: string | null + end_time: string | null + status: string | null + reservation_code: string | null + purpose: string | null + senate_type: string | null + hidden: boolean | null + is_event: boolean | null +} + function getWeeklyDates(startDate: string, endDate: string): string[] { const dates: string[] = [] const current = new Date(startDate + 'T00:00:00') @@ -204,7 +219,9 @@ export async function PATCH(request: Request) { .single(), adminSupabase .from('weekly_room_occurrences') - .select('occurrence_date, room_name, start_time, end_time, status, reservation_code, purpose') + .select( + 'occurrence_date, room_name, start_time, end_time, status, reservation_code, purpose, senate_type, hidden, is_event' + ) .eq('weekly_booking_id', weekly_id), ]) @@ -297,18 +314,24 @@ export async function PATCH(request: Request) { } const recipients = await resolveBookingRecipients(adminSupabase, scopedRow) - // `hidden != null` rather than a truthiness test: an occurrence forced - // visible (false) has been changed just as much as one forced hidden. + // Which weeks this edit actually moved, in date order. // - // Hoisted out of the alerts block below because the email needs it too: an - // edit to a single week used to be described using the series' start date, so - // someone told "your booking changed" was pointed at a date months earlier - // than the one that had actually moved (issue #79). - const overriddenOcc = newOccurrences.find( - o => o.room_name || o.start_time || o.end_time || o.status || o.reservation_code - || o.purpose || o.hidden != null + // This used to be "the first week carrying any override", which is a different + // question and usually a different week: an override set on week 1 months ago + // is still an override today, so every later edit to the series was reported + // against week 1 -- with an empty change list, because week 1 had not in fact + // moved (issue #91). Comparing each week against what was stored for it is the + // only way to name the week the administrator touched. + const prevByDate = new Map( + ((prevOccurrences ?? []) as PrevOccurrenceRow[]).map(o => [o.occurrence_date, o]) + ) + const movedOccurrences = newOccurrences.filter(o => + occurrenceMoved(prevByDate.get(o.occurrence_date), o) ) - const changedOcc = overriddenOcc ?? newOccurrences[0] + + // The alert points at the earliest week that moved, falling back to the start + // of the series when the edit was series-wide. + const alertOcc = movedOccurrences[0] ?? newOccurrences[0] if (recipients.length && auditLog) { await adminSupabase.from('user_alerts').insert( @@ -317,8 +340,8 @@ export async function PATCH(request: Request) { audit_log_id: auditLog.id, booking_id, booking_type: 'Weekly Room', - booking_date: changedOcc?.occurrence_date ?? start_date, - start_time: changedOcc?.start_time ?? start_time, + booking_date: alertOcc?.occurrence_date ?? start_date, + start_time: alertOcc?.start_time ?? start_time, })) ) } @@ -344,46 +367,38 @@ export async function PATCH(request: Request) { changed('Reservation code', prevWeekly?.reservation_code, reservation_code || null), ) - // When exactly one week carries overrides and the series itself did not - // move, the edit was to that week -- so the email describes that week. - // If the series moved too, the series is the story and a per-week - // heading would understate it. - const targetOcc = overriddenOcc && seriesChanges.length === 0 ? overriddenOcc : null + // When the series itself did not move, the edit was to the weeks that + // moved -- so the email describes those weeks. If the series moved too, + // the series is the story and a per-week heading would understate it. + const targetOccs = seriesChanges.length === 0 ? movedOccurrences : [] - if (targetOcc) { - const prev = (prevOccurrences ?? []).find( - (o: { occurrence_date: string }) => o.occurrence_date === targetOcc.occurrence_date - ) + const sessions = targetOccs.map(occ => { + const prev = prevByDate.get(occ.occurrence_date) // An occurrence field that is null inherits from the series, so the // comparison is between effective values -- otherwise clearing an // override would read as a change to nothing. - const occChanges = collectChanges( - changed('Room', prev?.room_name ?? prevWeekly?.room_name, targetOcc.room_name ?? room_name), - changed('Start time', prev?.start_time ?? prevWeekly?.start_time, targetOcc.start_time ?? start_time, formatTime), - changed('End time', prev?.end_time ?? prevWeekly?.end_time, targetOcc.end_time ?? end_time, formatTime), - changed('Status', prev?.status ?? prevWeekly?.status, targetOcc.status ?? status), - changed('Purpose', prev?.purpose ?? prevBooking?.purpose, targetOcc.purpose ?? purpose), - changed('Reservation code', prev?.reservation_code ?? prevWeekly?.reservation_code, targetOcc.reservation_code ?? (reservation_code || null)), + const changes = collectChanges( + changed('Room', prev?.room_name ?? prevWeekly?.room_name, occ.room_name ?? room_name), + changed('Start time', prev?.start_time ?? prevWeekly?.start_time, occ.start_time ?? start_time, formatTime), + changed('End time', prev?.end_time ?? prevWeekly?.end_time, occ.end_time ?? end_time, formatTime), + changed('Status', prev?.status ?? prevWeekly?.status, occ.status ?? status), + changed('Purpose', prev?.purpose ?? prevBooking?.purpose, occ.purpose ?? purpose), + changed('Reservation code', prev?.reservation_code ?? prevWeekly?.reservation_code, occ.reservation_code ?? (reservation_code || null)), ) - const index = newOccurrences.findIndex(o => o.occurrence_date === targetOcc.occurrence_date) - - await sendBookingUpdatedEmail({ - bodyName, - purpose: targetOcc.purpose ?? purpose, - roomOrTable: targetOcc.room_name || room_name || 'N/A', - date: targetOcc.occurrence_date, - startTime: targetOcc.start_time || start_time, - endTime: targetOcc.end_time || end_time, - status: targetOcc.status || status, - changes: occChanges, - occurrence: { - position: index >= 0 ? `week ${index + 1} of ${newOccurrences.length}` : null, - }, - recipients: emails, - }) - return - } + const index = newOccurrences.findIndex(o => o.occurrence_date === occ.occurrence_date) + + return { + date: occ.occurrence_date, + startTime: occ.start_time || start_time, + endTime: occ.end_time || end_time, + roomOrTable: occ.room_name || room_name || 'N/A', + status: occ.status || status, + purpose: occ.purpose ?? purpose, + position: index >= 0 ? `week ${index + 1} of ${newOccurrences.length}` : null, + changes, + } + }) await sendBookingUpdatedEmail({ bodyName, @@ -394,6 +409,7 @@ export async function PATCH(request: Request) { endTime: end_time, status, changes: seriesChanges, + sessions, recipients: emails, }) } catch (e) { diff --git a/lib/emails/booking-updated.ts b/lib/emails/booking-updated.ts index fd269f4..dd48e85 100644 --- a/lib/emails/booking-updated.ts +++ b/lib/emails/booking-updated.ts @@ -2,6 +2,26 @@ import { resend } from '@/lib/resend' import { sanitize, buildEmailHtml } from './utils' import { formatDate, formatTime, renderChanges, type BookingChange } from './changes' +/** + * One session of a repeating booking that an edit actually moved. + * + * The values are the session's own -- effective ones, with any per-week override + * already resolved against the series -- so the email can describe the week that + * changed rather than the series it belongs to. + */ +export interface UpdatedSession { + date: string + startTime: string + endTime: string + roomOrTable: string + status: string + purpose?: string | null + /** Where this session sits in the run, e.g. "week 3 of 12". Omitted if unknown. */ + position?: string | null + /** What moved in this session specifically. */ + changes?: BookingChange[] +} + interface BookingUpdatedEmailParams { bodyName: string roomOrTable: string @@ -11,59 +31,86 @@ interface BookingUpdatedEmailParams { status: string recipients: string[] /** - * What an administrator actually altered. Optional so a caller that cannot - * work it out still sends the email it sent before (issue #79). + * What an administrator actually altered at the series level. Optional so a + * caller that cannot work it out still sends the email it sent before (issue + * #79). */ changes?: BookingChange[] /** - * Set when the edit was to one session of a repeating booking rather than to - * the series. The email then describes that session -- its date, its time, its - * room -- instead of restating the series' start date, which is what it used - * to do no matter which week had been touched. + * The sessions of a repeating booking this edit moved, when the series itself + * did not move. The email then describes those sessions -- their dates, times + * and rooms -- instead of restating the series' start date. + * + * This is a list rather than a single session because the weekly editor saves + * every week at once, so one save can move several of them. Naming only one + * meant the other weeks changed silently (issue #91). */ - occurrence?: { - /** Where this session sits in the run, e.g. "week 3 of 12". Omitted if unknown. */ - position?: string | null - } | null + sessions?: UpdatedSession[] | null /** Shown above the details when set, e.g. the booking's purpose. */ purpose?: string | null } +/** The "Body / Room / Date / Time / Status" block, in both bodies of the email. */ +function renderDetails( + fields: { label: string; value: string }[] +): { text: string; html: string } { + return { + text: fields.map(f => `${f.label}: ${f.value}`).join('\n'), + html: fields.map(f => `${f.label}: ${f.value}
`).join(''), + } +} + +/** One session's heading, change rows and current details, for the multi-session email. */ +function renderSession(session: UpdatedSession): { text: string; html: string } { + const heading = session.position + ? `${formatDate(session.date)} (${sanitize(session.position)})` + : formatDate(session.date) + + // The session's date is already the heading here, so the change rows go in bare. + const rendered = renderChanges(session.changes ?? [], { heading: false }) + + const details = renderDetails([ + ...(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: 'Status', value: sanitize(session.status) }, + ]) + + return { + // Indented under the date, and held apart from the change rows above them: + // run together, "- Purpose: Weekly Meeting -> Exec Sync" followed + // immediately by "Purpose: Exec Sync" reads as a contradiction. + text: `${heading} +${rendered ? `${rendered.text}\n` : ''} +${details.text.split('\n').map(l => ` ${l}`).join('\n')}`, + html: ` +
+

${heading}

+ ${rendered ? rendered.html : ''} +

${details.html}

+
`, + } +} + export async function sendBookingUpdatedEmail(params: BookingUpdatedEmailParams) { const { bodyName, roomOrTable, date, startTime, endTime, status, recipients, - changes = [], occurrence = null, purpose = null, + changes = [], sessions = null, purpose = null, } = params if (!recipients.length) return const sBodyName = sanitize(bodyName) - const sRoomOrTable = sanitize(roomOrTable) - const sStatus = sanitize(status) const sPurpose = purpose ? sanitize(purpose) : null - const isOccurrence = !!occurrence - const rendered = renderChanges(changes) - - // A recipient scanning a phone should be able to tell from the subject whether - // this is their whole booking or one week of it. - const subject = isOccurrence - ? `Chambers — A Session of Your ${sBodyName} Booking Has Changed` - : 'Chambers — Your Booking Has Been Updated' - - const lead = isOccurrence - ? `One session of your ${sBodyName} weekly booking has been updated by a Chambers administrator. The rest of the series is unchanged.` - : `Your ${sBodyName} booking has been updated by a Chambers administrator.` + const moved = sessions ?? [] - const dateLabel = isOccurrence ? 'Session date' : 'Date' - const positionText = occurrence?.position ? `\nSession: ${sanitize(occurrence.position)}` : '' - const positionHtml = occurrence?.position - ? `Session: ${sanitize(occurrence.position)}
` - : '' - - const purposeText = sPurpose ? `Purpose: ${sPurpose}\n` : '' - const purposeHtml = sPurpose ? `Purpose: ${sPurpose}
` : '' - - const changesText = rendered ? `\nWhat changed:\n${rendered.text}\n` : '' + // Three shapes, one email: the whole series moved, one week of it moved, or + // several weeks of it moved. + const { subject, lead, text, html } = moved.length > 1 + ? buildMultiSession(sBodyName, moved) + : moved.length === 1 + ? buildSingleSession(sBodyName, moved[0]) + : buildSeries(sBodyName, sPurpose, { roomOrTable, date, startTime, endTime, status }, changes) await resend.emails.send({ from: process.env.RESEND_FROM_EMAIL!, @@ -71,28 +118,84 @@ export async function sendBookingUpdatedEmail(params: BookingUpdatedEmailParams) bcc: recipients, subject, text: `${lead} -${changesText} -${isOccurrence ? 'This session now reads:' : 'The booking now reads:'} -${purposeText}Body: ${sBodyName} -Room/Table: ${sRoomOrTable} -${dateLabel}: ${formatDate(date)} -Time: ${formatTime(startTime)} to ${formatTime(endTime)} -Status: ${sStatus}${positionText} +${text} If you have questions, please reach out to sgaOperations@northeastern.edu.`, html: buildEmailHtml(`

${lead}

- ${rendered ? rendered.html : ''} -

${isOccurrence ? 'This session now reads' : 'The booking now reads'}

-

- ${purposeHtml}Body: ${sBodyName}
- Room/Table: ${sRoomOrTable}
- ${dateLabel}: ${formatDate(date)}
- Time: ${formatTime(startTime)} to ${formatTime(endTime)}
- Status: ${sStatus}
- ${positionHtml} -

+ ${html} `), }) } + +function buildSeries( + sBodyName: string, + sPurpose: string | null, + now: { roomOrTable: string; date: string; startTime: string; endTime: string; status: string }, + changes: BookingChange[] +) { + const rendered = renderChanges(changes) + const details = renderDetails([ + ...(sPurpose ? [{ label: 'Purpose', value: sPurpose }] : []), + { label: 'Body', value: sBodyName }, + { label: 'Room/Table', value: sanitize(now.roomOrTable) }, + { label: 'Date', value: formatDate(now.date) }, + { label: 'Time', value: `${formatTime(now.startTime)} to ${formatTime(now.endTime)}` }, + { label: 'Status', value: sanitize(now.status) }, + ]) + + return { + subject: 'Chambers — Your Booking Has Been Updated', + lead: `Your ${sBodyName} booking has been updated by a Chambers administrator.`, + text: `${rendered ? `What changed:\n${rendered.text}\n\n` : ''}The booking now reads: + +${details.text}`, + html: ` + ${rendered ? rendered.html : ''} +

The booking now reads

+

${details.html}

`, + } +} + +function buildSingleSession(sBodyName: string, session: UpdatedSession) { + const rendered = renderChanges(session.changes ?? []) + const details = renderDetails([ + ...(session.purpose ? [{ label: 'Purpose', value: sanitize(session.purpose) }] : []), + { label: 'Body', value: sBodyName }, + { 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: 'Status', value: sanitize(session.status) }, + ...(session.position ? [{ label: 'Session', value: sanitize(session.position) }] : []), + ]) + + return { + // A recipient scanning a phone should be able to tell from the subject + // whether this is their whole booking or one week of it. + subject: `Chambers — A Session of Your ${sBodyName} Booking Has Changed`, + lead: `One session of your ${sBodyName} weekly booking has been updated by a Chambers administrator. The rest of the series is unchanged.`, + text: `${rendered ? `What changed:\n${rendered.text}\n\n` : ''}This session now reads: + +${details.text}`, + html: ` + ${rendered ? rendered.html : ''} +

This session now reads

+

${details.html}

`, + } +} + +function buildMultiSession(sBodyName: string, sessions: UpdatedSession[]) { + const blocks = sessions.map(renderSession) + + return { + subject: `Chambers — ${sessions.length} Sessions of Your ${sBodyName} Booking Have Changed`, + lead: `${sessions.length} sessions of your ${sBodyName} weekly booking have been updated by a Chambers administrator. The rest of the series is unchanged.`, + text: `These sessions now read: + +${blocks.map(b => b.text).join('\n\n')}`, + html: ` +

These sessions now read

+ ${blocks.map(b => b.html).join('')}`, + } +} diff --git a/lib/emails/changes.ts b/lib/emails/changes.ts index 3ff6c1c..23972c8 100644 --- a/lib/emails/changes.ts +++ b/lib/emails/changes.ts @@ -76,8 +76,15 @@ export function collectChanges(...items: (BookingChange | null)[]): BookingChang * is filed under), and inventing a "What changed" section that lists nothing * would be worse than leaving it out. The email then reads as it did before, as * a statement of where the booking now stands. + * + * `heading: false` drops the "What changed" label, for a caller that already + * has a heading of its own above the rows -- the per-session blocks of a + * multi-session update email, where repeating it under every date would shout. */ -export function renderChanges(changes: BookingChange[]): { text: string; html: string } | null { +export function renderChanges( + changes: BookingChange[], + { heading = true }: { heading?: boolean } = {} +): { text: string; html: string } | null { if (!changes.length) return null const text = changes @@ -85,7 +92,7 @@ export function renderChanges(changes: BookingChange[]): { text: string; html: s .join('\n') const html = ` -

What changed

+ ${heading ? '

What changed

' : ''} ${changes.map(c => ` diff --git a/lib/weekly-occurrences.ts b/lib/weekly-occurrences.ts new file mode 100644 index 0000000..0b64a64 --- /dev/null +++ b/lib/weekly-occurrences.ts @@ -0,0 +1,68 @@ +/** + * Telling which weeks of a repeating booking an edit actually moved. + * + * The weekly PATCH deletes and reinserts every occurrence on every save, so + * "what changed" cannot be read off the write -- it has to be a comparison + * against the rows that were there before. + * + * This used to be approximated as "the first week carrying any override", which + * is a different question and usually a different week: an override set on week + * 1 months ago is still an override today, so every later edit to the series was + * reported against week 1, with an empty change list, because week 1 had not in + * fact moved (issue #91). + */ + +/** + * The occurrence columns a PATCH can move. Everything but is_event is an + * override, so null is a real value here and means "inherit". + * + * 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. + */ +export const OCCURRENCE_FIELDS = [ + 'room_name', 'start_time', 'end_time', 'status', + 'reservation_code', 'purpose', 'senate_type', 'hidden', 'is_event', +] as const + +export type OccurrenceField = (typeof OCCURRENCE_FIELDS)[number] + +/** Any row carrying the override columns -- a stored one or a freshly built one. */ +export type OccurrenceRow = Partial> + +/** + * Normalises one override for comparison, so a value that only changed shape is + * not read as a change: Postgres returns a time as '18:30:00' where the editor + * submits '18:30', and a cleared text field arrives as '' where the stored row + * holds null. + */ +export function normalizeOccurrenceValue(field: OccurrenceField, value: unknown): string | null { + // is_event is the one column here that is not an override: an absent value + // means "not an event" rather than "inherit", so null and false are the same + // state and must not compare as a change. Without this, a week the series had + // not previously covered looked edited the moment it was generated. + if (field === 'is_event') return value === true ? 'true' : 'false' + + if (value === null || value === undefined) return null + 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 +} + +/** + * Whether this week's overrides differ from what was stored for it. + * + * `prev` is undefined for a date the series did not previously cover, which + * compares as all-null -- so extending a series does not, by itself, mark the + * new weeks as edited. That edit shows up as a change to the series' end date + * instead, which is where a recipient would look for it. + */ +export function occurrenceMoved( + prev: OccurrenceRow | undefined, + next: OccurrenceRow +): boolean { + return OCCURRENCE_FIELDS.some( + f => normalizeOccurrenceValue(f, prev?.[f]) !== normalizeOccurrenceValue(f, next[f]) + ) +} From 58667eb989c0c53b069bcc7168128d0a7fda0da4 Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Wed, 9 Sep 2026 18:05:18 -0400 Subject: [PATCH 2/2] fix: apply Senate session-type preferences to emails and alerts (#92, #93) The preference existed only on the client, inside the My Rooms list filter, so deselecting Office Hours hid those sessions from the page and did nothing else. Update emails and dashboard alerts still went to every member of the Senate, including the people who had said they did not want them. The rule -- and the list of session types, which was declared twice -- now live in lib/senate-types.ts, and resolveBookingRecipients applies it to the audience it returns, so the page, the emails and the alerts cannot drift apart. The weekly PATCH tells it which sessions the notification is about: the weeks that moved, or all of them when the series itself moved. One wanted session in a batch is enough to keep a member in the audience. Someone who follows Full Body but not Office Hours still needs the email about a save that moved both -- suppressing it would lose a session they asked to know about, which is the worse failure of the two. The filter applies to Leadership as well. The preference is about what someone reads, not what they are responsible for, and Leadership can still see every session on the booking itself. Co-Authored-By: Claude Opus 5 --- app/(dashboard)/my-rooms/my-rooms-client.tsx | 5 +- app/(dashboard)/my-rooms/shared.ts | 2 - app/(dashboard)/settings-modal.tsx | 10 ++- .../administrator/bookings/weekly/route.ts | 48 ++++++++------ lib/booking-scope.ts | 38 +++++++++-- lib/senate-types.ts | 64 +++++++++++++++++++ 6 files changed, 140 insertions(+), 27 deletions(-) create mode 100644 lib/senate-types.ts diff --git a/app/(dashboard)/my-rooms/my-rooms-client.tsx b/app/(dashboard)/my-rooms/my-rooms-client.tsx index 4589c12..eb7a797 100644 --- a/app/(dashboard)/my-rooms/my-rooms-client.tsx +++ b/app/(dashboard)/my-rooms/my-rooms-client.tsx @@ -7,6 +7,7 @@ import BookingDetailModal from './booking-detail-modal' import NotificationBell from './notification-bell' import CalendarView from './calendar-view' import { Skeleton } from '@/app/_components/skeleton' +import { wantsSenateSession } from '@/lib/senate-types' import { type FlatBooking, type MyRoomsResponse, @@ -136,8 +137,10 @@ export default function MyRoomsClient({ return () => window.removeEventListener('chambers:senate-prefs-updated', fetchBookings) }, [fetchBookings]) + // The same rule the update emails and dashboard alerts now apply, so what this + // page hides and what Chambers stops sending you cannot drift apart (#92, #93). const passesSenateFilter = (b: FlatBooking) => - b.bodyName !== 'Senate' || !b.senateType || (senateTypePreferences[b.senateType] ?? true) + wantsSenateSession(senateTypePreferences, b.bodyName, b.senateType) const filteredUpcoming = all.filter(b => isWithinDays(b.date, filter, today) && passesSenateFilter(b)) diff --git a/app/(dashboard)/my-rooms/shared.ts b/app/(dashboard)/my-rooms/shared.ts index d05bfce..bf2681c 100644 --- a/app/(dashboard)/my-rooms/shared.ts +++ b/app/(dashboard)/my-rooms/shared.ts @@ -99,8 +99,6 @@ export function bookingTitle(b: FlatBooking): string { return b.purpose?.trim() || b.scopeLabel } -export const SENATE_TYPES = ['Full Body', 'Weekly', 'Office Hours'] as const - export const statusColors: Record = { 'Reserved': 'bg-[#0f3d20] border-[#22c55e]', 'Alternate Room': 'bg-[#0e2f4f] border-[#4285f4]', diff --git a/app/(dashboard)/settings-modal.tsx b/app/(dashboard)/settings-modal.tsx index c3d53b5..a3ff2ce 100644 --- a/app/(dashboard)/settings-modal.tsx +++ b/app/(dashboard)/settings-modal.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react' import { getJson } from '@/lib/fetch-json' import { Skeleton } from '@/app/_components/skeleton' import { getPrefsForRole, EMAIL_PREF_LABELS, EmailPrefKey } from '@/lib/email-preferences' +import { SENATE_TYPES } from '@/lib/senate-types' interface Membership { id: string @@ -34,7 +35,7 @@ export interface Settings { available_bodies: AvailableBody[] } -export const SENATE_TYPES = ['Full Body', 'Weekly', 'Office Hours'] as const +export { SENATE_TYPES } from '@/lib/senate-types' interface SettingsModalProps { onClose: () => void @@ -273,7 +274,12 @@ export default function SettingsModal({ onClose, cachedSettings, onSettingsLoade {/* Senate Session Types */} {!loading && isSenateMember && (
-

Senate Session Types Shown in My Rooms

+
+

Senate Session Types You Follow

+

+ Deselected types are hidden from My Rooms, and Chambers stops emailing and alerting you about them. +

+
{SENATE_TYPES.map(type => (