From fa1fe00c51cd479604b67a8a050ed53d710a967c 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 0e27091..07b53e9 100644
--- a/UAT_PLAYBOOK.md
+++ b/UAT_PLAYBOOK.md
@@ -182,9 +182,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.12b Dynamic Portal Time Slots (GRO-1793, GRO-2105)
diff --git a/src/__tests__/Appointments.test.tsx b/src/__tests__/Appointments.test.tsx
index d17e87b..209e0eb 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, StatusBadge } from "../portal/sections/Appointments.tsx";
+import { parseTimeTo24Hour, isUpcoming, getAppointmentStart, formatAppointmentDate, formatAppointmentTime, CustomerNotesSection, ConfirmationSection, StatusBadge } 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 1d30e86..9844aa9 100644
--- a/src/portal/sections/Appointments.tsx
+++ b/src/portal/sections/Appointments.tsx
@@ -36,8 +36,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;
@@ -91,7 +97,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] ?? '';
@@ -104,10 +111,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 = {
@@ -338,11 +376,11 @@ function AppointmentCard({
- {formatDate(appt.date)}
+ {formatAppointmentDate(appt)}
- {appt.time}
+ {formatAppointmentTime(appt)}
with {appt.groomerName || 'First Available'}
@@ -710,7 +748,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'}