diff --git a/auth-state.png b/auth-state.png new file mode 100644 index 0000000..91b7d6b Binary files /dev/null and b/auth-state.png differ diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index a83b36d..7b9e512 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -226,3 +226,24 @@ export interface MedicalAlert { } export type CoatType = "smooth" | "double" | "curly" | "wire" | "long" | "hairless"; + +export interface NextAppointment { + id: string; + startTime: string; + serviceName: string; +} + +export interface PetProfileSummary { + id: string; + name: string; + breed: string | null; + dateOfBirth: string | null; + weightKg: number | null; + coatType: CoatType | null; + temperamentScore: number | null; + temperamentFlags: string[]; + medicalAlerts: MedicalAlert[]; + preferredCuts: string[]; + recentVisits: GroomingVisitLog[]; + nextAppointment: NextAppointment | null; +} diff --git a/src/__tests__/PetProfileCard.test.tsx b/src/__tests__/PetProfileCard.test.tsx new file mode 100644 index 0000000..a8606f6 --- /dev/null +++ b/src/__tests__/PetProfileCard.test.tsx @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import { PetProfileCard } from "../components/PetProfileCard.js"; + +const FULL_SUMMARY = { + id: "pet-1", + name: "Buddy", + breed: "Golden Retriever", + dateOfBirth: "2022-03-15", + weightKg: 30.5, + coatType: "double", + temperamentScore: 4, + temperamentFlags: ["anxious", "friendly"], + medicalAlerts: [ + { id: "a1", type: "allergy", description: "Chicken allergy", severity: "high" }, + { id: "a2", type: "condition", description: "Hip dysplasia", severity: "medium" }, + ], + preferredCuts: ["teddy bear", "puppy cut"], + recentVisits: [ + { id: "v1", petId: "pet-1", appointmentId: "appt-1", staffId: "staff-1", cutStyle: "teddy bear", productsUsed: "oat shampoo", notes: "Good boy", groomedAt: "2025-05-01T10:00:00Z", createdAt: "2025-05-01T10:00:00Z" }, + { id: "v2", petId: "pet-1", appointmentId: "appt-2", staffId: "staff-2", cutStyle: "puppy cut", productsUsed: null, notes: null, groomedAt: "2025-04-01T10:00:00Z", createdAt: "2025-04-01T10:00:00Z" }, + ], + nextAppointment: { id: "appt-3", startTime: "2025-06-01T09:00:00Z", serviceName: "Full groom" }, +}; + +const EMPTY_SUMMARY = { + id: "pet-2", + name: "Whiskers", + breed: null, + dateOfBirth: null, + weightKg: null, + coatType: null, + temperamentScore: null, + temperamentFlags: [], + medicalAlerts: [], + preferredCuts: [], + recentVisits: [], + nextAppointment: null, +}; + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe("PetProfileCard", () => { + it("shows loading skeleton while fetching", () => { + global.fetch = vi.fn(() => new Promise(() => {})) as unknown as typeof fetch; + render(); + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + }); + + it("renders full profile data correctly", async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: async () => FULL_SUMMARY, + } as Response) + ) as unknown as typeof fetch; + + render(); + + await waitFor(() => { + expect(screen.getByText("Buddy")).toBeInTheDocument(); + }); + expect(screen.getByText("Golden Retriever")).toBeInTheDocument(); + // age computed from DOB + expect(screen.getByText(/yr/)).toBeInTheDocument(); + // weight + expect(screen.getByText(/30.5 kg/)).toBeInTheDocument(); + // coat type badge + expect(screen.getByText("double")).toBeInTheDocument(); + // medical alerts + expect(screen.getByText("Chicken allergy")).toBeInTheDocument(); + expect(screen.getByText("Hip dysplasia")).toBeInTheDocument(); + // preferred cuts + expect(screen.getByText("teddy bear")).toBeInTheDocument(); + }); + + it("displays severity-colored badges for medical alerts", async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: async () => FULL_SUMMARY, + } as Response) + ) as unknown as typeof fetch; + + render(); + + await waitFor(() => { + expect(screen.getByText("Chicken allergy")).toBeInTheDocument(); + }); + + const highAlert = screen.getByText("Chicken allergy").closest("span"); + expect(highAlert).toHaveStyle({ color: "#dc2626" }); // high = red + + const mediumAlert = screen.getByText("Hip dysplasia").closest("span"); + expect(mediumAlert).toHaveStyle({ color: "#d97706" }); // medium = amber + }); + + it("handles empty pet data gracefully", async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: async () => EMPTY_SUMMARY, + } as Response) + ) as unknown as typeof fetch; + + render(); + + await waitFor(() => { + expect(screen.getByText("Whiskers")).toBeInTheDocument(); + }); + // no section labels with no data + expect(screen.queryByText(/medical alerts/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/preferred cuts/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/recent visits/i)).not.toBeInTheDocument(); + }); + + it("shows error message on fetch failure", async () => { + global.fetch = vi.fn(() => Promise.reject(new Error("network error"))) as unknown as typeof fetch; + + render(); + + await waitFor(() => { + expect(screen.getByText(/failed to load/i)).toBeInTheDocument(); + }); + }); + + it("refetches when petId changes", async () => { + const fetchMock = vi.fn((url: string) => { + if ((url as string).includes("pet-1")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ ...FULL_SUMMARY, name: "Buddy" }), + } as Response); + } + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ ...EMPTY_SUMMARY, name: "Whiskers" }), + } as Response); + }) as unknown as typeof fetch; + global.fetch = fetchMock; + + const { rerender } = render(); + await waitFor(() => expect(screen.getByText("Buddy")).toBeInTheDocument()); + expect(fetchMock).toHaveBeenCalledWith("/api/pets/pet-1/profile-summary"); + + rerender(); + await waitFor(() => expect(screen.getByText("Whiskers")).toBeInTheDocument()); + expect(fetchMock).toHaveBeenCalledWith("/api/pets/pet-2/profile-summary"); + }); +}); diff --git a/src/components/PetProfileCard.tsx b/src/components/PetProfileCard.tsx new file mode 100644 index 0000000..0fe8392 --- /dev/null +++ b/src/components/PetProfileCard.tsx @@ -0,0 +1,238 @@ +import { useEffect, useState } from "react"; +import type { MedicalAlert, PetProfileSummary } from "@groombook/types"; +import { PetPhotoDisplay } from "./PetPhotoDisplay.js"; + +interface Props { + petId: string; +} + +type LoadState = + | { status: "idle" } + | { status: "loading" } + | { status: "loaded"; data: PetProfileSummary } + | { status: "error"; message: string }; + +function computeAge(dateOfBirth: string | null): string | null { + if (!dateOfBirth) return null; + const birth = new Date(dateOfBirth); + const now = new Date(); + const totalMonths = (now.getFullYear() - birth.getFullYear()) * 12 + (now.getMonth() - birth.getMonth()); + if (totalMonths < 1) return "<1 mo"; + if (totalMonths < 12) return `${totalMonths} mo`; + const years = Math.floor(totalMonths / 12); + const months = totalMonths % 12; + if (months === 0) return `${years} yr`; + return `${years} yr ${months} mo`; +} + +function TemperamentDots({ score }: { score: number }) { + return ( +
+ {[1, 2, 3, 4, 5].map((n) => ( +
+ ))} +
+ ); +} + +const SEVERITY_STYLES: Record = { + high: { bg: "#fef2f2", color: "#dc2626", border: "#fca5a5" }, + medium: { bg: "#fffbeb", color: "#d97706", border: "#fde68a" }, + low: { bg: "#eff6ff", color: "#2563eb", border: "#bfdbfe" }, +}; + +function MedicalAlertBadge({ alert }: { alert: MedicalAlert }) { + const s = SEVERITY_STYLES[alert.severity]; + return ( + + {alert.description} + + ); +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export function PetProfileCard({ petId }: Props) { + const [state, setState] = useState({ status: "idle" }); + + useEffect(() => { + setState({ status: "loading" }); + fetch(`/api/pets/${petId}/profile-summary`) + .then(async (res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json() as Promise; + }) + .then((data) => setState({ status: "loaded", data })) + .catch((e: unknown) => setState({ status: "error", message: e instanceof Error ? e.message : "Unknown error" })); + }, [petId]); + + if (state.status === "idle" || state.status === "loading") { + return ( +
+
+
+
+
+
+
+
+
+
+
+ {[1, 2, 3].map((n) =>
)} +
+
+ ); + } + + if (state.status === "error") { + return ( +
+ Failed to load profile: {state.message} +
+ ); + } + + const { data } = state; + const age = computeAge(data.dateOfBirth); + + return ( +
+ {/* Header */} +
+ +
+ {data.name} +
+ {data.breed ?? "Unknown breed"} + {age && · {age}} +
+ {data.weightKg != null && ( +
{data.weightKg} kg
+ )} +
+
+ + {/* Coat type */} + {data.coatType && ( +
+ COAT TYPE + + {data.coatType} + +
+ )} + + {/* Temperament */} + {(data.temperamentScore != null || data.temperamentFlags.length > 0) && ( +
+ TEMPERAMENT +
+ {data.temperamentScore != null && } + {data.temperamentFlags.map((flag) => ( + + {flag} + + ))} +
+
+ )} + + {/* Medical alerts — most prominent */} + {data.medicalAlerts.length > 0 && ( +
+ MEDICAL ALERTS +
+ {data.medicalAlerts.map((alert) => ( + + ))} +
+
+ )} + + {/* Preferred cuts */} + {data.preferredCuts.length > 0 && ( +
+ PREFERRED CUTS +
+ {data.preferredCuts.map((cut) => ( + + {cut} + + ))} +
+
+ )} + + {/* Recent visits */} + {data.recentVisits.length > 0 && ( +
+ RECENT VISITS + {data.recentVisits.slice(0, 3).map((log) => ( +
+ {new Date(log.groomedAt).toLocaleDateString()} + {log.cutStyle && · {log.cutStyle}} + {log.staffId && ·} +
+ ))} +
+ )} + + {/* Next appointment */} + {data.nextAppointment && ( +
+ NEXT APPOINTMENT +
+ {new Date(data.nextAppointment.startTime).toLocaleDateString()} — {data.nextAppointment.serviceName} +
+
+ )} +
+ ); +} diff --git a/src/pages/Appointments.tsx b/src/pages/Appointments.tsx index b64186d..3362c4f 100644 --- a/src/pages/Appointments.tsx +++ b/src/pages/Appointments.tsx @@ -1,5 +1,8 @@ import { useEffect, useState, useCallback, useRef } from "react"; import type { Appointment, Client, Pet, Service, Staff } from "@groombook/types"; +import { PetPhotoDisplay } from "../components/PetPhotoDisplay.js"; +import { PetPhotoUpload } from "../components/PetPhotoUpload.js"; +import { PetProfileCard } from "../components/PetProfileCard.js"; // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -113,6 +116,7 @@ export function AppointmentsPage() { // null key = unassigned; staffId string = that groomer; undefined set = all visible const [hiddenGroomers, setHiddenGroomers] = useState>(new Set()); const [paymentStats, setPaymentStats] = useState<{ revenueThisMonth: number; outstanding: number; refundsThisMonth: number; methodBreakdown: { method: string | null; total: number }[] } | null>(null); + const [selectedPetId, setSelectedPetId] = useState(null); const weekEnd = addDays(weekStart, 6); @@ -510,7 +514,10 @@ export function AppointmentsPage() { + {form.petId && }