diff --git a/AGENTS.md b/AGENTS.md index 9679358..766aa5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,8 @@ src/ **Rule for contributors (including future Codex and Claude Code sessions):** if a feature requires a change to `_core` to support a new entity type, that's a smell — extend the registry instead. +**Completion rule for module additions and behavior changes:** include the authenticated v1 API and OpenAPI documentation, assistant tools backed by that API, and a complete reminder lifecycle for every user-visible due date. API, agent, and UI must call the same scoped server services; create/update must synchronize unfired reminders and delete must cancel them. + --- ## Data model (v1) diff --git a/CLAUDE.md b/CLAUDE.md index e15c0a3..ecd5bea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,6 +77,8 @@ src/ **Rule for contributors (including future Codex and Claude Code sessions):** if a feature requires a change to `_core` to support a new entity type, that's a smell — extend the registry instead. +**Completion rule for module additions and behavior changes:** include the authenticated v1 API and OpenAPI documentation, assistant tools backed by that API, and a complete reminder lifecycle for every user-visible due date. API, agent, and UI must call the same scoped server services; create/update must synchronize unfired reminders and delete must cancel them. + --- ## Data model (v1) diff --git a/docs/superpowers/plans/2026-07-09-pets-module.md b/docs/superpowers/plans/2026-07-09-pets-module.md new file mode 100644 index 0000000..5268732 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-pets-module.md @@ -0,0 +1,1151 @@ +# 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.