diff --git a/app/(dashboard)/dashboard-shell.tsx b/app/(dashboard)/dashboard-shell.tsx
index 19bb9af..75a99f8 100644
--- a/app/(dashboard)/dashboard-shell.tsx
+++ b/app/(dashboard)/dashboard-shell.tsx
@@ -435,7 +435,7 @@ export default function DashboardShell({
Chambers
NU Student Gov. Association
-
v1.14.0
+
v1.14.1
{userName && (
{getGreeting()}, {userName}
diff --git a/app/(dashboard)/sga-spaces/page.tsx b/app/(dashboard)/sga-spaces/page.tsx
index 63aed00..216eb03 100644
--- a/app/(dashboard)/sga-spaces/page.tsx
+++ b/app/(dashboard)/sga-spaces/page.tsx
@@ -458,6 +458,7 @@ export default function SGASpacesPage() {
editBookingId={editBooking.id}
initialTitle={editBooking.title}
initialAttendees={editBooking.attendees}
+ minHoursAdvance={minHoursAdvance}
onClose={() => setEditBooking(null)}
onSuccess={() => {
setEditBooking(null)
diff --git a/app/(dashboard)/sga-spaces/space-booking-modal.tsx b/app/(dashboard)/sga-spaces/space-booking-modal.tsx
index a9e48b6..59cf1a7 100644
--- a/app/(dashboard)/sga-spaces/space-booking-modal.tsx
+++ b/app/(dashboard)/sga-spaces/space-booking-modal.tsx
@@ -3,6 +3,7 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import TimePicker from '../bookings/time-picker'
import DateField from '@/app/_components/date-field'
+import { advanceNoticeError } from '@/lib/spaces-advance-notice'
interface User {
id: string
@@ -29,6 +30,8 @@ interface SpaceBookingModalProps {
initialAttendees?: User[]
onCancelBooking?: () => Promise
spaces?: Space[]
+ /** Hours of notice required before newly claimed time. 0 disables the rule. */
+ minHoursAdvance?: number
}
function isoToDateAndTime(iso: string): { date: string; time: string } {
@@ -61,6 +64,7 @@ export default function SpaceBookingModal({
initialAttendees = [],
onCancelBooking,
spaces,
+ minHoursAdvance = 0,
}: SpaceBookingModalProps) {
const isEditing = !!editBookingId
@@ -132,6 +136,18 @@ export default function SpaceBookingModal({
}
}
+ // The same rule the server applies, run as the form changes so an edit that
+ // would be refused says so before it is submitted (issue #94). Only for edits:
+ // a new booking cannot be drawn inside the notice window in the first place,
+ // and warning about the slot you have not finished picking would be noise.
+ const noticeWarning = isEditing && date
+ ? advanceNoticeError(
+ { start: dateAndTimeToIso(date, startTime), end: endTimeToIso(date, endTime) },
+ { start: initialStart, end: initialEnd },
+ minHoursAdvance
+ )
+ : null
+
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
@@ -303,6 +319,14 @@ export default function SpaceBookingModal({
)}
+ {/* Advance notice — a warning, not an error: the booking as it stands is
+ fine, it is the pending change that would be refused. */}
+ {noticeWarning && !error && (
+
+ {noticeWarning}
+
+ )}
+
{/* Error */}
{error && (
@@ -321,7 +345,7 @@ export default function SpaceBookingModal({
+
+
+
v1.14.1 — released
+
+ Update emails about a weekly booking now describe the week that actually changed. They had been pointing at whichever week carried the oldest override, which was usually not the week anyone had touched — so the email named a date months off and listed no changes at all. If one save moves several weeks, the email now covers each of them rather than only the first.
+
+
+ Senate session types you deselect in Settings now stop the emails and the alerts too, not just the rows in My Rooms. If you follow Full Body but not Office Hours, you will still hear about a change that moved both.
+
+
+ An SGA Space booking inside the advance notice window can be edited again. You can shorten it, start it later, rename it or cancel it outright at any point — only adding time to a booking still needs notice, and extending one that ends outside the window is fine. Previously such a booking could not be opened at all.
+
+
+
+
v1.14.0 — released
diff --git a/lib/spaces-advance-notice.ts b/lib/spaces-advance-notice.ts
new file mode 100644
index 0000000..89171c9
--- /dev/null
+++ b/lib/spaces-advance-notice.ts
@@ -0,0 +1,89 @@
+import { bostonWallClockNow } from './boston-time'
+
+/**
+ * The advance-notice rule for SGA Spaces, in one place (issue #94).
+ *
+ * The rule exists so that time is *claimed* a set number of hours before it is
+ * used. It follows that the thing to check is the time an edit newly claims, not
+ * whether the start moved: shortening a booking, pushing its start later,
+ * renaming it or cancelling it outright all release time or leave it alone, and
+ * none of them needs notice.
+ *
+ * The PATCH route used to reject any change to start_time that landed inside the
+ * window, which meant a booking that had entered the window could not be
+ * shortened, moved later, or given a different name -- and the calendar would
+ * not even open it, so in practice it could not be touched at all.
+ */
+
+export interface BookingInterval {
+ /** ISO instant. */
+ start: string
+ /** ISO instant. */
+ end: string
+}
+
+/**
+ * The earliest instant `next` claims that `prev` did not, or null when it claims
+ * nothing new.
+ *
+ * `prev` is null for a booking being created, where everything is new.
+ *
+ * The three shapes that claim time:
+ *
+ * start moved earlier the new block runs from the new start
+ * end moved later the new block runs from the *old* end -- which is
+ * why extending a booking whose end is still outside
+ * the window is fine
+ * moved clear of the old the whole interval is new, so it runs from the new
+ * interval start; relocating to next week is not an extension
+ */
+export function earliestNewlyClaimed(
+ next: BookingInterval,
+ prev: BookingInterval | null
+): string | null {
+ if (!prev) return next.start
+
+ const nextStart = Date.parse(next.start)
+ const nextEnd = Date.parse(next.end)
+ const prevStart = Date.parse(prev.start)
+ const prevEnd = Date.parse(prev.end)
+
+ if (nextStart < prevStart) return next.start
+ if (nextEnd > prevEnd) return nextStart > prevEnd ? next.start : prev.end
+ return null
+}
+
+/**
+ * The error to reject this booking with, or null when it is allowed.
+ *
+ * Returning the message rather than a boolean keeps the wording next to the rule
+ * it explains -- an edit and a creation fail for the same reason but need to be
+ * told different things about what to do next.
+ *
+ * `now` defaults to Boston wall-clock now and NOT to Date.now(), which would be
+ * wrong here in a way that is easy to miss: a space booking's start_time holds
+ * Boston wall-clock digits with a Z on the end, so measuring it against a real
+ * instant makes every booking look an offset earlier than it is. That is issue
+ * #87, fixed in 1e3d894, and putting the default here rather than at each call
+ * site is what keeps it fixed -- the browser is a caller too, and its clock is
+ * in whatever zone the viewer is sitting in.
+ */
+export function advanceNoticeError(
+ next: BookingInterval,
+ prev: BookingInterval | null,
+ minHours: number,
+ now: number = bostonWallClockNow().getTime()
+): string | null {
+ if (minHours <= 0) return null
+
+ const earliest = earliestNewlyClaimed(next, prev)
+ if (earliest === null) return null
+
+ if (Date.parse(earliest) >= now + minHours * 60 * 60 * 1000) return null
+
+ const hours = `${minHours} hour${minHours === 1 ? '' : 's'}`
+
+ return prev
+ ? `Adding time to a booking needs at least ${hours} of notice. You can still shorten this booking, start it later, rename it, or cancel it.`
+ : `Bookings must be made at least ${hours} in advance.`
+}
diff --git a/package-lock.json b/package-lock.json
index 7cc8c12..36b0822 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "chambers",
- "version": "1.14.0",
+ "version": "1.14.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "chambers",
- "version": "1.14.0",
+ "version": "1.14.1",
"dependencies": {
"@supabase/ssr": "^0.9.0",
"@supabase/supabase-js": "^2.99.1",
diff --git a/package.json b/package.json
index 0defb8c..54158d9 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "chambers",
- "version": "1.14.0",
+ "version": "1.14.1",
"private": true,
"scripts": {
"dev": "next dev",