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) && (