Compare commits

..

1 Commits

Author SHA1 Message Date
Savannah Savings fa1fe00c51 fix(GRO-2180): portal Appointments handles ISO startTime shape
CI / Test (pull_request) Failing after 28m15s
CI / Lint & Typecheck (pull_request) Failing after 28m15s
CI / Build & Push Docker Image (pull_request) Has been skipped
The /api/portal/appointments contract returns ISO `startTime`/`endTime`
and no `date`/`time` fields. `isUpcoming()` read `appt.date`/`appt.time`
and called `parseTimeTo24Hour(undefined)` → `undefined.split(' ')` →
TypeError. The throw was swallowed by the fetch `try/catch`, surfacing
"Failed to load appointments" and making "Book New" unreachable for
every signed-in customer.

- Add `getAppointmentStart()` helper: prefers ISO `startTime`, falls
  back to legacy `date` + `time`, returns null on missing/unparseable
  input so callers never throw.
- Rewrite `isUpcoming()` on top of the helper.
- Add `formatAppointmentDate()` / `formatAppointmentTime()` and use them
  at all date/time display sites (list row + RescheduleFlow header).
- Guard `parseTimeTo24Hour(undefined)`.
- Mark `date`/`time` optional and add `startTime`/`endTime` to the
  `Appointment` type to match the API contract.
- Tests: API-shape fixtures + regression guards (no throw on startTime
  shape, undefined-safe parse, helper resolution/formatting).
