diff --git a/app/api/display/[spaceId]/route.ts b/app/api/display/[spaceId]/route.ts index 78c61bb..bf675f6 100644 --- a/app/api/display/[spaceId]/route.ts +++ b/app/api/display/[spaceId]/route.ts @@ -1,5 +1,6 @@ import { createClient as createAdminClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' +import { bostonWallClockNow } from '@/lib/boston-time' const adminSupabase = createAdminClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -21,10 +22,17 @@ export async function GET( // Use the client-supplied local date (YYYY-MM-DD) so "today" matches the // display's clock rather than the server's UTC clock. + // + // The fallback is Boston wall clock, not the server's UTC date. Bookings are + // stored as wall-clock digits labelled Z, so a UTC "today" is already tomorrow + // after 8 PM EDT and the kiosk would ask for the wrong day's bookings. The + // display page always sends the parameter, so this is unreachable from it -- + // but it is the same trap as issue #87 and worth not leaving armed for the + // next caller. const dateParam = searchParams.get('date') const [year, month, day] = dateParam ? dateParam.split('-').map(Number) - : (() => { const n = new Date(); return [n.getUTCFullYear(), n.getUTCMonth() + 1, n.getUTCDate()] })() + : (() => { const n = bostonWallClockNow(); return [n.getUTCFullYear(), n.getUTCMonth() + 1, n.getUTCDate()] })() const todayStart = new Date(Date.UTC(year, month - 1, day)) const todayEnd = new Date(Date.UTC(year, month - 1, day + 1)) diff --git a/app/api/spaces/bookings/[id]/route.ts b/app/api/spaces/bookings/[id]/route.ts index 992573f..abad955 100644 --- a/app/api/spaces/bookings/[id]/route.ts +++ b/app/api/spaces/bookings/[id]/route.ts @@ -1,6 +1,7 @@ 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' @@ -105,7 +106,9 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id const minHours: number = settings?.min_hours_advance_spaces ?? 24 const startTimeChanged = start_time !== existing.start_time if (minHours > 0 && startTimeChanged) { - const earliestAllowed = new Date(Date.now() + minHours * 60 * 60 * 1000) + // 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.`, diff --git a/app/api/spaces/bookings/route.ts b/app/api/spaces/bookings/route.ts index 1c51ad0..b365f55 100644 --- a/app/api/spaces/bookings/route.ts +++ b/app/api/spaces/bookings/route.ts @@ -1,6 +1,7 @@ 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 { sendSpaceBookingConfirmedEmail } from '@/lib/emails/space-booking-confirmed' import { getAuthedUserWithLiveRoles } from '@/lib/authorization' @@ -159,8 +160,13 @@ export async function POST(request: Request) { ]) // 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). const minHours: number = settings?.min_hours_advance_spaces ?? 24 - const earliestAllowed = new Date(Date.now() + minHours * 60 * 60 * 1000) + 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.`, diff --git a/app/api/spaces/remaining-hours/route.ts b/app/api/spaces/remaining-hours/route.ts index 46ab720..79768ee 100644 --- a/app/api/spaces/remaining-hours/route.ts +++ b/app/api/spaces/remaining-hours/route.ts @@ -1,6 +1,7 @@ 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 { getAuthedUser } from '@/lib/auth' const adminSupabase = createAdminClient( @@ -10,8 +11,16 @@ const adminSupabase = createAdminClient( const DEFAULT_WEEKLY_HOURS = 18 +/** + * The Sun-Sat window to count this user's hours against. + * + * Boston wall-clock now, matching the domain space_bookings are stored in. Real + * UTC put the boundary in the wrong place for the last four hours of a Saturday + * -- UTC is already Sunday by 8 PM EDT, so bookings were counted against next + * week and the remaining-hours figure jumped. Same root cause as issue #87. + */ function getWeekBounds(): { weekStart: string; weekEnd: string } { - const now = new Date() + const now = bostonWallClockNow() const day = now.getUTCDay() const sun = new Date(now) sun.setUTCDate(now.getUTCDate() - day) diff --git a/lib/boston-time.ts b/lib/boston-time.ts new file mode 100644 index 0000000..381cd33 --- /dev/null +++ b/lib/boston-time.ts @@ -0,0 +1,52 @@ +/** + * "Now", in the domain SGA Spaces actually stores times in. + * + * That domain is unusual enough to be worth stating plainly, because nothing + * names it: a space booking's start_time holds Boston wall-clock digits with a + * Z on the end. A 6 PM booking is stored as T18:00:00.000Z whatever the offset + * happens to be that month. The calendar builds slots that way (slotToIso sets + * UTC hours straight from the slot index), the modal parses them back with + * getUTCHours, and the confirmation email relies on it too -- see the note in + * lib/emails/space-booking-confirmed.ts about UTC fields giving the right + * local-time digits to pair with TZID=America/New_York. + * + * It is self-consistent as long as nothing compares those values against a real + * instant. Two places did. The advance-notice check measured start_time against + * Date.now(), so during EDT every booking looked four hours earlier than it was + * and a two-hour requirement rejected anything less than six real hours out + * (issue #87). remaining-hours derived its Sun-Sat window from real UTC, so + * between 8 PM and midnight on a Saturday it counted against next week. + * + * Pinned to America/New_York rather than the server's clock: Vercel runs in UTC, + * so reading local fields there would be the same bug with an extra step. The + * offset is resolved by Intl for the given instant, so EDT and EST are both + * handled without a table. + */ +export function bostonWallClockNow(at: Date = new Date()): Date { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: 'America/New_York', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).formatToParts(at) + + const value = (type: Intl.DateTimeFormatPartTypes): number => { + const part = parts.find(p => p.type === type) + return part ? Number(part.value) : 0 + } + + return new Date(Date.UTC( + value('year'), + value('month') - 1, + value('day'), + // hour12:false yields 24 for midnight in some ICU versions rather than 0, + // which would roll the date forward a day. + value('hour') % 24, + value('minute'), + value('second'), + )) +}