Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/(dashboard)/dashboard-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,7 @@ export default function DashboardShell({
<span className="text-[#c8102e] font-bold text-xl tracking-tight">Chambers</span>
</div>
<p className="text-slate-500 text-xs mt-0.5">NU Student Gov. Association</p>
<p className="text-slate-600 text-xs mt-1">v1.14.0</p>
<p className="text-slate-600 text-xs mt-1">v1.14.1</p>
{userName && (
<div className="flex items-start justify-between mt-2">
<p className="text-slate-500 text-xs italic">{getGreeting()},<br />{userName}</p>
Expand Down
1 change: 1 addition & 0 deletions app/(dashboard)/sga-spaces/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 25 additions & 1 deletion app/(dashboard)/sga-spaces/space-booking-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,6 +30,8 @@ interface SpaceBookingModalProps {
initialAttendees?: User[]
onCancelBooking?: () => Promise<void>
spaces?: Space[]
/** Hours of notice required before newly claimed time. 0 disables the rule. */
minHoursAdvance?: number
}

function isoToDateAndTime(iso: string): { date: string; time: string } {
Expand Down Expand Up @@ -61,6 +64,7 @@ export default function SpaceBookingModal({
initialAttendees = [],
onCancelBooking,
spaces,
minHoursAdvance = 0,
}: SpaceBookingModalProps) {
const isEditing = !!editBookingId

Expand Down Expand Up @@ -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<HTMLFormElement>) => {
e.preventDefault()
setError(null)
Expand Down Expand Up @@ -303,6 +319,14 @@ export default function SpaceBookingModal({
)}
</div>

{/* 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 && (
<div className="bg-[#f97316]/10 border border-[#f97316]/30 rounded-lg px-3 py-2.5 text-sm text-[#fdba74]">
{noticeWarning}
</div>
)}

{/* Error */}
{error && (
<div className="bg-[#c8102e]/10 border border-[#c8102e]/30 rounded-lg px-3 py-2.5 text-sm text-[#f87171]">
Expand All @@ -321,7 +345,7 @@ export default function SpaceBookingModal({
</button>
<button
type="submit"
disabled={submitting}
disabled={submitting || !!noticeWarning}
className="flex-1 py-2.5 px-4 bg-[#c8102e] hover:bg-[#a50d26] disabled:opacity-60 text-white text-sm font-medium rounded-lg transition-colors"
>
{submitting ? (isEditing ? 'Saving…' : 'Booking…') : (isEditing ? 'Save Changes' : 'Confirm Booking')}
Expand Down
44 changes: 24 additions & 20 deletions app/(dashboard)/sga-spaces/space-calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,23 +219,22 @@ export default function SpaceCalendar({

// ── Mouse interaction ────────────────────────────────────────────────────────
const handleOverlayMouseMove = useCallback((e: React.MouseEvent, dayIdx: number) => {
if (!canBook) {
setOverlayCursor('default')
return
}
const slot = slotFromClientY(e.clientY)
if (isSlotBlocked(dayIdx, slot) || isSlotInNoticeZone(dayIdx, slot)) {
// Your own booking is reachable first, before the blocked and notice-zone
// guards. Those guards are about claiming *new* time, and opening a booking
// you already hold claims nothing -- it is how you shorten or cancel it
// (issue #94). Deciding this here rather than in the guards keeps a blackout
// or the notice window from swallowing the click on a booking sitting inside
// it, which is what made such a booking impossible to touch at all.
const ownBooking = bookingsByDay[dayIdx].find(
bs => slot >= bs.startSlot && slot < bs.endSlot && bs.booking.creator_id === currentUserId
)
if (ownBooking) {
setOverlayCursor('pointer')
setHoveredBookingId(ownBooking.booking.id)
} else if (!canBook || isSlotBlocked(dayIdx, slot) || isSlotInNoticeZone(dayIdx, slot) || isSlotBooked(dayIdx, slot)) {
setOverlayCursor('default')
setHoveredBookingId(null)
} else if (isSlotBooked(dayIdx, slot)) {
const hit = bookingsByDay[dayIdx].find(bs => slot >= bs.startSlot && slot < bs.endSlot)
if (hit && hit.booking.creator_id === currentUserId) {
setOverlayCursor('pointer')
setHoveredBookingId(hit.booking.id)
} else {
setOverlayCursor('default')
setHoveredBookingId(null)
}
} else {
setOverlayCursor('crosshair')
setHoveredBookingId(null)
Expand All @@ -245,14 +244,19 @@ export default function SpaceCalendar({
const handleColumnMouseDown = useCallback((e: React.MouseEvent, dayIdx: number) => {
e.preventDefault()
const slot = slotFromClientY(e.clientY)
if (isSlotBlocked(dayIdx, slot) || isSlotInNoticeZone(dayIdx, slot)) return
if (isSlotBooked(dayIdx, slot)) {
if (currentUserId && onBookingClick) {
const hit = bookingsByDay[dayIdx].find(bs => slot >= bs.startSlot && slot < bs.endSlot)
if (hit && hit.booking.creator_id === currentUserId) onBookingClick(hit.booking)
// Same order as the hover handler above: your own booking opens even inside
// the notice window (issue #94).
if (currentUserId && onBookingClick) {
const hit = bookingsByDay[dayIdx].find(
bs => slot >= bs.startSlot && slot < bs.endSlot && bs.booking.creator_id === currentUserId
)
if (hit) {
onBookingClick(hit.booking)
return
}
return
}
if (isSlotBlocked(dayIdx, slot) || isSlotInNoticeZone(dayIdx, slot)) return
if (isSlotBooked(dayIdx, slot)) return
if (!canBook) return
dragRef.current = { dayIdx, startSlot: slot, currentSlot: slot }
setDragPreview({ dayIdx, startSlot: slot, endSlot: slot + 1 })
Expand Down
24 changes: 11 additions & 13 deletions app/api/spaces/bookings/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { createClient } from '@/lib/supabase/server'
import { createClient as createAdminClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { bostonWallClockNow } from '@/lib/boston-time'
import { checkRateLimit } from '@/lib/check-rate-limit'
import { sendSpaceBookingCancelledEmail } from '@/lib/emails/space-booking-cancelled'
import { getAuthedUserWithLiveRoles } from '@/lib/authorization'
import { advanceNoticeError } from '@/lib/spaces-advance-notice'
import { waitUntil } from '@vercel/functions'

const DEFAULT_WEEKLY_HOURS = 18
Expand Down Expand Up @@ -102,19 +102,17 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
adminSupabase.from('app_settings').select('min_hours_advance_spaces').eq('id', 1).single(),
])

// Advance notice check — only enforce if start_time changed and limit > 0
// Advance notice applies to the time this edit newly claims, not to whether the
// start moved (issue #94). Shortening a booking, pushing its start later or
// renaming it releases time or leaves it alone, and needs no notice; only
// adding time inside the window is refused.
const minHours: number = settings?.min_hours_advance_spaces ?? 24
const startTimeChanged = start_time !== existing.start_time
if (minHours > 0 && startTimeChanged) {
// Boston wall-clock now, for the reason given in the POST route: start_time
// is wall-clock digits labelled Z, and Date.now() is a real instant.
const earliestAllowed = new Date(bostonWallClockNow().getTime() + minHours * 60 * 60 * 1000)
if (new Date(start_time) < earliestAllowed) {
return NextResponse.json({
error: `Bookings must be made at least ${minHours} hour${minHours === 1 ? '' : 's'} in advance.`,
}, { status: 400 })
}
}
const noticeError = advanceNoticeError(
{ start: start_time, end: end_time },
{ start: existing.start_time, end: existing.end_time },
minHours
)
if (noticeError) return NextResponse.json({ error: noticeError }, { status: 400 })

if (overlapping && overlapping.length > 0) {
return NextResponse.json({ error: 'This time slot overlaps with an existing booking for this space.' }, { status: 400 })
Expand Down
19 changes: 6 additions & 13 deletions app/api/spaces/bookings/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { createClient } from '@/lib/supabase/server'
import { createClient as createAdminClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { bostonWallClockNow } from '@/lib/boston-time'
import { checkRateLimit } from '@/lib/check-rate-limit'
import { advanceNoticeError } from '@/lib/spaces-advance-notice'
import { sendSpaceBookingConfirmedEmail } from '@/lib/emails/space-booking-confirmed'
import { getAuthedUserWithLiveRoles } from '@/lib/authorization'
import { waitUntil } from '@vercel/functions'
Expand Down Expand Up @@ -159,19 +159,12 @@ export async function POST(request: Request) {
adminSupabase.from('app_settings').select('min_hours_advance_spaces').eq('id', 1).single(),
])

// Advance notice check (skipped when limit is 0)
//
// Measured from Boston wall-clock now, not Date.now(): start_time carries
// wall-clock digits with a Z on the end, so comparing it against a real
// instant made every booking look an offset earlier than it was, and the
// requirement reject bookings that were comfortably far enough out (issue #87).
// Advance notice check (skipped when limit is 0). A creation claims its whole
// interval, so the shared rule reduces to the same test it always ran here --
// it is shared with the PATCH route, where the interesting case lives (#94).
const minHours: number = settings?.min_hours_advance_spaces ?? 24
const earliestAllowed = new Date(bostonWallClockNow().getTime() + minHours * 60 * 60 * 1000)
if (minHours > 0 && new Date(start_time) < earliestAllowed) {
return NextResponse.json({
error: `Bookings must be made at least ${minHours} hour${minHours === 1 ? '' : 's'} in advance.`,
}, { status: 400 })
}
const noticeError = advanceNoticeError({ start: start_time, end: end_time }, null, minHours)
if (noticeError) return NextResponse.json({ error: noticeError }, { status: 400 })

if (overlapping && overlapping.length > 0) {
return NextResponse.json({ error: 'This time slot overlaps with an existing booking for this space.' }, { status: 400 })
Expand Down
15 changes: 15 additions & 0 deletions app/faq/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,21 @@ export default async function FaqPage() {
</section>
</div>

<div className="space-y-6">
<section className="space-y-2">
<h2 className="text-[#f0f6ff] font-medium text-base">v1.14.1 &mdash; released</h2>
<p className="text-[#93b8d8] text-sm leading-relaxed">
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 &mdash; 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.
</p>
<p className="text-[#93b8d8] text-sm leading-relaxed">
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.
</p>
<p className="text-[#93b8d8] text-sm leading-relaxed">
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 &mdash; 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.
</p>
</section>
</div>

<div className="space-y-6">
<section className="space-y-2">
<h2 className="text-[#f0f6ff] font-medium text-base">v1.14.0 &mdash; released</h2>
Expand Down
89 changes: 89 additions & 0 deletions lib/spaces-advance-notice.ts
Original file line number Diff line number Diff line change
@@ -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.`
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "chambers",
"version": "1.14.0",
"version": "1.14.1",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
Loading