feat: add garden module infrastructure (task 70)
- Add MinIO service to compose.yaml with named volume - Add generic /api/uploads POST+GET route with auth, 5 MB, image/* guards - Add src/lib/minio.ts singleton client with ensureBucket helper - Add garden Drizzle schema: containers, plants, care_logs, care_schedules, species_cache - Register garden module in src/modules/index.ts and src/lib/db.ts - Apply migration 0015_garden_schema.sql - Add MINIO_* and PERENUAL_API_KEY to .env.example - Add task briefs 70-75 for the full garden feature arc - Add uploads.spec.ts E2E test (RED → GREEN on route auth guard)
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
# 70 — Garden infrastructure (MinIO + upload route + schema)
|
||||
|
||||
## Goal
|
||||
|
||||
Lay the infrastructure foundation for the garden module: add a MinIO object-storage container to the compose stack, create a generic file-upload API route, define the Drizzle schema for all four garden tables, and run the migration.
|
||||
|
||||
No UI or business logic — just the plumbing that every subsequent garden task depends on.
|
||||
|
||||
## Depends on
|
||||
|
||||
- 03 (Drizzle + Postgres), 07 (household seed)
|
||||
|
||||
## Scope
|
||||
|
||||
### compose.yaml additions (`deploy/compose.yaml`)
|
||||
|
||||
Add a `minio` service and a `garden-uploads` named volume:
|
||||
|
||||
```yaml
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||
volumes:
|
||||
- garden-uploads:/data
|
||||
ports:
|
||||
- "9000:9000" # API
|
||||
- "9001:9001" # Console (dev only — restrict in prod)
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
garden-uploads:
|
||||
```
|
||||
|
||||
Add `MINIO_ENDPOINT`, `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`, `MINIO_BUCKET` to `.env.example`.
|
||||
|
||||
### MinIO client (`src/lib/minio.ts`)
|
||||
|
||||
Thin singleton using the `minio` npm package:
|
||||
|
||||
- Connect using `MINIO_ENDPOINT`, `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`.
|
||||
- Export `minioClient` and `MINIO_BUCKET` constant.
|
||||
- On first use, create the bucket if it does not exist (`bucketExists` + `makeBucket`).
|
||||
|
||||
### Upload API route (`src/app/api/uploads/route.ts`)
|
||||
|
||||
- `POST /api/uploads` — accepts `multipart/form-data` with a single `file` field.
|
||||
- Validates: authenticated session required (401 if not); max file size 5 MB (413 if exceeded); MIME type must match `image/*` (415 if not).
|
||||
- Generates a storage key: `garden/<householdId>/<randomUUID>.<ext>`.
|
||||
- Streams to MinIO via `putObject`.
|
||||
- Returns `{ url: "/api/uploads/<key>" }`.
|
||||
- `GET /api/uploads/[...key]` — proxies the object back from MinIO using `getObject`. Sets `Cache-Control: public, max-age=31536000, immutable`.
|
||||
|
||||
This route is generic — not garden-specific. Other modules can reuse it.
|
||||
|
||||
### Schema (`src/modules/garden/schema.ts`)
|
||||
|
||||
Four tables, all `household_id`-scoped:
|
||||
|
||||
**`garden_containers`**
|
||||
|
||||
- `id` uuid pk
|
||||
- `household_id` fk households cascade
|
||||
- `name` text not null
|
||||
- `type` text not null default `'other'` — free text, convention: `'shelf' | 'terrarium' | 'raised-bed' | 'window-box' | 'single-pot' | 'outdoor' | 'other'`
|
||||
- `location_notes` text nullable
|
||||
- `cover_image_url` text nullable
|
||||
- `created_at`, `updated_at` timestamptz
|
||||
|
||||
Index on `household_id`.
|
||||
|
||||
**`garden_plants`**
|
||||
|
||||
- `id` uuid pk
|
||||
- `household_id` fk households cascade
|
||||
- `container_id` uuid nullable fk `garden_containers` set-null on delete
|
||||
- `name` text not null
|
||||
- `scientific_name` text nullable
|
||||
- `species_id` text nullable — external Perenual species ID, stored as string
|
||||
- `category` text not null default `'other'` — free text, convention: `'succulent' | 'tropical' | 'herb' | 'vegetable' | 'tree' | 'flower' | 'other'`
|
||||
- `notes` text nullable
|
||||
- `acquisition_date` date nullable
|
||||
- `growth_stage` text nullable — convention: `'seedling' | 'juvenile' | 'mature' | 'flowering' | 'fruiting' | 'dormant'`
|
||||
- `health_status` text not null default `'healthy'` — convention: `'healthy' | 'stressed' | 'sick' | 'dormant'`
|
||||
- `sunlight` text nullable
|
||||
- `watering_notes` text nullable
|
||||
- `fertilizing_notes` text nullable
|
||||
- `primary_image_url` text nullable
|
||||
- `images` jsonb not null default `'[]'` — typed as `string[]`, stores upload URL paths
|
||||
- `created_at`, `updated_at` timestamptz
|
||||
|
||||
Indexes on `(household_id)`, `(household_id, container_id)`.
|
||||
|
||||
**`garden_care_logs`**
|
||||
|
||||
- `id` uuid pk
|
||||
- `plant_id` uuid fk `garden_plants` cascade
|
||||
- `household_id` fk households cascade
|
||||
- `care_type` text not null — convention: `'watered' | 'fertilized' | 'repotted' | 'pruned' | 'misted' | 'inspected' | 'treated' | 'propagated' | 'custom'`
|
||||
- `performed_by` uuid fk users set-null on delete
|
||||
- `notes` text nullable
|
||||
- `performed_at` timestamptz not null default now()
|
||||
- `created_at` timestamptz
|
||||
|
||||
Indexes on `(plant_id, performed_at desc)`, `(household_id)`.
|
||||
|
||||
**`garden_care_schedules`**
|
||||
|
||||
- `id` uuid pk
|
||||
- `plant_id` uuid fk `garden_plants` cascade
|
||||
- `household_id` fk households cascade
|
||||
- `care_type` text not null
|
||||
- `interval_days` int not null
|
||||
- `last_performed_at` timestamptz nullable
|
||||
- `next_due_at` timestamptz nullable — recomputed after every care log entry
|
||||
- `enabled` boolean not null default true
|
||||
- `created_at`, `updated_at` timestamptz
|
||||
|
||||
Unique index on `(plant_id, care_type)` — one schedule per plant per care type.
|
||||
Index on `(household_id, next_due_at)` for dashboard queries.
|
||||
|
||||
### Drizzle migration
|
||||
|
||||
Generate and commit under `drizzle/`. Run `pnpm db:migrate` to apply.
|
||||
|
||||
### Module scaffold (`src/modules/garden/`)
|
||||
|
||||
Create the directory with stubs:
|
||||
|
||||
- `schema.ts` (complete, from above)
|
||||
- `manifest.tsx` (minimal stub: `id: "garden"`, `name: "Garden"`, empty `entities: []`)
|
||||
- `server/actions.ts` (empty stub)
|
||||
- `server/queries.ts` (empty stub)
|
||||
|
||||
Register in `src/modules/index.ts`:
|
||||
|
||||
```typescript
|
||||
import gardenManifest from "./garden/manifest";
|
||||
registerModule(gardenManifest);
|
||||
```
|
||||
|
||||
### Environment
|
||||
|
||||
Add to `.env.example`:
|
||||
|
||||
```
|
||||
MINIO_ENDPOINT=http://localhost:9000
|
||||
MINIO_ROOT_USER=famapp
|
||||
MINIO_ROOT_PASSWORD=changeme
|
||||
MINIO_BUCKET=garden
|
||||
PERENUAL_API_KEY= # filled in task 72
|
||||
```
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any garden-specific UI.
|
||||
- Perenual API integration (task 72).
|
||||
- Care logic (tasks 73–74).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `docker compose up minio` starts and passes health-check.
|
||||
- [ ] `POST /api/uploads` rejects unauthenticated requests with 401.
|
||||
- [ ] `POST /api/uploads` rejects files > 5 MB with 413.
|
||||
- [ ] `POST /api/uploads` accepts a valid JPEG and returns `{ url: "..." }`; `GET` of that URL returns the image bytes.
|
||||
- [ ] `pnpm db:migrate` applies cleanly; all four tables exist with the correct columns.
|
||||
- [ ] `garden` module appears in `/debug/registry`.
|
||||
@@ -0,0 +1,82 @@
|
||||
# 71 — Garden containers
|
||||
|
||||
## Goal
|
||||
|
||||
Implement full CRUD for **containers** — the grouping layer above individual plants (shelves, terrariums, raised beds, etc.). A container has a name, type, location notes, and an optional cover photo.
|
||||
|
||||
## Depends on
|
||||
|
||||
- 70 (garden infrastructure — schema, MinIO, upload route)
|
||||
|
||||
## Scope
|
||||
|
||||
### Server (`src/modules/garden/server/`)
|
||||
|
||||
Add to `actions.ts`:
|
||||
|
||||
- `createContainer(input: { name, type, locationNotes?, coverImageUrl? })` — Zod-validated, `getCurrentSession`, insert, `logActivity`, `revalidatePath("/garden")`.
|
||||
- `updateContainer(input: { id, name?, type?, locationNotes?, coverImageUrl? })` — patch, household membership check, logActivity.
|
||||
- `deleteContainer(input: { id })` — household membership check; plants with this `container_id` have it set to null (handled by schema `ON DELETE SET NULL`); logActivity; revalidatePath.
|
||||
|
||||
Add to `queries.ts`:
|
||||
|
||||
- `listContainers()` — returns all containers for the current household with a computed `plantCount`.
|
||||
- `getContainer(id)` — returns the container + all its plants (with last-watered date derived from care logs — use a lateral join or subquery).
|
||||
|
||||
### UI (`src/modules/garden/components/`)
|
||||
|
||||
**`container-list.tsx`** (server component)
|
||||
|
||||
- Grid of cards. Each card: cover photo (placeholder icon if none), name, type badge, plant count.
|
||||
- "New container" button → sheet/dialog.
|
||||
|
||||
**`container-detail.tsx`** (server component)
|
||||
|
||||
- Header: cover photo, name, type, location notes, edit/delete buttons.
|
||||
- Plant grid (placeholder for now — task 72 fills in the plant cards).
|
||||
- "+ Add plant to this container" button — links to `/garden/plants/new?containerId=<id>`.
|
||||
|
||||
**`container-form.tsx`** (client component)
|
||||
|
||||
- Fields: name (required), type (select — 7 options), location notes (textarea), cover photo (file input → `POST /api/uploads` → stores returned URL).
|
||||
- Used for both create and edit via an optional `existing` prop.
|
||||
|
||||
### Pages (`src/app/garden/`)
|
||||
|
||||
- `page.tsx` — `/garden` root page: two tabs — "Plants" (placeholder for task 72) and "Containers". Containers tab renders `<ContainerList />`.
|
||||
- `containers/[id]/page.tsx` — renders `<ContainerDetail />`.
|
||||
- `containers/new/page.tsx` — renders `<ContainerForm />` in create mode; redirects to `/garden` on success.
|
||||
|
||||
### Nav
|
||||
|
||||
Add `/garden` to the bottom nav and sidebar. Use the `sprout` Lucide icon (or nearest available). Register in the garden manifest:
|
||||
|
||||
```typescript
|
||||
nav: { href: "/garden", label: "Garden", icon: "sprout" }
|
||||
```
|
||||
|
||||
### Activity log
|
||||
|
||||
`renderActivity` for `garden.container` entity type in the manifest:
|
||||
|
||||
- `create` → `Created container "${name}"`
|
||||
- `update` → `Updated container "${name}"`
|
||||
- `delete` → `Deleted container`
|
||||
|
||||
### Share links
|
||||
|
||||
Containers are shareable (read-only). Add `loadForShare` and `renderSharedView` to the entity registration so a share link shows the container's name, type, location notes, cover photo, and plant names. Add `loadContainerForShare(id)` in `src/modules/garden/server/share-queries.ts`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Plant cards inside the container detail (task 72 fills those in).
|
||||
- Care tracking (tasks 73–74).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Create / edit / delete containers works end-to-end.
|
||||
- [ ] Cover photo uploads to MinIO and renders on the card and detail page.
|
||||
- [ ] Deleting a container nullifies `container_id` on its plants rather than deleting the plants.
|
||||
- [ ] `/garden` page renders with a Containers tab.
|
||||
- [ ] `garden.container` entity is registered with share support in the manifest.
|
||||
- [ ] Activity log entries appear for create / update / delete.
|
||||
@@ -0,0 +1,117 @@
|
||||
# 72 — Garden plants
|
||||
|
||||
## Goal
|
||||
|
||||
Implement full CRUD for individual **plants**. Each plant has a name, category, care notes, health status, up to 10 photos, an optional container assignment, and species data pulled from the **Perenual API** when the user searches for a known species.
|
||||
|
||||
## Depends on
|
||||
|
||||
- 70 (schema, MinIO, upload route), 71 (containers — container assignment picker)
|
||||
|
||||
## Scope
|
||||
|
||||
### Perenual API wrapper (`src/modules/garden/server/species-lookup.ts`)
|
||||
|
||||
Perenual (perenual.com) provides a free-tier plant species API. Key endpoint: `GET /api/species-list?key=<KEY>&q=<query>`.
|
||||
|
||||
Implement:
|
||||
|
||||
```typescript
|
||||
export type SpeciesSuggestion = {
|
||||
id: string;
|
||||
common_name: string;
|
||||
scientific_name: string;
|
||||
watering: string; // e.g. "frequent", "average", "minimum"
|
||||
sunlight: string[]; // e.g. ["full sun", "part shade"]
|
||||
cycle: string; // e.g. "Perennial"
|
||||
default_image_url: string | null;
|
||||
};
|
||||
|
||||
export async function searchSpecies(query: string): Promise<SpeciesSuggestion[]>;
|
||||
export async function getSpeciesById(id: string): Promise<SpeciesSuggestion | null>;
|
||||
```
|
||||
|
||||
Cache results for 24 hours in a `garden_species_cache` table (columns: `species_id` text pk, `data` jsonb, `cached_at` timestamptz). Add this table to the schema in `src/modules/garden/schema.ts` and include it in the migration from task 70 (or add a new migration).
|
||||
|
||||
If the API is unreachable or returns an error, log the failure and return `[]` / `null`. `PERENUAL_API_KEY` from env; if absent, `searchSpecies` returns `[]` silently so the app works without the key.
|
||||
|
||||
### Server (`src/modules/garden/server/`)
|
||||
|
||||
Add to `actions.ts`:
|
||||
|
||||
- `createPlant(input)` — Zod-validated, `getCurrentSession`, insert, `logActivity`, `revalidatePath("/garden")`.
|
||||
- `updatePlant(input: { id, ...partials })` — patch, household check, logActivity.
|
||||
- `deletePlant(input: { id })` — household check, logActivity; care logs + schedules cascade-delete via DB.
|
||||
- `addPlantImage(input: { id, url })` — appends URL to `images` jsonb array; reject if already 10 images.
|
||||
- `removePlantImage(input: { id, url })` — removes URL from array; if it was `primary_image_url`, set primary to first remaining or null.
|
||||
- `setPrimaryImage(input: { id, url })` — sets `primary_image_url`; url must already be in `images`.
|
||||
|
||||
Add to `queries.ts`:
|
||||
|
||||
- `listPlants({ containerId? })` — all plants in household optionally filtered by container. Include last-care timestamps per type via a lateral subquery.
|
||||
- `getPlant(id)` — full plant data + container name + care log summary (last 5 entries) + active schedules.
|
||||
|
||||
### UI (`src/modules/garden/components/`)
|
||||
|
||||
**`plant-list.tsx`** (server component)
|
||||
|
||||
- Grouped by container first; "Unassigned" group for plants with no container.
|
||||
- Each card: primary image thumbnail, name, health badge, "last watered X days ago", overdue care indicator.
|
||||
- Replaces the placeholder in the "Plants" tab on `/garden/page.tsx` from task 71.
|
||||
|
||||
**`plant-detail.tsx`** (server component)
|
||||
|
||||
- Three-tab layout: **Info**, **Gallery**, **Care** (Care tab filled in by task 73).
|
||||
- Info tab: all fields, container link, health/stage badges, species info.
|
||||
- Gallery tab: photo grid, primary image star-toggle, delete individual image, upload new image.
|
||||
|
||||
**`plant-form.tsx`** (client component)
|
||||
|
||||
- Fields: name (required), category (select), container (select, nullable), health status (select), growth stage (select).
|
||||
- Species search combobox: debounced search → `searchSpecies` → select → auto-fills scientific name, sunlight, watering notes. User can override.
|
||||
- Care notes textareas: watering notes, fertilizing notes, general notes.
|
||||
- Acquisition date picker.
|
||||
- Image uploader: drag-and-drop or click, previews, max 10. First uploaded image auto-set as primary.
|
||||
- `containerId` query-param pre-fill (from task 71 "Add plant to container" button).
|
||||
|
||||
**`species-search.tsx`** (client component)
|
||||
|
||||
- Debounced combobox calling a `/api/garden/species-search` route handler. Fires `onSelect(suggestion)` on pick.
|
||||
|
||||
### Pages (`src/app/garden/`)
|
||||
|
||||
- `plants/new/page.tsx` — `<PlantForm />` in create mode.
|
||||
- `plants/[id]/page.tsx` — `<PlantDetail />` with edit/delete controls.
|
||||
- `plants/[id]/edit/page.tsx` — `<PlantForm />` in edit mode.
|
||||
|
||||
### Activity log
|
||||
|
||||
`renderActivity` for `garden.plant`:
|
||||
|
||||
- `create` → `Added plant "${name}"`
|
||||
- `update` → `Updated "${name}"`
|
||||
- `delete` → `Removed plant "${name}"`
|
||||
|
||||
### Search
|
||||
|
||||
Register `search` on the `garden.plant` entity using `ilike` on `name || ' ' || coalesce(scientific_name, '')`.
|
||||
|
||||
### Share links
|
||||
|
||||
Plants are shareable (read-only). The shared view shows name, scientific name, primary image, gallery, health status, and care notes. Add `loadPlantForShare(id)` in `src/modules/garden/server/share-queries.ts`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Care log UI and schedules (task 73).
|
||||
- Dashboard widgets (task 75).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Create / edit / delete plants works end-to-end.
|
||||
- [ ] Species search returns Perenual results and auto-fills form fields on selection.
|
||||
- [ ] App works correctly via manual entry when `PERENUAL_API_KEY` is absent.
|
||||
- [ ] Up to 10 images upload; primary image can be set and changed; individual images can be removed.
|
||||
- [ ] Plant list on `/garden` groups by container with an "Unassigned" bucket.
|
||||
- [ ] `garden.plant` entity is registered with search and share support.
|
||||
- [ ] Activity log entries appear for create / update / delete.
|
||||
- [ ] Plant share link renders the read-only view correctly.
|
||||
@@ -0,0 +1,75 @@
|
||||
# 73 — Garden care tracking
|
||||
|
||||
## Goal
|
||||
|
||||
Implement the **care log** and **care schedule** systems. Users can log a care event for any plant (watered, fertilized, repotted, etc.), set up recurring schedules with an interval, and receive reminders when care is due.
|
||||
|
||||
## Depends on
|
||||
|
||||
- 70 (schema), 72 (plants exist to attach care records to)
|
||||
- 41 (reminders system — `scheduleReminder` / `cancelReminder`)
|
||||
|
||||
## Scope
|
||||
|
||||
### Care log actions (`src/modules/garden/server/actions.ts`)
|
||||
|
||||
- `logCare(input: { plantId, careType, notes?, performedAt? })` — Zod-validated; `performedAt` defaults to now(). Insert into `garden_care_logs`. After inserting: call `updateScheduleAfterCare(plantId, careType)`, call `logActivity`, `revalidatePath`.
|
||||
- `deleteCareLog(input: { id })` — household check, delete.
|
||||
|
||||
### Care schedule actions (`src/modules/garden/server/actions.ts`)
|
||||
|
||||
- `upsertCareSchedule(input: { plantId, careType, intervalDays, enabled? })` — upsert on `(plant_id, care_type)`. Recompute `next_due_at`: if `last_performed_at` exists use `last_performed_at + intervalDays`, else `now() + intervalDays`. Wire reminder: `scheduleReminder({ entityType: "garden.schedule", entityId: scheduleRow.id, fireAt: next_due_at })`.
|
||||
- `deleteCareSchedule(input: { id })` — `cancelReminder("garden.schedule", id)`, delete.
|
||||
- `toggleCareSchedule(input: { id, enabled })` — flip `enabled`; cancel reminder if disabling, reschedule if enabling.
|
||||
|
||||
### Schedule update helper (`src/modules/garden/server/care-schedule.ts`)
|
||||
|
||||
`updateScheduleAfterCare(plantId, careType)` — finds the schedule row, sets `last_performed_at = now()`, `next_due_at = now() + interval_days`, cancels old reminder, schedules new one.
|
||||
|
||||
Uses `entityType = "garden.schedule"` + `entityId = schedule.id` to satisfy the unique constraint on `(entity_type, entity_id)` in the reminders table — one reminder per schedule row, not per plant.
|
||||
|
||||
### Queries (`src/modules/garden/server/queries.ts`)
|
||||
|
||||
- `getCareLogs(plantId, limit?)` — most recent N logs ordered `performed_at desc`.
|
||||
- `getCareSchedules(plantId)` — all schedules with computed `daysUntilDue` and `isOverdue`.
|
||||
- `getOverduePlants(householdId)` — plants with at least one enabled schedule where `next_due_at < now()`, sorted most-overdue first.
|
||||
- `getCareDueSoon(householdId, withinDays)` — plants with care due within N days.
|
||||
|
||||
### UI (`src/modules/garden/components/`)
|
||||
|
||||
**`care-log-form.tsx`** (client component) — plant picker + care type select + optional notes + optional performed-at datetime (defaults to now). Used as a sheet and inline in the plant detail Care tab.
|
||||
|
||||
**`care-schedule-editor.tsx`** (client component) — list of active schedules per plant (care type, interval, next due, enabled toggle, delete). "Add schedule" form with care type + interval days.
|
||||
|
||||
**`care-history-list.tsx`** (server component) — chronological log entries: care type icon, "X days ago", performed-by avatar, notes.
|
||||
|
||||
### Plant detail Care tab (update `plant-detail.tsx` from task 72)
|
||||
|
||||
The Care tab (previously placeholder) renders: `<CareScheduleEditor />` → "Log care" button → `<CareHistoryList />`.
|
||||
|
||||
### Quick-add (update manifest)
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: "garden.log-care",
|
||||
label: "Log plant care",
|
||||
icon: "droplets",
|
||||
url: "/garden",
|
||||
}
|
||||
```
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Calendar/lists integration (task 74).
|
||||
- Dashboard widget (task 75).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Logging care inserts a log row and updates `last_performed_at` + `next_due_at` on the matching schedule.
|
||||
- [ ] Creating a schedule with interval 7 sets `next_due_at` to 7 days from now; a reminder is scheduled.
|
||||
- [ ] After logging care, old reminder is cancelled and a new one scheduled.
|
||||
- [ ] Disabling a schedule cancels its reminder; re-enabling reschedules it.
|
||||
- [ ] Deleting a schedule cancels its reminder.
|
||||
- [ ] `getOverduePlants` returns correctly sorted results.
|
||||
- [ ] Care tab in plant detail shows schedule editor, log form, and history.
|
||||
- [ ] "Log plant care" appears in the quick-add sheet.
|
||||
@@ -0,0 +1,83 @@
|
||||
# 74 — Garden integrations (calendar + lists)
|
||||
|
||||
## Goal
|
||||
|
||||
Wire the garden module into the two existing coordination modules:
|
||||
|
||||
1. **Calendar**: schedule a plant care event on the household calendar from within a care schedule row.
|
||||
2. **Lists**: push all overdue care tasks to the household task list in one tap.
|
||||
|
||||
## Depends on
|
||||
|
||||
- 73 (care schedules + overdue queries)
|
||||
- 10 (calendar module — `createEvent`)
|
||||
- 11 (lists module — `addItem`)
|
||||
|
||||
## Scope
|
||||
|
||||
### Calendar integration
|
||||
|
||||
#### Server action (`src/modules/garden/server/actions.ts`)
|
||||
|
||||
`scheduleOnCalendar(input: { scheduleId, calendarId, reminderMinutesBefore? })`:
|
||||
|
||||
- Look up the schedule + plant; household check.
|
||||
- `startAt` = `next_due_at` (reject with user-facing error if null).
|
||||
- `endAt` = `startAt + 30 min`; `allDay = false`.
|
||||
- Title convention: `"Water ${plant.name}"` / `"Fertilize ${plant.name}"` / `"Repot ${plant.name}"` / `"${careType} — ${plant.name}"` for other types.
|
||||
- Notes: `"Scheduled from garden. Next due: ${next_due_at.toLocaleDateString()}"`.
|
||||
- Call `createCalendarEvent(...)` from the bridge below.
|
||||
|
||||
#### Bridge file (`src/modules/garden/server/calendar-bridge.ts`)
|
||||
|
||||
```typescript
|
||||
export { createEvent as createCalendarEvent } from "@/modules/calendar/server/actions";
|
||||
export { listCalendars } from "@/modules/calendar/server/queries";
|
||||
```
|
||||
|
||||
This keeps the cross-module dependency explicit and swappable without touching `_core`.
|
||||
|
||||
#### UI
|
||||
|
||||
In `care-schedule-editor.tsx`, add an "Add to calendar" button per schedule row. Clicking it opens a popover with: calendar select (from `listCalendars`) + optional reminder-minutes input + "Schedule" button. Show a success toast linking to `/calendar`.
|
||||
|
||||
---
|
||||
|
||||
### Lists integration
|
||||
|
||||
#### Server action (`src/modules/garden/server/actions.ts`)
|
||||
|
||||
`pushOverdueToTaskList(): Promise<{ added: number }>`:
|
||||
|
||||
- Call `getOverduePlants(householdId)`.
|
||||
- Find the household default task list via the bridge below.
|
||||
- For each overdue `(plant, schedule)` pair insert a list item:
|
||||
- `text`: same title convention as the calendar integration.
|
||||
- `notes`: `"Overdue by ${daysOverdue} day(s)"`.
|
||||
- `dueAt`: `schedule.next_due_at`.
|
||||
- Skip pairs where an identical `text` item already exists in the list (prevent duplicates).
|
||||
- Return `{ added: N }`.
|
||||
|
||||
#### Bridge file (`src/modules/garden/server/lists-bridge.ts`)
|
||||
|
||||
```typescript
|
||||
export { addItem as addListItem } from "@/modules/lists/server/actions";
|
||||
export { listLists } from "@/modules/lists/server/queries";
|
||||
```
|
||||
|
||||
#### UI
|
||||
|
||||
Add an "Add overdue to task list" button to the `/garden` page header (available before the dashboard widget in task 75). Shows a toast: "Added N tasks to your task list." If N = 0, toast says "No overdue care tasks."
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Bi-directional sync (checking off a list item does not mark the plant as cared-for).
|
||||
- Recurring calendar events (garden creates single events only).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] "Add to calendar" creates a calendar event visible at `/calendar` with the correct title.
|
||||
- [ ] "Add overdue to task list" pushes one item per overdue schedule, skips existing duplicates.
|
||||
- [ ] Items appear in the task list at `/lists`.
|
||||
- [ ] Both actions enforce household scope.
|
||||
- [ ] Both are graceful no-ops when nothing is overdue / due.
|
||||
@@ -0,0 +1,144 @@
|
||||
# 75 — Garden dashboard, widgets, and manifest completion
|
||||
|
||||
## Goal
|
||||
|
||||
Complete the garden module: full manifest registration, two dashboard widgets, search integration, quick-add polish, and a Playwright happy-path test.
|
||||
|
||||
## Depends on
|
||||
|
||||
- 70–74 (all previous garden tasks)
|
||||
|
||||
## Scope
|
||||
|
||||
### Manifest completion (`src/modules/garden/manifest.tsx`)
|
||||
|
||||
Replace the task-70 stub with the full manifest.
|
||||
|
||||
**Entities:**
|
||||
|
||||
```typescript
|
||||
entities: [
|
||||
{
|
||||
type: "garden.container",
|
||||
label: { singular: "Container", plural: "Containers" },
|
||||
share: { canShare: true, defaultCapabilities: ["read"] },
|
||||
search: { search: searchContainers },
|
||||
resolveUrl: (id) => `/garden/containers/${id}`,
|
||||
loadForShare: loadContainerForShare,
|
||||
renderSharedView: ...,
|
||||
renderActivity: ...,
|
||||
},
|
||||
{
|
||||
type: "garden.plant",
|
||||
label: { singular: "Plant", plural: "Plants" },
|
||||
share: { canShare: true, defaultCapabilities: ["read"] },
|
||||
search: { search: searchPlants },
|
||||
resolveUrl: (id) => `/garden/plants/${id}`,
|
||||
loadForShare: loadPlantForShare,
|
||||
renderSharedView: ...,
|
||||
renderActivity: ...,
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
`garden.schedule` is internal (reminders only) — not registered as a shareable or searchable entity.
|
||||
|
||||
**Quick adds** (consolidate from tasks 71–73):
|
||||
|
||||
- `{ id: "garden.add-plant", label: "Add plant", icon: "leaf", url: "/garden/plants/new" }`
|
||||
- `{ id: "garden.add-container", label: "Add container", icon: "box", url: "/garden/containers/new" }`
|
||||
- `{ id: "garden.log-care", label: "Log plant care", icon: "droplets", url: "/garden?logCare=1" }`
|
||||
|
||||
**Nav**: `{ href: "/garden", label: "Garden", icon: "sprout" }`.
|
||||
|
||||
---
|
||||
|
||||
### Widget 1: `garden.care-due`
|
||||
|
||||
**Title**: "Plants needing care" | **Category**: "Garden"
|
||||
**Default size**: `{ w: 4, h: 4 }` | **Min size**: `{ w: 3, h: 2 }`
|
||||
|
||||
Config schema:
|
||||
|
||||
```typescript
|
||||
z.object({
|
||||
containerIds: z.union([z.literal("all"), z.array(z.string().uuid())]),
|
||||
daysAhead: z.number().int().min(0).max(30).default(0),
|
||||
});
|
||||
```
|
||||
|
||||
Default config: `{ containerIds: "all", daysAhead: 0 }`.
|
||||
|
||||
`resolveConfigOptions`: returns `{ containers: [{ id, name }] }`.
|
||||
|
||||
Render:
|
||||
|
||||
- Empty state if nothing is due: "All plants are on schedule."
|
||||
- Otherwise: compact list sorted by days overdue. Each row: plant name, care type icon, urgency badge ("X days overdue" / "due today"), inline "Log care" button (calls `logCare` action + revalidates — no navigation).
|
||||
- Truncate at 10 rows; "View all" link to `/garden`.
|
||||
- Rows with `next_due_at` within `daysAhead` days show as upcoming in a lighter style below overdue rows.
|
||||
|
||||
---
|
||||
|
||||
### Widget 2: `garden.overview`
|
||||
|
||||
**Title**: "Garden overview" | **Category**: "Garden"
|
||||
**Default size**: `{ w: 3, h: 2 }` | **Min size**: `{ w: 2, h: 2 }`
|
||||
|
||||
Config schema: `z.object({})`.
|
||||
|
||||
Render:
|
||||
|
||||
- Stat row: total plants, total containers, overdue care count.
|
||||
- "Next care" line: "Next: water [Plant] in N days" / "today" / "overdue".
|
||||
- Link to `/garden`.
|
||||
|
||||
---
|
||||
|
||||
### Widget component (`src/modules/garden/components/plant-widget.tsx`)
|
||||
|
||||
Server component implementing both widgets, following the pattern in `src/modules/lists/components/list-widget.tsx`.
|
||||
|
||||
---
|
||||
|
||||
### Search adapters
|
||||
|
||||
Implement `searchContainers` and `searchPlants` using `ilike` on name fields, returning `SearchResult[]`. Both appear in the command palette.
|
||||
|
||||
---
|
||||
|
||||
### Notification body improvement (optional)
|
||||
|
||||
If `tickReminders` in `src/modules/_core/reminders.ts` can be extended without structural changes — add a registry of entity-type resolvers and register `"garden.schedule"` to resolve to `"Time to water [plant name]"`. If it requires core changes, defer and note as known limitation.
|
||||
|
||||
---
|
||||
|
||||
### Playwright E2E test (`tests/garden.spec.ts`)
|
||||
|
||||
Happy path:
|
||||
|
||||
1. Log in as the seeded user.
|
||||
2. Create container "Living Room Shelf".
|
||||
3. Create plant "Pothos" in that container (manual entry, no species lookup).
|
||||
4. Add watering schedule: every 7 days.
|
||||
5. Log care: watered.
|
||||
6. Verify schedule `next_due_at` updates to ~7 days from now.
|
||||
7. Confirm plant appears grouped under "Living Room Shelf" on `/garden`.
|
||||
8. Add `garden.care-due` widget to the default dashboard.
|
||||
9. Confirm no overdue items (just watered).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Health trend charts.
|
||||
- Weather-based watering adjustments.
|
||||
- Social/community features.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Both widgets register and appear in the widget picker.
|
||||
- [ ] `garden.care-due` shows overdue plants sorted by urgency; inline log button works.
|
||||
- [ ] `garden.overview` shows correct counts.
|
||||
- [ ] Both entities appear in command palette search.
|
||||
- [ ] All three quick-adds appear in the `+` sheet.
|
||||
- [ ] Garden nav link appears in sidebar and bottom nav.
|
||||
- [ ] Playwright happy-path test passes.
|
||||
Reference in New Issue
Block a user