From e6b461cc90142f035fece7ad3bb4fbcb8286001c Mon Sep 17 00:00:00 2001 From: Rachel Shu Date: Wed, 28 Jan 2026 19:28:10 -0800 Subject: [PATCH 1/7] Add room booking system for members - Create Room Bookings table in Airtable via API - Add room-bookings.ts library with booking/conflict logic - Add API routes for rooms and bookings (GET, POST, DELETE) - Add /portal/book-room UI with hourly time slots - Add link to book rooms from portal main page Co-Authored-By: Claude Opus 4.5 --- app/lib/airtable.ts | 30 ++ app/lib/room-bookings.ts | 220 +++++++++++++ app/portal/api/room-bookings/[id]/route.ts | 57 ++++ app/portal/api/room-bookings/route.ts | 142 +++++++++ app/portal/api/rooms/route.ts | 19 ++ app/portal/book-room/BookRoomClient.tsx | 352 +++++++++++++++++++++ app/portal/book-room/page.tsx | 37 +++ app/portal/page.tsx | 12 + 8 files changed, 869 insertions(+) create mode 100644 app/lib/room-bookings.ts create mode 100644 app/portal/api/room-bookings/[id]/route.ts create mode 100644 app/portal/api/room-bookings/route.ts create mode 100644 app/portal/api/rooms/route.ts create mode 100644 app/portal/book-room/BookRoomClient.tsx create mode 100644 app/portal/book-room/page.tsx diff --git a/app/lib/airtable.ts b/app/lib/airtable.ts index 6d00388..bfe5da6 100644 --- a/app/lib/airtable.ts +++ b/app/lib/airtable.ts @@ -9,6 +9,8 @@ export const Tables = { Orgs: 'Orgs', Programs: 'Programs', DayPasses: 'Day Passes', + Rooms: 'Rooms', + RoomBookings: 'Room Bookings', } as const export type TableName = (typeof Tables)[keyof typeof Tables] @@ -260,5 +262,33 @@ export async function findRecord>( return records[0] || null } +/** + * Deletes a record by ID. + * + * @param table - Table name + * @param recordId - Airtable record ID + * @returns true if deleted, false if not found + */ +export async function deleteRecord(table: TableName, recordId: string): Promise { + const url = `${AIRTABLE_API_URL}/${env.AIRTABLE_BASE_ID}/${encodeURIComponent(table)}/${recordId}` + + const res = await fetch(url, { + method: 'DELETE', + headers: getHeaders(), + }) + + if (!res.ok) { + if (res.status === 404) { + return false + } + const errorData = await res.json().catch(() => ({})) + throw new Error( + `Airtable delete error: ${res.status} ${res.statusText} - ${JSON.stringify(errorData)}` + ) + } + + return true +} + // Re-export the escape helper for use in formulas export { escapeAirtableString } from './airtable-helpers' diff --git a/app/lib/room-bookings.ts b/app/lib/room-bookings.ts new file mode 100644 index 0000000..ab8eb89 --- /dev/null +++ b/app/lib/room-bookings.ts @@ -0,0 +1,220 @@ +import { + Tables, + findRecords, + findRecord, + createRecord, + updateRecord, + type AirtableRecord, +} from './airtable' +import { escapeAirtableString } from './airtable-helpers' + +// Airtable field interfaces +export interface RoomFields { + Name: string + Floor?: string + 'Room #'?: string + 'Room Size'?: number + Status?: string + Bookable?: boolean +} + +export interface RoomBookingFields { + Name?: string + Room?: string[] + 'Booked By'?: string[] + Start?: string + End?: string + Purpose?: string + Status?: 'Confirmed' | 'Cancelled' +} + +// Client-friendly interfaces +export interface Room { + id: string + name: string + floor?: string + roomNumber?: string + size?: number + status?: string + bookable: boolean +} + +export interface Booking { + id: string + roomId: string + roomName: string + userId: string + userName?: string + startDate: Date + endDate: Date + purpose?: string + status: 'Confirmed' | 'Cancelled' +} + +// Transform Airtable record to Room +function parseRoom(record: AirtableRecord): Room { + return { + id: record.id, + name: record.fields.Name, + floor: record.fields.Floor, + roomNumber: record.fields['Room #'], + size: record.fields['Room Size'], + status: record.fields.Status, + bookable: record.fields.Bookable === true, + } +} + +// Transform Airtable record to Booking +function parseBooking( + record: AirtableRecord, + roomName?: string +): Booking { + return { + id: record.id, + roomId: record.fields.Room?.[0] || '', + roomName: roomName || record.fields.Name?.split(' - ')[0] || '', + userId: record.fields['Booked By']?.[0] || '', + startDate: new Date(record.fields.Start || ''), + endDate: new Date(record.fields.End || ''), + purpose: record.fields.Purpose, + status: record.fields.Status || 'Confirmed', + } +} + +/** + * Get all rooms marked as bookable + */ +export async function getBookableRooms(): Promise { + const records = await findRecords(Tables.Rooms, '{Bookable} = TRUE()') + return records.map(parseRoom) +} + +/** + * Get a single room by ID + */ +export async function getRoom(roomId: string): Promise { + const record = await findRecord(Tables.Rooms, `RECORD_ID() = '${escapeAirtableString(roomId)}'`) + return record ? parseRoom(record) : null +} + +/** + * Get bookings for a specific room in a date range + */ +export async function getBookingsForRoom( + roomId: string, + startDate: Date, + endDate: Date +): Promise { + const startISO = startDate.toISOString() + const endISO = endDate.toISOString() + + // Find bookings that overlap with the requested time range + const formula = `AND( + FIND('${escapeAirtableString(roomId)}', ARRAYJOIN({Room})) > 0, + {Status} = 'Confirmed', + IS_BEFORE({Start}, '${endISO}'), + IS_AFTER({End}, '${startISO}') + )` + + const records = await findRecords(Tables.RoomBookings, formula) + return records.map((r) => parseBooking(r)) +} + +/** + * Get all bookings for a user + */ +export async function getUserBookings(userId: string): Promise { + const formula = `AND( + FIND('${escapeAirtableString(userId)}', ARRAYJOIN({Booked By})) > 0, + {Status} = 'Confirmed' + )` + + const records = await findRecords(Tables.RoomBookings, formula, { + sort: [{ field: 'Start', direction: 'asc' }], + }) + return records.map((r) => parseBooking(r)) +} + +/** + * Find conflicting bookings for a room in a time range + */ +export async function findConflicts( + roomId: string, + startDate: Date, + endDate: Date, + excludeBookingId?: string +): Promise { + const startISO = startDate.toISOString() + const endISO = endDate.toISOString() + + // Find bookings that overlap with the requested time range + let formula = `AND( + FIND('${escapeAirtableString(roomId)}', ARRAYJOIN({Room})) > 0, + {Status} = 'Confirmed', + IS_BEFORE({Start}, '${endISO}'), + IS_AFTER({End}, '${startISO}') + )` + + if (excludeBookingId) { + formula = `AND( + RECORD_ID() != '${escapeAirtableString(excludeBookingId)}', + FIND('${escapeAirtableString(roomId)}', ARRAYJOIN({Room})) > 0, + {Status} = 'Confirmed', + IS_BEFORE({Start}, '${endISO}'), + IS_AFTER({End}, '${startISO}') + )` + } + + const records = await findRecords(Tables.RoomBookings, formula) + return records.map((r) => parseBooking(r)) +} + +/** + * Create a new booking + */ +export async function createBooking(params: { + roomId: string + roomName: string + userId: string + userName: string + startDate: Date + endDate: Date + purpose?: string +}): Promise { + const { roomId, roomName, userId, userName, startDate, endDate, purpose } = params + + // Format the name field for easy identification + const dateStr = startDate.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) + const name = `${roomName} - ${userName} - ${dateStr}` + + const record = await createRecord(Tables.RoomBookings, { + Name: name, + Room: [roomId], + 'Booked By': [userId], + Start: startDate.toISOString(), + End: endDate.toISOString(), + Purpose: purpose, + Status: 'Confirmed', + }) + + return parseBooking(record, roomName) +} + +/** + * Cancel a booking (soft delete - sets status to Cancelled) + */ +export async function cancelBooking(bookingId: string): Promise { + try { + await updateRecord(Tables.RoomBookings, bookingId, { + Status: 'Cancelled', + }) + return true + } catch { + return false + } +} diff --git a/app/portal/api/room-bookings/[id]/route.ts b/app/portal/api/room-bookings/[id]/route.ts new file mode 100644 index 0000000..7ec10dc --- /dev/null +++ b/app/portal/api/room-bookings/[id]/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/app/lib/session' +import { cancelBooking } from '@/app/lib/room-bookings' +import { getRecord, Tables } from '@/app/lib/airtable' + +interface RoomBookingFields { + 'Booked By'?: string[] +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await getSession() + + if (!session.isLoggedIn) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + + if (!id) { + return NextResponse.json({ error: 'Booking ID is required' }, { status: 400 }) + } + + try { + // Get the booking to verify ownership + const booking = await getRecord(Tables.RoomBookings, id) + + if (!booking) { + return NextResponse.json({ error: 'Booking not found' }, { status: 404 }) + } + + // Check if user owns this booking (or is staff) + const userId = session.viewingAsUserId || session.userId + const bookedBy = booking.fields['Booked By']?.[0] + + if (bookedBy !== userId && !session.isStaff) { + return NextResponse.json( + { error: 'You can only cancel your own bookings' }, + { status: 403 } + ) + } + + // Cancel the booking + const success = await cancelBooking(id) + + if (!success) { + return NextResponse.json({ error: 'Failed to cancel booking' }, { status: 500 }) + } + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error cancelling booking:', error) + return NextResponse.json({ error: 'Failed to cancel booking' }, { status: 500 }) + } +} diff --git a/app/portal/api/room-bookings/route.ts b/app/portal/api/room-bookings/route.ts new file mode 100644 index 0000000..f75361d --- /dev/null +++ b/app/portal/api/room-bookings/route.ts @@ -0,0 +1,142 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/app/lib/session' +import { + getUserBookings, + getBookingsForRoom, + findConflicts, + createBooking, + getRoom, +} from '@/app/lib/room-bookings' +import { getRecord, Tables } from '@/app/lib/airtable' + +interface PersonFields { + Name?: string +} + +export async function GET(request: NextRequest) { + const session = await getSession() + + if (!session.isLoggedIn) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const searchParams = request.nextUrl.searchParams + const roomId = searchParams.get('roomId') + const startDate = searchParams.get('startDate') + const endDate = searchParams.get('endDate') + + try { + // If roomId and dates provided, get bookings for that room + if (roomId && startDate && endDate) { + const bookings = await getBookingsForRoom( + roomId, + new Date(startDate), + new Date(endDate) + ) + return NextResponse.json({ bookings }) + } + + // Otherwise, get current user's bookings + const userId = session.viewingAsUserId || session.userId + const bookings = await getUserBookings(userId) + return NextResponse.json({ bookings }) + } catch (error) { + console.error('Error fetching bookings:', error) + return NextResponse.json({ error: 'Failed to fetch bookings' }, { status: 500 }) + } +} + +export async function POST(request: NextRequest) { + const session = await getSession() + + if (!session.isLoggedIn) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const body = await request.json() + const { roomId, startDate, endDate, purpose } = body + + // Validate required fields + if (!roomId || !startDate || !endDate) { + return NextResponse.json( + { error: 'Room, start date, and end date are required' }, + { status: 400 } + ) + } + + const start = new Date(startDate) + const end = new Date(endDate) + + // Validate dates + if (isNaN(start.getTime()) || isNaN(end.getTime())) { + return NextResponse.json({ error: 'Invalid date format' }, { status: 400 }) + } + + if (end <= start) { + return NextResponse.json( + { error: 'End time must be after start time' }, + { status: 400 } + ) + } + + if (start < new Date()) { + return NextResponse.json( + { error: 'Cannot book in the past' }, + { status: 400 } + ) + } + + // Check for conflicts + const conflicts = await findConflicts(roomId, start, end) + if (conflicts.length > 0) { + return NextResponse.json( + { + error: 'Time slot is already booked', + conflicts: conflicts.map((c) => ({ + startDate: c.startDate, + endDate: c.endDate, + })), + }, + { status: 409 } + ) + } + + // Get room details + const room = await getRoom(roomId) + if (!room) { + return NextResponse.json({ error: 'Room not found' }, { status: 404 }) + } + + if (!room.bookable) { + return NextResponse.json({ error: 'Room is not bookable' }, { status: 400 }) + } + + // Get user details + const userId = session.viewingAsUserId || session.userId + const userName = session.viewingAsName || session.name || '' + + // If we don't have the name from session, fetch it + let finalUserName = userName + if (!finalUserName) { + const userRecord = await getRecord(Tables.People, userId) + finalUserName = userRecord?.fields.Name || 'Unknown' + } + + // Create the booking + const booking = await createBooking({ + roomId, + roomName: room.name, + userId, + userName: finalUserName, + startDate: start, + endDate: end, + purpose, + }) + + return NextResponse.json({ booking }, { status: 201 }) + } catch (error) { + console.error('Error creating booking:', error) + return NextResponse.json({ error: 'Failed to create booking' }, { status: 500 }) + } +} diff --git a/app/portal/api/rooms/route.ts b/app/portal/api/rooms/route.ts new file mode 100644 index 0000000..57afab3 --- /dev/null +++ b/app/portal/api/rooms/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from 'next/server' +import { getSession } from '@/app/lib/session' +import { getBookableRooms } from '@/app/lib/room-bookings' + +export async function GET() { + const session = await getSession() + + if (!session.isLoggedIn) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const rooms = await getBookableRooms() + return NextResponse.json({ rooms }) + } catch (error) { + console.error('Error fetching rooms:', error) + return NextResponse.json({ error: 'Failed to fetch rooms' }, { status: 500 }) + } +} diff --git a/app/portal/book-room/BookRoomClient.tsx b/app/portal/book-room/BookRoomClient.tsx new file mode 100644 index 0000000..8ff15ff --- /dev/null +++ b/app/portal/book-room/BookRoomClient.tsx @@ -0,0 +1,352 @@ +'use client' + +import { useState, useEffect } from 'react' + +interface Room { + id: string + name: string + floor?: string + roomNumber?: string + size?: number + status?: string + bookable: boolean +} + +interface Booking { + id: string + roomId: string + roomName: string + userId: string + startDate: string + endDate: string + purpose?: string + status: 'Confirmed' | 'Cancelled' +} + +interface BookRoomClientProps { + userId: string + userName: string +} + +// Generate hourly time slots from 8am to 10pm +function generateTimeSlots(): string[] { + const slots: string[] = [] + for (let hour = 8; hour <= 22; hour++) { + const hourStr = hour.toString().padStart(2, '0') + slots.push(`${hourStr}:00`) + } + return slots +} + +const TIME_SLOTS = generateTimeSlots() + +export default function BookRoomClient({ userId, userName }: BookRoomClientProps) { + const [rooms, setRooms] = useState([]) + const [bookings, setBookings] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Form state + const [selectedRoom, setSelectedRoom] = useState('') + const [selectedDate, setSelectedDate] = useState('') + const [startTime, setStartTime] = useState('') + const [endTime, setEndTime] = useState('') + const [purpose, setPurpose] = useState('') + const [submitting, setSubmitting] = useState(false) + const [submitError, setSubmitError] = useState(null) + const [submitSuccess, setSubmitSuccess] = useState(false) + + // Load rooms and user's bookings + useEffect(() => { + async function loadData() { + try { + const [roomsRes, bookingsRes] = await Promise.all([ + fetch('/portal/api/rooms'), + fetch('/portal/api/room-bookings'), + ]) + + if (!roomsRes.ok || !bookingsRes.ok) { + throw new Error('Failed to load data') + } + + const roomsData = await roomsRes.json() + const bookingsData = await bookingsRes.json() + + setRooms(roomsData.rooms || []) + setBookings(bookingsData.bookings || []) + setLoading(false) + } catch (err) { + setError('Failed to load rooms and bookings') + setLoading(false) + } + } + + loadData() + }, []) + + // Set default date to today + useEffect(() => { + const today = new Date().toISOString().split('T')[0] + setSelectedDate(today) + }, []) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setSubmitError(null) + setSubmitSuccess(false) + setSubmitting(true) + + if (!selectedRoom || !selectedDate || !startTime || !endTime) { + setSubmitError('Please fill in all required fields') + setSubmitting(false) + return + } + + // Construct ISO datetime strings + const startDate = new Date(`${selectedDate}T${startTime}:00`) + const endDate = new Date(`${selectedDate}T${endTime}:00`) + + try { + const response = await fetch('/portal/api/room-bookings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + roomId: selectedRoom, + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + purpose: purpose || undefined, + }), + }) + + const data = await response.json() + + if (!response.ok) { + setSubmitError(data.error || 'Failed to create booking') + setSubmitting(false) + return + } + + // Success - add to bookings list and reset form + setBookings((prev) => [...prev, data.booking]) + setSubmitSuccess(true) + setStartTime('') + setEndTime('') + setPurpose('') + setSubmitting(false) + } catch (err) { + setSubmitError('Failed to create booking') + setSubmitting(false) + } + } + + const handleCancel = async (bookingId: string) => { + if (!confirm('Are you sure you want to cancel this booking?')) { + return + } + + try { + const response = await fetch(`/portal/api/room-bookings/${bookingId}`, { + method: 'DELETE', + }) + + if (!response.ok) { + const data = await response.json() + alert(data.error || 'Failed to cancel booking') + return + } + + // Remove from list + setBookings((prev) => prev.filter((b) => b.id !== bookingId)) + } catch (err) { + alert('Failed to cancel booking') + } + } + + const formatDateTime = (dateStr: string) => { + const date = new Date(dateStr) + return date.toLocaleString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) + } + + if (loading) { + return

