Compare commits

...
16 Commits
Author SHA1 Message Date
ginnoir 298e6b868d chore: register migration 0016 in drizzle journal
Release / build-and-push (push) Has been cancelled
2026-06-02 00:27:10 -05:00
ginnoir 6d45b7e836 chore: bump version to 0.4.0
Release / build-and-push (push) Has been cancelled
2026-06-02 00:00:03 -05:00
ginnoir 7f2c5a44dd feat: garden care reminder bodies, task sync, upload cleanup
- care reminders now fire with plant-specific body ("Time to water Pothos")
  via title/body columns on the reminders table (migration 0016)
- bi-directional task sync: checking off a garden-linked task list item
  creates a care log; logging care from garden marks the linked task done
  (list_items.metadata stores gardenPlantId + gardenCareType linkage)
- upload error response no longer leaks debug detail; error message
  corrected from "5 MB" to "100 MB"
2026-06-01 23:58:00 -05:00
ginnoir dd980c6932 fix: garden ui polish - form styles, back nav, species dropdown, upload limit 2026-06-01 23:38:52 -05:00
ginnoir 6b25d67537 feat: garden dashboard widgets and manifest completion (task 75)
- getCareDueWidgetRows query: schedule rows due within N days, filtered
  by optional container list, ordered by next_due_at
- getGardenOverviewStats query: plant/container/overdue counts + next care
- CareDueWidget: compact list sorted by urgency, inline log-care button,
  truncates at 10 rows with View all link
- GardenOverviewWidget: stat row (plants, containers, overdue) + next care
  line + garden link
- plant-widget.tsx server component: InlineLogButton uses form action to
  call logCare without client JS or navigation
- manifest: garden.care-due and garden.overview widgets registered;
  resolveConfigOptions returns container list for care-due config picker
- quickAdds: icons updated (leaf/box/droplets), log-care URL ->
  /garden?logCare=1
2026-06-01 22:43:53 -05:00
ginnoir 080d26816e test: add reproducer for garden dashboard widgets (task 75) 2026-06-01 22:41:25 -05:00
ginnoir 0b0deff700 feat: garden calendar + lists integrations (task 74)
- calendar-bridge.ts and lists-bridge.ts isolate cross-module deps
- scheduleOnCalendar action: creates event from care schedule with 30-min
  slot, title convention (Water/Fertilize/Repot/Prune/custom), reminder opt
- pushOverdueToTaskList action: inserts one task per overdue (plant+schedule)
  pair into the default task list, skips duplicate text entries
- CareScheduleEditor: per-row calendar 📅 button opens inline form with
  calendar select + optional reminder minutes + success link to /calendar
