# Pets Module Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a household-scoped Pets module where family members can manage pet profiles, photos, vet appointments, vaccinations, prescriptions, and one-click calendar entries for vet visits. **Architecture:** Follow the Garden module's bounded-module structure: schema, validated server actions and queries, client components, a registered manifest, and App Router pages. The module owns all pet medical data and accesses Calendar only through `createEventForScope`; it stores the resulting calendar event ID to prevent duplicate calendar creation. Pet records are searchable and appear in activity/dashboard registries, but are intentionally not public-shareable because they contain medical information. **Tech Stack:** Next.js 15 App Router, React 19, TypeScript strict, Drizzle ORM/Postgres, Zod 4, Tailwind 4, shadcn Base UI, MinIO upload route, Playwright. ## Global Constraints - Follow `AGENTS.md`: household-scope every read and mutation; use server actions for mutations; do not import from sibling modules except the established calendar bridge action. - Keep a new Drizzle migration immutable once generated; run `pnpm db:generate` and commit the SQL plus `drizzle/meta` snapshot/journal updates. - Use the existing authenticated `/api/uploads` endpoint with a new `pets` key prefix; accept images only and retain the existing 10-photo per entity limit. - Use `date` columns for birth, vaccination, and prescription dates; use a timezone-aware timestamp for appointments. - Keep pet records private to the household: register `pets.pet` for search/activity/dashboard/quick-add, but do not add a share capability, `ShareButton`, or public loader. - Every module addition must include its authenticated v1 API surface, OpenAPI documentation, assistant tool definitions/execution/labels, and regression tests; the API and assistant must use the same scoped service functions as the UI. - Every user-visible due date needs a reminder lifecycle. Pet appointments use selected reminder offsets (default `[30]`); vaccinations use `dueOn`; active prescriptions use `expiresOn`. Creates/updates replace unfired reminders and deletes cancel them. - Use a shared timezone helper to schedule date-only vaccination and prescription reminders at 09:00 in `HOUSEHOLD_TIMEZONE` (falling back to `TZ`, then `America/Chicago`), including daylight-saving transitions. Do not schedule at UTC midnight. - Create a calendar event only when the user explicitly chooses a calendar. Record its ID in the appointment and reject a second creation for that appointment. - Preserve the existing untracked `.codex-remote-attachments/` directory; it is not part of this work. --- ## File Structure | Path | Responsibility | | -------------------------------------------------------- | -------------------------------------------------------------------------------------- | | `src/modules/pets/schema.ts` | Drizzle tables and inferred row types for pets and their medical records. | | `src/modules/pets/server/schemas.ts` | Shared Zod boundary validation for every mutation. | | `src/modules/pets/server/actions.ts` | Household-gated CRUD, image-gallery mutations, activity logging, and calendar bridge. | | `src/modules/pets/server/queries.ts` | DTO-producing scoped reads, search adapter, and dashboard statistics. | | `src/modules/pets/server/reminders.ts` | Pet reminder scheduling and cancellation helpers. | | `src/modules/pets/components/*` | Focused profile, gallery, and medical-record UI components. | | `src/modules/pets/manifest.tsx` | Nav, entity search/activity registration, overview widget, and quick-add contribution. | | `src/app/pets/**` | Thin authenticated route pages for listing, creating, editing, and viewing pets. | | `src/app/api/v1/pets/**` | Token/session-authenticated REST routes over the scoped pet service layer. | | `docs/api/openapi.yaml` | Documented pets REST paths and request/response schemas. | | `src/modules/agent/{tools,tool-executor,tool-labels}.ts` | Assistant-visible Pets contracts, API delegation, and progress copy. | | `src/modules/agent/server/loop-guards.ts` | Mutating Pets tools are treated as agent write operations. | | `src/lib/household-timezone.ts` | Shared DST-safe local date/time conversion for pet reminders and agent runtime. | | `src/components/quick-add/dialogs/pet-create-dialog.tsx` | Minimal quick-add profile dialog. | | `drizzle/0027_pets_module.sql` | Generated immutable database migration. | | `tests/unit/pets-schemas.test.ts` | Boundary validation regression coverage. | | `tests/e2e/pets.spec.ts` | Browser happy path for the profile, medical records, gallery, and calendar bridge. | ## Task 1: Pet data model and validation contract **Files:** - Create: `src/modules/pets/schema.ts` - Create: `src/modules/pets/server/schemas.ts` - Create: `tests/unit/pets-schemas.test.ts` - Create: generated `drizzle/0027_pets_module.sql` - Modify: generated `drizzle/meta/0027_snapshot.json` - Modify: generated `drizzle/meta/_journal.json` **Interfaces:** - Produces `pets`, `petAppointments`, `petVaccinations`, and `petPrescriptions` tables, all scoped by `householdId`. - Produces `petInput`, `petUpdateInput`, `appointmentInput`, `vaccinationInput`, `prescriptionInput`, and `scheduleAppointmentInput` Zod schemas. - Consumed by server actions, queries, pages, and the migration runner in later tasks. - [ ] **Step 1: Write the failing validation tests** ```ts // tests/unit/pets-schemas.test.ts import assert from "node:assert/strict"; import test from "node:test"; import { appointmentInput, petInput, prescriptionInput, vaccinationInput, } from "@/modules/pets/server/schemas"; test("pet and medical record schemas accept bounded valid input", () => { assert.equal(petInput.parse({ name: "Mochi", species: "cat", images: [] }).name, "Mochi"); assert.equal( appointmentInput.parse({ petId: "f5a50e32-a217-4d65-ae10-98a1f441d171", title: "Annual exam", appointmentAt: "2026-08-01T15:00:00.000Z", }).title, "Annual exam", ); assert.equal( vaccinationInput.parse({ petId: "f5a50e32-a217-4d65-ae10-98a1f441d171", name: "Rabies", administeredOn: "2026-07-01", }).name, "Rabies", ); assert.equal( prescriptionInput.parse({ petId: "f5a50e32-a217-4d65-ae10-98a1f441d171", medication: "Apoquel", dosage: "5 mg daily", active: true, }).active, true, ); }); test("pet and medical record schemas reject invalid identifiers, dates, and galleries", () => { assert.throws(() => petInput.parse({ name: "", species: "cat" })); assert.throws(() => petInput.parse({ name: "Mochi", species: "cat", images: Array(11).fill("/api/uploads/pets/a.jpg"), }), ); assert.throws(() => appointmentInput.parse({ petId: "not-a-uuid", title: "Exam", appointmentAt: "not-a-date" }), ); assert.throws(() => vaccinationInput.parse({ petId: "f5a50e32-a217-4d65-ae10-98a1f441d171", name: "Rabies", administeredOn: "07/01/2026", }), ); }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `pnpm exec tsx --test tests/unit/pets-schemas.test.ts` Expected: FAIL because the pets schema module does not exist. - [ ] **Step 3: Define the Drizzle tables and Zod inputs** ```ts // src/modules/pets/schema.ts (table shape) export const pets = pgTable( "pets", { id: uuid("id").primaryKey().defaultRandom(), householdId: uuid("household_id") .notNull() .references(() => households.id, { onDelete: "cascade" }), name: text("name").notNull(), species: text("species").notNull(), breed: text("breed"), birthDate: date("birth_date"), notes: text("notes"), primaryImageUrl: text("primary_image_url"), images: jsonb("images").notNull().default([]).$type(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => [index("pets_household_idx").on(t.householdId)], ); export const petAppointments = pgTable( "pet_appointments", { id: uuid("id").primaryKey().defaultRandom(), petId: uuid("pet_id") .notNull() .references(() => pets.id, { onDelete: "cascade" }), householdId: uuid("household_id") .notNull() .references(() => households.id, { onDelete: "cascade" }), title: text("title").notNull(), appointmentAt: timestamp("appointment_at", { withTimezone: true }).notNull(), clinic: text("clinic"), notes: text("notes"), calendarEventId: uuid("calendar_event_id"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => [index("pet_appointments_pet_at_idx").on(t.petId, t.appointmentAt)], ); export const petVaccinations = pgTable( "pet_vaccinations", { id: uuid("id").primaryKey().defaultRandom(), petId: uuid("pet_id") .notNull() .references(() => pets.id, { onDelete: "cascade" }), householdId: uuid("household_id") .notNull() .references(() => households.id, { onDelete: "cascade" }), name: text("name").notNull(), administeredOn: date("administered_on").notNull(), dueOn: date("due_on"), provider: text("provider"), notes: text("notes"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => [index("pet_vaccinations_pet_due_idx").on(t.petId, t.dueOn)], ); export const petPrescriptions = pgTable( "pet_prescriptions", { id: uuid("id").primaryKey().defaultRandom(), petId: uuid("pet_id") .notNull() .references(() => pets.id, { onDelete: "cascade" }), householdId: uuid("household_id") .notNull() .references(() => households.id, { onDelete: "cascade" }), medication: text("medication").notNull(), dosage: text("dosage").notNull(), instructions: text("instructions"), prescribedOn: date("prescribed_on"), expiresOn: date("expires_on"), refillsRemaining: integer("refills_remaining"), active: boolean("active").notNull().default(true), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => [index("pet_prescriptions_pet_active_idx").on(t.petId, t.active)], ); ``` ```ts // src/modules/pets/server/schemas.ts (selected contracts) const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); const optionalText = (max: number) => z.string().trim().max(max).nullable().optional(); export const petInput = z.object({ name: z.string().trim().min(1).max(120), species: z.string().trim().min(1).max(80), breed: optionalText(120), birthDate: isoDate.nullable().optional(), notes: optionalText(4000), images: z.array(z.string().min(1)).max(10).default([]), primaryImageUrl: z.string().min(1).nullable().optional(), }); export const appointmentInput = z.object({ petId: z.string().uuid(), title: z.string().trim().min(1).max(160), appointmentAt: z.coerce.date(), clinic: optionalText(160), notes: optionalText(4000), reminderOffsets: z.array(z.number().int().min(0).max(43_200)).default([30]), }); export const vaccinationInput = z.object({ petId: z.string().uuid(), name: z.string().trim().min(1).max(160), administeredOn: isoDate, dueOn: isoDate.nullable().optional(), provider: optionalText(160), notes: optionalText(4000), }); export const prescriptionInput = z.object({ petId: z.string().uuid(), medication: z.string().trim().min(1).max(160), dosage: z.string().trim().min(1).max(160), instructions: optionalText(2000), prescribedOn: isoDate.nullable().optional(), expiresOn: isoDate.nullable().optional(), refillsRemaining: z.number().int().min(0).max(99).nullable().optional(), active: z.boolean().default(true), }); export const scheduleAppointmentInput = z.object({ appointmentId: z.string().uuid(), calendarId: z.string().uuid(), }); ``` - [ ] **Step 4: Generate and inspect the migration** Run: `pnpm db:generate` Expected: a new `0027_pets_module.sql` creates exactly four tables, indexes listed above, and cascading foreign keys only to `households`/`pets`; no existing migration is modified. - [ ] **Step 5: Run the test to verify it passes** Run: `pnpm exec tsx --test tests/unit/pets-schemas.test.ts && pnpm typecheck` Expected: PASS; TypeScript reports no errors. - [ ] **Step 6: Commit** ```powershell git add src/modules/pets/schema.ts src/modules/pets/server/schemas.ts tests/unit/pets-schemas.test.ts drizzle git commit -m "feat(pets): add pet record schema" ``` ## Task 2: Scoped pet queries and mutations **Files:** - Create: `src/modules/pets/server/actions.ts` - Create: `src/modules/pets/server/queries.ts` - Create: `src/modules/pets/server/reminders.ts` - Create: `src/lib/household-timezone.ts` - Create: `tests/unit/household-timezone.test.ts` - Modify: `tests/unit/pets-schemas.test.ts` **Interfaces:** - Consumes the tables and inputs from Task 1 plus `ApiAuthContext`, `getCurrentSession`, and `logActivityForScope`. - Produces `createPetForScope`, `updatePetForScope`, `deletePetForScope`, record CRUD functions, image-gallery functions, `listPetsForScope`, `getPetForScope`, `searchPets`, and `getPetsOverviewStats`. - UI, v1 routes, and assistant tools call these `ForScope` exports; session wrappers only revalidate UI paths and never duplicate authorization or business rules. - Produces `src/modules/pets/server/reminders.ts`, which synchronizes appointment, vaccination, and prescription reminders through `_core/reminders`. - [ ] **Step 1: Extend validation coverage for update payloads** ```ts test("partial update schemas do not erase omitted fields", () => { assert.deepEqual(petUpdateInput.parse({ notes: null }), { notes: null }); assert.deepEqual(prescriptionUpdateInput.parse({ active: false }), { active: false }); }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `pnpm exec tsx --test tests/unit/pets-schemas.test.ts` Expected: FAIL because the update schemas are not exported. - [ ] **Step 3: Implement scope-safe data access and actions** ```ts async function assertCanAccessPet(id: string, householdId: string) { const [pet] = await db .select({ id: pets.id }) .from(pets) .where(and(eq(pets.id, id), eq(pets.householdId, householdId))) .limit(1); if (!pet) throw new Error("Forbidden"); } export async function createPetForScope(scope: ApiAuthContext, input: z.input) { const parsed = petInput.parse(input); const [pet] = await db .insert(pets) .values({ householdId: scope.householdId, ...parsed, breed: parsed.breed ?? null, birthDate: parsed.birthDate ?? null, notes: parsed.notes ?? null, primaryImageUrl: parsed.primaryImageUrl ?? parsed.images[0] ?? null, }) .returning(); if (!pet) throw new Error("Pet was not created"); await logActivityForScope(scope, { entityType: "pets.pet", entityId: pet.id, action: "create", payload: { name: pet.name }, }); return pet; } export async function createAppointmentForScope( scope: ApiAuthContext, input: z.input, ) { const parsed = appointmentInput.parse(input); await assertCanAccessPet(parsed.petId, scope.householdId); const [appointment] = await db .insert(petAppointments) .values({ ...parsed, householdId: scope.householdId, clinic: parsed.clinic ?? null, notes: parsed.notes ?? null, }) .returning(); if (!appointment) throw new Error("Appointment was not created"); return appointment; } ``` Implement matching update/delete functions for pets, appointments, vaccinations, and prescriptions. Scope every record mutation by both its record ID and `householdId`; check the parent pet before inserting a child. Use `undefined` for omitted update fields and `null` only for explicit clearing. Add `addPetImage`, `removePetImage`, and `setPetPrimaryImage` with the Garden behavior: require ownership, reject galleries of 11 images, and choose the first remaining image when the removed image was primary. Session wrappers revalidate `/pets`, `/pets/[id]`, and `/pets/[id]/edit` as appropriate. ```ts // src/modules/pets/server/reminders.ts export async function syncPetAppointmentReminders(input: { householdId: string; appointmentId: string; appointmentAt: Date; createdBy: string; petName: string; title: string; offsets: number[]; }) { await syncRemindersForEntity({ householdId: input.householdId, entityType: "pets.appointment", entityId: input.appointmentId, createdBy: input.createdBy, reminders: normalizeReminderOffsets(input.offsets).map((offsetMinutes) => ({ fireAt: fireAtForEventStart(input.appointmentAt, offsetMinutes), offsetMinutes, title: `Vet visit: ${input.petName}`, body: input.title, })), }); } export async function syncPetDueDateReminder(input: { householdId: string; entityType: "pets.vaccination" | "pets.prescription"; entityId: string; dueOn: string | null; createdBy: string; title: string; body: string; }) { if (!input.dueOn) return cancelReminder(input.entityType, input.entityId); return scheduleReminder({ ...input, fireAt: householdDateAt(input.dueOn, 9), offsetMinutes: null, }); } ``` Create `src/lib/household-timezone.ts` with `resolveHouseholdTimezone()` and `householdDateAt(isoDate: string, hour: number, timeZone = resolveHouseholdTimezone()): Date`. Implement `householdDateAt` by formatting a UTC candidate with `Intl.DateTimeFormat(..., { timeZone, hourCycle: "h23" }).formatToParts`, converting the formatted local parts to an offset, then subtracting that offset from the candidate. Task 5 replaces the Agent-local timezone resolver with this shared export. Add a unit test for `America/Chicago` covering both `2026-01-15` and `2026-07-15`, asserting the result formats to `09:00` in that timezone. ```ts // tests/unit/household-timezone.test.ts import assert from "node:assert/strict"; import test from "node:test"; import { householdDateAt } from "@/lib/household-timezone"; test("householdDateAt preserves 9 AM across Chicago daylight saving time", () => { const formatter = new Intl.DateTimeFormat("en-CA", { timeZone: "America/Chicago", hour: "2-digit", minute: "2-digit", hourCycle: "h23", }); for (const date of ["2026-01-15", "2026-07-15"]) { assert.equal(formatter.format(householdDateAt(date, 9, "America/Chicago")), "09:00"); } }); ``` On appointment create/update, call `syncPetAppointmentReminders` with `reminderOffsets ?? [30]`; on an update with omitted offsets, retain `listReminderOffsets("pets.appointment", appointment.id)`. On a caller-supplied empty array, cancel all unfired appointment reminders. On vaccination/prescription create/update, call `syncPetDueDateReminder` with `dueOn`/`expiresOn`; on deletion call `cancelReminder` for the matching entity. Appointment, vaccination, and prescription DTOs must expose current reminder offsets or `reminderScheduled` so the UI/API/agent can accurately present the state. ```ts export type PetDetailDto = { id: string; name: string; species: string; breed: string | null; birthDate: string | null; notes: string | null; primaryImageUrl: string | null; images: string[]; appointments: PetAppointmentDto[]; vaccinations: PetVaccinationDto[]; prescriptions: PetPrescriptionDto[]; }; export async function getPetsOverviewStats( householdId: string, ): Promise<{ petCount: number; dueSoonCount: number }> { // Count pets, then count vaccinations due within 30 days and active prescriptions expiring within 30 days. // A record due today or earlier is included in dueSoonCount. } ``` Have `listPetsForScope` order by name, `getPetForScope` fetch child records by descending appointment/administered/prescribed date, and `searchPets` search name, species, and breed with a 10-row limit. Convert database dates/timestamps to ISO strings in all DTOs. - [ ] **Step 4: Run the focused tests and static gate** Run: `pnpm exec tsx --test tests/unit/pets-schemas.test.ts tests/unit/household-timezone.test.ts && pnpm typecheck && pnpm lint` Expected: PASS; lint may retain only the repository's pre-existing warnings. - [ ] **Step 5: Commit** ```powershell git add src/modules/pets/server src/lib/household-timezone.ts tests/unit/pets-schemas.test.ts tests/unit/household-timezone.test.ts git commit -m "feat(pets): add scoped records and queries" ``` ## Task 3: Calendar bridge for vet appointments **Files:** - Create: `src/modules/pets/server/calendar-bridge.ts` - Modify: `src/modules/pets/server/schemas.ts` - Modify: `src/modules/pets/server/actions.ts` - Modify: `tests/unit/pets-schemas.test.ts` **Interfaces:** - Consumes `createEventForScope` and `listCalendars` from the Calendar module, the scoped appointment actions from Task 2, and a `scheduleAppointmentInput` payload. - Produces `scheduleAppointmentOnCalendarForScope(scope, input)` and its session wrapper, returning the created `CalendarEventDto`. - On success, cancels the appointment's `pets.appointment` reminders because the new `calendar.event` reminders become the single notification source. - The detail page in Task 7 consumes `listCalendars` and this action. - [ ] **Step 1: Add a failing calendar-bridge input test** ```ts test("calendar scheduling requires appointment and calendar identifiers", () => { assert.deepEqual( scheduleAppointmentInput.parse({ appointmentId: "f5a50e32-a217-4d65-ae10-98a1f441d171", calendarId: "bd8e10c2-58f4-4db8-9326-0b50fdda47bb", }), { appointmentId: "f5a50e32-a217-4d65-ae10-98a1f441d171", calendarId: "bd8e10c2-58f4-4db8-9326-0b50fdda47bb", }, ); assert.throws(() => scheduleAppointmentInput.parse({ appointmentId: "bad", calendarId: "also-bad" }), ); }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `pnpm exec tsx --test tests/unit/pets-schemas.test.ts` Expected: FAIL because `scheduleAppointmentInput` is absent. - [ ] **Step 3: Implement single-create calendar behavior** ```ts export async function scheduleAppointmentOnCalendarForScope( scope: ApiAuthContext, input: z.input, ) { const parsed = scheduleAppointmentInput.parse(input); const [row] = await db .select({ appointment: petAppointments, petName: pets.name }) .from(petAppointments) .innerJoin(pets, eq(petAppointments.petId, pets.id)) .where( and( eq(petAppointments.id, parsed.appointmentId), eq(petAppointments.householdId, scope.householdId), ), ) .limit(1); if (!row) throw new Error("Appointment not found"); if (row.appointment.calendarEventId) throw new Error("Appointment is already on the calendar"); const startAt = row.appointment.appointmentAt; const reminderOffsets = await listReminderOffsets("pets.appointment", row.appointment.id); const event = await createEventForScope( { householdId: scope.householdId, userId: scope.userId }, { calendarId: parsed.calendarId, title: `Vet: ${row.petName} — ${row.appointment.title}`, startAt, endAt: new Date(startAt.getTime() + 60 * 60 * 1000), allDay: false, location: row.appointment.clinic ?? undefined, notes: row.appointment.notes ?? undefined, reminderOffsets: reminderOffsets.length > 0 ? reminderOffsets : [30], }, ); await db .update(petAppointments) .set({ calendarEventId: event.id, updatedAt: new Date() }) .where(eq(petAppointments.id, row.appointment.id)); await cancelReminder("pets.appointment", row.appointment.id); await logActivityForScope(scope, { entityType: "pets.pet", entityId: row.appointment.petId, action: "update", payload: { appointment: row.appointment.title, calendarEventId: event.id }, }); return event; } ``` Export `listCalendars` and `CalendarDto` from the bridge file exactly as Garden does. Revalidate both the pet detail page and `/calendar` in the session wrapper. Do not update or delete linked calendar events in this issue; calendar remains the owner after creation. - [ ] **Step 4: Run the focused tests and static gate** Run: `pnpm exec tsx --test tests/unit/pets-schemas.test.ts && pnpm typecheck` Expected: PASS. - [ ] **Step 5: Commit** ```powershell git add src/modules/pets/server tests/unit/pets-schemas.test.ts git commit -m "feat(pets): add appointment calendar bridge" ``` ## Task 4: Authenticated Pets v1 API and OpenAPI contract **Files:** - Create: `src/app/api/v1/pets/route.ts` - Create: `src/app/api/v1/pets/[id]/route.ts` - Create: `src/app/api/v1/pets/[id]/appointments/route.ts` - Create: `src/app/api/v1/pets/appointments/[id]/route.ts` - Create: `src/app/api/v1/pets/appointments/[id]/calendar/route.ts` - Create: `src/app/api/v1/pets/[id]/vaccinations/route.ts` - Create: `src/app/api/v1/pets/vaccinations/[id]/route.ts` - Create: `src/app/api/v1/pets/[id]/prescriptions/route.ts` - Create: `src/app/api/v1/pets/prescriptions/[id]/route.ts` - Modify: `docs/api/openapi.yaml` - Create: `tests/unit/api-v1-pets.test.ts` **Interfaces:** - Consumes only Task 2/3 `ForScope` actions and queries plus `apiJson`/`withApiHandler`; routes must never call session-only wrappers. - Produces the REST collection/detail paths used by external clients and the assistant's HTTP tool executor. - Defines API schemas for `PetSummary`, `PetDetail`, `PetAppointment`, `PetVaccination`, `PetPrescription`, their create/update payloads, and the calendarization request. - [ ] **Step 1: Write failing route authentication and payload tests** ```ts // tests/unit/api-v1-pets.test.ts import assert from "node:assert/strict"; import { describe, it } from "node:test"; describe("Pets v1 API auth gates", () => { it("rejects unauthenticated collection reads", async () => { const { GET } = await import("../../src/app/api/v1/pets/route"); const response = await GET(new Request("http://localhost/api/v1/pets")); assert.equal(response.status, 401); assert.deepEqual(await response.json(), { error: "Unauthorized" }); }); it("rejects malformed pet identifiers before touching services", async () => { const { GET } = await import("../../src/app/api/v1/pets/[id]/route"); const response = await GET(new Request("http://localhost/api/v1/pets/not-a-uuid"), { params: Promise.resolve({ id: "not-a-uuid" }), }); assert.equal(response.status, 401); }); }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `pnpm exec tsx --test tests/unit/api-v1-pets.test.ts` Expected: FAIL because the Pets v1 route modules do not exist. - [ ] **Step 3: Implement the route families through scoped services** ```ts // src/app/api/v1/pets/[id]/appointments/route.ts export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; return withApiHandler(request, async (scope, req) => { z.string().uuid().parse(id); const body: unknown = await req.json(); const appointment = await createAppointmentForScope( scope, appointmentInput.parse({ ...(body as object), petId: id }), ); return apiJson(toAppointmentDto(appointment), 201); }); } ``` Implement all routes with the following exact surface: ```text GET, POST /api/v1/pets GET, PATCH, DELETE /api/v1/pets/{id} GET, POST /api/v1/pets/{id}/appointments PATCH, DELETE /api/v1/pets/appointments/{id} POST /api/v1/pets/appointments/{id}/calendar GET, POST /api/v1/pets/{id}/vaccinations PATCH, DELETE /api/v1/pets/vaccinations/{id} GET, POST /api/v1/pets/{id}/prescriptions PATCH, DELETE /api/v1/pets/prescriptions/{id} ``` For nested `POST`, overwrite `petId` with the URL parameter so callers cannot create a record under another household's pet. For update/delete/calendar routes, validate the record ID with Zod, call the corresponding `ForScope` function, and return `{ ok: true }` for a successful delete/calendar action. `GET /api/v1/pets/{id}` returns the aggregate `PetDetailDto`; nested GET routes return their record arrays. Preserve `withApiHandler` error mapping so malformed payloads are 400, ownership failures are 403, and missing scoped records are 404. Add each path and component schema to `docs/api/openapi.yaml`, tag them `Pets`, and document `reminderOffsets` as an array of non-negative integers on appointment create/update. Document `dueOn` and `expiresOn` as `format: date`, and state that those dates schedule a 09:00 household-local reminder when supplied. - [ ] **Step 4: Run unit API checks and inspect the OpenAPI endpoint** Run: `pnpm exec tsx --test tests/unit/api-v1-pets.test.ts tests/unit/api-v1-calendar.test.ts && pnpm typecheck` Expected: PASS; the existing OpenAPI route continues to serve the updated YAML behind authentication. - [ ] **Step 5: Commit** ```powershell git add src/app/api/v1/pets docs/api/openapi.yaml tests/unit/api-v1-pets.test.ts git commit -m "feat(pets): add authenticated v1 API" ``` ## Task 5: Assistant Pets tools and write safety **Files:** - Modify: `src/modules/agent/tools.ts` - Modify: `src/modules/agent/tool-executor.ts` - Modify: `src/modules/agent/tool-labels.ts` - Modify: `src/modules/agent/server/loop-guards.ts` - Modify: `tests/unit/agent-tools-runtime.test.ts` **Interfaces:** - Consumes Task 4's documented API paths through `createApiToolExecutor`; the agent never imports the Pets database module or bypasses API auth. - Produces read/write tool definitions that exactly map one public action to one API call, human progress labels, write-tool loop guards, and Pets guidance in `AGENT_SYSTEM_PROMPT`. - [ ] **Step 1: Add failing executor routing tests** ```ts it("routes pet medical tools through the loopback v1 API", async () => { const originalFetch = globalThis.fetch; const calls: Array<{ url: string; method: string; body?: unknown }> = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { calls.push({ url: String(input), method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined, }); return Response.json({ id: "appointment-1" }, { status: 201 }); }) as typeof fetch; const execute = createApiToolExecutor(new Request("https://fam.ginnoir.com/api/agent/chat")); await execute( "create_pet_appointment", JSON.stringify({ petId: "pet-1", title: "Annual exam", appointmentAt: "2026-08-01T15:00:00.000Z", reminderOffsets: [30], }), ); await execute( "schedule_pet_appointment_on_calendar", JSON.stringify({ appointmentId: "appointment-1", calendarId: "calendar-1" }), ); assert.deepEqual( calls.map((call) => [call.method, call.url.replace("http://127.0.0.1:3000", "")]), [ ["POST", "/api/v1/pets/pet-1/appointments"], ["POST", "/api/v1/pets/appointments/appointment-1/calendar"], ], ); globalThis.fetch = originalFetch; }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `pnpm exec tsx --test tests/unit/agent-tools-runtime.test.ts` Expected: FAIL because Pets tool names have no executor cases. - [ ] **Step 3: Add complete tool declarations, execution, labels, and guards** Declare and implement these exact tools in `tools.ts` and `tool-executor.ts`: ```text list_pets, get_pet, create_pet, update_pet, delete_pet create_pet_appointment, update_pet_appointment, delete_pet_appointment, schedule_pet_appointment_on_calendar create_pet_vaccination, update_pet_vaccination, delete_pet_vaccination create_pet_prescription, update_pet_prescription, delete_pet_prescription ``` Each executor case must validate required IDs with `requireString`, construct a body from only supplied optional fields, and call the corresponding Task 4 path through `callApi`. Mutating tool names all go in `WRITE_TOOL_NAMES`; `list_pets` and `get_pet` do not. Add one clear `tool-labels.ts` message per name, for example `create_pet_vaccination` → `Recording a vaccination…` and `schedule_pet_appointment_on_calendar` → `Adding vet visit to calendar…`. Update `AGENT_SYSTEM_PROMPT` with: `Pets: use list_pets to resolve a pet id before changing records. Appointments accept ISO 8601 datetimes and reminderOffsets in minutes; vaccination dueOn and prescription expiresOn are YYYY-MM-DD and schedule household-local reminders. Pet medical records are household-private and cannot be shared.` Move `resolveHouseholdTimezone` into `src/lib/household-timezone.ts` and import it from `tools.ts`; this prevents the new Pets reminder service from depending on the Agent module. - [ ] **Step 4: Run agent and type checks** Run: `pnpm exec tsx --test tests/unit/agent-tools-runtime.test.ts tests/unit/agent-loop-guards.test.ts && pnpm typecheck` Expected: PASS; the executor uses `http://127.0.0.1:$PORT` or `INTERNAL_API_BASE_URL`, never the public `fam.ginnoir.com` origin. - [ ] **Step 5: Commit** ```powershell git add src/modules/agent src/lib/household-timezone.ts tests/unit/agent-tools-runtime.test.ts git commit -m "feat(agent): add pets tools" ``` ## Task 6: Module registration, navigation, upload scope, dashboard, and quick add **Files:** - Create: `src/modules/pets/manifest.tsx` - Create: `src/modules/pets/components/pets-overview-widget.tsx` - Create: `src/components/quick-add/dialogs/pet-create-dialog.tsx` - Modify: `src/modules/index.ts` - Modify: `src/app/api/uploads/route.ts` - Modify: `src/components/nav-icon.tsx` - Modify: `src/components/quick-add-sheet.tsx` - Modify: `src/components/command-palette.tsx` - Modify: `src/components/quick-add/create-host.tsx` **Interfaces:** - Consumes `searchPets`, `getPetsOverviewStats`, and `createPet` from earlier tasks. - Produces a `pets` module manifest, a `pets.overview` widget, nav/quick-add icon support, and the `pets` MinIO prefix. - Pages in Task 7 are reachable via `/pets`; quick add opens `PetCreateDialog` with create key `pets.pet`. - [ ] **Step 1: Add the failing registration assertion to the browser test** ```ts // tests/e2e/pets.spec.ts (create this file now) import { expect, test } from "@playwright/test"; test("pets module is reachable from navigation and quick add", async ({ page }) => { await page.goto("/"); await page.getByRole("link", { name: "Pets" }).click(); await expect(page).toHaveURL(/\/pets$/); await page.getByRole("button", { name: /quick add|new/i }).click(); await expect(page.getByText("Add pet")).toBeVisible(); }); ``` - [ ] **Step 2: Run it to verify it fails** Run: `pnpm test:e2e -- tests/e2e/pets.spec.ts --grep "reachable"` Expected: FAIL because the Pets nav item and dialog do not exist. - [ ] **Step 3: Register the module and supporting UI** ```tsx // src/modules/pets/manifest.tsx const petsManifest: ModuleManifest = { id: "pets", name: "Pets", nav: { href: "/pets", label: "Pets", icon: "paw-print" }, entities: [ { type: "pets.pet", label: { singular: "Pet", plural: "Pets" }, search: { search: searchPets }, reminder: { canRemind: true }, resolveUrl: (id) => `/pets/${id}`, renderActivity: (entry) => entry.action === "create" ? `Added pet \"${String(entry.payload?.name ?? "")}\"` : `Updated pet \"${String(entry.payload?.name ?? "")}\"`, }, ], dashboardWidgets: [ { id: "pets.overview", title: "Pets overview", description: "Pet count and medical items due within 30 days.", category: "Pets", defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 2 }, defaultPriority: 60, configSchema: z.object({}), defaultConfig: {}, render: (props) => , }, ], quickAdds: [ { id: "pets.add", label: "Add pet", icon: "paw-print", url: "/pets/new", createKey: "pets.pet", }, ], }; ``` Add `PawPrint` to `ICONS` in `nav-icon.tsx`, map `paw-print` to `paw-print` in `QUICK_ADD_ICON`, and map it to `🐾` in `iconEmoji`. Register the manifest after Garden in `src/modules/index.ts`. Expand the upload scope allowlist to `scopeParam === "pets"` and retain the existing default-to-garden behavior. `PetCreateDialog` should expose required Name and Species fields, call `createPet`, show an error toast on failure, then close and navigate to `/pets/[id]`. ```tsx function PetsOverviewWidgetServer({ ctx }: { config: unknown; ctx: WidgetContext }) { return ; } ``` Render the widget's stable empty state as `No medical items due soon.` so it is useful on a newly created household and inspectable in dashboard edit mode. - [ ] **Step 4: Run the registration test and typecheck** Run: `pnpm test:e2e -- tests/e2e/pets.spec.ts --grep "reachable" && pnpm typecheck` Expected: PASS. - [ ] **Step 5: Commit** ```powershell git add src/modules/pets/manifest.tsx src/modules/pets/components/pets-overview-widget.tsx src/modules/index.ts src/app/api/uploads/route.ts src/components/nav-icon.tsx src/components/quick-add src/components/command-palette.tsx src/components/quick-add-sheet.tsx tests/e2e/pets.spec.ts git commit -m "feat(pets): register module and quick add" ``` ## Task 7: Pet profile pages, gallery, and medical record UI **Files:** - Create: `src/app/pets/page.tsx` - Create: `src/app/pets/new/page.tsx` - Create: `src/app/pets/[id]/page.tsx` - Create: `src/app/pets/[id]/edit/page.tsx` - Create: `src/modules/pets/components/pet-list.tsx` - Create: `src/modules/pets/components/pet-form.tsx` - Create: `src/modules/pets/components/pet-detail.tsx` - Create: `src/modules/pets/components/medical-records.tsx` - Modify: `tests/e2e/pets.spec.ts` **Interfaces:** - Consumes DTOs/actions from Tasks 2–3, `DetailBackLink`, Base UI tabs/badges/separators, and `CalendarDto`. - Produces an accessible CRUD UI at `/pets` and a tabbed profile detail with Info, Gallery, and Medical tabs. - The calendar bridge action is only shown for appointment records that have no `calendarEventId`. - [ ] **Step 1: Extend the E2E happy path** ```ts test("pets profile and medical records happy path", async ({ page }) => { const suffix = Date.now().toString(); const name = `E2E Pet ${suffix}`; await page.goto("/pets"); await page.getByRole("link", { name: /add pet/i }).click(); await page.getByLabel("Name").fill(name); await page.getByLabel("Species").fill("cat"); await page.getByRole("button", { name: /create/i }).click(); await expect(page.getByRole("heading", { name })).toBeVisible(); await page.getByRole("tab", { name: "Medical" }).click(); await page.getByRole("button", { name: /add appointment/i }).click(); await page.getByLabel("Title").fill("Annual exam"); await page.getByLabel("Appointment date and time").fill("2026-08-01T10:00"); await page.getByRole("button", { name: /save appointment/i }).click(); await expect(page.getByText("Annual exam")).toBeVisible(); await page.getByRole("button", { name: /add vaccination/i }).click(); await page.getByLabel("Vaccination name").fill("Rabies"); await page.getByLabel("Administered on").fill("2026-07-01"); await page.getByRole("button", { name: /save vaccination/i }).click(); await expect(page.getByText("Rabies")).toBeVisible(); await page.getByRole("button", { name: /add prescription/i }).click(); await page.getByLabel("Medication").fill("Apoquel"); await page.getByLabel("Dosage").fill("5 mg daily"); await page.getByRole("button", { name: /save prescription/i }).click(); await expect(page.getByText("Apoquel")).toBeVisible(); }); ``` - [ ] **Step 2: Run the test to verify it fails** Run: `pnpm test:e2e -- tests/e2e/pets.spec.ts --grep "profile and medical"` Expected: FAIL because `/pets` and the accessible controls do not exist. - [ ] **Step 3: Implement thin pages and focused components** ```tsx // src/app/pets/[id]/page.tsx export default async function PetPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const [pet, calendars] = await Promise.all([getPet(id), listCalendars()]); if (!pet) notFound(); return (
); } ``` `PetForm` must use `createPet`/`updatePet`, preserve nullable fields as `null`, and navigate to the returned/edited profile after success. `PetDetail` must use the same gallery lifecycle as `PlantDetail`, but upload to `/api/uploads?scope=pets`. It must not display `ShareButton` or `ShareLinkList`. Use `Tabs` for Info, Gallery, and Medical; keep medical editors in `medical-records.tsx` so profile/gallery state does not grow into one oversized component. Appointment editors expose validated reminder offsets (default one `30`-minute reminder, allow an empty array to disable); vaccination and active-prescription rows display `Reminder: 9:00 AM on YYYY-MM-DD` when `dueOn`/`expiresOn` is set, otherwise `No reminder scheduled`. ```tsx // schedule button contract inside medical-records.tsx { !appointment.calendarEventId && ( ); } ``` Use a Calendar select populated from `CalendarDto[]`; do not default a calendar or create events on medical-record save. A completed calendar bridge shows `Added to calendar`, hides the button, and replaces the pet appointment reminders with Calendar's same saved offsets. Each record editor must provide edit and delete controls, a local confirmation for delete, visible server errors, and labels matching the E2E names above. In particular, use `aria-label={\`Edit ${vaccination.name}\`}`and`aria-label={\`Edit ${prescription.medication}\`}`for row edit buttons, and expose`Due on`, `Expires on`, `Calendar`, and `Upload photo` as labels. - [ ] **Step 4: Run the complete pet E2E and static checks** Run: `pnpm test:e2e -- tests/e2e/pets.spec.ts && pnpm typecheck && pnpm lint` Expected: PASS; lint has no new warnings. - [ ] **Step 5: Commit** ```powershell git add src/app/pets src/modules/pets/components tests/e2e/pets.spec.ts git commit -m "feat(pets): add profile and medical records UI" ``` ## Task 8: Calendar bridge, reminder, and gallery browser coverage **Files:** - Modify: `tests/e2e/pets.spec.ts` **Interfaces:** - Consumes the finished pet UI, upload route, and Task 3 calendar bridge. - Produces regression coverage that a visit is calendarized only once and a gallery stays under the ten-photo boundary. - [ ] **Step 1: Add failure-first browser assertions** ```ts async function createPet(page: import("@playwright/test").Page, name: string) { await page.goto("/pets"); await page.getByRole("link", { name: /add pet/i }).click(); await page.getByLabel("Name").fill(name); await page.getByLabel("Species").fill("cat"); await page.getByRole("button", { name: /create/i }).click(); await expect(page.getByRole("heading", { name })).toBeVisible(); } test("pet appointment is calendarized once", async ({ page }) => { await createPet(page, `Calendar Pet ${Date.now()}`); await page.getByRole("tab", { name: "Medical" }).click(); await page.getByRole("button", { name: /add appointment/i }).click(); await page.getByLabel("Title").fill("Annual exam"); await page.getByLabel("Appointment date and time").fill("2026-08-01T10:00"); await page.getByRole("button", { name: /save appointment/i }).click(); await page.getByLabel("Calendar").selectOption({ index: 0 }); await page.getByRole("button", { name: /add to calendar/i }).click(); await expect(page.getByText("Added to calendar")).toBeVisible(); await expect(page.getByRole("button", { name: /add to calendar/i })).toHaveCount(0); await page.goto("/calendar"); await expect(page.getByText(/Vet: .*Annual exam/)).toBeVisible(); }); test("pet medical due dates show their scheduled reminders", async ({ page }) => { await createPet(page, `Reminder Pet ${Date.now()}`); await page.getByRole("tab", { name: "Medical" }).click(); await page.getByRole("button", { name: /add vaccination/i }).click(); await page.getByLabel("Vaccination name").fill("Rabies"); await page.getByLabel("Administered on").fill("2026-07-01"); await page.getByLabel("Due on").fill("2026-08-01"); await page.getByRole("button", { name: /save vaccination/i }).click(); await page.getByRole("button", { name: /add prescription/i }).click(); await page.getByLabel("Medication").fill("Apoquel"); await page.getByLabel("Dosage").fill("5 mg daily"); await page.getByLabel("Expires on").fill("2026-08-15"); await page.getByRole("button", { name: /save prescription/i }).click(); await expect(page.getByText("Reminder: 9:00 AM on 2026-08-01")).toBeVisible(); await expect(page.getByText("Reminder: 9:00 AM on 2026-08-15")).toBeVisible(); await page.getByRole("button", { name: "Edit Rabies" }).click(); await page.getByLabel("Due on").fill(""); await page.getByRole("button", { name: /save vaccination/i }).click(); await page.getByRole("button", { name: "Edit Apoquel" }).click(); await page.getByLabel("Expires on").fill(""); await page.getByRole("button", { name: /save prescription/i }).click(); await expect(page.getByText("No reminder scheduled")).toHaveCount(2); }); test("pet gallery only offers uploads below ten photos", async ({ page }) => { await createPet(page, `Gallery Pet ${Date.now()}`); await page.getByRole("tab", { name: "Gallery" }).click(); for (let index = 1; index <= 10; index++) { await page.getByLabel("Upload photo").setInputFiles({ name: `pet-${index}.png`, mimeType: "image/png", buffer: Buffer.from([137, 80, 78, 71]), }); await expect(page.getByText(`${index}/10 photos`)).toBeVisible(); } await expect(page.getByText("10/10 photos")).toBeVisible(); await expect(page.getByLabel("Upload photo")).toHaveCount(0); }); ``` - [ ] **Step 2: Run the tests to verify the relevant behavior fails before fixes** Run: `pnpm test:e2e -- tests/e2e/pets.spec.ts --grep "calendarized once|medical due dates|gallery only"` Expected: initially FAIL until the UI correctly records `calendarEventId`, surfaces synced reminders, and mirrors the Garden gallery limit. - [ ] **Step 3: Fix only the observed UI/action gaps** For a repeated calendar button, persist `calendarEventId` immediately after `createEventForScope` and render the button only when it is `null`. For a missing/uncancelled date reminder, call `syncPetDueDateReminder` after each create/update and `cancelReminder` in each delete branch. For an eleventh gallery control, render the upload label only when `pet.images.length < 10`. Do not introduce a pet-specific upload endpoint, direct MinIO client use in the browser, duplicate calendar event creation, UTC-midnight date reminders, or Calendar-module schema changes. - [ ] **Step 4: Run the full verification matrix** Run: `pnpm exec tsx --test tests/unit/pets-schemas.test.ts tests/unit/household-timezone.test.ts tests/unit/api-v1-pets.test.ts tests/unit/agent-tools-runtime.test.ts tests/unit/agent-loop-guards.test.ts; pnpm typecheck; pnpm lint; pnpm test:e2e -- tests/e2e/pets.spec.ts; pnpm build` Expected: all commands exit 0. Stop any local dev server started for the E2E run after verification. - [ ] **Step 5: Commit** ```powershell git add tests/e2e/pets.spec.ts src/modules/pets git commit -m "test(pets): cover calendar and gallery limits" ``` ## Task 9: Documentation, issue closeout, and release handoff **Files:** - Modify: `STATUS.md` - Modify: `docs/issues-map.md` - Modify: `AGENTS.md` - Modify: `CLAUDE.md` **Interfaces:** - Consumes validated behavior and commits from Tasks 1–8. - Produces durable project status and a Gitea issue closeout linked to the implementing commits/PR. - [ ] **Step 1: Write the documentation delta** Keep the matching `AGENTS.md` and `CLAUDE.md` completion rule: every module addition or behavior change must include authenticated v1 API/OpenAPI coverage, API-backed assistant tools, and the reminder lifecycle for user-visible due dates. Add a concise `STATUS.md` Done entry stating: household-scoped Pets module, profile/gallery, appointments/vaccinations/prescriptions, automatic appointment/vaccination/prescription reminder lifecycle, explicit one-time calendar bridge, private-by-default data, authenticated v1 API/OpenAPI contract, assistant tools, manifest/quick-add/dashboard registration, migration number, and validation commands. Change the `docs/issues-map.md` #32 row from a bare epic title to `Pets module — complete` with the Gitea URL unchanged. - [ ] **Step 2: Verify documentation and repository state** Run: `git diff --check && git status --short && pnpm format:check` Expected: no whitespace errors, only intended tracked files, and formatting passes. Preserve `.codex-remote-attachments/` as untracked. - [ ] **Step 3: Commit documentation** ```powershell git add AGENTS.md CLAUDE.md STATUS.md docs/issues-map.md git commit -m "docs(pets): record module completion" ``` - [ ] **Step 4: Push and close Gitea issue #32 only after live verification** Push the feature branch, open/merge the appropriate Gitea PR, verify the deployed `/pets` page with an authenticated browser session, then close `ginnoir/famapp#32` with the merged commit or PR reference. Do not close #33–#38. ## Plan Self-Review - **Spec coverage:** profile CRUD/photos are Tasks 1, 2, and 7; appointments, shots, prescriptions and their reminder lifecycles are Tasks 1–3 and 7–8; the explicit calendar bridge is Task 3; authenticated REST/API documentation is Task 4; assistant tools and write safety are Task 5; manifest/nav/widget/quick-add are Task 6; the requested E2E happy path is Tasks 6–8. - **Intentional scope limits:** no public sharing, external vet/geocoding integrations, automated Calendar event synchronization after creation, or list bridge. Those are not required by #32 and each needs a separately designed issue. - **Consistency:** every child record carries `petId` and `householdId`; UI, v1 API, and the assistant share `ForScope` services; appointment reminders transfer to the linked Calendar event exactly once; vaccination/prescription reminders use DST-safe household-local time; dates are serialized as ISO values in DTOs.