diff --git a/src/app/debug/bookings/BookingDebugPage.module.scss b/src/app/debug/bookings/BookingDebugPage.module.scss index 46e137b..bb97448 100644 --- a/src/app/debug/bookings/BookingDebugPage.module.scss +++ b/src/app/debug/bookings/BookingDebugPage.module.scss @@ -25,3 +25,50 @@ overflow-x: auto; margin-top: 1rem; } + +.debugOutput { + grid-column: 1 / -1; + background: #1a1a2e; + color: #eee; + padding: 1rem; + border-radius: 8px; + border-left: 4px solid #6366f1; + + h4 { + margin: 0 0 0.75rem; + font-size: 0.95rem; + } + + pre { + margin: 0; + white-space: pre-wrap; + font-size: 0.85rem; + } +} + +.errorMessage { + color: #ef4444; +} + +.bookedSlotsTable { + grid-column: 1 / -1; + background: #1a1a2e; + color: #eee; + padding: 1rem; + border-radius: 8px; + border-left: 4px solid #22c55e; + + h4 { + margin: 0 0 0.75rem; + font-size: 0.95rem; + } +} + +.bookedSlotsTableBody { + th, + td { + background: #252540 !important; + color: #eee !important; + border-color: #3f3f5a !important; + } +} diff --git a/src/app/debug/bookings/page.tsx b/src/app/debug/bookings/page.tsx index e823f7a..ec9a3a3 100644 --- a/src/app/debug/bookings/page.tsx +++ b/src/app/debug/bookings/page.tsx @@ -1,26 +1,155 @@ "use client"; -import { Button, Divider, Group, Select, Textarea, TextInput } from "@mantine/core"; +import { + Alert, + Button, + Divider, + Group, + Select, + Table, + Text, + Textarea, + TextInput, +} from "@mantine/core"; +import { DateInput, TimeInput } from "@mantine/dates"; import { useForm } from "@mantine/form"; import { notifications } from "@mantine/notifications"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { api } from "@/trpc/react"; import { ALL_BOOKING_STATUSES, BookingStatus, type BookingStatusValue } from "@/types/types"; import styles from "./BookingDebugPage.module.scss"; +/** Returns true if end is after start (or invalid); used for form validation. */ +function isEndAfterStart(start: string, end: string) { + const a = new Date(start).getTime(); + const b = new Date(end).getTime(); + if (Number.isNaN(a) || Number.isNaN(b)) return true; // let backend/zod handle invalid formats + return b > a; +} + +/** Formats start and end as a time range string in UTC (e.g. 9:00 AM – 10:30 AM). */ +function formatTimeSlot(startTime: string, endTime: string): string { + // Format in UTC to match stored booking times (e.g. "09:00:00+00" -> "9:00 AM") + const opts: Intl.DateTimeFormatOptions = { + hour: "numeric", + minute: "2-digit", + hour12: true, + timeZone: "UTC", + }; + const start = new Date(startTime).toLocaleTimeString("en-US", opts); + const end = new Date(endTime).toLocaleTimeString("en-US", opts); + return `${start} – ${end}`; +} + +/** Example pre-filled booking for testing. agencyId must be a valid user.id; set dynamically from getCurrentUser. */ +const EXAMPLE_BOOKING = { + title: "Test Example", + pickupAddress: "The Inn from the Cold, 110 11 Ave SE, Calgary, AB", + destinationAddress: "Sheldon M. Chumir Health Centre, 1213 4 St SW, Calgary, AB", + passengerInfo: "John Smith", + phoneNumber: "+1 (403) 760-9834", + purpose: "Medical appointment", + start: "2026-02-12T15:00:00.000Z", // Feb 12, 2026 3:00 PM UTC +}; + +/** Returns true if the booking overlaps the given UTC day (day boundaries in UTC). */ +function bookingOverlapsDay(booking: { startTime: string; endTime: string }, day: Date): boolean { + const y = day.getUTCFullYear(); + const m = day.getUTCMonth(); + const d = day.getUTCDate(); + const dStart = Date.UTC(y, m, d, 0, 0, 0, 0); + const dEnd = Date.UTC(y, m, d, 23, 59, 59, 999); + + const start = new Date(booking.startTime).getTime(); + const end = new Date(booking.endTime).getTime(); + + return start < dEnd && end > dStart; +} + +type BookingForSchedule = { + id: number; + driverId: string | null; + status: string; + startTime: string; + endTime: string; + title: string; +}; + +/** Table of a driver's bookings for the selected day; shows time slot and title. */ +function DriverScheduleTable({ + bookings, + day, + driverId, +}: { + bookings: BookingForSchedule[]; + day: Date | null; + driverId: string; +}) { + if (!day || !driverId) return null; + const bookedSlots = bookings + .filter( + (b) => b.driverId === driverId && b.status !== "cancelled" && bookingOverlapsDay(b, day), + ) + .sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime()); + + if (bookedSlots.length === 0) { + return ( + + No bookings this day – driver available all day + + ); + } + + return ( + + + + Time slot + Booking + + + + {bookedSlots.map((b) => ( + + {formatTimeSlot(b.startTime, b.endTime)} + + Booking #{b.id} – {b.title} + + + ))} + +
+ ); +} + +/** Debug page for creating/editing bookings and viewing driver availability. */ export default function BookingDebugPage() { const [bookingId, setBookingId] = useState(1); + // Day picker for driver availability (date-only) + const [selectedDay, setSelectedDay] = useState(() => { + const d = new Date(EXAMPLE_BOOKING.start); + return new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); + }); + + // Start time of day only (HH:mm) - day comes from selectedDay + const [startTimeOfDay, setStartTimeOfDay] = useState(() => { + const d = new Date(EXAMPLE_BOOKING.start); + const h = d.getUTCHours().toString().padStart(2, "0"); + const m = d.getUTCMinutes().toString().padStart(2, "0"); + return `${h}:${m}`; + }); const form = useForm({ initialValues: { - title: "", - pickupAddress: "", - destinationAddress: "", - passengerInfo: "", - start: "", - end: "", - agencyId: "", - purpose: "", + title: EXAMPLE_BOOKING.title, + pickupAddress: EXAMPLE_BOOKING.pickupAddress, + destinationAddress: EXAMPLE_BOOKING.destinationAddress, + passengerInfo: EXAMPLE_BOOKING.passengerInfo, + phoneNumber: EXAMPLE_BOOKING.phoneNumber ?? "", + start: EXAMPLE_BOOKING.start, // will be kept in sync with picker (string) + end: "", // will be kept in sync with picker (string), auto-calculated + agencyId: "", // set from getCurrentUser (must be valid user.id for FK) + purpose: EXAMPLE_BOOKING.purpose, driverId: "", status: BookingStatus.INCOMPLETE as BookingStatusValue, }, @@ -39,11 +168,97 @@ export default function BookingDebugPage() { const bookingQuery = api.bookings.getById.useQuery({ id: bookingId }, { enabled: false }); + const canShowDriverAvailability = !!selectedDay && !!form.values.driverId?.trim(); + const allBookingsQuery = api.bookings.getAll.useQuery(undefined, { - enabled: false, + enabled: true, // fetch on load for both driver availability table and Fetch All staleTime: 0, }); + const listDriversQuery = api.bookings.listDrivers.useQuery(); + const currentUserQuery = api.bookings.getCurrentUser.useQuery(); + + // Set agencyId from current user so it references a valid user (fixes FK constraint) + useEffect(() => { + if (currentUserQuery.data && !form.values.agencyId) { + form.setFieldValue("agencyId", currentUserQuery.data.id); + } + }, [currentUserQuery.data, form.setFieldValue, form.values.agencyId]); + + const driverOptions = useMemo( + () => + (listDriversQuery.data ?? []).map((d) => ({ + value: d.id, + label: "email" in d && d.email != null ? `${d.name} (${d.email})` : d.name, + })), + [listDriversQuery.data], + ); + + // Pre-select first driver when options load (for example pre-fill) + useEffect(() => { + if (driverOptions.length > 0 && !form.values.driverId) { + form.setFieldValue("driverId", driverOptions[0]?.value ?? ""); + } + }, [driverOptions, form.setFieldValue, form.values.driverId]); + + // Compute full start ISO from selectedDay + startTimeOfDay (naive UTC to match booking storage) + const computedStart = useMemo(() => { + const day = selectedDay; + const time = startTimeOfDay; + if (!day || !time) return ""; + const parts = time.split(":").map(Number); + const [h, m] = parts; + if (h == null || m == null || Number.isNaN(h) || Number.isNaN(m)) return ""; + const y = day.getFullYear(); + const mo = (day.getMonth() + 1).toString().padStart(2, "0"); + const d = day.getDate().toString().padStart(2, "0"); + const hh = h.toString().padStart(2, "0"); + const mm = m.toString().padStart(2, "0"); + return `${y}-${mo}-${d}T${hh}:${mm}:00.000Z`; + }, [selectedDay, startTimeOfDay]); + + // Keep form.start in sync with computedStart + useEffect(() => { + form.setFieldValue("start", computedStart); + }, [computedStart, form.setFieldValue]); + + const canCalculateEnd = + !!form.values.pickupAddress?.trim() && + !!form.values.destinationAddress?.trim() && + !!computedStart; + + const estimatedEndQuery = api.bookings.getEstimatedEndTime.useQuery( + { + pickupAddress: form.values.pickupAddress, + destinationAddress: form.values.destinationAddress, + startTime: computedStart, + }, + { enabled: canCalculateEnd, staleTime: 5 * 60 * 1000 }, + ); + + useEffect(() => { + if (estimatedEndQuery.data) { + form.setFieldValue("end", estimatedEndQuery.data.estimatedEndTime); + } + }, [estimatedEndQuery.data, form.setFieldValue]); + + const canCheckAvailability = + !!form.values.driverId?.trim() && + !!form.values.start && + !!form.values.end && + isEndAfterStart(form.values.start, form.values.end); + + const availabilityQuery = api.bookings.isDriverAvailable.useQuery( + { + driverId: form.values.driverId, + startTime: form.values.start, + endTime: form.values.end, + pickupAddress: form.values.pickupAddress?.trim() || undefined, + destinationAddress: form.values.destinationAddress?.trim() || undefined, + }, + { enabled: canCheckAvailability }, + ); + const createMutation = api.bookings.create.useMutation({ onSuccess: async () => { notifications.show({ color: "green", message: "Booking created!" }); @@ -52,6 +267,17 @@ export default function BookingDebugPage() { await allBookingsQuery.refetch(); form.reset(); + const exampleStartDate = new Date(EXAMPLE_BOOKING.start); + setSelectedDay( + new Date( + exampleStartDate.getUTCFullYear(), + exampleStartDate.getUTCMonth(), + exampleStartDate.getUTCDate(), + ), + ); + const h = exampleStartDate.getUTCHours().toString().padStart(2, "0"); + const m = exampleStartDate.getUTCMinutes().toString().padStart(2, "0"); + setStartTimeOfDay(`${h}:${m}`); }, onError: (err) => { notifications.show({ color: "red", message: err.message }); @@ -89,49 +315,28 @@ export default function BookingDebugPage() { label: s.replace("-", " ").replace(/\b\w/g, (c) => c.toUpperCase()), })); + const endAfterStart = useMemo(() => { + if (!form.values.start || !form.values.end) return true; + return isEndAfterStart(form.values.start, form.values.end); + }, [form.values.start, form.values.end]); + return (

Booking API Debug Panel

- - setBookingId(Number(e.target.value))} - /> - - - - - - {/* UPDATE STATUS */} -