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 && }