Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48063e4b7f | ||
|
|
6eab68560e | ||
|
|
7d0f02996f | ||
|
|
32e97b0677 | ||
|
|
e0b423aad5 | ||
|
|
389ff6040b | ||
|
|
65d36bab4e | ||
|
|
298e6b868d | ||
|
|
6d45b7e836 | ||
|
|
7f2c5a44dd | ||
|
|
dd980c6932 | ||
|
|
6b25d67537 | ||
|
|
080d26816e | ||
|
|
0b0deff700 | ||
|
|
eabcebdb00 | ||
|
|
0fef3d4ab6 | ||
|
|
fe282ba470 | ||
|
|
f3e38c576c | ||
|
|
a86f5471ce | ||
|
|
5f6b756342 | ||
|
|
2fd0677c5f | ||
|
|
5dcd49d4c9 | ||
|
|
29795e0d51 | ||
|
|
8921cb0444 | ||
|
|
e3d2b5a364 | ||
|
|
ed310d042f | ||
|
|
30af9f63bf | ||
|
|
e9bb2a5444 | ||
|
|
a95f10fcde | ||
|
|
b255aaeac1 | ||
|
|
19308be768 |
+13
-1
@@ -22,7 +22,6 @@ AUTH_OIDC_CLIENT_SECRET=replace-me
|
|||||||
# Web Push (generate with: pnpm vapid:generate — copy all three lines to .env)
|
# Web Push (generate with: pnpm vapid:generate — copy all three lines to .env)
|
||||||
VAPID_PUBLIC_KEY=
|
VAPID_PUBLIC_KEY=
|
||||||
VAPID_PRIVATE_KEY=
|
VAPID_PRIVATE_KEY=
|
||||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY=
|
|
||||||
VAPID_SUBJECT=mailto:you@example.com
|
VAPID_SUBJECT=mailto:you@example.com
|
||||||
|
|
||||||
# ntfy (optional fallback channel; leave blank to disable)
|
# ntfy (optional fallback channel; leave blank to disable)
|
||||||
@@ -31,3 +30,16 @@ NTFY_TOPIC=
|
|||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL=info
|
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
|
||||||
|
|
||||||
|
# OpenPlantBook plant species API (https://open.plantbook.io — free account required)
|
||||||
|
OPENPLANTBOOK_CLIENT_ID=
|
||||||
|
OPENPLANTBOOK_CLIENT_SECRET=
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
verify:
|
checks:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -28,10 +28,32 @@ jobs:
|
|||||||
|
|
||||||
- run: pnpm format:check
|
- run: pnpm format:check
|
||||||
|
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10.33.3
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: .next/cache
|
||||||
|
key: ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx', 'src/**/*.css') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-
|
||||||
|
${{ runner.os }}-nextjs-
|
||||||
|
|
||||||
- run: pnpm build
|
- run: pnpm build
|
||||||
env:
|
env:
|
||||||
# Build only — no DB connection. Provide a placeholder so any
|
|
||||||
# module-level reads of DATABASE_URL don't crash the build.
|
|
||||||
DATABASE_URL: postgres://placeholder:placeholder@localhost:5432/placeholder
|
DATABASE_URL: postgres://placeholder:placeholder@localhost:5432/placeholder
|
||||||
AUTH_SECRET: ci-placeholder-secret
|
AUTH_SECRET: ci-placeholder-secret
|
||||||
NEXT_PUBLIC_APP_URL: http://localhost:3000
|
NEXT_PUBLIC_APP_URL: http://localhost:3000
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
pnpm commitlint --edit "$1"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pnpm lint-staged
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"npm": {
|
||||||
|
"publish": false
|
||||||
|
},
|
||||||
|
"git": {
|
||||||
|
"commitMessage": "chore: release v${version}",
|
||||||
|
"tagName": "v${version}",
|
||||||
|
"requireBranch": "main"
|
||||||
|
},
|
||||||
|
"github": {
|
||||||
|
"release": true,
|
||||||
|
"releaseName": "v${version}"
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"@release-it/conventional-changelog": {
|
||||||
|
"preset": {
|
||||||
|
"name": "conventionalcommits",
|
||||||
|
"types": [
|
||||||
|
{ "type": "feat", "section": "Features" },
|
||||||
|
{ "type": "fix", "section": "Bug Fixes" },
|
||||||
|
{ "type": "perf", "section": "Performance" },
|
||||||
|
{ "type": "refactor", "section": "Refactoring" },
|
||||||
|
{ "type": "docs", "section": "Documentation" },
|
||||||
|
{ "type": "chore", "section": "Maintenance", "hidden": true },
|
||||||
|
{ "type": "ci", "section": "CI/CD", "hidden": true },
|
||||||
|
{ "type": "test", "section": "Tests", "hidden": true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"infile": "CHANGELOG.md"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
-3
@@ -1,10 +1,38 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
One line per release. "What's in prod" = the highest tag listed under a date that's been deployed.
|
## [0.4.4](https://github.com/ginnoir/famapp/compare/v0.4.3...v0.4.4) (2026-06-02)
|
||||||
|
|
||||||
Format: `## vX.Y.Z — YYYY-MM-DD`
|
### Bug Fixes
|
||||||
|
|
||||||
## Unreleased
|
- **garden:** remove trailing slashes from openplantbook api urls and update label ([c744921](https://github.com/ginnoir/famapp/commit/c744921e6cfcf0269992cfd9afea47cc55082602))
|
||||||
|
|
||||||
|
## [0.4.3](https://github.com/ginnoir/famapp/compare/v0.4.2...v0.4.3) (2026-06-02)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **garden:** replace perenual with openplantbook for species lookup ([8722c20](https://github.com/ginnoir/famapp/commit/8722c205e6257a0590b48d0a858ad71d56ab07ac))
|
||||||
|
|
||||||
|
All notable changes to famapp are documented here.
|
||||||
|
|
||||||
|
## [0.3.0](https://github.com/ginnoir/famapp/compare/v0.2.0...v0.3.0) (2026-06-01)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- add dev startup script, fix push notifications, and scope test sends to device ([0c12b05](https://github.com/ginnoir/famapp/commit/0c12b05d0989630b3ec1445f65048bd766d5f8d1))
|
||||||
|
- expand theme system to 10 full-skin palettes with light and dark modes ([c3c8ae3](https://github.com/ginnoir/famapp/commit/c3c8ae37a280206186dfde82ecb270aea5e0c512))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- refresh server components after nav style change to fix broken layout ([f55d685](https://github.com/ginnoir/famapp/commit/f55d685a794a539d2d6256d97c1a9c4e5fa3b8bf))
|
||||||
|
- scope rail nav-label hiding to sidebar and fall back fab-only to sidebar on desktop ([671b5fa](https://github.com/ginnoir/famapp/commit/671b5fae469b3cfcafe08b20ee2ce8ac15eef994))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- add github_token to .env.example ([5630537](https://github.com/ginnoir/famapp/commit/563053727908186a9816416fdf54c914002011de))
|
||||||
|
|
||||||
|
## Pre-release history
|
||||||
|
|
||||||
|
Changes from before the release workflow was established (2026-06-01):
|
||||||
|
|
||||||
- Production deployment scaffolding: tag-based release workflow (GHCR), migration entrypoint in container, prod-side dev-login startup assertion, image-pinned compose, pre-deploy checklist.
|
- Production deployment scaffolding: tag-based release workflow (GHCR), migration entrypoint in container, prod-side dev-login startup assertion, image-pinned compose, pre-deploy checklist.
|
||||||
- famapp/authentik-server now expose host ports (3010/9200) so existing Caddy stack can proxy by IP, matching the existing homelab pattern.
|
- famapp/authentik-server now expose host ports (3010/9200) so existing Caddy stack can proxy by IP, matching the existing homelab pattern.
|
||||||
|
|||||||
+2
-1
@@ -16,7 +16,8 @@ ENV CI=true
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm install --offline --frozen-lockfile
|
RUN pnpm install --offline --frozen-lockfile
|
||||||
RUN pnpm build
|
RUN --mount=type=cache,id=famapp-nextjs,target=/app/.next/cache \
|
||||||
|
pnpm build
|
||||||
|
|
||||||
# ── Stage 3: production runtime ───────────────────────────────────────────────
|
# ── Stage 3: production runtime ───────────────────────────────────────────────
|
||||||
FROM node:22-alpine AS runner
|
FROM node:22-alpine AS runner
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export default {
|
||||||
|
extends: ["@commitlint/config-conventional"],
|
||||||
|
rules: {
|
||||||
|
"type-enum": [
|
||||||
|
2,
|
||||||
|
"always",
|
||||||
|
["feat", "fix", "refactor", "docs", "test", "chore", "perf", "ci", "revert"],
|
||||||
|
],
|
||||||
|
"subject-case": [2, "always", "lower-case"],
|
||||||
|
"subject-max-length": [2, "always", 100],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -11,5 +11,19 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- famapp-db-data:/var/lib/postgresql/data
|
- 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:
|
volumes:
|
||||||
famapp-db-data:
|
famapp-db-data:
|
||||||
|
famapp-minio-data:
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 0003 — Release workflow
|
||||||
|
|
||||||
|
Date: 2026-06-01
|
||||||
|
Status: accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The project needed a consistent, enforced standard for commit messages, changelog generation, versioning, and GitHub releases — especially important since both humans and AI agents (Claude Code, Codex) commit to the repo.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
- **commitlint** (`@commitlint/config-conventional`) enforces conventional commit format at the `commit-msg` git hook
|
||||||
|
- **husky** v9 wires the hooks; `prepare` installs them on `pnpm install`
|
||||||
|
- **lint-staged** runs prettier + eslint on staged files at the `pre-commit` hook
|
||||||
|
- **release-it** + `@release-it/conventional-changelog` manages the full release cycle:
|
||||||
|
- bumps `package.json` version (semver)
|
||||||
|
- generates/prepends to `CHANGELOG.md`
|
||||||
|
- creates a signed git tag (`v{version}`)
|
||||||
|
- creates a GitHub Release with the generated notes
|
||||||
|
- pushes the tag and commit
|
||||||
|
|
||||||
|
Release commands:
|
||||||
|
|
||||||
|
- `pnpm release` — interactive (prompts for increment type)
|
||||||
|
- `pnpm release:patch` / `:minor` / `:major` — non-interactive
|
||||||
|
- `pnpm release:dry` — preview without writing anything
|
||||||
|
- Requires `GITHUB_TOKEN` in env for GitHub Release creation
|
||||||
|
|
||||||
|
Conventional commit types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `revert`
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Every commit is validated; bad format is rejected immediately
|
||||||
|
- CHANGELOG.md is auto-generated from commit history — no manual upkeep
|
||||||
|
- Releases are reproducible: one command, idempotent output
|
||||||
|
- `chore`, `ci`, `test` commits are hidden in the changelog; `feat`, `fix`, `perf`, `refactor`, `docs` are surfaced
|
||||||
+58
-8
@@ -34,20 +34,70 @@ This note tracks the development-only work added to make the app easy to run and
|
|||||||
|
|
||||||
## Current Local Run Procedure
|
## Current Local Run Procedure
|
||||||
|
|
||||||
|
One command starts the database, runs migrations, seeds, and launches the dev server:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
docker compose -f docker-compose.dev.yaml up -d
|
pnpm dev:local
|
||||||
pnpm db:migrate
|
|
||||||
pnpm db:seed
|
|
||||||
pnpm dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open:
|
The script prints three URLs at startup:
|
||||||
|
|
||||||
```text
|
| URL | Use for |
|
||||||
http://127.0.0.1:3000/login
|
| -------------------------- | ---------------------------------------------------------- |
|
||||||
|
| `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**.
|
||||||
|
|
||||||
|
### HMR and restarts
|
||||||
|
|
||||||
|
`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
|
||||||
|
|
||||||
|
### Clean slate
|
||||||
|
|
||||||
|
To delete all local data and start fresh (e.g. after a destructive schema change):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm dev:reset
|
||||||
```
|
```
|
||||||
|
|
||||||
Click **Dev login**.
|
### HTTPS for push notification and PWA testing (one-time setup)
|
||||||
|
|
||||||
|
Service workers and Web Push require HTTPS. The plain LAN address won't work for these.
|
||||||
|
Route through the existing Caddy server on the home server instead — no extra tooling needed.
|
||||||
|
|
||||||
|
**Step 1 — DHCP reservation**
|
||||||
|
|
||||||
|
Set a reservation on the router so the dev machine always gets `192.168.1.74`.
|
||||||
|
|
||||||
|
**Step 2 — DNS record**
|
||||||
|
|
||||||
|
Add a `dev.ginnoir.com` A record pointing to the same public IP as `fam.ginnoir.com`.
|
||||||
|
|
||||||
|
**Step 3 — Windows Firewall**
|
||||||
|
|
||||||
|
Allow inbound TCP 3000 on the dev machine (run once in an elevated PowerShell):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
New-NetFirewallRule -DisplayName "famapp dev" -Direction Inbound `
|
||||||
|
-Protocol TCP -LocalPort 3000 -Action Allow
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4 — Caddy snippet**
|
||||||
|
|
||||||
|
Paste `deploy/Caddyfile.dev.snippet` into the home server Caddyfile and reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
caddy reload --config /path/to/Caddyfile
|
||||||
|
```
|
||||||
|
|
||||||
|
After this, `https://dev.ginnoir.com` proxies to the dev machine with a real Let's Encrypt cert.
|
||||||
|
|
||||||
## Current Local E2E Procedure
|
## Current Local E2E Procedure
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# 70 — Garden infrastructure (MinIO + upload route + schema)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Lay the infrastructure foundation for the garden module: add a MinIO object-storage container to the compose stack, create a generic file-upload API route, define the Drizzle schema for all four garden tables, and run the migration.
|
||||||
|
|
||||||
|
No UI or business logic — just the plumbing that every subsequent garden task depends on.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- 03 (Drizzle + Postgres), 07 (household seed)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### compose.yaml additions (`deploy/compose.yaml`)
|
||||||
|
|
||||||
|
Add a `minio` service and a `garden-uploads` named volume:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- garden-uploads:/data
|
||||||
|
ports:
|
||||||
|
- "9000:9000" # API
|
||||||
|
- "9001:9001" # Console (dev only — restrict in prod)
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
garden-uploads:
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `MINIO_ENDPOINT`, `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`, `MINIO_BUCKET` to `.env.example`.
|
||||||
|
|
||||||
|
### MinIO client (`src/lib/minio.ts`)
|
||||||
|
|
||||||
|
Thin singleton using the `minio` npm package:
|
||||||
|
|
||||||
|
- Connect using `MINIO_ENDPOINT`, `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`.
|
||||||
|
- Export `minioClient` and `MINIO_BUCKET` constant.
|
||||||
|
- On first use, create the bucket if it does not exist (`bucketExists` + `makeBucket`).
|
||||||
|
|
||||||
|
### Upload API route (`src/app/api/uploads/route.ts`)
|
||||||
|
|
||||||
|
- `POST /api/uploads` — accepts `multipart/form-data` with a single `file` field.
|
||||||
|
- Validates: authenticated session required (401 if not); max file size 5 MB (413 if exceeded); MIME type must match `image/*` (415 if not).
|
||||||
|
- Generates a storage key: `garden/<householdId>/<randomUUID>.<ext>`.
|
||||||
|
- Streams to MinIO via `putObject`.
|
||||||
|
- Returns `{ url: "/api/uploads/<key>" }`.
|
||||||
|
- `GET /api/uploads/[...key]` — proxies the object back from MinIO using `getObject`. Sets `Cache-Control: public, max-age=31536000, immutable`.
|
||||||
|
|
||||||
|
This route is generic — not garden-specific. Other modules can reuse it.
|
||||||
|
|
||||||
|
### Schema (`src/modules/garden/schema.ts`)
|
||||||
|
|
||||||
|
Four tables, all `household_id`-scoped:
|
||||||
|
|
||||||
|
**`garden_containers`**
|
||||||
|
|
||||||
|
- `id` uuid pk
|
||||||
|
- `household_id` fk households cascade
|
||||||
|
- `name` text not null
|
||||||
|
- `type` text not null default `'other'` — free text, convention: `'shelf' | 'terrarium' | 'raised-bed' | 'window-box' | 'single-pot' | 'outdoor' | 'other'`
|
||||||
|
- `location_notes` text nullable
|
||||||
|
- `cover_image_url` text nullable
|
||||||
|
- `created_at`, `updated_at` timestamptz
|
||||||
|
|
||||||
|
Index on `household_id`.
|
||||||
|
|
||||||
|
**`garden_plants`**
|
||||||
|
|
||||||
|
- `id` uuid pk
|
||||||
|
- `household_id` fk households cascade
|
||||||
|
- `container_id` uuid nullable fk `garden_containers` set-null on delete
|
||||||
|
- `name` text not null
|
||||||
|
- `scientific_name` text nullable
|
||||||
|
- `species_id` text nullable — external Perenual species ID, stored as string
|
||||||
|
- `category` text not null default `'other'` — free text, convention: `'succulent' | 'tropical' | 'herb' | 'vegetable' | 'tree' | 'flower' | 'other'`
|
||||||
|
- `notes` text nullable
|
||||||
|
- `acquisition_date` date nullable
|
||||||
|
- `growth_stage` text nullable — convention: `'seedling' | 'juvenile' | 'mature' | 'flowering' | 'fruiting' | 'dormant'`
|
||||||
|
- `health_status` text not null default `'healthy'` — convention: `'healthy' | 'stressed' | 'sick' | 'dormant'`
|
||||||
|
- `sunlight` text nullable
|
||||||
|
- `watering_notes` text nullable
|
||||||
|
- `fertilizing_notes` text nullable
|
||||||
|
- `primary_image_url` text nullable
|
||||||
|
- `images` jsonb not null default `'[]'` — typed as `string[]`, stores upload URL paths
|
||||||
|
- `created_at`, `updated_at` timestamptz
|
||||||
|
|
||||||
|
Indexes on `(household_id)`, `(household_id, container_id)`.
|
||||||
|
|
||||||
|
**`garden_care_logs`**
|
||||||
|
|
||||||
|
- `id` uuid pk
|
||||||
|
- `plant_id` uuid fk `garden_plants` cascade
|
||||||
|
- `household_id` fk households cascade
|
||||||
|
- `care_type` text not null — convention: `'watered' | 'fertilized' | 'repotted' | 'pruned' | 'misted' | 'inspected' | 'treated' | 'propagated' | 'custom'`
|
||||||
|
- `performed_by` uuid fk users set-null on delete
|
||||||
|
- `notes` text nullable
|
||||||
|
- `performed_at` timestamptz not null default now()
|
||||||
|
- `created_at` timestamptz
|
||||||
|
|
||||||
|
Indexes on `(plant_id, performed_at desc)`, `(household_id)`.
|
||||||
|
|
||||||
|
**`garden_care_schedules`**
|
||||||
|
|
||||||
|
- `id` uuid pk
|
||||||
|
- `plant_id` uuid fk `garden_plants` cascade
|
||||||
|
- `household_id` fk households cascade
|
||||||
|
- `care_type` text not null
|
||||||
|
- `interval_days` int not null
|
||||||
|
- `last_performed_at` timestamptz nullable
|
||||||
|
- `next_due_at` timestamptz nullable — recomputed after every care log entry
|
||||||
|
- `enabled` boolean not null default true
|
||||||
|
- `created_at`, `updated_at` timestamptz
|
||||||
|
|
||||||
|
Unique index on `(plant_id, care_type)` — one schedule per plant per care type.
|
||||||
|
Index on `(household_id, next_due_at)` for dashboard queries.
|
||||||
|
|
||||||
|
### Drizzle migration
|
||||||
|
|
||||||
|
Generate and commit under `drizzle/`. Run `pnpm db:migrate` to apply.
|
||||||
|
|
||||||
|
### Module scaffold (`src/modules/garden/`)
|
||||||
|
|
||||||
|
Create the directory with stubs:
|
||||||
|
|
||||||
|
- `schema.ts` (complete, from above)
|
||||||
|
- `manifest.tsx` (minimal stub: `id: "garden"`, `name: "Garden"`, empty `entities: []`)
|
||||||
|
- `server/actions.ts` (empty stub)
|
||||||
|
- `server/queries.ts` (empty stub)
|
||||||
|
|
||||||
|
Register in `src/modules/index.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import gardenManifest from "./garden/manifest";
|
||||||
|
registerModule(gardenManifest);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment
|
||||||
|
|
||||||
|
Add to `.env.example`:
|
||||||
|
|
||||||
|
```
|
||||||
|
MINIO_ENDPOINT=http://localhost:9000
|
||||||
|
MINIO_ROOT_USER=famapp
|
||||||
|
MINIO_ROOT_PASSWORD=changeme
|
||||||
|
MINIO_BUCKET=garden
|
||||||
|
PERENUAL_API_KEY= # filled in task 72
|
||||||
|
```
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Any garden-specific UI.
|
||||||
|
- Perenual API integration (task 72).
|
||||||
|
- Care logic (tasks 73–74).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `docker compose up minio` starts and passes health-check.
|
||||||
|
- [ ] `POST /api/uploads` rejects unauthenticated requests with 401.
|
||||||
|
- [ ] `POST /api/uploads` rejects files > 5 MB with 413.
|
||||||
|
- [ ] `POST /api/uploads` accepts a valid JPEG and returns `{ url: "..." }`; `GET` of that URL returns the image bytes.
|
||||||
|
- [ ] `pnpm db:migrate` applies cleanly; all four tables exist with the correct columns.
|
||||||
|
- [ ] `garden` module appears in `/debug/registry`.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# 71 — Garden containers
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Implement full CRUD for **containers** — the grouping layer above individual plants (shelves, terrariums, raised beds, etc.). A container has a name, type, location notes, and an optional cover photo.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- 70 (garden infrastructure — schema, MinIO, upload route)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Server (`src/modules/garden/server/`)
|
||||||
|
|
||||||
|
Add to `actions.ts`:
|
||||||
|
|
||||||
|
- `createContainer(input: { name, type, locationNotes?, coverImageUrl? })` — Zod-validated, `getCurrentSession`, insert, `logActivity`, `revalidatePath("/garden")`.
|
||||||
|
- `updateContainer(input: { id, name?, type?, locationNotes?, coverImageUrl? })` — patch, household membership check, logActivity.
|
||||||
|
- `deleteContainer(input: { id })` — household membership check; plants with this `container_id` have it set to null (handled by schema `ON DELETE SET NULL`); logActivity; revalidatePath.
|
||||||
|
|
||||||
|
Add to `queries.ts`:
|
||||||
|
|
||||||
|
- `listContainers()` — returns all containers for the current household with a computed `plantCount`.
|
||||||
|
- `getContainer(id)` — returns the container + all its plants (with last-watered date derived from care logs — use a lateral join or subquery).
|
||||||
|
|
||||||
|
### UI (`src/modules/garden/components/`)
|
||||||
|
|
||||||
|
**`container-list.tsx`** (server component)
|
||||||
|
|
||||||
|
- Grid of cards. Each card: cover photo (placeholder icon if none), name, type badge, plant count.
|
||||||
|
- "New container" button → sheet/dialog.
|
||||||
|
|
||||||
|
**`container-detail.tsx`** (server component)
|
||||||
|
|
||||||
|
- Header: cover photo, name, type, location notes, edit/delete buttons.
|
||||||
|
- Plant grid (placeholder for now — task 72 fills in the plant cards).
|
||||||
|
- "+ Add plant to this container" button — links to `/garden/plants/new?containerId=<id>`.
|
||||||
|
|
||||||
|
**`container-form.tsx`** (client component)
|
||||||
|
|
||||||
|
- Fields: name (required), type (select — 7 options), location notes (textarea), cover photo (file input → `POST /api/uploads` → stores returned URL).
|
||||||
|
- Used for both create and edit via an optional `existing` prop.
|
||||||
|
|
||||||
|
### Pages (`src/app/garden/`)
|
||||||
|
|
||||||
|
- `page.tsx` — `/garden` root page: two tabs — "Plants" (placeholder for task 72) and "Containers". Containers tab renders `<ContainerList />`.
|
||||||
|
- `containers/[id]/page.tsx` — renders `<ContainerDetail />`.
|
||||||
|
- `containers/new/page.tsx` — renders `<ContainerForm />` in create mode; redirects to `/garden` on success.
|
||||||
|
|
||||||
|
### Nav
|
||||||
|
|
||||||
|
Add `/garden` to the bottom nav and sidebar. Use the `sprout` Lucide icon (or nearest available). Register in the garden manifest:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
nav: { href: "/garden", label: "Garden", icon: "sprout" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Activity log
|
||||||
|
|
||||||
|
`renderActivity` for `garden.container` entity type in the manifest:
|
||||||
|
|
||||||
|
- `create` → `Created container "${name}"`
|
||||||
|
- `update` → `Updated container "${name}"`
|
||||||
|
- `delete` → `Deleted container`
|
||||||
|
|
||||||
|
### Share links
|
||||||
|
|
||||||
|
Containers are shareable (read-only). Add `loadForShare` and `renderSharedView` to the entity registration so a share link shows the container's name, type, location notes, cover photo, and plant names. Add `loadContainerForShare(id)` in `src/modules/garden/server/share-queries.ts`.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Plant cards inside the container detail (task 72 fills those in).
|
||||||
|
- Care tracking (tasks 73–74).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Create / edit / delete containers works end-to-end.
|
||||||
|
- [ ] Cover photo uploads to MinIO and renders on the card and detail page.
|
||||||
|
- [ ] Deleting a container nullifies `container_id` on its plants rather than deleting the plants.
|
||||||
|
- [ ] `/garden` page renders with a Containers tab.
|
||||||
|
- [ ] `garden.container` entity is registered with share support in the manifest.
|
||||||
|
- [ ] Activity log entries appear for create / update / delete.
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# 72 — Garden plants
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Implement full CRUD for individual **plants**. Each plant has a name, category, care notes, health status, up to 10 photos, an optional container assignment, and species data pulled from the **Perenual API** when the user searches for a known species.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- 70 (schema, MinIO, upload route), 71 (containers — container assignment picker)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Perenual API wrapper (`src/modules/garden/server/species-lookup.ts`)
|
||||||
|
|
||||||
|
Perenual (perenual.com) provides a free-tier plant species API. Key endpoint: `GET /api/species-list?key=<KEY>&q=<query>`.
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type SpeciesSuggestion = {
|
||||||
|
id: string;
|
||||||
|
common_name: string;
|
||||||
|
scientific_name: string;
|
||||||
|
watering: string; // e.g. "frequent", "average", "minimum"
|
||||||
|
sunlight: string[]; // e.g. ["full sun", "part shade"]
|
||||||
|
cycle: string; // e.g. "Perennial"
|
||||||
|
default_image_url: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function searchSpecies(query: string): Promise<SpeciesSuggestion[]>;
|
||||||
|
export async function getSpeciesById(id: string): Promise<SpeciesSuggestion | null>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Cache results for 24 hours in a `garden_species_cache` table (columns: `species_id` text pk, `data` jsonb, `cached_at` timestamptz). Add this table to the schema in `src/modules/garden/schema.ts` and include it in the migration from task 70 (or add a new migration).
|
||||||
|
|
||||||
|
If the API is unreachable or returns an error, log the failure and return `[]` / `null`. `PERENUAL_API_KEY` from env; if absent, `searchSpecies` returns `[]` silently so the app works without the key.
|
||||||
|
|
||||||
|
### Server (`src/modules/garden/server/`)
|
||||||
|
|
||||||
|
Add to `actions.ts`:
|
||||||
|
|
||||||
|
- `createPlant(input)` — Zod-validated, `getCurrentSession`, insert, `logActivity`, `revalidatePath("/garden")`.
|
||||||
|
- `updatePlant(input: { id, ...partials })` — patch, household check, logActivity.
|
||||||
|
- `deletePlant(input: { id })` — household check, logActivity; care logs + schedules cascade-delete via DB.
|
||||||
|
- `addPlantImage(input: { id, url })` — appends URL to `images` jsonb array; reject if already 10 images.
|
||||||
|
- `removePlantImage(input: { id, url })` — removes URL from array; if it was `primary_image_url`, set primary to first remaining or null.
|
||||||
|
- `setPrimaryImage(input: { id, url })` — sets `primary_image_url`; url must already be in `images`.
|
||||||
|
|
||||||
|
Add to `queries.ts`:
|
||||||
|
|
||||||
|
- `listPlants({ containerId? })` — all plants in household optionally filtered by container. Include last-care timestamps per type via a lateral subquery.
|
||||||
|
- `getPlant(id)` — full plant data + container name + care log summary (last 5 entries) + active schedules.
|
||||||
|
|
||||||
|
### UI (`src/modules/garden/components/`)
|
||||||
|
|
||||||
|
**`plant-list.tsx`** (server component)
|
||||||
|
|
||||||
|
- Grouped by container first; "Unassigned" group for plants with no container.
|
||||||
|
- Each card: primary image thumbnail, name, health badge, "last watered X days ago", overdue care indicator.
|
||||||
|
- Replaces the placeholder in the "Plants" tab on `/garden/page.tsx` from task 71.
|
||||||
|
|
||||||
|
**`plant-detail.tsx`** (server component)
|
||||||
|
|
||||||
|
- Three-tab layout: **Info**, **Gallery**, **Care** (Care tab filled in by task 73).
|
||||||
|
- Info tab: all fields, container link, health/stage badges, species info.
|
||||||
|
- Gallery tab: photo grid, primary image star-toggle, delete individual image, upload new image.
|
||||||
|
|
||||||
|
**`plant-form.tsx`** (client component)
|
||||||
|
|
||||||
|
- Fields: name (required), category (select), container (select, nullable), health status (select), growth stage (select).
|
||||||
|
- Species search combobox: debounced search → `searchSpecies` → select → auto-fills scientific name, sunlight, watering notes. User can override.
|
||||||
|
- Care notes textareas: watering notes, fertilizing notes, general notes.
|
||||||
|
- Acquisition date picker.
|
||||||
|
- Image uploader: drag-and-drop or click, previews, max 10. First uploaded image auto-set as primary.
|
||||||
|
- `containerId` query-param pre-fill (from task 71 "Add plant to container" button).
|
||||||
|
|
||||||
|
**`species-search.tsx`** (client component)
|
||||||
|
|
||||||
|
- Debounced combobox calling a `/api/garden/species-search` route handler. Fires `onSelect(suggestion)` on pick.
|
||||||
|
|
||||||
|
### Pages (`src/app/garden/`)
|
||||||
|
|
||||||
|
- `plants/new/page.tsx` — `<PlantForm />` in create mode.
|
||||||
|
- `plants/[id]/page.tsx` — `<PlantDetail />` with edit/delete controls.
|
||||||
|
- `plants/[id]/edit/page.tsx` — `<PlantForm />` in edit mode.
|
||||||
|
|
||||||
|
### Activity log
|
||||||
|
|
||||||
|
`renderActivity` for `garden.plant`:
|
||||||
|
|
||||||
|
- `create` → `Added plant "${name}"`
|
||||||
|
- `update` → `Updated "${name}"`
|
||||||
|
- `delete` → `Removed plant "${name}"`
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
Register `search` on the `garden.plant` entity using `ilike` on `name || ' ' || coalesce(scientific_name, '')`.
|
||||||
|
|
||||||
|
### Share links
|
||||||
|
|
||||||
|
Plants are shareable (read-only). The shared view shows name, scientific name, primary image, gallery, health status, and care notes. Add `loadPlantForShare(id)` in `src/modules/garden/server/share-queries.ts`.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Care log UI and schedules (task 73).
|
||||||
|
- Dashboard widgets (task 75).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Create / edit / delete plants works end-to-end.
|
||||||
|
- [ ] Species search returns Perenual results and auto-fills form fields on selection.
|
||||||
|
- [ ] App works correctly via manual entry when `PERENUAL_API_KEY` is absent.
|
||||||
|
- [ ] Up to 10 images upload; primary image can be set and changed; individual images can be removed.
|
||||||
|
- [ ] Plant list on `/garden` groups by container with an "Unassigned" bucket.
|
||||||
|
- [ ] `garden.plant` entity is registered with search and share support.
|
||||||
|
- [ ] Activity log entries appear for create / update / delete.
|
||||||
|
- [ ] Plant share link renders the read-only view correctly.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# 73 — Garden care tracking
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Implement the **care log** and **care schedule** systems. Users can log a care event for any plant (watered, fertilized, repotted, etc.), set up recurring schedules with an interval, and receive reminders when care is due.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- 70 (schema), 72 (plants exist to attach care records to)
|
||||||
|
- 41 (reminders system — `scheduleReminder` / `cancelReminder`)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Care log actions (`src/modules/garden/server/actions.ts`)
|
||||||
|
|
||||||
|
- `logCare(input: { plantId, careType, notes?, performedAt? })` — Zod-validated; `performedAt` defaults to now(). Insert into `garden_care_logs`. After inserting: call `updateScheduleAfterCare(plantId, careType)`, call `logActivity`, `revalidatePath`.
|
||||||
|
- `deleteCareLog(input: { id })` — household check, delete.
|
||||||
|
|
||||||
|
### Care schedule actions (`src/modules/garden/server/actions.ts`)
|
||||||
|
|
||||||
|
- `upsertCareSchedule(input: { plantId, careType, intervalDays, enabled? })` — upsert on `(plant_id, care_type)`. Recompute `next_due_at`: if `last_performed_at` exists use `last_performed_at + intervalDays`, else `now() + intervalDays`. Wire reminder: `scheduleReminder({ entityType: "garden.schedule", entityId: scheduleRow.id, fireAt: next_due_at })`.
|
||||||
|
- `deleteCareSchedule(input: { id })` — `cancelReminder("garden.schedule", id)`, delete.
|
||||||
|
- `toggleCareSchedule(input: { id, enabled })` — flip `enabled`; cancel reminder if disabling, reschedule if enabling.
|
||||||
|
|
||||||
|
### Schedule update helper (`src/modules/garden/server/care-schedule.ts`)
|
||||||
|
|
||||||
|
`updateScheduleAfterCare(plantId, careType)` — finds the schedule row, sets `last_performed_at = now()`, `next_due_at = now() + interval_days`, cancels old reminder, schedules new one.
|
||||||
|
|
||||||
|
Uses `entityType = "garden.schedule"` + `entityId = schedule.id` to satisfy the unique constraint on `(entity_type, entity_id)` in the reminders table — one reminder per schedule row, not per plant.
|
||||||
|
|
||||||
|
### Queries (`src/modules/garden/server/queries.ts`)
|
||||||
|
|
||||||
|
- `getCareLogs(plantId, limit?)` — most recent N logs ordered `performed_at desc`.
|
||||||
|
- `getCareSchedules(plantId)` — all schedules with computed `daysUntilDue` and `isOverdue`.
|
||||||
|
- `getOverduePlants(householdId)` — plants with at least one enabled schedule where `next_due_at < now()`, sorted most-overdue first.
|
||||||
|
- `getCareDueSoon(householdId, withinDays)` — plants with care due within N days.
|
||||||
|
|
||||||
|
### UI (`src/modules/garden/components/`)
|
||||||
|
|
||||||
|
**`care-log-form.tsx`** (client component) — plant picker + care type select + optional notes + optional performed-at datetime (defaults to now). Used as a sheet and inline in the plant detail Care tab.
|
||||||
|
|
||||||
|
**`care-schedule-editor.tsx`** (client component) — list of active schedules per plant (care type, interval, next due, enabled toggle, delete). "Add schedule" form with care type + interval days.
|
||||||
|
|
||||||
|
**`care-history-list.tsx`** (server component) — chronological log entries: care type icon, "X days ago", performed-by avatar, notes.
|
||||||
|
|
||||||
|
### Plant detail Care tab (update `plant-detail.tsx` from task 72)
|
||||||
|
|
||||||
|
The Care tab (previously placeholder) renders: `<CareScheduleEditor />` → "Log care" button → `<CareHistoryList />`.
|
||||||
|
|
||||||
|
### Quick-add (update manifest)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: "garden.log-care",
|
||||||
|
label: "Log plant care",
|
||||||
|
icon: "droplets",
|
||||||
|
url: "/garden",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Calendar/lists integration (task 74).
|
||||||
|
- Dashboard widget (task 75).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Logging care inserts a log row and updates `last_performed_at` + `next_due_at` on the matching schedule.
|
||||||
|
- [ ] Creating a schedule with interval 7 sets `next_due_at` to 7 days from now; a reminder is scheduled.
|
||||||
|
- [ ] After logging care, old reminder is cancelled and a new one scheduled.
|
||||||
|
- [ ] Disabling a schedule cancels its reminder; re-enabling reschedules it.
|
||||||
|
- [ ] Deleting a schedule cancels its reminder.
|
||||||
|
- [ ] `getOverduePlants` returns correctly sorted results.
|
||||||
|
- [ ] Care tab in plant detail shows schedule editor, log form, and history.
|
||||||
|
- [ ] "Log plant care" appears in the quick-add sheet.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# 74 — Garden integrations (calendar + lists)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Wire the garden module into the two existing coordination modules:
|
||||||
|
|
||||||
|
1. **Calendar**: schedule a plant care event on the household calendar from within a care schedule row.
|
||||||
|
2. **Lists**: push all overdue care tasks to the household task list in one tap.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- 73 (care schedules + overdue queries)
|
||||||
|
- 10 (calendar module — `createEvent`)
|
||||||
|
- 11 (lists module — `addItem`)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Calendar integration
|
||||||
|
|
||||||
|
#### Server action (`src/modules/garden/server/actions.ts`)
|
||||||
|
|
||||||
|
`scheduleOnCalendar(input: { scheduleId, calendarId, reminderMinutesBefore? })`:
|
||||||
|
|
||||||
|
- Look up the schedule + plant; household check.
|
||||||
|
- `startAt` = `next_due_at` (reject with user-facing error if null).
|
||||||
|
- `endAt` = `startAt + 30 min`; `allDay = false`.
|
||||||
|
- Title convention: `"Water ${plant.name}"` / `"Fertilize ${plant.name}"` / `"Repot ${plant.name}"` / `"${careType} — ${plant.name}"` for other types.
|
||||||
|
- Notes: `"Scheduled from garden. Next due: ${next_due_at.toLocaleDateString()}"`.
|
||||||
|
- Call `createCalendarEvent(...)` from the bridge below.
|
||||||
|
|
||||||
|
#### Bridge file (`src/modules/garden/server/calendar-bridge.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export { createEvent as createCalendarEvent } from "@/modules/calendar/server/actions";
|
||||||
|
export { listCalendars } from "@/modules/calendar/server/queries";
|
||||||
|
```
|
||||||
|
|
||||||
|
This keeps the cross-module dependency explicit and swappable without touching `_core`.
|
||||||
|
|
||||||
|
#### UI
|
||||||
|
|
||||||
|
In `care-schedule-editor.tsx`, add an "Add to calendar" button per schedule row. Clicking it opens a popover with: calendar select (from `listCalendars`) + optional reminder-minutes input + "Schedule" button. Show a success toast linking to `/calendar`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Lists integration
|
||||||
|
|
||||||
|
#### Server action (`src/modules/garden/server/actions.ts`)
|
||||||
|
|
||||||
|
`pushOverdueToTaskList(): Promise<{ added: number }>`:
|
||||||
|
|
||||||
|
- Call `getOverduePlants(householdId)`.
|
||||||
|
- Find the household default task list via the bridge below.
|
||||||
|
- For each overdue `(plant, schedule)` pair insert a list item:
|
||||||
|
- `text`: same title convention as the calendar integration.
|
||||||
|
- `notes`: `"Overdue by ${daysOverdue} day(s)"`.
|
||||||
|
- `dueAt`: `schedule.next_due_at`.
|
||||||
|
- Skip pairs where an identical `text` item already exists in the list (prevent duplicates).
|
||||||
|
- Return `{ added: N }`.
|
||||||
|
|
||||||
|
#### Bridge file (`src/modules/garden/server/lists-bridge.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export { addItem as addListItem } from "@/modules/lists/server/actions";
|
||||||
|
export { listLists } from "@/modules/lists/server/queries";
|
||||||
|
```
|
||||||
|
|
||||||
|
#### UI
|
||||||
|
|
||||||
|
Add an "Add overdue to task list" button to the `/garden` page header (available before the dashboard widget in task 75). Shows a toast: "Added N tasks to your task list." If N = 0, toast says "No overdue care tasks."
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Bi-directional sync (checking off a list item does not mark the plant as cared-for).
|
||||||
|
- Recurring calendar events (garden creates single events only).
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] "Add to calendar" creates a calendar event visible at `/calendar` with the correct title.
|
||||||
|
- [ ] "Add overdue to task list" pushes one item per overdue schedule, skips existing duplicates.
|
||||||
|
- [ ] Items appear in the task list at `/lists`.
|
||||||
|
- [ ] Both actions enforce household scope.
|
||||||
|
- [ ] Both are graceful no-ops when nothing is overdue / due.
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# 75 — Garden dashboard, widgets, and manifest completion
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Complete the garden module: full manifest registration, two dashboard widgets, search integration, quick-add polish, and a Playwright happy-path test.
|
||||||
|
|
||||||
|
## Depends on
|
||||||
|
|
||||||
|
- 70–74 (all previous garden tasks)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Manifest completion (`src/modules/garden/manifest.tsx`)
|
||||||
|
|
||||||
|
Replace the task-70 stub with the full manifest.
|
||||||
|
|
||||||
|
**Entities:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
entities: [
|
||||||
|
{
|
||||||
|
type: "garden.container",
|
||||||
|
label: { singular: "Container", plural: "Containers" },
|
||||||
|
share: { canShare: true, defaultCapabilities: ["read"] },
|
||||||
|
search: { search: searchContainers },
|
||||||
|
resolveUrl: (id) => `/garden/containers/${id}`,
|
||||||
|
loadForShare: loadContainerForShare,
|
||||||
|
renderSharedView: ...,
|
||||||
|
renderActivity: ...,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "garden.plant",
|
||||||
|
label: { singular: "Plant", plural: "Plants" },
|
||||||
|
share: { canShare: true, defaultCapabilities: ["read"] },
|
||||||
|
search: { search: searchPlants },
|
||||||
|
resolveUrl: (id) => `/garden/plants/${id}`,
|
||||||
|
loadForShare: loadPlantForShare,
|
||||||
|
renderSharedView: ...,
|
||||||
|
renderActivity: ...,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
`garden.schedule` is internal (reminders only) — not registered as a shareable or searchable entity.
|
||||||
|
|
||||||
|
**Quick adds** (consolidate from tasks 71–73):
|
||||||
|
|
||||||
|
- `{ id: "garden.add-plant", label: "Add plant", icon: "leaf", url: "/garden/plants/new" }`
|
||||||
|
- `{ id: "garden.add-container", label: "Add container", icon: "box", url: "/garden/containers/new" }`
|
||||||
|
- `{ id: "garden.log-care", label: "Log plant care", icon: "droplets", url: "/garden?logCare=1" }`
|
||||||
|
|
||||||
|
**Nav**: `{ href: "/garden", label: "Garden", icon: "sprout" }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Widget 1: `garden.care-due`
|
||||||
|
|
||||||
|
**Title**: "Plants needing care" | **Category**: "Garden"
|
||||||
|
**Default size**: `{ w: 4, h: 4 }` | **Min size**: `{ w: 3, h: 2 }`
|
||||||
|
|
||||||
|
Config schema:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
z.object({
|
||||||
|
containerIds: z.union([z.literal("all"), z.array(z.string().uuid())]),
|
||||||
|
daysAhead: z.number().int().min(0).max(30).default(0),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Default config: `{ containerIds: "all", daysAhead: 0 }`.
|
||||||
|
|
||||||
|
`resolveConfigOptions`: returns `{ containers: [{ id, name }] }`.
|
||||||
|
|
||||||
|
Render:
|
||||||
|
|
||||||
|
- Empty state if nothing is due: "All plants are on schedule."
|
||||||
|
- Otherwise: compact list sorted by days overdue. Each row: plant name, care type icon, urgency badge ("X days overdue" / "due today"), inline "Log care" button (calls `logCare` action + revalidates — no navigation).
|
||||||
|
- Truncate at 10 rows; "View all" link to `/garden`.
|
||||||
|
- Rows with `next_due_at` within `daysAhead` days show as upcoming in a lighter style below overdue rows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Widget 2: `garden.overview`
|
||||||
|
|
||||||
|
**Title**: "Garden overview" | **Category**: "Garden"
|
||||||
|
**Default size**: `{ w: 3, h: 2 }` | **Min size**: `{ w: 2, h: 2 }`
|
||||||
|
|
||||||
|
Config schema: `z.object({})`.
|
||||||
|
|
||||||
|
Render:
|
||||||
|
|
||||||
|
- Stat row: total plants, total containers, overdue care count.
|
||||||
|
- "Next care" line: "Next: water [Plant] in N days" / "today" / "overdue".
|
||||||
|
- Link to `/garden`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Widget component (`src/modules/garden/components/plant-widget.tsx`)
|
||||||
|
|
||||||
|
Server component implementing both widgets, following the pattern in `src/modules/lists/components/list-widget.tsx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Search adapters
|
||||||
|
|
||||||
|
Implement `searchContainers` and `searchPlants` using `ilike` on name fields, returning `SearchResult[]`. Both appear in the command palette.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Notification body improvement (optional)
|
||||||
|
|
||||||
|
If `tickReminders` in `src/modules/_core/reminders.ts` can be extended without structural changes — add a registry of entity-type resolvers and register `"garden.schedule"` to resolve to `"Time to water [plant name]"`. If it requires core changes, defer and note as known limitation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Playwright E2E test (`tests/garden.spec.ts`)
|
||||||
|
|
||||||
|
Happy path:
|
||||||
|
|
||||||
|
1. Log in as the seeded user.
|
||||||
|
2. Create container "Living Room Shelf".
|
||||||
|
3. Create plant "Pothos" in that container (manual entry, no species lookup).
|
||||||
|
4. Add watering schedule: every 7 days.
|
||||||
|
5. Log care: watered.
|
||||||
|
6. Verify schedule `next_due_at` updates to ~7 days from now.
|
||||||
|
7. Confirm plant appears grouped under "Living Room Shelf" on `/garden`.
|
||||||
|
8. Add `garden.care-due` widget to the default dashboard.
|
||||||
|
9. Confirm no overdue items (just watered).
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Health trend charts.
|
||||||
|
- Weather-based watering adjustments.
|
||||||
|
- Social/community features.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] Both widgets register and appear in the widget picker.
|
||||||
|
- [ ] `garden.care-due` shows overdue plants sorted by urgency; inline log button works.
|
||||||
|
- [ ] `garden.overview` shows correct counts.
|
||||||
|
- [ ] Both entities appear in command palette search.
|
||||||
|
- [ ] All three quick-adds appear in the `+` sheet.
|
||||||
|
- [ ] Garden nav link appears in sidebar and bottom nav.
|
||||||
|
- [ ] Playwright happy-path test passes.
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -106,6 +106,20 @@
|
|||||||
"when": 1778600000000,
|
"when": 1778600000000,
|
||||||
"tag": "0014_paper_ink_theme",
|
"tag": "0014_paper_ink_theme",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+32
-6
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "famapp",
|
"name": "famapp",
|
||||||
"version": "0.0.0",
|
"version": "0.4.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.33.3",
|
"packageManager": "pnpm@10.33.3",
|
||||||
@@ -9,6 +9,9 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
"dev:network": "next dev --hostname 0.0.0.0",
|
||||||
|
"dev:local": "node scripts/dev.mjs",
|
||||||
|
"dev:reset": "node scripts/dev-reset.mjs",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
@@ -22,24 +25,47 @@
|
|||||||
"db:seed": "tsx --env-file=.env scripts/seed.ts",
|
"db:seed": "tsx --env-file=.env scripts/seed.ts",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"gen:icons": "node scripts/generate-icons.mjs",
|
"gen:icons": "node scripts/generate-icons.mjs",
|
||||||
"vapid:generate": "node scripts/vapid-generate.mjs"
|
"vapid:generate": "node scripts/vapid-generate.mjs",
|
||||||
|
"prepare": "husky",
|
||||||
|
"release": "dotenv -e .env -- release-it",
|
||||||
|
"release:patch": "dotenv -e .env -- release-it patch",
|
||||||
|
"release:minor": "dotenv -e .env -- release-it minor",
|
||||||
|
"release:major": "dotenv -e .env -- release-it major",
|
||||||
|
"release:dry": "dotenv -e .env -- release-it --dry-run"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{ts,tsx}": [
|
||||||
|
"eslint --fix",
|
||||||
|
"prettier --write"
|
||||||
|
],
|
||||||
|
"*.{js,mjs,cjs}": [
|
||||||
|
"prettier --write"
|
||||||
|
],
|
||||||
|
"*.{json,md,css,yaml,yml}": [
|
||||||
|
"prettier --write"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.5",
|
"@commitlint/cli": "^21.0.2",
|
||||||
|
"@commitlint/config-conventional": "^21.0.2",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
|
"@release-it/conventional-changelog": "^11.0.1",
|
||||||
"@tailwindcss/postcss": "^4.2.4",
|
"@tailwindcss/postcss": "^4.2.4",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-grid-layout": "^2.1.0",
|
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
|
"dotenv-cli": "^11.0.0",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"eslint": "^9.15.0",
|
"eslint": "^9.15.0",
|
||||||
"eslint-config-next": "^16.2.4",
|
"eslint-config-next": "^16.2.4",
|
||||||
"globals": "^15.12.0",
|
"globals": "^15.12.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^17.0.7",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
|
"release-it": "^20.2.0",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
"tsx": "^4.19.4",
|
"tsx": "^4.19.4",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
@@ -58,6 +84,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
|
"minio": "^8.0.7",
|
||||||
"next": "^15.5.15",
|
"next": "^15.5.15",
|
||||||
"next-auth": "5.0.0-beta.31",
|
"next-auth": "5.0.0-beta.31",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
@@ -69,7 +96,6 @@
|
|||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3"
|
||||||
"zod-to-json-schema": "^3.25.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1935
-24
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Dev environment reset script.
|
||||||
|
*
|
||||||
|
* Tears down the local Postgres container and deletes its volume (all local
|
||||||
|
* data is lost), then performs a fresh startup: container up, migrate, seed,
|
||||||
|
* Next.js dev server.
|
||||||
|
*
|
||||||
|
* Use this when you want a completely clean local database — e.g. after a
|
||||||
|
* destructive schema change or to reproduce a fresh-install scenario.
|
||||||
|
*
|
||||||
|
* Usage: pnpm dev:reset
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from "child_process";
|
||||||
|
import { main } from "./dev.mjs";
|
||||||
|
|
||||||
|
console.log("\n⚠ Resetting dev environment — all local data will be deleted.");
|
||||||
|
execSync("docker compose -f docker-compose.dev.yaml down -v", {
|
||||||
|
stdio: "inherit",
|
||||||
|
shell: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\n▸ Restarting from scratch...");
|
||||||
|
await main();
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* Dev environment startup script.
|
||||||
|
*
|
||||||
|
* Starts the local Postgres container, waits for it to be ready, runs
|
||||||
|
* migrations + seed, then spawns `next dev` bound to 0.0.0.0 so the app
|
||||||
|
* is reachable from other devices on the LAN.
|
||||||
|
*
|
||||||
|
* Usage: pnpm dev:local
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync, spawn } from "child_process";
|
||||||
|
import net from "net";
|
||||||
|
import os from "os";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
/** Return the first LAN IPv4 address (prefers 192.168.x / 10.x ranges). */
|
||||||
|
function getLanIp() {
|
||||||
|
const all = Object.values(os.networkInterfaces())
|
||||||
|
.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";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Poll TCP host:port until it accepts a connection or the timeout expires. */
|
||||||
|
function waitForTcp(host, port, timeoutMs = 30_000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
|
||||||
|
function attempt() {
|
||||||
|
const sock = net.createConnection({ host, port });
|
||||||
|
sock.on("connect", () => {
|
||||||
|
sock.destroy();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
sock.on("error", () => {
|
||||||
|
sock.destroy();
|
||||||
|
if (Date.now() >= deadline) {
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setTimeout(attempt, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run a shell command synchronously, streaming output to the terminal. */
|
||||||
|
function run(cmd) {
|
||||||
|
execSync(cmd, { stdio: "inherit", shell: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function main() {
|
||||||
|
// 1. Start DB container (idempotent — safe to call when already running)
|
||||||
|
console.log("\n▸ Starting database container...");
|
||||||
|
run("docker compose -f docker-compose.dev.yaml up -d");
|
||||||
|
|
||||||
|
// 2. Wait for Postgres to accept connections
|
||||||
|
process.stdout.write("▸ Waiting for Postgres");
|
||||||
|
const tick = setInterval(() => process.stdout.write("."), 500);
|
||||||
|
try {
|
||||||
|
await waitForTcp("127.0.0.1", 5432);
|
||||||
|
} finally {
|
||||||
|
clearInterval(tick);
|
||||||
|
process.stdout.write(" ready\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Apply any pending migrations (no-op if already current)
|
||||||
|
console.log("▸ Running migrations...");
|
||||||
|
run("pnpm db:migrate");
|
||||||
|
|
||||||
|
// 4. Seed default data (idempotent)
|
||||||
|
console.log("▸ Seeding...");
|
||||||
|
run("pnpm db:seed");
|
||||||
|
|
||||||
|
// 5. Print access URLs before the Next.js banner appears
|
||||||
|
const lanIp = getLanIp();
|
||||||
|
console.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||||
|
console.log(` Local → http://localhost:3000`);
|
||||||
|
console.log(` LAN → http://${lanIp}:3000 (phone on same WiFi)`);
|
||||||
|
console.log(` HTTPS → https://dev.ginnoir.com (push/PWA — needs Caddy snippet)`);
|
||||||
|
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
|
||||||
|
|
||||||
|
// 6. Spawn Next.js dev server bound to all interfaces.
|
||||||
|
// HMR handles most code changes without a restart.
|
||||||
|
// Restart needed for: .env changes, next.config.ts changes.
|
||||||
|
// Pass command as a single string with no args array — shell handles .cmd
|
||||||
|
// resolution on Windows and the empty args avoids DEP0190.
|
||||||
|
const next = spawn("pnpm run dev:network", [], { stdio: "inherit", shell: true });
|
||||||
|
|
||||||
|
// Forward Ctrl+C / SIGTERM to the child so it shuts down cleanly.
|
||||||
|
const forward = (sig) => () => next.kill(sig);
|
||||||
|
process.on("SIGINT", forward("SIGINT"));
|
||||||
|
process.on("SIGTERM", forward("SIGTERM"));
|
||||||
|
|
||||||
|
await new Promise((resolve) => next.on("exit", (code) => resolve(code)));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run when invoked directly (not imported by dev-reset.mjs)
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
if (process.argv[1] === __filename) {
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("\ndev startup failed:", err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Dev script: send a test push notification to all subscriptions for the dev user.
|
||||||
|
* Usage: tsx --env-file=.env scripts/send-test-push.mts
|
||||||
|
*/
|
||||||
|
import webPush from "web-push";
|
||||||
|
import postgres from "postgres";
|
||||||
|
|
||||||
|
const subject = process.env["VAPID_SUBJECT"];
|
||||||
|
const publicKey = process.env["VAPID_PUBLIC_KEY"];
|
||||||
|
const privateKey = process.env["VAPID_PRIVATE_KEY"];
|
||||||
|
const databaseUrl = process.env["DATABASE_URL"];
|
||||||
|
|
||||||
|
if (!subject || !publicKey || !privateKey) {
|
||||||
|
console.error("VAPID env vars not set");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!databaseUrl) {
|
||||||
|
console.error("DATABASE_URL not set");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
webPush.setVapidDetails(subject, publicKey, privateKey);
|
||||||
|
|
||||||
|
const sql = postgres(databaseUrl);
|
||||||
|
|
||||||
|
const subs = await sql`SELECT id, user_id, endpoint, p256dh, auth FROM push_subscriptions`;
|
||||||
|
console.log(`Found ${subs.length} push subscription(s)`);
|
||||||
|
|
||||||
|
if (subs.length === 0) {
|
||||||
|
console.log("No subscriptions — enable push notifications in Settings first.");
|
||||||
|
await sql.end();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
console.log(`Sent to ${sub.endpoint.slice(0, 60)}...`);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const e = err as { statusCode?: number; message?: string };
|
||||||
|
console.error(`Failed (${e.statusCode ?? "?"}): ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sql.end();
|
||||||
@@ -3,4 +3,3 @@ import webPush from "web-push";
|
|||||||
const { publicKey, privateKey } = webPush.generateVAPIDKeys();
|
const { publicKey, privateKey } = webPush.generateVAPIDKeys();
|
||||||
console.log(`VAPID_PUBLIC_KEY=${publicKey}`);
|
console.log(`VAPID_PUBLIC_KEY=${publicKey}`);
|
||||||
console.log(`VAPID_PRIVATE_KEY=${privateKey}`);
|
console.log(`VAPID_PRIVATE_KEY=${privateKey}`);
|
||||||
console.log(`NEXT_PUBLIC_VAPID_PUBLIC_KEY=${publicKey}`);
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}` });
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+229
-9
@@ -205,33 +205,117 @@
|
|||||||
--sans: var(--sans-inter, "Inter", system-ui, sans-serif);
|
--sans: var(--sans-inter, "Inter", system-ui, sans-serif);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Palettes — the only thing that varies is the accent + soft +
|
/* ── Palettes — each theme sets surfaces, accent, and household color. */
|
||||||
household-calendar color. Everything else stays paper-and-ink. */
|
|
||||||
[data-theme="clay"] {
|
[data-theme="clay"] {
|
||||||
|
--paper: #fbf9f4;
|
||||||
|
--paper-2: #f4f0e8;
|
||||||
|
--card: #ffffff;
|
||||||
|
--hair: rgba(90, 40, 20, 0.09);
|
||||||
|
--hair-2: rgba(90, 40, 20, 0.16);
|
||||||
|
--shade: rgba(90, 40, 20, 0.04);
|
||||||
--accent: #b85c3c;
|
--accent: #b85c3c;
|
||||||
--accent-soft: #ebd8ce;
|
--accent-soft: #ebd8ce;
|
||||||
--c-household: #b85c3c;
|
--c-household: #b85c3c;
|
||||||
}
|
}
|
||||||
[data-theme="indigo"] {
|
[data-theme="indigo"] {
|
||||||
|
--paper: #f4f7fb;
|
||||||
|
--paper-2: #eaeff7;
|
||||||
|
--card: #ffffff;
|
||||||
|
--hair: rgba(22, 40, 74, 0.09);
|
||||||
|
--hair-2: rgba(22, 40, 74, 0.16);
|
||||||
|
--shade: rgba(22, 40, 74, 0.04);
|
||||||
--accent: #3e5b8a;
|
--accent: #3e5b8a;
|
||||||
--accent-soft: #d5dde8;
|
--accent-soft: #d5dde8;
|
||||||
--c-household: #3e5b8a;
|
--c-household: #3e5b8a;
|
||||||
}
|
}
|
||||||
[data-theme="sage"] {
|
[data-theme="sage"] {
|
||||||
|
--paper: #f5f8f2;
|
||||||
|
--paper-2: #eaf0e4;
|
||||||
|
--card: #ffffff;
|
||||||
|
--hair: rgba(30, 55, 22, 0.09);
|
||||||
|
--hair-2: rgba(30, 55, 22, 0.16);
|
||||||
|
--shade: rgba(30, 55, 22, 0.04);
|
||||||
--accent: #6f8b5e;
|
--accent: #6f8b5e;
|
||||||
--accent-soft: #dbe3d2;
|
--accent-soft: #dbe3d2;
|
||||||
--c-household: #6f8b5e;
|
--c-household: #6f8b5e;
|
||||||
}
|
}
|
||||||
[data-theme="plum"] {
|
[data-theme="plum"] {
|
||||||
|
--paper: #faf5fb;
|
||||||
|
--paper-2: #f1e8f2;
|
||||||
|
--card: #ffffff;
|
||||||
|
--hair: rgba(60, 25, 65, 0.09);
|
||||||
|
--hair-2: rgba(60, 25, 65, 0.16);
|
||||||
|
--shade: rgba(60, 25, 65, 0.04);
|
||||||
--accent: #7b4f6e;
|
--accent: #7b4f6e;
|
||||||
--accent-soft: #e2d5dd;
|
--accent-soft: #e2d5dd;
|
||||||
--c-household: #7b4f6e;
|
--c-household: #7b4f6e;
|
||||||
}
|
}
|
||||||
[data-theme="ink"] {
|
[data-theme="ink"] {
|
||||||
|
--paper: #f8f7f5;
|
||||||
|
--paper-2: #edecea;
|
||||||
|
--card: #ffffff;
|
||||||
|
--hair: rgba(31, 27, 22, 0.09);
|
||||||
|
--hair-2: rgba(31, 27, 22, 0.16);
|
||||||
|
--shade: rgba(31, 27, 22, 0.04);
|
||||||
--accent: #1f1b16;
|
--accent: #1f1b16;
|
||||||
--accent-soft: #e8e4dc;
|
--accent-soft: #e8e4dc;
|
||||||
--c-household: #5c5145;
|
--c-household: #5c5145;
|
||||||
}
|
}
|
||||||
|
[data-theme="rose"] {
|
||||||
|
--paper: #fdf5f7;
|
||||||
|
--paper-2: #f7e8ec;
|
||||||
|
--card: #ffffff;
|
||||||
|
--hair: rgba(100, 20, 40, 0.09);
|
||||||
|
--hair-2: rgba(100, 20, 40, 0.16);
|
||||||
|
--shade: rgba(100, 20, 40, 0.04);
|
||||||
|
--accent: #b5485e;
|
||||||
|
--accent-soft: #f2d4da;
|
||||||
|
--c-household: #b5485e;
|
||||||
|
}
|
||||||
|
[data-theme="amber"] {
|
||||||
|
--paper: #fdf9f0;
|
||||||
|
--paper-2: #f5eed8;
|
||||||
|
--card: #ffffff;
|
||||||
|
--hair: rgba(90, 60, 5, 0.09);
|
||||||
|
--hair-2: rgba(90, 60, 5, 0.16);
|
||||||
|
--shade: rgba(90, 60, 5, 0.04);
|
||||||
|
--accent: #b07d1e;
|
||||||
|
--accent-soft: #f0e2b8;
|
||||||
|
--c-household: #b07d1e;
|
||||||
|
}
|
||||||
|
[data-theme="ocean"] {
|
||||||
|
--paper: #f2f9fb;
|
||||||
|
--paper-2: #e4f2f5;
|
||||||
|
--card: #ffffff;
|
||||||
|
--hair: rgba(10, 65, 80, 0.09);
|
||||||
|
--hair-2: rgba(10, 65, 80, 0.16);
|
||||||
|
--shade: rgba(10, 65, 80, 0.04);
|
||||||
|
--accent: #2e7a8a;
|
||||||
|
--accent-soft: #cce8ed;
|
||||||
|
--c-household: #2e7a8a;
|
||||||
|
}
|
||||||
|
[data-theme="slate"] {
|
||||||
|
--paper: #f4f6f9;
|
||||||
|
--paper-2: #e8ecf0;
|
||||||
|
--card: #ffffff;
|
||||||
|
--hair: rgba(20, 40, 60, 0.09);
|
||||||
|
--hair-2: rgba(20, 40, 60, 0.16);
|
||||||
|
--shade: rgba(20, 40, 60, 0.04);
|
||||||
|
--accent: #4a657a;
|
||||||
|
--accent-soft: #d0dae2;
|
||||||
|
--c-household: #4a657a;
|
||||||
|
}
|
||||||
|
[data-theme="forest"] {
|
||||||
|
--paper: #f2f7f3;
|
||||||
|
--paper-2: #e4eee6;
|
||||||
|
--card: #ffffff;
|
||||||
|
--hair: rgba(15, 55, 25, 0.09);
|
||||||
|
--hair-2: rgba(15, 55, 25, 0.16);
|
||||||
|
--shade: rgba(15, 55, 25, 0.04);
|
||||||
|
--accent: #3a6645;
|
||||||
|
--accent-soft: #c8dece;
|
||||||
|
--c-household: #3a6645;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Dark variant — every palette switches to deep ink surfaces.
|
/* ── Dark variant — every palette switches to deep ink surfaces.
|
||||||
Functional accents lift slightly so they stay readable. ───── */
|
Functional accents lift slightly so they stay readable. ───── */
|
||||||
@@ -277,7 +361,7 @@
|
|||||||
--border: var(--hair);
|
--border: var(--hair);
|
||||||
--input: var(--hair-2);
|
--input: var(--hair-2);
|
||||||
--ring: var(--ink-soft);
|
--ring: var(--ink-soft);
|
||||||
--sidebar: #1a1712;
|
--sidebar: var(--paper-2);
|
||||||
--sidebar-foreground: var(--ink-2);
|
--sidebar-foreground: var(--ink-2);
|
||||||
--sidebar-primary: var(--ink);
|
--sidebar-primary: var(--ink);
|
||||||
--sidebar-primary-foreground: var(--paper);
|
--sidebar-primary-foreground: var(--paper);
|
||||||
@@ -288,30 +372,115 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="clay"].dark {
|
[data-theme="clay"].dark {
|
||||||
|
--paper: #16140f;
|
||||||
|
--paper-2: #1e1b16;
|
||||||
|
--card: #24201a;
|
||||||
|
--hair: rgba(244, 210, 180, 0.08);
|
||||||
|
--hair-2: rgba(244, 210, 180, 0.16);
|
||||||
|
--shade: rgba(244, 210, 180, 0.04);
|
||||||
--accent: #d88468;
|
--accent: #d88468;
|
||||||
--accent-soft: #3a2e26;
|
--accent-soft: #3a2e26;
|
||||||
--c-household: #d88468;
|
--c-household: #d88468;
|
||||||
}
|
}
|
||||||
[data-theme="indigo"].dark {
|
[data-theme="indigo"].dark {
|
||||||
|
--paper: #0f1319;
|
||||||
|
--paper-2: #15202e;
|
||||||
|
--card: #1c2a3a;
|
||||||
|
--hair: rgba(184, 212, 244, 0.08);
|
||||||
|
--hair-2: rgba(184, 212, 244, 0.16);
|
||||||
|
--shade: rgba(184, 212, 244, 0.04);
|
||||||
--accent: #7e9dd0;
|
--accent: #7e9dd0;
|
||||||
--accent-soft: #232c3c;
|
--accent-soft: #1a2540;
|
||||||
--c-household: #7e9dd0;
|
--c-household: #7e9dd0;
|
||||||
}
|
}
|
||||||
[data-theme="sage"].dark {
|
[data-theme="sage"].dark {
|
||||||
|
--paper: #0f1710;
|
||||||
|
--paper-2: #162018;
|
||||||
|
--card: #1c2a1e;
|
||||||
|
--hair: rgba(196, 228, 186, 0.08);
|
||||||
|
--hair-2: rgba(196, 228, 186, 0.16);
|
||||||
|
--shade: rgba(196, 228, 186, 0.04);
|
||||||
--accent: #a0bc8e;
|
--accent: #a0bc8e;
|
||||||
--accent-soft: #25302a;
|
--accent-soft: #1e2e1c;
|
||||||
--c-household: #a0bc8e;
|
--c-household: #a0bc8e;
|
||||||
}
|
}
|
||||||
[data-theme="plum"].dark {
|
[data-theme="plum"].dark {
|
||||||
|
--paper: #160f1a;
|
||||||
|
--paper-2: #1e1522;
|
||||||
|
--card: #261a2e;
|
||||||
|
--hair: rgba(230, 180, 240, 0.08);
|
||||||
|
--hair-2: rgba(230, 180, 240, 0.16);
|
||||||
|
--shade: rgba(230, 180, 240, 0.04);
|
||||||
--accent: #be8bad;
|
--accent: #be8bad;
|
||||||
--accent-soft: #321f30;
|
--accent-soft: #2a1630;
|
||||||
--c-household: #be8bad;
|
--c-household: #be8bad;
|
||||||
}
|
}
|
||||||
[data-theme="ink"].dark {
|
[data-theme="ink"].dark {
|
||||||
|
--paper: #0e0e0e;
|
||||||
|
--paper-2: #181818;
|
||||||
|
--card: #222222;
|
||||||
|
--hair: rgba(240, 240, 240, 0.08);
|
||||||
|
--hair-2: rgba(240, 240, 240, 0.16);
|
||||||
|
--shade: rgba(240, 240, 240, 0.04);
|
||||||
--accent: #f4efe5;
|
--accent: #f4efe5;
|
||||||
--accent-soft: #2a2620;
|
--accent-soft: #2a2620;
|
||||||
--c-household: #c4bbaa;
|
--c-household: #c4bbaa;
|
||||||
}
|
}
|
||||||
|
[data-theme="rose"].dark {
|
||||||
|
--paper: #1a0e10;
|
||||||
|
--paper-2: #221418;
|
||||||
|
--card: #2c1a1e;
|
||||||
|
--hair: rgba(248, 188, 200, 0.08);
|
||||||
|
--hair-2: rgba(248, 188, 200, 0.16);
|
||||||
|
--shade: rgba(248, 188, 200, 0.04);
|
||||||
|
--accent: #d87f94;
|
||||||
|
--accent-soft: #36181e;
|
||||||
|
--c-household: #d87f94;
|
||||||
|
}
|
||||||
|
[data-theme="amber"].dark {
|
||||||
|
--paper: #18130a;
|
||||||
|
--paper-2: #201a0e;
|
||||||
|
--card: #2a2214;
|
||||||
|
--hair: rgba(240, 210, 140, 0.08);
|
||||||
|
--hair-2: rgba(240, 210, 140, 0.16);
|
||||||
|
--shade: rgba(240, 210, 140, 0.04);
|
||||||
|
--accent: #d4a84a;
|
||||||
|
--accent-soft: #342410;
|
||||||
|
--c-household: #d4a84a;
|
||||||
|
}
|
||||||
|
[data-theme="ocean"].dark {
|
||||||
|
--paper: #091518;
|
||||||
|
--paper-2: #0f2028;
|
||||||
|
--card: #142c38;
|
||||||
|
--hair: rgba(170, 230, 240, 0.08);
|
||||||
|
--hair-2: rgba(170, 230, 240, 0.16);
|
||||||
|
--shade: rgba(170, 230, 240, 0.04);
|
||||||
|
--accent: #5ab8c8;
|
||||||
|
--accent-soft: #0f2c36;
|
||||||
|
--c-household: #5ab8c8;
|
||||||
|
}
|
||||||
|
[data-theme="slate"].dark {
|
||||||
|
--paper: #0e1420;
|
||||||
|
--paper-2: #151e2e;
|
||||||
|
--card: #1c2840;
|
||||||
|
--hair: rgba(160, 190, 218, 0.08);
|
||||||
|
--hair-2: rgba(160, 190, 218, 0.16);
|
||||||
|
--shade: rgba(160, 190, 218, 0.04);
|
||||||
|
--accent: #7ea8c4;
|
||||||
|
--accent-soft: #182032;
|
||||||
|
--c-household: #7ea8c4;
|
||||||
|
}
|
||||||
|
[data-theme="forest"].dark {
|
||||||
|
--paper: #0c1710;
|
||||||
|
--paper-2: #122018;
|
||||||
|
--card: #162c20;
|
||||||
|
--hair: rgba(152, 210, 170, 0.08);
|
||||||
|
--hair-2: rgba(152, 210, 170, 0.16);
|
||||||
|
--shade: rgba(152, 210, 170, 0.04);
|
||||||
|
--accent: #74b888;
|
||||||
|
--accent-soft: #142618;
|
||||||
|
--c-household: #74b888;
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@@ -471,14 +640,14 @@
|
|||||||
color: var(--ink-mute);
|
color: var(--ink-mute);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
:where(html[data-nav="rail"]) .nav-item {
|
:where(html[data-nav="rail"]) .sidebar .nav-item {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
:where(html[data-nav="rail"]) .nav-label,
|
:where(html[data-nav="rail"]) .sidebar .nav-label,
|
||||||
:where(html[data-nav="rail"]) .nav-count {
|
:where(html[data-nav="rail"]) .sidebar .nav-count {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.sidebar-top .nav-item {
|
.sidebar-top .nav-item {
|
||||||
@@ -1126,3 +1295,54 @@
|
|||||||
color: var(--ink-mute);
|
color: var(--ink-mute);
|
||||||
font-variant-numeric: tabular-nums;
|
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
-1
@@ -69,7 +69,7 @@ const prePaintScript = `(function(){
|
|||||||
var navStyle = localStorage.getItem('themeNavStyle') || 'rail-desktop';
|
var navStyle = localStorage.getItem('themeNavStyle') || 'rail-desktop';
|
||||||
var dataNav = navStyle === 'compact-rail' ? 'rail'
|
var dataNav = navStyle === 'compact-rail' ? 'rail'
|
||||||
: navStyle === 'top-nav' ? 'top'
|
: navStyle === 'top-nav' ? 'top'
|
||||||
: navStyle === 'fab-only' ? 'fab' : 'sidebar';
|
: 'sidebar';
|
||||||
if (window.matchMedia && window.matchMedia('(max-width: 759px)').matches) {
|
if (window.matchMedia && window.matchMedia('(max-width: 759px)').matches) {
|
||||||
dataNav = navStyle === 'fab-only' ? 'fab' : 'bottom';
|
dataNav = navStyle === 'fab-only' ? 'fab' : 'bottom';
|
||||||
}
|
}
|
||||||
@@ -149,6 +149,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
data-density={density}
|
data-density={density}
|
||||||
data-nav={initialDataNav}
|
data-nav={initialDataNav}
|
||||||
className={cn(fontVars, isDark ? "dark" : "")}
|
className={cn(fontVars, isDark ? "dark" : "")}
|
||||||
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<head>
|
<head>
|
||||||
<script dangerouslySetInnerHTML={{ __html: prePaintScript }} />
|
<script dangerouslySetInnerHTML={{ __html: prePaintScript }} />
|
||||||
|
|||||||
+10
-6
@@ -2,7 +2,7 @@ import { signIn } from "@/lib/auth";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { DEV_LOGIN_COOKIE, isDevLoginEnabled } from "@/lib/dev-login-config";
|
import { isDevLoginEnabled } from "@/lib/dev-login-config";
|
||||||
import { createDevSession } from "@/lib/dev-login";
|
import { createDevSession } from "@/lib/dev-login";
|
||||||
import { BrandMark } from "@/components/brand-mark";
|
import { BrandMark } from "@/components/brand-mark";
|
||||||
|
|
||||||
@@ -37,13 +37,17 @@ export default function LoginPage() {
|
|||||||
<form
|
<form
|
||||||
action={async () => {
|
action={async () => {
|
||||||
"use server";
|
"use server";
|
||||||
|
// Auth.js may resolve either "authjs.session-token" (HTTP/dev) or
|
||||||
|
// "__Secure-authjs.session-token" (HTTPS) depending on AUTH_URL,
|
||||||
|
// trustHost, and proxy headers. Set both so the session is found
|
||||||
|
// regardless — this is dev-only code, correctness > elegance.
|
||||||
const { sessionToken, expires } = await createDevSession();
|
const { sessionToken, expires } = await createDevSession();
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set(DEV_LOGIN_COOKIE, sessionToken, {
|
const base = { httpOnly: true, sameSite: "lax" as const, path: "/", expires };
|
||||||
httpOnly: true,
|
cookieStore.set("authjs.session-token", sessionToken, base);
|
||||||
sameSite: "lax",
|
cookieStore.set("__Secure-authjs.session-token", sessionToken, {
|
||||||
path: "/",
|
...base,
|
||||||
expires,
|
secure: true,
|
||||||
});
|
});
|
||||||
redirect("/");
|
redirect("/");
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { and, eq } from "drizzle-orm";
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { pushSubscriptions } from "@/modules/_core/schema";
|
import { pushSubscriptions } from "@/modules/_core/schema";
|
||||||
import { getCurrentSession } from "@/lib/session";
|
import { getCurrentSession } from "@/lib/session";
|
||||||
import { sendPush } from "@/modules/_core/push";
|
import { sendPushToEndpoint } from "@/modules/_core/push";
|
||||||
|
|
||||||
type PushSubscriptionJSON = {
|
type PushSubscriptionJSON = {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
@@ -35,9 +35,16 @@ export async function unsubscribeFromPush(endpoint: string): Promise<void> {
|
|||||||
.where(and(eq(pushSubscriptions.userId, user.id), eq(pushSubscriptions.endpoint, endpoint)));
|
.where(and(eq(pushSubscriptions.userId, user.id), eq(pushSubscriptions.endpoint, endpoint)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendTestNotification(): Promise<void> {
|
export async function sendTestNotification(endpoint: string): Promise<void> {
|
||||||
const { user } = await getCurrentSession();
|
const { user } = await getCurrentSession();
|
||||||
await sendPush(user.id, {
|
// Verify the endpoint belongs to the current user before sending.
|
||||||
|
const [owned] = await db
|
||||||
|
.select({ id: pushSubscriptions.id })
|
||||||
|
.from(pushSubscriptions)
|
||||||
|
.where(and(eq(pushSubscriptions.userId, user.id), eq(pushSubscriptions.endpoint, endpoint)))
|
||||||
|
.limit(1);
|
||||||
|
if (!owned) return;
|
||||||
|
await sendPushToEndpoint(endpoint, {
|
||||||
title: "famapp test",
|
title: "famapp test",
|
||||||
body: "Push notifications are working!",
|
body: "Push notifications are working!",
|
||||||
url: "/settings",
|
url: "/settings",
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
"use client";
|
import { getRegistry } from "@/modules/_core/registry";
|
||||||
|
|
||||||
import { NavLink } from "@/components/nav-link";
|
import { NavLink } from "@/components/nav-link";
|
||||||
|
|
||||||
const ITEMS: Array<{ href: string; label: string; icon: string }> = [
|
export async function BottomNav() {
|
||||||
|
const { modules } = getRegistry();
|
||||||
|
const moduleNav = modules.flatMap((m) => (m.nav ? [m.nav] : []));
|
||||||
|
|
||||||
|
const items = [
|
||||||
{ href: "/", label: "Dashboard", icon: "home" },
|
{ href: "/", label: "Dashboard", icon: "home" },
|
||||||
{ href: "/calendar", label: "Calendar", icon: "calendar" },
|
...moduleNav.map((n) => ({ href: n.href, label: n.label, icon: n.icon ?? "circle" })),
|
||||||
{ href: "/lists", label: "Lists", icon: "list" },
|
|
||||||
{ href: "/notes", label: "Notes", icon: "note" },
|
|
||||||
{ href: "/settings", label: "Settings", icon: "settings" },
|
{ href: "/settings", label: "Settings", icon: "settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function BottomNav() {
|
|
||||||
return (
|
return (
|
||||||
<nav className="bottom-nav" aria-label="Primary navigation">
|
<nav className="bottom-nav" aria-label="Primary navigation">
|
||||||
{ITEMS.map((it) => (
|
{items.map((it) => (
|
||||||
<NavLink key={it.href} href={it.href} icon={it.icon} label={it.label} variant="bottom" />
|
<NavLink key={it.href} href={it.href} icon={it.icon} label={it.label} variant="bottom" />
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
Sprout,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -68,6 +69,7 @@ const ICONS: Record<string, React.ComponentType<LucideProps>> = {
|
|||||||
phone: Phone,
|
phone: Phone,
|
||||||
filter: Filter,
|
filter: Filter,
|
||||||
sun: Sun,
|
sun: Sun,
|
||||||
|
sprout: Sprout,
|
||||||
mail: Mail,
|
mail: Mail,
|
||||||
menu: Menu,
|
menu: Menu,
|
||||||
more: MoreHorizontal,
|
more: MoreHorizontal,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition, useEffect } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
subscribeToPush,
|
subscribeToPush,
|
||||||
@@ -23,6 +23,20 @@ export function PushOptIn({ vapidKey }: { vapidKey: string }) {
|
|||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [testSent, setTestSent] = useState(false);
|
const [testSent, setTestSent] = useState(false);
|
||||||
|
|
||||||
|
// Hydrate subscription state from the browser on mount so the UI reflects
|
||||||
|
// reality even when the user navigates away and returns to Settings.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!vapidKey || !("serviceWorker" in navigator) || !("PushManager" in window)) return;
|
||||||
|
navigator.serviceWorker.ready.then((reg) =>
|
||||||
|
reg.pushManager.getSubscription().then((existing) => {
|
||||||
|
if (existing) {
|
||||||
|
setEndpoint(existing.endpoint);
|
||||||
|
setStatus("subscribed");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [vapidKey]);
|
||||||
|
|
||||||
if (!vapidKey) return null;
|
if (!vapidKey) return null;
|
||||||
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
||||||
return (
|
return (
|
||||||
@@ -35,10 +49,15 @@ export function PushOptIn({ vapidKey }: { vapidKey: string }) {
|
|||||||
async function subscribe() {
|
async function subscribe() {
|
||||||
try {
|
try {
|
||||||
const registration = await navigator.serviceWorker.ready;
|
const registration = await navigator.serviceWorker.ready;
|
||||||
const sub = await registration.pushManager.subscribe({
|
// Reuse an existing browser subscription rather than calling subscribe()
|
||||||
|
// again — avoids redundant prompts and inconsistent behaviour on iOS.
|
||||||
|
const existing = await registration.pushManager.getSubscription();
|
||||||
|
const sub =
|
||||||
|
existing ??
|
||||||
|
(await registration.pushManager.subscribe({
|
||||||
userVisibleOnly: true,
|
userVisibleOnly: true,
|
||||||
applicationServerKey: urlBase64ToUint8Array(vapidKey),
|
applicationServerKey: urlBase64ToUint8Array(vapidKey),
|
||||||
});
|
}));
|
||||||
const json = sub.toJSON() as { endpoint: string; keys: { p256dh: string; auth: string } };
|
const json = sub.toJSON() as { endpoint: string; keys: { p256dh: string; auth: string } };
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
await subscribeToPush(json, navigator.userAgent);
|
await subscribeToPush(json, navigator.userAgent);
|
||||||
@@ -63,8 +82,9 @@ export function PushOptIn({ vapidKey }: { vapidKey: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sendTest() {
|
function sendTest() {
|
||||||
|
if (!endpoint) return;
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
await sendTestNotification();
|
await sendTestNotification(endpoint);
|
||||||
setTestSent(true);
|
setTestSent(true);
|
||||||
setTimeout(() => setTestSent(false), 3000);
|
setTimeout(() => setTestSent(false), 3000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,28 +67,39 @@ export function ThemePicker({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Palette</Label>
|
<Label>Theme</Label>
|
||||||
<div className="flex gap-2">
|
<Select value={t.palette} onValueChange={(v) => t.setPalette(v as Palette)}>
|
||||||
{PALETTES.map((p) => (
|
<SelectTrigger className="w-56">
|
||||||
<button
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
key={p.id}
|
<span
|
||||||
type="button"
|
className="inline-block w-5 h-4 rounded-sm shrink-0 overflow-hidden border-[0.5px]"
|
||||||
onClick={() => t.setPalette(p.id)}
|
|
||||||
aria-pressed={t.palette === p.id}
|
|
||||||
title={p.label}
|
|
||||||
className={cn(
|
|
||||||
"size-7 rounded-md border-[0.5px] cursor-pointer transition-transform",
|
|
||||||
t.palette === p.id ? "scale-110" : "hover:scale-105",
|
|
||||||
)}
|
|
||||||
style={{
|
style={{
|
||||||
background: p.hex,
|
background: `linear-gradient(to bottom, ${
|
||||||
|
PALETTES.find((p) => p.id === t.palette)?.paper ?? "#fff"
|
||||||
|
} 55%, ${PALETTES.find((p) => p.id === t.palette)?.hex ?? "#888"} 55%)`,
|
||||||
borderColor: "var(--hair-2)",
|
borderColor: "var(--hair-2)",
|
||||||
outline: t.palette === p.id ? "2px solid var(--ink)" : "none",
|
|
||||||
outlineOffset: 2,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
<SelectValue />
|
||||||
</div>
|
</div>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PALETTES.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>
|
||||||
|
<div className="flex items-center justify-between gap-6 w-40">
|
||||||
|
<span>{p.label}</span>
|
||||||
|
<span
|
||||||
|
className="inline-block w-5 h-4 rounded-sm shrink-0 overflow-hidden border-[0.5px]"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to bottom, ${p.paper} 55%, ${p.hex} 55%)`,
|
||||||
|
borderColor: "var(--hair-2)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import type {
|
import type {
|
||||||
Palette,
|
Palette,
|
||||||
ThemeMode,
|
ThemeMode,
|
||||||
@@ -67,6 +68,7 @@ export function useTheme(
|
|||||||
const [dashLayout, setDashLayoutState] = useState<DashLayout>(initial.dashLayout ?? "classic");
|
const [dashLayout, setDashLayoutState] = useState<DashLayout>(initial.dashLayout ?? "classic");
|
||||||
const [calView, setCalViewState] = useState<CalView>(initial.calView ?? "month");
|
const [calView, setCalViewState] = useState<CalView>(initial.calView ?? "month");
|
||||||
const [navStyle, setNavStyleState] = useState<NavStyle>(initial.navStyle ?? "rail-desktop");
|
const [navStyle, setNavStyleState] = useState<NavStyle>(initial.navStyle ?? "rail-desktop");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
// Re-apply data-nav whenever the viewport crosses the mobile breakpoint.
|
// Re-apply data-nav whenever the viewport crosses the mobile breakpoint.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -158,9 +160,11 @@ export function useTheme(
|
|||||||
(next: NavStyle) => {
|
(next: NavStyle) => {
|
||||||
setNavStyleState(next);
|
setNavStyleState(next);
|
||||||
applyTheme({ palette, mode, fontPair, density, navStyle: next });
|
applyTheme({ palette, mode, fontPair, density, navStyle: next });
|
||||||
persist({ navStyle: next });
|
if (signedIn) {
|
||||||
|
void setUserTheme({ navStyle: next }).then(() => router.refresh());
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[palette, mode, fontPair, density, persist],
|
[palette, mode, fontPair, density, signedIn, router],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,5 +2,15 @@ export async function register() {
|
|||||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
const { startReminderWorker } = await import("@/modules/_core/reminders");
|
const { startReminderWorker } = await import("@/modules/_core/reminders");
|
||||||
startReminderWorker();
|
startReminderWorker();
|
||||||
|
|
||||||
|
const vapidSubject = process.env["VAPID_SUBJECT"];
|
||||||
|
const vapidPublic = process.env["VAPID_PUBLIC_KEY"];
|
||||||
|
const vapidPrivate = process.env["VAPID_PRIVATE_KEY"];
|
||||||
|
if (!vapidSubject || !vapidPublic || !vapidPrivate) {
|
||||||
|
console.warn(
|
||||||
|
"[famapp] VAPID keys not configured — web push notifications are disabled. " +
|
||||||
|
"Run `pnpm vapid:generate` and add VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY to .env",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -4,9 +4,16 @@ import * as coreSchema from "@/modules/_core/schema";
|
|||||||
import * as calendarSchema from "@/modules/calendar/schema";
|
import * as calendarSchema from "@/modules/calendar/schema";
|
||||||
import * as listsSchema from "@/modules/lists/schema";
|
import * as listsSchema from "@/modules/lists/schema";
|
||||||
import * as notesSchema from "@/modules/notes/schema";
|
import * as notesSchema from "@/modules/notes/schema";
|
||||||
|
import * as gardenSchema from "@/modules/garden/schema";
|
||||||
|
|
||||||
const client = postgres(process.env["DATABASE_URL"]!);
|
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 });
|
export const db = drizzle(client, { schema });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -22,6 +22,6 @@ export type { QuickAddItem, SerializedQuickAddItem, SerializedWidgetMeta } from
|
|||||||
export { logActivity, logShareActivity } from "./activity";
|
export { logActivity, logShareActivity } from "./activity";
|
||||||
export { createShareLink, resolveShareToken, revokeShareLink } from "./share";
|
export { createShareLink, resolveShareToken, revokeShareLink } from "./share";
|
||||||
export type { ShareLinkCapabilities, CreateShareLinkResult } from "./share";
|
export type { ShareLinkCapabilities, CreateShareLinkResult } from "./share";
|
||||||
export { sendPush } from "./push";
|
export { sendPush, sendPushToEndpoint } from "./push";
|
||||||
export { notify } from "./notify";
|
export { notify } from "./notify";
|
||||||
export { scheduleReminder, cancelReminder, listReminders, startReminderWorker } from "./reminders";
|
export { scheduleReminder, cancelReminder, listReminders, startReminderWorker } from "./reminders";
|
||||||
|
|||||||
@@ -14,6 +14,32 @@ function ensureVapidConfigured() {
|
|||||||
webPush.setVapidDetails(subject, publicKey, privateKey);
|
webPush.setVapidDetails(subject, publicKey, privateKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendPushToEndpoint(
|
||||||
|
endpoint: string,
|
||||||
|
payload: { title: string; body: string; url?: string },
|
||||||
|
) {
|
||||||
|
ensureVapidConfigured();
|
||||||
|
const [sub] = await db
|
||||||
|
.select()
|
||||||
|
.from(pushSubscriptions)
|
||||||
|
.where(eq(pushSubscriptions.endpoint, endpoint))
|
||||||
|
.limit(1);
|
||||||
|
if (!sub) return;
|
||||||
|
try {
|
||||||
|
await webPush.sendNotification(
|
||||||
|
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
|
||||||
|
JSON.stringify({ title: payload.title, body: payload.body, url: payload.url ?? "/" }),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const status = (err as { statusCode?: number }).statusCode;
|
||||||
|
if (status === 404 || status === 410) {
|
||||||
|
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.endpoint, endpoint));
|
||||||
|
} else {
|
||||||
|
logger.error({ err }, "push delivery failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendPush(
|
export async function sendPush(
|
||||||
userId: string,
|
userId: string,
|
||||||
payload: { title: string; body: string; url?: string },
|
payload: { title: string; body: string; url?: string },
|
||||||
|
|||||||
@@ -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[] {
|
export function getQuickAdds(): SerializedQuickAddItem[] {
|
||||||
return [...modules.values()].flatMap((manifest) =>
|
return [...modules.values()].flatMap((manifest) =>
|
||||||
(manifest.quickAdds ?? []).map(({ id, label, icon, url }) => ({
|
(manifest.quickAdds ?? []).map(({ id, label, icon, url }) => ({
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export async function scheduleReminder(input: {
|
|||||||
fireAt: Date;
|
fireAt: Date;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
}) {
|
}) {
|
||||||
await db
|
await db
|
||||||
.insert(reminders)
|
.insert(reminders)
|
||||||
@@ -22,12 +24,20 @@ export async function scheduleReminder(input: {
|
|||||||
entityId: input.entityId,
|
entityId: input.entityId,
|
||||||
fireAt: input.fireAt,
|
fireAt: input.fireAt,
|
||||||
channel: input.channel ?? "auto",
|
channel: input.channel ?? "auto",
|
||||||
|
title: input.title ?? null,
|
||||||
|
body: input.body ?? null,
|
||||||
createdBy: input.createdBy,
|
createdBy: input.createdBy,
|
||||||
firedAt: null,
|
firedAt: null,
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: [reminders.entityType, reminders.entityId],
|
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;
|
if (!reminder.createdBy) return;
|
||||||
try {
|
try {
|
||||||
await notify(reminder.createdBy, {
|
await notify(reminder.createdBy, {
|
||||||
title: "Reminder",
|
title: reminder.title ?? "Reminder",
|
||||||
body: `You have a reminder`,
|
body: reminder.body ?? "You have a reminder",
|
||||||
url: reminder.entityType === "notes.note" ? `/notes/${reminder.entityId}` : "/",
|
url: reminder.entityType === "notes.note" ? `/notes/${reminder.entityId}` : "/",
|
||||||
channels: ["push", "inapp"],
|
channels: ["push", "inapp"],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -166,6 +166,8 @@ export const reminders = pgTable(
|
|||||||
entityId: uuid("entity_id").notNull(),
|
entityId: uuid("entity_id").notNull(),
|
||||||
fireAt: timestamp("fire_at", { withTimezone: true }).notNull(),
|
fireAt: timestamp("fire_at", { withTimezone: true }).notNull(),
|
||||||
channel: text("channel").notNull().default("auto"),
|
channel: text("channel").notNull().default("auto"),
|
||||||
|
title: text("title"),
|
||||||
|
body: text("body"),
|
||||||
firedAt: timestamp("fired_at", { withTimezone: true }),
|
firedAt: timestamp("fired_at", { withTimezone: true }),
|
||||||
createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }),
|
createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
|||||||
@@ -1,18 +1,33 @@
|
|||||||
export type ThemeMode = "light" | "dark" | "system";
|
export type ThemeMode = "light" | "dark" | "system";
|
||||||
|
|
||||||
export type Palette = "clay" | "indigo" | "sage" | "plum" | "ink";
|
export type Palette =
|
||||||
|
| "clay"
|
||||||
|
| "indigo"
|
||||||
|
| "sage"
|
||||||
|
| "plum"
|
||||||
|
| "ink"
|
||||||
|
| "rose"
|
||||||
|
| "amber"
|
||||||
|
| "ocean"
|
||||||
|
| "slate"
|
||||||
|
| "forest";
|
||||||
export type FontPair = "serif-sans" | "newsreader" | "fraunces" | "sans-only";
|
export type FontPair = "serif-sans" | "newsreader" | "fraunces" | "sans-only";
|
||||||
export type Density = "compact" | "regular" | "comfy";
|
export type Density = "compact" | "regular" | "comfy";
|
||||||
export type DashLayout = "classic" | "split" | "glance";
|
export type DashLayout = "classic" | "split" | "glance";
|
||||||
export type CalView = "month" | "week" | "day";
|
export type CalView = "month" | "week" | "day";
|
||||||
export type NavStyle = "rail-desktop" | "compact-rail" | "top-nav" | "fab-only";
|
export type NavStyle = "rail-desktop" | "compact-rail" | "top-nav" | "fab-only";
|
||||||
|
|
||||||
export const PALETTES: ReadonlyArray<{ id: Palette; label: string; hex: string }> = [
|
export const PALETTES: ReadonlyArray<{ id: Palette; label: string; hex: string; paper: string }> = [
|
||||||
{ id: "clay", label: "Clay", hex: "#B85C3C" },
|
{ id: "clay", label: "Clay", hex: "#B85C3C", paper: "#fbf9f4" },
|
||||||
{ id: "indigo", label: "Indigo ink", hex: "#3E5B8A" },
|
{ id: "indigo", label: "Indigo", hex: "#3E5B8A", paper: "#f4f7fb" },
|
||||||
{ id: "sage", label: "Sage", hex: "#6F8B5E" },
|
{ id: "sage", label: "Sage", hex: "#6F8B5E", paper: "#f5f8f2" },
|
||||||
{ id: "plum", label: "Plum", hex: "#7B4F6E" },
|
{ id: "plum", label: "Plum", hex: "#7B4F6E", paper: "#faf5fb" },
|
||||||
{ id: "ink", label: "Ink (mono)", hex: "#1F1B16" },
|
{ id: "ink", label: "Ink", hex: "#1F1B16", paper: "#f8f7f5" },
|
||||||
|
{ id: "rose", label: "Rose", hex: "#B5485E", paper: "#fdf5f7" },
|
||||||
|
{ id: "amber", label: "Amber", hex: "#B07D1E", paper: "#fdf9f0" },
|
||||||
|
{ id: "ocean", label: "Ocean", hex: "#2E7A8A", paper: "#f2f9fb" },
|
||||||
|
{ id: "slate", label: "Slate", hex: "#4A657A", paper: "#f4f6f9" },
|
||||||
|
{ id: "forest", label: "Forest", hex: "#3A6645", paper: "#f2f7f3" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const FONT_PAIRS: ReadonlyArray<{ id: FontPair; label: string }> = [
|
export const FONT_PAIRS: ReadonlyArray<{ id: FontPair; label: string }> = [
|
||||||
@@ -83,14 +98,13 @@ export const DEFAULT_THEME: ThemeState = {
|
|||||||
|
|
||||||
// Map our nav style preference to the data-nav attribute we set on <html>.
|
// Map our nav style preference to the data-nav attribute we set on <html>.
|
||||||
// On mobile (handled by NavModeProvider) we override to "bottom" or "fab".
|
// On mobile (handled by NavModeProvider) we override to "bottom" or "fab".
|
||||||
|
// fab-only has no desktop equivalent — falls back to sidebar so the nav doesn't disappear.
|
||||||
export function navStyleToDataNav(style: NavStyle): "sidebar" | "rail" | "top" | "fab" {
|
export function navStyleToDataNav(style: NavStyle): "sidebar" | "rail" | "top" | "fab" {
|
||||||
switch (style) {
|
switch (style) {
|
||||||
case "compact-rail":
|
case "compact-rail":
|
||||||
return "rail";
|
return "rail";
|
||||||
case "top-nav":
|
case "top-nav":
|
||||||
return "top";
|
return "top";
|
||||||
case "fab-only":
|
|
||||||
return "fab";
|
|
||||||
default:
|
default:
|
||||||
return "sidebar";
|
return "sidebar";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 & 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 OpenPlantBook 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
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://open.plantbook.io/api/v1";
|
||||||
|
|
||||||
|
type TokenCache = { access_token: string; expiresAt: number } | null;
|
||||||
|
let tokenCache: TokenCache = null;
|
||||||
|
|
||||||
|
async function getToken(): Promise<string | null> {
|
||||||
|
const clientId = process.env.OPENPLANTBOOK_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.OPENPLANTBOOK_CLIENT_SECRET;
|
||||||
|
if (!clientId || !clientSecret) return null;
|
||||||
|
|
||||||
|
if (tokenCache && Date.now() < tokenCache.expiresAt - 60_000) {
|
||||||
|
return tokenCache.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE_URL}/token/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "client_credentials",
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
}),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`token request failed: ${res.status}`);
|
||||||
|
const json = (await res.json()) as { access_token: string; expires_in: number };
|
||||||
|
tokenCache = {
|
||||||
|
access_token: json.access_token,
|
||||||
|
expiresAt: Date.now() + json.expires_in * 1000,
|
||||||
|
};
|
||||||
|
return tokenCache.access_token;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[species-lookup] getToken failed", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSunlight(value: unknown): string[] {
|
||||||
|
if (Array.isArray(value)) return value.map(String);
|
||||||
|
if (typeof value === "string" && value) return value.split(", ");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseItem(item: Record<string, unknown>): SpeciesSuggestion {
|
||||||
|
return {
|
||||||
|
id: String(item.pid ?? ""),
|
||||||
|
common_name: String(item.alias ?? ""),
|
||||||
|
scientific_name: String(item.display_pid ?? ""),
|
||||||
|
watering: String(item.watering ?? ""),
|
||||||
|
sunlight: parseSunlight(item.sunlight),
|
||||||
|
cycle: "",
|
||||||
|
default_image_url: item.image_url ? String(item.image_url) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchSpecies(query: string): Promise<SpeciesSuggestion[]> {
|
||||||
|
if (!query.trim()) return [];
|
||||||
|
const token = await getToken();
|
||||||
|
if (!token) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${BASE_URL}/plant/search?alias=${encodeURIComponent(query)}&limit=20`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`OpenPlantBook search error: ${res.status} ${res.url}`);
|
||||||
|
|
||||||
|
const json = (await res.json()) as { results?: unknown[] };
|
||||||
|
if (!Array.isArray(json.results)) return [];
|
||||||
|
|
||||||
|
const pids = json.results
|
||||||
|
.map((r) => String((r as Record<string, unknown>).pid ?? ""))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const details = await Promise.allSettled(pids.map((pid) => getSpeciesById(pid)));
|
||||||
|
return details
|
||||||
|
.filter(
|
||||||
|
(r): r is PromiseFulfilledResult<SpeciesSuggestion> =>
|
||||||
|
r.status === "fulfilled" && r.value !== null,
|
||||||
|
)
|
||||||
|
.map((r) => r.value);
|
||||||
|
} 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 token = await getToken();
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${BASE_URL}/plant/detail/${encodeURIComponent(id)}?include=care`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,10 @@ import coreManifest from "./_core/manifest";
|
|||||||
import calendarManifest from "./calendar/manifest";
|
import calendarManifest from "./calendar/manifest";
|
||||||
import listsManifest from "./lists/manifest";
|
import listsManifest from "./lists/manifest";
|
||||||
import notesManifest from "./notes/manifest";
|
import notesManifest from "./notes/manifest";
|
||||||
|
import gardenManifest from "./garden/manifest";
|
||||||
|
|
||||||
registerModule(coreManifest);
|
registerModule(coreManifest);
|
||||||
registerModule(calendarManifest);
|
registerModule(calendarManifest);
|
||||||
registerModule(listsManifest);
|
registerModule(listsManifest);
|
||||||
registerModule(notesManifest);
|
registerModule(notesManifest);
|
||||||
|
registerModule(gardenManifest);
|
||||||
|
|||||||
@@ -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";
|
import { households, users } from "../_core/schema";
|
||||||
|
|
||||||
export const lists = pgTable(
|
export const lists = pgTable(
|
||||||
@@ -33,6 +42,7 @@ export const listItems = pgTable(
|
|||||||
dueAt: timestamp("due_at", { withTimezone: true }),
|
dueAt: timestamp("due_at", { withTimezone: true }),
|
||||||
assigneeId: uuid("assignee_id").references(() => users.id, { onDelete: "set null" }),
|
assigneeId: uuid("assignee_id").references(() => users.id, { onDelete: "set null" }),
|
||||||
position: integer("position").notNull().default(0),
|
position: integer("position").notNull().default(0),
|
||||||
|
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { z } from "zod";
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { getCurrentSession } from "@/lib/session";
|
import { getCurrentSession } from "@/lib/session";
|
||||||
import { logActivity } from "@/modules/_core/activity";
|
import { logActivity } from "@/modules/_core/activity";
|
||||||
|
import { fireItemToggleHooks } from "@/modules/_core/registry";
|
||||||
import { listItems, lists } from "../schema";
|
import { listItems, lists } from "../schema";
|
||||||
import { getOrCreateDefaultList } from "./defaults";
|
import { getOrCreateDefaultList } from "./defaults";
|
||||||
import { canAccessList, getList } from "./queries";
|
import { canAccessList, getList } from "./queries";
|
||||||
@@ -23,6 +24,7 @@ const itemInput = z.object({
|
|||||||
notes: z.string().trim().max(2000).nullable().optional(),
|
notes: z.string().trim().max(2000).nullable().optional(),
|
||||||
dueAt: z.coerce.date().nullable().optional(),
|
dueAt: z.coerce.date().nullable().optional(),
|
||||||
assigneeId: z.string().uuid().nullable().optional(),
|
assigneeId: z.string().uuid().nullable().optional(),
|
||||||
|
metadata: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateItemInput = z.object({
|
const updateItemInput = z.object({
|
||||||
@@ -103,6 +105,7 @@ export async function addItem(input: z.input<typeof itemInput>) {
|
|||||||
notes: parsed.notes || null,
|
notes: parsed.notes || null,
|
||||||
dueAt: parsed.dueAt ?? null,
|
dueAt: parsed.dueAt ?? null,
|
||||||
assigneeId: parsed.assigneeId ?? null,
|
assigneeId: parsed.assigneeId ?? null,
|
||||||
|
metadata: parsed.metadata ?? null,
|
||||||
position: (positionRow?.maxPosition ?? -1) + 1,
|
position: (positionRow?.maxPosition ?? -1) + 1,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -133,7 +136,7 @@ export async function addItemToDefaultList(input: { type: string; text: string }
|
|||||||
|
|
||||||
export async function toggleItem(input: { id: string; done?: boolean }) {
|
export async function toggleItem(input: { id: string; done?: boolean }) {
|
||||||
const parsed = z.object({ id: z.string().uuid(), done: z.boolean().optional() }).parse(input);
|
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 existing = await getAuthorizedItem(parsed.id, household.id);
|
||||||
const done = parsed.done ?? !existing.done;
|
const done = parsed.done ?? !existing.done;
|
||||||
|
|
||||||
@@ -148,6 +151,16 @@ export async function toggleItem(input: { id: string; done?: boolean }) {
|
|||||||
action: "toggle",
|
action: "toggle",
|
||||||
payload: { done, text: existing.text },
|
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}`);
|
revalidatePath(`/lists/${existing.listId}`);
|
||||||
await notifyListChanged(existing.listId);
|
await notifyListChanged(existing.listId);
|
||||||
return getList(existing.listId);
|
return getList(existing.listId);
|
||||||
@@ -229,6 +242,7 @@ async function getAuthorizedItem(itemId: string, householdId: string) {
|
|||||||
listId: listItems.listId,
|
listId: listItems.listId,
|
||||||
done: listItems.done,
|
done: listItems.done,
|
||||||
text: listItems.text,
|
text: listItems.text,
|
||||||
|
metadata: listItems.metadata,
|
||||||
})
|
})
|
||||||
.from(listItems)
|
.from(listItems)
|
||||||
.innerJoin(lists, eq(listItems.listId, lists.id))
|
.innerJoin(lists, eq(listItems.listId, lists.id))
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export {};
|
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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());
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user