loading rooms...

+ } + + if (error) { + return

{error}

+ } + + // Filter to only future bookings + const futureBookings = bookings.filter( + (b) => new Date(b.endDate) > new Date() + ) + + return ( + <> + {/* Booking Form */} +
+

new booking

+
+
+ + +
+ +
+ + setSelectedDate(e.target.value)} + min={new Date().toISOString().split('T')[0]} + required + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + setPurpose(e.target.value)} + placeholder="e.g., team meeting, call, interview" + /> +
+ + {submitError && ( +
+

{submitError}

+
+ )} + + {submitSuccess && ( +
+

Booking created successfully!

+
+ )} + + +
+
+ +
+ + {/* User's Bookings */} +
+

your bookings

+ {futureBookings.length === 0 ? ( +

no upcoming bookings

+ ) : ( + + + + + + + + + + {futureBookings.map((booking) => ( + + + + + + ))} + +
roomwhen
{booking.roomName} + {formatDateTime(booking.startDate)} + {' - '} + {new Date(booking.endDate).toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + })} + {booking.purpose && ( + ({booking.purpose}) + )} + + +
+ )} +
+ + ) +} + +function formatTimeSlot(time: string): string { + const hour = parseInt(time.split(':')[0]) + const ampm = hour >= 12 ? 'pm' : 'am' + const displayHour = hour > 12 ? hour - 12 : hour === 0 ? 12 : hour + return `${displayHour}:00 ${ampm}` +} diff --git a/app/portal/book-room/page.tsx b/app/portal/book-room/page.tsx new file mode 100644 index 0000000..75ea937 --- /dev/null +++ b/app/portal/book-room/page.tsx @@ -0,0 +1,37 @@ +import { getSession } from '@/app/lib/session' +import { redirect } from 'next/navigation' +import Link from 'next/link' +import BookRoomClient from './BookRoomClient' + +export const dynamic = 'force-dynamic' + +export default async function BookRoomPage() { + const session = await getSession() + + if (!session.isLoggedIn) { + redirect('/portal/login') + } + + const userId = session.viewingAsUserId || session.userId + const userName = session.viewingAsName || session.name || '' + + return ( + <> + + ← back to portal + + +

book a room

+ + {session.viewingAsUserId && session.viewingAsName && ( +
+ admin mode: booking as{' '} + {session.viewingAsName}.{' '} + switch back +
+ )} + + + + ) +} diff --git a/app/portal/page.tsx b/app/portal/page.tsx index 67c3af7..edd1e95 100644 --- a/app/portal/page.tsx +++ b/app/portal/page.tsx @@ -130,6 +130,18 @@ export default async function DashboardPage() {
+ {/* Room Booking */} +
+

book a room

+

+ + reserve a meeting room or call booth + +

+
+ +
+ {/* Events */}
From 81f9ac5ee26a6508c62c25a2785c11a7bfe5bced Mon Sep 17 00:00:00 2001 From: Rachel Shu Date: Wed, 28 Jan 2026 19:48:30 -0800 Subject: [PATCH 2/7] Add day calendar view to room booking page Shows hourly time slots for the selected room and date: - Green highlight for user's current selection - Red highlight for already booked slots - Updates in real-time when booking is created Co-Authored-By: Claude Opus 4.5 --- app/portal/book-room/BookRoomClient.tsx | 90 ++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/app/portal/book-room/BookRoomClient.tsx b/app/portal/book-room/BookRoomClient.tsx index 8ff15ff..c9fad0d 100644 --- a/app/portal/book-room/BookRoomClient.tsx +++ b/app/portal/book-room/BookRoomClient.tsx @@ -43,6 +43,7 @@ const TIME_SLOTS = generateTimeSlots() export default function BookRoomClient({ userId, userName }: BookRoomClientProps) { const [rooms, setRooms] = useState([]) const [bookings, setBookings] = useState([]) + const [dayBookings, setDayBookings] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -84,6 +85,34 @@ export default function BookRoomClient({ userId, userName }: BookRoomClientProps loadData() }, []) + // Load bookings for selected room and date + useEffect(() => { + if (!selectedRoom || !selectedDate) { + setDayBookings([]) + return + } + + async function loadDayBookings() { + try { + const startOfDay = new Date(`${selectedDate}T00:00:00`) + const endOfDay = new Date(`${selectedDate}T23:59:59`) + + const res = await fetch( + `/portal/api/room-bookings?roomId=${selectedRoom}&startDate=${startOfDay.toISOString()}&endDate=${endOfDay.toISOString()}` + ) + + if (res.ok) { + const data = await res.json() + setDayBookings(data.bookings || []) + } + } catch { + // Silently fail - calendar view is secondary + } + } + + loadDayBookings() + }, [selectedRoom, selectedDate]) + // Set default date to today useEffect(() => { const today = new Date().toISOString().split('T')[0] @@ -126,8 +155,9 @@ export default function BookRoomClient({ userId, userName }: BookRoomClientProps return } - // Success - add to bookings list and reset form + // Success - add to bookings list and day view, reset form setBookings((prev) => [...prev, data.booking]) + setDayBookings((prev) => [...prev, data.booking]) setSubmitSuccess(true) setStartTime('') setEndTime('') @@ -186,6 +216,18 @@ export default function BookRoomClient({ userId, userName }: BookRoomClientProps (b) => new Date(b.endDate) > new Date() ) + // Check if a time slot is booked + const getSlotBooking = (slot: string): Booking | undefined => { + const slotHour = parseInt(slot.split(':')[0]) + return dayBookings.find((b) => { + const startHour = new Date(b.startDate).getHours() + const endHour = new Date(b.endDate).getHours() + return slotHour >= startHour && slotHour < endHour + }) + } + + const selectedRoomName = rooms.find((r) => r.id === selectedRoom)?.name + return ( <> {/* Booking Form */} @@ -293,6 +335,52 @@ export default function BookRoomClient({ userId, userName }: BookRoomClientProps {submitting ? 'booking...' : 'book room'} + + {/* Day Calendar View */} + {selectedRoom && selectedDate && ( +
+

+ {selectedRoomName} - {new Date(selectedDate + 'T12:00:00').toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })} +

