diff --git a/.mcp.json b/.mcp.json
new file mode 100644
index 0000000..6efc1ca
--- /dev/null
+++ b/.mcp.json
@@ -0,0 +1,11 @@
+{
+ "mcpServers": {
+ "gitea": {
+ "type": "http",
+ "url": "https://git-mcp.farh.net/mcp",
+ "headers": {
+ "Authorization": "Bearer ${GITEA_TOKEN}"
+ }
+ }
+ }
+}
diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md
index 2a2faae..c1452e9 100644
--- a/UAT_PLAYBOOK.md
+++ b/UAT_PLAYBOOK.md
@@ -54,6 +54,8 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
| TC-WEB-5.1.3 | Logout | Click logout button | Session cleared, redirected to login page |
| TC-WEB-5.1.4 | Session indicator | After successful login | User info/initials visible in UI indicating active session |
| TC-WEB-5.1.5 | Unauthenticated `/login` renders the form (GRO-2011) | In a private/incognito window with no session cookie, navigate to UAT `/login` | React root mounts; the GroomBook sign-in card with the OIDC button is visible. Network tab shows `/api/auth/get-session` 200, `/api/setup/status` 200, and the login form is rendered (NOT a blank white viewport). |
+| TC-WEB-5.1.6 | Swallowed render error surfaces in DOM (GRO-2094) | Trigger a render-time exception in the React tree (e.g. via temporary throw in a child component on a test build) and load `/login` in a clean context | Either the login form renders normally (happy path) OR the top-level `ErrorBoundary` testid `error-boundary` is visible with a populated `error-boundary-message` pre block showing the exception name/message/stack. **NEVER** a blank `
` with no error indicator. Browser console must contain either zero render errors or a `[ErrorBoundary]` line plus the raw exception. |
+| TC-WEB-5.1.7 | Global `error` and `unhandledrejection` listeners are wired (GRO-2094) | In a clean browser context, load `/login`, then trigger `setTimeout(() => { throw new Error("synthetic") }, 0)` from the console and `Promise.reject(new Error("synthetic-promise"))` | Browser console shows `[window.error]` and `[unhandledrejection]` log lines with the thrown values. Confirms global listeners are active in production. |
### 5.2 Authentication — VITE_API_URL Set
@@ -180,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=
&date=`; "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=&date=`; 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 ``. 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 |
@@ -207,6 +217,44 @@ 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=
&date=` 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.12e Book New `preferredTime` Formatting (GRO-2211, GRO-2213)
+
+| # | Scenario | Steps | Expected |
+|---|----------|-------|----------|
+| TC-WEB-5.12.22 | Slot buttons show formatted label | Sign in as `uat-customer@groombook.dev`, open `Appointments`, click "Book New", select a pet and service, pick a date with availability | Each time-slot button shows a human-readable label like `10:00 AM` (UTC), never a raw ISO timestamp (e.g. not `2026-06-09T10:00:00.000Z`) |
+| TC-WEB-5.12.23 | Confirmation review shows formatted label | Continue the Book New wizard to the Review step | The "Date & Time" summary and the final confirmation both display the formatted slot label (e.g. `10:00 AM`), not a raw ISO string |
+| TC-WEB-5.12.24 | Booking submit succeeds (regression) | Complete the Book New wizard and submit the request | Request succeeds with no `500` / `invalid input syntax for type time` error; the booking POST sends `preferredTime` as `HH:MM:SS` (e.g. `10:00:00`); the new appointment appears in the Upcoming list |
+
+> **GRO-2211/GRO-2213 note:** The Book New wizard previously rendered the raw
+> UTC ISO slot string as the button/confirmation label and submitted that same
+> ISO value as `preferredTime`, which the API rejected with
+> `invalid input syntax for type time` (HTTP 500). The fix adds shared UTC
+> helpers `formatSlotLabel(slot)` (display → `10:00 AM`) and `slotToTime(slot)`
+> (payload → `HH:MM:SS`) in `src/portal/sections/Appointments.tsx`, so the
+> displayed label and the submitted `preferredTime` both derive from the same
+> canonical UTC ISO slot. (The sibling `RescheduleFlow` `startTime` raw-ISO issue
+> on a different endpoint is tracked separately and is out of scope here.)
+
### 5.13 Reports UI
| # | Scenario | Steps | Expected |
@@ -354,7 +402,12 @@ These cases cover the `CustomerPortal` initialisation path that bridges an Authe
**Pre-conditions:**
-- UAT is configured with Authentik SSO and the `seed-uat-passwords` Secret in `groombook-uat` provides the seeded customer credentials (`uat-seed-password-source` memory).
+- UAT is configured with Authentik SSO. The seeded customer **Authentik** password lives in the `authentik-uat-users-credentials` Secret in the `groombook-uat` namespace (key `uat_customer_password`) — **NOT** in `seed-uat-passwords:customer-password` (that Secret holds the *Better Auth* email+password credential, a separate identity store; see GRO-2089). Pull the Authentik password at the start of every run:
+ ```bash
+ CUSTOMER_AUTHENTIK=$(kubectl get secret authentik-uat-users-credentials -n groombook-uat \
+ -o jsonpath='{.data.uat_customer_password}' | base64 -d)
+ ```
+ The Authentik user is provisioned by Terraform (`infra/terraform/users.tf`); the `lifecycle.ignore_changes = [password]` block means the password is set on initial creation and never auto-rotated, so the value held in the live Secret is the one Authentik itself has. If Authentik rejects it, the user was re-provisioned out-of-band via the Authentik admin UI and the Secret has drifted from the live identity — fix the Secret (or the admin-set password) and re-run.
- `POST /api/portal/session-from-auth` from [GRO-1866](https://paperclip.farhoodlabs.com/GRO/issues/GRO-1866) is deployed on UAT.
- Clear cookies and localStorage between cases unless otherwise noted.
@@ -372,6 +425,26 @@ These cases cover the `CustomerPortal` initialisation path that bridges an Authe
| TC-WEB-5.25.10 | Unauthenticated user is sent to login (no infinite loop) | Without signing in, navigate directly to `/`. | `App.tsx` renders the LoginPage. `CustomerPortal` does not render. No `session-from-auth` request is made. |
| TC-WEB-5.25.11 | Session persists across reload via Better Auth cookie | After TC-WEB-5.25.1 succeeds, reload the page. | Portal dashboard re-renders. A fresh `GET /api/auth/get-session` + `POST /api/portal/session-from-auth` pair runs and yields 200/201. Greeting still reads "Hi, <FirstName>". |
+### 5.27 Customer Portal — Authenticated HTML-route cold mount (GRO-2099)
+
+These cases guard against the regression where a customer who had just completed SSO sign-in was bounced back to `/login` (with a blank React root) when navigating directly to `/portal`, `/book`, `/schedule`, or even `/login` itself. Root cause: `Dashboard.tsx`'s `!sessionId && !isImpersonating && !getDevUser()` guard fired during the CustomerPortal's bootstrap — before the SSO bridge resolved `portalSessionId` — and redirected to `/login`. The fix: `CustomerPortal` now shows a loading state while the bootstrap is in flight, so the portal chrome and its `!sessionId` child guards do not mount prematurely. App.tsx additionally redirects an authenticated user at `/login` to `/` instead of rendering `null`.
+
+**Pre-conditions:**
+
+- TC-WEB-5.25.1 — TC-WEB-5.25.3 must pass on the build under test.
+- Clear cookies and localStorage between cases.
+
+| # | Scenario | Steps | Expected |
+|---|----------|-------|----------|
+| TC-WEB-5.27.1 | Authenticated customer lands on `/portal` after direct nav | 1. From clean state, complete TC-WEB-5.25.1 (SSO sign-in as a customer). 2. Land on `/`. 3. `browser_navigate` (full page load) directly to `/portal`. | Final URL stays at `/portal`. The React root is non-empty. The portal dashboard renders with the customer's name. No `Navigate to /login` fires. |
+| TC-WEB-5.27.2 | Authenticated customer lands on `/book` and `/schedule` after direct nav | From TC-WEB-5.27.1, `browser_navigate` to `/book` then `/schedule` (one fresh navigation each). | Each final URL stays at the navigated path. The portal chrome is visible. The page does not redirect to `/login`. |
+| TC-WEB-5.27.3 | Authenticated customer at `/login` is auto-redirected to `/` | From TC-WEB-5.27.1, `browser_navigate` to `/login`. | The browser ends at `/` (not at a blank `/login`). The portal dashboard renders. No blank React root at `/login`. |
+| TC-WEB-5.27.4 | Loading state is visible during the bootstrap, no portal chrome flash | 1. With the UAT build under test, open DevTools → Network and throttle to Slow 3G. 2. Sign in via SSO. 3. Land on `/`. | A "Loading…" element (`role="status"`) is briefly visible. The portal nav (Home / Appointments / etc.) is NOT visible during the loading window. No `Navigate to /login` fires during the bootstrap. |
+| TC-WEB-5.27.5 | SSO bridge still runs and yields 201 | From TC-WEB-5.27.4 (or TC-WEB-5.27.1), inspect Network. | The same `GET /api/auth/get-session` (200) → `POST /api/portal/session-from-auth` (201) sequence from TC-WEB-5.25.2 still runs. The customer name appears in the greeting. |
+| TC-WEB-5.27.6 | Unauthenticated direct nav to `/portal` still ends at `/login` (no regression) | Clear cookies. `browser_navigate` to `/portal`. | The portal briefly shows the loading state, then `CustomerPortal`'s `!session && !portalSessionId` guard redirects to `/login`. The login form renders. No infinite loop. |
+| TC-WEB-5.27.7 | Groomer SSO still works (no regression) | 1. From clean state, sign in via SSO as the groomer identity (uat-groomer). 2. Land on `/`. | `App.tsx`'s staff check redirects to `/admin`. The groomer nav renders. No `CustomerPortal` flash. No `/portal` redirect loop. |
+| TC-WEB-5.27.8 | Impersonation session still works (no regression) | 1. With an active impersonation session, open `/?sessionId=`. | The amber "STAFF VIEW" chrome renders. The portal loads. No `/login` redirect. |
+
### 5.26 Customer Portal — RescheduleFlow under SSO Bridge (GRO-2012)
These cases guard against the regression where an SSO-bridge customer (no `?sessionId=` URL param, no impersonation session) could trigger the RescheduleFlow and have `RescheduleFlow` receive `sessionId={null}`, which caused the internal `/api/book/availability` call to send `X-Impersonation-Session-Id: ` (empty) and return 401. The fix: `CustomerPortal` now passes `sessionId={session?.id ?? portalSessionId}` to `` (matching the fallback `renderSection()` already used).
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index a83b36d..2b58d63 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -34,6 +34,10 @@ export interface Pet {
breed: string | null;
weightKg: number | null;
dateOfBirth: string | null;
+ /** Portal-shaped serialization of weightKg (GET/PATCH /api/portal/pets). */
+ weight?: string | number | null;
+ /** Portal-shaped serialization of dateOfBirth (GET/PATCH /api/portal/pets). */
+ birthDate?: string | null;
healthAlerts: string | null;
groomingNotes: string | null;
cutStyle: string | null;
diff --git a/src/App.tsx b/src/App.tsx
index 1bbb17f..b97da60 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -378,8 +378,12 @@ export function App() {
return ;
}
- // Show login BEFORE checking needsSetup (needsSetup is never set for unauthenticated users)
- if (!authDisabled && !session) {
+ // Show login BEFORE checking needsSetup (needsSetup is never set for unauthenticated users).
+ // At /login with a valid session, fall through so the staff redirect below can
+ // route staff to /admin and the final render can redirect customers to / (portal).
+ // Previously, an authenticated customer at /login would see a blank page because
+ // the final render returns null at /login (showCustomerPortal is false). See GRO-2099.
+ if (!authDisabled && !session && location.pathname === "/login") {
return ;
}
@@ -401,6 +405,14 @@ export function App() {
// Don't render portal chrome at /login — DevLoginSelector is shown instead
const showCustomerPortal = !location.pathname.startsWith("/admin") && location.pathname !== "/login";
+ // At /login with a valid session, redirect to the portal root. Without this,
+ // the final render returns null at /login (showCustomerPortal is false) and
+ // the user sees a blank page after a successful sign-in. Staff are routed
+ // to /admin by the earlier staff check. See GRO-2099.
+ if (!authDisabled && session && location.pathname === "/login") {
+ return ;
+ }
+
return (
{location.pathname.startsWith("/admin") ? (
diff --git a/src/ErrorBoundary.tsx b/src/ErrorBoundary.tsx
new file mode 100644
index 0000000..5e0b730
--- /dev/null
+++ b/src/ErrorBoundary.tsx
@@ -0,0 +1,77 @@
+import { Component } from "react";
+import type { ErrorInfo, ReactNode } from "react";
+
+interface ErrorBoundaryProps {
+ children: ReactNode;
+}
+
+interface ErrorBoundaryState {
+ error: Error | null;
+}
+
+/**
+ * Top-level ErrorBoundary — renders the error visibly so the actual exception
+ * appears in the DOM (and therefore in the Playwright snapshot) instead of
+ * React 18+ unmounting the entire tree to a blank ``.
+ *
+ * Background: GRO-2094. The bundle was executing but never painting, with
+ * the failure swallowed. Surfacing the error here is the first step; the
+ * real fix is in the underlying component that threw.
+ */
+export class ErrorBoundary extends Component
{
+ state: ErrorBoundaryState = { error: null };
+
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
+ return { error };
+ }
+
+ componentDidCatch(error: Error, info: ErrorInfo): void {
+ // Also surface to the console — this is what the test harness greps for.
+ // eslint-disable-next-line no-console
+ console.error("[ErrorBoundary] Uncaught render error:", error, info);
+ }
+
+ render() {
+ if (this.state.error) {
+ const err = this.state.error;
+ return (
+
+
Something went wrong
+
+ The app failed to render. The full error is shown below — please share this
+ output when reporting the bug.
+
+
+ {err.name}: {err.message}
+ {"\n\n"}
+ {err.stack ?? "(no stack)"}
+
+
+ );
+ }
+ return this.props.children;
+ }
+}
diff --git a/src/__tests__/Appointments.test.tsx b/src/__tests__/Appointments.test.tsx
index 0f6fd76..c00bb01 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, 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( {}} 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( {}} 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();
});
});
-});
\ No newline at end of file
+});
+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 }) {
+ 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 } = {};
+ vi.mocked(global.fetch).mockImplementation(routedFetch(captured) as typeof fetch);
+
+ render( {}} 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");
+ });
+});
diff --git a/src/__tests__/ErrorBoundary.test.tsx b/src/__tests__/ErrorBoundary.test.tsx
new file mode 100644
index 0000000..6d6d4c9
--- /dev/null
+++ b/src/__tests__/ErrorBoundary.test.tsx
@@ -0,0 +1,54 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, cleanup } from "@testing-library/react";
+import { ErrorBoundary } from "../ErrorBoundary";
+
+function ThrowingChild(): never {
+ throw new Error("synthetic render-time failure for GRO-2094");
+}
+
+function GoodChild() {
+ return ok
;
+}
+
+describe("ErrorBoundary (GRO-2094)", () => {
+ let errorSpy: ReturnType;
+
+ beforeEach(() => {
+ // React 18+ logs caught render errors to console.error via React's own
+ // instrumentation; suppress it so test output is clean but capture it
+ // for an assertion below.
+ errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ errorSpy.mockRestore();
+ cleanup();
+ });
+
+ it("renders children when nothing throws", () => {
+ render(
+
+
+
+ );
+ expect(screen.getByTestId("good-child")).toBeInTheDocument();
+ expect(screen.queryByTestId("error-boundary")).not.toBeInTheDocument();
+ });
+
+ it("renders the error visibly when a child throws during render", () => {
+ render(
+
+
+
+ );
+
+ const fallback = screen.getByTestId("error-boundary");
+ expect(fallback).toBeInTheDocument();
+ const message = screen.getByTestId("error-boundary-message");
+ // The actual exception is shown — no more silent blank root.
+ expect(message.textContent).toContain("synthetic render-time failure for GRO-2094");
+ // The boundary also calls console.error so it shows up in the Playwright
+ // console log even if the DOM-rendered fallback is somehow missed.
+ expect(errorSpy).toHaveBeenCalled();
+ });
+});
diff --git a/src/__tests__/PetForm.test.tsx b/src/__tests__/PetForm.test.tsx
index f49e135..b2ea982 100644
--- a/src/__tests__/PetForm.test.tsx
+++ b/src/__tests__/PetForm.test.tsx
@@ -154,4 +154,12 @@ describe("PetForm", () => {
expect(screen.getByText("Anxious")).toBeTruthy();
expect(screen.getByText("Good with kids")).toBeTruthy();
});
+
+ // ── Weight pre-fill from portal `weight` key (GRO-2207) ───────────────────────
+
+ it("pre-fills weight from the portal `weight` key when weightKg is absent", () => {
+ const portalPet: Pet = { ...BASE_PET, weightKg: null, weight: "12.50" };
+ render( );
+ expect(screen.getByDisplayValue(12.5)).toBeTruthy();
+ });
});
\ No newline at end of file
diff --git a/src/__tests__/PetProfiles.test.tsx b/src/__tests__/PetProfiles.test.tsx
new file mode 100644
index 0000000..3c2329e
--- /dev/null
+++ b/src/__tests__/PetProfiles.test.tsx
@@ -0,0 +1,80 @@
+import { describe, it, expect } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { BasicInfoTab, formatSizeCategory } from "../portal/sections/PetProfiles.js";
+import type { Pet } from "@groombook/types";
+
+// The portal endpoint (GET /api/portal/pets) serializes DB columns under
+// portal-shaped keys: weightKg→weight, dateOfBirth→birthDate. The read view
+// must surface those keys (GRO-2207), not the raw staff-side weightKg/dateOfBirth.
+const PORTAL_PET: Pet = {
+ id: "pet-1",
+ clientId: "client-1",
+ name: "Pup Alpha",
+ species: "dog",
+ breed: "Poodle",
+ // Staff-shaped keys intentionally null — only the portal keys are populated,
+ // proving the read view reads `weight`/`birthDate`.
+ weightKg: null,
+ dateOfBirth: null,
+ weight: "12.50",
+ birthDate: "2022-03-10T00:00:00.000Z",
+ petSizeCategory: "extra_large",
+ healthAlerts: null,
+ groomingNotes: null,
+ cutStyle: null,
+ shampooPreference: null,
+ specialCareNotes: null,
+ customFields: {},
+ coatType: null,
+ preferredCuts: [],
+ medicalAlerts: [],
+ createdAt: "2024-01-01T00:00:00.000Z",
+ updatedAt: "2024-01-01T00:00:00.000Z",
+};
+
+describe("BasicInfoTab read view (GRO-2207)", () => {
+ it("renders Weight from the portal `weight` key", () => {
+ render( );
+ expect(screen.getByText("12.50 kg")).toBeInTheDocument();
+ });
+
+ it("renders Date of Birth from the portal `birthDate` key", () => {
+ render( );
+ expect(screen.getByText("March 10, 2022")).toBeInTheDocument();
+ });
+
+ it("renders Size Category formatted from petSizeCategory", () => {
+ render( );
+ expect(screen.getByText("Size Category")).toBeInTheDocument();
+ expect(screen.getByText("Extra Large")).toBeInTheDocument();
+ });
+
+ it("falls back to staff-shaped keys when portal keys are absent", () => {
+ const staffShaped: Pet = { ...PORTAL_PET, weight: null, birthDate: null, weightKg: 25, dateOfBirth: "2020-01-05T00:00:00.000Z" };
+ render( );
+ expect(screen.getByText("25 kg")).toBeInTheDocument();
+ expect(screen.getByText("January 5, 2020")).toBeInTheDocument();
+ });
+
+ it("shows Unknown for missing weight/DoB and size", () => {
+ const empty: Pet = { ...PORTAL_PET, weight: null, birthDate: null, weightKg: null, dateOfBirth: null, petSizeCategory: null };
+ render( );
+ // Weight, Date of Birth and Size Category rows all read "Unknown".
+ expect(screen.getAllByText("Unknown").length).toBeGreaterThanOrEqual(3);
+ });
+});
+
+describe("formatSizeCategory", () => {
+ it("title-cases each underscore-separated segment", () => {
+ expect(formatSizeCategory("extra_large")).toBe("Extra Large");
+ expect(formatSizeCategory("small")).toBe("Small");
+ expect(formatSizeCategory("medium")).toBe("Medium");
+ expect(formatSizeCategory("large")).toBe("Large");
+ });
+
+ it("returns Unknown for null/undefined/empty", () => {
+ expect(formatSizeCategory(null)).toBe("Unknown");
+ expect(formatSizeCategory(undefined)).toBe("Unknown");
+ expect(formatSizeCategory("")).toBe("Unknown");
+ });
+});
diff --git a/src/__tests__/portal.test.tsx b/src/__tests__/portal.test.tsx
index 96f1adf..73e0a92 100644
--- a/src/__tests__/portal.test.tsx
+++ b/src/__tests__/portal.test.tsx
@@ -558,4 +558,58 @@ describe("CustomerPortal SSO bridge", () => {
expect(lastProps!.sessionId).toBe("sso-sess-1");
expect(lastProps!.appointment.id).toBe("appt-1");
});
+
+ // GRO-2099 regression: the portal chrome (and Dashboard's `!sessionId` guard)
+ // must NOT render before the SSO bridge resolves. A loading state must be
+ // shown instead. Previously, the Dashboard's redirect-to-/login guard fired
+ // mid-bootstrap, leaving the user with a blank page after sign-in.
+ it("renders a loading state during the SSO bridge (does not flash portal chrome)", async () => {
+ // Slow bridge: resolve get-session and session-from-auth after a tick so
+ // we can observe the loading state mid-bootstrap.
+ let resolveBridge!: (value: Response) => void;
+ const bridgePromise = new Promise((resolve) => {
+ resolveBridge = resolve;
+ });
+
+ global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => {
+ const url = typeof input === "string" ? input : input.toString();
+ if (url === "/api/branding") return Promise.resolve(brandingResponse);
+ if (url === "/api/auth/get-session") {
+ return Promise.resolve({
+ ok: true,
+ json: async () => ({ user: { email: "customer@example.com", role: "customer" } }),
+ } as Response);
+ }
+ if (url === "/api/portal/session-from-auth" && init?.method === "POST") {
+ return bridgePromise;
+ }
+ return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
+ }) as unknown as typeof fetch;
+
+ const { CustomerPortal } = await import("../portal/CustomerPortal.js");
+ render(
+
+
+
+ );
+
+ // Loading state is visible while the bridge is in flight. The portal nav
+ // (Home / Appointments / etc.) must NOT be present — its presence would
+ // indicate the chrome is rendering with a null session, which is the
+ // pre-GRO-2099 bug.
+ expect(await screen.findByRole("status")).toHaveTextContent(/Loading/i);
+ expect(screen.queryByText("Home")).not.toBeInTheDocument();
+ expect(screen.queryByText("Appointments")).not.toBeInTheDocument();
+
+ // Resolve the bridge and confirm the portal renders normally.
+ resolveBridge({
+ ok: true,
+ status: 201,
+ json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
+ } as Response);
+
+ await waitFor(() => {
+ expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument();
+ });
+ });
});
diff --git a/src/main.tsx b/src/main.tsx
index 3920a8d..77ffcf1 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -2,9 +2,41 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App.js";
+import { ErrorBoundary } from "./ErrorBoundary.js";
import { installDevFetchInterceptor } from "./lib/devFetch.js";
import "./index.css";
+// --------------------------------------------------------------------
+// Global error capture (GRO-2094).
+//
+// Symptom: React root stays empty at /login — bundle parses, no console
+// errors, no error boundary fallback. Some failure is being swallowed
+// before it reaches React's commit phase. These listeners make sure any
+// thrown error or unhandled promise rejection is at least visible in
+// the console (and in the Playwright network/console log) instead of
+// vanishing into the void.
+// --------------------------------------------------------------------
+function reportGlobalError(kind: string, payload: unknown): void {
+ // eslint-disable-next-line no-console
+ console.error(`[${kind}]`, payload);
+}
+
+window.addEventListener("error", (event) => {
+ reportGlobalError("window.error", {
+ message: event.message,
+ filename: event.filename,
+ lineno: event.lineno,
+ colno: event.colno,
+ error: event.error,
+ });
+});
+
+window.addEventListener("unhandledrejection", (event) => {
+ reportGlobalError("unhandledrejection", {
+ reason: event.reason,
+ });
+});
+
installDevFetchInterceptor();
const root = document.getElementById("root");
@@ -12,8 +44,10 @@ if (!root) throw new Error("Root element not found");
createRoot(root).render(
-
-
-
+
+
+
+
+
);
diff --git a/src/portal/CustomerPortal.tsx b/src/portal/CustomerPortal.tsx
index 3b284b7..acb2f44 100644
--- a/src/portal/CustomerPortal.tsx
+++ b/src/portal/CustomerPortal.tsx
@@ -241,13 +241,31 @@ export function CustomerPortal() {
const avatarInitials = (clientName.split(" ")[0] || "G").charAt(0).toUpperCase();
+ // Show a loading state while the SSO bridge is in progress. The portal chrome
+ // and its sections (e.g. Dashboard) assume a session is established and run
+ // their own auth guards — rendering them before the bridge resolves triggers
+ // a redirect to /login from `Dashboard.tsx`'s `!sessionId` check, breaking the
+ // post-sign-in flow. Once `initComplete` is true we know whether a session was
+ // established and can render the correct branch. See GRO-2099.
+ if (!initComplete) {
+ return (
+
+ );
+ }
+
// After init completes, redirect unauthenticated users to /login and staff to /admin.
// The portal chrome must NEVER be visible to users without a valid client session.
// For client dev users, we stay on the portal even if session is null — the dev-session
// response may not have id set immediately, or there may be timing issues with the
// session state. Dev users are verified via localStorage and the dev-session flow.
// SSO customers are recognised by portalSessionId (set by the Better Auth bridge).
- if (initComplete && !session && !portalSessionId) {
+ if (!session && !portalSessionId) {
if (authError) {
// GRO-1867: graceful 404 fallback — authenticated user has no client row.
return (
diff --git a/src/portal/sections/Appointments.tsx b/src/portal/sections/Appointments.tsx
index 0a86e2f..7ed22e3 100644
--- a/src/portal/sections/Appointments.tsx
+++ b/src/portal/sections/Appointments.tsx
@@ -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 = {};
+ 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 = {
confirmed: 'bg-green-100 text-green-700',
pending: 'bg-amber-100 text-amber-600',
@@ -144,7 +290,8 @@ export const AppointmentsSection: React.FC = ({ 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)
- .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([]);
const [services, setServices] = useState([]);
@@ -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)
- .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!
- {selectedPet?.name} on {formatDate(selectedDate)} at {selectedTime}
+ {selectedPet?.name} on {formatDate(selectedDate)} at {formatSlotLabel(selectedTime)}
- {time}
+ {formatSlotLabel(time)}
))}
@@ -1192,7 +1360,7 @@ function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
Date & Time
- {formatDate(selectedDate)} at {selectedTime}
+ {formatDate(selectedDate)} at {formatSlotLabel(selectedTime)}
{recurring && (
diff --git a/src/portal/sections/PetForm.tsx b/src/portal/sections/PetForm.tsx
index 5e195a4..e0773ea 100644
--- a/src/portal/sections/PetForm.tsx
+++ b/src/portal/sections/PetForm.tsx
@@ -22,7 +22,7 @@ function newAlert(): Omit {
export function PetForm({ pet, onSave, onCancel, saving, saveError }: Props) {
const [name, setName] = useState(pet?.name ?? "");
const [breed, setBreed] = useState(pet?.breed ?? "");
- const [weight, setWeight] = useState(pet?.weightKg ?? 0);
+ const [weight, setWeight] = useState(Number(pet?.weight ?? pet?.weightKg ?? 0));
const [notes, setNotes] = useState(pet?.healthAlerts ?? "");
const [coatType, setCoatType] = useState((pet?.coatType as CoatType) ?? "");
const [petSizeCategory, setPetSizeCategory] = useState(pet?.petSizeCategory as SizeOption ?? "");
diff --git a/src/portal/sections/PetProfiles.tsx b/src/portal/sections/PetProfiles.tsx
index 412b475..7fe0cf2 100644
--- a/src/portal/sections/PetProfiles.tsx
+++ b/src/portal/sections/PetProfiles.tsx
@@ -176,9 +176,9 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
{selectedPet.name}
-
{selectedPet.breed ?? "Unknown breed"} · {selectedPet.weightKg ? `${selectedPet.weightKg} kg` : "Unknown weight"}
+
{selectedPet.breed ?? "Unknown breed"} · {(() => { const w = selectedPet.weight ?? selectedPet.weightKg; return w != null && w !== "" ? `${w} kg` : "Unknown weight"; })()}
- Born {selectedPet.dateOfBirth ? new Date(selectedPet.dateOfBirth).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"}
+ Born {(() => { const d = selectedPet.birthDate ?? selectedPet.dateOfBirth; return d ? new Date(d).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"; })()}
{!readOnly && (
@@ -222,6 +222,14 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
);
}
+export function formatSizeCategory(size?: string | null): string {
+ if (!size) return "Unknown";
+ return size
+ .split("_")
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(" ");
+}
+
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
@@ -244,7 +252,7 @@ function SeverityBadge({ severity }: { severity: "low" | "medium" | "high" }) {
);
}
-function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
+export function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
const score = pet.temperamentScore;
const flags = pet.temperamentFlags ?? [];
@@ -252,8 +260,9 @@ function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
-
-
+ { const w = pet.weight ?? pet.weightKg; return w != null && w !== "" ? `${w} kg` : "Unknown"; })()} />
+ { const d = pet.birthDate ?? pet.dateOfBirth; return d ? new Date(d).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"; })()} />
+
{/* Temperament (staff-set, read-only) */}
{(score != null || flags.length > 0) && (