From 0000ca801067f7313a93c5e78e9494bc4fc83c05 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Mon, 8 Jun 2026 17:29:23 +0000 Subject: [PATCH] fix(portal): show Weight/DoB + Size Category in pet read view (GRO-2207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portal GET /api/portal/pets serializes weightKg→weight and dateOfBirth→ birthDate. The portal read view read the staff-shaped keys, so populated pets rendered 'Unknown'. Size Category (petSizeCategory) was never shown in any read tab. - packages/types: add portal-shaped weight?/birthDate? alongside the existing staff-side weightKg/dateOfBirth (no rename — staff Clients pages still use weightKg/dateOfBirth). - PetProfiles: header + Basic Info InfoRows read portal keys with a fallback to staff keys; add a Size Category InfoRow with a formatSizeCategory helper (extra_large → 'Extra Large'). - PetForm: pre-fill weight from portal weight key with weightKg fallback. - Tests: PetProfiles.test.tsx (read view + formatter) and a PetForm pre-fill case. Co-Authored-By: Claude Opus 4.8 --- packages/types/src/index.ts | 4 ++ src/__tests__/PetForm.test.tsx | 8 +++ src/__tests__/PetProfiles.test.tsx | 80 +++++++++++++++++++++++++++++ src/portal/sections/PetForm.tsx | 2 +- src/portal/sections/PetProfiles.tsx | 19 +++++-- 5 files changed, 107 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/PetProfiles.test.tsx 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__/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/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) && (