Compare commits

...

3 Commits

Author SHA1 Message Date
Savannah Savings fa1fe00c51 fix(GRO-2180): portal Appointments handles ISO startTime shape
CI / Test (pull_request) Failing after 28m15s
CI / Lint & Typecheck (pull_request) Failing after 28m15s
CI / Build & Push Docker Image (pull_request) Has been skipped
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 <noreply@anthropic.com>
2026-06-08 03:02:57 +00:00
Flea Flicker f0c58c193c fix(GRO-2105): include serviceId in BookingFlow/RescheduleFlow availability call (#46)
CI / Test (push) Successful in 22s
CI / Lint & Typecheck (push) Successful in 28s
CI / Test (pull_request) Successful in 25s
CI / Lint & Typecheck (pull_request) Successful in 27s
CI / Build & Push Docker Image (push) Successful in 11s
CI / Build & Push Docker Image (pull_request) Successful in 12s
2026-06-02 19:06:15 +00:00
The Dogfather 4600dcf950 Merge pull request 'fix(GRO-2094): instrument bootstrap with global error + ErrorBoundary' (#43) from fix/gro-2094-react-blank-mount into dev
CI / Test (push) Successful in 21s
CI / Lint & Typecheck (push) Successful in 32s
CI / Build & Push Docker Image (push) Successful in 15s
CI / Test (pull_request) Successful in 22s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Image (pull_request) Successful in 14s
2026-06-02 18:32:21 +00:00
3 changed files with 257 additions and 38 deletions
+20 -8
View File
@@ -182,22 +182,34 @@ 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)
#### 5.12b Dynamic Portal Time Slots (GRO-1793, GRO-2105)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.12.5 | BookingFlow dynamic slots | Open Book New, select pet and service, pick a date | Time slots fetched from API; "Checking availability…" shown while loading |
| TC-WEB-5.12.5 | BookingFlow dynamic slots | Open Book New, select pet and service, pick a date | `GET /api/book/availability?serviceId=<selected>&date=<picked>`; "Checking availability…" shown while loading; slot list rendered |
| TC-WEB-5.12.6 | BookingFlow slots match wizard | Compare BookingFlow slot times with public booking wizard for same date | Same slots displayed |
| TC-WEB-5.12.7 | BookingFlow error state | Mock API failure on availability fetch | "Failed to load time slots" error shown |
| TC-WEB-5.12.7 | BookingFlow error state | Mock API failure on availability fetch (4xx/5xx OR a 200 with non-array body) | "Failed to load time slots" error shown and the page stays interactive (no white screen) |
| TC-WEB-5.12.8 | BookingFlow no slots | Select date with no availability | "No available slots on this date" shown |
| TC-WEB-5.12.9 | RescheduleFlow dynamic slots | Open reschedule, pick a new date | Time slots fetched from API; loading state shown |
| TC-WEB-5.12.10 | RescheduleFlow error state | Mock API failure on availability fetch | "Failed to load time slots" error shown |
| TC-WEB-5.12.9 | RescheduleFlow dynamic slots | Open reschedule, pick a new date | `GET /api/book/availability?serviceId=<appt.serviceId>&date=<picked>`; loading state shown; slot list rendered |
| TC-WEB-5.12.10 | RescheduleFlow error state | Mock API failure on availability fetch (4xx/5xx OR a 200 with non-array body) | "Failed to load time slots" error shown and the page stays interactive (no white screen) |
| TC-WEB-5.12.11 | RescheduleFlow no slots | Select date with no availability | "No available slots on this date" shown |
> **GRO-2105 regression note:** prior to the fix, both `BookingFlow` and
> `RescheduleFlow` called `/api/book/availability` with only `date=…`, so the
> API responded 400 `{error:"serviceId and date are required"}`. The React
> handler then `.map()`'d that error object, throwing `TypeError: ee.map is
> not a function` and wiping `<div id="root">`. The fix ensures both flows
> include `serviceId` in the query string and surface the API's error string
> (or "Failed to load time slots") instead of crashing.
#### 5.12c Waitlist/Booking Status Badges (GRO-1795)
| # | Scenario | Steps | Expected |
+122 -3
View File
@@ -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", () => {
@@ -530,7 +614,7 @@ describe("RescheduleFlow dynamic time slots", () => {
});
});
it("calls /api/book/availability with the selected date", async () => {
it("calls /api/book/availability with the serviceId and selected date", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ["9:00 AM"] as string[],
@@ -544,7 +628,7 @@ describe("RescheduleFlow dynamic time slots", () => {
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/book/availability?date=2027-02-20",
"/api/book/availability?serviceId=service-1&date=2027-02-20",
expect.objectContaining({
headers: expect.objectContaining({ "X-Impersonation-Session-Id": "test-session-id" }),
})
@@ -552,6 +636,41 @@ describe("RescheduleFlow dynamic time slots", () => {
});
});
it("shows error message when API returns a 4xx error object instead of an array", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ error: "serviceId and date are required" }),
} as Response);
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
fireEvent.change(dateInput, { target: { value: "2027-02-20" } });
await waitFor(() => {
expect(screen.getByText(/serviceId and date are required/i)).toBeInTheDocument();
});
});
it("shows generic error when API returns 200 but body is not an array", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ error: "serviceId and date are required" }),
} as Response);
const { RescheduleFlow } = await import("../portal/sections/Appointments.tsx");
render(<RescheduleFlow appointment={RESCHEDULE_APPT} onClose={() => {}} sessionId="test-session-id" />);
const dateInput = screen.getByLabelText(/date/i) || screen.getByRole("textbox", { name: /date/i });
fireEvent.change(dateInput, { target: { value: "2027-02-20" } });
await waitFor(() => {
expect(screen.getByText(/Failed to load time slots/i)).toBeInTheDocument();
});
});
it("re-fetches slots when date changes", async () => {
vi.mocked(global.fetch)
.mockResolvedValueOnce({
+115 -27
View File
@@ -2,13 +2,48 @@ import React, { useState, useEffect } from 'react';
import { Calendar, Clock, Plus, ChevronRight, ChevronDown, Loader2 } from 'lucide-react';
import { ANALYTICS_EVENTS, fireAnalyticsEvent } from '../../lib/analytics';
// ─── Availability fetch helper ───────────────────────────────────────────────
// Returns ISO startTime strings for the given service/date, or an error message.
// Validates HTTP status and that the body is actually an array — the API
// responds with `{error: "..."}` on 4xx, and we must not treat that as slots.
const AVAILABILITY_ERROR_MESSAGE = 'Failed to load time slots';
async function fetchAvailability(
params: { serviceId: string; date: string },
sessionId: string | null,
): Promise<{ times: string[]; error: string | null }> {
const url = `/api/book/availability?${new URLSearchParams(params).toString()}`;
const headers: Record<string, string> = {};
if (sessionId) headers['X-Impersonation-Session-Id'] = sessionId;
try {
const res = await fetch(url, { headers });
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
return { times: [], error: body.error ?? `${AVAILABILITY_ERROR_MESSAGE} (HTTP ${res.status})` };
}
const data: unknown = await res.json();
if (!Array.isArray(data)) {
return { times: [], error: AVAILABILITY_ERROR_MESSAGE };
}
return { times: data as string[], error: null };
} catch {
return { times: [], error: AVAILABILITY_ERROR_MESSAGE };
}
}
export interface Appointment {
id: string;
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;
@@ -62,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] ?? '';
@@ -75,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<string, string> = {
@@ -309,11 +376,11 @@ function AppointmentCard({
<div className="flex items-center gap-3 text-xs text-stone-500 mt-0.5">
<span className="flex items-center gap-1">
<Calendar size={12} />
{formatDate(appt.date)}
{formatAppointmentDate(appt)}
</span>
<span className="flex items-center gap-1">
<Clock size={12} />
{appt.time}
{formatAppointmentTime(appt)}
</span>
<span>with {appt.groomerName || 'First Available'}</span>
</div>
@@ -595,19 +662,29 @@ export function RescheduleFlow({
useEffect(() => {
if (!selectedDate || !sessionId) {
setAvailableTimes([]);
setSlotsError(null);
return;
}
const params = new URLSearchParams({ date: selectedDate });
if (!appt.serviceId) {
setAvailableTimes([]);
setSlotsError('Failed to load time slots');
return;
}
let cancelled = false;
setSlotsLoading(true);
setSlotsError(null);
fetch(`/api/book/availability?${params.toString()}`, {
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
})
.then((r) => r.json() as Promise<string[]>)
.then(setAvailableTimes)
.catch(() => setSlotsError('Failed to load time slots'))
.finally(() => setSlotsLoading(false));
}, [selectedDate, sessionId]);
fetchAvailability({ serviceId: appt.serviceId, date: selectedDate }, sessionId).then(
({ times, error }) => {
if (cancelled) return;
setAvailableTimes(times);
setSlotsError(error);
setSlotsLoading(false);
},
);
return () => {
cancelled = true;
};
}, [selectedDate, sessionId, appt.serviceId]);
async function handleSubmit() {
if (!selectedDate || !selectedTime) return;
@@ -671,7 +748,7 @@ export function RescheduleFlow({
{appt.petName || 'Pet'} {appt.serviceName || 'Service'}
</p>
<p className="text-stone-500 mt-0.5">
{formatDate(appt.date)} at {appt.time} with{' '}
{formatAppointmentDate(appt)} at {formatAppointmentTime(appt)} with{' '}
{appt.groomerName || 'First Available'}
</p>
</div>
@@ -766,19 +843,30 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
useEffect(() => {
if (!selectedDate || !sessionId) {
setAvailableTimes([]);
setSlotsError(null);
return;
}
const params = new URLSearchParams({ date: selectedDate });
const serviceId = selectedServices[0]?.id;
if (!serviceId) {
setAvailableTimes([]);
setSlotsError('Failed to load time slots');
return;
}
let cancelled = false;
setSlotsLoading(true);
setSlotsError(null);
fetch(`/api/book/availability?${params.toString()}`, {
headers: { "X-Impersonation-Session-Id": sessionId ?? "" },
})
.then((r) => r.json() as Promise<string[]>)
.then(setAvailableTimes)
.catch(() => setSlotsError('Failed to load time slots'))
.finally(() => setSlotsLoading(false));
}, [selectedDate, sessionId]);
fetchAvailability({ serviceId, date: selectedDate }, sessionId).then(
({ times, error }) => {
if (cancelled) return;
setAvailableTimes(times);
setSlotsError(error);
setSlotsLoading(false);
},
);
return () => {
cancelled = true;
};
}, [selectedDate, sessionId, selectedServices]);
useEffect(() => {
const fetchData = async () => {