Compare commits

...

5 Commits

Author SHA1 Message Date
Savannah Savings 7edf132bfe fix(portal): send preferredTime as HH:MM:SS and format booking slot labels (GRO-2211)
CI / Test (pull_request) Successful in 20s
CI / Lint & Typecheck (pull_request) Successful in 26s
CI / Build & Push Docker Image (pull_request) Successful in 46s
The Book New wizard held raw ISO slot strings (e.g.
"2026-06-09T10:00:00.000Z") from /api/book/availability and rendered them
verbatim on the slot buttons, the Review "Date & Time" line, and the success
screen, and POSTed them straight as preferredTime. The api inserts that into
the Postgres `time` column, so a full ISO datetime is `invalid input syntax
for type time` → unhandled 500, breaking portal booking (GRO-2211).

- Add shared UTC helpers formatSlotLabel(slot) and slotToTime(slot) so the
  display label and the submitted time derive from the same canonical UTC ISO
  slot and never desync. Both format/extract in UTC (slots are UTC business
  hours); guards tolerate already-formatted labels and HH:MM:SS values.
- BookNew: render formatSlotLabel(time) on slot buttons, the Review line, and
  the confirmation; submit preferredTime: slotToTime(selectedTime) → HH:MM:SS.
- Export BookingFlow for testing.
- Tests: helper unit tests + a Book New funnel integration test asserting the
  rendered slot label is "10:00 AM" (never raw ISO) and the waitlist POST body
  preferredTime matches /^\d{2}:\d{2}:\d{2}$/.