+
+ {TIME_SLOTS.slice(0, -1).map((slot) => { + const booking = getSlotBooking(slot) + const isBooked = !!booking + const isSelected = startTime && endTime && slot >= startTime && slot < endTime + + return ( +
+ + {formatTimeSlot(slot)} + + {isBooked && ( + + booked{booking.purpose && ` - ${booking.purpose}`} + + )} + {isSelected && !isBooked && ( + your selection + )} +
+ ) + })} +
+
+ )}

From 8ad4b4b9cda2340d763503c3b203a822eaf8d609 Mon Sep 17 00:00:00 2001 From: Rachel Shu Date: Wed, 28 Jan 2026 19:54:24 -0800 Subject: [PATCH 3/7] Fix booking display by filtering linked records client-side Airtable's ARRAYJOIN function doesn't work on linked record fields in formulas. Changed to fetch all matching bookings and filter by room/user ID client-side instead. Also updated day calendar to show all rooms side-by-side. Co-Authored-By: Claude Opus 4.5 --- app/lib/room-bookings.ts | 30 ++-- app/portal/book-room/BookRoomClient.tsx | 177 ++++++++++++++++-------- 2 files changed, 138 insertions(+), 69 deletions(-) diff --git a/app/lib/room-bookings.ts b/app/lib/room-bookings.ts index ab8eb89..56e926f 100644 --- a/app/lib/room-bookings.ts +++ b/app/lib/room-bookings.ts @@ -109,30 +109,37 @@ export async function getBookingsForRoom( const endISO = endDate.toISOString() // Find bookings that overlap with the requested time range + // (linked record fields require client-side filtering) const formula = `AND( - FIND('${escapeAirtableString(roomId)}', ARRAYJOIN({Room})) > 0, {Status} = 'Confirmed', IS_BEFORE({Start}, '${endISO}'), IS_AFTER({End}, '${startISO}') )` const records = await findRecords(Tables.RoomBookings, formula) - return records.map((r) => parseBooking(r)) + + // Filter to only this room's bookings + return records + .filter((r) => r.fields.Room?.includes(roomId)) + .map((r) => parseBooking(r)) } /** * Get all bookings for a user */ export async function getUserBookings(userId: string): Promise { - const formula = `AND( - FIND('${escapeAirtableString(userId)}', ARRAYJOIN({Booked By})) > 0, - {Status} = 'Confirmed' - )` + // Fetch confirmed bookings and filter by user client-side + // (linked record fields in Airtable formulas require lookup/rollup fields for text search) + const formula = `{Status} = 'Confirmed'` const records = await findRecords(Tables.RoomBookings, formula, { sort: [{ field: 'Start', direction: 'asc' }], }) - return records.map((r) => parseBooking(r)) + + // Filter to only this user's bookings + return records + .filter((r) => r.fields['Booked By']?.includes(userId)) + .map((r) => parseBooking(r)) } /** @@ -148,8 +155,8 @@ export async function findConflicts( const endISO = endDate.toISOString() // Find bookings that overlap with the requested time range + // (linked record fields require client-side filtering) let formula = `AND( - FIND('${escapeAirtableString(roomId)}', ARRAYJOIN({Room})) > 0, {Status} = 'Confirmed', IS_BEFORE({Start}, '${endISO}'), IS_AFTER({End}, '${startISO}') @@ -158,7 +165,6 @@ export async function findConflicts( if (excludeBookingId) { formula = `AND( RECORD_ID() != '${escapeAirtableString(excludeBookingId)}', - FIND('${escapeAirtableString(roomId)}', ARRAYJOIN({Room})) > 0, {Status} = 'Confirmed', IS_BEFORE({Start}, '${endISO}'), IS_AFTER({End}, '${startISO}') @@ -166,7 +172,11 @@ export async function findConflicts( } const records = await findRecords(Tables.RoomBookings, formula) - return records.map((r) => parseBooking(r)) + + // Filter to only this room's bookings + return records + .filter((r) => r.fields.Room?.includes(roomId)) + .map((r) => parseBooking(r)) } /** diff --git a/app/portal/book-room/BookRoomClient.tsx b/app/portal/book-room/BookRoomClient.tsx index c9fad0d..6e9f899 100644 --- a/app/portal/book-room/BookRoomClient.tsx +++ b/app/portal/book-room/BookRoomClient.tsx @@ -85,9 +85,9 @@ export default function BookRoomClient({ userId, userName }: BookRoomClientProps loadData() }, []) - // Load bookings for selected room and date + // Load bookings for ALL rooms on selected date useEffect(() => { - if (!selectedRoom || !selectedDate) { + if (!selectedDate || rooms.length === 0) { setDayBookings([]) return } @@ -97,21 +97,25 @@ export default function BookRoomClient({ userId, userName }: BookRoomClientProps const startOfDay = new Date(`${selectedDate}T00:00:00`) const endOfDay = new Date(`${selectedDate}T23:59:59`) - const res = await fetch( - `/portal/api/room-bookings?roomId=${selectedRoom}&startDate=${startOfDay.toISOString()}&endDate=${endOfDay.toISOString()}` + // Fetch bookings for all rooms in parallel + const results = await Promise.all( + rooms.map((room) => + fetch( + `/portal/api/room-bookings?roomId=${room.id}&startDate=${startOfDay.toISOString()}&endDate=${endOfDay.toISOString()}` + ).then((res) => (res.ok ? res.json() : { bookings: [] })) + ) ) - if (res.ok) { - const data = await res.json() - setDayBookings(data.bookings || []) - } + // Flatten all bookings into one array + const allBookings = results.flatMap((r) => r.bookings || []) + setDayBookings(allBookings) } catch { // Silently fail - calendar view is secondary } } loadDayBookings() - }, [selectedRoom, selectedDate]) + }, [selectedDate, rooms]) // Set default date to today useEffect(() => { @@ -156,8 +160,9 @@ export default function BookRoomClient({ userId, userName }: BookRoomClientProps } // Success - add to bookings list and day view, reset form - setBookings((prev) => [...prev, data.booking]) - setDayBookings((prev) => [...prev, data.booking]) + const newBooking = { ...data.booking, roomId: selectedRoom } + setBookings((prev) => [...prev, newBooking]) + setDayBookings((prev) => [...prev, newBooking]) setSubmitSuccess(true) setStartTime('') setEndTime('') @@ -216,18 +221,17 @@ export default function BookRoomClient({ userId, userName }: BookRoomClientProps (b) => new Date(b.endDate) > new Date() ) - // Check if a time slot is booked - const getSlotBooking = (slot: string): Booking | undefined => { + // Check if a time slot is booked for a specific room + const getSlotBooking = (roomId: string, slot: string): Booking | undefined => { const slotHour = parseInt(slot.split(':')[0]) return dayBookings.find((b) => { + if (b.roomId !== roomId) return false const startHour = new Date(b.startDate).getHours() const endHour = new Date(b.endDate).getHours() return slotHour >= startHour && slotHour < endHour }) } - const selectedRoomName = rooms.find((r) => r.id === selectedRoom)?.name - return ( <> {/* Booking Form */} @@ -336,52 +340,107 @@ export default function BookRoomClient({ userId, userName }: BookRoomClientProps - {/* Day Calendar View */} - {selectedRoom && selectedDate && ( -
-

- {selectedRoomName} - {new Date(selectedDate + 'T12:00:00').toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })} -

