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
10 changes: 9 additions & 1 deletion app/api/display/[spaceId]/route.ts
Original file line number Diff line number Diff line change
@@ -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!,
Expand All @@ -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))

Expand Down
5 changes: 4 additions & 1 deletion app/api/spaces/bookings/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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.`,
Expand Down
8 changes: 7 additions & 1 deletion app/api/spaces/bookings/route.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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.`,
Expand Down
11 changes: 10 additions & 1 deletion app/api/spaces/remaining-hours/route.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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)
Expand Down
52 changes: 52 additions & 0 deletions lib/boston-time.ts
Original file line number Diff line number Diff line change
@@ -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'),
))
}
Loading