From 903ce2d675e2ff2f60e7526f891123952e55fce5 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 2 Jun 2026 14:48:03 +0000 Subject: [PATCH 1/8] =?UTF-8?q?fix(GRO-2089):=20correct=20Authentik=20cust?= =?UTF-8?q?omer=20credential=20source=20in=20UAT=5FPLAYBOOK=20=C2=A75.25?= =?UTF-8?q?=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(GRO-2089): correct Authentik customer credential source in UAT_PLAYBOOK §5.25 --- .mcp.json | 11 +++++++++++ UAT_PLAYBOOK.md | 7 ++++++- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 .mcp.json 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..fb51993 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -354,7 +354,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. From f1cf58dc56774dc538ac029876bf7c6f7fe0d611 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Tue, 2 Jun 2026 16:35:16 +0000 Subject: [PATCH 2/8] fix(GRO-2099): show loading state during CustomerPortal SSO bridge bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: `Dashboard.tsx:194` runs its own `!sessionId && !isImpersonating && !getDevUser()` auth guard, redirecting to `/login` if `sessionId` is null. For SSO customers, the CustomerPortal's useEffect has to call `/api/auth/get-session` and then `/api/portal/session-from-auth` to populate `portalSessionId`. During that bootstrap window (typically 100-300ms), `sessionId` is null and the guard fires — redirecting the user to `/login` and breaking the post-sign-in flow. App.tsx additionally returned `null` at `/login` for authenticated users (`showCustomerPortal` is false at `/login`), leaving a blank React root even if the redirect target was /login itself. Fix: - `CustomerPortal.tsx`: show a 'Loading…' state (`role=status`) while `!initComplete`. The portal chrome and its child sections only mount once the bootstrap has resolved, so child auth guards don't fire prematurely. - `App.tsx`: at `/login` with a valid session, redirect to `/` so the customer lands on the portal instead of seeing a blank page. - `App.tsx`: only return `LoginPage` when at `/login` — other portal routes defer the auth check to `CustomerPortal` (the customer SSO bridge resolves `portalSessionId` on mount). - `UAT_PLAYBOOK.md`: add §5.27 with 8 cases covering the bug, the loading state, the /login auto-redirect, the unauth fallback, and the groomer / impersonation non-regressions. - `src/__tests__/portal.test.tsx`: add a regression test that asserts the loading state is shown during the bridge and the portal nav is NOT in the DOM mid-bootstrap. Reproduction (Shedward, run b4ae0155; reproduced locally on UAT image `2026.06.01-ec29f71`): 1. From `about:blank`, complete customer SSO as `uat-customer`. 2. `browser_navigate` to `/portal`. 3. Pre-fix: redirected to `/login` with blank React root. 4. Post-fix: URL stays at `/portal`, dashboard renders with customer name. Co-Authored-By: Paperclip --- UAT_PLAYBOOK.md | 20 +++++++++++++ src/App.tsx | 16 +++++++++-- src/__tests__/portal.test.tsx | 54 +++++++++++++++++++++++++++++++++++ src/portal/CustomerPortal.tsx | 20 ++++++++++++- 4 files changed, 107 insertions(+), 3 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index fb51993..f3daa58 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -377,6 +377,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/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/__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/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 ( +
+
Loading…
+
+ ); + } + // 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 ( From 7daa9c480a65c686d67b117632f55c170a56c1c1 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Tue, 2 Jun 2026 16:05:15 +0000 Subject: [PATCH 3/8] fix(GRO-2094): instrument bootstrap with global error + ErrorBoundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundle at /login was executing but the React tree never painted — no console errors, no fallback UI, just an empty
. Add three layers of defense so a future failure of this shape is captured instead of being silently swallowed: 1. window 'error' and 'unhandledrejection' listeners in main.tsx, printing structured context to console.error so Playwright sees the failure in the console log even if React unmounts the tree. 2. A top-level in main.tsx that renders the actual exception (name, message, stack) inside the DOM instead of leaving
empty. The boundary also logs to console.error via componentDidCatch. 3. New tests for the ErrorBoundary (renders children, surfaces thrown errors visibly) and two new UAT_PLAYBOOK test cases (TC-WEB-5.1.6 / 5.1.7) that explicitly assert the 'never-blank-root' invariant on UAT. Co-Authored-By: Paperclip --- UAT_PLAYBOOK.md | 2 + src/ErrorBoundary.tsx | 77 ++++++++++++++++++++++++++++ src/__tests__/ErrorBoundary.test.tsx | 54 +++++++++++++++++++ src/main.tsx | 40 +++++++++++++-- 4 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 src/ErrorBoundary.tsx create mode 100644 src/__tests__/ErrorBoundary.test.tsx diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index f3daa58..58f2b31 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 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__/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/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( - - - + + + + + ); From de7386e47aae3d61bac38cfca823084b66d0080b Mon Sep 17 00:00:00 2001 From: The Dogfather <20+gb_dogfather@noreply.git.farh.net> Date: Tue, 2 Jun 2026 18:42:25 +0000 Subject: [PATCH 4/8] Promote to UAT: GRO-2094 React bootstrap error instrumentation (#45) Co-authored-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net> Co-committed-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net> --- .mcp.json | 11 ++++ UAT_PLAYBOOK.md | 29 ++++++++++- src/App.tsx | 16 +++++- src/ErrorBoundary.tsx | 77 ++++++++++++++++++++++++++++ src/__tests__/ErrorBoundary.test.tsx | 54 +++++++++++++++++++ src/__tests__/portal.test.tsx | 54 +++++++++++++++++++ src/main.tsx | 40 +++++++++++++-- src/portal/CustomerPortal.tsx | 20 +++++++- 8 files changed, 294 insertions(+), 7 deletions(-) create mode 100644 .mcp.json create mode 100644 src/ErrorBoundary.tsx create mode 100644 src/__tests__/ErrorBoundary.test.tsx 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..58f2b31 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 @@ -354,7 +356,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 +379,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/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__/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__/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 ( +
+
Loading…
+
+ ); + } + // 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 ( From f0c58c193cb799ba3aa892bd7a268792ff46f076 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 2 Jun 2026 19:06:15 +0000 Subject: [PATCH 5/8] fix(GRO-2105): include serviceId in BookingFlow/RescheduleFlow availability call (#46) --- UAT_PLAYBOOK.md | 18 ++++-- src/__tests__/Appointments.test.tsx | 39 ++++++++++++- src/portal/sections/Appointments.tsx | 86 ++++++++++++++++++++++------ 3 files changed, 118 insertions(+), 25 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 58f2b31..0e27091 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -186,18 +186,26 @@ export const { signIn, signOut, useSession, changePassword } = authClient; | 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 | diff --git a/src/__tests__/Appointments.test.tsx b/src/__tests__/Appointments.test.tsx index 0f6fd76..d17e87b 100644 --- a/src/__tests__/Appointments.test.tsx +++ b/src/__tests__/Appointments.test.tsx @@ -530,7 +530,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 +544,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 +552,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({ diff --git a/src/portal/sections/Appointments.tsx b/src/portal/sections/Appointments.tsx index 0a86e2f..1d30e86 100644 --- a/src/portal/sections/Appointments.tsx +++ b/src/portal/sections/Appointments.tsx @@ -2,6 +2,35 @@ 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; @@ -595,19 +624,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; @@ -766,19 +805,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 () => { From 47c29ecbc2d59f6c36b950a82e5fd2f99654a6b4 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Tue, 2 Jun 2026 19:17:03 +0000 Subject: [PATCH 6/8] Promote to UAT: GRO-2105 BookingFlow/RescheduleFlow availability fix (#47) --- UAT_PLAYBOOK.md | 18 ++++-- src/__tests__/Appointments.test.tsx | 39 ++++++++++++- src/portal/sections/Appointments.tsx | 86 ++++++++++++++++++++++------ 3 files changed, 118 insertions(+), 25 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 58f2b31..0e27091 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -186,18 +186,26 @@ export const { signIn, signOut, useSession, changePassword } = authClient; | 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 | diff --git a/src/__tests__/Appointments.test.tsx b/src/__tests__/Appointments.test.tsx index 0f6fd76..d17e87b 100644 --- a/src/__tests__/Appointments.test.tsx +++ b/src/__tests__/Appointments.test.tsx @@ -530,7 +530,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 +544,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 +552,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({ diff --git a/src/portal/sections/Appointments.tsx b/src/portal/sections/Appointments.tsx index 0a86e2f..1d30e86 100644 --- a/src/portal/sections/Appointments.tsx +++ b/src/portal/sections/Appointments.tsx @@ -2,6 +2,35 @@ 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; @@ -595,19 +624,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; @@ -766,19 +805,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 () => { From 3397767a013997baec9c2e9739f2aac126d7ef53 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Mon, 8 Jun 2026 04:18:35 +0000 Subject: [PATCH 7/8] fix(GRO-2180): normalize portal appointments API shape so /appointments loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- UAT_PLAYBOOK.md | 22 ++++++- src/__tests__/Appointments.test.tsx | 80 ++++++++++++++++++++++- src/portal/sections/Appointments.tsx | 95 ++++++++++++++++++++++++++-- 3 files changed, 189 insertions(+), 8 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 0e27091..1e3a021 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -182,7 +182,7 @@ 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 | @@ -217,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=&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.13 Reports UI | # | Scenario | Steps | Expected | diff --git a/src/__tests__/Appointments.test.tsx b/src/__tests__/Appointments.test.tsx index d17e87b..4cd645f 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 } 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", () => { diff --git a/src/portal/sections/Appointments.tsx b/src/portal/sections/Appointments.tsx index 1d30e86..3f588d6 100644 --- a/src/portal/sections/Appointments.tsx +++ b/src/portal/sections/Appointments.tsx @@ -36,6 +36,10 @@ export interface Appointment { 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'; @@ -91,13 +95,15 @@ 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; @@ -106,10 +112,86 @@ export function parseTimeTo24Hour(time: string): string { 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', @@ -173,7 +255,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)); From bc21d6de0986bf04e987967ffde6843f7ddfb8b1 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Mon, 8 Jun 2026 17:36:16 +0000 Subject: [PATCH 8/8] =?UTF-8?q?Promote=20dev=20=E2=86=92=20uat:=20GRO-2213?= =?UTF-8?q?=20portal=20booking=20preferredTime=20HH:MM:SS=20fix=20(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- UAT_PLAYBOOK.md | 18 +++++ packages/types/src/index.ts | 4 + src/__tests__/Appointments.test.tsx | 115 ++++++++++++++++++++++++++- src/__tests__/PetForm.test.tsx | 8 ++ src/__tests__/PetProfiles.test.tsx | 80 +++++++++++++++++++ src/portal/sections/Appointments.tsx | 45 +++++++++-- src/portal/sections/PetForm.tsx | 2 +- src/portal/sections/PetProfiles.tsx | 19 +++-- 8 files changed, 278 insertions(+), 13 deletions(-) create mode 100644 src/__tests__/PetProfiles.test.tsx diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 1e3a021..c1452e9 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -237,6 +237,24 @@ export const { signIn, signOut, useSession, changePassword } = authClient; > 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 | 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/__tests__/Appointments.test.tsx b/src/__tests__/Appointments.test.tsx index 4cd645f..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, normalizeAppointment, 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", @@ -690,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__/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/portal/sections/Appointments.tsx b/src/portal/sections/Appointments.tsx index 3f588d6..7ed22e3 100644 --- a/src/portal/sections/Appointments.tsx +++ b/src/portal/sections/Appointments.tsx @@ -110,6 +110,41 @@ export function parseTimeTo24Hour(time: string | null | undefined): string { 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(); // Prefer the absolute ISO `startTime` from the API; fall back to the @@ -860,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([]); @@ -972,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, }), @@ -1035,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)}

))}
@@ -1325,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) && (