-
- {TIME_SLOTS.slice(0, -1).map((slot) => { - const booking = getSlotBooking(slot) - const isBooked = !!booking - const isSelected = startTime && endTime && slot >= startTime && slot < endTime - - return ( -
- + + + {/* Day Calendar View - All Rooms */} + {selectedDate && rooms.length > 0 && ( +
+

+ {new Date(selectedDate + 'T12:00:00').toLocaleDateString('en-US', { + weekday: 'long', + month: 'short', + day: 'numeric', + })} +

+
+ + + + + {rooms.map((room) => ( + + ))} + + + + {TIME_SLOTS.slice(0, -1).map((slot) => ( + + + {rooms.map((room) => { + const booking = getSlotBooking(room.id, slot) + const isBooked = !!booking + const isSelected = + selectedRoom === room.id && + startTime && + endTime && + slot >= startTime && + slot < endTime + + return ( + + ) + })} + + ))} + +
+ {room.name} +
{formatTimeSlot(slot)} - - {isBooked && ( - - booked{booking.purpose && ` - ${booking.purpose}`} - - )} - {isSelected && !isBooked && ( - your selection - )} - - ) - })} - + { + if (!isBooked) { + setSelectedRoom(room.id) + const slotHour = parseInt(slot.split(':')[0]) + setStartTime(slot) + if (slotHour < 22) { + setEndTime(`${(slotHour + 1).toString().padStart(2, '0')}:00`) + } + } + }} + > + {isBooked ? ( + x + ) : isSelected ? ( + * + ) : ( + - + )} +
- )} -
+