GRO-2213

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-08 16:39:27 +00:00
Flea Flicker 2b494c01f8 fix(GRO-2180): portal Appointments handles ISO startTime shape (#49)
CI / Test (push) Successful in 17s
CI / Lint & Typecheck (push) Successful in 22s
CI / Build & Push Docker Image (push) Successful in 14s
CI / Test (pull_request) Successful in 20s
CI / Lint & Typecheck (pull_request) Successful in 26s
CI / Build & Push Docker Image (pull_request) Successful in 10s
Self-merge to dev after green CI (run #115). Phase 1 CI-only gate.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-08 08:26:27 +00:00
Flea Flicker 3397767a01 fix(GRO-2180): normalize portal appointments API shape so /appointments loads
CI / Test (pull_request) Successful in 21s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Image (pull_request) Successful in 46s
The /api/portal/appointments endpoint returns ISO startTime/endTime plus
nested pet/service/staff objects, but the portal client Appointment type
expected flat date/time/petName fields. isUpcoming() read appt.date/appt.time
(both undefined), so parseTimeTo24Hour(undefined) threw a TypeError; the
useEffect try/catch set the error state and the success-path-only Book New
button became unreachable.

- Add normalizeAppointment() at the fetch boundary mapping the API shape to the
  flat Appointment shape (derives display date/time from startTime, duration
  from the start/end delta), tolerant of the legacy flat shape.
- Prefer absolute startTime in isUpcoming(); fall back to date/time.
- Harden parseTimeTo24Hour against blank/undefined input (no NaN).
- Add Appointment.startTime/endTime to the type.
- Tests: normalizeAppointment + isUpcoming(startTime) + parseTimeTo24Hour safety.
- Update UAT_PLAYBOOK.md §5.12.2 and new §5.12d regression cases.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-08 04:18:35 +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 459 additions and 39 deletions
+34 -6
View File
@@ -182,22 +182,30 @@ 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.2 | Appointment list | View client portal appointments | List of client's appointments visible — each card shows pet name, service, formatted date/time, and groomer (no "Failed to load appointments" error, no blank screen). "Book New" button is visible and clickable. See 5.12d. |
| 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 |
#### 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 |
@@ -209,6 +217,26 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
| TC-WEB-5.12.16 | Badge status from data | Compare badge label to appointment.status field | Badge label matches the API appointment status exactly |
| TC-WEB-5.12.17 | Unknown status fallback | Render badge with unknown status value | Badge renders with the raw status string as label and fallback CSS class |
#### 5.12d Appointment API Shape Normalization (GRO-2180)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.12.18 | Portal appointments load (regression) | Sign in as `uat-customer@groombook.dev`, open `Appointments` | List renders without the "Failed to load appointments. Please try again." error; "Book New" button is visible and clickable |
| TC-WEB-5.12.19 | Card fields populated from API | Inspect an appointment card | Pet name, service, formatted date (e.g. "Mon, Jun 1, 2026"), time (e.g. "10:00 AM"), and groomer name render — derived from the API's `startTime`/`endTime`/nested `pet`/`staff` objects |
| TC-WEB-5.12.20 | Upcoming vs Past split | View both tabs | Future, non-cancelled/non-completed appointments appear under "Upcoming"; past/completed/cancelled under "Past" (classification uses absolute `startTime`) |
| TC-WEB-5.12.21 | Reschedule from card | Expand an upcoming appointment, click Reschedule, pick a date | `GET /api/book/availability?serviceId=<appt.serviceId>&date=<picked>` fires with a non-empty `serviceId` (sourced from the API's nested `service.id`) |
> **GRO-2180 regression note:** `/api/portal/appointments` returns ISO
> `startTime`/`endTime` and nested `pet`/`service`/`staff` objects, but the portal
> client `Appointment` type expected flat `date`/`time`/`petName` fields.
> `isUpcoming()` read `appt.date`/`appt.time` (both `undefined`), so
> `parseTimeTo24Hour(undefined)` threw `TypeError`, the `useEffect` `try/catch`
> set the error state, and the "Book New" button (only rendered in the success
> path) became unreachable. The fix normalizes the API response into the flat
> `Appointment` shape at the fetch boundary (`normalizeAppointment`), prefers the
> absolute `startTime` in `isUpcoming`, and hardens `parseTimeTo24Hour` against
> blank/undefined input.
### 5.13 Reports UI
| # | Scenario | Steps | Expected |
+228 -4
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, normalizeAppointment, CustomerNotesSection, ConfirmationSection, StatusBadge, formatSlotLabel, slotToTime, BookingFlow } from "../portal/sections/Appointments.tsx";
const UPCOMING_APPT = {
id: "appt-1",
@@ -42,6 +42,84 @@ describe("parseTimeTo24Hour", () => {
expect(parseTimeTo24Hour("11:00 PM")).toBe("23:00:00");
expect(parseTimeTo24Hour("12:00 PM")).toBe("12:00:00");
});
it("does not throw on undefined/null/empty input (GRO-2180)", () => {
expect(() => parseTimeTo24Hour(undefined)).not.toThrow();
expect(() => parseTimeTo24Hour(null)).not.toThrow();
expect(parseTimeTo24Hour(undefined)).toBe("00:00:00");
expect(parseTimeTo24Hour("")).toBe("00:00:00");
});
});
// GRO-2180: `/api/portal/appointments` returns ISO `startTime`/`endTime` + nested
// pet/service/staff objects, not the flat date/time/petName shape the UI renders.
describe("normalizeAppointment (API startTime shape — GRO-2180)", () => {
const RAW_API_APPT = {
id: "a0000001-0000-0000-0000-000000000001",
startTime: "2026-06-01T10:00:00.000Z",
endTime: "2026-06-01T10:45:00.000Z",
status: "completed" as const,
confirmationStatus: "confirmed" as const,
customerNotes: "Please be gentle",
notes: null,
pet: { id: "c0000001-0000-0000-0000-000000000001", name: "UAT Pup Alpha", photo: null },
service: { id: "b0000001-0000-0000-0000-000000000001", name: "Full Groom" },
staff: { id: "00000000-0000-0000-0000-000000000004", name: "UAT Staff Groomer" },
};
it("maps nested pet/service/staff and ISO startTime without throwing", () => {
const appt = normalizeAppointment(RAW_API_APPT);
expect(appt.id).toBe("a0000001-0000-0000-0000-000000000001");
expect(appt.petId).toBe("c0000001-0000-0000-0000-000000000001");
expect(appt.serviceId).toBe("b0000001-0000-0000-0000-000000000001");
expect(appt.groomerId).toBe("00000000-0000-0000-0000-000000000004");
expect(appt.petName).toBe("UAT Pup Alpha");
expect(appt.serviceName).toBe("Full Groom");
expect(appt.groomerName).toBe("UAT Staff Groomer");
expect(appt.startTime).toBe("2026-06-01T10:00:00.000Z");
expect(appt.customerNotes).toBe("Please be gentle");
});
it("derives duration in minutes from start/end delta", () => {
expect(normalizeAppointment(RAW_API_APPT).duration).toBe(45);
});
it("produces a date/time pair that does not crash isUpcoming or formatDate", () => {
const appt = normalizeAppointment(RAW_API_APPT);
expect(typeof appt.date).toBe("string");
expect(typeof appt.time).toBe("string");
expect(() => isUpcoming(appt)).not.toThrow();
});
it("classifies a past completed appointment as not upcoming", () => {
expect(isUpcoming(normalizeAppointment(RAW_API_APPT))).toBe(false);
});
it("classifies a future scheduled appointment as upcoming via startTime", () => {
const future = normalizeAppointment({
...RAW_API_APPT,
startTime: "2099-01-01T10:00:00.000Z",
endTime: "2099-01-01T11:00:00.000Z",
status: "confirmed",
});
expect(isUpcoming(future)).toBe(true);
});
it("tolerates null nested objects without throwing", () => {
const appt = normalizeAppointment({
id: "a2",
startTime: "2099-01-01T10:00:00.000Z",
endTime: "2099-01-01T11:00:00.000Z",
status: "scheduled",
pet: null,
service: null,
staff: null,
});
expect(appt.petId).toBe("");
expect(appt.serviceId).toBe("");
expect(appt.groomerId).toBeNull();
expect(appt.petName).toBeUndefined();
});
});
describe("isUpcoming", () => {
@@ -530,7 +608,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 +622,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 +630,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({
@@ -577,4 +690,115 @@ describe("RescheduleFlow dynamic time slots", () => {
expect(screen.getByText("1:00 PM")).toBeInTheDocument();
});
});
});
});
describe("slot helpers (GRO-2213)", () => {
it("formatSlotLabel formats a canonical UTC ISO slot to a UTC clock label", () => {
expect(formatSlotLabel("2026-06-09T10:00:00.000Z")).toBe("10:00 AM");
expect(formatSlotLabel("2026-06-09T14:30:00.000Z")).toBe("2:30 PM");
expect(formatSlotLabel("2026-06-09T09:00:00.000Z")).toBe("9:00 AM");
});
it("formatSlotLabel never echoes a raw ISO string", () => {
expect(formatSlotLabel("2026-06-09T10:00:00.000Z")).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
});
it("formatSlotLabel passes through an already-formatted label unchanged", () => {
expect(formatSlotLabel("10:00 AM")).toBe("10:00 AM");
});
it("slotToTime extracts the UTC HH:MM:SS time component from an ISO slot", () => {
expect(slotToTime("2026-06-09T10:00:00.000Z")).toBe("10:00:00");
expect(slotToTime("2026-06-09T14:30:00.000Z")).toBe("14:30:00");
expect(slotToTime("2026-06-09T10:00:00.000Z")).toMatch(/^\d{2}:\d{2}:\d{2}$/);
});
it("slotToTime guards a value that is already HH:MM:SS", () => {
expect(slotToTime("10:00:00")).toBe("10:00:00");
});
it("slotToTime converts a 12-hour label fallback to HH:MM:SS", () => {
expect(slotToTime("9:00 AM")).toBe("09:00:00");
expect(slotToTime("2:30 PM")).toBe("14:30:00");
});
});
describe("BookingFlow Book New funnel (GRO-2213)", () => {
beforeEach(() => {
vi.clearAllMocks();
global.fetch = vi.fn();
});
function routedFetch(captured: { waitlistBody?: Record<string, unknown> }) {
return (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (url.includes("/api/portal/pets")) {
return Promise.resolve({
ok: true,
json: async () => ({ pets: [{ id: "pet-1", name: "Buddy", breed: "Lab" }] }),
} as Response);
}
if (url.includes("/api/portal/services")) {
return Promise.resolve({
ok: true,
json: async () => ({
services: [{ id: "service-1", name: "Bath & Brush", isAddOn: false, duration: 60, price: 50 }],
}),
} as Response);
}
if (url.includes("/api/book/availability")) {
return Promise.resolve({
ok: true,
json: async () => ["2026-06-09T10:00:00.000Z", "2026-06-09T14:30:00.000Z"],
} as Response);
}
if (url.includes("/api/portal/waitlist")) {
captured.waitlistBody = JSON.parse((init?.body as string) ?? "{}");
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
};
}
it("renders formatted slot labels (not raw ISO) and submits preferredTime as HH:MM:SS", async () => {
const captured: { waitlistBody?: Record<string, unknown> } = {};
vi.mocked(global.fetch).mockImplementation(routedFetch(captured) as typeof fetch);
render(<BookingFlow onClose={() => {}} sessionId="test-session-id" />);
// Step 1 — pick pet (auto-advances to step 2)
await waitFor(() => expect(screen.getByText("Buddy")).toBeInTheDocument());
fireEvent.click(screen.getByText("Buddy"));
// Step 2 — pick service, then Next
await waitFor(() => expect(screen.getByText("Bath & Brush")).toBeInTheDocument());
fireEvent.click(screen.getByText("Bath & Brush"));
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
// Step 3 — groomer, Next
await waitFor(() => expect(screen.getByText("First Available")).toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
// Step 4 — date + slot
await waitFor(() => expect(screen.getByLabelText(/date/i)).toBeInTheDocument());
fireEvent.change(screen.getByLabelText(/date/i), { target: { value: "2026-06-09" } });
// Slot button shows the formatted UTC label, never the raw ISO
await waitFor(() => expect(screen.getByText("10:00 AM")).toBeInTheDocument());
expect(screen.queryByText(/2026-06-09T10:00:00/)).not.toBeInTheDocument();
fireEvent.click(screen.getByText("10:00 AM"));
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
// Step 5 — review shows the formatted label
await waitFor(() => expect(screen.getByText(/Review & Confirm/i)).toBeInTheDocument());
expect(screen.getByText(/10:00 AM/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Confirm Booking/i }));
await waitFor(() => expect(captured.waitlistBody).toBeDefined());
const body = captured.waitlistBody ?? {};
expect(body.preferredTime).toMatch(/^\d{2}:\d{2}:\d{2}$/);
expect(body.preferredTime).toBe("10:00:00");
expect(body.preferredDate).toBe("2026-06-09");
});
});
+197 -29
View File
@@ -2,11 +2,44 @@ 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;
// Absolute ISO instants as returned by `/api/portal/appointments`. `date`/`time`
// below are the locally-formatted display strings derived from `startTime`.
startTime?: string;
endTime?: string;
date: string;
time: string;
status: 'scheduled' | 'confirmed' | 'pending' | 'waitlisted' | 'completed' | 'cancelled' | 'no-show';
@@ -62,25 +95,138 @@ export function formatDate(dateStr: string): string {
});
}
export function parseTimeTo24Hour(time: string): string {
const parts = time.split(' ');
export function parseTimeTo24Hour(time: string | null | undefined): string {
const parts = (time ?? '').split(' ');
const hoursMinutes = parts[0] ?? '';
const period = parts[1] ?? '';
const [hoursStr, minutesStr] = hoursMinutes.split(':');
const hours = parseInt(hoursStr ?? '0', 10);
const minutes = parseInt(minutesStr ?? '0', 10);
// `|| '0'` (not `?? '0'`) so empty strings from blank/undefined input
// fall back to 0 rather than parsing to NaN.
const hours = parseInt(hoursStr || '0', 10);
const minutes = parseInt(minutesStr || '0', 10);
let hours24 = hours;
if (period === 'PM' && hours !== 12) hours24 += 12;
if (period === 'AM' && hours === 12) hours24 = 0;
return `${hours24.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`;
}
// A booking slot is the canonical UTC ISO instant returned by
// `/api/book/availability` (e.g. "2026-06-09T10:00:00.000Z" is the 10:00 UTC
// business slot — see api `src/lib/slots.ts`, which builds them with
// `setUTCHours`). Display label and submit payload both derive from the slot via
// these helpers so they never desync. Always format/extract in UTC: slots are
// generated as UTC business hours, so a browser-local conversion would mislabel
// the slot and diverge from the stored Postgres `time` column.
export function formatSlotLabel(slot: string): string {
const d = new Date(slot);
// Non-ISO input (e.g. an already-formatted "10:00 AM" label) — show as-is.
if (Number.isNaN(d.getTime())) return slot;
return new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
timeZone: 'UTC',
}).format(d);
}
// Extracts the UTC `HH:MM:SS` time component the api stores in the Postgres
// `time` column. The api inserts this verbatim, so a full ISO datetime here is
// an `invalid input syntax for type time` 500 (GRO-2211).
export function slotToTime(slot: string): string {
if (/^\d{2}:\d{2}:\d{2}$/.test(slot)) return slot; // already HH:MM:SS
const d = new Date(slot);
if (!Number.isNaN(d.getTime())) {
const hh = String(d.getUTCHours()).padStart(2, '0');
const mm = String(d.getUTCMinutes()).padStart(2, '0');
const ss = String(d.getUTCSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
// "10:00 AM"-style label fallback.
return parseTimeTo24Hour(slot);
}
export function isUpcoming(appt: Appointment): boolean {
const now = new Date();
const apptDate = new Date(`${appt.date}T${parseTimeTo24Hour(appt.time)}`);
// Prefer the absolute ISO `startTime` from the API; fall back to the
// locally-formatted date/time pair for already-normalized/legacy shapes.
const apptDate = appt.startTime
? new Date(appt.startTime)
: new Date(`${appt.date}T${parseTimeTo24Hour(appt.time)}`);
return apptDate > now && appt.status !== 'cancelled' && appt.status !== 'completed';
}
// ─── API → UI shape normalization ────────────────────────────────────────────
// `/api/portal/appointments` returns ISO `startTime`/`endTime` plus nested
// pet/service/staff objects, NOT the flat `date`/`time`/`petName` shape the
// portal UI renders. Every field below is optional so the legacy flat shape
// (used by tests/fixtures) is tolerated unchanged.
export interface RawApiAppointment {
id: string;
startTime?: string | null;
endTime?: string | null;
status: Appointment['status'];
confirmationStatus?: Appointment['confirmationStatus'];
customerNotes?: string | null;
notes?: string | null;
pet?: { id?: string | null; name?: string | null; photo?: string | null } | null;
service?: { id?: string | null; name?: string | null } | null;
staff?: { id?: string | null; name?: string | null } | null;
// Legacy / already-flat fields
petId?: string;
serviceId?: string;
groomerId?: string | null;
date?: string;
time?: string;
petName?: string;
serviceName?: string;
groomerName?: string;
duration?: number;
price?: number;
addOns?: string[];
}
function toLocalDateString(d: Date): string {
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function toLocalTimeString(d: Date): string {
return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
}
// Maps a raw API appointment into the flat `Appointment` shape the portal
// renders. Derives display `date`/`time` from the absolute `startTime` and
// `duration` from the start/end delta. Tolerates the legacy flat shape.
export function normalizeAppointment(raw: RawApiAppointment): Appointment {
const start = raw.startTime ? new Date(raw.startTime) : null;
const end = raw.endTime ? new Date(raw.endTime) : null;
const derivedDuration =
start && end ? Math.round((end.getTime() - start.getTime()) / 60000) : undefined;
return {
id: raw.id,
petId: raw.pet?.id ?? raw.petId ?? '',
serviceId: raw.service?.id ?? raw.serviceId ?? '',
groomerId: raw.staff?.id ?? raw.groomerId ?? null,
startTime: raw.startTime ?? undefined,
endTime: raw.endTime ?? undefined,
date: start ? toLocalDateString(start) : raw.date ?? '',
time: start ? toLocalTimeString(start) : raw.time ?? '',
status: raw.status,
petName: raw.pet?.name ?? raw.petName,
serviceName: raw.service?.name ?? raw.serviceName,
groomerName: raw.staff?.name ?? raw.groomerName,
duration: raw.duration ?? derivedDuration,
price: raw.price,
notes: raw.notes ?? undefined,
customerNotes: raw.customerNotes ?? undefined,
addOns: raw.addOns,
confirmationStatus: raw.confirmationStatus,
};
}
const STATUS_COLORS: Record<string, string> = {
confirmed: 'bg-green-100 text-green-700',
pending: 'bg-amber-100 text-amber-600',
@@ -144,7 +290,8 @@ export const AppointmentsSection: React.FC<AppointmentsSectionProps> = ({ sessio
if (response.ok) {
const data = await response.json();
const fetchedAppointments: Appointment[] = data.appointments || data || [];
const rawAppointments: RawApiAppointment[] = data.appointments || data || [];
const fetchedAppointments: Appointment[] = rawAppointments.map(normalizeAppointment);
const upcoming = fetchedAppointments.filter((appt) => isUpcoming(appt));
const past = fetchedAppointments.filter((appt) => !isUpcoming(appt));
@@ -595,19 +742,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;
@@ -738,7 +895,7 @@ interface BookingFlowProps {
sessionId: string | null;
}
function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
const [step, setStep] = useState(1);
const [pets, setPets] = useState<Pet[]>([]);
const [services, setServices] = useState<Service[]>([]);
@@ -766,19 +923,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 () => {
@@ -839,7 +1007,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
addOnIds: selectedAddOns.map((s) => s.id),
groomerId: selectedGroomer === 'first-available' ? null : selectedGroomer,
preferredDate: selectedDate,
preferredTime: selectedTime,
preferredTime: slotToTime(selectedTime),
notes: notes || undefined,
recurring: recurring || undefined,
}),
@@ -902,7 +1070,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
Appointment Requested!
</h3>
<p className="text-sm text-stone-500 mb-4">
{selectedPet?.name} on {formatDate(selectedDate)} at {selectedTime}
{selectedPet?.name} on {formatDate(selectedDate)} at {formatSlotLabel(selectedTime)}
</p>
<button
onClick={onClose}
@@ -1122,7 +1290,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
: 'border-stone-200 hover:border-stone-300'
}`}
>
{time}
{formatSlotLabel(time)}
</button>
))}
</div>
@@ -1192,7 +1360,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
<div className="flex justify-between">
<span className="text-stone-500">Date & Time</span>
<span className="font-medium">
{formatDate(selectedDate)} at {selectedTime}
{formatDate(selectedDate)} at {formatSlotLabel(selectedTime)}
</span>
</div>
{recurring && (