- Garden page header: PushOverdueButton with auto-dismissing feedback toast
2026-06-01 20:33:15 -05:00
ginnoir eabcebdb00 test: add reproducer for garden integrations (task 74) 2026-06-01 20:30:18 -05:00
ginnoir 0fef3d4ab6 feat: garden care tracking - logs, schedules, reminders (task 73)
- logCare/deleteCareLog actions with schedule update and reminder wiring
- upsertCareSchedule/deleteCareSchedule/toggleCareSchedule actions
- updateScheduleAfterCare helper cancels old reminder, schedules new one
- getCareLogs/getCareSchedules/getOverduePlants/getCareDueSoon queries
- CareLogForm, CareScheduleEditor, CareHistoryList components
- Plant detail Care tab wired up with schedule editor + log form + history
- Log plant care quick-add added to garden manifest
2026-06-01 20:23:43 -05:00
ginnoir fe282ba470 test: add reproducer for garden care tracking (task 73) 2026-06-01 20:23:30 -05:00
ginnoir f3e38c576c feat: garden plants crud (task 72)
- perenual species lookup with 24h db cache (api key optional)
- /api/garden/species-search route handler
- createPlant / updatePlant / deletePlant server actions
- addPlantImage / removePlantImage / setPrimaryImage actions (max 10)
- listPlants query grouped by container with last-watered subquery
- getPlant query with care log summary and active schedules
- plant-form client component with species autofill and image upload
- plant-list server component grouped by container with unassigned bucket
- plant-detail client component with info/gallery/care tabs
- species-search debounced combobox component
- /garden/plants/new, /[id], /[id]/edit pages
- garden.plant entity registered with search and share support
- /garden page updated to containers/plants tabs (default: containers)
2026-06-01 20:13:20 -05:00
ginnoir a86f5471ce test: add reproducer for garden plants crud (task 72) 2026-06-01 20:04:22 -05:00
ginnoir 5f6b756342 feat: garden containers crud (task 71)
- add listContainers/getContainer/searchContainers queries
- add createContainer/updateContainer/deleteContainer server actions with logActivity
- add ContainerList, ContainerDetail, ContainerForm client components
- add /garden, /garden/containers/[id], /garden/containers/new pages
- register garden.container entity with share + search in manifest
- add sprout icon to NavIcon registry
- add garden e2e test spec
2026-06-01 19:28:34 -05:00
ginnoir 2fd0677c5f 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)
2026-06-01 19:23:19 -05:00
ginnoir 5dcd49d4c9 fix: remove duplicate changelog header and drop release-it header config 2026-06-01 18:47:47 -05:00
ginnoir 29795e0d51 chore: format pre-existing files missed by lint-staged 2026-06-01 18:46:53 -05:00
61 changed files with 5328 additions and 28 deletions
+9
View File
@@ -33,3 +33,12 @@ LOG_LEVEL=info
# GitHub (required for pnpm release — creates a GitHub Release)
GITHUB_TOKEN=
# MinIO object storage (used for plant/container image uploads)
MINIO_ENDPOINT=http://localhost:9000
MINIO_ROOT_USER=famapp
MINIO_ROOT_PASSWORD=changeme
MINIO_BUCKET=garden
# Perenual plant species API (https://perenual.com — free tier available)
PERENUAL_API_KEY=
+1 -2
View File
@@ -26,8 +26,7 @@
{ "type": "test", "section": "Tests", "hidden": true }
]
},
"infile": "CHANGELOG.md",
"header": "# Changelog\n\nAll notable changes to famapp are documented here.\n"
"infile": "CHANGELOG.md"
}
}
}
-6
View File
@@ -18,12 +18,6 @@ All notable changes to famapp are documented here.
- add github_token to .env.example ([5630537](https://github.com/ginnoir/famapp/commit/563053727908186a9816416fdf54c914002011de))
# Changelog
All notable changes to famapp are documented here.
<!-- release-it prepends new entries above this line -->
## Pre-release history
Changes from before the release workflow was established (2026-06-01):
+14
View File
@@ -11,5 +11,19 @@ services:
volumes:
- famapp-db-data:/var/lib/postgresql/data
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
restart: unless-stopped
environment:
MINIO_ROOT_USER: famapp
MINIO_ROOT_PASSWORD: changeme
ports:
- "9000:9000"
- "9001:9001"
volumes:
- famapp-minio-data:/data
volumes:
famapp-db-data:
famapp-minio-data:
+6 -5
View File
@@ -42,11 +42,11 @@ pnpm dev:local
The script prints three URLs at startup:
| URL | Use for |
|-----|---------|
| `http://localhost:3000` | Browser on this machine |
| `http://192.168.1.74:3000` | Phone on the same WiFi (general UI testing) |
| `https://dev.ginnoir.com` | Push notifications + PWA install (needs Caddy — see below) |
| URL | Use for |
| -------------------------- | ---------------------------------------------------------- |
| `http://localhost:3000` | Browser on this machine |
| `http://192.168.1.74:3000` | Phone on the same WiFi (general UI testing) |
| `https://dev.ginnoir.com` | Push notifications + PWA install (needs Caddy — see below) |
Then open `/login` and click **Dev login**.
@@ -55,6 +55,7 @@ Then open `/login` and click **Dev login**.
`next dev` has hot module replacement — most `.ts`/`.tsx` changes apply instantly without a restart.
A full restart (`Ctrl+C``pnpm dev:local`) is needed for:
- `.env` changes
- `next.config.ts` changes
+173
View File
@@ -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 7374).
## 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`.
+82
View File
@@ -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 7374).
## 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.
+117
View File
@@ -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.
+75
View File
@@ -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.
+83
View File
@@ -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.
+144
View File
@@ -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
- 7074 (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 7173):
- `{ 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.
+74
View File
@@ -0,0 +1,74 @@
-- Task 70: Garden module — containers, plants, care logs, care schedules, species cache
CREATE TABLE "garden_containers" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"household_id" uuid NOT NULL REFERENCES "households"("id") ON DELETE CASCADE,
"name" text NOT NULL,
"type" text NOT NULL DEFAULT 'other',
"location_notes" text,
"cover_image_url" text,
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
);
CREATE INDEX "garden_containers_household_idx" ON "garden_containers" ("household_id");
CREATE TABLE "garden_plants" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"household_id" uuid NOT NULL REFERENCES "households"("id") ON DELETE CASCADE,
"container_id" uuid REFERENCES "garden_containers"("id") ON DELETE SET NULL,
"name" text NOT NULL,
"scientific_name" text,
"species_id" text,
"category" text NOT NULL DEFAULT 'other',
"notes" text,
"acquisition_date" date,
"growth_stage" text,
"health_status" text NOT NULL DEFAULT 'healthy',
"sunlight" text,
"watering_notes" text,
"fertilizing_notes" text,
"primary_image_url" text,
"images" jsonb NOT NULL DEFAULT '[]',
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
);
CREATE INDEX "garden_plants_household_idx" ON "garden_plants" ("household_id");
CREATE INDEX "garden_plants_household_container_idx" ON "garden_plants" ("household_id", "container_id");
CREATE TABLE "garden_care_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plant_id" uuid NOT NULL REFERENCES "garden_plants"("id") ON DELETE CASCADE,
"household_id" uuid NOT NULL REFERENCES "households"("id") ON DELETE CASCADE,
"care_type" text NOT NULL,
"performed_by" uuid REFERENCES "users"("id") ON DELETE SET NULL,
"notes" text,
"performed_at" timestamp with time zone NOT NULL DEFAULT now(),
"created_at" timestamp with time zone NOT NULL DEFAULT now()
);
CREATE INDEX "garden_care_logs_plant_performed_idx" ON "garden_care_logs" ("plant_id", "performed_at");
CREATE INDEX "garden_care_logs_household_idx" ON "garden_care_logs" ("household_id");
CREATE TABLE "garden_care_schedules" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plant_id" uuid NOT NULL REFERENCES "garden_plants"("id") ON DELETE CASCADE,
"household_id" uuid NOT NULL REFERENCES "households"("id") ON DELETE CASCADE,
"care_type" text NOT NULL,
"interval_days" integer NOT NULL,
"last_performed_at" timestamp with time zone,
"next_due_at" timestamp with time zone,
"enabled" boolean NOT NULL DEFAULT true,
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX "garden_care_schedules_plant_type_uq" ON "garden_care_schedules" ("plant_id", "care_type");
CREATE INDEX "garden_care_schedules_household_due_idx" ON "garden_care_schedules" ("household_id", "next_due_at");
CREATE TABLE "garden_species_cache" (
"species_id" text PRIMARY KEY NOT NULL,
"data" jsonb NOT NULL,
"cached_at" timestamp with time zone NOT NULL DEFAULT now()
);
+6
View File
@@ -0,0 +1,6 @@
-- Add title/body to reminders for rich notification bodies
ALTER TABLE reminders ADD COLUMN IF NOT EXISTS title text;
ALTER TABLE reminders ADD COLUMN IF NOT EXISTS body text;
-- Add metadata to list_items for garden task linking
ALTER TABLE list_items ADD COLUMN IF NOT EXISTS metadata jsonb;
+14
View File
@@ -106,6 +106,20 @@
"when": 1778600000000,
"tag": "0014_paper_ink_theme",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1748736000000,
"tag": "0015_garden_schema",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1748822400000,
"tag": "0016_garden_improvements",
"breakpoints": true
}
]
}
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "famapp",
"version": "0.3.0",
"version": "0.4.0",
"private": true,
"type": "module",
"packageManager": "pnpm@10.33.3",
@@ -86,6 +86,7 @@
"cmdk": "^1.1.1",
"drizzle-orm": "^0.45.2",
"lucide-react": "^1.14.0",
"minio": "^8.0.7",
"next": "^15.5.15",
"next-auth": "5.0.0-beta.31",
"pino": "^10.3.1",
+188
View File
@@ -44,6 +44,9 @@ importers:
lucide-react:
specifier: ^1.14.0
version: 1.14.0(react@19.2.5)
minio:
specifier: ^8.0.7
version: 8.0.7
next:
specifier: ^15.5.15
version: 15.5.15(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -1430,6 +1433,9 @@ packages:
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
'@nodable/entities@2.1.1':
resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -2175,6 +2181,9 @@ packages:
async-retry@1.3.3:
resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==}
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
@@ -2210,6 +2219,9 @@ packages:
before-after-hook@4.0.0:
resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==}
block-stream2@2.1.0:
resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==}
bn.js@4.12.3:
resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==}
@@ -2228,11 +2240,18 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
browser-or-node@2.1.1:
resolution: {integrity: sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==}
browserslist@4.28.2:
resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer-crc32@1.0.0:
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
engines: {node: '>=8.0.0'}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
@@ -2520,6 +2539,10 @@ packages:
supports-color:
optional: true
decode-uri-component@0.2.2:
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'}
dedent@1.7.2:
resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==}
peerDependencies:
@@ -3019,6 +3042,13 @@ packages:
fast-wrap-ansi@0.2.0:
resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==}
fast-xml-builder@1.2.0:
resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==}
fast-xml-parser@5.8.0:
resolution: {integrity: sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==}
hasBin: true
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
@@ -3050,6 +3080,10 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
filter-obj@1.1.0:
resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==}
engines: {node: '>=0.10.0'}
finalhandler@2.1.1:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'}
@@ -3341,6 +3375,10 @@ packages:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
ipaddr.js@2.4.0:
resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==}
engines: {node: '>= 10'}
is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
@@ -3736,6 +3774,9 @@ packages:
lodash.uniqby@4.7.0:
resolution: {integrity: sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==}
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
log-symbols@6.0.0:
resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==}
engines: {node: '>=18'}
@@ -3801,10 +3842,18 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-db@1.54.0:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
mime-types@3.0.2:
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
engines: {node: '>=18'}
@@ -3830,6 +3879,10 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minio@8.0.7:
resolution: {integrity: sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==}
engines: {node: ^16 || ^18 || >=20}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -4084,6 +4137,10 @@ packages:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
path-expression-matcher@1.5.0:
resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==}
engines: {node: '>=14.0.0'}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
@@ -4236,6 +4293,10 @@ packages:
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
engines: {node: '>=0.6'}
query-string@7.1.3:
resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==}
engines: {node: '>=6'}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -4426,6 +4487,10 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
sax@1.6.0:
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
engines: {node: '>=11.0.0'}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -4555,6 +4620,10 @@ packages:
spdx-license-ids@3.0.23:
resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==}
split-on-first@1.1.0:
resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==}
engines: {node: '>=6'}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
@@ -4578,9 +4647,19 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
stream-chain@2.2.5:
resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==}
stream-json@1.9.1:
resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==}
strict-event-emitter@0.5.1:
resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
strict-uri-encode@2.0.0:
resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==}
engines: {node: '>=4'}
string-argv@0.3.2:
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
engines: {node: '>=0.6.19'}
@@ -4655,6 +4734,9 @@ packages:
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
engines: {node: '>=14.16'}
strnum@2.3.0:
resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==}
styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'}
@@ -4694,6 +4776,9 @@ packages:
resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==}
engines: {node: '>=20'}
through2@4.0.2:
resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -4963,6 +5048,18 @@ packages:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'}
xml-naming@0.1.0:
resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==}
engines: {node: '>=16.0.0'}
xml2js@0.6.2:
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
engines: {node: '>=4.0.0'}
xmlbuilder@11.0.1:
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
engines: {node: '>=4.0'}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -6076,6 +6173,8 @@ snapshots:
'@noble/hashes@1.8.0': {}
'@nodable/entities@2.1.1': {}
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -6785,6 +6884,8 @@ snapshots:
dependencies:
retry: 0.13.1
async@3.2.6: {}
atomic-sleep@1.0.0: {}
available-typed-arrays@1.0.7:
@@ -6805,6 +6906,10 @@ snapshots:
before-after-hook@4.0.0: {}
block-stream2@2.1.0:
dependencies:
readable-stream: 3.6.2
bn.js@4.12.3: {}
body-parser@2.2.2:
@@ -6834,6 +6939,8 @@ snapshots:
dependencies:
fill-range: 7.1.1
browser-or-node@2.1.1: {}
browserslist@4.28.2:
dependencies:
baseline-browser-mapping: 2.10.27
@@ -6842,6 +6949,8 @@ snapshots:
node-releases: 2.0.38
update-browserslist-db: 1.2.3(browserslist@4.28.2)
buffer-crc32@1.0.0: {}
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
@@ -7114,6 +7223,8 @@ snapshots:
dependencies:
ms: 2.1.3
decode-uri-component@0.2.2: {}
dedent@1.7.2: {}
deep-is@0.1.4: {}
@@ -7766,6 +7877,19 @@ snapshots:
dependencies:
fast-string-width: 3.0.2
fast-xml-builder@1.2.0:
dependencies:
path-expression-matcher: 1.5.0
xml-naming: 0.1.0
fast-xml-parser@5.8.0:
dependencies:
'@nodable/entities': 2.1.1
fast-xml-builder: 1.2.0
path-expression-matcher: 1.5.0
strnum: 2.3.0
xml-naming: 0.1.0
fastq@1.20.1:
dependencies:
reusify: 1.1.0
@@ -7795,6 +7919,8 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
filter-obj@1.1.0: {}
finalhandler@2.1.1:
dependencies:
debug: 4.4.3
@@ -8085,6 +8211,8 @@ snapshots:
ipaddr.js@1.9.1: {}
ipaddr.js@2.4.0: {}
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.9
@@ -8429,6 +8557,8 @@ snapshots:
lodash.uniqby@4.7.0: {}
lodash@4.18.1: {}
log-symbols@6.0.0:
dependencies:
chalk: 5.6.2
@@ -8486,8 +8616,14 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.2
mime-db@1.52.0: {}
mime-db@1.54.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
mime-types@3.0.2:
dependencies:
mime-db: 1.54.0
@@ -8508,6 +8644,22 @@ snapshots:
minimist@1.2.8: {}
minio@8.0.7:
dependencies:
async: 3.2.6
block-stream2: 2.1.0
browser-or-node: 2.1.1
buffer-crc32: 1.0.0
eventemitter3: 5.0.4
fast-xml-parser: 5.8.0
ipaddr.js: 2.4.0
lodash: 4.18.1
mime-types: 2.1.35
query-string: 7.1.3
stream-json: 1.9.1
through2: 4.0.2
xml2js: 0.6.2
ms@2.1.3: {}
msw@2.14.3(@types/node@22.19.17)(typescript@5.9.3):
@@ -8798,6 +8950,8 @@ snapshots:
path-exists@4.0.0: {}
path-expression-matcher@1.5.0: {}
path-key@3.1.1: {}
path-key@4.0.0: {}
@@ -8957,6 +9111,13 @@ snapshots:
dependencies:
side-channel: 1.1.0
query-string@7.1.3:
dependencies:
decode-uri-component: 0.2.2
filter-obj: 1.1.0
split-on-first: 1.1.0
strict-uri-encode: 2.0.0
queue-microtask@1.2.3: {}
quick-format-unescaped@4.0.4: {}
@@ -9183,6 +9344,8 @@ snapshots:
safer-buffer@2.1.2: {}
sax@1.6.0: {}
scheduler@0.27.0: {}
secure-json-parse@4.1.0: {}
@@ -9409,6 +9572,8 @@ snapshots:
spdx-license-ids@3.0.23: {}
split-on-first@1.1.0: {}
split2@4.2.0: {}
stable-hash@0.0.5: {}
@@ -9424,8 +9589,16 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
stream-chain@2.2.5: {}
stream-json@1.9.1:
dependencies:
stream-chain: 2.2.5
strict-event-emitter@0.5.1: {}
strict-uri-encode@2.0.0: {}
string-argv@0.3.2: {}
string-width@4.2.3:
@@ -9523,6 +9696,8 @@ snapshots:
strip-json-comments@5.0.3: {}
strnum@2.3.0: {}
styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.5):
dependencies:
client-only: 0.0.1
@@ -9548,6 +9723,10 @@ snapshots:
dependencies:
real-require: 0.2.0
through2@4.0.2:
dependencies:
readable-stream: 3.6.2
tiny-invariant@1.3.3: {}
tinyexec@1.2.4: {}
@@ -9863,6 +10042,15 @@ snapshots:
is-wsl: 3.1.1
powershell-utils: 0.1.0
xml-naming@0.1.0: {}
xml2js@0.6.2:
dependencies:
sax: 1.6.0
xmlbuilder: 11.0.1
xmlbuilder@11.0.1: {}
y18n@5.0.8: {}
yallist@3.1.1: {}
+3 -7
View File
@@ -19,11 +19,7 @@ function getLanIp() {
.flat()
.filter((n) => n.family === "IPv4" && !n.internal)
.map((n) => n.address);
return (
all.find((a) => a.startsWith("192.168.") || a.startsWith("10.")) ??
all[0] ??
"unknown"
);
return all.find((a) => a.startsWith("192.168.") || a.startsWith("10.")) ?? all[0] ?? "unknown";
}
/** Poll TCP host:port until it accepts a connection or the timeout expires. */
@@ -43,8 +39,8 @@ function waitForTcp(host, port, timeoutMs = 30_000) {
reject(
new Error(
`Postgres not ready on ${host}:${port} after ${timeoutMs / 1000}s. ` +
"Is Docker running? Check: docker compose -f docker-compose.dev.yaml logs"
)
"Is Docker running? Check: docker compose -f docker-compose.dev.yaml logs",
),
);
} else {
setTimeout(attempt, 500);
+5 -1
View File
@@ -36,7 +36,11 @@ for (const sub of subs) {
try {
await webPush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify({ title: "famapp test", body: "Push notifications are working!", url: "/settings" }),
JSON.stringify({
title: "famapp test",
body: "Push notifications are working!",
url: "/settings",
}),
);
console.log(`Sent to ${sub.endpoint.slice(0, 60)}...`);
} catch (err: unknown) {
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { getCurrentSession } from "@/lib/session";
import { searchSpecies } from "@/modules/garden/server/species-lookup";
export async function GET(request: Request) {
try {
await getCurrentSession();
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const q = searchParams.get("q") ?? "";
const results = await searchSpecies(q);
return NextResponse.json(results);
}
+34
View File
@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { minioClient, MINIO_BUCKET } from "@/lib/minio";
export const runtime = "nodejs";
export async function GET(_request: Request, { params }: { params: Promise<{ key: string[] }> }) {
const { key } = await params;
const objectKey = key.join("/");
try {
const stat = await minioClient.statObject(MINIO_BUCKET, objectKey);
const stream = await minioClient.getObject(MINIO_BUCKET, objectKey);
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
}
const buffer = Buffer.concat(chunks);
const metaData = stat.metaData as Record<string, string> | undefined;
const contentType =
metaData?.["content-type"] ?? metaData?.["Content-Type"] ?? "application/octet-stream";
return new Response(buffer, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
"Content-Length": String(buffer.length),
},
});
} catch {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
}
+62
View File
@@ -0,0 +1,62 @@
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import { getCurrentSession } from "@/lib/session";
import { ensureBucket, minioClient, MINIO_BUCKET } from "@/lib/minio";
export const runtime = "nodejs";
export const maxDuration = 60;
export const config = {
api: { bodyParser: { sizeLimit: "100mb" } },
};
const MAX_FILE_SIZE = 100 * 1024 * 1024;
export async function POST(request: Request) {
let session: Awaited<ReturnType<typeof getCurrentSession>>;
try {
session = await getCurrentSession();
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let formData: FormData;
try {
formData = await request.formData();
} catch {
return NextResponse.json({ error: "Invalid multipart body" }, { status: 400 });
}
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "No file field in request" }, { status: 400 });
}
if (!file.type.startsWith("image/")) {
return NextResponse.json({ error: "Only image files are allowed" }, { status: 415 });
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: "File exceeds 100 MB limit" }, { status: 413 });
}
const rawExt = file.name.split(".").pop() ?? "bin";
const safeExt = rawExt
.replace(/[^a-z0-9]/gi, "")
.toLowerCase()
.slice(0, 8);
const key = `garden/${session.household.id}/${randomUUID()}.${safeExt}`;
try {
await ensureBucket();
const buffer = Buffer.from(await file.arrayBuffer());
await minioClient.putObject(MINIO_BUCKET, key, buffer, buffer.length, {
"Content-Type": file.type,
});
} catch (err) {
console.error("Upload failed", err);
return NextResponse.json({ error: "Upload service unavailable" }, { status: 503 });
}
return NextResponse.json({ url: `/api/uploads/${key}` });
}
+14
View File
@@ -0,0 +1,14 @@
import { notFound } from "next/navigation";
import { getContainer } from "@/modules/garden/server/queries";
import { ContainerDetail } from "@/modules/garden/components/container-detail";
export default async function ContainerPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const container = await getContainer(id);
if (!container) notFound();
return (
<div className="page-content">
<ContainerDetail container={container} />
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { redirect } from "next/navigation";
import { createContainer } from "@/modules/garden/server/actions";
export default function NewContainerPage() {
async function handleCreate(formData: FormData) {
"use server";
await createContainer({
name: formData.get("name") as string,
type: (formData.get("type") as string) || "other",
locationNotes: (formData.get("locationNotes") as string) || null,
});
redirect("/garden");
}
return (
<div className="page-content max-w-lg">
<div className="flex items-center gap-3 mb-1">
<a href="/garden" className="btn btn-ghost btn-icon" aria-label="Back to garden">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</a>
<h1 className="page-title">New container</h1>
</div>
<form action={handleCreate} className="flex flex-col gap-4 mt-4">
<div className="flex flex-col gap-1">
<label htmlFor="name" className="text-sm font-medium">
Name
</label>
<input id="name" name="name" required maxLength={120} className="input" />
</div>
<div className="flex flex-col gap-1">
<label htmlFor="type" className="text-sm font-medium">
Type
</label>
<select id="type" name="type" defaultValue="other">
{(
[
["shelf", "Shelf"],
["terrarium", "Terrarium"],
["raised-bed", "Raised bed"],
["window-box", "Window box"],
["single-pot", "Single pot"],
["outdoor", "Outdoor"],
["other", "Other"],
] as const
).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="locationNotes" className="text-sm font-medium">
Location notes
</label>
<textarea
id="locationNotes"
name="locationNotes"
maxLength={500}
rows={2}
className="input"
/>
</div>
<div className="flex gap-2 justify-end">
<a href="/garden" className="btn btn-ghost">
Cancel
</a>
<button type="submit" className="btn btn-primary">
Create
</button>
</div>
</form>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import Link from "next/link";
import { listContainers, listPlants } from "@/modules/garden/server/queries";
import { ContainerList } from "@/modules/garden/components/container-list";
import { PlantList } from "@/modules/garden/components/plant-list";
import { PushOverdueButton } from "@/modules/garden/components/push-overdue-button";
type Props = {
searchParams: Promise<{ tab?: string }>;
};
export default async function GardenPage({ searchParams }: Props) {
const { tab = "containers" } = await searchParams;
const isPlants = tab === "plants";
const [containers, plants] = await Promise.all([
listContainers(),
isPlants ? listPlants() : Promise.resolve([]),
]);
return (
<div className="page-content">
<div className="flex items-center justify-between mb-1">
<h1 className="page-title mb-0">Garden</h1>
<PushOverdueButton />
</div>
<div className="flex gap-6 border-b border-[var(--ink-faint)] mb-6">
<Link
href="/garden?tab=containers"
className={`pb-2 text-sm font-medium transition-colors ${
!isPlants
? "border-b-2 border-[var(--ink)] text-[var(--ink)]"
: "text-[var(--ink-mute)]"
}`}
>
Containers
</Link>
<Link
href="/garden?tab=plants"
className={`pb-2 text-sm font-medium transition-colors ${
isPlants ? "border-b-2 border-[var(--ink)] text-[var(--ink)]" : "text-[var(--ink-mute)]"
}`}
>
Plants
</Link>
</div>
{isPlants ? <PlantList plants={plants} /> : <ContainerList containers={containers} />}
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { notFound } from "next/navigation";
import { getPlant, listContainers } from "@/modules/garden/server/queries";
import { PlantForm } from "@/modules/garden/components/plant-form";
export default async function EditPlantPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [plant, containers] = await Promise.all([getPlant(id), listContainers()]);
if (!plant) notFound();
return (
<div className="page-content max-w-xl">
<h1 className="page-title">Edit Plant</h1>
<PlantForm existingPlant={plant} containers={containers} />
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { notFound } from "next/navigation";
import { listCalendars } from "@/modules/garden/server/calendar-bridge";
import { getCareLogs, getCareSchedules, getPlant } from "@/modules/garden/server/queries";
import { PlantDetail } from "@/modules/garden/components/plant-detail";
export default async function PlantPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [plant, careLogs, careSchedules, calendars] = await Promise.all([
getPlant(id),
getCareLogs(id),
getCareSchedules(id),
listCalendars(),
]);
if (!plant) notFound();
return (
<div className="page-content">
<PlantDetail
plant={plant}
careLogs={careLogs}
careSchedules={careSchedules}
calendars={calendars}
/>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { listContainers } from "@/modules/garden/server/queries";
import { PlantForm } from "@/modules/garden/components/plant-form";
export default async function NewPlantPage({
searchParams,
}: {
searchParams: Promise<{ containerId?: string }>;
}) {
const params = await searchParams;
const containers = await listContainers();
return (
<div className="page-content max-w-xl">
<div className="flex items-center gap-3 mb-1">
<a href="/garden" className="btn btn-ghost btn-icon" aria-label="Back to garden">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</a>
<h1 className="page-title">Add Plant</h1>
</div>
<PlantForm containers={containers} defaultContainerId={params.containerId ?? null} />
</div>
);
}
+51
View File
@@ -1295,3 +1295,54 @@
color: var(--ink-mute);
font-variant-numeric: tabular-nums;
}
/* ── Form controls ──────────────────────────────────────────────── */
.input,
select {
width: 100%;
padding: 7px 10px;
border-radius: var(--r-sm);
border: 1px solid var(--hair-2);
background: var(--card);
color: var(--ink);
font: inherit;
font-size: 13.5px;
line-height: 1.4;
box-shadow: inset 0 1px 2px rgba(31, 27, 22, 0.05);
transition:
border-color 0.12s,
box-shadow 0.12s;
appearance: none;
-webkit-appearance: none;
outline: none;
}
.input:focus,
select:focus {
border-color: var(--ink-soft);
box-shadow:
inset 0 1px 2px rgba(31, 27, 22, 0.05),
0 0 0 2px rgba(31, 27, 22, 0.08);
}
.input::placeholder {
color: var(--ink-faint);
}
select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238a7e6e' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 30px;
cursor: pointer;
}
.dark .input,
.dark select {
background: var(--paper-2);
border-color: var(--hair-2);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.12);
}
.dark .input:focus,
.dark select:focus {
border-color: var(--ink-soft);
box-shadow:
inset 0 1px 2px rgba(0, 0, 0, 0.12),
0 0 0 2px rgba(244, 239, 229, 0.08);
}
+2
View File
@@ -1,6 +1,7 @@
import {
Bell,
Calendar,
Sprout,
CalendarDays,
CheckSquare,
ChevronDown,
@@ -68,6 +69,7 @@ const ICONS: Record<string, React.ComponentType<LucideProps>> = {
phone: Phone,
filter: Filter,
sun: Sun,
sprout: Sprout,
mail: Mail,
menu: Menu,
more: MoreHorizontal,
+8 -1
View File
@@ -4,9 +4,16 @@ import * as coreSchema from "@/modules/_core/schema";
import * as calendarSchema from "@/modules/calendar/schema";
import * as listsSchema from "@/modules/lists/schema";
import * as notesSchema from "@/modules/notes/schema";
import * as gardenSchema from "@/modules/garden/schema";
const client = postgres(process.env["DATABASE_URL"]!);
const schema = { ...coreSchema, ...calendarSchema, ...listsSchema, ...notesSchema };
const schema = {
...coreSchema,
...calendarSchema,
...listsSchema,
...notesSchema,
...gardenSchema,
};
export const db = drizzle(client, { schema });
+29
View File
@@ -0,0 +1,29 @@
import { Client } from "minio";
const rawEndpoint = process.env.MINIO_ENDPOINT ?? "http://localhost:9000";
const endpointUrl = new URL(rawEndpoint);
export const minioClient = new Client({
endPoint: endpointUrl.hostname,
port: endpointUrl.port
? parseInt(endpointUrl.port)
: endpointUrl.protocol === "https:"
? 443
: 80,
useSSL: endpointUrl.protocol === "https:",
accessKey: process.env.MINIO_ROOT_USER ?? "",
secretKey: process.env.MINIO_ROOT_PASSWORD ?? "",
});
export const MINIO_BUCKET = process.env.MINIO_BUCKET ?? "garden";
let bucketReady = false;
export async function ensureBucket(): Promise<void> {
if (bucketReady) return;
const exists = await minioClient.bucketExists(MINIO_BUCKET);
if (!exists) {
await minioClient.makeBucket(MINIO_BUCKET);
}
bucketReady = true;
}
+20
View File
@@ -73,6 +73,26 @@ export function getWidgetMetas(): SerializedWidgetMeta[] {
);
}
// ─── Item toggle hooks ────────────────────────────────────────────────────────
type ItemTogglePayload = {
itemId: string;
metadata: Record<string, unknown>;
done: boolean;
userId: string;
};
type ItemToggleHook = (payload: ItemTogglePayload) => Promise<void>;
const itemToggleHooks = new Map<string, ItemToggleHook>();
export function registerItemToggleHook(id: string, hook: ItemToggleHook): void {
itemToggleHooks.set(id, hook);
}
export async function fireItemToggleHooks(payload: ItemTogglePayload): Promise<void> {
await Promise.allSettled([...itemToggleHooks.values()].map((h) => h(payload)));
}
export function getQuickAdds(): SerializedQuickAddItem[] {
return [...modules.values()].flatMap((manifest) =>
(manifest.quickAdds ?? []).map(({ id, label, icon, url }) => ({
+13 -3
View File
@@ -13,6 +13,8 @@ export async function scheduleReminder(input: {
fireAt: Date;
createdBy: string;
channel?: string;
title?: string;
body?: string;
}) {
await db
.insert(reminders)
@@ -22,12 +24,20 @@ export async function scheduleReminder(input: {
entityId: input.entityId,
fireAt: input.fireAt,
channel: input.channel ?? "auto",
title: input.title ?? null,
body: input.body ?? null,
createdBy: input.createdBy,
firedAt: null,
})
.onConflictDoUpdate({
target: [reminders.entityType, reminders.entityId],
set: { fireAt: input.fireAt, firedAt: null, createdBy: input.createdBy },
set: {
fireAt: input.fireAt,
title: input.title ?? null,
body: input.body ?? null,
firedAt: null,
createdBy: input.createdBy,
},
});
}
@@ -82,8 +92,8 @@ export async function tickReminders() {
if (!reminder.createdBy) return;
try {
await notify(reminder.createdBy, {
title: "Reminder",
body: `You have a reminder`,
title: reminder.title ?? "Reminder",
body: reminder.body ?? "You have a reminder",
url: reminder.entityType === "notes.note" ? `/notes/${reminder.entityId}` : "/",
channels: ["push", "inapp"],
});
+2
View File
@@ -166,6 +166,8 @@ export const reminders = pgTable(
entityId: uuid("entity_id").notNull(),
fireAt: timestamp("fire_at", { withTimezone: true }).notNull(),
channel: text("channel").notNull().default("auto"),
title: text("title"),
body: text("body"),
firedAt: timestamp("fired_at", { withTimezone: true }),
createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
@@ -0,0 +1,45 @@
import type { CareLogDto } from "../server/queries";
const CARE_ICONS: Record<string, string> = {
watering: "💧",
fertilizing: "🌱",
repotting: "🪴",
pruning: "✂️",
"pest-control": "🐛",
other: "📋",
};
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return "Today";
if (days === 1) return "Yesterday";
return `${days} days ago`;
}
type Props = {
logs: CareLogDto[];
};
export function CareHistoryList({ logs }: Props) {
if (logs.length === 0) {
return <p className="text-sm text-[var(--ink-mute)]">No care events logged yet.</p>;
}
return (
<ul className="flex flex-col gap-2">
{logs.map((log) => (
<li key={log.id} className="flex gap-3 items-start text-sm">
<span className="text-lg leading-none mt-0.5" aria-hidden>
{CARE_ICONS[log.careType] ?? "📋"}
</span>
<div className="flex flex-col gap-0.5">
<span className="font-medium capitalize">{log.careType}</span>
<span className="text-xs text-[var(--ink-mute)]">{timeAgo(log.performedAt)}</span>
{log.notes && <p className="text-xs text-[var(--ink-mute)]">{log.notes}</p>}
</div>
</li>
))}
</ul>
);
}
@@ -0,0 +1,97 @@
"use client";
import { useState, useTransition } from "react";
import { logCare } from "../server/actions";
const CARE_TYPES = ["watering", "fertilizing", "repotting", "pruning", "pest-control", "other"];
type Props = {
plantId: string;
defaultCareType?: string;
onSuccess?: () => void;
};
export function CareLogForm({ plantId, defaultCareType = "watering", onSuccess }: Props) {
const [careType, setCareType] = useState(defaultCareType);
const [notes, setNotes] = useState("");
const [performedAt, setPerformedAt] = useState("");
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
startTransition(async () => {
try {
await logCare({
plantId,
careType,
notes: notes || null,
performedAt: performedAt || undefined,
});
setNotes("");
setPerformedAt("");
onSuccess?.();
} catch {
setError("Failed to log care. Please try again.");
}
});
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label htmlFor="care-type" className="text-sm font-medium">
Care type
</label>
<select
id="care-type"
value={careType}
onChange={(e) => setCareType(e.target.value)}
className="input input-sm"
required
>
{CARE_TYPES.map((t) => (
<option key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1).replace("-", " ")}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="performed-at" className="text-sm font-medium">
Date &amp; time (optional defaults to now)
</label>
<input
id="performed-at"
type="datetime-local"
value={performedAt}
onChange={(e) => setPerformedAt(e.target.value)}
className="input input-sm"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="care-notes" className="text-sm font-medium">
Notes (optional)
</label>
<textarea
id="care-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
maxLength={2000}
className="input input-sm resize-none"
placeholder="Any observations…"
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<button type="submit" className="btn btn-primary btn-sm self-start" disabled={isPending}>
{isPending ? "Logging…" : "Log care"}
</button>
</form>
);
}
@@ -0,0 +1,272 @@
"use client";
import Link from "next/link";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
deleteCareSchedule,
scheduleOnCalendar,
toggleCareSchedule,
upsertCareSchedule,
} from "../server/actions";
import type { CalendarDto } from "../server/calendar-bridge";
import type { CareScheduleDto } from "../server/queries";
const CARE_TYPES = ["watering", "fertilizing", "repotting", "pruning", "pest-control", "other"];
type Props = {
plantId: string;
schedules: CareScheduleDto[];
calendars: CalendarDto[];
};
function daysLabel(n: number | null): string {
if (n === null) return "No due date";
if (n < 0) return `${Math.abs(n)} day${Math.abs(n) !== 1 ? "s" : ""} overdue`;
if (n === 0) return "Due today";
return `Due in ${n} day${n !== 1 ? "s" : ""}`;
}
export function CareScheduleEditor({ plantId, schedules, calendars }: Props) {
const [showForm, setShowForm] = useState(false);
const [careType, setCareType] = useState("watering");
const [intervalDays, setIntervalDays] = useState("7");
const [formError, setFormError] = useState<string | null>(null);
const [calendarOpenId, setCalendarOpenId] = useState<string | null>(null);
const [selectedCalendarId, setSelectedCalendarId] = useState(calendars[0]?.id ?? "");
const [reminderMinutes, setReminderMinutes] = useState("");
const [calendarSuccess, setCalendarSuccess] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const router = useRouter();
function handleAddSchedule(e: React.FormEvent) {
e.preventDefault();
const days = parseInt(intervalDays, 10);
if (isNaN(days) || days < 1 || days > 365) {
setFormError("Interval must be between 1 and 365 days.");
return;
}
setFormError(null);
startTransition(async () => {
try {
await upsertCareSchedule({ plantId, careType, intervalDays: days });
setShowForm(false);
setCareType("watering");
setIntervalDays("7");
router.refresh();
} catch {
setFormError("Failed to save schedule.");
}
});
}
function handleDelete(id: string) {
startTransition(async () => {
await deleteCareSchedule({ id });
router.refresh();
});
}
function handleToggle(id: string, enabled: boolean) {
startTransition(async () => {
await toggleCareSchedule({ id, enabled });
router.refresh();
});
}
function handleScheduleOnCalendar(scheduleId: string) {
if (!selectedCalendarId) return;
startTransition(async () => {
try {
await scheduleOnCalendar({
scheduleId,
calendarId: selectedCalendarId,
reminderMinutesBefore: reminderMinutes ? parseInt(reminderMinutes, 10) : undefined,
});
setCalendarOpenId(null);
setCalendarSuccess(scheduleId);
setTimeout(() => setCalendarSuccess(null), 4000);
} catch (err) {
setFormError(err instanceof Error ? err.message : "Failed to add to calendar.");
}
});
}
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-[var(--ink-mute)] uppercase tracking-wide">
Schedules
</p>
<button
className="btn btn-ghost btn-xs"
onClick={() => setShowForm((v) => !v)}
disabled={isPending}
>
{showForm ? "Cancel" : "Add schedule"}
</button>
</div>
{showForm && (
<form
onSubmit={handleAddSchedule}
className="flex flex-col gap-2 p-3 bg-[var(--surface-2)] rounded-lg"
>
<div className="flex gap-2">
<div className="flex flex-col gap-1 flex-1">
<label htmlFor="sched-care-type" className="text-xs font-medium">
Care type
</label>
<select
id="sched-care-type"
value={careType}
onChange={(e) => setCareType(e.target.value)}
className="input input-xs"
>
{CARE_TYPES.map((t) => (
<option key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1).replace("-", " ")}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1 w-28">
<label htmlFor="sched-interval" className="text-xs font-medium">
Interval (days)
</label>
<input
id="sched-interval"
type="number"
min={1}
max={365}
value={intervalDays}
onChange={(e) => setIntervalDays(e.target.value)}
className="input input-xs"
required
/>
</div>
</div>
{formError && <p className="text-xs text-red-500">{formError}</p>}
<button type="submit" className="btn btn-primary btn-xs self-start" disabled={isPending}>
{isPending ? "Saving…" : "Add"}
</button>
</form>
)}
{schedules.length === 0 && !showForm && (
<p className="text-sm text-[var(--ink-mute)]">No schedules yet.</p>
)}
{schedules.map((s) => (
<div key={s.id} className="flex flex-col gap-1">
<div
className={`flex items-center justify-between gap-2 p-2 rounded-lg border ${
s.isOverdue
? "border-red-300 bg-red-50 dark:bg-red-950/20"
: "border-[var(--ink-faint)]"
}`}
>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium capitalize">{s.careType}</span>
<span className="text-xs text-[var(--ink-mute)]">
Every {s.intervalDays} days · {daysLabel(s.daysUntilDue)}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{calendars.length > 0 && s.nextDueAt && (
<button
onClick={() => setCalendarOpenId(calendarOpenId === s.id ? null : s.id)}
disabled={isPending}
title="Add to calendar"
className="text-xs text-[var(--ink-mute)] hover:text-[var(--ink)]"
>
📅
</button>
)}
<button
onClick={() => handleToggle(s.id, !s.enabled)}
disabled={isPending}
title={s.enabled ? "Disable" : "Enable"}
className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${
s.enabled
? "border-green-400 text-green-700 dark:text-green-400"
: "border-[var(--ink-faint)] text-[var(--ink-mute)]"
}`}
>
{s.enabled ? "Active" : "Paused"}
</button>
<button
onClick={() => handleDelete(s.id)}
disabled={isPending}
title="Delete schedule"
className="text-[var(--ink-mute)] hover:text-red-500 text-xs"
>
</button>
</div>
</div>
{calendarOpenId === s.id && (
<div className="flex flex-col gap-2 p-3 bg-[var(--surface-2)] rounded-lg border border-[var(--ink-faint)] ml-2">
<div className="flex gap-2">
<div className="flex flex-col gap-1 flex-1">
<label htmlFor={`cal-select-${s.id}`} className="text-xs font-medium">
Calendar
</label>
<select
id={`cal-select-${s.id}`}
value={selectedCalendarId}
onChange={(e) => setSelectedCalendarId(e.target.value)}
className="input input-xs"
>
{calendars.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1 w-28">
<label htmlFor={`remind-${s.id}`} className="text-xs font-medium">
Remind (min)
</label>
<input
id={`remind-${s.id}`}
type="number"
min={0}
max={1440}
placeholder="optional"
value={reminderMinutes}
onChange={(e) => setReminderMinutes(e.target.value)}
className="input input-xs"
/>
</div>
</div>
<div className="flex gap-2 items-center">
<button
onClick={() => handleScheduleOnCalendar(s.id)}
disabled={isPending || !selectedCalendarId}
className="btn btn-primary btn-xs"
>
{isPending ? "Scheduling…" : "Schedule"}
</button>
<button onClick={() => setCalendarOpenId(null)} className="btn btn-ghost btn-xs">
Cancel
</button>
</div>
</div>
)}
{calendarSuccess === s.id && (
<p className="text-xs text-green-600 dark:text-green-400 ml-2">
Event added to calendar.{" "}
<Link href="/calendar" className="underline">
View in calendar
</Link>
</p>
)}
</div>
))}
</div>
);
}
@@ -0,0 +1,127 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { deleteContainer } from "../server/actions";
import { ContainerForm } from "./container-form";
import type { ContainerDetailDto } from "../server/queries";
type Props = { container: ContainerDetailDto };
export function ContainerDetail({ container }: Props) {
const [editing, setEditing] = useState(false);
const [confirming, setConfirming] = useState(false);
const [isPending, startTransition] = useTransition();
const router = useRouter();
function handleDelete() {
startTransition(async () => {
await deleteContainer({ id: container.id });
router.push("/garden");
router.refresh();
});
}
return (
<div className="flex flex-col gap-6">
{container.coverImageUrl && (
<img
src={container.coverImageUrl}
alt=""
className="w-full max-h-48 object-cover rounded-lg"
/>
)}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">{container.name}</h1>
<p className="text-sm text-[var(--ink-mute)] capitalize mt-1">{container.type}</p>
{container.locationNotes && <p className="text-sm mt-2">{container.locationNotes}</p>}
</div>
<div className="flex gap-2 shrink-0">
<button className="btn btn-ghost btn-sm" onClick={() => setEditing(true)}>
Edit
</button>
{confirming ? (
<div className="flex gap-1">
<button className="btn btn-danger btn-sm" onClick={handleDelete} disabled={isPending}>
Confirm
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setConfirming(false)}
disabled={isPending}
>
Cancel
</button>
</div>
) : (
<button className="btn btn-ghost btn-sm" onClick={() => setConfirming(true)}>
Delete
</button>
)}
</div>
</div>
{editing && (
<div className="card p-4">
<h2 className="font-medium mb-3">Edit container</h2>
<ContainerForm
existing={container}
onSuccess={() => {
setEditing(false);
router.refresh();
}}
onCancel={() => setEditing(false)}
/>
</div>
)}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Plants ({container.plantCount})</h2>
<a
href={`/garden/plants/new?containerId=${container.id}`}
className="btn btn-ghost btn-sm"
>
+ Add plant
</a>
</div>
{container.plants.length === 0 ? (
<p className="text-sm text-[var(--ink-mute)]">No plants in this container yet.</p>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{container.plants.map((p) => (
<a
key={p.id}
href={`/garden/plants/${p.id}`}
className="card p-3 hover:bg-[var(--surface-2)] transition-colors"
>
<div className="flex items-center gap-3">
{p.primaryImageUrl && (
<img
src={p.primaryImageUrl}
alt=""
className="w-10 h-10 rounded-full object-cover shrink-0"
/>
)}
<div>
<p className="font-medium text-sm">{p.name}</p>
{p.scientificName && (
<p className="text-xs text-[var(--ink-mute)] italic">{p.scientificName}</p>
)}
</div>
<span
className={`ml-auto text-xs badge ${p.healthStatus === "healthy" ? "badge-success" : "badge-warning"}`}
>
{p.healthStatus}
</span>
</div>
</a>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,112 @@
"use client";
import { useRef, useState, useTransition } from "react";
import { createContainer, updateContainer } from "../server/actions";
import type { ContainerDto } from "../server/queries";
const CONTAINER_TYPES = [
{ value: "shelf", label: "Shelf" },
{ value: "terrarium", label: "Terrarium" },
{ value: "raised-bed", label: "Raised bed" },
{ value: "window-box", label: "Window box" },
{ value: "single-pot", label: "Single pot" },
{ value: "outdoor", label: "Outdoor" },
{ value: "other", label: "Other" },
];
type Props = {
existing?: ContainerDto;
onSuccess?: () => void;
onCancel?: () => void;
};
export function ContainerForm({ existing, onSuccess, onCancel }: Props) {
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const formRef = useRef<HTMLFormElement>(null);
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const input = {
name: fd.get("name") as string,
type: fd.get("type") as string,
locationNotes: (fd.get("locationNotes") as string) || null,
};
setError(null);
startTransition(async () => {
try {
if (existing) {
await updateContainer({ id: existing.id, ...input });
} else {
await createContainer(input);
}
formRef.current?.reset();
onSuccess?.();
} catch {
setError("Something went wrong. Please try again.");
}
});
}
return (
<form ref={formRef} onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label htmlFor="container-name" className="text-sm font-medium">
Name
</label>
<input
id="container-name"
name="name"
required
maxLength={120}
defaultValue={existing?.name}
placeholder="e.g. Living Room Shelf"
className="input"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="container-type" className="text-sm font-medium">
Type
</label>
<select id="container-type" name="type" defaultValue={existing?.type ?? "other"}>
{CONTAINER_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="container-notes" className="text-sm font-medium">
Location notes
</label>
<textarea
id="container-notes"
name="locationNotes"
maxLength={500}
rows={2}
defaultValue={existing?.locationNotes ?? ""}
placeholder="Optional — e.g. south-facing window"
className="input"
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-2 justify-end">
{onCancel && (
<button type="button" onClick={onCancel} className="btn btn-ghost" disabled={isPending}>
Cancel
</button>
)}
<button type="submit" className="btn btn-primary" disabled={isPending}>
{isPending ? "Saving…" : existing ? "Save" : "Create"}
</button>
</div>
</form>
);
}
@@ -0,0 +1,65 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ContainerForm } from "./container-form";
import type { ContainerDto } from "../server/queries";
type Props = { containers: ContainerDto[] };
export function ContainerList({ containers }: Props) {
const [showNew, setShowNew] = useState(false);
const router = useRouter();
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Containers</h2>
<button className="btn btn-primary btn-sm" onClick={() => setShowNew(true)}>
New container
</button>
</div>
{showNew && (
<div className="card p-4">
<h3 className="font-medium mb-3">New container</h3>
<ContainerForm
onSuccess={() => {
setShowNew(false);
router.refresh();
}}
onCancel={() => setShowNew(false)}
/>
</div>
)}
{containers.length === 0 && !showNew && (
<p className="text-sm text-[var(--ink-mute)]">
No containers yet. Add one to start organising your plants.
</p>
)}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{containers.map((c) => (
<Link
key={c.id}
href={`/garden/containers/${c.id}`}
className="card p-4 hover:bg-[var(--surface-2)] transition-colors"
>
{c.coverImageUrl && (
<img src={c.coverImageUrl} alt="" className="w-full h-32 object-cover rounded mb-3" />
)}
<div className="flex items-start justify-between gap-2">
<span className="font-medium leading-tight">{c.name}</span>
<span className="badge badge-outline text-xs shrink-0 capitalize">{c.type}</span>
</div>
<p className="text-sm text-[var(--ink-mute)] mt-1">
{c.plantCount} {c.plantCount === 1 ? "plant" : "plants"}
</p>
</Link>
))}
</div>
</div>
);
}
@@ -0,0 +1,282 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { deletePlant, addPlantImage, removePlantImage, setPrimaryImage } from "../server/actions";
import type { CalendarDto } from "../server/calendar-bridge";
import type { CareLogDto, CareScheduleDto, PlantDetailDto } from "../server/queries";
import { CareHistoryList } from "./care-history-list";
import { CareLogForm } from "./care-log-form";
import { CareScheduleEditor } from "./care-schedule-editor";
type Tab = "info" | "gallery" | "care";
type Props = {
plant: PlantDetailDto;
careLogs: CareLogDto[];
careSchedules: CareScheduleDto[];
calendars: CalendarDto[];
};
function InfoRow({
label,
value,
children,
}: {
label: string;
value?: string | null;
children?: React.ReactNode;
}) {
if (!value && !children) return null;
return (
<div className="flex gap-3">
<span className="text-sm text-[var(--ink-mute)] w-28 shrink-0">{label}</span>
<span className="text-sm">{children ?? value}</span>
</div>
);
}
function healthBadgeClass(status: string): string {
if (status === "healthy") return "badge-success";
if (status === "sick") return "badge-danger";
return "badge-warning";
}
export function PlantDetail({ plant, careLogs, careSchedules, calendars }: Props) {
const [tab, setTab] = useState<Tab>("info");
const [confirming, setConfirming] = useState(false);
const [isPending, startTransition] = useTransition();
const [galleryError, setGalleryError] = useState<string | null>(null);
const [uploadingImage, setUploadingImage] = useState(false);
const router = useRouter();
function handleDelete() {
startTransition(async () => {
await deletePlant({ id: plant.id });
router.push("/garden");
router.refresh();
});
}
async function handleImageUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setGalleryError(null);
setUploadingImage(true);
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/uploads", { method: "POST", body: fd });
if (!res.ok) throw new Error("Upload failed");
const data = (await res.json()) as { url: string };
await addPlantImage({ id: plant.id, url: data.url });
router.refresh();
} catch {
setGalleryError("Image upload failed.");
} finally {
setUploadingImage(false);
e.target.value = "";
}
}
function handleRemoveImage(url: string) {
startTransition(async () => {
await removePlantImage({ id: plant.id, url });
router.refresh();
});
}
function handleSetPrimary(url: string) {
startTransition(async () => {
await setPrimaryImage({ id: plant.id, url });
router.refresh();
});
}
return (
<div className="flex flex-col gap-5">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
{plant.primaryImageUrl && (
<img
src={plant.primaryImageUrl}
alt=""
className="w-16 h-16 rounded-lg object-cover shrink-0"
/>
)}
<div>
<h1 className="text-2xl font-bold">{plant.name}</h1>
{plant.scientificName && (
<p className="text-sm text-[var(--ink-mute)] italic mt-0.5">{plant.scientificName}</p>
)}
<div className="flex flex-wrap gap-2 mt-1">
<span className={`text-xs badge ${healthBadgeClass(plant.healthStatus)}`}>
{plant.healthStatus}
</span>
{plant.growthStage && (
<span className="text-xs badge badge-outline capitalize">{plant.growthStage}</span>
)}
</div>
</div>
</div>
<div className="flex gap-2 shrink-0">
<Link href={`/garden/plants/${plant.id}/edit`} className="btn btn-ghost btn-sm">
Edit
</Link>
{confirming ? (
<div className="flex gap-1">
<button className="btn btn-danger btn-sm" onClick={handleDelete} disabled={isPending}>
Confirm
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setConfirming(false)}
disabled={isPending}
>
Cancel
</button>
</div>
) : (
<button className="btn btn-ghost btn-sm" onClick={() => setConfirming(true)}>
Delete
</button>
)}
</div>
</div>
{/* Tabs */}
<div className="flex gap-6 border-b border-[var(--ink-faint)]">
{(["info", "gallery", "care"] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`pb-2 text-sm font-medium capitalize transition-colors ${
tab === t
? "border-b-2 border-[var(--ink)] text-[var(--ink)]"
: "text-[var(--ink-mute)]"
}`}
>
{t}
</button>
))}
</div>
{/* Info */}
{tab === "info" && (
<div className="flex flex-col gap-2">
<InfoRow label="Category" value={plant.category} />
{plant.containerName && (
<InfoRow label="Container">
<Link href={`/garden/containers/${plant.containerId}`} className="underline">
{plant.containerName}
</Link>
</InfoRow>
)}
<InfoRow label="Acquired" value={plant.acquisitionDate} />
<InfoRow label="Sunlight" value={plant.sunlight} />
<InfoRow label="Watering" value={plant.wateringNotes} />
<InfoRow label="Fertilizing" value={plant.fertilizingNotes} />
<InfoRow label="Notes" value={plant.notes} />
{plant.recentCareLogs.length > 0 && (
<div className="mt-2">
<p className="text-xs font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-1">
Recent care
</p>
<ul className="text-sm space-y-1">
{plant.recentCareLogs.map((log) => (
<li key={log.id} className="flex gap-2 flex-wrap">
<span className="capitalize">{log.careType}</span>
<span className="text-[var(--ink-mute)]">
{new Date(log.performedAt).toLocaleDateString()}
</span>
{log.notes && <span className="text-[var(--ink-mute)]"> {log.notes}</span>}
</li>
))}
</ul>
</div>
)}
</div>
)}
{/* Gallery */}
{tab === "gallery" && (
<div className="flex flex-col gap-4">
{plant.images.length === 0 ? (
<p className="text-sm text-[var(--ink-mute)]">No photos yet.</p>
) : (
<div className="grid grid-cols-3 gap-2">
{plant.images.map((url) => (
<div key={url} className="relative group">
<img src={url} alt="" className="w-full aspect-square object-cover rounded-lg" />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 rounded-lg flex items-center justify-center gap-3 transition-opacity">
<button
onClick={() => handleSetPrimary(url)}
disabled={isPending}
title="Set as primary"
className={`text-lg leading-none ${url === plant.primaryImageUrl ? "text-yellow-400" : "text-white"}`}
>
</button>
<button
onClick={() => handleRemoveImage(url)}
disabled={isPending}
title="Remove"
className="text-white text-lg leading-none"
>
</button>
</div>
{url === plant.primaryImageUrl && (
<span className="absolute top-1 left-1 text-xs px-1 bg-black/60 text-yellow-300 rounded">
Primary
</span>
)}
</div>
))}
</div>
)}
{galleryError && <p className="text-sm text-red-500">{galleryError}</p>}
<div className="flex items-center gap-3">
{plant.images.length < 10 && (
<label className="btn btn-ghost btn-sm cursor-pointer">
{uploadingImage ? "Uploading…" : "Upload photo"}
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
disabled={uploadingImage}
/>
</label>
)}
<span className="text-xs text-[var(--ink-mute)]">{plant.images.length}/10 photos</span>
</div>
</div>
)}
{/* Care */}
{tab === "care" && (
<div className="flex flex-col gap-6">
<CareScheduleEditor plantId={plant.id} schedules={careSchedules} calendars={calendars} />
<div className="border-t border-[var(--ink-faint)] pt-4">
<p className="text-sm font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-3">
Log care
</p>
<CareLogForm plantId={plant.id} onSuccess={() => router.refresh()} />
</div>
<div className="border-t border-[var(--ink-faint)] pt-4">
<p className="text-sm font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-3">
History
</p>
<CareHistoryList logs={careLogs} />
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,367 @@
"use client";
import { useState, useTransition, useRef } from "react";
import { useRouter } from "next/navigation";
import { createPlant, updatePlant } from "../server/actions";
import { SpeciesSearch } from "./species-search";
import type { PlantDetailDto, ContainerDto } from "../server/queries";
import type { SpeciesSuggestion } from "../server/species-lookup";
const CATEGORIES = [
{ value: "vegetable", label: "Vegetable" },
{ value: "fruit", label: "Fruit" },
{ value: "herb", label: "Herb" },
{ value: "flower", label: "Flower" },
{ value: "succulent", label: "Succulent" },
{ value: "cactus", label: "Cactus" },
{ value: "tropical", label: "Tropical" },
{ value: "tree", label: "Tree" },
{ value: "shrub", label: "Shrub" },
{ value: "other", label: "Other" },
];
const HEALTH_STATUSES = [
{ value: "healthy", label: "Healthy" },
{ value: "needs-attention", label: "Needs attention" },
{ value: "sick", label: "Sick" },
{ value: "dormant", label: "Dormant" },
];
const GROWTH_STAGES = [
{ value: "", label: "Unknown" },
{ value: "seedling", label: "Seedling" },
{ value: "sprout", label: "Sprout" },
{ value: "vegetative", label: "Vegetative" },
{ value: "budding", label: "Budding" },
{ value: "flowering", label: "Flowering" },
{ value: "ripening", label: "Ripening" },
{ value: "dormant", label: "Dormant" },
];
type Props = {
existingPlant?: PlantDetailDto;
defaultContainerId?: string | null;
containers: ContainerDto[];
};
export function PlantForm({ existingPlant, defaultContainerId, containers }: Props) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [images, setImages] = useState<string[]>(existingPlant?.images ?? []);
const [uploading, setUploading] = useState(false);
const [selectedSpeciesId, setSelectedSpeciesId] = useState(existingPlant?.speciesId ?? "");
const scientificNameRef = useRef<HTMLInputElement>(null);
const sunlightRef = useRef<HTMLInputElement>(null);
const wateringNotesRef = useRef<HTMLTextAreaElement>(null);
function handleSpeciesSelect(species: SpeciesSuggestion) {
setSelectedSpeciesId(species.id);
if (scientificNameRef.current) scientificNameRef.current.value = species.scientific_name;
if (sunlightRef.current) sunlightRef.current.value = species.sunlight.join(", ");
if (wateringNotesRef.current) wateringNotesRef.current.value = species.watering;
}
async function handleImageSelect(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
if (images.length >= 10) {
setError("Maximum 10 images allowed.");
return;
}
setUploading(true);
setError(null);
try {
const urls: string[] = [];
for (const file of files) {
if (images.length + urls.length >= 10) break;
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/uploads", { method: "POST", body: fd });
if (!res.ok) throw new Error("Upload failed");
const data = (await res.json()) as { url: string };
urls.push(data.url);
}
setImages((prev) => [...prev, ...urls]);
} catch {
setError("Image upload failed. Please try again.");
} finally {
setUploading(false);
e.target.value = "";
}
}
function handleRemoveImage(url: string) {
setImages((prev) => prev.filter((u) => u !== url));
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const input = {
name: fd.get("name") as string,
category: (fd.get("category") as string) || "other",
containerId: (fd.get("containerId") as string) || null,
healthStatus: (fd.get("healthStatus") as string) || "healthy",
growthStage: (fd.get("growthStage") as string) || null,
scientificName: scientificNameRef.current?.value || null,
speciesId: selectedSpeciesId || null,
sunlight: sunlightRef.current?.value || null,
wateringNotes: wateringNotesRef.current?.value || null,
fertilizingNotes: (fd.get("fertilizingNotes") as string) || null,
notes: (fd.get("notes") as string) || null,
acquisitionDate: (fd.get("acquisitionDate") as string) || null,
images,
primaryImageUrl: existingPlant?.primaryImageUrl ?? images[0] ?? null,
};
setError(null);
startTransition(async () => {
try {
if (existingPlant) {
await updatePlant({ id: existingPlant.id, ...input });
router.push(`/garden/plants/${existingPlant.id}`);
router.refresh();
} else {
const plant = await createPlant(input);
router.push(`/garden/plants/${plant.id}`);
}
} catch {
setError("Something went wrong. Please try again.");
}
});
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4 max-w-xl">
<div className="flex flex-col gap-1">
<label htmlFor="plant-name" className="text-sm font-medium">
Name <span className="text-red-500">*</span>
</label>
<input
id="plant-name"
name="name"
required
maxLength={120}
defaultValue={existingPlant?.name}
className="input"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium">Species lookup (optional)</label>
<SpeciesSearch
onSelect={handleSpeciesSelect}
initialValue={existingPlant?.scientificName ?? ""}
/>
<p className="text-xs text-[var(--ink-mute)]">
Search Perenual to auto-fill scientific name and care notes.
</p>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="plant-scientific-name" className="text-sm font-medium">
Scientific name
</label>
<input
id="plant-scientific-name"
name="scientificName"
ref={scientificNameRef}
maxLength={200}
defaultValue={existingPlant?.scientificName ?? ""}
className="input"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1">
<label htmlFor="plant-category" className="text-sm font-medium">
Category
</label>
<select
id="plant-category"
name="category"
defaultValue={existingPlant?.category ?? "other"}
>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="plant-container" className="text-sm font-medium">
Container
</label>
<select
id="plant-container"
name="containerId"
defaultValue={existingPlant?.containerId ?? defaultContainerId ?? ""}
>
<option value="">None</option>
{containers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1">
<label htmlFor="plant-health" className="text-sm font-medium">
Health status
</label>
<select
id="plant-health"
name="healthStatus"
defaultValue={existingPlant?.healthStatus ?? "healthy"}
>
{HEALTH_STATUSES.map((h) => (
<option key={h.value} value={h.value}>
{h.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="plant-stage" className="text-sm font-medium">
Growth stage
</label>
<select
id="plant-stage"
name="growthStage"
defaultValue={existingPlant?.growthStage ?? ""}
>
{GROWTH_STAGES.map((g) => (
<option key={g.value} value={g.value}>
{g.label}
</option>
))}
</select>
</div>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="plant-acquired" className="text-sm font-medium">
Acquisition date
</label>
<input
id="plant-acquired"
type="date"
name="acquisitionDate"
defaultValue={existingPlant?.acquisitionDate ?? ""}
className="input"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="plant-sunlight" className="text-sm font-medium">
Sunlight requirements
</label>
<input
id="plant-sunlight"
name="sunlight"
ref={sunlightRef}
maxLength={200}
defaultValue={existingPlant?.sunlight ?? ""}
placeholder="e.g. full sun, part shade"
className="input"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="plant-watering" className="text-sm font-medium">
Watering notes
</label>
<textarea
id="plant-watering"
name="wateringNotes"
ref={wateringNotesRef}
maxLength={2000}
rows={2}
defaultValue={existingPlant?.wateringNotes ?? ""}
className="input"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="plant-fertilizing" className="text-sm font-medium">
Fertilizing notes
</label>
<textarea
id="plant-fertilizing"
name="fertilizingNotes"
maxLength={2000}
rows={2}
defaultValue={existingPlant?.fertilizingNotes ?? ""}
className="input"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="plant-notes" className="text-sm font-medium">
General notes
</label>
<textarea
id="plant-notes"
name="notes"
maxLength={2000}
rows={3}
defaultValue={existingPlant?.notes ?? ""}
className="input"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Photos ({images.length}/10)</label>
{images.length > 0 && (
<div className="grid grid-cols-4 gap-2">
{images.map((url) => (
<div key={url} className="relative group">
<img src={url} alt="" className="w-full aspect-square object-cover rounded" />
<button
type="button"
onClick={() => handleRemoveImage(url)}
className="absolute top-0.5 right-0.5 bg-black/60 text-white rounded text-xs px-1 opacity-0 group-hover:opacity-100"
>
</button>
</div>
))}
</div>
)}
{images.length < 10 && (
<label className="btn btn-ghost btn-sm w-fit cursor-pointer">
{uploading ? "Uploading…" : "Add photos"}
<input
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleImageSelect}
disabled={uploading}
/>
</label>
)}
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-2 justify-end">
<a
href={existingPlant ? `/garden/plants/${existingPlant.id}` : "/garden"}
className="btn btn-ghost"
>
Cancel
</a>
<button type="submit" className="btn btn-primary" disabled={isPending || uploading}>
{isPending ? "Saving…" : existingPlant ? "Save" : "Create"}
</button>
</div>
</form>
);
}
@@ -0,0 +1,106 @@
import Link from "next/link";
import type { PlantListItemDto } from "../server/queries";
type Props = {
plants: PlantListItemDto[];
};
function daysAgo(isoString: string): string {
const diff = Date.now() - new Date(isoString).getTime();
const days = Math.floor(diff / 86_400_000);
if (days === 0) return "today";
if (days === 1) return "yesterday";
return `${days}d ago`;
}
function healthBadgeClass(status: string): string {
if (status === "healthy") return "badge-success";
if (status === "sick") return "badge-danger";
return "badge-warning";
}
export function PlantList({ plants }: Props) {
if (plants.length === 0) {
return (
<div className="flex flex-col items-center gap-3 py-8 text-center">
<p className="text-sm text-[var(--ink-mute)]">No plants yet.</p>
<Link href="/garden/plants/new" className="btn btn-primary btn-sm">
Add your first plant
</Link>
</div>
);
}
const groups = new Map<string | null, { name: string | null; items: PlantListItemDto[] }>();
for (const plant of plants) {
const key = plant.containerId;
if (!groups.has(key)) groups.set(key, { name: plant.containerName, items: [] });
groups.get(key)!.items.push(plant);
}
const entries = [...groups.entries()].sort(([a], [b]) => {
if (a === null) return 1;
if (b === null) return -1;
return 0;
});
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--ink-mute)]">
{plants.length} {plants.length === 1 ? "plant" : "plants"}
</span>
<Link href="/garden/plants/new" className="btn btn-primary btn-sm">
Add plant
</Link>
</div>
{entries.map(([containerId, group]) => (
<div key={containerId ?? "_unassigned"}>
<h3 className="text-xs font-semibold text-[var(--ink-mute)] uppercase tracking-wider mb-2">
{group.name ?? "Unassigned"}
</h3>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{group.items.map((plant) => (
<Link
key={plant.id}
href={`/garden/plants/${plant.id}`}
className="card p-3 hover:bg-[var(--surface-2)] transition-colors"
>
<div className="flex items-center gap-3">
{plant.primaryImageUrl ? (
<img
src={plant.primaryImageUrl}
alt=""
className="w-12 h-12 rounded-lg object-cover shrink-0"
/>
) : (
<div className="w-12 h-12 rounded-lg bg-[var(--surface-2)] shrink-0 flex items-center justify-center text-xl">
🌱
</div>
)}
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{plant.name}</p>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
<span className={`text-xs badge ${healthBadgeClass(plant.healthStatus)}`}>
{plant.healthStatus}
</span>
{plant.lastWateredAt && (
<span className="text-xs text-[var(--ink-mute)]">
Watered {daysAgo(plant.lastWateredAt)}
</span>
)}
</div>
{plant.hasOverdueCare && (
<p className="text-xs text-amber-600 mt-0.5">Care overdue</p>
)}
</div>
</div>
</Link>
))}
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,124 @@
import Link from "next/link";
import { logCare } from "../server/actions";
import type { CareDueWidgetRow, GardenOverviewStats } from "../server/queries";
const CARE_ICONS: Record<string, string> = {
watering: "💧",
fertilizing: "🌱",
repotting: "🪴",
pruning: "✂️",
"pest-control": "🐛",
other: "📋",
};
function urgencyLabel(days: number): { text: string; cls: string } {
if (days < 0)
return { text: `${Math.abs(days)}d overdue`, cls: "text-red-600 dark:text-red-400" };
if (days === 0) return { text: "due today", cls: "text-amber-600 dark:text-amber-400" };
return { text: `in ${days}d`, cls: "text-[var(--ink-mute)]" };
}
// ─── Care-due widget ──────────────────────────────────────────────────────────
export function CareDueWidget({ rows }: { rows: CareDueWidgetRow[] }) {
if (rows.length === 0) {
return <p className="text-sm text-[var(--ink-mute)]">All plants are on schedule.</p>;
}
const displayed = rows.slice(0, 10);
return (
<div className="flex flex-col gap-1">
{displayed.map((row) => {
const { text, cls } = urgencyLabel(row.daysUntilDue);
const icon = CARE_ICONS[row.careType] ?? "📋";
return (
<div key={row.scheduleId} className="flex items-center gap-2 py-1">
<span className="text-base leading-none" aria-hidden>
{icon}
</span>
<Link
href={`/garden/plants/${row.plantId}`}
className="text-sm flex-1 truncate hover:underline"
>
{row.plantName}
</Link>
<span className={`text-xs shrink-0 ${cls}`}>{text}</span>
<InlineLogButton plantId={row.plantId} careType={row.careType} />
</div>
);
})}
{rows.length > 10 && (
<Link href="/garden" className="text-xs text-[var(--ink-mute)] hover:underline mt-1">
View all ({rows.length})
</Link>
)}
</div>
);
}
function InlineLogButton({ plantId, careType }: { plantId: string; careType: string }) {
async function action() {
"use server";
await logCare({ plantId, careType });
}
return (
<form action={action}>
<button
type="submit"
title={`Log ${careType}`}
className="text-xs text-[var(--ink-mute)] hover:text-[var(--ink)] px-1"
>
</button>
</form>
);
}
// ─── Overview widget ──────────────────────────────────────────────────────────
export function GardenOverviewWidget({ stats }: { stats: GardenOverviewStats }) {
function nextCareLabel(): string {
const n = stats.nextCare;
if (!n) return "No upcoming care scheduled.";
const dayText =
n.daysUntilDue < 0
? "overdue"
: n.daysUntilDue === 0
? "today"
: `in ${n.daysUntilDue} day${n.daysUntilDue !== 1 ? "s" : ""}`;
const careLabel = n.careType.charAt(0).toUpperCase() + n.careType.slice(1);
return `${careLabel} ${n.plantName}${dayText}`;
}
return (
<div className="flex flex-col gap-3">
<div className="flex gap-4">
<div className="flex flex-col items-center">
<span className="text-2xl font-bold leading-none">{stats.plantCount}</span>
<span className="text-xs text-[var(--ink-mute)] mt-0.5">
{stats.plantCount === 1 ? "plant" : "plants"}
</span>
</div>
<div className="flex flex-col items-center">
<span className="text-2xl font-bold leading-none">{stats.containerCount}</span>
<span className="text-xs text-[var(--ink-mute)] mt-0.5">
{stats.containerCount === 1 ? "container" : "containers"}
</span>
</div>
{stats.overdueCount > 0 && (
<div className="flex flex-col items-center">
<span className="text-2xl font-bold leading-none text-red-500">
{stats.overdueCount}
</span>
<span className="text-xs text-[var(--ink-mute)] mt-0.5">overdue</span>
</div>
)}
</div>
<p className="text-sm text-[var(--ink-mute)]">{nextCareLabel()}</p>
<Link href="/garden" className="text-xs text-[var(--ink-mute)] hover:underline self-start">
View garden
</Link>
</div>
);
}
@@ -0,0 +1,47 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { pushOverdueToTaskList } from "../server/actions";
export function PushOverdueButton() {
const [message, setMessage] = useState<string | null>(null);
const [isError, setIsError] = useState(false);
const [isPending, startTransition] = useTransition();
useEffect(() => {
if (!message) return;
const id = setTimeout(() => setMessage(null), 4000);
return () => clearTimeout(id);
}, [message]);
function handleClick() {
setMessage(null);
setIsError(false);
startTransition(async () => {
try {
const { added } = await pushOverdueToTaskList();
setMessage(
added === 0
? "No overdue care tasks."
: `Added ${added} task${added !== 1 ? "s" : ""} to your task list.`,
);
} catch {
setIsError(true);
setMessage("Failed to push tasks. Please try again.");
}
});
}
return (
<div className="flex items-center gap-2">
<button onClick={handleClick} disabled={isPending} className="btn btn-ghost btn-sm">
{isPending ? "Pushing…" : "Add overdue to task list"}
</button>
{message && (
<span className={`text-xs ${isError ? "text-red-500" : "text-[var(--ink-mute)]"}`}>
{message}
</span>
)}
</div>
);
}
@@ -0,0 +1,107 @@
"use client";
import { useState, useEffect, useRef } from "react";
import type { SpeciesSuggestion } from "../server/species-lookup";
type Props = {
onSelect: (species: SpeciesSuggestion) => void;
initialValue?: string;
};
export function SpeciesSearch({ onSelect, initialValue = "" }: Props) {
const [query, setQuery] = useState(initialValue);
const [results, setResults] = useState<SpeciesSuggestion[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(
async () => {
if (!query.trim()) {
setResults([]);
setIsOpen(false);
return;
}
setIsLoading(true);
try {
const res = await fetch(`/api/garden/species-search?q=${encodeURIComponent(query)}`);
const data = (await res.json()) as SpeciesSuggestion[];
setResults(Array.isArray(data) ? data : []);
setIsOpen(Array.isArray(data) && data.length > 0);
} catch {
setResults([]);
} finally {
setIsLoading(false);
}
},
query.trim() ? 500 : 0,
);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [query]);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
function handleSelect(species: SpeciesSuggestion) {
onSelect(species);
setQuery(species.common_name);
setIsOpen(false);
setResults([]);
}
return (
<div ref={containerRef} className="relative">
<div className="relative">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => results.length > 0 && setIsOpen(true)}
placeholder="Search by common or scientific name…"
className="input w-full"
autoComplete="off"
/>
{isLoading && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-[var(--ink-mute)]">
</span>
)}
</div>
{isOpen && results.length > 0 && (
<ul className="absolute z-50 w-full mt-1 bg-[var(--card)] border border-[var(--hair-2)] rounded-md shadow-lg max-h-52 overflow-auto">
{results.map((s) => (
<li key={s.id}>
<button
type="button"
onClick={() => handleSelect(s)}
className="w-full text-left px-3 py-2 hover:bg-[var(--paper-2)] text-sm text-[var(--ink)]"
>
<span className="font-medium">{s.common_name}</span>
{s.scientific_name && (
<span className="text-[var(--ink-mute)] italic ml-2 text-xs">
{s.scientific_name}
</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
);
}
+240
View File
@@ -0,0 +1,240 @@
import { z } from "zod";
import { and, eq, sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { registerItemToggleHook } from "../_core/registry";
import type { ModuleManifest, WidgetContext } from "../_core/module";
import {
loadContainerForShare,
loadPlantForShare,
type ContainerShareData,
type PlantShareData,
} from "./server/share-queries";
import {
listContainers,
searchContainers,
searchPlants,
getCareDueWidgetRows,
getGardenOverviewStats,
} from "./server/queries";
import { CareDueWidget, GardenOverviewWidget } from "./components/plant-widget";
const careDueConfigSchema = z.object({
containerIds: z.union([z.literal("all"), z.array(z.string().uuid())]),
daysAhead: z.number().int().min(0).max(30).default(0),
});
async function CareDueWidgetServer({ config, ctx }: { config: unknown; ctx: WidgetContext }) {
const parsed = careDueConfigSchema.parse(config);
const rows = await getCareDueWidgetRows(ctx.householdId, parsed.containerIds, parsed.daysAhead);
return <CareDueWidget rows={rows} />;
}
async function GardenOverviewWidgetServer({ ctx }: { config: unknown; ctx: WidgetContext }) {
const stats = await getGardenOverviewStats(ctx.householdId);
return <GardenOverviewWidget stats={stats} />;
}
const gardenManifest: ModuleManifest = {
id: "garden",
name: "Garden",
nav: { href: "/garden", label: "Garden", icon: "sprout" },
entities: [
{
type: "garden.plant",
label: { singular: "Plant", plural: "Plants" },
share: { canShare: true, defaultCapabilities: ["read"] },
search: { search: searchPlants },
resolveUrl: (id) => `/garden/plants/${id}`,
loadForShare: (id) => loadPlantForShare(id),
renderSharedView: ({ data }) => {
const d = data as PlantShareData;
return (
<div className="flex flex-col gap-3">
{d.primaryImageUrl && (
<img
src={d.primaryImageUrl}
alt=""
className="w-full max-h-48 object-cover rounded-lg"
/>
)}
<h2 className="text-xl font-bold">{d.name}</h2>
{d.scientificName && (
<p className="text-sm italic text-[var(--ink-mute)]">{d.scientificName}</p>
)}
<p className="text-sm capitalize">
{d.healthStatus} · {d.category}
</p>
{d.wateringNotes && (
<div>
<p className="text-xs font-semibold uppercase text-[var(--ink-mute)] mb-1">
Watering
</p>
<p className="text-sm">{d.wateringNotes}</p>
</div>
)}
{d.fertilizingNotes && (
<div>
<p className="text-xs font-semibold uppercase text-[var(--ink-mute)] mb-1">
Fertilizing
</p>
<p className="text-sm">{d.fertilizingNotes}</p>
</div>
)}
{d.notes && <p className="text-sm">{d.notes}</p>}
{d.images.length > 1 && (
<div className="grid grid-cols-3 gap-2 mt-1">
{d.images.slice(1).map((url) => (
<img
key={url}
src={url}
alt=""
className="w-full aspect-square object-cover rounded"
/>
))}
</div>
)}
</div>
);
},
renderActivity: (entry) => {
const name = entry.payload?.name as string | undefined;
if (entry.action === "create") return `Added plant${name ? ` "${name}"` : ""}`;
if (entry.action === "delete") return `Removed plant${name ? ` "${name}"` : ""}`;
return `Updated${name ? ` "${name}"` : " plant"}`;
},
},
{
type: "garden.container",
label: { singular: "Container", plural: "Containers" },
share: { canShare: true, defaultCapabilities: ["read"] },
search: { search: searchContainers },
resolveUrl: (id) => `/garden/containers/${id}`,
loadForShare: (id) => loadContainerForShare(id),
renderSharedView: ({ data }) => {
const d = data as ContainerShareData;
return (
<div className="flex flex-col gap-3">
<h2 className="text-xl font-bold">{d.name}</h2>
<p className="text-sm capitalize text-[var(--ink-mute)]">{d.type}</p>
{d.locationNotes && <p className="text-sm">{d.locationNotes}</p>}
{d.plants.length > 0 && (
<div>
<p className="text-sm font-medium mb-1">Plants</p>
<ul className="text-sm space-y-1">
{d.plants.map((p) => (
<li key={p.id}>
{p.name}
{p.scientificName && (
<span className="text-[var(--ink-mute)] italic ml-1">
({p.scientificName})
</span>
)}
</li>
))}
</ul>
</div>
)}
</div>
);
},
renderActivity: (entry) => {
const name = entry.payload?.name as string | undefined;
if (entry.action === "create") return `Created container${name ? ` "${name}"` : ""}`;
if (entry.action === "delete") return "Deleted container";
return `Updated container${name ? ` "${name}"` : ""}`;
},
},
],
dashboardWidgets: [
{
id: "garden.care-due",
title: "Plants needing care",
description: "Shows plants with overdue or upcoming care schedules.",
category: "Garden",
defaultSize: { w: 4, h: 4 },
minSize: { w: 3, h: 2 },
defaultPriority: 40,
configSchema: careDueConfigSchema,
defaultConfig: { containerIds: "all", daysAhead: 0 },
resolveConfigOptions: async () => ({
containers: (await listContainers()).map((c) => ({ id: c.id, name: c.name })),
}),
render: (props) => <CareDueWidgetServer {...props} />,
},
{
id: "garden.overview",
title: "Garden overview",
description: "Plant and container counts with next care summary.",
category: "Garden",
defaultSize: { w: 3, h: 2 },
minSize: { w: 2, h: 2 },
defaultPriority: 41,
configSchema: z.object({}),
defaultConfig: {},
render: (props) => <GardenOverviewWidgetServer {...props} />,
},
],
quickAdds: [
{
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",
},
],
};
import { gardenCareLogs, gardenPlants } from "./schema";
registerItemToggleHook("garden.care-task", async ({ metadata, done, userId }) => {
if (!done) return;
const plantId = metadata.gardenPlantId;
const careType = metadata.gardenCareType;
if (typeof plantId !== "string" || typeof careType !== "string") return;
const [plant] = await db
.select({ householdId: gardenPlants.householdId })
.from(gardenPlants)
.where(eq(gardenPlants.id, plantId))
.limit(1);
if (!plant) return;
// Avoid duplicate log if care was already logged within the last 10 minutes
const recentLog = await db
.select({ id: gardenCareLogs.id })
.from(gardenCareLogs)
.where(
and(
eq(gardenCareLogs.plantId, plantId),
eq(gardenCareLogs.careType, careType),
sql`${gardenCareLogs.performedAt} > now() - interval '10 minutes'`,
),
)
.limit(1);
if (recentLog[0]) return;
await db.insert(gardenCareLogs).values({
plantId,
householdId: plant.householdId,
careType,
performedBy: userId,
notes: "Logged via task list",
performedAt: new Date(),
});
});
export default gardenManifest;
+119
View File
@@ -0,0 +1,119 @@
import {
boolean,
date,
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import { households, users } from "../_core/schema";
export const gardenContainers = pgTable(
"garden_containers",
{
id: uuid("id").primaryKey().defaultRandom(),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
name: text("name").notNull(),
type: text("type").notNull().default("other"),
locationNotes: text("location_notes"),
coverImageUrl: text("cover_image_url"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index("garden_containers_household_idx").on(t.householdId)],
);
export const gardenPlants = pgTable(
"garden_plants",
{
id: uuid("id").primaryKey().defaultRandom(),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
containerId: uuid("container_id").references(() => gardenContainers.id, {
onDelete: "set null",
}),
name: text("name").notNull(),
scientificName: text("scientific_name"),
speciesId: text("species_id"),
category: text("category").notNull().default("other"),
notes: text("notes"),
acquisitionDate: date("acquisition_date"),
growthStage: text("growth_stage"),
healthStatus: text("health_status").notNull().default("healthy"),
sunlight: text("sunlight"),
wateringNotes: text("watering_notes"),
fertilizingNotes: text("fertilizing_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("garden_plants_household_idx").on(t.householdId),
index("garden_plants_household_container_idx").on(t.householdId, t.containerId),
],
);
export const gardenCareLogs = pgTable(
"garden_care_logs",
{
id: uuid("id").primaryKey().defaultRandom(),
plantId: uuid("plant_id")
.notNull()
.references(() => gardenPlants.id, { onDelete: "cascade" }),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
careType: text("care_type").notNull(),
performedBy: uuid("performed_by").references(() => users.id, { onDelete: "set null" }),
notes: text("notes"),
performedAt: timestamp("performed_at", { withTimezone: true }).notNull().defaultNow(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index("garden_care_logs_plant_performed_idx").on(t.plantId, t.performedAt),
index("garden_care_logs_household_idx").on(t.householdId),
],
);
export const gardenCareSchedules = pgTable(
"garden_care_schedules",
{
id: uuid("id").primaryKey().defaultRandom(),
plantId: uuid("plant_id")
.notNull()
.references(() => gardenPlants.id, { onDelete: "cascade" }),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
careType: text("care_type").notNull(),
intervalDays: integer("interval_days").notNull(),
lastPerformedAt: timestamp("last_performed_at", { withTimezone: true }),
nextDueAt: timestamp("next_due_at", { withTimezone: true }),
enabled: boolean("enabled").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex("garden_care_schedules_plant_type_uq").on(t.plantId, t.careType),
index("garden_care_schedules_household_due_idx").on(t.householdId, t.nextDueAt),
],
);
export const gardenSpeciesCache = pgTable("garden_species_cache", {
speciesId: text("species_id").primaryKey(),
data: jsonb("data").notNull().$type<Record<string, unknown>>(),
cachedAt: timestamp("cached_at", { withTimezone: true }).notNull().defaultNow(),
});
export type GardenContainer = typeof gardenContainers.$inferSelect;
export type GardenPlant = typeof gardenPlants.$inferSelect;
export type GardenCareLog = typeof gardenCareLogs.$inferSelect;
export type GardenCareSchedule = typeof gardenCareSchedules.$inferSelect;
+630
View File
@@ -0,0 +1,630 @@
"use server";
import { and, eq, lte, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { logActivity } from "@/modules/_core/activity";
import { cancelReminder, scheduleReminder } from "@/modules/_core/reminders";
import { listItems } from "@/modules/lists/schema";
import { notifyListChanged } from "@/modules/lists/server/realtime";
import { gardenCareLogs, gardenCareSchedules, gardenContainers, gardenPlants } from "../schema";
import { createCalendarEvent } from "./calendar-bridge";
import { buildCareReminderBody, buildCareTitle } from "./care-utils";
import { updateScheduleAfterCare } from "./care-schedule";
import { addGardenCareTask, getList, listLists } from "./lists-bridge";
const containerInput = z.object({
name: z.string().trim().min(1).max(120),
type: z.string().trim().min(1).max(40).default("other"),
locationNotes: z.string().trim().max(500).nullable().optional(),
coverImageUrl: z.string().trim().max(500).nullable().optional(),
});
export async function createContainer(input: z.input<typeof containerInput>) {
const parsed = containerInput.parse(input);
const { household } = await getCurrentSession();
const [container] = await db
.insert(gardenContainers)
.values({
householdId: household.id,
name: parsed.name,
type: parsed.type,
locationNotes: parsed.locationNotes ?? null,
coverImageUrl: parsed.coverImageUrl ?? null,
})
.returning();
if (!container) throw new Error("Container was not created");
await logActivity({
entityType: "garden.container",
entityId: container.id,
action: "create",
payload: { name: container.name },
});
revalidatePath("/garden");
return container;
}
export async function updateContainer(
input: { id: string } & Partial<z.input<typeof containerInput>>,
) {
const parsed = z.object({ id: z.string().uuid() }).and(containerInput.partial()).parse(input);
const { household } = await getCurrentSession();
await assertCanAccessContainer(parsed.id, household.id);
await db
.update(gardenContainers)
.set({
name: parsed.name,
type: parsed.type,
locationNotes:
parsed.locationNotes === undefined ? undefined : (parsed.locationNotes ?? null),
coverImageUrl:
parsed.coverImageUrl === undefined ? undefined : (parsed.coverImageUrl ?? null),
updatedAt: new Date(),
})
.where(eq(gardenContainers.id, parsed.id));
await logActivity({
entityType: "garden.container",
entityId: parsed.id,
action: "update",
payload: { name: parsed.name },
});
revalidatePath("/garden");
revalidatePath(`/garden/containers/${parsed.id}`);
}
export async function deleteContainer(input: { id: string }) {
const parsed = z.object({ id: z.string().uuid() }).parse(input);
const { household } = await getCurrentSession();
await assertCanAccessContainer(parsed.id, household.id);
await logActivity({
entityType: "garden.container",
entityId: parsed.id,
action: "delete",
});
await db.delete(gardenContainers).where(eq(gardenContainers.id, parsed.id));
revalidatePath("/garden");
}
async function assertCanAccessContainer(id: string, householdId: string) {
const [row] = await db
.select({ id: gardenContainers.id })
.from(gardenContainers)
.where(and(eq(gardenContainers.id, id), eq(gardenContainers.householdId, householdId)))
.limit(1);
if (!row) throw new Error("Forbidden");
}
// ─── Plant actions ────────────────────────────────────────────────────────────
const plantInput = z.object({
name: z.string().trim().min(1).max(120),
category: z.string().trim().min(1).max(40).default("other"),
containerId: z.string().uuid().nullable().optional(),
healthStatus: z.string().trim().min(1).max(40).default("healthy"),
growthStage: z.string().trim().max(40).nullable().optional(),
scientificName: z.string().trim().max(200).nullable().optional(),
speciesId: z.string().trim().max(100).nullable().optional(),
sunlight: z.string().trim().max(200).nullable().optional(),
wateringNotes: z.string().trim().max(2000).nullable().optional(),
fertilizingNotes: z.string().trim().max(2000).nullable().optional(),
notes: z.string().trim().max(2000).nullable().optional(),
acquisitionDate: z.string().nullable().optional(),
images: z.array(z.string().url()).max(10).default([]),
primaryImageUrl: z.string().url().nullable().optional(),
});
export async function createPlant(input: z.input<typeof plantInput>) {
const parsed = plantInput.parse(input);
const { household } = await getCurrentSession();
if (parsed.containerId) {
await assertCanAccessContainer(parsed.containerId, household.id);
}
const [plant] = await db
.insert(gardenPlants)
.values({
householdId: household.id,
name: parsed.name,
category: parsed.category ?? "other",
containerId: parsed.containerId ?? null,
healthStatus: parsed.healthStatus ?? "healthy",
growthStage: parsed.growthStage ?? null,
scientificName: parsed.scientificName ?? null,
speciesId: parsed.speciesId ?? null,
sunlight: parsed.sunlight ?? null,
wateringNotes: parsed.wateringNotes ?? null,
fertilizingNotes: parsed.fertilizingNotes ?? null,
notes: parsed.notes ?? null,
acquisitionDate: parsed.acquisitionDate ?? null,
images: parsed.images,
primaryImageUrl: parsed.primaryImageUrl ?? parsed.images[0] ?? null,
})
.returning();
if (!plant) throw new Error("Plant was not created");
await logActivity({
entityType: "garden.plant",
entityId: plant.id,
action: "create",
payload: { name: plant.name },
});
revalidatePath("/garden");
return plant;
}
export async function updatePlant(input: { id: string } & Partial<z.input<typeof plantInput>>) {
const parsed = z.object({ id: z.string().uuid() }).and(plantInput.partial()).parse(input);
const { household } = await getCurrentSession();
await assertCanAccessPlant(parsed.id, household.id);
if (parsed.containerId) {
await assertCanAccessContainer(parsed.containerId, household.id);
}
await db
.update(gardenPlants)
.set({
name: parsed.name,
category: parsed.category,
containerId: parsed.containerId === undefined ? undefined : (parsed.containerId ?? null),
healthStatus: parsed.healthStatus,
growthStage: parsed.growthStage === undefined ? undefined : (parsed.growthStage ?? null),
scientificName:
parsed.scientificName === undefined ? undefined : (parsed.scientificName ?? null),
speciesId: parsed.speciesId === undefined ? undefined : (parsed.speciesId ?? null),
sunlight: parsed.sunlight === undefined ? undefined : (parsed.sunlight ?? null),
wateringNotes:
parsed.wateringNotes === undefined ? undefined : (parsed.wateringNotes ?? null),
fertilizingNotes:
parsed.fertilizingNotes === undefined ? undefined : (parsed.fertilizingNotes ?? null),
notes: parsed.notes === undefined ? undefined : (parsed.notes ?? null),
acquisitionDate:
parsed.acquisitionDate === undefined ? undefined : (parsed.acquisitionDate ?? null),
images: parsed.images,
primaryImageUrl:
parsed.primaryImageUrl === undefined ? undefined : (parsed.primaryImageUrl ?? null),
updatedAt: new Date(),
})
.where(eq(gardenPlants.id, parsed.id));
await logActivity({
entityType: "garden.plant",
entityId: parsed.id,
action: "update",
payload: { name: parsed.name },
});
revalidatePath("/garden");
revalidatePath(`/garden/plants/${parsed.id}`);
}
export async function deletePlant(input: { id: string }) {
const parsed = z.object({ id: z.string().uuid() }).parse(input);
const { household } = await getCurrentSession();
await assertCanAccessPlant(parsed.id, household.id);
const [plant] = await db
.select({ name: gardenPlants.name })
.from(gardenPlants)
.where(eq(gardenPlants.id, parsed.id))
.limit(1);
await logActivity({
entityType: "garden.plant",
entityId: parsed.id,
action: "delete",
payload: { name: plant?.name },
});
await db.delete(gardenPlants).where(eq(gardenPlants.id, parsed.id));
revalidatePath("/garden");
}
export async function addPlantImage(input: { id: string; url: string }) {
const parsed = z.object({ id: z.string().uuid(), url: z.string().url() }).parse(input);
const { household } = await getCurrentSession();
await assertCanAccessPlant(parsed.id, household.id);
const [row] = await db
.select({ images: gardenPlants.images })
.from(gardenPlants)
.where(eq(gardenPlants.id, parsed.id))
.limit(1);
if (!row) throw new Error("Plant not found");
if (row.images.length >= 10) throw new Error("Maximum 10 images allowed");
const newImages = [...row.images, parsed.url];
await db
.update(gardenPlants)
.set({
images: newImages,
primaryImageUrl: row.images.length === 0 ? parsed.url : undefined,
updatedAt: new Date(),
})
.where(eq(gardenPlants.id, parsed.id));
revalidatePath(`/garden/plants/${parsed.id}`);
}
export async function removePlantImage(input: { id: string; url: string }) {
const parsed = z.object({ id: z.string().uuid(), url: z.string() }).parse(input);
const { household } = await getCurrentSession();
await assertCanAccessPlant(parsed.id, household.id);
const [row] = await db
.select({ images: gardenPlants.images, primaryImageUrl: gardenPlants.primaryImageUrl })
.from(gardenPlants)
.where(eq(gardenPlants.id, parsed.id))
.limit(1);
if (!row) throw new Error("Plant not found");
const newImages = row.images.filter((u) => u !== parsed.url);
const wasPrimary = row.primaryImageUrl === parsed.url;
const newPrimary = wasPrimary ? (newImages[0] ?? null) : row.primaryImageUrl;
await db
.update(gardenPlants)
.set({ images: newImages, primaryImageUrl: newPrimary, updatedAt: new Date() })
.where(eq(gardenPlants.id, parsed.id));
revalidatePath(`/garden/plants/${parsed.id}`);
}
export async function setPrimaryImage(input: { id: string; url: string }) {
const parsed = z.object({ id: z.string().uuid(), url: z.string() }).parse(input);
const { household } = await getCurrentSession();
await assertCanAccessPlant(parsed.id, household.id);
const [row] = await db
.select({ images: gardenPlants.images })
.from(gardenPlants)
.where(eq(gardenPlants.id, parsed.id))
.limit(1);
if (!row) throw new Error("Plant not found");
if (!row.images.includes(parsed.url)) throw new Error("Image not in plant gallery");
await db
.update(gardenPlants)
.set({ primaryImageUrl: parsed.url, updatedAt: new Date() })
.where(eq(gardenPlants.id, parsed.id));
revalidatePath(`/garden/plants/${parsed.id}`);
}
async function assertCanAccessPlant(id: string, householdId: string) {
const [row] = await db
.select({ id: gardenPlants.id })
.from(gardenPlants)
.where(and(eq(gardenPlants.id, id), eq(gardenPlants.householdId, householdId)))
.limit(1);
if (!row) throw new Error("Forbidden");
}
// ─── Care log actions ─────────────────────────────────────────────────────────
const careLogInput = z.object({
plantId: z.string().uuid(),
careType: z.string().trim().min(1).max(40),
notes: z.string().trim().max(2000).nullable().optional(),
performedAt: z.string().optional(),
});
export async function logCare(input: z.input<typeof careLogInput>) {
const parsed = careLogInput.parse(input);
const { household, user } = await getCurrentSession();
await assertCanAccessPlant(parsed.plantId, household.id);
const performedAt = parsed.performedAt ? new Date(parsed.performedAt) : new Date();
const [log] = await db
.insert(gardenCareLogs)
.values({
plantId: parsed.plantId,
householdId: household.id,
careType: parsed.careType,
performedBy: user.id,
notes: parsed.notes ?? null,
performedAt,
})
.returning();
if (!log) throw new Error("Care log was not created");
await updateScheduleAfterCare(parsed.plantId, parsed.careType, user.id, household.id);
await logActivity({
entityType: "garden.plant",
entityId: parsed.plantId,
action: "update",
payload: { careType: parsed.careType },
});
// Mark linked task item done if one exists for this plant + care type
const linkedItems = await db
.select({ id: listItems.id, listId: listItems.listId })
.from(listItems)
.where(
and(
sql`${listItems.metadata}->>'gardenPlantId' = ${parsed.plantId}`,
sql`${listItems.metadata}->>'gardenCareType' = ${parsed.careType}`,
eq(listItems.done, false),
),
)
.limit(1);
if (linkedItems[0]) {
await db
.update(listItems)
.set({ done: true, updatedAt: new Date() })
.where(eq(listItems.id, linkedItems[0].id));
await notifyListChanged(linkedItems[0].listId);
revalidatePath(`/lists/${linkedItems[0].listId}`);
}
revalidatePath(`/garden/plants/${parsed.plantId}`);
return log;
}
export async function deleteCareLog(input: { id: string }) {
const parsed = z.object({ id: z.string().uuid() }).parse(input);
const { household } = await getCurrentSession();
const [row] = await db
.select({ id: gardenCareLogs.id, plantId: gardenCareLogs.plantId })
.from(gardenCareLogs)
.where(and(eq(gardenCareLogs.id, parsed.id), eq(gardenCareLogs.householdId, household.id)))
.limit(1);
if (!row) throw new Error("Forbidden");
await db.delete(gardenCareLogs).where(eq(gardenCareLogs.id, parsed.id));
revalidatePath(`/garden/plants/${row.plantId}`);
}
// ─── Care schedule actions ────────────────────────────────────────────────────
const careScheduleInput = z.object({
plantId: z.string().uuid(),
careType: z.string().trim().min(1).max(40),
intervalDays: z.number().int().min(1).max(365),
enabled: z.boolean().optional(),
});
export async function upsertCareSchedule(input: z.input<typeof careScheduleInput>) {
const parsed = careScheduleInput.parse(input);
const { household, user } = await getCurrentSession();
await assertCanAccessPlant(parsed.plantId, household.id);
const now = new Date();
const enabled = parsed.enabled ?? true;
const [existing] = await db
.select({ id: gardenCareSchedules.id, lastPerformedAt: gardenCareSchedules.lastPerformedAt })
.from(gardenCareSchedules)
.where(
and(
eq(gardenCareSchedules.plantId, parsed.plantId),
eq(gardenCareSchedules.careType, parsed.careType),
),
)
.limit(1);
const base = existing?.lastPerformedAt ?? now;
const nextDueAt = new Date(base.getTime() + parsed.intervalDays * 24 * 60 * 60 * 1000);
const [schedule] = await db
.insert(gardenCareSchedules)
.values({
plantId: parsed.plantId,
householdId: household.id,
careType: parsed.careType,
intervalDays: parsed.intervalDays,
nextDueAt,
enabled,
})
.onConflictDoUpdate({
target: [gardenCareSchedules.plantId, gardenCareSchedules.careType],
set: { intervalDays: parsed.intervalDays, nextDueAt, enabled, updatedAt: now },
})
.returning();
if (!schedule) throw new Error("Schedule was not created");
const [plantRow] = await db
.select({ name: gardenPlants.name })
.from(gardenPlants)
.where(eq(gardenPlants.id, parsed.plantId))
.limit(1);
await cancelReminder("garden.schedule", schedule.id);
if (enabled) {
await scheduleReminder({
householdId: household.id,
entityType: "garden.schedule",
entityId: schedule.id,
fireAt: nextDueAt,
createdBy: user.id,
title: "Garden care reminder",
body: plantRow ? buildCareReminderBody(parsed.careType, plantRow.name) : undefined,
});
}
revalidatePath(`/garden/plants/${parsed.plantId}`);
return schedule;
}
export async function deleteCareSchedule(input: { id: string }) {
const parsed = z.object({ id: z.string().uuid() }).parse(input);
const { household } = await getCurrentSession();
const [row] = await db
.select({ id: gardenCareSchedules.id, plantId: gardenCareSchedules.plantId })
.from(gardenCareSchedules)
.where(
and(eq(gardenCareSchedules.id, parsed.id), eq(gardenCareSchedules.householdId, household.id)),
)
.limit(1);
if (!row) throw new Error("Forbidden");
await cancelReminder("garden.schedule", parsed.id);
await db.delete(gardenCareSchedules).where(eq(gardenCareSchedules.id, parsed.id));
revalidatePath(`/garden/plants/${row.plantId}`);
}
export async function toggleCareSchedule(input: { id: string; enabled: boolean }) {
const parsed = z.object({ id: z.string().uuid(), enabled: z.boolean() }).parse(input);
const { household, user } = await getCurrentSession();
const [row] = await db
.select()
.from(gardenCareSchedules)
.where(
and(eq(gardenCareSchedules.id, parsed.id), eq(gardenCareSchedules.householdId, household.id)),
)
.limit(1);
if (!row) throw new Error("Forbidden");
await db
.update(gardenCareSchedules)
.set({ enabled: parsed.enabled, updatedAt: new Date() })
.where(eq(gardenCareSchedules.id, parsed.id));
await cancelReminder("garden.schedule", parsed.id);
if (parsed.enabled && row.nextDueAt) {
const [togglePlantRow] = await db
.select({ name: gardenPlants.name })
.from(gardenPlants)
.where(eq(gardenPlants.id, row.plantId))
.limit(1);
await scheduleReminder({
householdId: household.id,
entityType: "garden.schedule",
entityId: parsed.id,
fireAt: row.nextDueAt,
createdBy: user.id,
title: "Garden care reminder",
body: togglePlantRow ? buildCareReminderBody(row.careType, togglePlantRow.name) : undefined,
});
}
revalidatePath(`/garden/plants/${row.plantId}`);
}
// ─── Calendar integration ─────────────────────────────────────────────────────
const scheduleOnCalendarInput = z.object({
scheduleId: z.string().uuid(),
calendarId: z.string().uuid(),
reminderMinutesBefore: z.number().int().min(0).max(1440).optional(),
});
export async function scheduleOnCalendar(input: z.input<typeof scheduleOnCalendarInput>) {
const parsed = scheduleOnCalendarInput.parse(input);
const { household } = await getCurrentSession();
const [row] = await db
.select({
nextDueAt: gardenCareSchedules.nextDueAt,
careType: gardenCareSchedules.careType,
plantName: gardenPlants.name,
})
.from(gardenCareSchedules)
.innerJoin(gardenPlants, eq(gardenCareSchedules.plantId, gardenPlants.id))
.where(
and(
eq(gardenCareSchedules.id, parsed.scheduleId),
eq(gardenCareSchedules.householdId, household.id),
),
)
.limit(1);
if (!row) throw new Error("Forbidden");
if (!row.nextDueAt) throw new Error("This schedule has no upcoming due date set.");
const startAt = row.nextDueAt;
const endAt = new Date(startAt.getTime() + 30 * 60 * 1000);
const title = buildCareTitle(row.careType, row.plantName);
await createCalendarEvent({
calendarId: parsed.calendarId,
title,
startAt,
endAt,
allDay: false,
notes: `Scheduled from garden. Next due: ${startAt.toLocaleDateString()}`,
remindMinutesBefore: parsed.reminderMinutesBefore ?? null,
});
revalidatePath("/calendar");
}
// ─── Lists integration ────────────────────────────────────────────────────────
export async function pushOverdueToTaskList(): Promise<{ added: number }> {
const { household } = await getCurrentSession();
const overduePairs = await db
.select({
plantId: gardenPlants.id,
plantName: gardenPlants.name,
careType: gardenCareSchedules.careType,
nextDueAt: gardenCareSchedules.nextDueAt,
})
.from(gardenCareSchedules)
.innerJoin(gardenPlants, eq(gardenCareSchedules.plantId, gardenPlants.id))
.where(
and(
eq(gardenCareSchedules.householdId, household.id),
eq(gardenCareSchedules.enabled, true),
lte(gardenCareSchedules.nextDueAt, sql`now()`),
),
);
if (overduePairs.length === 0) return { added: 0 };
const taskLists = await listLists({ type: "task" });
const taskList = taskLists[0];
if (!taskList) return { added: 0 };
const listDetail = await getList(taskList.id);
const existingTexts = new Set(listDetail.items.map((i) => i.text));
let added = 0;
for (const pair of overduePairs) {
const title = buildCareTitle(pair.careType, pair.plantName);
if (existingTexts.has(title)) continue;
const daysOverdue = pair.nextDueAt
? Math.abs(Math.floor((Date.now() - pair.nextDueAt.getTime()) / (1000 * 60 * 60 * 24)))
: 0;
await addGardenCareTask({
listId: taskList.id,
text: title,
notes: `Overdue by ${daysOverdue} day(s)`,
dueAt: pair.nextDueAt,
gardenPlantId: pair.plantId,
gardenCareType: pair.careType,
});
existingTexts.add(title);
added++;
}
return { added };
}
@@ -0,0 +1,3 @@
export { createEvent as createCalendarEvent } from "@/modules/calendar/server/actions";
export { listCalendars } from "@/modules/calendar/server/queries";
export type { CalendarDto } from "@/modules/calendar/server/queries";
@@ -0,0 +1,47 @@
import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { cancelReminder, scheduleReminder } from "@/modules/_core/reminders";
import { gardenCareSchedules, gardenPlants } from "../schema";
import { buildCareReminderBody } from "./care-utils";
export async function updateScheduleAfterCare(
plantId: string,
careType: string,
userId: string,
householdId: string,
) {
const [schedule] = await db
.select()
.from(gardenCareSchedules)
.where(
and(eq(gardenCareSchedules.plantId, plantId), eq(gardenCareSchedules.careType, careType)),
)
.limit(1);
if (!schedule || !schedule.enabled) return;
const now = new Date();
const nextDueAt = new Date(now.getTime() + schedule.intervalDays * 24 * 60 * 60 * 1000);
await db
.update(gardenCareSchedules)
.set({ lastPerformedAt: now, nextDueAt, updatedAt: now })
.where(eq(gardenCareSchedules.id, schedule.id));
const [plant] = await db
.select({ name: gardenPlants.name })
.from(gardenPlants)
.where(eq(gardenPlants.id, plantId))
.limit(1);
await cancelReminder("garden.schedule", schedule.id);
await scheduleReminder({
householdId,
entityType: "garden.schedule",
entityId: schedule.id,
fireAt: nextDueAt,
createdBy: userId,
title: "Garden care reminder",
body: plant ? buildCareReminderBody(careType, plant.name) : undefined,
});
}
+16
View File
@@ -0,0 +1,16 @@
const CARE_VERBS: Record<string, string> = {
watering: "Water",
fertilizing: "Fertilize",
repotting: "Repot",
pruning: "Prune",
};
export function buildCareTitle(careType: string, plantName: string): string {
const verb = CARE_VERBS[careType];
return verb ? `${verb} ${plantName}` : `${careType}${plantName}`;
}
export function buildCareReminderBody(careType: string, plantName: string): string {
const verb = CARE_VERBS[careType]?.toLowerCase() ?? careType;
return `Time to ${verb} ${plantName}`;
}
+21
View File
@@ -0,0 +1,21 @@
export { addItem as addListItem } from "@/modules/lists/server/actions";
export { getList, listLists } from "@/modules/lists/server/queries";
import { addItem } from "@/modules/lists/server/actions";
export async function addGardenCareTask(input: {
listId: string;
text: string;
notes?: string | null;
dueAt?: Date | null;
gardenPlantId: string;
gardenCareType: string;
}) {
return addItem({
listId: input.listId,
text: input.text,
notes: input.notes ?? null,
dueAt: input.dueAt ?? null,
metadata: { gardenPlantId: input.gardenPlantId, gardenCareType: input.gardenCareType },
});
}
+619
View File
@@ -0,0 +1,619 @@
import { and, desc, eq, lte, sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { gardenCareLogs, gardenCareSchedules, gardenContainers, gardenPlants } from "../schema";
export type ContainerDto = {
id: string;
householdId: string;
name: string;
type: string;
locationNotes: string | null;
coverImageUrl: string | null;
plantCount: number;
createdAt: string;
updatedAt: string;
};
export type ContainerDetailDto = ContainerDto & {
plants: PlantSummaryDto[];
};
export type PlantSummaryDto = {
id: string;
name: string;
scientificName: string | null;
healthStatus: string;
primaryImageUrl: string | null;
category: string;
};
export async function listContainers(): Promise<ContainerDto[]> {
const { household } = await getCurrentSession();
const rows = await db
.select({
id: gardenContainers.id,
householdId: gardenContainers.householdId,
name: gardenContainers.name,
type: gardenContainers.type,
locationNotes: gardenContainers.locationNotes,
coverImageUrl: gardenContainers.coverImageUrl,
createdAt: gardenContainers.createdAt,
updatedAt: gardenContainers.updatedAt,
plantCount: sql<number>`count(${gardenPlants.id})::int`,
})
.from(gardenContainers)
.leftJoin(gardenPlants, eq(gardenPlants.containerId, gardenContainers.id))
.where(eq(gardenContainers.householdId, household.id))
.groupBy(gardenContainers.id)
.orderBy(gardenContainers.name);
return rows.map((r) => ({
...r,
plantCount: r.plantCount ?? 0,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
}));
}
export async function getContainer(id: string): Promise<ContainerDetailDto | null> {
const { household } = await getCurrentSession();
const [container] = await db
.select({
id: gardenContainers.id,
householdId: gardenContainers.householdId,
name: gardenContainers.name,
type: gardenContainers.type,
locationNotes: gardenContainers.locationNotes,
coverImageUrl: gardenContainers.coverImageUrl,
createdAt: gardenContainers.createdAt,
updatedAt: gardenContainers.updatedAt,
plantCount: sql<number>`(select count(*)::int from garden_plants where container_id = ${gardenContainers.id})`,
})
.from(gardenContainers)
.where(and(eq(gardenContainers.id, id), eq(gardenContainers.householdId, household.id)))
.limit(1);
if (!container) return null;
const plants = await db
.select({
id: gardenPlants.id,
name: gardenPlants.name,
scientificName: gardenPlants.scientificName,
healthStatus: gardenPlants.healthStatus,
primaryImageUrl: gardenPlants.primaryImageUrl,
category: gardenPlants.category,
})
.from(gardenPlants)
.where(eq(gardenPlants.containerId, id))
.orderBy(desc(gardenPlants.name));
return {
id: container.id,
householdId: container.householdId,
name: container.name,
type: container.type,
locationNotes: container.locationNotes,
coverImageUrl: container.coverImageUrl,
plantCount: container.plantCount ?? 0,
createdAt: container.createdAt.toISOString(),
updatedAt: container.updatedAt.toISOString(),
plants,
};
}
export async function canAccessContainer(id: string, householdId: string): Promise<boolean> {
const [row] = await db
.select({ id: gardenContainers.id })
.from(gardenContainers)
.where(and(eq(gardenContainers.id, id), eq(gardenContainers.householdId, householdId)))
.limit(1);
return !!row;
}
export async function searchContainers(query: string, householdId: string) {
const rows = await db
.select({ id: gardenContainers.id, name: gardenContainers.name })
.from(gardenContainers)
.where(
and(
eq(gardenContainers.householdId, householdId),
sql`${gardenContainers.name} ilike ${`%${query}%`}`,
),
)
.limit(10);
return rows.map((r) => ({
id: r.id,
title: r.name,
url: `/garden/containers/${r.id}`,
}));
}
// ─── Plant queries ────────────────────────────────────────────────────────────
export type PlantListItemDto = {
id: string;
containerId: string | null;
containerName: string | null;
name: string;
healthStatus: string;
primaryImageUrl: string | null;
category: string;
lastWateredAt: string | null;
hasOverdueCare: boolean;
};
export type CareLogSummaryDto = {
id: string;
careType: string;
notes: string | null;
performedAt: string;
};
export type CareScheduleSummaryDto = {
id: string;
careType: string;
intervalDays: number;
nextDueAt: string | null;
enabled: boolean;
};
export type PlantDetailDto = {
id: string;
householdId: string;
containerId: string | null;
containerName: string | null;
name: string;
scientificName: string | null;
speciesId: string | null;
category: string;
notes: string | null;
acquisitionDate: string | null;
growthStage: string | null;
healthStatus: string;
sunlight: string | null;
wateringNotes: string | null;
fertilizingNotes: string | null;
primaryImageUrl: string | null;
images: string[];
recentCareLogs: CareLogSummaryDto[];
activeSchedules: CareScheduleSummaryDto[];
createdAt: string;
updatedAt: string;
};
export async function listPlants({ containerId }: { containerId?: string } = {}): Promise<
PlantListItemDto[]
> {
const { household } = await getCurrentSession();
const filter = containerId
? and(eq(gardenPlants.householdId, household.id), eq(gardenPlants.containerId, containerId))
: eq(gardenPlants.householdId, household.id);
const rows = await db
.select({
id: gardenPlants.id,
containerId: gardenPlants.containerId,
containerName: gardenContainers.name,
name: gardenPlants.name,
healthStatus: gardenPlants.healthStatus,
primaryImageUrl: gardenPlants.primaryImageUrl,
category: gardenPlants.category,
lastWateredAt: sql<string | null>`(
select performed_at::text from garden_care_logs
where plant_id = ${gardenPlants.id}
and care_type = 'watering'
order by performed_at desc
limit 1
)`,
hasOverdueCare: sql<boolean>`exists(
select 1 from garden_care_schedules
where plant_id = ${gardenPlants.id}
and enabled = true
and next_due_at < now()
)`,
})
.from(gardenPlants)
.leftJoin(gardenContainers, eq(gardenPlants.containerId, gardenContainers.id))
.where(filter)
.orderBy(gardenContainers.name, gardenPlants.name);
return rows.map((r) => ({
...r,
containerName: r.containerName ?? null,
lastWateredAt: r.lastWateredAt ?? null,
hasOverdueCare: !!r.hasOverdueCare,
}));
}
export async function getPlant(id: string): Promise<PlantDetailDto | null> {
const { household } = await getCurrentSession();
const [row] = await db
.select({
id: gardenPlants.id,
householdId: gardenPlants.householdId,
containerId: gardenPlants.containerId,
containerName: gardenContainers.name,
name: gardenPlants.name,
scientificName: gardenPlants.scientificName,
speciesId: gardenPlants.speciesId,
category: gardenPlants.category,
notes: gardenPlants.notes,
acquisitionDate: gardenPlants.acquisitionDate,
growthStage: gardenPlants.growthStage,
healthStatus: gardenPlants.healthStatus,
sunlight: gardenPlants.sunlight,
wateringNotes: gardenPlants.wateringNotes,
fertilizingNotes: gardenPlants.fertilizingNotes,
primaryImageUrl: gardenPlants.primaryImageUrl,
images: gardenPlants.images,
createdAt: gardenPlants.createdAt,
updatedAt: gardenPlants.updatedAt,
})
.from(gardenPlants)
.leftJoin(gardenContainers, eq(gardenPlants.containerId, gardenContainers.id))
.where(and(eq(gardenPlants.id, id), eq(gardenPlants.householdId, household.id)))
.limit(1);
if (!row) return null;
const careLogs = await db
.select({
id: gardenCareLogs.id,
careType: gardenCareLogs.careType,
notes: gardenCareLogs.notes,
performedAt: gardenCareLogs.performedAt,
})
.from(gardenCareLogs)
.where(eq(gardenCareLogs.plantId, id))
.orderBy(desc(gardenCareLogs.performedAt))
.limit(5);
const schedules = await db
.select({
id: gardenCareSchedules.id,
careType: gardenCareSchedules.careType,
intervalDays: gardenCareSchedules.intervalDays,
nextDueAt: gardenCareSchedules.nextDueAt,
enabled: gardenCareSchedules.enabled,
})
.from(gardenCareSchedules)
.where(and(eq(gardenCareSchedules.plantId, id), eq(gardenCareSchedules.enabled, true)));
return {
id: row.id,
householdId: row.householdId,
containerId: row.containerId,
containerName: row.containerName ?? null,
name: row.name,
scientificName: row.scientificName,
speciesId: row.speciesId,
category: row.category,
notes: row.notes,
acquisitionDate: row.acquisitionDate,
growthStage: row.growthStage,
healthStatus: row.healthStatus,
sunlight: row.sunlight,
wateringNotes: row.wateringNotes,
fertilizingNotes: row.fertilizingNotes,
primaryImageUrl: row.primaryImageUrl,
images: row.images,
recentCareLogs: careLogs.map((l) => ({
id: l.id,
careType: l.careType,
notes: l.notes,
performedAt: l.performedAt.toISOString(),
})),
activeSchedules: schedules.map((s) => ({
id: s.id,
careType: s.careType,
intervalDays: s.intervalDays,
nextDueAt: s.nextDueAt?.toISOString() ?? null,
enabled: s.enabled,
})),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export async function searchPlants(query: string, householdId: string) {
const rows = await db
.select({
id: gardenPlants.id,
name: gardenPlants.name,
scientificName: gardenPlants.scientificName,
})
.from(gardenPlants)
.where(
and(
eq(gardenPlants.householdId, householdId),
sql`(${gardenPlants.name} || ' ' || coalesce(${gardenPlants.scientificName}, '')) ilike ${`%${query}%`}`,
),
)
.limit(10);
return rows.map((r) => ({
id: r.id,
title: r.name + (r.scientificName ? ` (${r.scientificName})` : ""),
url: `/garden/plants/${r.id}`,
}));
}
// ─── Care queries ─────────────────────────────────────────────────────────────
export type CareLogDto = {
id: string;
careType: string;
notes: string | null;
performedAt: string;
performedBy: string | null;
};
export type CareScheduleDto = {
id: string;
careType: string;
intervalDays: number;
lastPerformedAt: string | null;
nextDueAt: string | null;
enabled: boolean;
daysUntilDue: number | null;
isOverdue: boolean;
};
export async function getCareLogs(plantId: string, limit = 20): Promise<CareLogDto[]> {
const { household } = await getCurrentSession();
const rows = await db
.select({
id: gardenCareLogs.id,
careType: gardenCareLogs.careType,
notes: gardenCareLogs.notes,
performedAt: gardenCareLogs.performedAt,
performedBy: gardenCareLogs.performedBy,
})
.from(gardenCareLogs)
.where(and(eq(gardenCareLogs.plantId, plantId), eq(gardenCareLogs.householdId, household.id)))
.orderBy(desc(gardenCareLogs.performedAt))
.limit(limit);
return rows.map((r) => ({
id: r.id,
careType: r.careType,
notes: r.notes,
performedAt: r.performedAt.toISOString(),
performedBy: r.performedBy,
}));
}
export async function getCareSchedules(plantId: string): Promise<CareScheduleDto[]> {
const { household } = await getCurrentSession();
const rows = await db
.select()
.from(gardenCareSchedules)
.where(
and(
eq(gardenCareSchedules.plantId, plantId),
eq(gardenCareSchedules.householdId, household.id),
),
)
.orderBy(gardenCareSchedules.careType);
const now = new Date();
return rows.map((s) => {
const next = s.nextDueAt;
const daysUntilDue = next
? Math.ceil((next.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
: null;
return {
id: s.id,
careType: s.careType,
intervalDays: s.intervalDays,
lastPerformedAt: s.lastPerformedAt?.toISOString() ?? null,
nextDueAt: next?.toISOString() ?? null,
enabled: s.enabled,
daysUntilDue,
isOverdue: daysUntilDue !== null && daysUntilDue < 0,
};
});
}
export type OverduePlantDto = {
id: string;
name: string;
primaryImageUrl: string | null;
mostOverdueAt: Date;
};
export async function getOverduePlants(householdId: string): Promise<OverduePlantDto[]> {
const rows = await db
.select({
id: gardenPlants.id,
name: gardenPlants.name,
primaryImageUrl: gardenPlants.primaryImageUrl,
mostOverdueAt: sql<Date>`min(${gardenCareSchedules.nextDueAt})`,
})
.from(gardenPlants)
.innerJoin(
gardenCareSchedules,
and(
eq(gardenCareSchedules.plantId, gardenPlants.id),
eq(gardenCareSchedules.enabled, true),
lte(gardenCareSchedules.nextDueAt, sql`now()`),
),
)
.where(eq(gardenPlants.householdId, householdId))
.groupBy(gardenPlants.id, gardenPlants.name, gardenPlants.primaryImageUrl)
.orderBy(sql`min(${gardenCareSchedules.nextDueAt})`);
return rows;
}
export type CareDueSoonDto = {
id: string;
name: string;
primaryImageUrl: string | null;
nextDueAt: Date;
};
export async function getCareDueSoon(
householdId: string,
withinDays: number,
): Promise<CareDueSoonDto[]> {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() + withinDays);
const rows = await db
.select({
id: gardenPlants.id,
name: gardenPlants.name,
primaryImageUrl: gardenPlants.primaryImageUrl,
nextDueAt: sql<Date>`min(${gardenCareSchedules.nextDueAt})`,
})
.from(gardenPlants)
.innerJoin(
gardenCareSchedules,
and(
eq(gardenCareSchedules.plantId, gardenPlants.id),
eq(gardenCareSchedules.enabled, true),
lte(gardenCareSchedules.nextDueAt, cutoff),
),
)
.where(eq(gardenPlants.householdId, householdId))
.groupBy(gardenPlants.id, gardenPlants.name, gardenPlants.primaryImageUrl)
.orderBy(sql`min(${gardenCareSchedules.nextDueAt})`);
return rows;
}
// ─── Widget queries ───────────────────────────────────────────────────────────
export type CareDueWidgetRow = {
plantId: string;
plantName: string;
scheduleId: string;
careType: string;
nextDueAt: Date | null;
daysUntilDue: number;
};
export async function getCareDueWidgetRows(
householdId: string,
containerIds: "all" | string[],
daysAhead: number,
): Promise<CareDueWidgetRow[]> {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() + daysAhead);
const baseConditions = [
eq(gardenCareSchedules.householdId, householdId),
eq(gardenCareSchedules.enabled, true),
lte(gardenCareSchedules.nextDueAt, cutoff),
];
const containerFilter =
containerIds !== "all" && containerIds.length > 0
? sql`${gardenPlants.containerId} = ANY(ARRAY[${sql.join(
containerIds.map((id) => sql`${id}::uuid`),
sql`, `,
)}])`
: undefined;
const where =
containerFilter !== undefined
? and(...baseConditions, containerFilter)
: and(...baseConditions);
const rows = await db
.select({
plantId: gardenPlants.id,
plantName: gardenPlants.name,
scheduleId: gardenCareSchedules.id,
careType: gardenCareSchedules.careType,
nextDueAt: gardenCareSchedules.nextDueAt,
})
.from(gardenCareSchedules)
.innerJoin(gardenPlants, eq(gardenCareSchedules.plantId, gardenPlants.id))
.where(where)
.orderBy(gardenCareSchedules.nextDueAt);
const now = Date.now();
return rows.map((r) => ({
plantId: r.plantId,
plantName: r.plantName,
scheduleId: r.scheduleId,
careType: r.careType,
nextDueAt: r.nextDueAt,
daysUntilDue: r.nextDueAt
? Math.ceil((r.nextDueAt.getTime() - now) / (1000 * 60 * 60 * 24))
: 0,
}));
}
export type GardenOverviewStats = {
plantCount: number;
containerCount: number;
overdueCount: number;
nextCare: { plantName: string; careType: string; daysUntilDue: number } | null;
};
export async function getGardenOverviewStats(householdId: string): Promise<GardenOverviewStats> {
const [plantRow, containerRow] = await Promise.all([
db
.select({ count: sql<number>`count(*)::int` })
.from(gardenPlants)
.where(eq(gardenPlants.householdId, householdId)),
db
.select({ count: sql<number>`count(*)::int` })
.from(gardenContainers)
.where(eq(gardenContainers.householdId, householdId)),
]);
const plantCount = plantRow[0]?.count ?? 0;
const containerCount = containerRow[0]?.count ?? 0;
const overdueRows = await db
.select({ count: sql<number>`count(*)::int` })
.from(gardenCareSchedules)
.where(
and(
eq(gardenCareSchedules.householdId, householdId),
eq(gardenCareSchedules.enabled, true),
lte(gardenCareSchedules.nextDueAt, sql`now()`),
),
);
const overdueCount = overdueRows[0]?.count ?? 0;
const nextRows = await db
.select({
plantName: gardenPlants.name,
careType: gardenCareSchedules.careType,
nextDueAt: gardenCareSchedules.nextDueAt,
})
.from(gardenCareSchedules)
.innerJoin(gardenPlants, eq(gardenCareSchedules.plantId, gardenPlants.id))
.where(
and(eq(gardenCareSchedules.householdId, householdId), eq(gardenCareSchedules.enabled, true)),
)
.orderBy(gardenCareSchedules.nextDueAt)
.limit(1);
const next = nextRows[0];
const now = Date.now();
const nextCare = next?.nextDueAt
? {
plantName: next.plantName,
careType: next.careType,
daysUntilDue: Math.ceil((next.nextDueAt.getTime() - now) / (1000 * 60 * 60 * 24)),
}
: null;
return { plantCount, containerCount, overdueCount, nextCare };
}
@@ -0,0 +1,79 @@
import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { gardenContainers, gardenPlants } from "../schema";
export type ContainerShareData = {
id: string;
name: string;
type: string;
locationNotes: string | null;
coverImageUrl: string | null;
plants: { id: string; name: string; scientificName: string | null }[];
};
export type PlantShareData = {
id: string;
name: string;
scientificName: string | null;
primaryImageUrl: string | null;
images: string[];
healthStatus: string;
growthStage: string | null;
category: string;
wateringNotes: string | null;
fertilizingNotes: string | null;
notes: string | null;
sunlight: string | null;
};
export async function loadPlantForShare(id: string): Promise<PlantShareData | null> {
const [plant] = await db
.select({
id: gardenPlants.id,
name: gardenPlants.name,
scientificName: gardenPlants.scientificName,
primaryImageUrl: gardenPlants.primaryImageUrl,
images: gardenPlants.images,
healthStatus: gardenPlants.healthStatus,
growthStage: gardenPlants.growthStage,
category: gardenPlants.category,
wateringNotes: gardenPlants.wateringNotes,
fertilizingNotes: gardenPlants.fertilizingNotes,
notes: gardenPlants.notes,
sunlight: gardenPlants.sunlight,
})
.from(gardenPlants)
.where(eq(gardenPlants.id, id))
.limit(1);
if (!plant) return null;
return plant;
}
export async function loadContainerForShare(id: string): Promise<ContainerShareData | null> {
const [container] = await db
.select()
.from(gardenContainers)
.where(eq(gardenContainers.id, id))
.limit(1);
if (!container) return null;
const plants = await db
.select({
id: gardenPlants.id,
name: gardenPlants.name,
scientificName: gardenPlants.scientificName,
})
.from(gardenPlants)
.where(and(eq(gardenPlants.containerId, id)));
return {
id: container.id,
name: container.name,
type: container.type,
locationNotes: container.locationNotes,
coverImageUrl: container.coverImageUrl,
plants,
};
}
+114
View File
@@ -0,0 +1,114 @@
import { and, eq, gt } from "drizzle-orm";
import { db } from "@/lib/db";
import { gardenSpeciesCache } from "../schema";
export type SpeciesSuggestion = {
id: string;
common_name: string;
scientific_name: string;
watering: string;
sunlight: string[];
cycle: string;
default_image_url: string | null;
};
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const BASE_URL = "https://perenual.com";
function parseItem(item: Record<string, unknown>): SpeciesSuggestion {
const rawNames = item.scientific_name;
const scientificName = Array.isArray(rawNames) && rawNames.length > 0 ? String(rawNames[0]) : "";
const sunlight = Array.isArray(item.sunlight) ? item.sunlight.map(String) : [];
const defaultImage = item.default_image as Record<string, unknown> | null | undefined;
const rawImageUrl = defaultImage?.regular_url ?? defaultImage?.medium_url ?? null;
return {
id: String(item.id),
common_name: String(item.common_name ?? ""),
scientific_name: scientificName,
watering: String(item.watering ?? ""),
sunlight,
cycle: String(item.cycle ?? ""),
default_image_url: rawImageUrl ? String(rawImageUrl) : null,
};
}
export async function searchSpecies(query: string): Promise<SpeciesSuggestion[]> {
const apiKey = process.env.PERENUAL_API_KEY;
if (!apiKey) return [];
if (!query.trim()) return [];
try {
const url = `${BASE_URL}/api/species-list?key=${encodeURIComponent(apiKey)}&q=${encodeURIComponent(query)}`;
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) throw new Error(`Perenual API error: ${res.status}`);
const json = (await res.json()) as { data?: unknown[] };
if (!Array.isArray(json.data)) return [];
const results = json.data.map((item) => parseItem(item as Record<string, unknown>));
await Promise.allSettled(
results.map((r) =>
db
.insert(gardenSpeciesCache)
.values({
speciesId: r.id,
data: r as unknown as Record<string, unknown>,
cachedAt: new Date(),
})
.onConflictDoUpdate({
target: gardenSpeciesCache.speciesId,
set: { data: r as unknown as Record<string, unknown>, cachedAt: new Date() },
}),
),
);
return results;
} catch (err) {
console.error("[species-lookup] searchSpecies failed", err);
return [];
}
}
export async function getSpeciesById(id: string): Promise<SpeciesSuggestion | null> {
const cutoff = new Date(Date.now() - CACHE_TTL_MS);
const [cached] = await db
.select()
.from(gardenSpeciesCache)
.where(and(eq(gardenSpeciesCache.speciesId, id), gt(gardenSpeciesCache.cachedAt, cutoff)))
.limit(1);
if (cached) return cached.data as unknown as SpeciesSuggestion;
const apiKey = process.env.PERENUAL_API_KEY;
if (!apiKey) return null;
try {
const url = `${BASE_URL}/api/species/details/${encodeURIComponent(id)}?key=${encodeURIComponent(apiKey)}`;
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) return null;
const item = (await res.json()) as Record<string, unknown>;
const result = parseItem(item);
await db
.insert(gardenSpeciesCache)
.values({
speciesId: result.id,
data: result as unknown as Record<string, unknown>,
cachedAt: new Date(),
})
.onConflictDoUpdate({
target: gardenSpeciesCache.speciesId,
set: { data: result as unknown as Record<string, unknown>, cachedAt: new Date() },
});
return result;
} catch (err) {
console.error("[species-lookup] getSpeciesById failed", err);
return null;
}
}
+2
View File
@@ -3,8 +3,10 @@ import coreManifest from "./_core/manifest";
import calendarManifest from "./calendar/manifest";
import listsManifest from "./lists/manifest";
import notesManifest from "./notes/manifest";
import gardenManifest from "./garden/manifest";
registerModule(coreManifest);
registerModule(calendarManifest);
registerModule(listsManifest);
registerModule(notesManifest);
registerModule(gardenManifest);
+11 -1
View File
@@ -1,4 +1,13 @@
import { index, integer, pgTable, text, timestamp, uuid, boolean } from "drizzle-orm/pg-core";
import {
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uuid,
boolean,
} from "drizzle-orm/pg-core";
import { households, users } from "../_core/schema";
export const lists = pgTable(
@@ -33,6 +42,7 @@ export const listItems = pgTable(
dueAt: timestamp("due_at", { withTimezone: true }),
assigneeId: uuid("assignee_id").references(() => users.id, { onDelete: "set null" }),
position: integer("position").notNull().default(0),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
+15 -1
View File
@@ -6,6 +6,7 @@ import { z } from "zod";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { logActivity } from "@/modules/_core/activity";
import { fireItemToggleHooks } from "@/modules/_core/registry";
import { listItems, lists } from "../schema";
import { getOrCreateDefaultList } from "./defaults";
import { canAccessList, getList } from "./queries";
@@ -23,6 +24,7 @@ const itemInput = z.object({
notes: z.string().trim().max(2000).nullable().optional(),
dueAt: z.coerce.date().nullable().optional(),
assigneeId: z.string().uuid().nullable().optional(),
metadata: z.record(z.string(), z.unknown()).nullable().optional(),
});
const updateItemInput = z.object({
@@ -103,6 +105,7 @@ export async function addItem(input: z.input<typeof itemInput>) {
notes: parsed.notes || null,
dueAt: parsed.dueAt ?? null,
assigneeId: parsed.assigneeId ?? null,
metadata: parsed.metadata ?? null,
position: (positionRow?.maxPosition ?? -1) + 1,
})
.returning();
@@ -133,7 +136,7 @@ export async function addItemToDefaultList(input: { type: string; text: string }
export async function toggleItem(input: { id: string; done?: boolean }) {
const parsed = z.object({ id: z.string().uuid(), done: z.boolean().optional() }).parse(input);
const { household } = await getCurrentSession();
const { household, user } = await getCurrentSession();
const existing = await getAuthorizedItem(parsed.id, household.id);
const done = parsed.done ?? !existing.done;
@@ -148,6 +151,16 @@ export async function toggleItem(input: { id: string; done?: boolean }) {
action: "toggle",
payload: { done, text: existing.text },
});
if (done && existing.metadata) {
await fireItemToggleHooks({
itemId: parsed.id,
metadata: existing.metadata as Record<string, unknown>,
done,
userId: user.id,
});
}
revalidatePath(`/lists/${existing.listId}`);
await notifyListChanged(existing.listId);
return getList(existing.listId);
@@ -229,6 +242,7 @@ async function getAuthorizedItem(itemId: string, householdId: string) {
listId: listItems.listId,
done: listItems.done,
text: listItems.text,
metadata: listItems.metadata,
})
.from(listItems)
.innerJoin(lists, eq(listItems.listId, lists.id))
+168
View File
@@ -0,0 +1,168 @@
import { test, expect } from "@playwright/test";
test("garden plants happy path", async ({ page }) => {
const suffix = Date.now().toString();
const plantName = `E2E Plant ${suffix}`;
await page.goto("/garden?tab=plants");
await expect(page.getByRole("heading", { name: "Garden" })).toBeVisible();
// Add plant
await page.getByRole("link", { name: /add plant/i }).click();
await expect(page).toHaveURL(/\/garden\/plants\/new/);
await page.getByLabel("Name").fill(plantName);
await page.getByRole("button", { name: /create/i }).click();
await expect(page).toHaveURL(/\/garden\/plants\//);
await expect(page.getByRole("heading", { name: plantName })).toBeVisible();
// Edit plant
await page.getByRole("link", { name: /edit/i }).click();
await expect(page).toHaveURL(/\/garden\/plants\/.*\/edit/);
const updatedName = `${plantName} Updated`;
await page.getByLabel("Name").fill(updatedName);
await page.getByRole("button", { name: /save/i }).click();
await expect(page).toHaveURL(/\/garden\/plants\//);
await expect(page.getByRole("heading", { name: updatedName })).toBeVisible();
// Delete plant
await page.getByRole("button", { name: /delete/i }).click();
await page.getByRole("button", { name: /confirm/i }).click();
await expect(page).toHaveURL(/\/garden/);
});
test("garden care tracking happy path", async ({ page }) => {
const suffix = Date.now().toString();
const plantName = `Care Plant ${suffix}`;
// Create a plant to attach care to
await page.goto("/garden?tab=plants");
await page.getByRole("link", { name: /add plant/i }).click();
await page.getByLabel("Name").fill(plantName);
await page.getByRole("button", { name: /create/i }).click();
await expect(page).toHaveURL(/\/garden\/plants\//);
// Open the Care tab
await page.getByRole("button", { name: /care/i }).click();
// Add a care schedule
await page.getByRole("button", { name: /add schedule/i }).click();
await page.getByLabel(/care type/i).fill("watering");
await page.getByLabel(/interval/i).fill("7");
await page.getByRole("button", { name: /save|add/i }).click();
// Verify schedule appears
await expect(page.getByText("watering")).toBeVisible();
await expect(page.getByText(/7 days|every 7/i)).toBeVisible();
// Log a care event
await page.getByRole("button", { name: /log care/i }).click();
await expect(page.getByLabel(/care type/i)).toBeVisible();
await page.getByRole("button", { name: /log|save/i }).click();
// Verify history entry appears
await expect(page.getByText(/watered|watering/i)).toBeVisible();
// Cleanup: delete the plant
await page.getByRole("button", { name: /info/i }).click();
await page.getByRole("button", { name: /delete/i }).click();
await page.getByRole("button", { name: /confirm/i }).click();
await expect(page).toHaveURL(/\/garden/);
});
test("garden dashboard widget happy path", async ({ page }) => {
const suffix = Date.now().toString();
const plantName = `Widget Plant ${suffix}`;
// Create a plant
await page.goto("/garden?tab=plants");
await page.getByRole("link", { name: /add plant/i }).click();
await page.getByLabel("Name").fill(plantName);
await page.getByRole("button", { name: /create/i }).click();
await expect(page).toHaveURL(/\/garden\/plants\//);
// Add watering schedule every 7 days
await page.getByRole("button", { name: /care/i }).click();
await page.getByRole("button", { name: /add schedule/i }).click();
await page.getByLabel(/interval/i).fill("7");
await page.getByRole("button", { name: /^add$/i }).click();
// Log care (watered)
await page.getByRole("button", { name: /log care/i }).click();
await page.getByRole("button", { name: /^log care$/i }).click();
// Navigate to dashboard and add garden.care-due widget
await page.goto("/d/home?edit=1");
const addWidgetButton = page.getByRole("button", { name: /add widget/i });
if (await addWidgetButton.isVisible()) {
await addWidgetButton.click();
const careDueOption = page.getByText(/plants needing care/i);
if (await careDueOption.isVisible()) {
await careDueOption.click();
}
}
// Save layout if dirty
const saveButton = page.getByRole("button", { name: /save/i });
if (await saveButton.isVisible()) {
await saveButton.click();
}
// Navigate back to dashboard — should show the widget
await page.goto("/d/home");
await expect(page.getByText(/plants needing care|all plants are on schedule/i)).toBeVisible({
timeout: 8000,
});
// Cleanup
await page.goto("/garden?tab=plants");
await page.getByText(plantName).click();
await page.getByRole("button", { name: /delete/i }).click();
await page.getByRole("button", { name: /confirm/i }).click();
});
test("garden integrations - push overdue to task list", async ({ page }) => {
// Navigate to garden page
await page.goto("/garden");
await expect(page.getByRole("heading", { name: "Garden" })).toBeVisible();
// The "Add overdue to task list" button should be present
const pushButton = page.getByRole("button", { name: /overdue|task list/i });
await expect(pushButton).toBeVisible();
// Click it — with no overdue plants it should show "No overdue" feedback
await pushButton.click();
await expect(page.getByText(/no overdue|added \d+ task/i)).toBeVisible({ timeout: 5000 });
});
test("garden containers happy path", async ({ page }) => {
await page.goto("/garden");
await expect(page.getByRole("heading", { name: "Garden" })).toBeVisible();
// Create a container
await page.getByRole("button", { name: /new container/i }).click();
await page.getByLabel("Name").fill("Living Room Shelf");
await page.getByLabel("Type").selectOption("shelf");
await page.getByRole("button", { name: /save|create/i }).click();
// Verify it appears in the list
await expect(page.getByText("Living Room Shelf")).toBeVisible();
// Open the container detail
await page.getByText("Living Room Shelf").click();
await expect(page.getByRole("heading", { name: "Living Room Shelf" })).toBeVisible();
// Edit the container
await page.getByRole("button", { name: /edit/i }).click();
await page.getByLabel("Name").fill("Living Room Shelf (Updated)");
await page.getByRole("button", { name: /save/i }).click();
await expect(page.getByRole("heading", { name: "Living Room Shelf (Updated)" })).toBeVisible();
// Delete the container
await page.getByRole("button", { name: /delete/i }).click();
await page.getByRole("button", { name: /confirm|yes/i }).click();
await expect(page).toHaveURL(/\/garden/);
await expect(page.getByText("Living Room Shelf")).toBeHidden();
});
+32
View File
@@ -0,0 +1,32 @@
import { test, expect } from "@playwright/test";
// Run without any stored auth cookies so requests are genuinely unauthenticated
test.use({ storageState: { cookies: [], origins: [] } });
test("POST /api/uploads rejects unauthenticated requests with 401", async ({ request }) => {
const response = await request.post("/api/uploads", {
multipart: {
file: {
name: "test.jpg",
mimeType: "image/jpeg",
buffer: Buffer.from("fake-image-data"),
},
},
});
expect(response.status()).toBe(401);
});
test("POST /api/uploads rejects non-image files with 415", async ({ request }) => {
// Without auth this will be 401, but the route shape is validated
const response = await request.post("/api/uploads", {
multipart: {
file: {
name: "document.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF-1.4 test"),
},
},
});
// 401 because unauthenticated; 415 once authenticated — both indicate the route exists
expect([401, 415]).toContain(response.status());
});