1152 lines
51 KiB
Markdown
1152 lines
51 KiB
Markdown
# 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<string[]>(),
|
||
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<typeof petInput>) {
|
||
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<typeof appointmentInput>,
|
||
) {
|
||
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<typeof scheduleAppointmentInput>,
|
||
) {
|
||
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) => <PetsOverviewWidgetServer {...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 <PetsOverviewWidget stats={await getPetsOverviewStats(ctx.householdId)} />;
|
||
}
|
||
```
|
||
|
||
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 (
|
||
<div className="page-content">
|
||
<DetailBackLink href="/pets" label="Pets" className="mb-4" />
|
||
<PetDetail pet={pet} calendars={calendars} />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
`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 && (
|
||
<button
|
||
onClick={() =>
|
||
startTransition(async () => {
|
||
await scheduleAppointmentOnCalendar({
|
||
appointmentId: appointment.id,
|
||
calendarId,
|
||
reminderMinutesBefore: 30,
|
||
});
|
||
router.refresh();
|
||
})
|
||
}
|
||
disabled={isPending || !calendarId}
|
||
>
|
||
Add to calendar
|
||
</button>
|
||
);
|
||
}
|
||
```
|
||
|
||
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.
|