- Update UAT_PLAYBOOK.md §5.12 (customer portal appointments).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 03:02:57 +00:00
8 changed files with 163 additions and 715 deletions
+7 -51
View File
@@ -182,9 +182,13 @@ 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 — 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 |
| TC-WEB-5.12.2 | Appointment list loads | Sign in as `uat-customer@groombook.dev`, open **Appointments** | List of the customer's appointments renders. **No** "Failed to load appointments" error and **no** Retry button. (GRO-2180) |
| TC-WEB-5.12.3 | Date/time display | Inspect each appointment card | Each card shows a human-readable date and time derived from the API `startTime` (e.g. "Mon, Jun 1, 2026" / "10:00 AM"); no `undefined` or blank date/time. (GRO-2180) |
| TC-WEB-5.12.4 | Book New reachable | On the loaded Appointments view (non-readonly), look for the **Book New** button | "Book New" button is visible and opens the booking modal. (GRO-2180) |
| TC-WEB-5.12.5 | Upcoming/Past split | Toggle the **Upcoming** and **Past** tabs | Future appointments appear under Upcoming; completed/cancelled/past appear under Past. (GRO-2180) |
| TC-WEB-5.12.6 | Confirm appointment | Click confirm on pending appointment | Appointment status updated to confirmed |
| TC-WEB-5.12.7 | Cancel appointment | Click cancel on appointment | Appointment marked as cancelled |
| TC-WEB-5.12.8 | Reschedule display | Open **Reschedule** on an upcoming appointment | Summary header shows the current appointment's date and time (from `startTime`); no `undefined`. (GRO-2180) |
#### 5.12b Dynamic Portal Time Slots (GRO-1793, GRO-2105)
@@ -217,54 +221,6 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
| TC-WEB-5.12.16 | Badge status from data | Compare badge label to appointment.status field | Badge label matches the API appointment status exactly |
| TC-WEB-5.12.17 | Unknown status fallback | Render badge with unknown status value | Badge renders with the raw status string as label and fallback CSS class |
#### 5.12d Appointment API Shape Normalization (GRO-2180)
| # | Scenario | Steps | Expected |
|---|----------|-------|----------|
| TC-WEB-5.12.18 | Portal appointments load (regression) | Sign in as `uat-customer@groombook.dev`, open `Appointments` | List renders without the "Failed to load appointments. Please try again." error; "Book New" button is visible and clickable |
| TC-WEB-5.12.19 | Card fields populated from API | Inspect an appointment card | Pet name, service, formatted date (e.g. "Mon, Jun 1, 2026"), time (e.g. "10:00 AM"), and groomer name render — derived from the API's `startTime`/`endTime`/nested `pet`/`staff` objects |
| TC-WEB-5.12.20 | Upcoming vs Past split | View both tabs | Future, non-cancelled/non-completed appointments appear under "Upcoming"; past/completed/cancelled under "Past" (classification uses absolute `startTime`) |
| TC-WEB-5.12.21 | Reschedule from card | Expand an upcoming appointment, click Reschedule, pick a date | `GET /api/book/availability?serviceId=<appt.serviceId>&date=<picked>` fires with a non-empty `serviceId` (sourced from the API's nested `service.id`) |
> **GRO-2180 regression note:** `/api/portal/appointments` returns ISO
> `startTime`/`endTime` and nested `pet`/`service`/`staff` objects, but the portal
> client `Appointment` type expected flat `date`/`time`/`petName` fields.
> `isUpcoming()` read `appt.date`/`appt.time` (both `undefined`), so
> `parseTimeTo24Hour(undefined)` threw `TypeError`, the `useEffect` `try/catch`
> set the error state, and the "Book New" button (only rendered in the success
> path) became unreachable. The fix normalizes the API response into the flat
> `Appointment` shape at the fetch boundary (`normalizeAppointment`), prefers the
> absolute `startTime` in `isUpcoming`, and hardens `parseTimeTo24Hour` against
> blank/undefined input.
#### 5.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 |
| TC-WEB-5.12.25 | Slow-wizard submit succeeds (GRO-2234) | Sign in as `uat-customer@groombook.dev`, open `Appointments`, click "Book New", then deliberately pace the wizard (pet → service → groomer → date/slot → review) so that **>2 minutes** elapse before clicking "Confirm Booking". | Submit returns success — **no** "Failed to book appointment. Please try again." error. In DevTools → Network, if the first `POST /api/portal/waitlist` returns `401`, a `POST /api/portal/session-from-auth` fires immediately after and the booking is retried once with the fresh `X-Impersonation-Session-Id`, then returns 201. The appointment appears in the Upcoming list. |
> **GRO-2234 note:** A deliberately-paced Book New wizard could outlive the
> portal impersonation session, so the final `POST /api/portal/waitlist` returned
> `401 {"error":"Unauthorized"}` ("Failed to book appointment"). The web fix adds
> a transparent one-shot re-mint: on a `401` from the waitlist submit,
> `BookingFlow` calls `POST /api/portal/session-from-auth` (the Better Auth
> cookie is still valid) and retries the submit once with the fresh session id.
> The companion API fix (groombook/api GRO-2234) adds bounded sliding expiration
> so active sessions rarely lapse in the first place.
> **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 |
-4
View File
@@ -34,10 +34,6 @@ 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;
+81 -319
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, normalizeService, formatServicePrice, CustomerNotesSection, ConfirmationSection, StatusBadge, formatSlotLabel, slotToTime, BookingFlow } from "../portal/sections/Appointments.tsx";
import { parseTimeTo24Hour, isUpcoming, getAppointmentStart, formatAppointmentDate, formatAppointmentTime, CustomerNotesSection, ConfirmationSection, StatusBadge } from "../portal/sections/Appointments.tsx";
const UPCOMING_APPT = {
id: "appt-1",
@@ -29,6 +29,26 @@ const PAST_APPT = {
status: "completed" as const,
};
// GRO-2180: the /api/portal/appointments contract returns ISO startTime/endTime
// and no date/time fields. These fixtures mirror that shape exactly.
const API_UPCOMING_APPT = {
id: "appt-api-1",
petId: "pet-1",
serviceId: "service-1",
groomerId: null,
startTime: "2099-01-01T10:00:00.000Z",
endTime: "2099-01-01T10:45:00.000Z",
status: "confirmed" as const,
};
const API_PAST_APPT = {
...API_UPCOMING_APPT,
id: "appt-api-2",
startTime: "2020-01-01T10:00:00.000Z",
endTime: "2020-01-01T10:45:00.000Z",
status: "completed" as const,
};
describe("parseTimeTo24Hour", () => {
it("converts AM times correctly", () => {
expect(parseTimeTo24Hour("9:00 AM")).toBe("09:00:00");
@@ -43,85 +63,14 @@ describe("parseTimeTo24Hour", () => {
expect(parseTimeTo24Hour("12:00 PM")).toBe("12:00:00");
});
it("does not throw on undefined/null/empty input (GRO-2180)", () => {
// GRO-2180 regression: must not throw on undefined/empty input.
it("returns a safe default for missing input", () => {
expect(() => parseTimeTo24Hour(undefined)).not.toThrow();
expect(() => parseTimeTo24Hour(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", () => {
it("returns true for future confirmed appointments", () => {
expect(isUpcoming(UPCOMING_APPT)).toBe(true);
@@ -138,6 +87,63 @@ describe("isUpcoming", () => {
it("returns false for completed appointments", () => {
expect(isUpcoming({ ...UPCOMING_APPT, status: "completed" })).toBe(false);
});
// GRO-2180 regression: the API contract uses ISO startTime with no date/time.
// Previously isUpcoming threw a TypeError on this shape, breaking the page.
it("does not throw on the API startTime/endTime shape", () => {
expect(() => isUpcoming(API_UPCOMING_APPT)).not.toThrow();
expect(() => isUpcoming(API_PAST_APPT)).not.toThrow();
});
it("returns true for future appointments using startTime", () => {
expect(isUpcoming(API_UPCOMING_APPT)).toBe(true);
});
it("returns false for past appointments using startTime", () => {
expect(isUpcoming(API_PAST_APPT)).toBe(false);
});
it("returns false (not throw) when neither startTime nor date is present", () => {
const { startTime, endTime, ...noDate } = API_UPCOMING_APPT;
void startTime;
void endTime;
expect(() => isUpcoming(noDate)).not.toThrow();
expect(isUpcoming(noDate)).toBe(false);
});
});
describe("getAppointmentStart / display helpers (GRO-2180)", () => {
it("resolves the start instant from ISO startTime", () => {
const start = getAppointmentStart(API_UPCOMING_APPT);
expect(start).not.toBeNull();
expect(start?.toISOString()).toBe("2099-01-01T10:00:00.000Z");
});
it("falls back to legacy date + time when startTime is absent", () => {
const start = getAppointmentStart(UPCOMING_APPT);
expect(start).not.toBeNull();
});
it("returns null when there is no usable date", () => {
const { startTime, endTime, ...noDate } = API_UPCOMING_APPT;
void startTime;
void endTime;
expect(getAppointmentStart(noDate)).toBeNull();
});
it("formats date/time without throwing on the API shape", () => {
expect(() => formatAppointmentDate(API_UPCOMING_APPT)).not.toThrow();
expect(() => formatAppointmentTime(API_UPCOMING_APPT)).not.toThrow();
expect(formatAppointmentDate(API_UPCOMING_APPT)).not.toBe("");
expect(formatAppointmentTime(API_UPCOMING_APPT)).not.toBe("");
});
it("returns empty display strings when there is no usable date", () => {
const { startTime, endTime, ...noDate } = API_UPCOMING_APPT;
void startTime;
void endTime;
expect(formatAppointmentDate(noDate)).toBe("");
});
});
describe("CustomerNotesSection", () => {
@@ -690,248 +696,4 @@ describe("RescheduleFlow dynamic time slots", () => {
expect(screen.getByText("1:00 PM")).toBeInTheDocument();
});
});
});
describe("slot helpers (GRO-2213)", () => {
it("formatSlotLabel formats a canonical UTC ISO slot to a UTC clock label", () => {
expect(formatSlotLabel("2026-06-09T10:00:00.000Z")).toBe("10:00 AM");
expect(formatSlotLabel("2026-06-09T14:30:00.000Z")).toBe("2:30 PM");
expect(formatSlotLabel("2026-06-09T09:00:00.000Z")).toBe("9:00 AM");
});
it("formatSlotLabel never echoes a raw ISO string", () => {
expect(formatSlotLabel("2026-06-09T10:00:00.000Z")).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
});
it("formatSlotLabel passes through an already-formatted label unchanged", () => {
expect(formatSlotLabel("10:00 AM")).toBe("10:00 AM");
});
it("slotToTime extracts the UTC HH:MM:SS time component from an ISO slot", () => {
expect(slotToTime("2026-06-09T10:00:00.000Z")).toBe("10:00:00");
expect(slotToTime("2026-06-09T14:30:00.000Z")).toBe("14:30:00");
expect(slotToTime("2026-06-09T10:00:00.000Z")).toMatch(/^\d{2}:\d{2}:\d{2}$/);
});
it("slotToTime guards a value that is already HH:MM:SS", () => {
expect(slotToTime("10:00:00")).toBe("10:00:00");
});
it("slotToTime converts a 12-hour label fallback to HH:MM:SS", () => {
expect(slotToTime("9:00 AM")).toBe("09:00:00");
expect(slotToTime("2:30 PM")).toBe("14:30:00");
});
});
describe("BookingFlow Book New funnel (GRO-2213)", () => {
beforeEach(() => {
vi.clearAllMocks();
global.fetch = vi.fn();
});
function routedFetch(captured: { waitlistBody?: Record<string, unknown> }) {
return (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (url.includes("/api/portal/pets")) {
return Promise.resolve({
ok: true,
json: async () => ({ pets: [{ id: "pet-1", name: "Buddy", breed: "Lab" }] }),
} as Response);
}
if (url.includes("/api/portal/services")) {
return Promise.resolve({
ok: true,
json: async () => ({
services: [{ id: "service-1", name: "Bath & Brush", isAddOn: false, duration: 60, price: 50 }],
}),
} as Response);
}
if (url.includes("/api/book/availability")) {
return Promise.resolve({
ok: true,
json: async () => ["2026-06-09T10:00:00.000Z", "2026-06-09T14:30:00.000Z"],
} as Response);
}
if (url.includes("/api/portal/waitlist")) {
captured.waitlistBody = JSON.parse((init?.body as string) ?? "{}");
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
};
}
it("renders formatted slot labels (not raw ISO) and submits preferredTime as HH:MM:SS", async () => {
const captured: { waitlistBody?: Record<string, unknown> } = {};
vi.mocked(global.fetch).mockImplementation(routedFetch(captured) as typeof fetch);
render(<BookingFlow onClose={() => {}} sessionId="test-session-id" />);
// Step 1 — pick pet (auto-advances to step 2)
await waitFor(() => expect(screen.getByText("Buddy")).toBeInTheDocument());
fireEvent.click(screen.getByText("Buddy"));
// Step 2 — pick service, then Next
await waitFor(() => expect(screen.getByText("Bath & Brush")).toBeInTheDocument());
fireEvent.click(screen.getByText("Bath & Brush"));
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
// Step 3 — groomer, Next
await waitFor(() => expect(screen.getByText("First Available")).toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
// Step 4 — date + slot
await waitFor(() => expect(screen.getByLabelText(/date/i)).toBeInTheDocument());
fireEvent.change(screen.getByLabelText(/date/i), { target: { value: "2026-06-09" } });
// Slot button shows the formatted UTC label, never the raw ISO
await waitFor(() => expect(screen.getByText("10:00 AM")).toBeInTheDocument());
expect(screen.queryByText(/2026-06-09T10:00:00/)).not.toBeInTheDocument();
fireEvent.click(screen.getByText("10:00 AM"));
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
// Step 5 — review shows the formatted label
await waitFor(() => expect(screen.getByText(/Review & Confirm/i)).toBeInTheDocument());
expect(screen.getByText(/10:00 AM/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Confirm Booking/i }));
await waitFor(() => expect(captured.waitlistBody).toBeDefined());
const body = captured.waitlistBody ?? {};
expect(body.preferredTime).toMatch(/^\d{2}:\d{2}:\d{2}$/);
expect(body.preferredTime).toBe("10:00:00");
expect(body.preferredDate).toBe("2026-06-09");
});
it("re-mints the portal session and retries once when waitlist returns 401 (GRO-2234)", async () => {
const calls = { waitlist: 0, remint: 0 };
const waitlistHeaders: string[] = [];
const routed = (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"],
} as Response);
}
if (url.includes("/api/portal/session-from-auth")) {
calls.remint += 1;
return Promise.resolve({
ok: true,
json: async () => ({ sessionId: "fresh-session-id", clientId: "c1", clientName: "Jane" }),
} as Response);
}
if (url.includes("/api/portal/waitlist")) {
calls.waitlist += 1;
const headers = (init?.headers ?? {}) as Record<string, string>;
waitlistHeaders.push(headers["X-Impersonation-Session-Id"] ?? "");
// First attempt: session lapsed → 401. Retry after re-mint: success.
if (calls.waitlist === 1) {
return Promise.resolve({ ok: false, status: 401, json: async () => ({ error: "Unauthorized" }) } as Response);
}
return Promise.resolve({ ok: true, status: 201, json: async () => ({}) } as Response);
}
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
};
global.fetch = vi.fn().mockImplementation(routed as typeof fetch);
render(<BookingFlow onClose={() => {}} sessionId="stale-session-id" />);
await waitFor(() => expect(screen.getByText("Buddy")).toBeInTheDocument());
fireEvent.click(screen.getByText("Buddy"));
await waitFor(() => expect(screen.getByText("Bath & Brush")).toBeInTheDocument());
fireEvent.click(screen.getByText("Bath & Brush"));
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
await waitFor(() => expect(screen.getByText("First Available")).toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
await waitFor(() => expect(screen.getByLabelText(/date/i)).toBeInTheDocument());
fireEvent.change(screen.getByLabelText(/date/i), { target: { value: "2026-06-09" } });
await waitFor(() => expect(screen.getByText("10:00 AM")).toBeInTheDocument());
fireEvent.click(screen.getByText("10:00 AM"));
fireEvent.click(screen.getByRole("button", { name: /^Next$/ }));
await waitFor(() => expect(screen.getByText(/Review & Confirm/i)).toBeInTheDocument());
fireEvent.click(screen.getByRole("button", { name: /Confirm Booking/i }));
// Re-mint happened exactly once, waitlist retried with the fresh id, and the
// booking succeeded (no error surfaced).
await waitFor(() => expect(calls.waitlist).toBe(2));
expect(calls.remint).toBe(1);
expect(waitlistHeaders).toEqual(["stale-session-id", "fresh-session-id"]);
expect(screen.queryByText(/Failed to book appointment/i)).not.toBeInTheDocument();
});
});
describe("normalizeService", () => {
it("maps API basePriceCents/durationMinutes to price (dollars)/duration", () => {
const svc = normalizeService({
id: "svc-1",
name: "Full Groom",
basePriceCents: 4500,
durationMinutes: 60,
});
expect(svc.price).toBe(45);
expect(svc.duration).toBe(60);
});
it("preserves an already-normalized payload (price/duration)", () => {
const svc = normalizeService({
id: "svc-2",
name: "Bath",
price: 30,
duration: 30,
});
expect(svc.price).toBe(30);
expect(svc.duration).toBe(30);
});
it("leaves price/duration undefined when both source shapes are absent", () => {
const svc = normalizeService({ id: "svc-3", name: "Mystery" });
expect(svc.price).toBeUndefined();
expect(svc.duration).toBeUndefined();
});
it("coerces null fields to undefined", () => {
const svc = normalizeService({
id: "svc-4",
name: "Nail Trim",
basePriceCents: null,
durationMinutes: null,
description: null,
});
expect(svc.price).toBeUndefined();
expect(svc.duration).toBeUndefined();
expect(svc.description).toBeUndefined();
});
});
describe("formatServicePrice", () => {
it("prefers an explicit priceRange string", () => {
expect(formatServicePrice({ priceRange: "$40$60", price: 45 })).toBe("$40$60");
});
it("formats integer dollars without trailing zeros", () => {
expect(formatServicePrice({ price: 45 })).toBe("$45");
});
it("formats fractional dollars to cents", () => {
expect(formatServicePrice({ price: 45.5 })).toBe("$45.50");
});
it("returns null when no price is available (never '$undefined')", () => {
expect(formatServicePrice({})).toBeNull();
expect(formatServicePrice({ price: undefined })).toBeNull();
});
});
});
-8
View File
@@ -154,12 +154,4 @@ 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(<PetForm pet={portalPet} onSave={onSave} onCancel={onCancel} />);
expect(screen.getByDisplayValue(12.5)).toBeTruthy();
});
});
-80
View File
@@ -1,80 +0,0 @@
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(<BasicInfoTab pet={PORTAL_PET} readOnly />);
expect(screen.getByText("12.50 kg")).toBeInTheDocument();
});
it("renders Date of Birth from the portal `birthDate` key", () => {
render(<BasicInfoTab pet={PORTAL_PET} readOnly />);
expect(screen.getByText("March 10, 2022")).toBeInTheDocument();
});
it("renders Size Category formatted from petSizeCategory", () => {
render(<BasicInfoTab pet={PORTAL_PET} readOnly />);
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(<BasicInfoTab pet={staffShaped} readOnly />);
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(<BasicInfoTab pet={empty} readOnly />);
// 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");
});
});
+69 -238
View File
@@ -8,28 +8,6 @@ import { ANALYTICS_EVENTS, fireAnalyticsEvent } from '../../lib/analytics';
// responds with `{error: "..."}` on 4xx, and we must not treat that as slots.
const AVAILABILITY_ERROR_MESSAGE = 'Failed to load time slots';
/**
* Re-mint an SSO-bridge portal session from the active Better Auth session.
* Defense-in-depth for GRO-2234: if a portal call returns 401 mid-flow (the
* impersonation session lapsed during a slow wizard), the customer's Better
* Auth cookie is still valid, so we can transparently obtain a fresh portal
* session id and retry once. Returns the new session id, or null if no Better
* Auth session is available (e.g. staff/dev impersonation paths).
*/
async function remintPortalSession(): Promise<string | null> {
try {
const res = await fetch('/api/portal/session-from-auth', {
method: 'POST',
credentials: 'include',
});
if (!res.ok) return null;
const data = (await res.json().catch(() => ({}))) as { sessionId?: string };
return data.sessionId ?? null;
} catch {
return null;
}
}
async function fetchAvailability(
params: { serviceId: string; date: string },
sessionId: string | null,
@@ -58,12 +36,14 @@ 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`.
// The /api/portal/appointments contract returns ISO `startTime`/`endTime`.
// `date`/`time` are the legacy display shape, still produced locally by some
// flows (e.g. test fixtures), so both shapes are optional and code reads
// `startTime` first, falling back to `date` + `time`.
startTime?: string;
endTime?: string;
date: string;
time: string;
date?: string;
time?: string;
status: 'scheduled' | 'confirmed' | 'pending' | 'waitlisted' | 'completed' | 'cancelled' | 'no-show';
petName?: string;
serviceName?: string;
@@ -89,8 +69,8 @@ interface Service {
id: string;
name: string;
description?: string;
duration?: number;
price?: number;
duration: number;
price: number;
priceRange?: string;
isAddOn?: boolean;
}
@@ -118,181 +98,54 @@ export function formatDate(dateStr: string): string {
}
export function parseTimeTo24Hour(time: string | null | undefined): string {
const parts = (time ?? '').split(' ');
if (!time) return '00:00:00';
const parts = time.split(' ');
const hoursMinutes = parts[0] ?? '';
const period = parts[1] ?? '';
const [hoursStr, minutesStr] = hoursMinutes.split(':');
// `|| '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);
const hours = parseInt(hoursStr ?? '0', 10);
const minutes = parseInt(minutesStr ?? '0', 10);
let hours24 = hours;
if (period === 'PM' && hours !== 12) hours24 += 12;
if (period === 'AM' && hours === 12) hours24 = 0;
return `${hours24.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`;
}
// A booking slot is the canonical UTC ISO instant returned by
// `/api/book/availability` (e.g. "2026-06-09T10:00:00.000Z" is the 10:00 UTC
// business slot — see api `src/lib/slots.ts`, which builds them with
// `setUTCHours`). Display label and submit payload both derive from the slot via
// these helpers so they never desync. Always format/extract in UTC: slots are
// generated as UTC business hours, so a browser-local conversion would mislabel
// the slot and diverge from the stored Postgres `time` column.
export function formatSlotLabel(slot: string): string {
const d = new Date(slot);
// Non-ISO input (e.g. an already-formatted "10:00 AM" label) — show as-is.
if (Number.isNaN(d.getTime())) return slot;
return new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
timeZone: 'UTC',
}).format(d);
}
// Extracts the UTC `HH:MM:SS` time component the api stores in the Postgres
// `time` column. The api inserts this verbatim, so a full ISO datetime here is
// an `invalid input syntax for type time` 500 (GRO-2211).
export function slotToTime(slot: string): string {
if (/^\d{2}:\d{2}:\d{2}$/.test(slot)) return slot; // already HH:MM:SS
const d = new Date(slot);
if (!Number.isNaN(d.getTime())) {
const hh = String(d.getUTCHours()).padStart(2, '0');
const mm = String(d.getUTCMinutes()).padStart(2, '0');
const ss = String(d.getUTCSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
// "10:00 AM"-style label fallback.
return parseTimeTo24Hour(slot);
/**
* Resolve an appointment's start instant from either the API contract shape
* (ISO `startTime`) or the legacy `date` + `time` shape. Returns null when no
* usable date is present or the value is unparseable, so callers never throw.
*/
export function getAppointmentStart(appt: Appointment): Date | null {
const raw = appt.startTime
? appt.startTime
: appt.date
? `${appt.date}T${parseTimeTo24Hour(appt.time)}`
: null;
if (!raw) return null;
const parsed = new Date(raw);
return isNaN(parsed.getTime()) ? null : parsed;
}
export function isUpcoming(appt: Appointment): boolean {
const now = new Date();
// 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';
const start = getAppointmentStart(appt);
if (!start) return false;
return start > new Date() && 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[];
/** Display date string, preferring the ISO `startTime` contract shape. */
export function formatAppointmentDate(appt: Appointment): string {
const start = getAppointmentStart(appt);
return start ? formatDate(start.toISOString()) : '';
}
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,
};
}
// Raw service shape from `GET /api/portal/services`, which projects the
// canonical DB columns (`basePriceCents`, `durationMinutes`). Also tolerates an
// already-normalized payload so either shape renders correctly.
interface RawApiService {
id: string;
name: string;
description?: string | null;
basePriceCents?: number | null;
durationMinutes?: number | null;
price?: number | null;
duration?: number | null;
priceRange?: string | null;
isAddOn?: boolean | null;
}
// Normalizes a raw API service into the flat `Service` shape the cards render:
// price as dollars (from `basePriceCents`) and duration in minutes (from
// `durationMinutes`). Leaves fields undefined when genuinely absent so the card
// can hide them rather than print `$undefined` / empty `min`.
export function normalizeService(raw: RawApiService): Service {
const price =
raw.price ?? (typeof raw.basePriceCents === 'number' ? raw.basePriceCents / 100 : undefined);
const duration = raw.duration ?? raw.durationMinutes ?? undefined;
return {
id: raw.id,
name: raw.name,
description: raw.description ?? undefined,
duration: duration ?? undefined,
price: price ?? undefined,
priceRange: raw.priceRange ?? undefined,
isAddOn: raw.isAddOn ?? undefined,
};
}
// Renders a service price for display, preferring an explicit `priceRange`
// string, then a numeric dollar `price` (integers without trailing zeros, e.g.
// `$45`; fractional values to cents, e.g. `$45.50`). Returns null when neither
// is available so the caller can omit the price line entirely.
export function formatServicePrice(svc: Pick<Service, 'price' | 'priceRange'>): string | null {
if (svc.priceRange) return svc.priceRange;
if (typeof svc.price === 'number' && Number.isFinite(svc.price)) {
return `$${Number.isInteger(svc.price) ? svc.price : svc.price.toFixed(2)}`;
/** Display time string, preferring the ISO `startTime` contract shape. */
export function formatAppointmentTime(appt: Appointment): string {
const start = getAppointmentStart(appt);
if (start) {
return start.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
}
return null;
return appt.time ?? '';
}
const STATUS_COLORS: Record<string, string> = {
@@ -358,8 +211,7 @@ export const AppointmentsSection: React.FC<AppointmentsSectionProps> = ({ sessio
if (response.ok) {
const data = await response.json();
const rawAppointments: RawApiAppointment[] = data.appointments || data || [];
const fetchedAppointments: Appointment[] = rawAppointments.map(normalizeAppointment);
const fetchedAppointments: Appointment[] = data.appointments || data || [];
const upcoming = fetchedAppointments.filter((appt) => isUpcoming(appt));
const past = fetchedAppointments.filter((appt) => !isUpcoming(appt));
@@ -524,11 +376,11 @@ function AppointmentCard({
<div className="flex items-center gap-3 text-xs text-stone-500 mt-0.5">
<span className="flex items-center gap-1">
<Calendar size={12} />
{formatDate(appt.date)}
{formatAppointmentDate(appt)}
</span>
<span className="flex items-center gap-1">
<Clock size={12} />
{appt.time}
{formatAppointmentTime(appt)}
</span>
<span>with {appt.groomerName || 'First Available'}</span>
</div>
@@ -896,7 +748,7 @@ export function RescheduleFlow({
{appt.petName || 'Pet'} {appt.serviceName || 'Service'}
</p>
<p className="text-stone-500 mt-0.5">
{formatDate(appt.date)} at {appt.time} with{' '}
{formatAppointmentDate(appt)} at {formatAppointmentTime(appt)} with{' '}
{appt.groomerName || 'First Available'}
</p>
</div>
@@ -963,7 +815,7 @@ interface BookingFlowProps {
sessionId: string | null;
}
export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
const [step, setStep] = useState(1);
const [pets, setPets] = useState<Pet[]>([]);
const [services, setServices] = useState<Service[]>([]);
@@ -1040,8 +892,7 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
if (servicesRes.ok) {
const servicesData = await servicesRes.json();
const rawServices: RawApiService[] = servicesData.services || servicesData || [];
setServices(rawServices.map(normalizeService));
setServices(servicesData.services || servicesData || []);
}
} catch {
setError('Failed to load data. Please try again.');
@@ -1062,40 +913,26 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
setSubmitting(true);
setError(null);
const payload = JSON.stringify({
petId: selectedPet.id,
serviceId: selectedServices[0]?.id,
serviceIds: selectedServices.map((s) => s.id),
addOnIds: selectedAddOns.map((s) => s.id),
groomerId: selectedGroomer === 'first-available' ? null : selectedGroomer,
preferredDate: selectedDate,
preferredTime: slotToTime(selectedTime),
notes: notes || undefined,
recurring: recurring || undefined,
});
const submitWaitlist = (id: string) =>
fetch('/api/portal/waitlist', {
try {
const response = await fetch('/api/portal/waitlist', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Impersonation-Session-Id': id,
'X-Impersonation-Session-Id': sessionId ?? '',
},
body: payload,
body: JSON.stringify({
petId: selectedPet.id,
serviceId: selectedServices[0]?.id,
serviceIds: selectedServices.map((s) => s.id),
addOnIds: selectedAddOns.map((s) => s.id),
groomerId: selectedGroomer === 'first-available' ? null : selectedGroomer,
preferredDate: selectedDate,
preferredTime: selectedTime,
notes: notes || undefined,
recurring: recurring || undefined,
}),
});
try {
let response = await submitWaitlist(sessionId);
// GRO-2234: a deliberately-paced wizard can outlive the portal session.
// The customer's Better Auth session is still valid, so transparently
// re-mint a fresh portal session and retry once before surfacing an error.
if (response.status === 401) {
const freshSessionId = await remintPortalSession();
if (freshSessionId) {
response = await submitWaitlist(freshSessionId);
}
}
if (response.ok) {
setConfirmed(true);
fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SUBMIT, { step: "submit", flow: "portal" });
@@ -1153,7 +990,7 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
Appointment Requested!
</h3>
<p className="text-sm text-stone-500 mb-4">
{selectedPet?.name} on {formatDate(selectedDate)} at {formatSlotLabel(selectedTime)}
{selectedPet?.name} on {formatDate(selectedDate)} at {selectedTime}
</p>
<button
onClick={onClose}
@@ -1237,14 +1074,10 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
)}
</div>
<div className="text-right shrink-0 ml-3">
{formatServicePrice(svc) && (
<p className="text-sm font-medium text-stone-700">
{formatServicePrice(svc)}
</p>
)}
{typeof svc.duration === 'number' && (
<p className="text-xs text-stone-400">{svc.duration} min</p>
)}
<p className="text-sm font-medium text-stone-700">
{svc.priceRange || `$${svc.price}`}
</p>
<p className="text-xs text-stone-400">{svc.duration} min</p>
</div>
</button>
))}
@@ -1277,11 +1110,9 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
<p className="text-xs text-stone-500">{svc.description}</p>
)}
</div>
{formatServicePrice(svc) && (
<span className="text-stone-600 shrink-0 ml-3">
{formatServicePrice(svc)}
</span>
)}
<span className="text-stone-600 shrink-0 ml-3">
{svc.priceRange || `$${svc.price}`}
</span>
</button>
))}
</div>
@@ -1379,7 +1210,7 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
: 'border-stone-200 hover:border-stone-300'
}`}
>
{formatSlotLabel(time)}
{time}
</button>
))}
</div>
@@ -1449,7 +1280,7 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) {
<div className="flex justify-between">
<span className="text-stone-500">Date & Time</span>
<span className="font-medium">
{formatDate(selectedDate)} at {formatSlotLabel(selectedTime)}
{formatDate(selectedDate)} at {selectedTime}
</span>
</div>
{recurring && (
+1 -1
View File
@@ -22,7 +22,7 @@ function newAlert(): Omit<MedicalAlert, "id"> {
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(Number(pet?.weight ?? pet?.weightKg ?? 0));
const [weight, setWeight] = useState(pet?.weightKg ?? 0);
const [notes, setNotes] = useState(pet?.healthAlerts ?? "");
const [coatType, setCoatType] = useState<CoatType | "">((pet?.coatType as CoatType) ?? "");
const [petSizeCategory, setPetSizeCategory] = useState<SizeOption | "">(pet?.petSizeCategory as SizeOption ?? "");
+5 -14
View File
@@ -176,9 +176,9 @@ export function PetProfiles({ sessionId, readOnly }: Props) {
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-stone-800">{selectedPet.name}</h2>
<p className="text-stone-500 text-sm">{selectedPet.breed ?? "Unknown breed"} · {(() => { const w = selectedPet.weight ?? selectedPet.weightKg; return w != null && w !== "" ? `${w} kg` : "Unknown weight"; })()}</p>
<p className="text-stone-500 text-sm">{selectedPet.breed ?? "Unknown breed"} · {selectedPet.weightKg ? `${selectedPet.weightKg} kg` : "Unknown weight"}</p>
<p className="text-stone-400 text-xs mt-0.5">
Born {(() => { const d = selectedPet.birthDate ?? selectedPet.dateOfBirth; return d ? new Date(d).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"; })()}
Born {selectedPet.dateOfBirth ? new Date(selectedPet.dateOfBirth).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"}
</p>
</div>
{!readOnly && (
@@ -222,14 +222,6 @@ 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 (
<div className="flex flex-col sm:flex-row sm:items-center py-2.5 border-b border-stone-100 last:border-0">
@@ -252,7 +244,7 @@ function SeverityBadge({ severity }: { severity: "low" | "medium" | "high" }) {
);
}
export function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) {
const score = pet.temperamentScore;
const flags = pet.temperamentFlags ?? [];
@@ -260,9 +252,8 @@ export function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean })
<div>
<InfoRow label="Name" value={pet.name} />
<InfoRow label="Breed" value={pet.breed || "Unknown"} />
<InfoRow label="Weight" value={(() => { const w = pet.weight ?? pet.weightKg; return w != null && w !== "" ? `${w} kg` : "Unknown"; })()} />
<InfoRow label="Date of Birth" value={(() => { const d = pet.birthDate ?? pet.dateOfBirth; return d ? new Date(d).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"; })()} />
<InfoRow label="Size Category" value={formatSizeCategory(pet.petSizeCategory)} />
<InfoRow label="Weight" value={pet.weightKg ? `${pet.weightKg} kg` : "Unknown"} />
<InfoRow label="Date of Birth" value={pet.dateOfBirth ? new Date(pet.dateOfBirth).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) : "Unknown"} />
{/* Temperament (staff-set, read-only) */}
{(score != null || flags.length > 0) && (