From 9a30b4bd44d5bfeb911611c07226a44de752356a Mon Sep 17 00:00:00 2001 From: Savannah Savings Date: Mon, 8 Jun 2026 02:59:31 +0000 Subject: [PATCH] fix(GRO-2180): portal Appointments handles ISO startTime shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /api/portal/appointments contract returns ISO `startTime`/`endTime` and no `date`/`time` fields. `isUpcoming()` read `appt.date`/`appt.time` and called `parseTimeTo24Hour(undefined)` โ†’ `undefined.split(' ')` โ†’ TypeError. The throw was swallowed by the fetch `try/catch`, surfacing "Failed to load appointments" and making "Book New" unreachable for every signed-in customer. - Add `getAppointmentStart()` helper: prefers ISO `startTime`, falls back to legacy `date` + `time`, returns null on missing/unparseable input so callers never throw. - Rewrite `isUpcoming()` on top of the helper. - Add `formatAppointmentDate()` / `formatAppointmentTime()` and use them at all date/time display sites (list row + RescheduleFlow header). - Guard `parseTimeTo24Hour(undefined)`. - Mark `date`/`time` optional and add `startTime`/`endTime` to the `Appointment` type to match the API contract. - Tests: API-shape fixtures + regression guards (no throw on startTime shape, undefined-safe parse, helper resolution/formatting). - Update UAT_PLAYBOOK.md ยง5.12 (customer portal appointments). Co-Authored-By: Claude Opus 4.8 --- UAT_PLAYBOOK.md | 10 +++- src/__tests__/Appointments.test.tsx | 86 +++++++++++++++++++++++++++- src/portal/sections/Appointments.tsx | 56 +++++++++++++++--- 3 files changed, 139 insertions(+), 13 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index a7c76e7..ee377e1 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -142,9 +142,13 @@ export const { signIn, signOut, useSession, changePassword } = authClient; | # | Scenario | Steps | Expected | |---|----------|-------|----------| | TC-WEB-5.12.1 | Client-facing view | Log in as client persona | Customer portal UI displayed | -| TC-WEB-5.12.2 | Appointment list | View client portal appointments | List of client's appointments visible | -| TC-WEB-5.12.3 | Confirm appointment | Click confirm on pending appointment | Appointment status updated to confirmed | -| TC-WEB-5.12.4 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled | +| TC-WEB-5.12.2 | Appointment list loads | Sign in as `uat-customer@groombook.dev`, open **Appointments** | List of the customer's appointments renders. **No** "Failed to load appointments" error and **no** Retry button. (GRO-2180) | +| TC-WEB-5.12.3 | Date/time display | Inspect each appointment card | Each card shows a human-readable date and time derived from the API `startTime` (e.g. "Mon, Jun 1, 2026" / "10:00 AM"); no `undefined` or blank date/time. (GRO-2180) | +| TC-WEB-5.12.4 | Book New reachable | On the loaded Appointments view (non-readonly), look for the **Book New** button | "Book New" button is visible and opens the booking modal. (GRO-2180) | +| TC-WEB-5.12.5 | Upcoming/Past split | Toggle the **Upcoming** and **Past** tabs | Future appointments appear under Upcoming; completed/cancelled/past appear under Past. (GRO-2180) | +| TC-WEB-5.12.6 | Confirm appointment | Click confirm on pending appointment | Appointment status updated to confirmed | +| TC-WEB-5.12.7 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled | +| TC-WEB-5.12.8 | Reschedule display | Open **Reschedule** on an upcoming appointment | Summary header shows the current appointment's date and time (from `startTime`); no `undefined`. (GRO-2180) | ### 5.13 Reports UI diff --git a/src/__tests__/Appointments.test.tsx b/src/__tests__/Appointments.test.tsx index bc42a07..836011e 100644 --- a/src/__tests__/Appointments.test.tsx +++ b/src/__tests__/Appointments.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; -import { parseTimeTo24Hour, isUpcoming, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.tsx"; +import { parseTimeTo24Hour, isUpcoming, getAppointmentStart, formatAppointmentDate, formatAppointmentTime, CustomerNotesSection, ConfirmationSection } from "../portal/sections/Appointments.tsx"; const UPCOMING_APPT = { id: "appt-1", @@ -29,6 +29,26 @@ const PAST_APPT = { status: "completed" as const, }; +// GRO-2180: the /api/portal/appointments contract returns ISO startTime/endTime +// and no date/time fields. These fixtures mirror that shape exactly. +const API_UPCOMING_APPT = { + id: "appt-api-1", + petId: "pet-1", + serviceId: "service-1", + groomerId: null, + startTime: "2099-01-01T10:00:00.000Z", + endTime: "2099-01-01T10:45:00.000Z", + status: "confirmed" as const, +}; + +const API_PAST_APPT = { + ...API_UPCOMING_APPT, + id: "appt-api-2", + startTime: "2020-01-01T10:00:00.000Z", + endTime: "2020-01-01T10:45:00.000Z", + status: "completed" as const, +}; + describe("parseTimeTo24Hour", () => { it("converts AM times correctly", () => { expect(parseTimeTo24Hour("9:00 AM")).toBe("09:00:00"); @@ -42,6 +62,13 @@ describe("parseTimeTo24Hour", () => { expect(parseTimeTo24Hour("11:00 PM")).toBe("23:00:00"); expect(parseTimeTo24Hour("12:00 PM")).toBe("12:00:00"); }); + + // GRO-2180 regression: must not throw on undefined/empty input. + it("returns a safe default for missing input", () => { + expect(() => parseTimeTo24Hour(undefined)).not.toThrow(); + expect(parseTimeTo24Hour(undefined)).toBe("00:00:00"); + expect(parseTimeTo24Hour("")).toBe("00:00:00"); + }); }); describe("isUpcoming", () => { @@ -60,6 +87,63 @@ describe("isUpcoming", () => { it("returns false for completed appointments", () => { expect(isUpcoming({ ...UPCOMING_APPT, status: "completed" })).toBe(false); }); + + // GRO-2180 regression: the API contract uses ISO startTime with no date/time. + // Previously isUpcoming threw a TypeError on this shape, breaking the page. + it("does not throw on the API startTime/endTime shape", () => { + expect(() => isUpcoming(API_UPCOMING_APPT)).not.toThrow(); + expect(() => isUpcoming(API_PAST_APPT)).not.toThrow(); + }); + + it("returns true for future appointments using startTime", () => { + expect(isUpcoming(API_UPCOMING_APPT)).toBe(true); + }); + + it("returns false for past appointments using startTime", () => { + expect(isUpcoming(API_PAST_APPT)).toBe(false); + }); + + it("returns false (not throw) when neither startTime nor date is present", () => { + const { startTime, endTime, ...noDate } = API_UPCOMING_APPT; + void startTime; + void endTime; + expect(() => isUpcoming(noDate)).not.toThrow(); + expect(isUpcoming(noDate)).toBe(false); + }); +}); + +describe("getAppointmentStart / display helpers (GRO-2180)", () => { + it("resolves the start instant from ISO startTime", () => { + const start = getAppointmentStart(API_UPCOMING_APPT); + expect(start).not.toBeNull(); + expect(start?.toISOString()).toBe("2099-01-01T10:00:00.000Z"); + }); + + it("falls back to legacy date + time when startTime is absent", () => { + const start = getAppointmentStart(UPCOMING_APPT); + expect(start).not.toBeNull(); + }); + + it("returns null when there is no usable date", () => { + const { startTime, endTime, ...noDate } = API_UPCOMING_APPT; + void startTime; + void endTime; + expect(getAppointmentStart(noDate)).toBeNull(); + }); + + it("formats date/time without throwing on the API shape", () => { + expect(() => formatAppointmentDate(API_UPCOMING_APPT)).not.toThrow(); + expect(() => formatAppointmentTime(API_UPCOMING_APPT)).not.toThrow(); + expect(formatAppointmentDate(API_UPCOMING_APPT)).not.toBe(""); + expect(formatAppointmentTime(API_UPCOMING_APPT)).not.toBe(""); + }); + + it("returns empty display strings when there is no usable date", () => { + const { startTime, endTime, ...noDate } = API_UPCOMING_APPT; + void startTime; + void endTime; + expect(formatAppointmentDate(noDate)).toBe(""); + }); }); describe("CustomerNotesSection", () => { diff --git a/src/portal/sections/Appointments.tsx b/src/portal/sections/Appointments.tsx index f5fad62..66d6645 100644 --- a/src/portal/sections/Appointments.tsx +++ b/src/portal/sections/Appointments.tsx @@ -6,8 +6,14 @@ export interface Appointment { petId: string; serviceId: string; groomerId: string | null; - date: string; - time: string; + // The /api/portal/appointments contract returns ISO `startTime`/`endTime`. + // `date`/`time` are the legacy display shape, still produced locally by some + // flows (e.g. test fixtures), so both shapes are optional and code reads + // `startTime` first, falling back to `date` + `time`. + startTime?: string; + endTime?: string; + date?: string; + time?: string; status: 'scheduled' | 'confirmed' | 'pending' | 'waitlisted' | 'completed' | 'cancelled' | 'no-show'; petName?: string; serviceName?: string; @@ -61,7 +67,8 @@ export function formatDate(dateStr: string): string { }); } -export function parseTimeTo24Hour(time: string): string { +export function parseTimeTo24Hour(time: string | null | undefined): string { + if (!time) return '00:00:00'; const parts = time.split(' '); const hoursMinutes = parts[0] ?? ''; const period = parts[1] ?? ''; @@ -74,10 +81,41 @@ export function parseTimeTo24Hour(time: string): string { return `${hours24.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`; } +/** + * Resolve an appointment's start instant from either the API contract shape + * (ISO `startTime`) or the legacy `date` + `time` shape. Returns null when no + * usable date is present or the value is unparseable, so callers never throw. + */ +export function getAppointmentStart(appt: Appointment): Date | null { + const raw = appt.startTime + ? appt.startTime + : appt.date + ? `${appt.date}T${parseTimeTo24Hour(appt.time)}` + : null; + if (!raw) return null; + const parsed = new Date(raw); + return isNaN(parsed.getTime()) ? null : parsed; +} + export function isUpcoming(appt: Appointment): boolean { - const now = new Date(); - const apptDate = new Date(`${appt.date}T${parseTimeTo24Hour(appt.time)}`); - return apptDate > now && appt.status !== 'cancelled' && appt.status !== 'completed'; + const start = getAppointmentStart(appt); + if (!start) return false; + return start > new Date() && appt.status !== 'cancelled' && appt.status !== 'completed'; +} + +/** Display date string, preferring the ISO `startTime` contract shape. */ +export function formatAppointmentDate(appt: Appointment): string { + const start = getAppointmentStart(appt); + return start ? formatDate(start.toISOString()) : ''; +} + +/** Display time string, preferring the ISO `startTime` contract shape. */ +export function formatAppointmentTime(appt: Appointment): string { + const start = getAppointmentStart(appt); + if (start) { + return start.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); + } + return appt.time ?? ''; } const STATUS_COLORS: Record = { @@ -288,11 +326,11 @@ function AppointmentCard({
- {formatDate(appt.date)} + {formatAppointmentDate(appt)} - {appt.time} + {formatAppointmentTime(appt)} with {appt.groomerName || 'First Available'}
@@ -646,7 +684,7 @@ export function RescheduleFlow({ {appt.petName || 'Pet'} โ€” {appt.serviceName || 'Service'}

- {formatDate(appt.date)} at {appt.time} with{' '} + {formatAppointmentDate(appt)} at {formatAppointmentTime(appt)} with{' '} {appt.groomerName || 'First Available'}