+ click a slot to select it | x = booked | * = your selection +

+ + )}
From e87d8a55075cbaf0ceed2df7befea903d99dc206 Mon Sep 17 00:00:00 2001 From: Rachel Shu Date: Wed, 28 Jan 2026 23:46:27 -0800 Subject: [PATCH 4/7] Improve room booking UX - Move date field before room selector - Prefill start/end times to nearest hour - Switch table orientation: rooms as rows, times as columns - Group rooms by floor in calendar view Co-Authored-By: Claude Opus 4.5 --- app/portal/book-room/BookRoomClient.tsx | 215 +++++++++++++++--------- 1 file changed, 132 insertions(+), 83 deletions(-) diff --git a/app/portal/book-room/BookRoomClient.tsx b/app/portal/book-room/BookRoomClient.tsx index 6e9f899..92cd34d 100644 --- a/app/portal/book-room/BookRoomClient.tsx +++ b/app/portal/book-room/BookRoomClient.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect } from 'react' +import React, { useState, useEffect } from 'react' interface Room { id: string @@ -117,10 +117,27 @@ export default function BookRoomClient({ userId, userName }: BookRoomClientProps loadDayBookings() }, [selectedDate, rooms]) - // Set default date to today + // Set default date to today and prefill times to nearest hour useEffect(() => { - const today = new Date().toISOString().split('T')[0] + const now = new Date() + const today = now.toISOString().split('T')[0] setSelectedDate(today) + + // Round up to next hour for start time + const currentHour = now.getHours() + const nextHour = currentHour + 1 + if (nextHour >= 8 && nextHour < 22) { + const startSlot = `${nextHour.toString().padStart(2, '0')}:00` + setStartTime(startSlot) + if (nextHour < 21) { + setEndTime(`${(nextHour + 1).toString().padStart(2, '0')}:00`) + } + } else if (nextHour < 8) { + // Before 8am, default to 8am + setStartTime('08:00') + setEndTime('09:00') + } + // If after 9pm, leave times empty (too late to book today) }, []) const handleSubmit = async (e: React.FormEvent) => { @@ -238,6 +255,18 @@ export default function BookRoomClient({ userId, userName }: BookRoomClientProps

new booking

+
+ + setSelectedDate(e.target.value)} + min={new Date().toISOString().split('T')[0]} + required + /> +
+
setSelectedDate(e.target.value)} - min={new Date().toISOString().split('T')[0]} - required - /> -
-