Compare commits
30 Commits
5f4c482636
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d7b119c1a | |||
| 862f5627c7 | |||
| 083e2992c9 | |||
| 2ad9ac53f8 | |||
| f351ed3846 | |||
|
|
a50f7eaa49 | ||
|
|
fe24cd73be | ||
|
|
5319e8af2f | ||
|
|
cc212c87c7 | ||
|
|
e65d28524b | ||
|
|
25b1ba9aeb | ||
| 8f77b7c453 | |||
|
|
dd7af9e8d4 | ||
|
|
6b75e47ee9 | ||
|
|
d3af826e3a | ||
|
|
70df86ba59 | ||
|
|
67bc23c64e | ||
|
|
3b35e835c8 | ||
|
|
61dc8acacf | ||
|
|
c449b0c2ae | ||
|
|
e501679416 | ||
|
|
608a192877 | ||
|
|
62a0089ec2 | ||
|
|
0d52ea5ad1 | ||
|
|
f483630419 | ||
|
|
8ec43f3561 | ||
| ddc0a60a0c | |||
| 4aacffdf4e | |||
| b8af0f986b | |||
| 80324afea2 |
2
.npmrc
2
.npmrc
@@ -1 +1 @@
|
|||||||
@otm:registry=http://192.168.0.5:3022/api/packages/tonym/npm/
|
@otm:registry=https://git.poweredbyotm.com/api/packages/tonym/npm/
|
||||||
|
|||||||
73
CLAUDE.md
73
CLAUDE.md
@@ -9,17 +9,78 @@ user-facing overview.
|
|||||||
|
|
||||||
- **Garden** — Plant registry + care/harvest logs, plant **groups**, reusable
|
- **Garden** — Plant registry + care/harvest logs, plant **groups**, reusable
|
||||||
**PlantType** records, Wikipedia photos. Plants sit on the shared location tree.
|
**PlantType** records, Wikipedia photos. Plants sit on the shared location tree.
|
||||||
- **Inventory** — durable **Item**s that nest (`parentItemId`), with **Label**s
|
Harvest logs carry structured `amount`+`unit` (legacy freeform `quantity` kept
|
||||||
(M:N via `LabelOnItem`), `ItemAttachment`, and `ItemLog`. Shares the Location spine.
|
for old rows — don't regress); a harvest can simultaneously create a pantry
|
||||||
|
item (`addPlantLog`'s `pantry` input → GARDEN-source consumable). PlantTypes
|
||||||
|
can link a UW–Madison Extension article (`uwExtensionUrl/Title`), searched via
|
||||||
|
`/api/v1/plant-types/uw-search` (proxy to hort.extension.wisc.edu's WP REST
|
||||||
|
API; helper `src/lib/garden/uw-extension.ts`).
|
||||||
|
- **Inventory** — durable **Item**s that nest (`parentItemId`: a toolbox is an
|
||||||
|
Item that holds Items, with a cycle guard), with **Label**s (M:N via
|
||||||
|
`LabelOnItem`), `ItemAttachment` (PHOTO/MANUAL/RECEIPT/WARRANTY), and `ItemLog`.
|
||||||
|
Items carry value/purchase/warranty (incl. `lifetimeWarranty`, `insured`),
|
||||||
|
`serial`/`modelNumber`/`brand`, a **sold** lifecycle, and freeform `customFields`
|
||||||
|
(JSON, e.g. VIN). Maintenance = a `Task` linked via `Task.itemId`. **HomeBox CSV
|
||||||
|
import** at `/api/v1/items/import` (pure parser `src/lib/inventory/homebox-import.ts`).
|
||||||
|
`kind` is DURABLE|CONSUMABLE. Shares the Location spine.
|
||||||
|
- **Pantry** (Kitchen → Pantry & Freezer) — CONSUMABLE Items with categories,
|
||||||
|
price paid, tags (shared `Label` table), and expiry. Pulls go through
|
||||||
|
`/api/v1/pantry/[id]/use` or `/bulk-use` and write `ItemLog` type `USED` with a
|
||||||
|
structured `amount` (consumption is queryable — don't regress to NOTE strings).
|
||||||
|
Service `src/lib/services/pantry.ts`; pure logic + tests in `src/lib/pantry/core.ts`.
|
||||||
|
Rides the `items:*` scopes (pantry items ARE Items).
|
||||||
- **Animals** — `Animal` + `AnimalLog` (pets/livestock); placed on the Location
|
- **Animals** — `Animal` + `AnimalLog` (pets/livestock); placed on the Location
|
||||||
tree, and `Task`s can link to an animal (`Task.animalId`).
|
tree, and `Task`s can link to an animal (`Task.animalId`).
|
||||||
- **Locations** — `Location` tree (self-ref `parentId`) shared by plants, items, animals.
|
- **Locations** — `Location` tree (self-ref `parentId`) shared by plants, items, animals.
|
||||||
- **Reminders** — `Task` + `TaskCompletion`; recurrence in `src/lib/tasks/schedule.ts`.
|
- **Reminders** — `Task` + `TaskCompletion`; recurrence in `src/lib/tasks/schedule.ts`
|
||||||
|
(incl. `occurrencesInRange` projection). **/calendar** renders a month view of
|
||||||
|
all tasks (garden + house) plus PlantType bloom/harvest windows
|
||||||
|
(`src/lib/garden/season.ts`) and garden-brain suggestions.
|
||||||
|
- **Garden brain** — `src/lib/garden/knowledge.ts`: hand-curated zone-4b
|
||||||
|
(~May 15 frost) care timing, companions/foes, rotation families for ~28
|
||||||
|
crops; pure matchers `knowledgeFor`/`tipsForMonth` (whole-word stem match —
|
||||||
|
beware "Pear"/"Pea"-style false hits, tested). Surfaced as calendar
|
||||||
|
"Good to do this month" (one-click YEARLY task via /api/v1/tasks) and a
|
||||||
|
"Growing in zone 4b" card on type pages. Tips cite UW Extension via site
|
||||||
|
search URLs — never fabricate article URLs.
|
||||||
- **REST API** — `/api/v1/*` authed by `ApiToken` (per-scope; logic in `src/lib/api/`).
|
- **REST API** — `/api/v1/*` authed by `ApiToken` (per-scope; logic in `src/lib/api/`).
|
||||||
- **Suggestions** — `@otm/account-panel` (Gitea npm registry) → files issues to the repo.
|
- **Suggestions** — `@otm/account-panel` (Gitea npm registry) → files issues to the repo.
|
||||||
- **Backup & Restore** — `/api/admin/backup` + `/api/admin/restore` (admin only).
|
- **Backup & Restore** — `/api/admin/backup` + `/api/admin/restore` (admin only).
|
||||||
- **Self-update** — admin "Pull & rebuild" (see Deploy).
|
- **Self-update** — admin "Pull & rebuild" (see Deploy).
|
||||||
|
|
||||||
|
## Data model (the one rule)
|
||||||
|
|
||||||
|
**Two nesting worlds, crossed once.** *Places* (`Location`, self-ref `parentId`,
|
||||||
|
unlimited depth — `LocationKind` is an icon hint, never a constraint) nest in
|
||||||
|
places. *Items* (things you own) nest in items, and an Item lives in a Place **or**
|
||||||
|
inside another Item. You never put a Place inside an Item. **Plants** and
|
||||||
|
**Animals** are leaves in a Place (own logs, don't nest). HomeBox + Grocy + farmOS
|
||||||
|
collapse into: **Places · Plants/Animals (leaves) · Items (durable + consumable,
|
||||||
|
nesting)**. Logs are per-domain tables (`PlantLog`/`ItemLog`/`AnimalLog`), NOT a
|
||||||
|
polymorphic table; `Task` carries nullable `plantId`/`itemId`/`animalId`.
|
||||||
|
|
||||||
|
## Architecture conventions
|
||||||
|
|
||||||
|
- **API-first.** New modules (Inventory, Animals) are built on `/api/v1/*` and the
|
||||||
|
UI calls those same endpoints — no separate UI routes. Garden/Tasks predate this
|
||||||
|
and still use `/api/plants` etc.
|
||||||
|
- **Service layer** `src/lib/services/*` does DB work + AuditEvent, taking
|
||||||
|
`(ctx: AuthContext, input)`. Routes are thin: `authenticateRequest(req, scope)` →
|
||||||
|
service → `handleApiError`. The MCP server (unbuilt) will reuse these services.
|
||||||
|
- **Auth helper** `src/lib/api/authenticate.ts` accepts EITHER a NextAuth cookie
|
||||||
|
session (full scope `["*"]`) OR a Bearer PAT (limited to its `scopes`; write
|
||||||
|
implies read). Scope constants in `src/lib/api/scopes.ts` (client-safe).
|
||||||
|
- **Decimals** for money/quantity (`@db.Decimal`) — convert to `Number()` before
|
||||||
|
passing to client components (Prisma Decimal isn't serializable across the boundary).
|
||||||
|
|
||||||
|
## Blocked on a domain + HTTPS (deferred)
|
||||||
|
|
||||||
|
Camera scanning/photo-capture (`getUserMedia` needs a secure context), the **PWA**
|
||||||
|
(service worker + install need HTTPS), and the **MCP server** (Claude needs a public
|
||||||
|
HTTPS URL) all wait until there's a domain + reverse-proxy/tunnel. The app is
|
||||||
|
LAN-only over `http://NAS:8053` today. Pantry/consumables and other pure web work
|
||||||
|
can ship before then.
|
||||||
|
|
||||||
## Local dev
|
## Local dev
|
||||||
|
|
||||||
- DB: `postgresql://tonym@localhost:5432/moonbase_dev`; copy `.env.example` → `.env`.
|
- DB: `postgresql://tonym@localhost:5432/moonbase_dev`; copy `.env.example` → `.env`.
|
||||||
@@ -31,6 +92,9 @@ user-facing overview.
|
|||||||
|
|
||||||
## Deploy — git checkout on the NAS (NOT rsync)
|
## Deploy — git checkout on the NAS (NOT rsync)
|
||||||
|
|
||||||
|
> Working without this file (e.g. a Claude on another machine)? The self-contained
|
||||||
|
> version is `docs/git-deploy-guide.md`.
|
||||||
|
|
||||||
The NAS appdata (`/mnt/user/appdata/moonbase`) is a real **git checkout** tracking
|
The NAS appdata (`/mnt/user/appdata/moonbase`) is a real **git checkout** tracking
|
||||||
`origin/master` (remote URL carries the token). Deploy by:
|
`origin/master` (remote URL carries the token). Deploy by:
|
||||||
|
|
||||||
@@ -57,6 +121,9 @@ reloads. **Never rsync over the appdata dir** — it fights the checkout.
|
|||||||
- **Every commit bumps APP_VERSION** in `src/lib/version.ts` + a plain-English
|
- **Every commit bumps APP_VERSION** in `src/lib/version.ts` + a plain-English
|
||||||
entry atop `src/lib/changelog.ts` (the footer shows it + an "update available"
|
entry atop `src/lib/changelog.ts` (the footer shows it + an "update available"
|
||||||
badge from `src/lib/update-check.ts`). Minor for features/migrations, patch for fixes.
|
badge from `src/lib/update-check.ts`). Minor for features/migrations, patch for fixes.
|
||||||
|
**Fetch first**: parallel sessions push to master — check
|
||||||
|
`git show origin/master:src/lib/version.ts` before picking a number
|
||||||
|
(a v0.22.0 collision has already happened once).
|
||||||
- **AuditEvent on every mutation** — append-only.
|
- **AuditEvent on every mutation** — append-only.
|
||||||
- **Soft-delete** via `active=false`.
|
- **Soft-delete** via `active=false`.
|
||||||
- **Pure core + colocated test** — logic in `src/lib/...` with a `.test.ts`; routes thin.
|
- **Pure core + colocated test** — logic in `src/lib/...` with a `.test.ts`; routes thin.
|
||||||
|
|||||||
11
Dockerfile
11
Dockerfile
@@ -10,6 +10,17 @@ COPY package.json package-lock.json* ./
|
|||||||
COPY prisma ./prisma
|
COPY prisma ./prisma
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
|
# Workspace-style freshness for the shared OTM UI package: every image build
|
||||||
|
# installs the latest published @otm/account-panel on top of the lockfile, so
|
||||||
|
# rebuilds pick up package releases without needing a pin-bump commit (the
|
||||||
|
# package.json pin is the dev-time floor). The ADD pulls the registry
|
||||||
|
# packument, which only changes on a publish — so this layer stays cached
|
||||||
|
# between publishes and busts exactly when a new version exists. Moonbase
|
||||||
|
# builds on the NAS itself, so the LAN host is always reachable.
|
||||||
|
COPY .npmrc ./
|
||||||
|
ADD http://192.168.0.5:3022/api/packages/tonym/npm/@otm/account-panel /tmp/otm-account-panel.packument.json
|
||||||
|
RUN npm install --no-save --no-audit --no-fund @otm/account-panel@latest
|
||||||
|
|
||||||
# ---- build ----
|
# ---- build ----
|
||||||
FROM node:20-bookworm-slim AS build
|
FROM node:20-bookworm-slim AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -12,13 +12,18 @@ Live at **http://192.168.0.5:8053**.
|
|||||||
reusable **plant types** (the "kind" record: care notes, sun/water, bloom &
|
reusable **plant types** (the "kind" record: care notes, sun/water, bloom &
|
||||||
harvest months, pet toxicity), Wikipedia photos, and plants placed on the shared
|
harvest months, pet toxicity), Wikipedia photos, and plants placed on the shared
|
||||||
location tree.
|
location tree.
|
||||||
- **Inventory** — durable **items** that nest (boxes within rooms within
|
- **Inventory** — durable **items** that nest (a toolbox holds the tools inside
|
||||||
buildings), with labels, attachments, and logs. Shares the same location spine
|
it), with labels, photos + manuals/receipts/warranty docs, value & warranty
|
||||||
as the garden.
|
tracking (with expiry warnings), serial/brand, a sold record, and custom fields
|
||||||
- **Animals** — pets & livestock with their own logs, placed on the shared
|
(e.g. a vehicle's VIN). Schedule recurring **maintenance** per item. **Import
|
||||||
location tree; reminders can be linked to an animal.
|
straight from HomeBox** via CSV. Shares the same location spine as the garden.
|
||||||
|
- **Animals** — pets & livestock with their own logs (feeding, vet, weight, meds)
|
||||||
|
and auto-computed age, placed on the shared location tree; reminders can be
|
||||||
|
linked to an animal.
|
||||||
|
- **Locations** — manage the whole place tree (areas, buildings, rooms, storage),
|
||||||
|
nested as deep as you like; everything (plants, items, animals) lives here.
|
||||||
- **Reminders** — one-off or recurring tasks (every N days / monthly / yearly),
|
- **Reminders** — one-off or recurring tasks (every N days / monthly / yearly),
|
||||||
optionally linked to a plant or animal.
|
optionally linked to a plant, item, or animal.
|
||||||
- **REST API** (`/api/v1/*`) — personal access **tokens** with per-scope
|
- **REST API** (`/api/v1/*`) — personal access **tokens** with per-scope
|
||||||
permissions (read plants, log harvests, …) for phone shortcuts, Home Assistant,
|
permissions (read plants, log harvests, …) for phone shortcuts, Home Assistant,
|
||||||
and automation.
|
and automation.
|
||||||
@@ -69,7 +74,7 @@ Two ways to ship a change:
|
|||||||
Do **not** rsync over the appdata dir — it would fight the checkout. Secrets
|
Do **not** rsync over the appdata dir — it would fight the checkout. Secrets
|
||||||
(`NEXTAUTH_SECRET`, `GITEA_TOKEN`, `UPDATER_SECRET`) live in a gitignored host
|
(`NEXTAUTH_SECRET`, `GITEA_TOKEN`, `UPDATER_SECRET`) live in a gitignored host
|
||||||
`.env` next to the compose file, never committed. Migrations apply automatically
|
`.env` next to the compose file, never committed. Migrations apply automatically
|
||||||
on container start.
|
on container start. Full deploy walkthrough: [`docs/git-deploy-guide.md`](docs/git-deploy-guide.md).
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
77
docs/git-deploy-guide.md
Normal file
77
docs/git-deploy-guide.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Editing & deploying Moon Base — a guide for any Claude instance
|
||||||
|
|
||||||
|
Read this if you're a Claude (or anyone) working on Moon Base **without** the full
|
||||||
|
project `CLAUDE.md` in context — e.g. running on a different machine. It's the
|
||||||
|
self-contained version of how to change the code and get it onto the live server.
|
||||||
|
|
||||||
|
## The repo
|
||||||
|
|
||||||
|
- Lives on the home NAS Gitea: `http://192.168.0.5:3022/bonna61/Moonbase`
|
||||||
|
- Clone: `git clone http://tonym:<TOKEN>@192.168.0.5:3022/bonna61/Moonbase.git`
|
||||||
|
(the API token lives in the workspace-level `~/Projects/CLAUDE.md` — **never commit it**)
|
||||||
|
- Default branch: **`master`**.
|
||||||
|
|
||||||
|
## The golden rule: the NAS appdata is a GIT CHECKOUT, not a sync target
|
||||||
|
|
||||||
|
The running app on the NAS lives at `/mnt/user/appdata/moonbase`, which is a real
|
||||||
|
`git clone` tracking `origin/master`. **You deploy by pushing to Gitea, then pulling
|
||||||
|
on the NAS — never by copying files.** Do **not** `rsync` over the appdata dir; it
|
||||||
|
fights the checkout and corrupts it.
|
||||||
|
|
||||||
|
## How to make a change
|
||||||
|
|
||||||
|
1. **Edit** code in your local working copy.
|
||||||
|
2. **Bump the version:** set `APP_VERSION` in `src/lib/version.ts` and add a
|
||||||
|
plain-English entry to the **top** of `src/lib/changelog.ts` — write it for
|
||||||
|
Bonna (the non-technical user): what changed and why. Minor (`0.X.0`) for
|
||||||
|
features/migrations, patch (`0.0.X`) for fixes. Docs-only changes don't need a bump.
|
||||||
|
3. **Schema change?** Create a migration: `npx prisma migrate dev --create-only
|
||||||
|
--name <slug>`, review the SQL, then `npx prisma migrate deploy` locally. The
|
||||||
|
server applies pending migrations automatically on container start.
|
||||||
|
4. **Verify locally:** `npx tsc --noEmit` and `npm run test`; run `npx next build`
|
||||||
|
for anything non-trivial (it catches client/server boundary errors `tsc` misses —
|
||||||
|
e.g. passing a Prisma `Decimal` to a client component).
|
||||||
|
5. **Commit.** If you're Claude, end the message with the `Co-Authored-By:` trailer.
|
||||||
|
|
||||||
|
## How to deploy (two ways)
|
||||||
|
|
||||||
|
**Preferred — in-app, no SSH:** push, then an admin clicks **Settings → Updates →
|
||||||
|
Pull & rebuild** in the live app. A privileged `updater` sidecar runs
|
||||||
|
`git pull && docker compose up -d --build app` detached and the UI reloads.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git push origin master
|
||||||
|
# then click "Pull & rebuild" in the app
|
||||||
|
```
|
||||||
|
|
||||||
|
**Manual — needs SSH to the NAS:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git push origin master
|
||||||
|
ssh root@192.168.0.5 'cd /mnt/user/appdata/moonbase && git pull && docker compose up -d --build'
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm success: the footer version chip (or `GET /api/version`) should show the
|
||||||
|
version you just shipped.
|
||||||
|
|
||||||
|
## Secrets — never commit them
|
||||||
|
|
||||||
|
`NEXTAUTH_SECRET`, `GITEA_TOKEN`, `UPDATER_SECRET` live in a **gitignored host
|
||||||
|
`.env`** next to the compose file on the NAS, interpolated into compose as `${VAR}`.
|
||||||
|
They are not in the repo. Don't add them.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Don't let `npx prisma` resolve at runtime.** With no local prisma on PATH it
|
||||||
|
pulls Prisma 7, which rejects `url` in the datasource. The Docker image ships the
|
||||||
|
full `node_modules` so the pinned prisma stays on PATH.
|
||||||
|
- **Migrations run on container start** (entrypoint waits for Postgres first). A bad
|
||||||
|
migration blocks startup — always test `migrate deploy` locally first.
|
||||||
|
- **First deploy of a big migration:** take a backup first (Settings → Backup &
|
||||||
|
Restore → Download) — the data is live (Bonna's garden).
|
||||||
|
- **Two people may be committing.** If you didn't write a file, don't sweep it into
|
||||||
|
your commit — stage only your own paths (`git add <paths>`) and check `git status`
|
||||||
|
before committing.
|
||||||
|
|
||||||
|
That's the whole story: **edit locally → bump version → commit → push → pull/rebuild
|
||||||
|
on the NAS. Never rsync.**
|
||||||
62
package-lock.json
generated
62
package-lock.json
generated
@@ -9,13 +9,14 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@otm/account-panel": "0.8.2",
|
"@otm/account-panel": "0.22.1",
|
||||||
"@paralleldrive/cuid2": "^3.3.0",
|
"@paralleldrive/cuid2": "^3.3.0",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"@radix-ui/react-avatar": "^1.1.1",
|
"@radix-ui/react-avatar": "^1.1.1",
|
||||||
"@radix-ui/react-dialog": "^1.1.2",
|
"@radix-ui/react-dialog": "^1.1.2",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
|
"@radix-ui/react-popover": "^1.1.17",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
@@ -1025,13 +1026,27 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@otm/account-panel": {
|
"node_modules/@otm/account-panel": {
|
||||||
"version": "0.8.2",
|
"version": "0.22.1",
|
||||||
"resolved": "http://192.168.0.5:3022/api/packages/tonym/npm/%40otm%2Faccount-panel/-/0.8.2/account-panel-0.8.2.tgz",
|
"resolved": "https://git.poweredbyotm.com/api/packages/tonym/npm/%40otm%2Faccount-panel/-/0.22.1/account-panel-0.22.1.tgz",
|
||||||
"integrity": "sha512-E7uJ3Xxm8/xz6gcUAS4TUI+eCjaVZLFs7CjF7RpwSLUn3DpIszGSE4BN0519BQOThpQ69p3m15h/b64HLMgHgw==",
|
"integrity": "sha512-JwPVqe5FPENt6nlwlZ91PX58Y2QvFINHLi385zH/f4tO5PvifDYVVqwmzF0TrQP9NaUwOiHJV2PgRdotlN3c7A==",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
"@stripe/react-stripe-js": ">=2",
|
||||||
|
"@stripe/stripe-js": ">=3",
|
||||||
"next": ">=14",
|
"next": ">=14",
|
||||||
"next-auth": ">=4",
|
"next-auth": ">=4",
|
||||||
"react": ">=18"
|
"react": ">=18",
|
||||||
|
"stripe": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@stripe/react-stripe-js": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@stripe/stripe-js": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"stripe": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
@@ -1492,6 +1507,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-popover": {
|
||||||
|
"version": "1.1.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.17.tgz",
|
||||||
|
"integrity": "sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.4",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.3",
|
||||||
|
"@radix-ui/react-context": "1.1.4",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.13",
|
||||||
|
"@radix-ui/react-focus-guards": "1.1.4",
|
||||||
|
"@radix-ui/react-focus-scope": "1.1.10",
|
||||||
|
"@radix-ui/react-id": "1.1.2",
|
||||||
|
"@radix-ui/react-popper": "1.3.1",
|
||||||
|
"@radix-ui/react-portal": "1.1.12",
|
||||||
|
"@radix-ui/react-presence": "1.1.6",
|
||||||
|
"@radix-ui/react-primitive": "2.1.6",
|
||||||
|
"@radix-ui/react-slot": "1.3.0",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.3",
|
||||||
|
"aria-hidden": "^1.2.4",
|
||||||
|
"react-remove-scroll": "^2.7.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-popper": {
|
"node_modules/@radix-ui/react-popper": {
|
||||||
"version": "1.3.1",
|
"version": "1.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz",
|
||||||
|
|||||||
@@ -23,13 +23,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@otm/account-panel": "0.8.2",
|
"@otm/account-panel": "0.22.1",
|
||||||
"@paralleldrive/cuid2": "^3.3.0",
|
"@paralleldrive/cuid2": "^3.3.0",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"@radix-ui/react-avatar": "^1.1.1",
|
"@radix-ui/react-avatar": "^1.1.1",
|
||||||
"@radix-ui/react-dialog": "^1.1.2",
|
"@radix-ui/react-dialog": "^1.1.2",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
|
"@radix-ui/react-popover": "^1.1.17",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Task" ADD COLUMN "locationId" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Task_locationId_idx" ON "Task"("locationId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Task" ADD CONSTRAINT "Task_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
15
prisma/migrations/20260629000001_v0_19_pantry/migration.sql
Normal file
15
prisma/migrations/20260629000001_v0_19_pantry/migration.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- v0.19 Pantry: add storedAt, source enum, and plantId to Item
|
||||||
|
|
||||||
|
CREATE TYPE "ItemSource" AS ENUM ('GARDEN', 'HOMEMADE', 'STORE', 'FARMERS_MARKET', 'GIFTED', 'OTHER');
|
||||||
|
|
||||||
|
ALTER TABLE "Item"
|
||||||
|
ADD COLUMN "storedAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "source" "ItemSource",
|
||||||
|
ADD COLUMN "plantId" TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE "Item"
|
||||||
|
ADD CONSTRAINT "Item_plantId_fkey"
|
||||||
|
FOREIGN KEY ("plantId") REFERENCES "Plant"("id")
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX "Item_plantId_idx" ON "Item"("plantId");
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- v0.20: pantry categories + price tracking
|
||||||
|
|
||||||
|
ALTER TABLE "Item"
|
||||||
|
ADD COLUMN "pantryCategory" TEXT,
|
||||||
|
ADD COLUMN "pantrySubcategory" TEXT,
|
||||||
|
ADD COLUMN "pricePaid" DECIMAL(10,2),
|
||||||
|
ADD COLUMN "priceUnit" TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX "Item_pantryCategory_idx" ON "Item"("pantryCategory");
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "ItemLogType" ADD VALUE 'USED';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ItemLog" ADD COLUMN "amount" DECIMAL(10,2);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PlantLog" ADD COLUMN "amount" DECIMAL(10,2),
|
||||||
|
ADD COLUMN "unit" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PlantType" ADD COLUMN "uwExtensionTitle" TEXT,
|
||||||
|
ADD COLUMN "uwExtensionUrl" TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "PlantCategory" ADD VALUE 'ROOT_VEGETABLE';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ItemLog" ADD COLUMN "reason" TEXT;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Ramble" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"pinned" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"archivedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Ramble_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Ramble_pinned_archivedAt_createdAt_idx" ON "Ramble"("pinned", "archivedAt", "createdAt");
|
||||||
@@ -72,6 +72,20 @@ model AuditEvent {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ramble box — sticky-note brain dump for stray homestead thoughts ("move the
|
||||||
|
// rhubarb", "oil the chainsaw"). Not a Task and not a Location leaf; just a
|
||||||
|
// free-text jot you pin, archive (soft), or delete (hard). Ported from FMB.
|
||||||
|
model Ramble {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
body String
|
||||||
|
pinned Boolean @default(false)
|
||||||
|
archivedAt DateTime? // soft-archive; null = active
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([pinned, archivedAt, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Location — shared hierarchical place tree (garden areas, buildings, rooms,
|
// Location — shared hierarchical place tree (garden areas, buildings, rooms,
|
||||||
// storage bins). The spine for Garden, Inventory, and Pantry. Plants/items/
|
// storage bins). The spine for Garden, Inventory, and Pantry. Plants/items/
|
||||||
@@ -102,6 +116,7 @@ model Location {
|
|||||||
plants Plant[]
|
plants Plant[]
|
||||||
items Item[]
|
items Item[]
|
||||||
animals Animal[]
|
animals Animal[]
|
||||||
|
tasks Task[]
|
||||||
|
|
||||||
@@index([parentId])
|
@@index([parentId])
|
||||||
@@index([kind])
|
@@index([kind])
|
||||||
@@ -121,6 +136,7 @@ enum PlantCategory {
|
|||||||
GROUNDCOVER // strawberries, clover, creeping thyme
|
GROUNDCOVER // strawberries, clover, creeping thyme
|
||||||
VINE // grapes, hops, kiwi
|
VINE // grapes, hops, kiwi
|
||||||
ANNUAL_VEGETABLE // tomatoes, squash, beans, peppers — seasonal veg
|
ANNUAL_VEGETABLE // tomatoes, squash, beans, peppers — seasonal veg
|
||||||
|
ROOT_VEGETABLE // carrots, beets, sweet potatoes, radishes
|
||||||
ANNUAL // legacy, treated as annual vegetable
|
ANNUAL // legacy, treated as annual vegetable
|
||||||
ANNUAL_FLOWER // zinnias, marigolds, nasturtiums, cosmos
|
ANNUAL_FLOWER // zinnias, marigolds, nasturtiums, cosmos
|
||||||
PERENNIAL // asparagus, rhubarb, perennial veg
|
PERENNIAL // asparagus, rhubarb, perennial veg
|
||||||
@@ -164,6 +180,7 @@ model Plant {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
logs PlantLog[]
|
logs PlantLog[]
|
||||||
tasks Task[]
|
tasks Task[]
|
||||||
|
pantryItems Item[]
|
||||||
|
|
||||||
@@index([category])
|
@@index([category])
|
||||||
@@index([zone])
|
@@index([zone])
|
||||||
@@ -193,6 +210,8 @@ model PlantType {
|
|||||||
edibleParts String? // "Fruit, young leaves"
|
edibleParts String? // "Fruit, young leaves"
|
||||||
spacing String? // "10–15 ft", "18 in"
|
spacing String? // "10–15 ft", "18 in"
|
||||||
imageUrl String?
|
imageUrl String?
|
||||||
|
uwExtensionUrl String? // linked UW–Madison Extension article (hort.extension.wisc.edu)
|
||||||
|
uwExtensionTitle String? // its title, shown as the link text
|
||||||
notes String?
|
notes String?
|
||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -242,6 +261,8 @@ model Task {
|
|||||||
item Item? @relation(fields: [itemId], references: [id], onDelete: SetNull)
|
item Item? @relation(fields: [itemId], references: [id], onDelete: SetNull)
|
||||||
animalId String?
|
animalId String?
|
||||||
animal Animal? @relation(fields: [animalId], references: [id], onDelete: SetNull)
|
animal Animal? @relation(fields: [animalId], references: [id], onDelete: SetNull)
|
||||||
|
locationId String?
|
||||||
|
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||||||
recurrence RecurrenceType @default(YEARLY)
|
recurrence RecurrenceType @default(YEARLY)
|
||||||
intervalDays Int? // for CUSTOM: every N days
|
intervalDays Int? // for CUSTOM: every N days
|
||||||
month Int? // 1–12: for YEARLY, which month
|
month Int? // 1–12: for YEARLY, which month
|
||||||
@@ -256,6 +277,7 @@ model Task {
|
|||||||
@@index([plantId])
|
@@index([plantId])
|
||||||
@@index([itemId])
|
@@index([itemId])
|
||||||
@@index([animalId])
|
@@index([animalId])
|
||||||
|
@@index([locationId])
|
||||||
@@index([active])
|
@@index([active])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +299,9 @@ model PlantLog {
|
|||||||
type PlantLogType
|
type PlantLogType
|
||||||
date DateTime @default(now())
|
date DateTime @default(now())
|
||||||
notes String?
|
notes String?
|
||||||
quantity String? // freeform for harvests: "2 lbs", "1 gallon", "a big handful"
|
quantity String? // legacy freeform harvests ("a big handful") — new logs use amount+unit
|
||||||
|
amount Decimal? @db.Decimal(10, 2) // structured harvest quantity (queryable totals)
|
||||||
|
unit String? // "lbs", "each", "quarts" — pairs with amount
|
||||||
actorId String?
|
actorId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@ -295,7 +319,16 @@ model PlantLog {
|
|||||||
|
|
||||||
enum ItemKind {
|
enum ItemKind {
|
||||||
DURABLE // tools, gear, equipment — "where is it?"
|
DURABLE // tools, gear, equipment — "where is it?"
|
||||||
CONSUMABLE // food, feed, supplies — "do I have it?" (Pantry phase)
|
CONSUMABLE // food, feed, supplies — "do I have it?"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ItemSource {
|
||||||
|
GARDEN // grown/harvested here
|
||||||
|
HOMEMADE // made/preserved ourselves
|
||||||
|
STORE // grocery or big-box store
|
||||||
|
FARMERS_MARKET // local market
|
||||||
|
GIFTED // given by someone
|
||||||
|
OTHER
|
||||||
}
|
}
|
||||||
|
|
||||||
model Item {
|
model Item {
|
||||||
@@ -334,10 +367,18 @@ model Item {
|
|||||||
// Custom fields — flexible key/value (e.g. VIN, "Oil Weight": "5W-30")
|
// Custom fields — flexible key/value (e.g. VIN, "Oil Weight": "5W-30")
|
||||||
customFields Json?
|
customFields Json?
|
||||||
|
|
||||||
// Consumable attributes (Pantry phase)
|
// Consumable attributes
|
||||||
barcode String?
|
barcode String?
|
||||||
bestBefore DateTime?
|
storedAt DateTime? // date frozen / preserved / stored
|
||||||
minStock Decimal? @db.Decimal(10, 2)
|
bestBefore DateTime? // expiry / best-by date
|
||||||
|
minStock Decimal? @db.Decimal(10, 2)
|
||||||
|
source ItemSource? // where it came from
|
||||||
|
plantId String? // link to a garden plant (harvest source)
|
||||||
|
plant Plant? @relation(fields: [plantId], references: [id], onDelete: SetNull)
|
||||||
|
pantryCategory String? // e.g. "Meat", "Produce", "Dairy"
|
||||||
|
pantrySubcategory String? // e.g. "Beef", "Poultry", "Berries"
|
||||||
|
pricePaid Decimal? @db.Decimal(10, 2) // how much was paid
|
||||||
|
priceUnit String? // "package", "lb", "oz", "kg", etc.
|
||||||
|
|
||||||
// Shared
|
// Shared
|
||||||
qrSlug String? @unique // short slug for a printed QR label
|
qrSlug String? @unique // short slug for a printed QR label
|
||||||
@@ -358,6 +399,8 @@ model Item {
|
|||||||
@@index([active])
|
@@index([active])
|
||||||
@@index([name])
|
@@index([name])
|
||||||
@@index([barcode])
|
@@index([barcode])
|
||||||
|
@@index([plantId])
|
||||||
|
@@index([pantryCategory])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Label {
|
model Label {
|
||||||
@@ -396,6 +439,7 @@ enum ItemLogType {
|
|||||||
PURCHASED
|
PURCHASED
|
||||||
REPAIRED
|
REPAIRED
|
||||||
DISPOSED
|
DISPOSED
|
||||||
|
USED // consumable pulled from the pantry; `amount` holds how much
|
||||||
}
|
}
|
||||||
|
|
||||||
model ItemLog {
|
model ItemLog {
|
||||||
@@ -406,6 +450,8 @@ model ItemLog {
|
|||||||
date DateTime @default(now())
|
date DateTime @default(now())
|
||||||
notes String?
|
notes String?
|
||||||
cost Decimal? @db.Decimal(10, 2)
|
cost Decimal? @db.Decimal(10, 2)
|
||||||
|
amount Decimal? @db.Decimal(10, 2) // quantity consumed, for USED logs (same unit as the item)
|
||||||
|
reason String? // disposition for USED logs: consumed | preserved | gifted | waste (see pantry/core.ts)
|
||||||
actorId String?
|
actorId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
|||||||
14
scripts/reset-password.ts
Normal file
14
scripts/reset-password.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { hashSync } from "bcryptjs";
|
||||||
|
|
||||||
|
const db = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const hash = hashSync("moonbase!", 10);
|
||||||
|
const result = await db.user.updateMany({
|
||||||
|
data: { passwordHash: hash, mustChangePassword: false },
|
||||||
|
});
|
||||||
|
console.log(`Reset password for ${result.count} user(s). Login with: moonbase!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error).finally(() => db.$disconnect());
|
||||||
76
src/app/(dashboard)/calendar/page.tsx
Normal file
76
src/app/(dashboard)/calendar/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { CalendarView, type CalendarTask, type SeasonType } from "@/components/calendar/calendar-view";
|
||||||
|
|
||||||
|
export const metadata = { title: "Calendar" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function CalendarPage() {
|
||||||
|
const [tasks, types, plants] = await Promise.all([
|
||||||
|
db.task.findMany({
|
||||||
|
where: { active: true },
|
||||||
|
orderBy: { nextDue: "asc" },
|
||||||
|
include: {
|
||||||
|
plant: { select: { commonName: true, variety: true } },
|
||||||
|
animal: { select: { name: true } },
|
||||||
|
item: { select: { name: true } },
|
||||||
|
location: { select: { name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.plantType.findMany({
|
||||||
|
where: {
|
||||||
|
active: true,
|
||||||
|
plants: { some: { active: true } },
|
||||||
|
OR: [
|
||||||
|
{ bloomStart: { not: null } },
|
||||||
|
{ bloomEnd: { not: null } },
|
||||||
|
{ harvestStart: { not: null } },
|
||||||
|
{ harvestEnd: { not: null } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { commonName: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true, commonName: true,
|
||||||
|
bloomStart: true, bloomEnd: true, harvestStart: true, harvestEnd: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.plant.findMany({
|
||||||
|
where: { active: true },
|
||||||
|
select: { commonName: true, plantType: { select: { commonName: true } } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const calendarTasks: CalendarTask[] = tasks.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
category: t.category,
|
||||||
|
nextDue: t.nextDue.toISOString(),
|
||||||
|
recurrence: t.recurrence,
|
||||||
|
intervalDays: t.intervalDays,
|
||||||
|
month: t.month,
|
||||||
|
linkedTo:
|
||||||
|
(t.plant && `${t.plant.commonName}${t.plant.variety ? ` (${t.plant.variety})` : ""}`) ||
|
||||||
|
t.animal?.name ||
|
||||||
|
t.item?.name ||
|
||||||
|
t.location?.name ||
|
||||||
|
null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const seasonTypes: SeasonType[] = types;
|
||||||
|
|
||||||
|
// Names the garden brain matches against: plants + their linked types.
|
||||||
|
const plantNames = Array.from(
|
||||||
|
new Set(plants.flatMap((p) => [p.commonName, p.plantType?.commonName]).filter((n): n is string => !!n)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-4xl">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-2xl font-semibold">Calendar</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
Everything due around the homestead — garden and house — plus what's blooming and ready to pick.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<CalendarView tasks={calendarTasks} seasonTypes={seasonTypes} plantNames={plantNames} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { getSession } from "@/lib/auth";
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Leaf, CalendarDays, Sprout } from "lucide-react";
|
import { Leaf, Sprout, AlertTriangle, UtensilsCrossed } from "lucide-react";
|
||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
|
|
||||||
export const metadata = { title: "Dashboard" };
|
export const metadata = { title: "Dashboard" };
|
||||||
@@ -10,13 +10,21 @@ export const metadata = { title: "Dashboard" };
|
|||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
|
|
||||||
const [plantCount, recentLogs] = await Promise.all([
|
const thirtyDaysFromNow = new Date(Date.now() + 30 * 86_400_000);
|
||||||
|
|
||||||
|
const [plantCount, recentLogs, expiringItems] = await Promise.all([
|
||||||
db.plant.count({ where: { active: true } }),
|
db.plant.count({ where: { active: true } }),
|
||||||
db.plantLog.findMany({
|
db.plantLog.findMany({
|
||||||
orderBy: { date: "desc" },
|
orderBy: { date: "desc" },
|
||||||
take: 5,
|
take: 5,
|
||||||
include: { plant: { select: { commonName: true, variety: true } } },
|
include: { plant: { select: { commonName: true, variety: true } } },
|
||||||
}),
|
}),
|
||||||
|
db.item.findMany({
|
||||||
|
where: { kind: "CONSUMABLE", active: true, bestBefore: { lte: thirtyDaysFromNow } },
|
||||||
|
orderBy: { bestBefore: "asc" },
|
||||||
|
select: { id: true, name: true, bestBefore: true, quantity: true, unit: true },
|
||||||
|
take: 5,
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const firstName = session?.user.name.split(" ")[0] ?? "there";
|
const firstName = session?.user.name.split(" ")[0] ?? "there";
|
||||||
@@ -42,6 +50,34 @@ export default async function DashboardPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/kitchen/pantry">
|
||||||
|
<Card className="hover:shadow-md transition-shadow cursor-pointer">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||||
|
<UtensilsCrossed className="h-4 w-4 text-amber-600" />
|
||||||
|
Kitchen
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{expiringItems.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Nothing expiring soon</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="flex items-center gap-1.5 text-sm font-medium text-amber-700">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
|
{expiringItems.length} expiring soon
|
||||||
|
</p>
|
||||||
|
{expiringItems.slice(0, 3).map(i => (
|
||||||
|
<p key={i.id} className="text-xs text-muted-foreground truncate">
|
||||||
|
{i.name} · {new Date(i.bestBefore!).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{recentLogs.length > 0 && (
|
{recentLogs.length > 0 && (
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { db } from "@/lib/db";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { MapPin, CalendarDays, ChevronLeft, Sprout, Package, BookOpen } from "lucide-react";
|
import { MapPin, CalendarDays, ChevronLeft, Sprout, Package, BookOpen, GraduationCap } from "lucide-react";
|
||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
import { CATEGORY_LABELS, LOG_TYPE_LABELS, LOG_TYPE_COLORS } from "@/lib/garden/categories";
|
import { CATEGORY_LABELS, LOG_TYPE_LABELS, LOG_TYPE_COLORS } from "@/lib/garden/categories";
|
||||||
import { AddLogButton } from "@/components/garden/add-log-button";
|
import { AddLogButton } from "@/components/garden/add-log-button";
|
||||||
@@ -22,7 +22,7 @@ export default async function PlantDetailPage({ params }: { params: { id: string
|
|||||||
include: {
|
include: {
|
||||||
logs: { orderBy: { date: "desc" } },
|
logs: { orderBy: { date: "desc" } },
|
||||||
location: { select: { id: true, name: true } },
|
location: { select: { id: true, name: true } },
|
||||||
plantType: { select: { id: true, commonName: true } },
|
plantType: { select: { id: true, commonName: true, uwExtensionUrl: true, uwExtensionTitle: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
db.location.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
db.location.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
||||||
@@ -78,6 +78,19 @@ export default async function PlantDetailPage({ params }: { params: { id: string
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{plant.plantType?.uwExtensionUrl && (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<GraduationCap className="h-4 w-4 shrink-0" />
|
||||||
|
<a
|
||||||
|
href={plant.plantType.uwExtensionUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="hover:text-foreground underline-offset-2 hover:underline truncate"
|
||||||
|
>
|
||||||
|
UW Extension: {plant.plantType.uwExtensionTitle || "article"}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{plant.plantedAt && (
|
{plant.plantedAt && (
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
<CalendarDays className="h-4 w-4 shrink-0" />
|
<CalendarDays className="h-4 w-4 shrink-0" />
|
||||||
@@ -99,7 +112,7 @@ export default async function PlantDetailPage({ params }: { params: { id: string
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="font-display text-lg font-semibold">Care & harvest log</h2>
|
<h2 className="font-display text-lg font-semibold">Care & harvest log</h2>
|
||||||
<AddLogButton plantId={plant.id} plantName={plant.commonName} />
|
<AddLogButton plantId={plant.id} plantName={plant.commonName} locations={locations} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{plant.logs.length === 0 ? (
|
{plant.logs.length === 0 ? (
|
||||||
@@ -116,8 +129,10 @@ export default async function PlantDetailPage({ params }: { params: { id: string
|
|||||||
<span className={`text-xs font-semibold uppercase tracking-wide ${LOG_TYPE_COLORS[log.type]}`}>
|
<span className={`text-xs font-semibold uppercase tracking-wide ${LOG_TYPE_COLORS[log.type]}`}>
|
||||||
{LOG_TYPE_LABELS[log.type]}
|
{LOG_TYPE_LABELS[log.type]}
|
||||||
</span>
|
</span>
|
||||||
{log.quantity && (
|
{(log.amount != null || log.quantity) && (
|
||||||
<span className="text-xs text-muted-foreground">· {log.quantity}</span>
|
<span className="text-xs text-muted-foreground">
|
||||||
|
· {log.amount != null ? `${Number(log.amount)} ${log.unit ?? ""}`.trim() : log.quantity}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-muted-foreground ml-auto">{formatDate(log.date)}</span>
|
<span className="text-xs text-muted-foreground ml-auto">{formatDate(log.date)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { AddPlantButton } from "@/components/garden/add-plant-button";
|
import { AddPlantButton } from "@/components/garden/add-plant-button";
|
||||||
import { GardenGrid } from "@/components/garden/garden-grid";
|
import { GardenGrid } from "@/components/garden/garden-grid";
|
||||||
|
import { GardenJournal } from "@/components/garden/garden-journal";
|
||||||
|
|
||||||
export const metadata = { title: "Garden" };
|
export const metadata = { title: "Garden" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function GardenPage() {
|
export default async function GardenPage() {
|
||||||
const [plants, groups, locations] = await Promise.all([
|
const twoWeeksAgo = new Date(Date.now() - 14 * 86_400_000);
|
||||||
|
const [plants, groups, locations, recentLogs] = await Promise.all([
|
||||||
db.plant.findMany({
|
db.plant.findMany({
|
||||||
where: { active: true },
|
where: { active: true },
|
||||||
orderBy: [{ commonName: "asc" }],
|
orderBy: [{ commonName: "asc" }],
|
||||||
@@ -26,10 +29,25 @@ export default async function GardenPage() {
|
|||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true },
|
||||||
}),
|
}),
|
||||||
|
db.plantLog.findMany({
|
||||||
|
where: { date: { gte: twoWeeksAgo }, plant: { active: true } },
|
||||||
|
orderBy: { date: "desc" },
|
||||||
|
take: 10,
|
||||||
|
select: {
|
||||||
|
id: true, type: true, date: true, notes: true, quantity: true,
|
||||||
|
amount: true, unit: true,
|
||||||
|
plant: { select: { id: true, commonName: true, variety: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const journal = recentLogs.map((l) => ({
|
||||||
|
...l,
|
||||||
|
amount: l.amount != null ? Number(l.amount) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-display text-2xl font-semibold">Food Forest</h1>
|
<h1 className="font-display text-2xl font-semibold">Food Forest</h1>
|
||||||
@@ -40,7 +58,9 @@ export default async function GardenPage() {
|
|||||||
<AddPlantButton groups={groups} locations={locations} />
|
<AddPlantButton groups={groups} locations={locations} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GardenGrid plants={plants} groups={groups} />
|
<GardenGrid plants={plants} groups={groups} locations={locations} />
|
||||||
|
|
||||||
|
<GardenJournal entries={journal} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
ChevronLeft, Sun, Droplets, Flower2, Apple, AlertTriangle, Ruler, MapPin, Leaf,
|
ChevronLeft, Sun, Droplets, Flower2, Apple, AlertTriangle, Ruler, MapPin, Leaf,
|
||||||
|
GraduationCap, ExternalLink, Sprout, ThumbsUp, ThumbsDown, Repeat,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { CATEGORY_LABELS } from "@/lib/garden/categories";
|
import { CATEGORY_LABELS } from "@/lib/garden/categories";
|
||||||
|
import { knowledgeFor, uwSearchUrl, ROTATION_LABELS } from "@/lib/garden/knowledge";
|
||||||
import { EditPlantTypeButton, type PlantTypeFields } from "@/components/garden/plant-type-dialog";
|
import { EditPlantTypeButton, type PlantTypeFields } from "@/components/garden/plant-type-dialog";
|
||||||
import { DeletePlantTypeButton } from "@/components/garden/delete-plant-type-button";
|
import { DeletePlantTypeButton } from "@/components/garden/delete-plant-type-button";
|
||||||
|
|
||||||
@@ -24,6 +26,77 @@ export async function generateMetadata({ params }: { params: { id: string } }) {
|
|||||||
return { title: t?.commonName ?? "Plant type" };
|
return { title: t?.commonName ?? "Plant type" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Garden-brain card: companions, antagonists, rotation family, and the
|
||||||
|
// zone-4b care timeline for this kind of plant (when we know it).
|
||||||
|
function GrowingTogether({ name }: { name: string }) {
|
||||||
|
const k = knowledgeFor(name);
|
||||||
|
if (!k) return null;
|
||||||
|
|
||||||
|
const hasCompanions = (k.friends?.length ?? 0) > 0 || (k.foes?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4 space-y-3 text-sm">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Growing {k.name.toLowerCase()} in zone 4b
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={uwSearchUrl(k.uwQuery)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
UW Extension <ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{k.tips.length > 0 && (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{k.tips.map((t) => (
|
||||||
|
<li key={t.action} className="flex items-start gap-2">
|
||||||
|
<Sprout className="h-3.5 w-3.5 mt-0.5 shrink-0 text-[hsl(var(--leaf))]" />
|
||||||
|
<span>
|
||||||
|
<span className="text-muted-foreground">{t.months.map((m) => MONTHS[m]).join(", ")}:</span>{" "}
|
||||||
|
{t.action}
|
||||||
|
{t.detail && <span className="text-muted-foreground"> — {t.detail}</span>}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasCompanions && (
|
||||||
|
<div className="flex flex-col gap-1 pt-2 border-t">
|
||||||
|
{k.friends && k.friends.length > 0 && (
|
||||||
|
<p className="flex items-start gap-2">
|
||||||
|
<ThumbsUp className="h-3.5 w-3.5 mt-0.5 shrink-0 text-[hsl(var(--leaf))]" />
|
||||||
|
<span><span className="text-muted-foreground">Grows well with:</span> {k.friends.join(", ")}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{k.foes && k.foes.length > 0 && (
|
||||||
|
<p className="flex items-start gap-2">
|
||||||
|
<ThumbsDown className="h-3.5 w-3.5 mt-0.5 shrink-0 text-[hsl(var(--ember))]" />
|
||||||
|
<span><span className="text-muted-foreground">Keep away from:</span> {k.foes.join(", ")}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(k.family || k.rotationNote) && (
|
||||||
|
<p className="flex items-start gap-2 pt-2 border-t">
|
||||||
|
<Repeat className="h-3.5 w-3.5 mt-0.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span>
|
||||||
|
{k.family && <span className="text-muted-foreground">Rotation family: {ROTATION_LABELS[k.family]}.</span>}
|
||||||
|
{k.rotationNote && <span> {k.rotationNote}</span>}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function PlantTypeDetailPage({ params }: { params: { id: string } }) {
|
export default async function PlantTypeDetailPage({ params }: { params: { id: string } }) {
|
||||||
const type = await db.plantType.findUnique({
|
const type = await db.plantType.findUnique({
|
||||||
where: { id: params.id },
|
where: { id: params.id },
|
||||||
@@ -56,6 +129,8 @@ export default async function PlantTypeDetailPage({ params }: { params: { id: st
|
|||||||
toxicToPets: type.toxicToPets,
|
toxicToPets: type.toxicToPets,
|
||||||
edibleParts: type.edibleParts,
|
edibleParts: type.edibleParts,
|
||||||
spacing: type.spacing,
|
spacing: type.spacing,
|
||||||
|
uwExtensionUrl: type.uwExtensionUrl,
|
||||||
|
uwExtensionTitle: type.uwExtensionTitle,
|
||||||
notes: type.notes,
|
notes: type.notes,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -113,6 +188,24 @@ export default async function PlantTypeDetailPage({ params }: { params: { id: st
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{type.uwExtensionUrl && (
|
||||||
|
<a
|
||||||
|
href={type.uwExtensionUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center gap-2 text-sm rounded-lg border px-3 py-2.5 hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<GraduationCap className="h-4 w-4 shrink-0 text-[hsl(var(--leaf))]" />
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
<span className="text-muted-foreground">UW Extension:</span>{" "}
|
||||||
|
{type.uwExtensionTitle || "article"}
|
||||||
|
</span>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 ml-auto shrink-0 text-muted-foreground" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<GrowingTogether name={type.commonName} />
|
||||||
|
|
||||||
{(type.careNotes || type.notes) && (
|
{(type.careNotes || type.notes) && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-4 space-y-3 text-sm">
|
<CardContent className="pt-4 space-y-3 text-sm">
|
||||||
|
|||||||
28
src/app/(dashboard)/kitchen/pantry/page.tsx
Normal file
28
src/app/(dashboard)/kitchen/pantry/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { sessionContext } from "@/lib/api/authenticate";
|
||||||
|
import { listPantry } from "@/lib/services/pantry";
|
||||||
|
import { PantryClient } from "@/components/pantry/pantry-client";
|
||||||
|
|
||||||
|
export const metadata = { title: "Pantry & Freezer" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function PantryPage() {
|
||||||
|
const ctx = await sessionContext();
|
||||||
|
const [items, locations, plants] = await Promise.all([
|
||||||
|
listPantry(ctx),
|
||||||
|
db.location.findMany({
|
||||||
|
where: {
|
||||||
|
active: true,
|
||||||
|
OR: [
|
||||||
|
{ name: { equals: "Kitchen", mode: "insensitive" } },
|
||||||
|
{ path: { contains: "Kitchen", mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { path: "asc" },
|
||||||
|
select: { id: true, name: true, path: true },
|
||||||
|
}),
|
||||||
|
db.plant.findMany({ where: { active: true }, orderBy: { commonName: "asc" }, select: { id: true, commonName: true, variety: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return <PantryClient initialItems={items} locations={locations} plants={plants} />;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getSession } from "@/lib/auth";
|
import { getSession } from "@/lib/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { SuggestionLightbulb } from "@otm/account-panel";
|
||||||
import { Sidebar } from "@/components/layout/sidebar";
|
import { Sidebar } from "@/components/layout/sidebar";
|
||||||
import { TopNav } from "@/components/layout/topnav";
|
import { TopNav } from "@/components/layout/topnav";
|
||||||
import { Footer } from "@/components/layout/footer";
|
import { Footer } from "@/components/layout/footer";
|
||||||
@@ -7,10 +9,18 @@ import { Footer } from "@/components/layout/footer";
|
|||||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
if (!session) redirect("/login");
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
// Force a password change before anything else (provisioned accounts ship with
|
// Force a password change before anything else (provisioned accounts ship with
|
||||||
// a known default + mustChangePassword=true). The target page is outside this
|
// a known default + mustChangePassword=true). Read the flag from the DB, not the
|
||||||
// layout group, so there's no redirect loop.
|
// JWT: the cookie is only refreshed on re-login/update(), so trusting it would
|
||||||
if (session.user.mustChangePassword) redirect("/change-password");
|
// keep bouncing the user back here even after they've set a new password. The
|
||||||
|
// target page is outside this layout group, so there's no redirect loop.
|
||||||
|
const account = await db.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: { active: true, mustChangePassword: true },
|
||||||
|
});
|
||||||
|
if (!account || !account.active) redirect("/login");
|
||||||
|
if (account.mustChangePassword) redirect("/change-password");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-screen flex-col">
|
||||||
@@ -22,6 +32,11 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
{/* Floating quick-capture idea/feedback widget (parity with dms + fmb).
|
||||||
|
Mounted at the shell root — a fixed-position child of a backdrop-blur
|
||||||
|
ancestor would get trapped. Posts to /api/suggestions → files a Gitea
|
||||||
|
issue; "View all →" links to the full triage page. */}
|
||||||
|
<SuggestionLightbulb panelHref="/suggestions" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
41
src/app/(dashboard)/ramble/page.tsx
Normal file
41
src/app/(dashboard)/ramble/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { RambleClient } from "./ramble-client";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Ramble" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function RamblePage() {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
const [active, archived] = await Promise.all([
|
||||||
|
db.ramble.findMany({
|
||||||
|
where: { archivedAt: null },
|
||||||
|
orderBy: [{ pinned: "desc" }, { createdAt: "desc" }],
|
||||||
|
}),
|
||||||
|
db.ramble.findMany({
|
||||||
|
where: { archivedAt: { not: null } },
|
||||||
|
orderBy: { archivedAt: "desc" },
|
||||||
|
take: 30,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Prisma Dates don't serialize across the server/client boundary — send ISO strings.
|
||||||
|
const serialize = (r: (typeof active)[number]) => ({
|
||||||
|
id: r.id,
|
||||||
|
body: r.body,
|
||||||
|
pinned: r.pinned,
|
||||||
|
archivedAt: r.archivedAt ? r.archivedAt.toISOString() : null,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RambleClient
|
||||||
|
initialActive={active.map(serialize)}
|
||||||
|
initialArchived={archived.map(serialize)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
src/app/(dashboard)/ramble/ramble-client.tsx
Normal file
212
src/app/(dashboard)/ramble/ramble-client.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { Pin, PinOff, Archive, ArchiveRestore, Trash2, StickyNote } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
type Ramble = {
|
||||||
|
id: string;
|
||||||
|
body: string;
|
||||||
|
pinned: boolean;
|
||||||
|
archivedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rotating sticky-note colors (light + dark variants), keyed off note id so a
|
||||||
|
// note keeps its color across refreshes rather than reshuffling.
|
||||||
|
const STICKY = [
|
||||||
|
"bg-amber-50 border-amber-200 dark:bg-amber-950/40 dark:border-amber-900",
|
||||||
|
"bg-rose-50 border-rose-200 dark:bg-rose-950/40 dark:border-rose-900",
|
||||||
|
"bg-emerald-50 border-emerald-200 dark:bg-emerald-950/40 dark:border-emerald-900",
|
||||||
|
"bg-sky-50 border-sky-200 dark:bg-sky-950/40 dark:border-sky-900",
|
||||||
|
"bg-violet-50 border-violet-200 dark:bg-violet-950/40 dark:border-violet-900",
|
||||||
|
];
|
||||||
|
function stickyColor(id: string): string {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
|
||||||
|
return STICKY[h % STICKY.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RambleClient({
|
||||||
|
initialActive,
|
||||||
|
initialArchived,
|
||||||
|
}: {
|
||||||
|
initialActive: Ramble[];
|
||||||
|
initialArchived: Ramble[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
const [posting, setPosting] = useState(false);
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
|
||||||
|
const active = initialActive;
|
||||||
|
const archived = initialArchived;
|
||||||
|
|
||||||
|
async function post(pinned: boolean) {
|
||||||
|
const text = body.trim();
|
||||||
|
if (!text) return;
|
||||||
|
setPosting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/rambles", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ body: text, pinned }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.json()).error ?? "Failed to save");
|
||||||
|
setBody("");
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
} catch (err) {
|
||||||
|
toast({ title: "Couldn't save", description: String(err).replace("Error: ", "") });
|
||||||
|
} finally {
|
||||||
|
setPosting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mutate(id: string, patch: Record<string, unknown>) {
|
||||||
|
const res = await fetch(`/api/rambles/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
toast({ title: "Couldn't update", description: (await res.json()).error ?? "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string) {
|
||||||
|
if (!window.confirm("Delete this note? This can't be undone.")) return;
|
||||||
|
const res = await fetch(`/api/rambles/${id}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) {
|
||||||
|
toast({ title: "Couldn't delete", description: (await res.json()).error ?? "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-2xl font-semibold flex items-center gap-2">
|
||||||
|
<StickyNote className="h-6 w-6 text-[hsl(var(--leaf))]" />
|
||||||
|
Ramble
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
A place to dump stray thoughts — chores, ideas, reminders that aren't tasks yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Composer */}
|
||||||
|
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||||
|
<Textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") post(false);
|
||||||
|
}}
|
||||||
|
placeholder="What's on your mind? (⌘/Ctrl+Enter to post)"
|
||||||
|
rows={3}
|
||||||
|
className="resize-none"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => post(true)} disabled={posting || !body.trim()}>
|
||||||
|
<Pin className="h-4 w-4" /> Post pinned
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => post(false)} disabled={posting || !body.trim()}>
|
||||||
|
{posting ? "Posting…" : "Post"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active notes */}
|
||||||
|
{active.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-10">
|
||||||
|
No notes yet. Jot something above.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{active.map((r) => (
|
||||||
|
<NoteCard key={r.id} r={r} onMutate={mutate} onRemove={remove} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Archived */}
|
||||||
|
{archived.length > 0 && (
|
||||||
|
<div className="pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowArchived((v) => !v)}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{showArchived ? "Hide" : "Show"} archived ({archived.length})
|
||||||
|
</button>
|
||||||
|
{showArchived && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 mt-3 opacity-70">
|
||||||
|
{archived.map((r) => (
|
||||||
|
<NoteCard key={r.id} r={r} archivedView onMutate={mutate} onRemove={remove} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoteCard({
|
||||||
|
r,
|
||||||
|
archivedView,
|
||||||
|
onMutate,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
r: Ramble;
|
||||||
|
archivedView?: boolean;
|
||||||
|
onMutate: (id: string, patch: Record<string, unknown>) => void;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("group relative rounded-lg border p-3 shadow-sm", stickyColor(r.id))}>
|
||||||
|
{r.pinned && !archivedView && (
|
||||||
|
<Pin className="absolute right-2 top-2 h-3.5 w-3.5 text-[hsl(var(--leaf))] fill-current" />
|
||||||
|
)}
|
||||||
|
<p className="text-sm whitespace-pre-wrap break-words pr-4 min-h-[2rem]">{r.body}</p>
|
||||||
|
<div className="mt-2 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{formatDistanceToNow(new Date(r.createdAt), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{!archivedView && (
|
||||||
|
<button
|
||||||
|
title={r.pinned ? "Unpin" : "Pin"}
|
||||||
|
onClick={() => onMutate(r.id, { pinned: !r.pinned })}
|
||||||
|
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{r.pinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
title={archivedView ? "Restore" : "Archive"}
|
||||||
|
onClick={() => onMutate(r.id, { archived: !archivedView })}
|
||||||
|
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{archivedView ? <ArchiveRestore className="h-3.5 w-3.5" /> : <Archive className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
title="Delete"
|
||||||
|
onClick={() => onRemove(r.id)}
|
||||||
|
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/10 text-[hsl(var(--ember))]"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,5 +9,8 @@ export const dynamic = "force-dynamic";
|
|||||||
export default async function SuggestionsPage() {
|
export default async function SuggestionsPage() {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
if (!session) redirect("/login");
|
if (!session) redirect("/login");
|
||||||
|
// Render the panel bare (matches FMB). Earlier versions needed a white-card
|
||||||
|
// wrapper for contrast, but the current @otm/account-panel is theme-aware —
|
||||||
|
// the wrapper was fighting its own styles and hiding image thumbnails.
|
||||||
return <SuggestionsPanel />;
|
return <SuggestionsPanel />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ export default async function TasksPage() {
|
|||||||
const tasks = await db.task.findMany({
|
const tasks = await db.task.findMany({
|
||||||
where: { active: true },
|
where: { active: true },
|
||||||
orderBy: { nextDue: "asc" },
|
orderBy: { nextDue: "asc" },
|
||||||
include: { plant: { select: { id: true, commonName: true, variety: true } } },
|
include: {
|
||||||
|
plant: { select: { id: true, commonName: true, variety: true } },
|
||||||
|
item: { select: { id: true, name: true } },
|
||||||
|
animal: { select: { id: true, name: true } },
|
||||||
|
location: { select: { id: true, name: true, path: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const overdue = tasks.filter((t) => isOverdue(t.nextDue));
|
const overdue = tasks.filter((t) => isOverdue(t.nextDue));
|
||||||
@@ -104,6 +109,9 @@ function Section({ title, icon: Icon, iconClass, children }: {
|
|||||||
function TaskCard({ task, overdue = false }: {
|
function TaskCard({ task, overdue = false }: {
|
||||||
task: Awaited<ReturnType<typeof db.task.findMany>>[0] & {
|
task: Awaited<ReturnType<typeof db.task.findMany>>[0] & {
|
||||||
plant: { id: string; commonName: string; variety: string | null } | null;
|
plant: { id: string; commonName: string; variety: string | null } | null;
|
||||||
|
item: { id: string; name: string } | null;
|
||||||
|
animal: { id: string; name: string } | null;
|
||||||
|
location: { id: string; name: string; path: string | null } | null;
|
||||||
};
|
};
|
||||||
overdue?: boolean;
|
overdue?: boolean;
|
||||||
}) {
|
}) {
|
||||||
@@ -124,10 +132,23 @@ function TaskCard({ task, overdue = false }: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{task.plant && (
|
{task.plant && (
|
||||||
<Link href={`/garden/${task.plant.id}`} className="text-xs text-[hsl(var(--leaf))] hover:underline">
|
<Link href={`/garden/${task.plant.id}`} className="text-xs text-[hsl(var(--leaf))] hover:underline block">
|
||||||
{task.plant.commonName}{task.plant.variety ? ` · ${task.plant.variety}` : ""}
|
{task.plant.commonName}{task.plant.variety ? ` · ${task.plant.variety}` : ""}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
{task.item && (
|
||||||
|
<Link href={`/inventory/${task.item.id}`} className="text-xs text-[hsl(var(--leaf))] hover:underline block">
|
||||||
|
{task.item.name}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{task.animal && (
|
||||||
|
<Link href={`/animals/${task.animal.id}`} className="text-xs text-[hsl(var(--leaf))] hover:underline block">
|
||||||
|
{task.animal.name}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{task.location && (
|
||||||
|
<span className="text-xs text-muted-foreground">📍 {task.location.path || task.location.name}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{task.notes && (
|
{task.notes && (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">{task.notes}</p>
|
<p className="text-xs text-muted-foreground mt-0.5">{task.notes}</p>
|
||||||
|
|||||||
49
src/app/api/auth/recover/route.ts
Normal file
49
src/app/api/auth/recover/route.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { hashSync } from "bcryptjs";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
secret: z.string().min(1),
|
||||||
|
username: z.string().min(1),
|
||||||
|
password: z.string().min(6),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const recoverySecret = process.env.RECOVERY_SECRET;
|
||||||
|
if (!recoverySecret) {
|
||||||
|
return NextResponse.json({ error: "Recovery not configured" }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const data = schema.parse(body);
|
||||||
|
|
||||||
|
if (data.secret !== recoverySecret) {
|
||||||
|
return NextResponse.json({ error: "Invalid recovery secret" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.user.findFirst({
|
||||||
|
where: { OR: [{ username: data.username }, { email: data.username }], active: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { passwordHash: hashSync(data.password, 10), mustChangePassword: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.auditEvent.create({
|
||||||
|
data: { action: "auth.recover", entityId: user.id, payload: { username: user.username } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, name: user.name });
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof z.ZodError) return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||||
|
console.error(err);
|
||||||
|
return NextResponse.json({ error: "Server error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/app/api/locations/bulk/route.ts
Normal file
22
src/app/api/locations/bulk/route.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||||
|
import { bulkSetupLocations } from "@/lib/services/location-setup";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
outline: z.string().min(1),
|
||||||
|
groupGardenUnder: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/locations/bulk — create a Location tree from an indented outline,
|
||||||
|
// optionally sweeping existing top-level garden areas under a named parent.
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const ctx = await authenticateRequest(req, "locations:write");
|
||||||
|
const { outline, groupGardenUnder } = schema.parse(await req.json());
|
||||||
|
const summary = await bulkSetupLocations(ctx, { outline, groupGardenUnder });
|
||||||
|
return NextResponse.json(summary, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
return handleApiError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,11 +23,14 @@ const updateSchema = z.object({
|
|||||||
edibleParts: z.string().optional(),
|
edibleParts: z.string().optional(),
|
||||||
spacing: z.string().optional(),
|
spacing: z.string().optional(),
|
||||||
imageUrl: z.string().optional(),
|
imageUrl: z.string().optional(),
|
||||||
|
uwExtensionUrl: z.string().optional(),
|
||||||
|
uwExtensionTitle: z.string().optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const TEXT_FIELDS = [
|
const TEXT_FIELDS = [
|
||||||
"species", "careNotes", "sun", "water", "edibleParts", "spacing", "imageUrl", "notes",
|
"species", "careNotes", "sun", "water", "edibleParts", "spacing", "imageUrl",
|
||||||
|
"uwExtensionUrl", "uwExtensionTitle", "notes",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ const createSchema = z.object({
|
|||||||
bloomEnd: month.optional(),
|
bloomEnd: month.optional(),
|
||||||
harvestStart: month.optional(),
|
harvestStart: month.optional(),
|
||||||
harvestEnd: month.optional(),
|
harvestEnd: month.optional(),
|
||||||
toxicToPets: z.boolean().optional(),
|
toxicToPets: z.boolean().nullable().optional(), // "Unknown" in the dialog arrives as null
|
||||||
edibleParts: z.string().optional(),
|
edibleParts: z.string().optional(),
|
||||||
spacing: z.string().optional(),
|
spacing: z.string().optional(),
|
||||||
imageUrl: z.string().optional(),
|
imageUrl: z.string().optional(),
|
||||||
|
uwExtensionUrl: z.string().optional(),
|
||||||
|
uwExtensionTitle: z.string().optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,6 +68,8 @@ export async function POST(req: Request) {
|
|||||||
edibleParts: data.edibleParts?.trim() || null,
|
edibleParts: data.edibleParts?.trim() || null,
|
||||||
spacing: data.spacing?.trim() || null,
|
spacing: data.spacing?.trim() || null,
|
||||||
imageUrl: data.imageUrl?.trim() || null,
|
imageUrl: data.imageUrl?.trim() || null,
|
||||||
|
uwExtensionUrl: data.uwExtensionUrl?.trim() || null,
|
||||||
|
uwExtensionTitle: data.uwExtensionTitle?.trim() || null,
|
||||||
notes: data.notes?.trim() || null,
|
notes: data.notes?.trim() || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
65
src/app/api/rambles/[id]/route.ts
Normal file
65
src/app/api/rambles/[id]/route.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { requireAuth } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const patchSchema = z.object({
|
||||||
|
body: z.string().trim().min(1).max(5000).optional(),
|
||||||
|
pinned: z.boolean().optional(),
|
||||||
|
archived: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/rambles/:id — edit body, pin/unpin, or archive/restore.
|
||||||
|
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
||||||
|
let session;
|
||||||
|
try {
|
||||||
|
session = await requireAuth();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let input;
|
||||||
|
try {
|
||||||
|
input = patchSchema.parse(await req.json());
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof z.ZodError ? err.errors[0].message : "Invalid input";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: { body?: string; pinned?: boolean; archivedAt?: Date | null } = {};
|
||||||
|
if (input.body !== undefined) data.body = input.body;
|
||||||
|
if (input.pinned !== undefined) data.pinned = input.pinned;
|
||||||
|
if (input.archived !== undefined) data.archivedAt = input.archived ? new Date() : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ramble = await db.ramble.update({ where: { id: params.id }, data });
|
||||||
|
await db.auditEvent.create({
|
||||||
|
data: { action: "ramble.update", entityId: ramble.id, actorId: session.user.id, payload: input },
|
||||||
|
});
|
||||||
|
return NextResponse.json(ramble);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/rambles/:id — hard delete (a ramble is disposable by design).
|
||||||
|
export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
|
||||||
|
let session;
|
||||||
|
try {
|
||||||
|
session = await requireAuth();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.ramble.delete({ where: { id: params.id } });
|
||||||
|
await db.auditEvent.create({
|
||||||
|
data: { action: "ramble.delete", entityId: params.id, actorId: session.user.id },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/app/api/rambles/route.ts
Normal file
38
src/app/api/rambles/route.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { requireAuth } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
body: z.string().trim().min(1, "Write something first.").max(5000),
|
||||||
|
pinned: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/rambles — jot a new note.
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
let session;
|
||||||
|
try {
|
||||||
|
session = await requireAuth();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let input;
|
||||||
|
try {
|
||||||
|
input = createSchema.parse(await req.json());
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof z.ZodError ? err.errors[0].message : "Invalid input";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ramble = await db.ramble.create({
|
||||||
|
data: { body: input.body, pinned: input.pinned ?? false },
|
||||||
|
});
|
||||||
|
await db.auditEvent.create({
|
||||||
|
data: { action: "ramble.create", entityId: ramble.id, actorId: session.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(ramble, { status: 201 });
|
||||||
|
}
|
||||||
26
src/app/api/v1/pantry/[id]/route.ts
Normal file
26
src/app/api/v1/pantry/[id]/route.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||||
|
import { pantryUpdateInput } from "@/lib/api/schemas";
|
||||||
|
import { updatePantryItem, deletePantryItem } from "@/lib/services/pantry";
|
||||||
|
|
||||||
|
// PATCH /api/v1/pantry/:id
|
||||||
|
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const ctx = await authenticateRequest(req, "items:write");
|
||||||
|
const input = pantryUpdateInput.parse(await req.json());
|
||||||
|
return NextResponse.json(await updatePantryItem(ctx, params.id, input));
|
||||||
|
} catch (err) {
|
||||||
|
return handleApiError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/v1/pantry/:id — soft-delete (active=false)
|
||||||
|
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const ctx = await authenticateRequest(req, "items:write");
|
||||||
|
await deletePantryItem(ctx, params.id);
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
return handleApiError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/app/api/v1/pantry/[id]/use/route.ts
Normal file
16
src/app/api/v1/pantry/[id]/use/route.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||||
|
import { pantryUseInput } from "@/lib/api/schemas";
|
||||||
|
import { usePantryItem } from "@/lib/services/pantry";
|
||||||
|
|
||||||
|
// POST /api/v1/pantry/:id/use — pull an amount; logs a USED ItemLog
|
||||||
|
export async function POST(req: Request, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const ctx = await authenticateRequest(req, "items:write");
|
||||||
|
const input = pantryUseInput.parse(await req.json());
|
||||||
|
const result = await usePantryItem(ctx, params.id, input);
|
||||||
|
return NextResponse.json({ ok: true, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
return handleApiError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/app/api/v1/pantry/bulk-use/route.ts
Normal file
17
src/app/api/v1/pantry/bulk-use/route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||||
|
import { pantryBulkUseInput } from "@/lib/api/schemas";
|
||||||
|
import { bulkUsePantry } from "@/lib/services/pantry";
|
||||||
|
|
||||||
|
// POST /api/v1/pantry/bulk-use — pull several items at once.
|
||||||
|
// Responds with `skipped` for any pull whose item no longer exists.
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const ctx = await authenticateRequest(req, "items:write");
|
||||||
|
const input = pantryBulkUseInput.parse(await req.json());
|
||||||
|
const result = await bulkUsePantry(ctx, input);
|
||||||
|
return NextResponse.json({ ok: result.skipped.length === 0, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
return handleApiError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/app/api/v1/pantry/catalog/route.ts
Normal file
14
src/app/api/v1/pantry/catalog/route.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||||
|
import { pantryCatalog } from "@/lib/services/pantry";
|
||||||
|
|
||||||
|
// GET /api/v1/pantry/catalog?q= — autocomplete data for the add/edit dialog
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const ctx = await authenticateRequest(req, "items:read");
|
||||||
|
const q = new URL(req.url).searchParams.get("q") ?? undefined;
|
||||||
|
return NextResponse.json(await pantryCatalog(ctx, q));
|
||||||
|
} catch (err) {
|
||||||
|
return handleApiError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/app/api/v1/pantry/route.ts
Normal file
27
src/app/api/v1/pantry/route.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||||
|
import { pantryCreateInput } from "@/lib/api/schemas";
|
||||||
|
import { listPantry, createPantryItem } from "@/lib/services/pantry";
|
||||||
|
|
||||||
|
// Pantry items are consumable Items, so they ride the items:* scopes.
|
||||||
|
|
||||||
|
// GET /api/v1/pantry
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const ctx = await authenticateRequest(req, "items:read");
|
||||||
|
return NextResponse.json(await listPantry(ctx));
|
||||||
|
} catch (err) {
|
||||||
|
return handleApiError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/pantry
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const ctx = await authenticateRequest(req, "items:write");
|
||||||
|
const input = pantryCreateInput.parse(await req.json());
|
||||||
|
return NextResponse.json(await createPantryItem(ctx, input), { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
return handleApiError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/app/api/v1/plant-types/uw-search/route.ts
Normal file
15
src/app/api/v1/plant-types/uw-search/route.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||||
|
import { searchUwExtension } from "@/lib/garden/uw-extension";
|
||||||
|
|
||||||
|
// GET /api/v1/plant-types/uw-search?q=raspberry — proxy to the UW–Madison
|
||||||
|
// Extension horticulture article search (avoids CORS, keeps the client thin).
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
await authenticateRequest(req, "plant-types:read");
|
||||||
|
const q = new URL(req.url).searchParams.get("q") ?? "";
|
||||||
|
return NextResponse.json(await searchUwExtension(q));
|
||||||
|
} catch (err) {
|
||||||
|
return handleApiError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,11 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
|
/* Force native form controls (input/textarea/select) to the light theme so
|
||||||
|
* they don't pick up the OS dark fill — otherwise an unstyled <input> renders
|
||||||
|
* with a dark system background in a dark-mode browser, hiding placeholder
|
||||||
|
* text. .dark overrides this below. */
|
||||||
|
color-scheme: light;
|
||||||
--dew: 120 18% 94%; /* #EEF3EE — pale sage background */
|
--dew: 120 18% 94%; /* #EEF3EE — pale sage background */
|
||||||
--dew-deep: 120 20% 97%; /* slightly lighter for cards */
|
--dew-deep: 120 20% 97%; /* slightly lighter for cards */
|
||||||
--soil: 25 35% 18%; /* #3A2B1A — warm dark brown text */
|
--soil: 25 35% 18%; /* #3A2B1A — warm dark brown text */
|
||||||
@@ -58,6 +63,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
|
color-scheme: dark;
|
||||||
--dew: 140 18% 10%;
|
--dew: 140 18% 10%;
|
||||||
--dew-deep: 140 18% 12%;
|
--dew-deep: 140 18% 12%;
|
||||||
--soil: 120 18% 88%;
|
--soil: 120 18% 88%;
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ function LoginForm() {
|
|||||||
<Button type="submit" className="w-full" disabled={busy}>
|
<Button type="submit" className="w-full" disabled={busy}>
|
||||||
{busy ? "Signing in…" : "Sign in"}
|
{busy ? "Signing in…" : "Sign in"}
|
||||||
</Button>
|
</Button>
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
<a href="/recover" className="underline underline-offset-2 hover:text-foreground">
|
||||||
|
Forgot password?
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
121
src/app/recover/page.tsx
Normal file
121
src/app/recover/page.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export default function RecoverPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [form, setForm] = useState({ secret: "", username: "", password: "", confirm: "" });
|
||||||
|
const [status, setStatus] = useState<{ type: "error" | "success"; message: string } | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (form.password !== form.confirm) {
|
||||||
|
setStatus({ type: "error", message: "Passwords don't match." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setStatus(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/recover", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ secret: form.secret, username: form.username, password: form.password }),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok) throw new Error(json.error ?? "Failed");
|
||||||
|
setStatus({ type: "success", message: `Password reset for ${json.name}. You can now log in.` });
|
||||||
|
} catch (err) {
|
||||||
|
setStatus({ type: "error", message: String(err).replace("Error: ", "") });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||||
|
<div className="w-full max-w-sm space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Account Recovery</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Enter the recovery secret from your <code>.env</code> file to reset a password.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium">Recovery secret</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={form.secret}
|
||||||
|
onChange={e => setForm(f => ({ ...f, secret: e.target.value }))}
|
||||||
|
className="w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
placeholder="from your .env file"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={form.username}
|
||||||
|
onChange={e => setForm(f => ({ ...f, username: e.target.value }))}
|
||||||
|
className="w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
placeholder="bonna"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium">New password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
value={form.password}
|
||||||
|
onChange={e => setForm(f => ({ ...f, password: e.target.value }))}
|
||||||
|
className="w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm font-medium">Confirm new password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={form.confirm}
|
||||||
|
onChange={e => setForm(f => ({ ...f, confirm: e.target.value }))}
|
||||||
|
className="w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status && (
|
||||||
|
<div className={`rounded-md p-3 text-sm ${status.type === "success" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>
|
||||||
|
{status.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "Resetting…" : "Reset password"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{status?.type === "success" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push("/login")}
|
||||||
|
className="w-full rounded-md border px-4 py-2 text-sm font-medium hover:bg-accent"
|
||||||
|
>
|
||||||
|
Go to login
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
336
src/components/calendar/calendar-view.tsx
Normal file
336
src/components/calendar/calendar-view.tsx
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Month calendar merging every kind of task (garden, home, equipment) with
|
||||||
|
// the garden's bloom/harvest windows. Tasks project forward from their
|
||||||
|
// nextDue via occurrencesInRange; windows come from PlantType months.
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AlertTriangle, Apple, BellPlus, ChevronLeft, ChevronRight, ExternalLink, Flower2, Sparkles } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { occurrencesInRange, describeRecurrence, type RecurrenceType } from "@/lib/tasks/schedule";
|
||||||
|
import { monthInWindow } from "@/lib/garden/season";
|
||||||
|
import { tipsForMonth, type MonthlySuggestion } from "@/lib/garden/knowledge";
|
||||||
|
import { CompleteTaskButton } from "@/components/tasks/complete-task-button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type CalendarTask = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
category: "GARDEN" | "HOME" | "EQUIPMENT" | "OTHER";
|
||||||
|
nextDue: string; // ISO
|
||||||
|
recurrence: RecurrenceType;
|
||||||
|
intervalDays: number | null;
|
||||||
|
month: number | null;
|
||||||
|
linkedTo: string | null; // plant/animal/item/location name
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SeasonType = {
|
||||||
|
id: string;
|
||||||
|
commonName: string;
|
||||||
|
bloomStart: number | null;
|
||||||
|
bloomEnd: number | null;
|
||||||
|
harvestStart: number | null;
|
||||||
|
harvestEnd: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// "Good to do this month" — garden-brain suggestions matched to the plants on
|
||||||
|
// hand, each one a click away from becoming a real yearly reminder.
|
||||||
|
function SuggestionList({ month, year, monthLabel, plantNames, existingTitles }: {
|
||||||
|
month: number; // 1–12
|
||||||
|
year: number;
|
||||||
|
monthLabel: string;
|
||||||
|
plantNames: string[];
|
||||||
|
existingTitles: string[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [added, setAdded] = useState<Set<string>>(new Set());
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const suggestions = useMemo(
|
||||||
|
() => tipsForMonth(month, plantNames),
|
||||||
|
[month, plantNames],
|
||||||
|
);
|
||||||
|
const existing = useMemo(
|
||||||
|
() => new Set(existingTitles.map((t) => t.toLowerCase())),
|
||||||
|
[existingTitles],
|
||||||
|
);
|
||||||
|
const open = suggestions.filter(
|
||||||
|
(s) => !existing.has(s.action.toLowerCase()) && !added.has(s.action),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (open.length === 0) return null;
|
||||||
|
|
||||||
|
async function addReminder(s: MonthlySuggestion) {
|
||||||
|
setBusy(s.action);
|
||||||
|
try {
|
||||||
|
const today = new Date();
|
||||||
|
const inViewedMonth = today.getFullYear() === year && today.getMonth() + 1 === month;
|
||||||
|
const nextDue = inViewedMonth ? today : new Date(year, month - 1, 1);
|
||||||
|
const res = await fetch("/api/v1/tasks", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: s.action,
|
||||||
|
notes: [s.crop, s.detail].filter(Boolean).join(" — ") || undefined,
|
||||||
|
category: "GARDEN",
|
||||||
|
recurrence: "YEARLY",
|
||||||
|
month,
|
||||||
|
nextDue: nextDue.toISOString(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.json()).error ?? "Could not add reminder");
|
||||||
|
setAdded((prev) => new Set(prev).add(s.action));
|
||||||
|
toast({ title: "Reminder added", description: `"${s.action}" will come back every ${monthLabel}.` });
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast({ title: "Error", description: String(err).replace("Error: ", ""), variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-[hsl(var(--leaf)/.05)] overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2.5 border-b bg-[hsl(var(--leaf)/.08)]">
|
||||||
|
<Sparkles className="h-4 w-4 text-[hsl(var(--leaf))]" />
|
||||||
|
<span className="text-sm font-medium">Good to do in {monthLabel}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">· for what you grow (zone 4b)</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y">
|
||||||
|
{open.map((s) => (
|
||||||
|
<div key={s.action} className="flex items-center gap-3 px-4 py-2.5 text-sm">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium">
|
||||||
|
{s.action}
|
||||||
|
{s.crop && <span className="text-muted-foreground font-normal text-xs"> · {s.crop}</span>}
|
||||||
|
</p>
|
||||||
|
{s.detail && <p className="text-xs text-muted-foreground">{s.detail}</p>}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={s.uwUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
title="Read more at UW Extension"
|
||||||
|
className="text-muted-foreground hover:text-foreground shrink-0"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1 text-xs shrink-0"
|
||||||
|
disabled={busy === s.action}
|
||||||
|
onClick={() => addReminder(s)}
|
||||||
|
>
|
||||||
|
{busy === s.action ? "…" : <><BellPlus className="h-3 w-3" /> Remind me</>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_STYLES: Record<CalendarTask["category"], string> = {
|
||||||
|
GARDEN: "bg-[hsl(var(--leaf)/.12)] text-[hsl(var(--leaf))]",
|
||||||
|
HOME: "bg-amber-100 text-amber-800",
|
||||||
|
EQUIPMENT: "bg-sky-100 text-sky-800",
|
||||||
|
OTHER: "bg-muted text-muted-foreground",
|
||||||
|
};
|
||||||
|
|
||||||
|
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
|
||||||
|
function iso(d: Date): string {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarView({ tasks, seasonTypes, plantNames }: {
|
||||||
|
tasks: CalendarTask[];
|
||||||
|
seasonTypes: SeasonType[];
|
||||||
|
plantNames: string[];
|
||||||
|
}) {
|
||||||
|
const today = new Date();
|
||||||
|
const todayIso = iso(today);
|
||||||
|
const [year, setYear] = useState(today.getFullYear());
|
||||||
|
const [month, setMonth] = useState(today.getMonth()); // 0-indexed
|
||||||
|
const [selected, setSelected] = useState<string | null>(todayIso);
|
||||||
|
|
||||||
|
const monthStart = new Date(year, month, 1);
|
||||||
|
const monthEnd = new Date(year, month + 1, 0);
|
||||||
|
|
||||||
|
const byDay = useMemo(() => {
|
||||||
|
const map = new Map<string, CalendarTask[]>();
|
||||||
|
for (const t of tasks) {
|
||||||
|
const occ = occurrencesInRange(
|
||||||
|
{ nextDue: new Date(t.nextDue), recurrence: t.recurrence, intervalDays: t.intervalDays, month: t.month },
|
||||||
|
monthStart,
|
||||||
|
monthEnd,
|
||||||
|
);
|
||||||
|
for (const d of occ) {
|
||||||
|
const key = iso(d);
|
||||||
|
if (!map.has(key)) map.set(key, []);
|
||||||
|
map.get(key)!.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [tasks, year, month]);
|
||||||
|
|
||||||
|
const blooming = seasonTypes.filter((t) => monthInWindow(month + 1, t.bloomStart, t.bloomEnd));
|
||||||
|
const harvesting = seasonTypes.filter((t) => monthInWindow(month + 1, t.harvestStart, t.harvestEnd));
|
||||||
|
const overdue = tasks.filter((t) => new Date(t.nextDue) < new Date(todayIso));
|
||||||
|
|
||||||
|
function nav(delta: number) {
|
||||||
|
const d = new Date(year, month + delta, 1);
|
||||||
|
setYear(d.getFullYear());
|
||||||
|
setMonth(d.getMonth());
|
||||||
|
setSelected(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const leadingBlanks = monthStart.getDay();
|
||||||
|
const daysInMonth = monthEnd.getDate();
|
||||||
|
const selectedTasks = selected ? byDay.get(selected) ?? [] : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="font-display text-xl font-semibold flex-1">
|
||||||
|
{monthStart.toLocaleDateString(undefined, { month: "long", year: "numeric" })}
|
||||||
|
</h2>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => nav(-1)} aria-label="Previous month">
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline" size="sm"
|
||||||
|
onClick={() => { setYear(today.getFullYear()); setMonth(today.getMonth()); setSelected(todayIso); }}
|
||||||
|
>
|
||||||
|
Today
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => nav(1)} aria-label="Next month">
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(blooming.length > 0 || harvesting.length > 0) && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 text-xs">
|
||||||
|
{harvesting.map((t) => (
|
||||||
|
<Badge key={`h-${t.id}`} variant="outline" className="gap-1 border-[hsl(var(--leaf)/.5)] text-[hsl(var(--leaf))]">
|
||||||
|
<Apple className="h-3 w-3" /> {t.commonName} ready to harvest
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{blooming.map((t) => (
|
||||||
|
<Badge key={`b-${t.id}`} variant="outline" className="gap-1 border-pink-300 text-pink-700">
|
||||||
|
<Flower2 className="h-3 w-3" /> {t.commonName} in bloom
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{overdue.length > 0 && (
|
||||||
|
<Link
|
||||||
|
href="/tasks"
|
||||||
|
className="flex items-center gap-2 text-sm rounded-lg border border-[hsl(var(--ember)/.4)] bg-[hsl(var(--ember)/.06)] px-3 py-2 text-[hsl(var(--ember))] hover:bg-[hsl(var(--ember)/.1)] transition-colors"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||||
|
{overdue.length} {overdue.length === 1 ? "task is" : "tasks are"} overdue — see Reminders
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-card overflow-hidden">
|
||||||
|
<div className="grid grid-cols-7 border-b bg-muted/40">
|
||||||
|
{WEEKDAYS.map((w) => (
|
||||||
|
<div key={w} className="px-2 py-1.5 text-xs font-medium text-muted-foreground text-center">{w}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7">
|
||||||
|
{Array.from({ length: leadingBlanks }).map((_, i) => (
|
||||||
|
<div key={`blank-${i}`} className="min-h-[72px] border-b border-r bg-muted/20" />
|
||||||
|
))}
|
||||||
|
{Array.from({ length: daysInMonth }).map((_, i) => {
|
||||||
|
const day = i + 1;
|
||||||
|
const key = iso(new Date(year, month, day));
|
||||||
|
const dayTasks = byDay.get(key) ?? [];
|
||||||
|
const isToday = key === todayIso;
|
||||||
|
const isSelected = key === selected;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setSelected(key)}
|
||||||
|
className={cn(
|
||||||
|
"min-h-[72px] border-b border-r p-1 text-left align-top transition-colors hover:bg-accent/50",
|
||||||
|
isSelected && "bg-accent/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-5 w-5 items-center justify-center rounded-full text-xs",
|
||||||
|
isToday ? "bg-[hsl(var(--leaf))] text-white font-semibold" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</span>
|
||||||
|
<div className="mt-0.5 space-y-0.5">
|
||||||
|
{dayTasks.slice(0, 3).map((t) => (
|
||||||
|
<div
|
||||||
|
key={`${t.id}-${key}`}
|
||||||
|
className={cn("truncate rounded px-1 py-px text-[10px] leading-4", CATEGORY_STYLES[t.category])}
|
||||||
|
>
|
||||||
|
{t.title}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{dayTasks.length > 3 && (
|
||||||
|
<div className="text-[10px] text-muted-foreground px-1">+{dayTasks.length - 3} more</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SuggestionList
|
||||||
|
month={month + 1}
|
||||||
|
year={year}
|
||||||
|
monthLabel={monthStart.toLocaleDateString(undefined, { month: "long" })}
|
||||||
|
plantNames={plantNames}
|
||||||
|
existingTitles={tasks.map((t) => t.title)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground mb-2">
|
||||||
|
{new Date(`${selected}T00:00:00`).toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" })}
|
||||||
|
</h3>
|
||||||
|
{selectedTasks.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground border rounded-lg px-4 py-3">Nothing scheduled.</p>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border bg-card divide-y">
|
||||||
|
{selectedTasks.map((t) => {
|
||||||
|
const isCurrentDue = iso(new Date(t.nextDue)) === selected;
|
||||||
|
return (
|
||||||
|
<div key={t.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||||
|
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", CATEGORY_STYLES[t.category])}>
|
||||||
|
{t.category.toLowerCase()}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{t.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{describeRecurrence(t.recurrence, t.intervalDays, t.month)}
|
||||||
|
{t.linkedTo && ` · ${t.linkedTo}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isCurrentDue && <CompleteTaskButton taskId={t.id} taskTitle={t.title} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,24 +24,33 @@ const LOG_TYPE_ORDER: PlantLogType[] = [
|
|||||||
"OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED",
|
"OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const UNIT_SUGGESTIONS = ["lbs", "oz", "each", "pints", "quarts", "gallons", "bunches", "heads", "cups"];
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
type: z.nativeEnum(PlantLogType),
|
type: z.nativeEnum(PlantLogType),
|
||||||
date: z.string().optional(),
|
date: z.string().optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
quantity: z.string().optional(),
|
amount: z.string().optional(), // form keeps strings; parsed on submit
|
||||||
|
unit: z.string().optional(),
|
||||||
});
|
});
|
||||||
type FormValues = z.infer<typeof schema>;
|
type FormValues = z.infer<typeof schema>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
plantId: string;
|
plantId: string;
|
||||||
plantName: string;
|
plantName: string;
|
||||||
|
locations: { id: string; name: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddLogButton({ plantId, plantName }: Props) {
|
const NO_LOCATION = "__none__";
|
||||||
|
|
||||||
|
export function AddLogButton({ plantId, plantName, locations }: Props) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [logType, setLogType] = useState<PlantLogType>("OBSERVATION");
|
const [logType, setLogType] = useState<PlantLogType>("OBSERVATION");
|
||||||
|
const [toPantry, setToPantry] = useState(false);
|
||||||
|
const [pantryLocationId, setPantryLocationId] = useState<string>(NO_LOCATION);
|
||||||
|
const [bestBefore, setBestBefore] = useState("");
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
@@ -51,21 +60,50 @@ export function AddLogButton({ plantId, plantName }: Props) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function resetAll() {
|
||||||
|
form.reset({ type: "OBSERVATION", date: new Date().toISOString().split("T")[0] });
|
||||||
|
setLogType("OBSERVATION");
|
||||||
|
setToPantry(false);
|
||||||
|
setPantryLocationId(NO_LOCATION);
|
||||||
|
setBestBefore("");
|
||||||
|
}
|
||||||
|
|
||||||
async function onSubmit(values: FormValues) {
|
async function onSubmit(values: FormValues) {
|
||||||
const res = await fetch(`/api/plants/${plantId}/logs`, {
|
const isHarvest = values.type === "HARVEST";
|
||||||
|
const amount = isHarvest && values.amount ? parseFloat(values.amount) : null;
|
||||||
|
if (isHarvest && values.amount && (amount == null || isNaN(amount) || amount < 0)) {
|
||||||
|
toast({ title: "Enter the amount as a number", description: "e.g. 2 or 2.5 — pick a unit next to it.", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`/api/v1/plants/${plantId}/logs`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(values),
|
body: JSON.stringify({
|
||||||
|
type: values.type,
|
||||||
|
date: values.date,
|
||||||
|
notes: values.notes,
|
||||||
|
amount: amount != null && !isNaN(amount) ? amount : undefined,
|
||||||
|
unit: isHarvest ? values.unit || undefined : undefined,
|
||||||
|
pantry: isHarvest && toPantry
|
||||||
|
? {
|
||||||
|
locationId: pantryLocationId === NO_LOCATION ? null : pantryLocationId,
|
||||||
|
bestBefore: bestBefore || null,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
toast({ title: "Error", description: err.error ?? "Could not save log entry.", variant: "destructive" });
|
toast({ title: "Error", description: err.error ?? "Could not save log entry.", variant: "destructive" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast({ title: "Log entry saved!" });
|
toast({
|
||||||
|
title: "Log entry saved!",
|
||||||
|
description: isHarvest && toPantry ? "Also added to Pantry & Freezer." : undefined,
|
||||||
|
});
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
form.reset({ type: "OBSERVATION", date: new Date().toISOString().split("T")[0] });
|
resetAll();
|
||||||
setLogType("OBSERVATION");
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,10 +148,67 @@ export function AddLogButton({ plantId, plantName }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{logType === "HARVEST" && (
|
{logType === "HARVEST" && (
|
||||||
<div className="space-y-1.5">
|
<>
|
||||||
<Label htmlFor="quantity">How much did you harvest?</Label>
|
<div className="space-y-1.5">
|
||||||
<Input id="quantity" placeholder="2 lbs, 1 gallon, a big handful…" {...form.register("quantity")} />
|
<Label>How much did you harvest?</Label>
|
||||||
</div>
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
min="0"
|
||||||
|
placeholder="2.5"
|
||||||
|
className="w-24"
|
||||||
|
{...form.register("amount")}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
list="harvest-units"
|
||||||
|
placeholder="lbs, each, quarts…"
|
||||||
|
className="flex-1"
|
||||||
|
{...form.register("unit")}
|
||||||
|
/>
|
||||||
|
<datalist id="harvest-units">
|
||||||
|
{UNIT_SUGGESTIONS.map((u) => <option key={u} value={u} />)}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 rounded-md border p-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 accent-[hsl(var(--leaf))]"
|
||||||
|
checked={toPantry}
|
||||||
|
onChange={(e) => setToPantry(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Add to Pantry & Freezer
|
||||||
|
</label>
|
||||||
|
{toPantry && (
|
||||||
|
<div className="space-y-2 pt-1">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Where will it be stored?</Label>
|
||||||
|
<Select value={pantryLocationId} onValueChange={setPantryLocationId}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NO_LOCATION}>No specific spot</SelectItem>
|
||||||
|
{locations.map((l) => (
|
||||||
|
<SelectItem key={l.id} value={l.id}>{l.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="best-before" className="text-xs">Best before (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="best-before"
|
||||||
|
type="date"
|
||||||
|
value={bestBefore}
|
||||||
|
onChange={(e) => setBestBefore(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
// The garden "shelf": every plant visible as a photo card with one-tap
|
||||||
|
// logging. Search + chips replace the old collapse trees.
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Leaf, MapPin, CalendarDays, ChevronDown, ChevronRight, Users } from "lucide-react";
|
import { Leaf, MapPin, Search, Users } from "lucide-react";
|
||||||
import { formatDate } from "@/lib/utils";
|
|
||||||
import { CATEGORY_LABELS } from "@/lib/garden/categories";
|
|
||||||
import { GroupLogButton } from "@/components/garden/group-log-button";
|
import { GroupLogButton } from "@/components/garden/group-log-button";
|
||||||
import { LocationLogButton } from "@/components/garden/location-log-button";
|
import { LocationLogButton } from "@/components/garden/location-log-button";
|
||||||
|
import { QuickLogActions } from "@/components/garden/quick-log-actions";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type Plant = {
|
type Plant = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,41 +28,55 @@ type Plant = {
|
|||||||
logs: { date: Date; type: string }[];
|
logs: { date: Date; type: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Group = { id: string; name: string; _count: { plants: number } };
|
||||||
|
type LocationOption = { id: string; name: string };
|
||||||
|
|
||||||
// Dual-read (Release A): prefer the Location name, fall back to the legacy zone.
|
// Dual-read (Release A): prefer the Location name, fall back to the legacy zone.
|
||||||
function placeName(p: Plant): string | null {
|
function placeName(p: Plant): string | null {
|
||||||
return p.location?.name ?? p.zone ?? null;
|
return p.location?.name ?? p.zone ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Group = {
|
const LAST_VERB: Record<string, string> = {
|
||||||
id: string;
|
OBSERVATION: "noted",
|
||||||
name: string;
|
CARE: "tended",
|
||||||
_count: { plants: number };
|
HARVEST: "picked",
|
||||||
|
TREATMENT: "treated",
|
||||||
|
TRANSPLANT: "moved",
|
||||||
|
DIED: "died",
|
||||||
};
|
};
|
||||||
|
|
||||||
type ViewMode = "type" | "location";
|
function lastActivity(p: Plant): string | null {
|
||||||
|
const log = p.logs[0];
|
||||||
// Cluster plants that share the same commonName + variety into one row.
|
if (!log) return null;
|
||||||
function clusterByVariety(plants: Plant[]) {
|
const days = Math.floor((Date.now() - new Date(log.date).getTime()) / 86_400_000);
|
||||||
const map = new Map<string, Plant[]>();
|
const when = days <= 0 ? "today" : days === 1 ? "yesterday" : `${days}d ago`;
|
||||||
for (const p of plants) {
|
return `${LAST_VERB[log.type] ?? "logged"} ${when}`;
|
||||||
const key = `${p.commonName}||${p.variety ?? ""}`;
|
|
||||||
if (!map.has(key)) map.set(key, []);
|
|
||||||
map.get(key)!.push(p);
|
|
||||||
}
|
|
||||||
return Array.from(map.values());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GardenGrid({ plants, groups }: { plants: Plant[]; groups: Group[] }) {
|
export function GardenGrid({ plants, groups, locations }: {
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
plants: Plant[];
|
||||||
const [view, setView] = useState<ViewMode>("type");
|
groups: Group[];
|
||||||
|
locations: LocationOption[];
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [chip, setChip] = useState<string>("all"); // "all" | "place" | group id
|
||||||
|
|
||||||
function toggle(id: string) {
|
const filtered = useMemo(() => {
|
||||||
setExpanded((prev) => {
|
const q = search.trim().toLowerCase();
|
||||||
const next = new Set(prev);
|
let list = plants;
|
||||||
next.has(id) ? next.delete(id) : next.add(id);
|
if (chip !== "all" && chip !== "place") list = list.filter((p) => p.group?.id === chip);
|
||||||
return next;
|
if (q) {
|
||||||
});
|
list = list.filter((p) =>
|
||||||
}
|
p.commonName.toLowerCase().includes(q) ||
|
||||||
|
p.variety?.toLowerCase().includes(q) ||
|
||||||
|
p.species?.toLowerCase().includes(q) ||
|
||||||
|
placeName(p)?.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [...list].sort((a, b) => a.commonName.localeCompare(b.commonName));
|
||||||
|
}, [plants, search, chip]);
|
||||||
|
|
||||||
|
const activeGroup = groups.find((g) => g.id === chip);
|
||||||
|
|
||||||
if (plants.length === 0) {
|
if (plants.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -74,137 +89,78 @@ export function GardenGrid({ plants, groups }: { plants: Plant[]; groups: Group[
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-1 p-1 bg-muted rounded-lg w-fit">
|
<div className="relative">
|
||||||
<Button size="sm" variant={view === "type" ? "secondary" : "ghost"} className="h-7 text-xs" onClick={() => setView("type")}>
|
<Search className="h-4 w-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||||
By type
|
<Input
|
||||||
</Button>
|
value={search}
|
||||||
<Button size="sm" variant={view === "location" ? "secondary" : "ghost"} className="h-7 text-xs" onClick={() => setView("location")}>
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
By location
|
placeholder="Find a plant…"
|
||||||
</Button>
|
className="pl-9"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{view === "type" ? (
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
<TypeView plants={plants} groups={groups} expanded={expanded} toggle={toggle} />
|
<Chip active={chip === "all"} onClick={() => setChip("all")}>
|
||||||
) : (
|
All · {plants.length}
|
||||||
<LocationView plants={plants} expanded={expanded} toggle={toggle} />
|
</Chip>
|
||||||
)}
|
{groups.filter((g) => g._count.plants > 0).map((g) => (
|
||||||
</div>
|
<Chip key={g.id} active={chip === g.id} onClick={() => setChip(g.id)}>
|
||||||
);
|
{g.name}
|
||||||
}
|
</Chip>
|
||||||
|
))}
|
||||||
function TypeView({ plants, groups, expanded, toggle }: {
|
<Chip active={chip === "place"} onClick={() => setChip("place")}>
|
||||||
plants: Plant[];
|
<MapPin className="h-3 w-3" /> By place
|
||||||
groups: Group[];
|
</Chip>
|
||||||
expanded: Set<string>;
|
{activeGroup && (
|
||||||
toggle: (id: string) => void;
|
<span className="ml-auto">
|
||||||
}) {
|
<GroupLogButton groupId={activeGroup.id} groupName={activeGroup.name} />
|
||||||
const ungrouped = plants.filter((p) => !p.group);
|
|
||||||
const grouped = groups.map((g) => ({
|
|
||||||
...g,
|
|
||||||
plants: plants.filter((p) => p.group?.id === g.id),
|
|
||||||
})).filter((g) => g.plants.length > 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{grouped.map((g) => {
|
|
||||||
const isOpen = expanded.has(g.id);
|
|
||||||
const clusters = clusterByVariety(g.plants);
|
|
||||||
return (
|
|
||||||
<div key={g.id} className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => toggle(g.id)}
|
|
||||||
className="flex items-center gap-2 text-left hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
{isOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
|
||||||
<Users className="h-4 w-4 text-[hsl(var(--leaf))]" />
|
|
||||||
<span className="font-display font-semibold">{g.name}</span>
|
|
||||||
<span className="text-sm text-muted-foreground">· {g.plants.length} {g.plants.length === 1 ? "plant" : "plants"}</span>
|
|
||||||
</button>
|
|
||||||
<GroupLogButton groupId={g.id} groupName={g.name} />
|
|
||||||
</div>
|
|
||||||
{isOpen ? (
|
|
||||||
<div className="pl-6 space-y-2">
|
|
||||||
{clusters.map((cluster) => (
|
|
||||||
<VarietyRow key={`${cluster[0].commonName}||${cluster[0].variety}`} plants={cluster} expanded={expanded} toggle={toggle} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button onClick={() => toggle(g.id)} className="pl-6 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
|
||||||
{clusters.map((c) => c[0].variety ? `${c[0].commonName} (${c[0].variety})` : c[0].commonName).join(", ")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{ungrouped.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{grouped.length > 0 && <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Other plants</p>}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{clusterByVariety(ungrouped).map((cluster) => (
|
|
||||||
<VarietyRow key={`${cluster[0].commonName}||${cluster[0].variety}`} plants={cluster} expanded={expanded} toggle={toggle} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A single variety that may span multiple locations.
|
|
||||||
function VarietyRow({ plants, expanded, toggle }: {
|
|
||||||
plants: Plant[];
|
|
||||||
expanded: Set<string>;
|
|
||||||
toggle: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
const rep = plants[0];
|
|
||||||
const key = `variety||${rep.commonName}||${rep.variety ?? ""}`;
|
|
||||||
const isOpen = expanded.has(key);
|
|
||||||
|
|
||||||
// Collapsible row with location chips.
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<button
|
|
||||||
onClick={() => toggle(key)}
|
|
||||||
className="flex items-start gap-2 text-left hover:text-foreground transition-colors w-full"
|
|
||||||
>
|
|
||||||
{isOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" /> : <ChevronRight className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<span className="font-medium text-sm">
|
|
||||||
{rep.commonName}
|
|
||||||
{rep.variety && <span className="text-muted-foreground font-normal"> · {rep.variety}</span>}
|
|
||||||
</span>
|
</span>
|
||||||
{!isOpen && (
|
)}
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
</div>
|
||||||
{plants.map((p) => (
|
|
||||||
<Badge key={p.id} variant="outline" className="text-xs gap-1">
|
|
||||||
<MapPin className="h-2.5 w-2.5" />
|
|
||||||
{placeName(p) ?? "No location"}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isOpen && (
|
{chip === "place" ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pl-6">
|
<PlaceSections plants={filtered} locations={locations} />
|
||||||
{plants.map((p) => <PlantCard key={p.id} plant={p} />)}
|
) : (
|
||||||
</div>
|
<CardGrid plants={filtered} locations={locations} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||||||
|
No plants match{search ? ` “${search.trim()}”` : ""}.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LocationView({ plants, expanded, toggle }: {
|
function Chip({ active, onClick, children }: {
|
||||||
plants: Plant[];
|
active: boolean; onClick: () => void; children: React.ReactNode;
|
||||||
expanded: Set<string>;
|
|
||||||
toggle: (id: string) => void;
|
|
||||||
}) {
|
}) {
|
||||||
// Group by the shared Location (id). Plants not yet on the spine but with a
|
return (
|
||||||
// legacy zone string still group by that name so nothing disappears mid-migration.
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-[hsl(var(--leaf))] text-white border-[hsl(var(--leaf))]"
|
||||||
|
: "bg-background hover:bg-accent text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardGrid({ plants, locations }: { plants: Plant[]; locations: LocationOption[] }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||||
|
{plants.map((p) => <PlantCard key={p.id} plant={p} locations={locations} />)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaceSections({ plants, locations }: { plants: Plant[]; locations: LocationOption[] }) {
|
||||||
type Place = { key: string; locationId: string | null; name: string; plants: Plant[] };
|
type Place = { key: string; locationId: string | null; name: string; plants: Plant[] };
|
||||||
const byKey = new Map<string, Place>();
|
const byKey = new Map<string, Place>();
|
||||||
const noPlace: Plant[] = [];
|
const noPlace: Plant[] = [];
|
||||||
@@ -216,110 +172,81 @@ function LocationView({ plants, expanded, toggle }: {
|
|||||||
if (!byKey.has(key)) byKey.set(key, { key, locationId: p.locationId, name, plants: [] });
|
if (!byKey.has(key)) byKey.set(key, { key, locationId: p.locationId, name, plants: [] });
|
||||||
byKey.get(key)!.plants.push(p);
|
byKey.get(key)!.plants.push(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
const places = Array.from(byKey.values()).sort((a, b) => a.name.localeCompare(b.name));
|
const places = Array.from(byKey.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{places.map((place) => {
|
{places.map((place) => (
|
||||||
const isOpen = expanded.has(place.key);
|
<div key={place.key} className="space-y-2">
|
||||||
return (
|
<div className="flex items-center gap-2">
|
||||||
<div key={place.key} className="space-y-2">
|
<MapPin className="h-4 w-4 text-[hsl(var(--leaf))]" />
|
||||||
<div className="flex items-center gap-2">
|
<span className="font-display font-semibold">{place.name}</span>
|
||||||
<button
|
<span className="text-sm text-muted-foreground">
|
||||||
onClick={() => toggle(place.key)}
|
· {place.plants.length} {place.plants.length === 1 ? "plant" : "plants"}
|
||||||
className="flex items-center gap-2 text-left hover:text-foreground transition-colors"
|
</span>
|
||||||
>
|
{place.locationId && <LocationLogButton locationId={place.locationId} locationName={place.name} />}
|
||||||
{isOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
|
||||||
<MapPin className="h-4 w-4 text-[hsl(var(--leaf))]" />
|
|
||||||
<span className="font-display font-semibold">{place.name}</span>
|
|
||||||
<span className="text-sm text-muted-foreground">· {place.plants.length} {place.plants.length === 1 ? "plant" : "plants"}</span>
|
|
||||||
</button>
|
|
||||||
{place.locationId && <LocationLogButton locationId={place.locationId} locationName={place.name} />}
|
|
||||||
</div>
|
|
||||||
{isOpen ? (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pl-6">
|
|
||||||
{place.plants.map((p) => <PlantCard key={p.id} plant={p} />)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button onClick={() => toggle(place.key)} className="pl-6 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
|
||||||
{place.plants.map((p) => p.variety ? `${p.commonName} (${p.variety})` : p.commonName).join(", ")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
<CardGrid plants={place.plants} locations={locations} />
|
||||||
})}
|
</div>
|
||||||
|
))}
|
||||||
{noPlace.length > 0 && (
|
{noPlace.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{places.length > 0 && <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">No location set</p>}
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">No location set</p>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<CardGrid plants={noPlace} locations={locations} />
|
||||||
{noPlace.map((p) => <PlantCard key={p.id} plant={p} />)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlantCard({ plant }: { plant: Plant }) {
|
function PlantCard({ plant, locations }: { plant: Plant; locations: LocationOption[] }) {
|
||||||
|
const place = placeName(plant);
|
||||||
|
const activity = lastActivity(plant);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/garden/${plant.id}`}>
|
<div className="rounded-lg border bg-card overflow-hidden hover:shadow-md transition-shadow flex flex-col">
|
||||||
<Card className="hover:shadow-md transition-shadow h-full cursor-pointer overflow-hidden">
|
<Link href={`/garden/${plant.id}`} className="block group">
|
||||||
{plant.imageUrl && (
|
{plant.imageUrl ? (
|
||||||
<div className="h-36 w-full overflow-hidden bg-muted">
|
<div className="h-28 w-full overflow-hidden bg-muted">
|
||||||
<img
|
<img
|
||||||
src={plant.imageUrl}
|
src={plant.imageUrl}
|
||||||
alt={plant.commonName}
|
alt={plant.commonName}
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover group-hover:scale-[1.03] transition-transform"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
{!plant.imageUrl && (
|
<div className="h-28 w-full bg-[hsl(var(--leaf)/.08)] flex items-center justify-center">
|
||||||
<div className="h-36 w-full bg-[hsl(var(--leaf)/.08)] flex items-center justify-center">
|
<Leaf className="h-9 w-9 text-[hsl(var(--leaf)/.3)]" />
|
||||||
<Leaf className="h-10 w-10 text-[hsl(var(--leaf)/.3)]" />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<CardContent className="pt-3 pb-4 space-y-2">
|
</Link>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="p-2.5 pt-2 flex-1 flex flex-col gap-1.5">
|
||||||
<div className="min-w-0">
|
<Link href={`/garden/${plant.id}`} className="min-w-0">
|
||||||
<p className="font-medium truncate">
|
<p className="font-medium text-sm truncate leading-tight">
|
||||||
{plant.commonName}
|
{plant.commonName}
|
||||||
{plant.variety && <span className="text-muted-foreground font-normal"> · {plant.variety}</span>}
|
{plant.variety && <span className="text-muted-foreground font-normal"> · {plant.variety}</span>}
|
||||||
</p>
|
</p>
|
||||||
{plant.species && <p className="text-xs text-muted-foreground italic truncate">{plant.species}</p>}
|
</Link>
|
||||||
</div>
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
{place && (
|
||||||
{CATEGORY_LABELS[plant.category as keyof typeof CATEGORY_LABELS]}
|
<span className="inline-flex items-center gap-1">
|
||||||
</Badge>
|
<MapPin className="h-3 w-3 shrink-0" />
|
||||||
</div>
|
{place}
|
||||||
|
</span>
|
||||||
<div className="space-y-1 text-xs text-muted-foreground">
|
)}
|
||||||
{placeName(plant) && (
|
{place && activity && " · "}
|
||||||
<div className="flex items-center gap-1.5">
|
{activity}
|
||||||
<MapPin className="h-3 w-3 shrink-0" />
|
</p>
|
||||||
<span className="truncate">{placeName(plant)}</span>
|
{plant.group && (
|
||||||
</div>
|
<Badge variant="outline" className="w-fit text-[10px] gap-1 py-0">
|
||||||
)}
|
<Users className="h-2.5 w-2.5" />
|
||||||
{plant.plantedAt && (
|
{plant.group.name}
|
||||||
<div className="flex items-center gap-1.5">
|
</Badge>
|
||||||
<CalendarDays className="h-3 w-3 shrink-0" />
|
)}
|
||||||
<span>Planted {formatDate(plant.plantedAt)}</span>
|
<div className="mt-auto pt-1">
|
||||||
</div>
|
<QuickLogActions plantId={plant.id} plantName={plant.commonName} locations={locations} />
|
||||||
)}
|
</div>
|
||||||
{plant.logs[0] && (
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
</div>
|
||||||
<Leaf className="h-3 w-3 shrink-0 text-[hsl(var(--leaf))]" />
|
|
||||||
<span>Last {plant.logs[0].type.toLowerCase()} {formatDate(plant.logs[0].date)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-1 text-xs text-muted-foreground/60">
|
|
||||||
{plant._count.logs} log {plant._count.logs === 1 ? "entry" : "entries"}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
68
src/components/garden/garden-journal.tsx
Normal file
68
src/components/garden/garden-journal.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// "This week in your garden" — recent log entries across all plants,
|
||||||
|
// rendered like diary lines. Server component; data comes from the page.
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Droplets, Eye, ShoppingBasket, Sprout, Syringe, Shovel } from "lucide-react";
|
||||||
|
|
||||||
|
export type JournalEntry = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
date: Date;
|
||||||
|
notes: string | null;
|
||||||
|
quantity: string | null;
|
||||||
|
amount: number | null;
|
||||||
|
unit: string | null;
|
||||||
|
plant: { id: string; commonName: string; variety: string | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
const ICONS: Record<string, React.ReactNode> = {
|
||||||
|
OBSERVATION: <Eye className="h-4 w-4 text-muted-foreground" />,
|
||||||
|
CARE: <Droplets className="h-4 w-4 text-sky-600" />,
|
||||||
|
HARVEST: <ShoppingBasket className="h-4 w-4 text-[hsl(var(--leaf))]" />,
|
||||||
|
TREATMENT: <Syringe className="h-4 w-4 text-amber-600" />,
|
||||||
|
TRANSPLANT: <Shovel className="h-4 w-4 text-muted-foreground" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
function dayLabel(date: Date): string {
|
||||||
|
const d = new Date(date);
|
||||||
|
const days = Math.floor((Date.now() - d.getTime()) / 86_400_000);
|
||||||
|
if (days <= 0) return "Today";
|
||||||
|
if (days === 1) return "Yesterday";
|
||||||
|
if (days < 7) return d.toLocaleDateString(undefined, { weekday: "short" });
|
||||||
|
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryText(e: JournalEntry): string {
|
||||||
|
const bits: string[] = [];
|
||||||
|
if (e.type === "HARVEST") {
|
||||||
|
const qty = e.amount != null ? `${e.amount} ${e.unit ?? ""}`.trim() : e.quantity;
|
||||||
|
bits.push(qty ? `picked ${qty}` : "harvested");
|
||||||
|
}
|
||||||
|
if (e.notes) bits.push(e.notes);
|
||||||
|
if (bits.length === 0) bits.push(e.type.toLowerCase());
|
||||||
|
return bits.join(" — ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GardenJournal({ entries }: { entries: JournalEntry[] }) {
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display text-lg font-semibold mb-3">This week in your garden</h2>
|
||||||
|
<div className="rounded-lg border bg-card divide-y">
|
||||||
|
{entries.map((e) => (
|
||||||
|
<div key={e.id} className="flex items-center gap-3 px-4 py-2.5 text-sm">
|
||||||
|
<span className="shrink-0">{ICONS[e.type] ?? <Sprout className="h-4 w-4 text-muted-foreground" />}</span>
|
||||||
|
<p className="flex-1 min-w-0 truncate">
|
||||||
|
<Link href={`/garden/${e.plant.id}`} className="font-medium hover:underline underline-offset-2">
|
||||||
|
{e.plant.commonName}
|
||||||
|
{e.plant.variety && <span className="text-muted-foreground font-normal"> ({e.plant.variety})</span>}
|
||||||
|
</Link>
|
||||||
|
<span className="text-muted-foreground"> — {entryText(e)}</span>
|
||||||
|
</p>
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">{dayLabel(e.date)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,9 +14,11 @@ import {
|
|||||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Plus, Pencil } from "lucide-react";
|
import { Plus, Pencil, Search, X, ExternalLink } from "lucide-react";
|
||||||
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
|
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
|
||||||
|
|
||||||
|
type UwMatch = { id: number; title: string; url: string; excerpt: string };
|
||||||
|
|
||||||
export type PlantTypeFields = {
|
export type PlantTypeFields = {
|
||||||
id?: string;
|
id?: string;
|
||||||
commonName: string;
|
commonName: string;
|
||||||
@@ -32,6 +34,8 @@ export type PlantTypeFields = {
|
|||||||
toxicToPets: boolean | null;
|
toxicToPets: boolean | null;
|
||||||
edibleParts: string | null;
|
edibleParts: string | null;
|
||||||
spacing: string | null;
|
spacing: string | null;
|
||||||
|
uwExtensionUrl: string | null;
|
||||||
|
uwExtensionTitle: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,7 +47,8 @@ function blank(): PlantTypeFields {
|
|||||||
commonName: "", species: null, category: "OTHER", careNotes: null,
|
commonName: "", species: null, category: "OTHER", careNotes: null,
|
||||||
sun: null, water: null, bloomStart: null, bloomEnd: null,
|
sun: null, water: null, bloomStart: null, bloomEnd: null,
|
||||||
harvestStart: null, harvestEnd: null, toxicToPets: null,
|
harvestStart: null, harvestEnd: null, toxicToPets: null,
|
||||||
edibleParts: null, spacing: null, notes: null,
|
edibleParts: null, spacing: null, uwExtensionUrl: null,
|
||||||
|
uwExtensionTitle: null, notes: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +78,8 @@ function PlantTypeDialog({
|
|||||||
const [f, setF] = useState<PlantTypeFields>(initial);
|
const [f, setF] = useState<PlantTypeFields>(initial);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [uwMatches, setUwMatches] = useState<UwMatch[] | null>(null);
|
||||||
|
const [uwBusy, setUwBusy] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -80,6 +87,28 @@ function PlantTypeDialog({
|
|||||||
setF((p) => ({ ...p, [key]: value }));
|
setF((p) => ({ ...p, [key]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function searchUw() {
|
||||||
|
const q = f.commonName.trim();
|
||||||
|
if (!q) { setError("Enter the plant name first, then look it up."); return; }
|
||||||
|
setError("");
|
||||||
|
setUwBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/plant-types/uw-search?q=${encodeURIComponent(q)}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error ?? "Lookup failed");
|
||||||
|
setUwMatches(data);
|
||||||
|
} catch (err) {
|
||||||
|
toast({ title: "UW Extension lookup failed", description: String(err).replace("Error: ", ""), variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setUwBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickUw(m: UwMatch) {
|
||||||
|
setF((p) => ({ ...p, uwExtensionUrl: m.url, uwExtensionTitle: m.title }));
|
||||||
|
setUwMatches(null);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!f.commonName.trim()) { setError("Please enter the plant name."); return; }
|
if (!f.commonName.trim()) { setError("Please enter the plant name."); return; }
|
||||||
@@ -95,6 +124,8 @@ function PlantTypeDialog({
|
|||||||
water: f.water ?? "",
|
water: f.water ?? "",
|
||||||
edibleParts: f.edibleParts ?? "",
|
edibleParts: f.edibleParts ?? "",
|
||||||
spacing: f.spacing ?? "",
|
spacing: f.spacing ?? "",
|
||||||
|
uwExtensionUrl: f.uwExtensionUrl ?? "",
|
||||||
|
uwExtensionTitle: f.uwExtensionTitle ?? "",
|
||||||
notes: f.notes ?? "",
|
notes: f.notes ?? "",
|
||||||
toxicToPets: f.toxicToPets,
|
toxicToPets: f.toxicToPets,
|
||||||
};
|
};
|
||||||
@@ -217,6 +248,56 @@ function PlantTypeDialog({
|
|||||||
placeholder="Fruit, young leaves" />
|
placeholder="Fruit, young leaves" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label>UW Extension article</Label>
|
||||||
|
{f.uwExtensionUrl ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm border rounded-md px-3 py-2">
|
||||||
|
<a
|
||||||
|
href={f.uwExtensionUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex-1 min-w-0 truncate underline underline-offset-2 hover:text-foreground text-muted-foreground"
|
||||||
|
>
|
||||||
|
{f.uwExtensionTitle || f.uwExtensionUrl}
|
||||||
|
</a>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Remove link"
|
||||||
|
className="text-muted-foreground hover:text-destructive shrink-0"
|
||||||
|
onClick={() => setF((p) => ({ ...p, uwExtensionUrl: null, uwExtensionTitle: null }))}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={searchUw} disabled={uwBusy} className="gap-1.5">
|
||||||
|
<Search className="h-3.5 w-3.5" />
|
||||||
|
{uwBusy ? "Searching…" : "Look up on UW Extension"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{uwMatches && (
|
||||||
|
<div className="border rounded-md divide-y max-h-48 overflow-y-auto">
|
||||||
|
{uwMatches.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground p-3">
|
||||||
|
No UW Extension articles matched “{f.commonName.trim()}”.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{uwMatches.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => pickUw(m)}
|
||||||
|
className="w-full text-left p-2.5 hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium">{m.title}</p>
|
||||||
|
{m.excerpt && <p className="text-xs text-muted-foreground line-clamp-2">{m.excerpt}</p>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="col-span-2 space-y-1.5">
|
<div className="col-span-2 space-y-1.5">
|
||||||
<Label>Care notes</Label>
|
<Label>Care notes</Label>
|
||||||
<Textarea rows={3} value={f.careNotes ?? ""} onChange={(e) => set("careNotes", e.target.value)}
|
<Textarea rows={3} value={f.careNotes ?? ""} onChange={(e) => set("careNotes", e.target.value)}
|
||||||
|
|||||||
253
src/components/garden/quick-log-actions.tsx
Normal file
253
src/components/garden/quick-log-actions.tsx
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// One-tap logging from a plant card: droplet = watered (CARE), eye = quick
|
||||||
|
// observation note, basket = harvest (amount + optional pantry hand-off).
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Droplets, Eye, ShoppingBasket, Check } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
const UNIT_SUGGESTIONS = ["lbs", "oz", "each", "pints", "quarts", "gallons", "bunches", "heads", "cups"];
|
||||||
|
const NO_LOCATION = "__none__";
|
||||||
|
|
||||||
|
async function postLog(plantId: string, body: Record<string, unknown>) {
|
||||||
|
const res = await fetch(`/api/v1/plants/${plantId}/logs`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error ?? "Could not save");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickLogActions({ plantId, plantName, locations }: {
|
||||||
|
plantId: string;
|
||||||
|
plantName: string;
|
||||||
|
locations: { id: string; name: string }[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [mode, setMode] = useState<"idle" | "noting" | "done">("idle");
|
||||||
|
const [doneLabel, setDoneLabel] = useState("");
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const noteRef = useRef<HTMLInputElement>(null);
|
||||||
|
const doneTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "noting") noteRef.current?.focus();
|
||||||
|
return () => clearTimeout(doneTimer.current);
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
|
function flashDone(label: string) {
|
||||||
|
setDoneLabel(label);
|
||||||
|
setMode("done");
|
||||||
|
doneTimer.current = setTimeout(() => setMode("idle"), 2500);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logWatered() {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await postLog(plantId, { type: "CARE", notes: "Watered" });
|
||||||
|
flashDone("Watered — logged");
|
||||||
|
} catch (err) {
|
||||||
|
toast({ title: "Error", description: String(err).replace("Error: ", ""), variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logNote() {
|
||||||
|
const text = note.trim();
|
||||||
|
if (!text) { setMode("idle"); return; }
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await postLog(plantId, { type: "OBSERVATION", notes: text });
|
||||||
|
setNote("");
|
||||||
|
flashDone("Noted");
|
||||||
|
} catch (err) {
|
||||||
|
toast({ title: "Error", description: String(err).replace("Error: ", ""), variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "done") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-1.5 h-8 rounded-md bg-[hsl(var(--leaf)/.12)] text-[hsl(var(--leaf))] text-xs font-medium">
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
{doneLabel}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "noting") {
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="flex gap-1.5"
|
||||||
|
onSubmit={(e) => { e.preventDefault(); logNote(); }}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
ref={noteRef}
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Escape") { setNote(""); setMode("idle"); } }}
|
||||||
|
placeholder="What did you notice?"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="sm" className="h-8 px-2.5 text-xs" disabled={busy}>
|
||||||
|
{busy ? "…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button
|
||||||
|
variant="outline" size="sm" className="h-8 flex-1"
|
||||||
|
title="Watered" aria-label={`Log watering for ${plantName}`}
|
||||||
|
disabled={busy} onClick={logWatered}
|
||||||
|
>
|
||||||
|
<Droplets className="h-4 w-4 text-sky-600" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline" size="sm" className="h-8 flex-1"
|
||||||
|
title="Note something" aria-label={`Add a note for ${plantName}`}
|
||||||
|
onClick={() => setMode("noting")}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
<QuickHarvestDialog
|
||||||
|
plantId={plantId}
|
||||||
|
plantName={plantName}
|
||||||
|
locations={locations}
|
||||||
|
onLogged={() => flashDone("Harvest logged")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickHarvestDialog({ plantId, plantName, locations, onLogged }: {
|
||||||
|
plantId: string;
|
||||||
|
plantName: string;
|
||||||
|
locations: { id: string; name: string }[];
|
||||||
|
onLogged: () => void;
|
||||||
|
}) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [amount, setAmount] = useState("");
|
||||||
|
const [unit, setUnit] = useState("");
|
||||||
|
const [toPantry, setToPantry] = useState(true);
|
||||||
|
const [locationId, setLocationId] = useState(NO_LOCATION);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const amt = amount ? parseFloat(amount) : null;
|
||||||
|
if (amount && (amt == null || isNaN(amt) || amt < 0)) {
|
||||||
|
toast({ title: "Enter the amount as a number", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await postLog(plantId, {
|
||||||
|
type: "HARVEST",
|
||||||
|
amount: amt ?? undefined,
|
||||||
|
unit: unit.trim() || undefined,
|
||||||
|
pantry: toPantry
|
||||||
|
? { locationId: locationId === NO_LOCATION ? null : locationId }
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
setAmount("");
|
||||||
|
setUnit("");
|
||||||
|
onLogged();
|
||||||
|
} catch (err) {
|
||||||
|
toast({ title: "Error", description: String(err).replace("Error: ", ""), variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline" size="sm"
|
||||||
|
className="h-8 flex-1 border-[hsl(var(--leaf)/.5)]"
|
||||||
|
title="Harvest" aria-label={`Log a harvest for ${plantName}`}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<ShoppingBasket className="h-4 w-4 text-[hsl(var(--leaf))]" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-w-xs">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Harvest — {plantName}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={submit} className="space-y-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="number" step="any" min="0" placeholder="2.5"
|
||||||
|
className="w-24" value={amount} autoFocus
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
list="quick-harvest-units" placeholder="lbs, each…"
|
||||||
|
className="flex-1" value={unit}
|
||||||
|
onChange={(e) => setUnit(e.target.value)}
|
||||||
|
/>
|
||||||
|
<datalist id="quick-harvest-units">
|
||||||
|
{UNIT_SUGGESTIONS.map((u) => <option key={u} value={u} />)}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 rounded-md border p-2.5">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 accent-[hsl(var(--leaf))]"
|
||||||
|
checked={toPantry}
|
||||||
|
onChange={(e) => setToPantry(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Add to Pantry & Freezer
|
||||||
|
</label>
|
||||||
|
{toPantry && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">Stored where?</Label>
|
||||||
|
<Select value={locationId} onValueChange={setLocationId}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NO_LOCATION}>No specific spot</SelectItem>
|
||||||
|
{locations.map((l) => (
|
||||||
|
<SelectItem key={l.id} value={l.id}>{l.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Log harvest"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Package, MapPin, ChevronDown, ChevronRight, Box, ShieldCheck, ShieldAlert, ShieldX,
|
Package, MapPin, ChevronDown, ChevronRight, Box, ShieldCheck, ShieldAlert, ShieldX,
|
||||||
|
Search, Tag,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { warrantyStatus } from "@/lib/inventory/value";
|
import { warrantyStatus } from "@/lib/inventory/value";
|
||||||
|
|
||||||
@@ -37,6 +38,31 @@ function WarrantyBadge({ expiry }: { expiry: string | Date | null }) {
|
|||||||
return <Badge variant="outline" className={`gap-1 text-[10px] ${map.cls}`}><Icon className="h-2.5 w-2.5" />{map.label}</Badge>;
|
return <Badge variant="outline" className={`gap-1 text-[10px] ${map.cls}`}><Icon className="h-2.5 w-2.5" />{map.label}</Badge>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ItemThumb({ item, hasKids }: { item: GridItem; hasKids: boolean }) {
|
||||||
|
if (item.imageUrl) {
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
return <img src={item.imageUrl} alt="" className="h-8 w-8 rounded object-cover shrink-0" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="h-8 w-8 rounded bg-[hsl(var(--leaf)/.08)] flex items-center justify-center shrink-0">
|
||||||
|
{hasKids ? <Box className="h-4 w-4 text-[hsl(var(--leaf)/.5)]" /> : <Package className="h-4 w-4 text-[hsl(var(--leaf)/.5)]" />}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemBadges({ item }: { item: GridItem }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
{item.labels.slice(0, 3).map((l) => (
|
||||||
|
<Badge key={l.label.id} variant="secondary" className="text-[10px]">{l.label.name}</Badge>
|
||||||
|
))}
|
||||||
|
<WarrantyBadge expiry={item.warrantyExpiry} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tree row — used when grouping by location with no active search. Shows nested
|
||||||
|
// container contents under an expand toggle.
|
||||||
function ItemRow({ item, childrenByParent, depth }: {
|
function ItemRow({ item, childrenByParent, depth }: {
|
||||||
item: GridItem;
|
item: GridItem;
|
||||||
childrenByParent: Map<string, GridItem[]>;
|
childrenByParent: Map<string, GridItem[]>;
|
||||||
@@ -55,14 +81,7 @@ function ItemRow({ item, childrenByParent, depth }: {
|
|||||||
</button>
|
</button>
|
||||||
) : <span className="w-4 shrink-0" />}
|
) : <span className="w-4 shrink-0" />}
|
||||||
|
|
||||||
{item.imageUrl ? (
|
<ItemThumb item={item} hasKids={kids.length > 0} />
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img src={item.imageUrl} alt="" className="h-8 w-8 rounded object-cover shrink-0" />
|
|
||||||
) : (
|
|
||||||
<span className="h-8 w-8 rounded bg-[hsl(var(--leaf)/.08)] flex items-center justify-center shrink-0">
|
|
||||||
{kids.length > 0 ? <Box className="h-4 w-4 text-[hsl(var(--leaf)/.5)]" /> : <Package className="h-4 w-4 text-[hsl(var(--leaf)/.5)]" />}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Link href={`/inventory/${item.id}`} className="min-w-0 flex-1">
|
<Link href={`/inventory/${item.id}`} className="min-w-0 flex-1">
|
||||||
<span className="font-medium text-sm">{item.name}</span>
|
<span className="font-medium text-sm">{item.name}</span>
|
||||||
@@ -70,12 +89,7 @@ function ItemRow({ item, childrenByParent, depth }: {
|
|||||||
{kids.length > 0 && <span className="text-xs text-muted-foreground"> · holds {kids.length}</span>}
|
{kids.length > 0 && <span className="text-xs text-muted-foreground"> · holds {kids.length}</span>}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<ItemBadges item={item} />
|
||||||
{item.labels.slice(0, 3).map((l) => (
|
|
||||||
<Badge key={l.label.id} variant="secondary" className="text-[10px]">{l.label.name}</Badge>
|
|
||||||
))}
|
|
||||||
<WarrantyBadge expiry={item.warrantyExpiry} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{open && kids.map((k) => (
|
{open && kids.map((k) => (
|
||||||
<ItemRow key={k.id} item={k} childrenByParent={childrenByParent} depth={depth + 1} />
|
<ItemRow key={k.id} item={k} childrenByParent={childrenByParent} depth={depth + 1} />
|
||||||
@@ -84,7 +98,101 @@ function ItemRow({ item, childrenByParent, depth }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flat row — used for search results and label grouping, where the container
|
||||||
|
// tree doesn't apply. Shows a location/container hint instead of nesting.
|
||||||
|
function FlatRow({ item, hint }: { item: GridItem; hint: string | null }) {
|
||||||
|
const qty = Number(item.quantity);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2.5 hover:bg-accent transition-colors">
|
||||||
|
<span className="w-4 shrink-0" />
|
||||||
|
<ItemThumb item={item} hasKids={item._count.containedItems > 0} />
|
||||||
|
<Link href={`/inventory/${item.id}`} className="min-w-0 flex-1">
|
||||||
|
<span className="font-medium text-sm">{item.name}</span>
|
||||||
|
{qty !== 1 && <span className="text-xs text-muted-foreground"> · {qty}{item.unit ? ` ${item.unit}` : ""}</span>}
|
||||||
|
{hint && <span className="block text-xs text-muted-foreground truncate">{hint}</span>}
|
||||||
|
</Link>
|
||||||
|
<ItemBadges item={item} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Group({ icon: Icon, name, count, children }: {
|
||||||
|
icon: typeof MapPin; name: string; count: number; children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm font-medium">
|
||||||
|
<Icon className="h-4 w-4 text-[hsl(var(--leaf))]" />
|
||||||
|
<span className="font-display">{name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground font-normal">· {count}</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y border rounded-lg overflow-hidden">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupBy = "location" | "label";
|
||||||
|
|
||||||
|
// The place an item physically sits in. An item lives directly in a Place, or
|
||||||
|
// inside another Item that does — so for contained items (own location null) we
|
||||||
|
// walk up the container chain to the nearest ancestor that has a location. The
|
||||||
|
// `seen` guard keeps a corrupt cycle from looping forever.
|
||||||
|
function effectiveLocation(
|
||||||
|
it: GridItem,
|
||||||
|
byId: Map<string, GridItem>,
|
||||||
|
): { id: string; name: string } | null {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let cur: GridItem | undefined = it;
|
||||||
|
while (cur) {
|
||||||
|
if (cur.location) return cur.location;
|
||||||
|
if (!cur.parentItemId || seen.has(cur.id)) break;
|
||||||
|
seen.add(cur.id);
|
||||||
|
cur = byId.get(cur.parentItemId);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function InventoryGrid({ items }: { items: GridItem[] }) {
|
export function InventoryGrid({ items }: { items: GridItem[] }) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [groupBy, setGroupBy] = useState<GroupBy>("location");
|
||||||
|
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
|
||||||
|
// Item lookup by id — powers the "inside X" hint and the container-chain walk
|
||||||
|
// that resolves a contained item's effective location.
|
||||||
|
const byId = useMemo(() => {
|
||||||
|
const m = new Map<string, GridItem>();
|
||||||
|
for (const it of items) m.set(it.id, it);
|
||||||
|
return m;
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
// Match name, effective location, or any label so search finds things by where
|
||||||
|
// they live (including inside a container) or how they're tagged, not just by name.
|
||||||
|
const matched = useMemo(
|
||||||
|
() =>
|
||||||
|
items.filter((it) => {
|
||||||
|
if (!q) return true;
|
||||||
|
const loc = effectiveLocation(it, byId);
|
||||||
|
return (
|
||||||
|
it.name.toLowerCase().includes(q) ||
|
||||||
|
(loc?.name.toLowerCase().includes(q) ?? false) ||
|
||||||
|
it.labels.some((l) => l.label.name.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[items, q, byId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Container nesting only makes sense in the unfiltered location tree; a search
|
||||||
|
// or a label view shows a flat list, so we tree only in that one case.
|
||||||
|
const treeView = groupBy === "location" && !q;
|
||||||
|
|
||||||
|
const hintFor = (it: GridItem): string | null => {
|
||||||
|
const parent = it.parentItemId ? byId.get(it.parentItemId) : undefined;
|
||||||
|
if (parent) return `in ${parent.name}`;
|
||||||
|
return it.location ? it.location.name : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Zero inventory: show just the empty state, no search/toggle chrome to operate on.
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-16 text-muted-foreground">
|
<div className="text-center py-16 text-muted-foreground">
|
||||||
@@ -95,7 +203,55 @@ export function InventoryGrid({ items }: { items: GridItem[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Items contained inside another item are rendered under their parent.
|
const empty = (
|
||||||
|
<div className="text-center py-16 text-muted-foreground">
|
||||||
|
<Package className="h-12 w-12 mx-auto mb-4 opacity-20" />
|
||||||
|
<p className="font-medium">Nothing matches</p>
|
||||||
|
<p className="text-sm mt-1">Try a different search or grouping.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2 sm:items-center">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search items, locations, or labels…"
|
||||||
|
className="w-full rounded-md border bg-background pl-9 pr-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center rounded-md border p-0.5 text-sm shrink-0">
|
||||||
|
{(["location", "label"] as const).map((g) => (
|
||||||
|
<button
|
||||||
|
key={g}
|
||||||
|
onClick={() => setGroupBy(g)}
|
||||||
|
className={`flex items-center gap-1.5 rounded px-3 py-1.5 transition-colors ${
|
||||||
|
groupBy === g ? "bg-[hsl(var(--leaf))] text-white" : "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{g === "location" ? <MapPin className="h-3.5 w-3.5" /> : <Tag className="h-3.5 w-3.5" />}
|
||||||
|
{g === "location" ? "Location" : "Label"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{matched.length === 0 ? empty : treeView ? (
|
||||||
|
<LocationTree items={matched} />
|
||||||
|
) : groupBy === "location" ? (
|
||||||
|
<FlatByLocation items={matched} byId={byId} hintFor={hintFor} />
|
||||||
|
) : (
|
||||||
|
<FlatByLabel items={matched} hintFor={hintFor} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default view: top-level items grouped by location, container contents nested.
|
||||||
|
function LocationTree({ items }: { items: GridItem[] }) {
|
||||||
const childrenByParent = new Map<string, GridItem[]>();
|
const childrenByParent = new Map<string, GridItem[]>();
|
||||||
for (const it of items) {
|
for (const it of items) {
|
||||||
if (it.parentItemId) {
|
if (it.parentItemId) {
|
||||||
@@ -105,8 +261,11 @@ export function InventoryGrid({ items }: { items: GridItem[] }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top-level items (not inside another item) grouped by location.
|
// Top-level = items with no parent, plus "orphans" whose parent isn't in the
|
||||||
const topLevel = items.filter((i) => !i.parentItemId);
|
// set (e.g. a soft-deleted container) — otherwise those still-active children
|
||||||
|
// would render nowhere, since nothing left recurses into them.
|
||||||
|
const present = new Set(items.map((i) => i.id));
|
||||||
|
const topLevel = items.filter((i) => !i.parentItemId || !present.has(i.parentItemId));
|
||||||
const groups = new Map<string, { name: string; items: GridItem[] }>();
|
const groups = new Map<string, { name: string; items: GridItem[] }>();
|
||||||
const noLoc: GridItem[] = [];
|
const noLoc: GridItem[] = [];
|
||||||
for (const it of topLevel) {
|
for (const it of topLevel) {
|
||||||
@@ -115,30 +274,91 @@ export function InventoryGrid({ items }: { items: GridItem[] }) {
|
|||||||
g.items.push(it);
|
g.items.push(it);
|
||||||
groups.set(it.location.id, g);
|
groups.set(it.location.id, g);
|
||||||
}
|
}
|
||||||
const sorted = Array.from(groups.values()).sort((a, b) => a.name.localeCompare(b.name));
|
const sorted = Array.from(groups.entries()).sort((a, b) => a[1].name.localeCompare(b[1].name));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{sorted.map((g) => (
|
{sorted.map(([id, g]) => (
|
||||||
<div key={g.name} className="space-y-1.5">
|
<Group key={id} icon={MapPin} name={g.name} count={g.items.length}>
|
||||||
<div className="flex items-center gap-1.5 text-sm font-medium">
|
{g.items.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
|
||||||
<MapPin className="h-4 w-4 text-[hsl(var(--leaf))]" />
|
</Group>
|
||||||
<span className="font-display">{g.name}</span>
|
|
||||||
<span className="text-xs text-muted-foreground font-normal">· {g.items.length}</span>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y border rounded-lg overflow-hidden">
|
|
||||||
{g.items.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
{noLoc.length > 0 &&
|
||||||
{noLoc.length > 0 && (
|
(sorted.length > 0 ? (
|
||||||
<div className="space-y-1.5">
|
<Group icon={MapPin} name="No location" count={noLoc.length}>
|
||||||
{sorted.length > 0 && <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">No location</p>}
|
{noLoc.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
<div className="divide-y border rounded-lg overflow-hidden">
|
<div className="divide-y border rounded-lg overflow-hidden">
|
||||||
{noLoc.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
|
{noLoc.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flat results grouped by location (every matching item is its own row). Groups
|
||||||
|
// key on the effective location id — so a contained item shows under its
|
||||||
|
// container's place (matching the tree view), and two same-named places stay
|
||||||
|
// separate instead of merging.
|
||||||
|
function FlatByLocation({ items, byId, hintFor }: {
|
||||||
|
items: GridItem[];
|
||||||
|
byId: Map<string, GridItem>;
|
||||||
|
hintFor: (i: GridItem) => string | null;
|
||||||
|
}) {
|
||||||
|
const groups = new Map<string, { name: string; items: GridItem[] }>();
|
||||||
|
const noLoc: GridItem[] = [];
|
||||||
|
for (const it of items) {
|
||||||
|
const loc = effectiveLocation(it, byId);
|
||||||
|
if (!loc) { noLoc.push(it); continue; }
|
||||||
|
const g = groups.get(loc.id) ?? { name: loc.name, items: [] };
|
||||||
|
g.items.push(it);
|
||||||
|
groups.set(loc.id, g);
|
||||||
|
}
|
||||||
|
const sorted = Array.from(groups.entries()).sort((a, b) => a[1].name.localeCompare(b[1].name));
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{sorted.map(([id, g]) => (
|
||||||
|
<Group key={id} icon={MapPin} name={g.name} count={g.items.length}>
|
||||||
|
{g.items.map((it) => <FlatRow key={it.id} item={it} hint={it.parentItemId ? hintFor(it) : null} />)}
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
{noLoc.length > 0 && (
|
||||||
|
<Group icon={MapPin} name="No location" count={noLoc.length}>
|
||||||
|
{noLoc.map((it) => <FlatRow key={it.id} item={it} hint={it.parentItemId ? hintFor(it) : null} />)}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flat results grouped by label. An item with multiple labels appears under each;
|
||||||
|
// items with no labels collect under a synthetic "No labels" group. Real-label
|
||||||
|
// groups key on label id, so two same-named labels stay separate and a user label
|
||||||
|
// named "No labels" can't merge into the synthetic bucket.
|
||||||
|
function FlatByLabel({ items, hintFor }: { items: GridItem[]; hintFor: (i: GridItem) => string | null }) {
|
||||||
|
const groups = new Map<string, { name: string; items: GridItem[] }>();
|
||||||
|
const unlabeled: GridItem[] = [];
|
||||||
|
for (const it of items) {
|
||||||
|
if (it.labels.length === 0) { unlabeled.push(it); continue; }
|
||||||
|
for (const { label } of it.labels) {
|
||||||
|
const g = groups.get(label.id) ?? { name: label.name, items: [] };
|
||||||
|
g.items.push(it);
|
||||||
|
groups.set(label.id, g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sorted = Array.from(groups.entries()).sort((a, b) => a[1].name.localeCompare(b[1].name));
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{sorted.map(([id, g]) => (
|
||||||
|
<Group key={id} icon={Tag} name={g.name} count={g.items.length}>
|
||||||
|
{g.items.map((it) => <FlatRow key={it.id} item={it} hint={hintFor(it)} />)}
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
{unlabeled.length > 0 && (
|
||||||
|
<Group icon={Tag} name="No labels" count={unlabeled.length}>
|
||||||
|
{unlabeled.map((it) => <FlatRow key={it.id} item={it} hint={hintFor(it)} />)}
|
||||||
|
</Group>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
76
src/components/layout/mobile-nav.tsx
Normal file
76
src/components/layout/mobile-nav.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { Menu, X, Leaf } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { navItems, settingsItems, isNavActive } from "./nav-items";
|
||||||
|
|
||||||
|
export function MobileNav() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const path = usePathname();
|
||||||
|
|
||||||
|
useEffect(() => { setOpen(false); }, [path]); // close on navigation
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.style.overflow = open ? "hidden" : "";
|
||||||
|
return () => { document.body.style.overflow = ""; };
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const linkClass = (active: boolean, inactive: string) =>
|
||||||
|
cn(
|
||||||
|
"flex items-center gap-2.5 px-3 py-2.5 rounded-md text-sm transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-[hsl(var(--canopy-accent))] text-[hsl(var(--canopy-accent-foreground))]"
|
||||||
|
: inactive,
|
||||||
|
);
|
||||||
|
const MAIN_INACTIVE = "hover:bg-[hsl(var(--canopy-accent))] text-[hsl(var(--canopy-foreground))]/80";
|
||||||
|
const SETTINGS_INACTIVE = "hover:bg-[hsl(var(--canopy-accent))] text-[hsl(var(--canopy-foreground))]/60";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="md:hidden p-1 -ml-1 text-foreground"
|
||||||
|
aria-label="Open menu"
|
||||||
|
>
|
||||||
|
<Menu className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="fixed inset-0 z-50 md:hidden">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={() => setOpen(false)} />
|
||||||
|
<aside className="absolute left-0 top-0 bottom-0 w-64 bg-[hsl(var(--canopy))] text-[hsl(var(--canopy-foreground))] flex flex-col shadow-xl">
|
||||||
|
<div className="flex items-center justify-between px-4 py-4 border-b border-[hsl(var(--canopy-border))]">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="h-7 w-7 rounded-full bg-[hsl(var(--leaf))] flex items-center justify-center">
|
||||||
|
<Leaf className="h-4 w-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="font-display text-lg font-semibold">Moon Base</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setOpen(false)} aria-label="Close menu" className="p-1">
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 overflow-y-auto px-2 py-3 space-y-0.5">
|
||||||
|
{navItems.map(({ label, href, icon: Icon }) => (
|
||||||
|
<Link key={href} href={href} onClick={() => setOpen(false)} className={linkClass(isNavActive(path, href, navItems), MAIN_INACTIVE)}>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" />{label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="px-2 py-2 border-t border-[hsl(var(--canopy-border))] space-y-0.5">
|
||||||
|
{settingsItems.map(({ label, href, icon: Icon }) => (
|
||||||
|
<Link key={href} href={href} onClick={() => setOpen(false)} className={linkClass(path.startsWith(href), SETTINGS_INACTIVE)}>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" />{label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/components/layout/nav-items.ts
Normal file
42
src/components/layout/nav-items.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import {
|
||||||
|
LayoutDashboard, Leaf, BookOpen, Package, PawPrint, MapPin, Bell,
|
||||||
|
Lightbulb, KeyRound, HardDrive, RefreshCw, UtensilsCrossed, CalendarDays,
|
||||||
|
StickyNote,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export type NavItem = { label: string; href: string; icon: LucideIcon; section?: string };
|
||||||
|
|
||||||
|
export const navItems: NavItem[] = [
|
||||||
|
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
||||||
|
{ label: "Garden", href: "/garden", icon: Leaf },
|
||||||
|
{ label: "Plant types", href: "/garden/types", icon: BookOpen },
|
||||||
|
{ label: "Inventory", href: "/inventory", icon: Package },
|
||||||
|
{ label: "Animals", href: "/animals", icon: PawPrint },
|
||||||
|
{ label: "Locations", href: "/locations", icon: MapPin },
|
||||||
|
{ label: "Reminders", href: "/tasks", icon: Bell },
|
||||||
|
{ label: "Calendar", href: "/calendar", icon: CalendarDays },
|
||||||
|
{ label: "Ramble", href: "/ramble", icon: StickyNote },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const kitchenItems: NavItem[] = [
|
||||||
|
{ label: "Pantry & Freezer", href: "/kitchen/pantry", icon: UtensilsCrossed },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const settingsItems: NavItem[] = [
|
||||||
|
{ label: "Suggestions", href: "/suggestions", icon: Lightbulb },
|
||||||
|
{ label: "API tokens", href: "/settings/tokens", icon: KeyRound },
|
||||||
|
{ label: "Backup & Restore", href: "/settings/backup", icon: HardDrive },
|
||||||
|
{ label: "Updates", href: "/settings/updates", icon: RefreshCw },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Active on exact match or a sub-path, unless a more specific item (e.g.
|
||||||
|
// /garden/types) already claims the current path.
|
||||||
|
export function isNavActive(path: string, href: string, all: NavItem[]): boolean {
|
||||||
|
if (path === href) return true;
|
||||||
|
if (href === "/dashboard") return false;
|
||||||
|
if (!path.startsWith(href + "/")) return false;
|
||||||
|
return !all.some(
|
||||||
|
(o) => o.href.length > href.length && (path === o.href || path.startsWith(o.href + "/")),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,28 +2,9 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive, Lightbulb, RefreshCw, BookOpen, KeyRound, Package, PawPrint, MapPin } from "lucide-react";
|
import { Leaf } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { navItems, kitchenItems, settingsItems, isNavActive } from "./nav-items";
|
||||||
const navItems = [
|
|
||||||
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
|
||||||
{ label: "Garden", href: "/garden", icon: Leaf },
|
|
||||||
{ label: "Plant types", href: "/garden/types", icon: BookOpen },
|
|
||||||
{ label: "Inventory", href: "/inventory", icon: Package },
|
|
||||||
{ label: "Animals", href: "/animals", icon: PawPrint },
|
|
||||||
{ label: "Locations", href: "/locations", icon: MapPin },
|
|
||||||
{ label: "Reminders", href: "/tasks", icon: Bell },
|
|
||||||
// Home maintenance and Equipment come in future releases
|
|
||||||
// { label: "Home", href: "/home", icon: Home },
|
|
||||||
// { label: "Equipment", href: "/equipment", icon: Wrench },
|
|
||||||
];
|
|
||||||
|
|
||||||
const settingsItems = [
|
|
||||||
{ label: "Suggestions", href: "/suggestions", icon: Lightbulb },
|
|
||||||
{ label: "API tokens", href: "/settings/tokens", icon: KeyRound },
|
|
||||||
{ label: "Backup & Restore", href: "/settings/backup", icon: HardDrive },
|
|
||||||
{ label: "Updates", href: "/settings/updates", icon: RefreshCw },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const path = usePathname();
|
const path = usePathname();
|
||||||
@@ -39,14 +20,7 @@ export function Sidebar() {
|
|||||||
|
|
||||||
<nav className="flex-1 px-2 py-3 space-y-0.5">
|
<nav className="flex-1 px-2 py-3 space-y-0.5">
|
||||||
{navItems.map(({ label, href, icon: Icon }) => {
|
{navItems.map(({ label, href, icon: Icon }) => {
|
||||||
// Active on exact match or a sub-path, unless a more specific nav item
|
const active = isNavActive(path, href, navItems);
|
||||||
// (e.g. /garden/types) already claims the current path.
|
|
||||||
const moreSpecific = navItems.some(
|
|
||||||
(o) => o.href.length > href.length && (path === o.href || path.startsWith(o.href + "/"))
|
|
||||||
);
|
|
||||||
const active =
|
|
||||||
path === href ||
|
|
||||||
(href !== "/dashboard" && path.startsWith(href + "/") && !moreSpecific);
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={href}
|
key={href}
|
||||||
@@ -65,6 +39,24 @@ export function Sidebar() {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div className="px-2 py-2 border-t border-[hsl(var(--canopy-border))] space-y-0.5">
|
||||||
|
<p className="px-3 pt-1 pb-0.5 text-[10px] uppercase tracking-widest text-[hsl(var(--canopy-foreground))]/40">Kitchen</p>
|
||||||
|
{kitchenItems.map(({ label, href, icon: Icon }) => {
|
||||||
|
const active = path.startsWith(href);
|
||||||
|
return (
|
||||||
|
<Link key={href} href={href} className={cn(
|
||||||
|
"flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-[hsl(var(--canopy-accent))] text-[hsl(var(--canopy-accent-foreground))]"
|
||||||
|
: "hover:bg-[hsl(var(--canopy-accent))] hover:text-[hsl(var(--canopy-accent-foreground))] text-[hsl(var(--canopy-foreground))]/80"
|
||||||
|
)}>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="px-2 py-2 border-t border-[hsl(var(--canopy-border))] space-y-0.5">
|
<div className="px-2 py-2 border-t border-[hsl(var(--canopy-border))] space-y-0.5">
|
||||||
{settingsItems.map(({ label, href, icon: Icon }) => {
|
{settingsItems.map(({ label, href, icon: Icon }) => {
|
||||||
const active = path.startsWith(href);
|
const active = path.startsWith(href);
|
||||||
|
|||||||
29
src/components/layout/theme-toggle.tsx
Normal file
29
src/components/layout/theme-toggle.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { Sun, Moon } from "lucide-react";
|
||||||
|
|
||||||
|
// Light ↔ dark toggle. The dark palette already lives in globals.css (.dark);
|
||||||
|
// this just gives it a control. The icon shows the mood you'll switch *to*.
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
|
// Avoid a hydration mismatch: theme isn't known until mounted on the client.
|
||||||
|
if (!mounted) return <div className="h-9 w-9" aria-hidden />;
|
||||||
|
|
||||||
|
const isDark = resolvedTheme === "dark";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||||
|
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
|
title={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
|
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
import { LogOut, User } from "lucide-react";
|
import { LogOut, Lightbulb, KeyRound } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -10,6 +11,8 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { MobileNav } from "@/components/layout/mobile-nav";
|
||||||
|
import { ThemeToggle } from "@/components/layout/theme-toggle";
|
||||||
import { initials } from "@/lib/utils";
|
import { initials } from "@/lib/utils";
|
||||||
|
|
||||||
interface TopNavProps {
|
interface TopNavProps {
|
||||||
@@ -19,32 +22,48 @@ interface TopNavProps {
|
|||||||
export function TopNav({ user }: TopNavProps) {
|
export function TopNav({ user }: TopNavProps) {
|
||||||
return (
|
return (
|
||||||
<header className="h-14 border-b flex items-center justify-between px-4 md:px-6 shrink-0 bg-background">
|
<header className="h-14 border-b flex items-center justify-between px-4 md:px-6 shrink-0 bg-background">
|
||||||
<div className="flex items-center gap-2 md:hidden">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-display font-semibold">Moon Base</span>
|
<MobileNav />
|
||||||
|
<span className="font-display font-semibold md:hidden">Moon Base</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden md:block" />
|
<div className="flex items-center gap-1">
|
||||||
|
<ThemeToggle />
|
||||||
|
|
||||||
<DropdownMenu>
|
<Link
|
||||||
<DropdownMenuTrigger asChild>
|
href="/suggestions"
|
||||||
<Button variant="ghost" size="sm" className="gap-2">
|
aria-label="Suggestions & feedback"
|
||||||
<div className="h-6 w-6 rounded-full bg-[hsl(var(--leaf))] flex items-center justify-center text-white text-[10px] font-bold">
|
title="Suggestions & feedback"
|
||||||
{initials(user.name)}
|
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||||
</div>
|
>
|
||||||
<span className="text-sm hidden sm:inline">{user.name}</span>
|
<Lightbulb className="h-5 w-5" />
|
||||||
</Button>
|
</Link>
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenu>
|
||||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
<DropdownMenuTrigger asChild>
|
||||||
{user.email}
|
<Button variant="ghost" size="sm" className="gap-2">
|
||||||
</div>
|
<div className="h-6 w-6 rounded-full bg-[hsl(var(--leaf))] flex items-center justify-center text-white text-[10px] font-bold">
|
||||||
<DropdownMenuSeparator />
|
{initials(user.name)}
|
||||||
<DropdownMenuItem onClick={() => signOut({ callbackUrl: "/login" })}>
|
</div>
|
||||||
<LogOut className="h-4 w-4 mr-2" />
|
<span className="text-sm hidden sm:inline">{user.name}</span>
|
||||||
Sign out
|
</Button>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuTrigger>
|
||||||
</DropdownMenuContent>
|
<DropdownMenuContent align="end">
|
||||||
</DropdownMenu>
|
<div className="px-2 py-1.5 text-xs text-muted-foreground">{user.email}</div>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/change-password">
|
||||||
|
<KeyRound className="h-4 w-4 mr-2" />
|
||||||
|
Change password
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => signOut({ callbackUrl: "/login" })}>
|
||||||
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
|
Sign out
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ import {
|
|||||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator,
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Plus, MoreVertical, MapPin, Building2, DoorOpen, Box, Trees, HelpCircle } from "lucide-react";
|
import { Plus, MoreVertical, MapPin, Building2, DoorOpen, Box, Trees, HelpCircle, Bell } from "lucide-react";
|
||||||
import { buildTree, flattenForSelect } from "@/lib/locations/tree";
|
import { buildTree, flattenForSelect } from "@/lib/locations/tree";
|
||||||
|
import { MONTH_NAMES } from "@/lib/tasks/schedule";
|
||||||
|
import { QuickSetupButton } from "@/components/locations/quick-setup-button";
|
||||||
|
|
||||||
type Loc = {
|
type Loc = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -38,6 +40,10 @@ const NONE = "__none__";
|
|||||||
|
|
||||||
export function LocationManager({ locations }: { locations: Loc[] }) {
|
export function LocationManager({ locations }: { locations: Loc[] }) {
|
||||||
const [dialog, setDialog] = useState<null | { mode: "add" | "edit"; id?: string; name: string; kind: string; parentId: string }>(null);
|
const [dialog, setDialog] = useState<null | { mode: "add" | "edit"; id?: string; name: string; kind: string; parentId: string }>(null);
|
||||||
|
const [reminder, setReminder] = useState<null | {
|
||||||
|
locationId: string; locationName: string; title: string;
|
||||||
|
recurrence: string; intervalDays: string; month: string; nextDue: string;
|
||||||
|
}>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -84,6 +90,28 @@ export function LocationManager({ locations }: { locations: Loc[] }) {
|
|||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveReminder() {
|
||||||
|
if (!reminder || !reminder.title.trim()) return;
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
title: reminder.title.trim(),
|
||||||
|
category: "HOME",
|
||||||
|
locationId: reminder.locationId,
|
||||||
|
recurrence: reminder.recurrence,
|
||||||
|
nextDue: new Date(reminder.nextDue).toISOString(),
|
||||||
|
};
|
||||||
|
if (reminder.recurrence === "CUSTOM") body.intervalDays = parseInt(reminder.intervalDays);
|
||||||
|
if (reminder.recurrence === "YEARLY") body.month = parseInt(reminder.month);
|
||||||
|
setBusy(true);
|
||||||
|
const res = await fetch("/api/v1/tasks", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (!res.ok) { toast({ title: "Error", description: "Could not save reminder.", variant: "destructive" }); return; }
|
||||||
|
toast({ title: "Reminder added", description: "It'll show in Reminders." });
|
||||||
|
setReminder(null);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
// Valid parents for the dialog (exclude self when editing — server guards descendants).
|
// Valid parents for the dialog (exclude self when editing — server guards descendants).
|
||||||
const parentChoices = flat.filter((n) => !(dialog?.mode === "edit" && n.id === dialog.id));
|
const parentChoices = flat.filter((n) => !(dialog?.mode === "edit" && n.id === dialog.id));
|
||||||
|
|
||||||
@@ -93,7 +121,10 @@ export function LocationManager({ locations }: { locations: Loc[] }) {
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
The shared place tree — garden areas, buildings, rooms, and storage. Plants and items live here.
|
The shared place tree — garden areas, buildings, rooms, and storage. Plants and items live here.
|
||||||
</p>
|
</p>
|
||||||
<Button size="sm" onClick={() => openAdd()}><Plus className="h-4 w-4 mr-1" />Add location</Button>
|
<div className="flex items-center gap-2">
|
||||||
|
<QuickSetupButton />
|
||||||
|
<Button size="sm" onClick={() => openAdd()}><Plus className="h-4 w-4 mr-1" />Add location</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{locations.length === 0 ? (
|
{locations.length === 0 ? (
|
||||||
@@ -122,6 +153,9 @@ export function LocationManager({ locations }: { locations: Loc[] }) {
|
|||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => openAdd(n.id)}>Add sub-location</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => openAdd(n.id)}>Add sub-location</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => openEdit(n as unknown as Loc)}>Rename / move</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => openEdit(n as unknown as Loc)}>Rename / move</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setReminder({ locationId: n.id, locationName: n.name, title: "", recurrence: "YEARLY", intervalDays: "90", month: "1", nextDue: new Date().toISOString().slice(0, 10) })}>
|
||||||
|
Add reminder
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="text-[hsl(var(--ember))]" onClick={() => remove(n as unknown as Loc)}>Delete</DropdownMenuItem>
|
<DropdownMenuItem className="text-[hsl(var(--ember))]" onClick={() => remove(n as unknown as Loc)}>Delete</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -171,6 +205,60 @@ export function LocationManager({ locations }: { locations: Loc[] }) {
|
|||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={!!reminder} onOpenChange={(v) => { if (!v) setReminder(null); }}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Reminder for {reminder?.locationName}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{reminder && (
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); saveReminder(); }} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>What needs doing?</Label>
|
||||||
|
<Input value={reminder.title} onChange={(e) => setReminder({ ...reminder, title: e.target.value })}
|
||||||
|
placeholder="Clean gutters, change furnace filter…" autoFocus />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Repeats</Label>
|
||||||
|
<Select value={reminder.recurrence} onValueChange={(v) => setReminder({ ...reminder, recurrence: v })}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ONCE">Just once</SelectItem>
|
||||||
|
<SelectItem value="CUSTOM">Every N days</SelectItem>
|
||||||
|
<SelectItem value="MONTHLY">Every month</SelectItem>
|
||||||
|
<SelectItem value="YEARLY">Every year</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{reminder.recurrence === "CUSTOM" && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Every how many days?</Label>
|
||||||
|
<Input type="number" min="1" value={reminder.intervalDays} onChange={(e) => setReminder({ ...reminder, intervalDays: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{reminder.recurrence === "YEARLY" && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Which month?</Label>
|
||||||
|
<Select value={reminder.month} onValueChange={(v) => setReminder({ ...reminder, month: v })}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MONTH_NAMES.map((n, i) => <SelectItem key={i + 1} value={String(i + 1)}>{n}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>First due date</Label>
|
||||||
|
<Input type="date" value={reminder.nextDue} onChange={(e) => setReminder({ ...reminder, nextDue: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setReminder(null)}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Save reminder"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
104
src/components/locations/quick-setup-button.tsx
Normal file
104
src/components/locations/quick-setup-button.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Wand2 } from "lucide-react";
|
||||||
|
|
||||||
|
// Pre-filled with the Moon homestead house layout — Tony just clicks Create.
|
||||||
|
const DEFAULT_OUTLINE = `House
|
||||||
|
Main floor
|
||||||
|
Entrance
|
||||||
|
Living room
|
||||||
|
Family room
|
||||||
|
Dining room
|
||||||
|
Kitchen
|
||||||
|
Pantry
|
||||||
|
Office
|
||||||
|
Bedroom
|
||||||
|
Guest room
|
||||||
|
Bathroom
|
||||||
|
Laundry room
|
||||||
|
Basement
|
||||||
|
Stairs
|
||||||
|
Laundry room
|
||||||
|
Furnace room
|
||||||
|
Workshop
|
||||||
|
Studio
|
||||||
|
Bathroom
|
||||||
|
Garage
|
||||||
|
Exterior
|
||||||
|
Gutters
|
||||||
|
Exterior lights
|
||||||
|
Roof
|
||||||
|
Outbuildings
|
||||||
|
Brown shed
|
||||||
|
Green shed
|
||||||
|
Wood shed
|
||||||
|
Yard`;
|
||||||
|
|
||||||
|
export function QuickSetupButton() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [outline, setOutline] = useState(DEFAULT_OUTLINE);
|
||||||
|
const [groupGarden, setGroupGarden] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
setBusy(true);
|
||||||
|
const res = await fetch("/api/locations/bulk", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ outline, groupGardenUnder: groupGarden ? "Yard" : undefined }),
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
toast({ title: "Error", description: err.error ?? "Setup failed.", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const s = await res.json();
|
||||||
|
toast({
|
||||||
|
title: "Places set up!",
|
||||||
|
description: `${s.created} created${s.moved ? `, ${s.moved} garden areas grouped under Yard` : ""}.`,
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
|
||||||
|
<Wand2 className="h-4 w-4 mr-1" /> Quick setup
|
||||||
|
</Button>
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Quick setup from an outline</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Each indent is a sub-location. Re-running is safe — existing places are reused, not duplicated.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Textarea rows={14} value={outline} onChange={(e) => setOutline(e.target.value)} className="font-mono text-xs" />
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input type="checkbox" checked={groupGarden} onChange={(e) => setGroupGarden(e.target.checked)} className="accent-[hsl(var(--leaf))]" />
|
||||||
|
Move existing garden areas (places that hold plants) under “Yard”
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||||
|
<Button onClick={run} disabled={busy || !outline.trim()}>{busy ? "Setting up…" : "Create"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
257
src/components/pantry/pantry-client.tsx
Normal file
257
src/components/pantry/pantry-client.tsx
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AlertTriangle, ChevronDown, ChevronRight, Trash2, UtensilsCrossed } from "lucide-react";
|
||||||
|
import { PantryItemDialog } from "./pantry-item-dialog";
|
||||||
|
import { PullDialog } from "./pull-dialog";
|
||||||
|
import { UseItemDialog } from "./use-item-dialog";
|
||||||
|
|
||||||
|
export type PantryItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
quantity: number;
|
||||||
|
unit: string | null;
|
||||||
|
packageSize: string | null;
|
||||||
|
storedAt: Date | null;
|
||||||
|
bestBefore: Date | null;
|
||||||
|
source: string | null;
|
||||||
|
locationId: string | null;
|
||||||
|
locationName: string | null;
|
||||||
|
plantId: string | null;
|
||||||
|
plantName: string | null;
|
||||||
|
pantryCategory: string | null;
|
||||||
|
pantrySubcategory: string | null;
|
||||||
|
pricePaid: number | null;
|
||||||
|
priceUnit: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LocationOption = { id: string; name: string; path: string | null };
|
||||||
|
export type PlantOption = { id: string; commonName: string; variety: string | null };
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
GARDEN: "Our garden", HOMEMADE: "Homemade", STORE: "Store",
|
||||||
|
FARMERS_MARKET: "Farmers market", GIFTED: "Gifted", OTHER: "Other",
|
||||||
|
};
|
||||||
|
|
||||||
|
function daysUntil(date: Date) {
|
||||||
|
return Math.ceil((new Date(date).getTime() - Date.now()) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExpiryBadge({ date }: { date: Date }) {
|
||||||
|
const days = daysUntil(date);
|
||||||
|
if (days < 0) return <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 px-1.5 py-0.5 rounded">Expired</span>;
|
||||||
|
if (days <= 7) return <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 px-1.5 py-0.5 rounded">Expires in {days}d</span>;
|
||||||
|
if (days <= 30) return <span className="text-xs font-medium text-amber-600 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded">Expires in {days}d</span>;
|
||||||
|
return <span className="text-xs text-muted-foreground">{new Date(date).toLocaleDateString()}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemRow({ item, locations, plants, onRefresh }: {
|
||||||
|
item: PantryItem; locations: LocationOption[]; plants: PlantOption[]; onRefresh: () => void;
|
||||||
|
}) {
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/pantry/${item.id}`, { method: "DELETE" });
|
||||||
|
if (res.ok) onRefresh();
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
setConfirming(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceStr = item.pricePaid != null
|
||||||
|
? `$${item.pricePaid.toFixed(2)}${item.priceUnit && item.priceUnit !== "package" ? `/${item.priceUnit}` : ""}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3 border-b last:border-0 hover:bg-muted/30 transition-colors">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-medium text-sm">{item.name}</span>
|
||||||
|
{item.description && <span className="text-xs text-muted-foreground">{item.description}</span>}
|
||||||
|
{item.source && <span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">{SOURCE_LABELS[item.source] ?? item.source}</span>}
|
||||||
|
{item.plantName && <span className="text-xs text-green-700 bg-green-50 border border-green-200 px-1.5 py-0.5 rounded">🌱 {item.plantName}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{item.quantity} {item.unit ?? ""}
|
||||||
|
</span>
|
||||||
|
{priceStr && <span className="text-xs text-muted-foreground">{priceStr}</span>}
|
||||||
|
{item.locationName && <span className="text-xs text-muted-foreground">· {item.locationName}</span>}
|
||||||
|
{item.storedAt && <span className="text-xs text-muted-foreground">Stored {new Date(item.storedAt).toLocaleDateString()}</span>}
|
||||||
|
{item.bestBefore && <ExpiryBadge date={item.bestBefore} />}
|
||||||
|
</div>
|
||||||
|
{item.tags?.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{item.tags.map(tag => (
|
||||||
|
<span key={tag} className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item.notes && <p className="text-xs text-muted-foreground mt-0.5 italic">{item.notes}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{confirming ? (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-muted-foreground mr-1">Delete?</span>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="text-xs text-destructive font-medium hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{deleting ? "…" : "Yes"}
|
||||||
|
</button>
|
||||||
|
<span className="text-muted-foreground text-xs">/</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirming(false)}
|
||||||
|
className="text-xs text-muted-foreground hover:underline"
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UseItemDialog item={item} onSuccess={onRefresh} />
|
||||||
|
<PantryItemDialog item={item} locations={locations} plants={plants} onSuccess={onRefresh} />
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirming(true)}
|
||||||
|
className="h-8 w-8 flex items-center justify-center rounded-md hover:bg-destructive/10 hover:text-destructive text-muted-foreground transition-colors"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubcategoryGroup({ name, items, locations, plants, onRefresh }: {
|
||||||
|
name: string; items: PantryItem[]; locations: LocationOption[]; plants: PlantOption[]; onRefresh: () => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={() => setOpen(o => !o)} className="w-full flex items-center gap-2 px-4 py-2 hover:bg-muted/20 transition-colors">
|
||||||
|
{open ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">{name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">({items.length})</span>
|
||||||
|
</button>
|
||||||
|
{open && items.map(item => (
|
||||||
|
<ItemRow key={item.id} item={item} locations={locations} plants={plants} onRefresh={onRefresh} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryGroup({ name, items, locations, plants, onRefresh }: {
|
||||||
|
name: string; items: PantryItem[]; locations: LocationOption[]; plants: PlantOption[]; onRefresh: () => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
const expiring = items.filter(i => i.bestBefore && daysUntil(i.bestBefore) <= 30);
|
||||||
|
|
||||||
|
// Group by subcategory
|
||||||
|
const subcats = items.reduce<Record<string, PantryItem[]>>((acc, item) => {
|
||||||
|
const key = item.pantrySubcategory ?? "—";
|
||||||
|
if (!acc[key]) acc[key] = [];
|
||||||
|
acc[key].push(item);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
const hasSubcats = Object.keys(subcats).length > 1 || !subcats["—"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-card overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-3 hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{open ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
<span className="font-semibold">{name}</span>
|
||||||
|
<span className="text-sm text-muted-foreground">{items.length} {items.length === 1 ? "item" : "items"}</span>
|
||||||
|
</div>
|
||||||
|
{expiring.length > 0 && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-amber-600">
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
{expiring.length} expiring soon
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="border-t">
|
||||||
|
{hasSubcats
|
||||||
|
? Object.entries(subcats).sort(([a], [b]) => a.localeCompare(b)).map(([sub, subItems]) => (
|
||||||
|
<SubcategoryGroup key={sub} name={sub === "—" ? "Other" : sub} items={subItems} locations={locations} plants={plants} onRefresh={onRefresh} />
|
||||||
|
))
|
||||||
|
: items.map(item => (
|
||||||
|
<ItemRow key={item.id} item={item} locations={locations} plants={plants} onRefresh={onRefresh} />
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PantryClient({ initialItems, locations, plants }: {
|
||||||
|
initialItems: PantryItem[]; locations: LocationOption[]; plants: PlantOption[];
|
||||||
|
}) {
|
||||||
|
const [items, setItems] = useState(initialItems);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const res = await fetch("/api/v1/pantry");
|
||||||
|
if (res.ok) setItems(await res.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = items.reduce<Record<string, PantryItem[]>>((acc, item) => {
|
||||||
|
const key = item.pantryCategory ?? "Uncategorized";
|
||||||
|
if (!acc[key]) acc[key] = [];
|
||||||
|
acc[key].push(item);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const expiringCount = items.filter(i => i.bestBefore && daysUntil(i.bestBefore) <= 30).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-2xl font-semibold">Pantry & Freezer</h1>
|
||||||
|
{expiringCount > 0 && (
|
||||||
|
<p className="text-sm text-amber-600 mt-0.5 flex items-center gap-1">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
|
{expiringCount} {expiringCount === 1 ? "item" : "items"} expiring within 30 days
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PullDialog items={items} onSuccess={refresh} />
|
||||||
|
<PantryItemDialog locations={locations} plants={plants} onSuccess={refresh} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="text-center py-16 text-muted-foreground">
|
||||||
|
<UtensilsCrossed className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||||
|
<p className="text-sm">Nothing in the pantry yet — add your first item.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Object.entries(grouped)
|
||||||
|
.sort(([a], [b]) => a === "Uncategorized" ? 1 : b === "Uncategorized" ? -1 : a.localeCompare(b))
|
||||||
|
.map(([name, catItems]) => (
|
||||||
|
<CategoryGroup key={name} name={name} items={catItems} locations={locations} plants={plants} onRefresh={refresh} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
487
src/components/pantry/pantry-item-dialog.tsx
Normal file
487
src/components/pantry/pantry-item-dialog.tsx
Normal file
@@ -0,0 +1,487 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { Plus, Pencil, Sparkles, X } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
|
import { CATEGORY_OPTIONS, getSubcategoryOptions } from "@/lib/pantry/categories";
|
||||||
|
import type { PantryItem, LocationOption, PlantOption } from "./pantry-client";
|
||||||
|
|
||||||
|
const SOURCES = [
|
||||||
|
{ value: "GARDEN", label: "Our garden" },
|
||||||
|
{ value: "HOMEMADE", label: "Homemade" },
|
||||||
|
{ value: "STORE", label: "Store" },
|
||||||
|
{ value: "FARMERS_MARKET", label: "Farmers market" },
|
||||||
|
{ value: "GIFTED", label: "Gifted" },
|
||||||
|
{ value: "OTHER", label: "Other" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const COMMON_UNITS = ["lbs", "oz", "g", "kg", "bags", "jars", "quart jars", "pint jars", "gallon bags", "quart bags", "containers", "cans", "bottles", "bunches", "loaves", "portions", "each"];
|
||||||
|
const PRICE_UNITS = ["package", "lb", "oz", "kg", "each", "dozen", "bunch"];
|
||||||
|
|
||||||
|
|
||||||
|
type Suggestion = {
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
unit: string | null;
|
||||||
|
source: string | null;
|
||||||
|
pantryCategory: string | null;
|
||||||
|
pantrySubcategory: string | null;
|
||||||
|
priceUnit: string | null;
|
||||||
|
pricePaid: number | null;
|
||||||
|
locationId: string | null;
|
||||||
|
locationName: string | null;
|
||||||
|
plantId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CatalogData = {
|
||||||
|
suggestions: Suggestion[];
|
||||||
|
categories: string[];
|
||||||
|
subcategories: { category: string; subcategory: string }[];
|
||||||
|
labels: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Form = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
quantity: string;
|
||||||
|
unit: string;
|
||||||
|
locationId: string;
|
||||||
|
storedAt: string;
|
||||||
|
bestBefore: string;
|
||||||
|
source: string;
|
||||||
|
plantId: string;
|
||||||
|
pantryCategory: string;
|
||||||
|
pantrySubcategory: string;
|
||||||
|
pricePaid: string;
|
||||||
|
priceUnit: string;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
item?: PantryItem;
|
||||||
|
locations: LocationOption[];
|
||||||
|
plants: PlantOption[];
|
||||||
|
onSuccess: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PantryItemDialog({ item, locations, plants, onSuccess }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [catalog, setCatalog] = useState<CatalogData | null>(null);
|
||||||
|
const [nameSuggestions, setNameSuggestions] = useState<Suggestion[]>([]);
|
||||||
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
|
const [autofillBanner, setAutofillBanner] = useState<Suggestion | null>(null);
|
||||||
|
const [tags, setTags] = useState<string[]>(item?.tags ?? []);
|
||||||
|
const [tagInput, setTagInput] = useState("");
|
||||||
|
const [tagSuggestions, setTagSuggestions] = useState<string[]>([]);
|
||||||
|
const [showTagDropdown, setShowTagDropdown] = useState(false);
|
||||||
|
const nameRef = useRef<HTMLInputElement>(null);
|
||||||
|
const tagInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
const blankForm = (): Form => ({
|
||||||
|
name: item?.name ?? "",
|
||||||
|
description: item?.description ?? "",
|
||||||
|
quantity: item?.quantity?.toString() ?? "1",
|
||||||
|
unit: item?.unit ?? "",
|
||||||
|
locationId: item?.locationId ?? "",
|
||||||
|
storedAt: item?.storedAt ? new Date(item.storedAt).toISOString().slice(0, 10) : "",
|
||||||
|
bestBefore: item?.bestBefore ? new Date(item.bestBefore).toISOString().slice(0, 10) : "",
|
||||||
|
source: item?.source ?? "",
|
||||||
|
plantId: item?.plantId ?? "",
|
||||||
|
pantryCategory: (item as any)?.pantryCategory ?? "",
|
||||||
|
pantrySubcategory: (item as any)?.pantrySubcategory ?? "",
|
||||||
|
pricePaid: (item as any)?.pricePaid?.toString() ?? "",
|
||||||
|
priceUnit: (item as any)?.priceUnit ?? "package",
|
||||||
|
notes: item?.notes ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [form, setForm] = useState<Form>(blankForm);
|
||||||
|
|
||||||
|
function set(key: keyof Form, val: string) {
|
||||||
|
setForm(f => ({ ...f, [key]: val }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTag(name: string) {
|
||||||
|
const t = name.trim();
|
||||||
|
if (!t || tags.includes(t)) return;
|
||||||
|
setTags(prev => [...prev, t]);
|
||||||
|
setTagInput("");
|
||||||
|
setShowTagDropdown(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag(name: string) {
|
||||||
|
setTags(prev => prev.filter(t => t !== name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTagKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if ((e.key === "Enter" || e.key === ",") && tagInput.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
addTag(tagInput);
|
||||||
|
} else if (e.key === "Backspace" && !tagInput && tags.length) {
|
||||||
|
removeTag(tags[tags.length - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset tag state when dialog opens/closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setTags(item?.tags ?? []);
|
||||||
|
setTagInput("");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Load catalog on open (for name autocomplete suggestions)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
fetch("/api/v1/pantry/catalog")
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(setCatalog)
|
||||||
|
.catch(() => {});
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Tag autocomplete from catalog labels
|
||||||
|
useEffect(() => {
|
||||||
|
const q = tagInput.trim().toLowerCase();
|
||||||
|
if (!q || !catalog?.labels) { setTagSuggestions([]); setShowTagDropdown(false); return; }
|
||||||
|
const matches = catalog.labels.filter(l => l.toLowerCase().includes(q) && !tags.includes(l));
|
||||||
|
setTagSuggestions(matches.slice(0, 6));
|
||||||
|
setShowTagDropdown(matches.length > 0 || q.length > 0);
|
||||||
|
}, [tagInput, catalog, tags]);
|
||||||
|
|
||||||
|
// Debounced name search for autocomplete
|
||||||
|
function handleNameChange(val: string) {
|
||||||
|
set("name", val);
|
||||||
|
setAutofillBanner(null);
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
if (val.length < 2) { setNameSuggestions([]); setShowDropdown(false); return; }
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
const res = await fetch(`/api/v1/pantry/catalog?q=${encodeURIComponent(val)}`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data: CatalogData = await res.json();
|
||||||
|
setNameSuggestions(data.suggestions.slice(0, 6));
|
||||||
|
setShowDropdown(data.suggestions.length > 0);
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAutofill(s: Suggestion) {
|
||||||
|
setForm(f => ({
|
||||||
|
...f,
|
||||||
|
name: s.name,
|
||||||
|
description: s.description ?? f.description,
|
||||||
|
unit: s.unit ?? f.unit,
|
||||||
|
source: s.source ?? f.source,
|
||||||
|
pantryCategory: s.pantryCategory ?? f.pantryCategory,
|
||||||
|
pantrySubcategory: s.pantrySubcategory ?? f.pantrySubcategory,
|
||||||
|
priceUnit: s.priceUnit ?? f.priceUnit,
|
||||||
|
pricePaid: s.pricePaid != null ? s.pricePaid.toString() : f.pricePaid,
|
||||||
|
locationId: s.locationId ?? f.locationId,
|
||||||
|
plantId: s.plantId ?? f.plantId,
|
||||||
|
}));
|
||||||
|
setShowDropdown(false);
|
||||||
|
setAutofillBanner(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSuggestion(s: Suggestion) {
|
||||||
|
setShowDropdown(false);
|
||||||
|
setAutofillBanner(s);
|
||||||
|
set("name", s.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge static category list with any custom categories previously saved to the DB.
|
||||||
|
const catalogCategories: string[] = catalog?.categories ?? [];
|
||||||
|
const knownCategoryValues = new Set(CATEGORY_OPTIONS.map(o => o.value.toLowerCase()));
|
||||||
|
const extraCategoryOptions = catalogCategories
|
||||||
|
.filter(c => !knownCategoryValues.has(c.toLowerCase()))
|
||||||
|
.map(c => ({ value: c, label: c }));
|
||||||
|
const allCategoryOptions = [...CATEGORY_OPTIONS, ...extraCategoryOptions];
|
||||||
|
|
||||||
|
// Static subcategories for this category + any custom ones from the catalog.
|
||||||
|
const staticSubcategoryOptions = getSubcategoryOptions(form.pantryCategory);
|
||||||
|
const catalogSubcategories: { category: string; subcategory: string }[] = catalog?.subcategories ?? [];
|
||||||
|
const knownSubValues = new Set(staticSubcategoryOptions.map(o => o.value.toLowerCase()));
|
||||||
|
const extraSubOptions = catalogSubcategories
|
||||||
|
.filter(cs => cs.category.toLowerCase() === form.pantryCategory.toLowerCase() && !knownSubValues.has(cs.subcategory.toLowerCase()))
|
||||||
|
.map(cs => ({ value: cs.subcategory, label: cs.subcategory }));
|
||||||
|
const subcategoryOptions = [...staticSubcategoryOptions, ...extraSubOptions];
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim() || null,
|
||||||
|
quantity: parseFloat(form.quantity) || 1,
|
||||||
|
unit: form.unit.trim() || null,
|
||||||
|
locationId: form.locationId || null,
|
||||||
|
storedAt: form.storedAt || null,
|
||||||
|
bestBefore: form.bestBefore || null,
|
||||||
|
source: form.source || null,
|
||||||
|
plantId: form.plantId || null,
|
||||||
|
pantryCategory: form.pantryCategory.trim() || null,
|
||||||
|
pantrySubcategory: form.pantrySubcategory.trim() || null,
|
||||||
|
pricePaid: form.pricePaid ? parseFloat(form.pricePaid) : null,
|
||||||
|
priceUnit: form.priceUnit || null,
|
||||||
|
notes: form.notes.trim() || null,
|
||||||
|
kind: "CONSUMABLE",
|
||||||
|
tags,
|
||||||
|
};
|
||||||
|
const res = await fetch(item ? `/api/v1/pantry/${item.id}` : "/api/v1/pantry", {
|
||||||
|
method: item ? "PATCH" : "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.json()).error ?? "Failed");
|
||||||
|
setOpen(false);
|
||||||
|
setForm(blankForm());
|
||||||
|
setAutofillBanner(null);
|
||||||
|
setTags([]);
|
||||||
|
setTagInput("");
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err).replace("Error: ", ""));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls = "w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{item ? (
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setOpen(true)}>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={() => setOpen(true)} className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" /> Add item
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={o => { setOpen(o); if (!o) { setAutofillBanner(null); setShowDropdown(false); } }}>
|
||||||
|
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{item ? "Edit item" : "Add pantry item"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} noValidate className="space-y-4 py-2">
|
||||||
|
|
||||||
|
{/* Name with autocomplete */}
|
||||||
|
<div className="space-y-1 relative">
|
||||||
|
<Label>Name *</Label>
|
||||||
|
<input
|
||||||
|
ref={nameRef}
|
||||||
|
className={inputCls}
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => handleNameChange(e.target.value)}
|
||||||
|
onBlur={() => setTimeout(() => setShowDropdown(false), 150)}
|
||||||
|
required
|
||||||
|
placeholder="e.g. Blueberries, Ground beef, Chicken broth"
|
||||||
|
/>
|
||||||
|
{showDropdown && nameSuggestions.length > 0 && (
|
||||||
|
<div className="absolute z-50 left-0 right-0 top-full mt-1 bg-popover border rounded-md shadow-md overflow-hidden">
|
||||||
|
<p className="px-3 py-1.5 text-[10px] text-muted-foreground uppercase tracking-wider">Previously added</p>
|
||||||
|
{nameSuggestions.map(s => (
|
||||||
|
<button
|
||||||
|
key={s.name}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={() => selectSuggestion(s)}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-center justify-between gap-2"
|
||||||
|
>
|
||||||
|
<span>{s.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">
|
||||||
|
{[s.pantryCategory, s.pantrySubcategory].filter(Boolean).join(" › ")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Autofill banner */}
|
||||||
|
{autofillBanner && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md bg-amber-50 border border-amber-200 px-3 py-2 text-sm">
|
||||||
|
<Sparkles className="h-4 w-4 text-amber-500 shrink-0" />
|
||||||
|
<span className="flex-1 text-amber-800">
|
||||||
|
Fill in previous settings for <strong>{autofillBanner.name}</strong>?
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={() => applyAutofill(autofillBanner)} className="text-amber-700 font-medium hover:underline shrink-0">Apply</button>
|
||||||
|
<button type="button" onClick={() => setAutofillBanner(null)} className="text-amber-400 hover:text-amber-600">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Category</Label>
|
||||||
|
<Combobox
|
||||||
|
options={allCategoryOptions}
|
||||||
|
value={form.pantryCategory}
|
||||||
|
onChange={v => { set("pantryCategory", v); set("pantrySubcategory", ""); }}
|
||||||
|
placeholder="Select or add category…"
|
||||||
|
allowCustom
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Subcategory</Label>
|
||||||
|
<Combobox
|
||||||
|
options={subcategoryOptions}
|
||||||
|
value={form.pantrySubcategory}
|
||||||
|
onChange={v => set("pantrySubcategory", v)}
|
||||||
|
placeholder="Select or add…"
|
||||||
|
disabled={!form.pantryCategory}
|
||||||
|
emptyText="Type to add a subcategory"
|
||||||
|
allowCustom
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Package / container description</Label>
|
||||||
|
<input className={inputCls} value={form.description} onChange={e => set("description", e.target.value)} placeholder="e.g. 1-gallon freezer bag, quart jar, vacuum-sealed" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quantity + unit */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Quantity *</Label>
|
||||||
|
<input className={inputCls} type="number" min="0" step="1" value={form.quantity} onChange={e => set("quantity", e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Unit</Label>
|
||||||
|
<input className={inputCls} list="unit-list" value={form.unit} onChange={e => set("unit", e.target.value)} placeholder="bags, lbs, jars…" />
|
||||||
|
<datalist id="unit-list">
|
||||||
|
{COMMON_UNITS.map(u => <option key={u} value={u} />)}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Price paid</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm">$</span>
|
||||||
|
<input className={inputCls + " pl-6"} type="number" min="0" step="0.01" value={form.pricePaid} onChange={e => set("pricePaid", e.target.value)} placeholder="0.00" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Per</Label>
|
||||||
|
<select className={inputCls} value={form.priceUnit} onChange={e => set("priceUnit", e.target.value)}>
|
||||||
|
{PRICE_UNITS.map(u => <option key={u} value={u}>{u}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Location</Label>
|
||||||
|
<select className={inputCls} value={form.locationId} onChange={e => set("locationId", e.target.value)}>
|
||||||
|
<option value="">— none —</option>
|
||||||
|
{locations.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dates */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Date stored / frozen</Label>
|
||||||
|
<input className={inputCls} type="date" value={form.storedAt} onChange={e => set("storedAt", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Best by / expires</Label>
|
||||||
|
<input className={inputCls} type="date" value={form.bestBefore} onChange={e => set("bestBefore", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Source</Label>
|
||||||
|
<select className={inputCls} value={form.source} onChange={e => set("source", e.target.value)}>
|
||||||
|
<option value="">— unknown —</option>
|
||||||
|
{SOURCES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.source === "GARDEN" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Plant</Label>
|
||||||
|
<select className={inputCls} value={form.plantId} onChange={e => set("plantId", e.target.value)}>
|
||||||
|
<option value="">— select a plant —</option>
|
||||||
|
{plants.map(p => (
|
||||||
|
<option key={p.id} value={p.id}>{p.commonName}{p.variety ? ` (${p.variety})` : ""}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Notes</Label>
|
||||||
|
<textarea className={inputCls} rows={2} value={form.notes} onChange={e => set("notes", e.target.value)} placeholder="Any notes…" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags — multi-label, e.g. "Pre-cooked" + "Poultry" */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Tags</Label>
|
||||||
|
<div className={`${inputCls} flex flex-wrap gap-1.5 min-h-[40px] cursor-text`} onClick={() => tagInputRef.current?.focus()}>
|
||||||
|
{tags.map(tag => (
|
||||||
|
<span key={tag} className="inline-flex items-center gap-1 rounded-full bg-primary/10 text-primary text-xs px-2 py-0.5">
|
||||||
|
{tag}
|
||||||
|
<button type="button" onClick={e => { e.stopPropagation(); removeTag(tag); }} className="hover:text-destructive">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<div className="relative flex-1 min-w-[120px]">
|
||||||
|
<input
|
||||||
|
ref={tagInputRef}
|
||||||
|
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
placeholder={tags.length ? "" : "e.g. Pre-cooked, Poultry…"}
|
||||||
|
value={tagInput}
|
||||||
|
onChange={e => setTagInput(e.target.value)}
|
||||||
|
onKeyDown={handleTagKeyDown}
|
||||||
|
onBlur={() => setTimeout(() => setShowTagDropdown(false), 150)}
|
||||||
|
/>
|
||||||
|
{showTagDropdown && (
|
||||||
|
<div className="absolute z-50 left-0 top-full mt-1 bg-popover border rounded-md shadow-md overflow-hidden min-w-[160px]">
|
||||||
|
{tagSuggestions.map(s => (
|
||||||
|
<button key={s} type="button" onMouseDown={() => addTag(s)}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm hover:bg-accent">
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{tagInput.trim() && !tagSuggestions.some(s => s.toLowerCase() === tagInput.trim().toLowerCase()) && (
|
||||||
|
<button type="button" onMouseDown={() => addTag(tagInput)}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-center gap-2 text-primary border-t">
|
||||||
|
<Plus className="h-3.5 w-3.5" /> Add “{tagInput.trim()}”
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Press Enter or comma to add. Tag things multiple ways, like “Pre-cooked” and “Poultry”.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? "Saving…" : item ? "Save" : "Add item"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
236
src/components/pantry/pull-dialog.tsx
Normal file
236
src/components/pantry/pull-dialog.tsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo, useRef } from "react";
|
||||||
|
import { PackageOpen } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { PANTRY_USE_REASONS, type PantryUseReason } from "@/lib/pantry/core";
|
||||||
|
import type { PantryItem } from "./pantry-client";
|
||||||
|
|
||||||
|
const QUICK_REASONS = ["Family dinner", "Dinner party", "Taking on a trip", "Meal prep", "Gave it away"];
|
||||||
|
|
||||||
|
type Pull = { amount: string };
|
||||||
|
|
||||||
|
export function PullDialog({ items, onSuccess }: { items: PantryItem[]; onSuccess: () => void }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [pulls, setPulls] = useState<Record<string, Pull>>({});
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [disposition, setDisposition] = useState<PantryUseReason>("consumed");
|
||||||
|
const [selectedReason, setSelectedReason] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
if (!q) return items;
|
||||||
|
return items.filter(i =>
|
||||||
|
i.name.toLowerCase().includes(q) ||
|
||||||
|
i.pantryCategory?.toLowerCase().includes(q) ||
|
||||||
|
i.pantrySubcategory?.toLowerCase().includes(q) ||
|
||||||
|
i.tags?.some(t => t.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
}, [items, search]);
|
||||||
|
|
||||||
|
const activePulls = Object.entries(pulls).filter(([, p]) => {
|
||||||
|
const n = parseFloat(p.amount);
|
||||||
|
return !isNaN(n) && n > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function setAmount(id: string, val: string) {
|
||||||
|
setPulls(prev => ({ ...prev, [id]: { amount: val } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickReason(r: string) {
|
||||||
|
if (selectedReason === r) { setSelectedReason(null); setReason(""); }
|
||||||
|
else { setSelectedReason(r); setReason(r); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setSearch("");
|
||||||
|
setPulls({});
|
||||||
|
setReason("");
|
||||||
|
setDisposition("consumed");
|
||||||
|
setSelectedReason(null);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (activePulls.length === 0) { setError("Enter a quantity for at least one item."); return; }
|
||||||
|
|
||||||
|
// Validate amounts against stock
|
||||||
|
for (const [id, p] of activePulls) {
|
||||||
|
const item = items.find(i => i.id === id);
|
||||||
|
const amt = parseFloat(p.amount);
|
||||||
|
if (item && amt > item.quantity) {
|
||||||
|
setError(`Only ${item.quantity} ${item.unit ?? ""} of "${item.name}" on hand.`.trim());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/pantry/bulk-use", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
pulls: activePulls.map(([id, p]) => ({ id, amount: parseFloat(p.amount) })),
|
||||||
|
notes: reason.trim() || null,
|
||||||
|
reason: disposition,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error ?? "Failed");
|
||||||
|
if (data.skipped?.length > 0) {
|
||||||
|
// Someone else deleted these since the list loaded — say so instead of
|
||||||
|
// pretending the whole pull worked.
|
||||||
|
const names = data.skipped.map(
|
||||||
|
(s: { id: string }) => items.find(i => i.id === s.id)?.name ?? "an item",
|
||||||
|
);
|
||||||
|
setPulls({});
|
||||||
|
setError(
|
||||||
|
`Couldn't pull ${names.join(", ")} — no longer in the pantry. Everything else was pulled.`,
|
||||||
|
);
|
||||||
|
onSuccess();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
reset();
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err).replace("Error: ", ""));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls = "w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => { reset(); setOpen(true); }} className="gap-2">
|
||||||
|
<PackageOpen className="h-4 w-4" /> Pull from pantry
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={o => { setOpen(o); if (!o) reset(); }}>
|
||||||
|
<DialogContent className="max-w-lg max-h-[90vh] flex flex-col p-0">
|
||||||
|
<DialogHeader className="px-6 pt-6 pb-0 shrink-0">
|
||||||
|
<DialogTitle>Pull from pantry</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
|
||||||
|
{/* Reason */}
|
||||||
|
<div className="px-6 pt-4 pb-3 border-b space-y-2 shrink-0">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{PANTRY_USE_REASONS.map(r => (
|
||||||
|
<button key={r.value} type="button" onClick={() => setDisposition(r.value)}
|
||||||
|
className={cn("rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||||
|
disposition === r.value ? "bg-primary text-primary-foreground border-primary" : "bg-background hover:bg-accent"
|
||||||
|
)}>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Label className="text-xs text-muted-foreground">What's it for? (optional)</Label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{QUICK_REASONS.map(r => (
|
||||||
|
<button key={r} type="button" onClick={() => pickReason(r)}
|
||||||
|
className={cn("rounded-full border px-3 py-1 text-xs",
|
||||||
|
selectedReason === r ? "bg-primary text-primary-foreground border-primary" : "bg-background hover:bg-accent"
|
||||||
|
)}>
|
||||||
|
{r}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
placeholder="Or type your own reason…"
|
||||||
|
value={reason}
|
||||||
|
onChange={e => { setReason(e.target.value); setSelectedReason(null); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="px-6 py-3 border-b shrink-0">
|
||||||
|
<input
|
||||||
|
ref={searchRef}
|
||||||
|
className={inputCls}
|
||||||
|
placeholder="Search by name, category, or tag…"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items list */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-2">
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">No items match.</p>
|
||||||
|
)}
|
||||||
|
{filtered.map(item => {
|
||||||
|
const amt = pulls[item.id]?.amount ?? "";
|
||||||
|
const n = parseFloat(amt);
|
||||||
|
const active = !isNaN(n) && n > 0;
|
||||||
|
const over = active && n > item.quantity;
|
||||||
|
return (
|
||||||
|
<div key={item.id}
|
||||||
|
className={cn("flex items-center gap-3 py-2.5 border-b last:border-0",
|
||||||
|
active && "bg-primary/5 -mx-6 px-6 rounded"
|
||||||
|
)}>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{item.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{item.quantity} {item.unit ?? "on hand"}
|
||||||
|
{item.pantryCategory && <> · {item.pantryCategory}</>}
|
||||||
|
{item.tags?.length > 0 && <> · {item.tags.join(", ")}</>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 w-24">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-md border px-2 py-1.5 text-sm text-center focus:outline-none focus:ring-2 focus:ring-ring",
|
||||||
|
over && "border-destructive text-destructive",
|
||||||
|
active && !over && "border-primary"
|
||||||
|
)}
|
||||||
|
placeholder="0"
|
||||||
|
value={amt}
|
||||||
|
onChange={e => setAmount(item.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
{over && <p className="text-[10px] text-destructive text-center mt-0.5">Max {item.quantity}</p>}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0 w-8">{item.unit ?? ""}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-6 py-4 border-t shrink-0 space-y-3">
|
||||||
|
{activePulls.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Pulling {activePulls.length} item{activePulls.length !== 1 ? "s" : ""}
|
||||||
|
{reason && <> for <strong>{reason}</strong></>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={saving || activePulls.length === 0}>
|
||||||
|
{saving ? "Saving…" : activePulls.length > 0 ? `Pull ${activePulls.length} item${activePulls.length !== 1 ? "s" : ""}` : "Pull"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
src/components/pantry/use-item-dialog.tsx
Normal file
153
src/components/pantry/use-item-dialog.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { Minus } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { PANTRY_USE_REASONS, type PantryUseReason } from "@/lib/pantry/core";
|
||||||
|
import type { PantryItem } from "./pantry-client";
|
||||||
|
|
||||||
|
const QUICK_REASONS = ["Family dinner", "Dinner party", "Taking on a trip", "Meal prep", "Gave it away"];
|
||||||
|
|
||||||
|
export function UseItemDialog({ item, onSuccess }: { item: PantryItem; onSuccess: () => void }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [amount, setAmount] = useState("1");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [disposition, setDisposition] = useState<PantryUseReason>("consumed");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedReason, setSelectedReason] = useState<string | null>(null);
|
||||||
|
const notesRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
function pickReason(reason: string) {
|
||||||
|
if (selectedReason === reason) {
|
||||||
|
setSelectedReason(null);
|
||||||
|
setNotes("");
|
||||||
|
} else {
|
||||||
|
setSelectedReason(reason);
|
||||||
|
setNotes(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const parsed = parseFloat(amount);
|
||||||
|
if (!parsed || parsed <= 0) {
|
||||||
|
setError("Enter an amount greater than 0");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed > item.quantity) {
|
||||||
|
setError(`Only ${item.quantity} ${item.unit ?? ""} on hand`.trim());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/pantry/${item.id}/use`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ amount: parseFloat(amount), notes: notes.trim() || null, reason: disposition }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.json()).error ?? "Failed");
|
||||||
|
setOpen(false);
|
||||||
|
setAmount("1");
|
||||||
|
setNotes("");
|
||||||
|
setDisposition("consumed");
|
||||||
|
setSelectedReason(null);
|
||||||
|
onSuccess();
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err).replace("Error: ", ""));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls = "w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setOpen(true)} title="Use some">
|
||||||
|
<Minus className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={o => { setOpen(o); if (!o) { setSelectedReason(null); setNotes(""); } }}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Use {item.name}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} noValidate className="space-y-4 py-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Currently: <strong>{item.quantity} {item.unit ?? ""}</strong>
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Reason</Label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{PANTRY_USE_REASONS.map(r => (
|
||||||
|
<button
|
||||||
|
key={r.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDisposition(r.value)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border px-3 py-1 text-xs transition-colors",
|
||||||
|
disposition === r.value
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "bg-background hover:bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>How much are you using? ({item.unit ?? "units"})</Label>
|
||||||
|
<input
|
||||||
|
className={inputCls}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
value={amount}
|
||||||
|
onChange={e => setAmount(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>What's it for? (optional)</Label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{QUICK_REASONS.map(reason => (
|
||||||
|
<button
|
||||||
|
key={reason}
|
||||||
|
type="button"
|
||||||
|
onClick={() => pickReason(reason)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border px-3 py-1 text-xs",
|
||||||
|
selectedReason === reason
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "bg-background hover:bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{reason}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={notesRef}
|
||||||
|
className={inputCls}
|
||||||
|
value={notes}
|
||||||
|
onChange={e => { setNotes(e.target.value); setSelectedReason(null); }}
|
||||||
|
placeholder="e.g. used in chicken soup, or pick a tag above"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? "Saving…" : "Use"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
src/components/ui/combobox.tsx
Normal file
146
src/components/ui/combobox.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
import { Check, ChevronsUpDown, Plus, Search } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type ComboOption = { value: string; label: string };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
options: ComboOption[];
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
emptyText?: string;
|
||||||
|
allowCustom?: boolean; // show "Add '[query]'" when no exact match
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Combobox({ options, value, onChange, placeholder = "Select…", disabled, emptyText = "No results", allowCustom = false }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const filtered = query
|
||||||
|
? options.filter(o => o.label.toLowerCase().includes(query.toLowerCase()))
|
||||||
|
: options;
|
||||||
|
|
||||||
|
// Show "add" option when allowCustom and the query isn't already an exact match
|
||||||
|
const trimmed = query.trim();
|
||||||
|
const exactMatch = options.some(o => o.label.toLowerCase() === trimmed.toLowerCase());
|
||||||
|
const showAdd = allowCustom && trimmed.length > 0 && !exactMatch;
|
||||||
|
|
||||||
|
// Display: if value matches a known option use its label, otherwise show value as-is (custom)
|
||||||
|
const selected = options.find(o => o.value === value);
|
||||||
|
const displayValue = selected?.label ?? value;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setQuery("");
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 10);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
function pick(val: string) {
|
||||||
|
onChange(val);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover.Root open={open} onOpenChange={disabled ? undefined : setOpen}>
|
||||||
|
<Popover.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center justify-between rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring",
|
||||||
|
disabled && "opacity-50 cursor-not-allowed",
|
||||||
|
!displayValue && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{displayValue || placeholder}</span>
|
||||||
|
<ChevronsUpDown className="h-4 w-4 text-muted-foreground shrink-0 ml-2" />
|
||||||
|
</button>
|
||||||
|
</Popover.Trigger>
|
||||||
|
|
||||||
|
<Popover.Portal>
|
||||||
|
<Popover.Content
|
||||||
|
className="z-50 w-[var(--radix-popover-trigger-width)] rounded-md border bg-popover shadow-md p-0 overflow-hidden"
|
||||||
|
sideOffset={4}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
{/* Search input */}
|
||||||
|
<div className="flex items-center border-b px-3 py-2 gap-2">
|
||||||
|
<Search className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
placeholder="Search…"
|
||||||
|
value={query}
|
||||||
|
onChange={e => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Options list */}
|
||||||
|
<div className="max-h-56 overflow-y-auto py-1">
|
||||||
|
{/* Clear option */}
|
||||||
|
{value && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full text-left px-3 py-2 text-sm text-muted-foreground hover:bg-accent"
|
||||||
|
onClick={() => pick("")}
|
||||||
|
>
|
||||||
|
— clear —
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filtered.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-center justify-between gap-2",
|
||||||
|
opt.value === value && "font-medium"
|
||||||
|
)}
|
||||||
|
onClick={() => pick(opt.value)}
|
||||||
|
>
|
||||||
|
<span>{opt.label}</span>
|
||||||
|
{opt.value === value && <Check className="h-3.5 w-3.5 text-primary shrink-0" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{filtered.length === 0 && !allowCustom && (
|
||||||
|
<p className="px-3 py-4 text-sm text-muted-foreground text-center">{emptyText}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add custom option — shows as "Add '[query]'" when typing, or "+ New" hint when idle */}
|
||||||
|
{allowCustom && (
|
||||||
|
<div className="border-t mt-1">
|
||||||
|
{showAdd ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-center gap-2 text-primary"
|
||||||
|
onClick={() => pick(trimmed)}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
Add “{trimmed}”
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-center gap-2 text-muted-foreground"
|
||||||
|
onClick={() => inputRef.current?.focus()}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
Add new…
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -81,6 +81,11 @@ export async function authenticateRequest(req: Request, required?: string): Prom
|
|||||||
return { userId: tok.userId, role: tok.user.role, scopes: tok.scopes, via: "token" };
|
return { userId: tok.userId, role: tok.user.role, scopes: tok.scopes, via: "token" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return sessionContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AuthContext for server components (cookie session only, full scope). */
|
||||||
|
export async function sessionContext(): Promise<AuthContext> {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
if (!session) throw new ApiError(401, "Unauthorized");
|
if (!session) throw new ApiError(401, "Unauthorized");
|
||||||
return { userId: session.user.id, role: session.user.role, scopes: ["*"], via: "session" };
|
return { userId: session.user.id, role: session.user.role, scopes: ["*"], via: "session" };
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
// Zod schemas shared by the REST API and (later) the MCP tool definitions, so
|
// Zod schemas shared by the REST API and (later) the MCP tool definitions, so
|
||||||
// validation lives in exactly one place.
|
// validation lives in exactly one place.
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { PANTRY_USE_REASON_VALUES } from "@/lib/pantry/core";
|
||||||
|
|
||||||
export const plantLogInput = z.object({
|
export const plantLogInput = z.object({
|
||||||
type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]),
|
type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
quantity: z.string().optional(),
|
quantity: z.string().optional(), // legacy freeform; prefer amount+unit
|
||||||
|
amount: z.coerce.number().nonnegative().nullable().optional(),
|
||||||
|
unit: z.string().optional(),
|
||||||
date: z.string().optional(), // ISO date; defaults to now
|
date: z.string().optional(), // ISO date; defaults to now
|
||||||
|
// HARVEST only: also create a pantry item (GARDEN source, linked to the plant)
|
||||||
|
pantry: z
|
||||||
|
.object({
|
||||||
|
locationId: z.string().nullable().optional(),
|
||||||
|
bestBefore: z.string().nullable().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const taskCreateInput = z.object({
|
export const taskCreateInput = z.object({
|
||||||
@@ -16,6 +26,7 @@ export const taskCreateInput = z.object({
|
|||||||
plantId: z.string().optional(),
|
plantId: z.string().optional(),
|
||||||
itemId: z.string().optional(),
|
itemId: z.string().optional(),
|
||||||
animalId: z.string().optional(),
|
animalId: z.string().optional(),
|
||||||
|
locationId: z.string().optional(),
|
||||||
recurrence: z.enum(["ONCE", "CUSTOM", "MONTHLY", "YEARLY"]).default("ONCE"),
|
recurrence: z.enum(["ONCE", "CUSTOM", "MONTHLY", "YEARLY"]).default("ONCE"),
|
||||||
intervalDays: z.number().int().positive().optional(),
|
intervalDays: z.number().int().positive().optional(),
|
||||||
month: z.number().int().min(1).max(12).optional(),
|
month: z.number().int().min(1).max(12).optional(),
|
||||||
@@ -58,12 +69,53 @@ export const itemCreateInput = z.object({
|
|||||||
export const itemUpdateInput = itemCreateInput.partial();
|
export const itemUpdateInput = itemCreateInput.partial();
|
||||||
|
|
||||||
export const itemLogInput = z.object({
|
export const itemLogInput = z.object({
|
||||||
type: z.enum(["NOTE", "MAINTENANCE", "MOVED", "PURCHASED", "REPAIRED", "DISPOSED"]),
|
type: z.enum(["NOTE", "MAINTENANCE", "MOVED", "PURCHASED", "REPAIRED", "DISPOSED", "USED"]),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
cost: z.coerce.number().nonnegative().nullable().optional(),
|
cost: z.coerce.number().nonnegative().nullable().optional(),
|
||||||
|
amount: z.coerce.number().nonnegative().nullable().optional(),
|
||||||
date: z.string().optional(),
|
date: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Pantry (consumable items; the UI's Kitchen → Pantry & Freezer) ---
|
||||||
|
|
||||||
|
export const pantryCreateInput = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
description: z.string().nullable().optional(),
|
||||||
|
quantity: z.coerce.number().min(0).default(1),
|
||||||
|
unit: z.string().nullable().optional(),
|
||||||
|
packageSize: z.string().nullable().optional(), // legacy alias for description
|
||||||
|
locationId: z.string().nullable().optional(),
|
||||||
|
storedAt: z.string().nullable().optional(),
|
||||||
|
bestBefore: z.string().nullable().optional(),
|
||||||
|
source: z.enum(["GARDEN", "HOMEMADE", "STORE", "FARMERS_MARKET", "GIFTED", "OTHER"]).nullable().optional(),
|
||||||
|
plantId: z.string().nullable().optional(),
|
||||||
|
pantryCategory: z.string().nullable().optional(),
|
||||||
|
pantrySubcategory: z.string().nullable().optional(),
|
||||||
|
pricePaid: z.coerce.number().nonnegative().nullable().optional(),
|
||||||
|
priceUnit: z.string().nullable().optional(),
|
||||||
|
notes: z.string().nullable().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pantryUpdateInput = pantryCreateInput.partial();
|
||||||
|
|
||||||
|
export const pantryUseInput = z.object({
|
||||||
|
amount: z.coerce.number().positive(),
|
||||||
|
notes: z.string().nullable().optional(),
|
||||||
|
reason: z.enum(PANTRY_USE_REASON_VALUES).nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pantryBulkUseInput = z.object({
|
||||||
|
pulls: z.array(z.object({ id: z.string(), amount: z.coerce.number().positive() })).min(1),
|
||||||
|
notes: z.string().nullable().optional(),
|
||||||
|
reason: z.enum(PANTRY_USE_REASON_VALUES).nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PantryCreateInput = z.infer<typeof pantryCreateInput>;
|
||||||
|
export type PantryUpdateInput = z.infer<typeof pantryUpdateInput>;
|
||||||
|
export type PantryUseInput = z.infer<typeof pantryUseInput>;
|
||||||
|
export type PantryBulkUseInput = z.infer<typeof pantryBulkUseInput>;
|
||||||
|
|
||||||
export type PlantLogInput = z.infer<typeof plantLogInput>;
|
export type PlantLogInput = z.infer<typeof plantLogInput>;
|
||||||
export type TaskCreateInput = z.infer<typeof taskCreateInput>;
|
export type TaskCreateInput = z.infer<typeof taskCreateInput>;
|
||||||
export type TaskCompleteInput = z.infer<typeof taskCompleteInput>;
|
export type TaskCompleteInput = z.infer<typeof taskCompleteInput>;
|
||||||
|
|||||||
@@ -8,6 +8,166 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.30.0",
|
||||||
|
date: "2026-07-12",
|
||||||
|
changes: [
|
||||||
|
"Added the floating feedback/idea button (bottom-right corner) that the shop and bakery apps have — jot a quick idea or bug from any page, paste in a screenshot, and it files straight to the project. The header lightbulb still opens the full list.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.29.0",
|
||||||
|
date: "2026-07-12",
|
||||||
|
changes: [
|
||||||
|
"New Ramble page — a sticky-note board for dumping stray thoughts and ideas that aren't tasks yet. Pin the important ones, archive the ones you're done with.",
|
||||||
|
"Dark mode is back: a sun/moon button in the top bar flips the whole app between light and dark.",
|
||||||
|
"When you pull something from the pantry you can now say why — Immediate use, Preserved, Gave away, or Waste — so we can actually see what's getting eaten vs. thrown out.",
|
||||||
|
"Cleaned up the Suggestions page so it's readable and photo thumbnails show up.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.28.1",
|
||||||
|
date: "2026-07-10",
|
||||||
|
changes: [
|
||||||
|
"The little feedback window's subtitle now says “Bugs, ideas, anything.” instead of “We read every one.” — same window, less cheese.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.28.0",
|
||||||
|
date: "2026-07-06",
|
||||||
|
changes: [
|
||||||
|
"Fixed: adding a new plant type by hand failed with a confusing \"expected boolean\" error when \"Toxic to pets?\" was left as Unknown. Leaving it Unknown works now.",
|
||||||
|
"New \"Root vegetable\" category for plants and plant types — carrots, beets, sweet potatoes, radishes.",
|
||||||
|
"The garden brain now knows sweet potatoes (including Japanese ones): plant slips in warm June soil, dig before the first fall frost, cure warm to sweeten.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.27.0",
|
||||||
|
date: "2026-07-06",
|
||||||
|
changes: [
|
||||||
|
"The garden got a brain! The Calendar now shows \"Good to do this month\" — timely advice matched to what you actually grow, tuned for our zone (4b, ~May 15 last frost): when to prune the raspberries, start tomato seeds, plant garlic, divide the irises, and more. Each tip has a \"Remind me\" button that turns it into a yearly reminder, and a link to read more at UW–Madison Extension.",
|
||||||
|
"Plant type pages now show a \"Growing in zone 4b\" card: the care timeline for that plant, what it grows well next to, what to keep it away from, and its crop-rotation family (so you know not to plant tomatoes where the potatoes just were).",
|
||||||
|
"This starts with 28 common crops, fruits, and flowers — it'll grow over time.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.26.0",
|
||||||
|
date: "2026-07-06",
|
||||||
|
changes: [
|
||||||
|
"New Calendar page (in the menu, next to Reminders) — a month view of everything due around the homestead. Garden chores, furnace filter, vacuum servicing: one calendar. Repeating reminders show every time they'll come up, not just the next one.",
|
||||||
|
"The calendar also knows your garden's seasons: chips at the top of each month show what's in bloom and what's ready to harvest, based on the bloom/harvest months saved on your plant types.",
|
||||||
|
"Click any day to see its tasks and mark them done right there; anything overdue is flagged at the top with a link to Reminders.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.25.0",
|
||||||
|
date: "2026-07-06",
|
||||||
|
changes: [
|
||||||
|
"The Garden got a makeover! Every plant now shows as a photo card, always visible — no more clicking through folded-up lists. Find things with the new search box or the filter chips (your groups, or \"By place\").",
|
||||||
|
"Log without leaving the page: every card has three little buttons — the water drop logs \"Watered\" in one tap, the eye lets you jot a quick note, and the basket logs a harvest (with the amount and the send-to-pantry option). A small green \"logged\" flash confirms it saved.",
|
||||||
|
"New \"This week in your garden\" journal at the bottom of the Garden page — your recent activity across all plants, readable like a diary.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.24.0",
|
||||||
|
date: "2026-07-06",
|
||||||
|
changes: [
|
||||||
|
"Garden: logging a harvest now asks how much as a real number with a unit (2.5 lbs, 12 each) — so the app can start totaling what each plant gives you. Old \"a big handful\" entries still show fine.",
|
||||||
|
"Garden → Kitchen: when you log a harvest there's a new \"Add to Pantry & Freezer\" checkbox — tick it, pick where it's stored, and the harvest lands in the pantry automatically, linked to the plant and marked as from Our garden.",
|
||||||
|
"Plant types can now link to a UW–Madison Extension article: open a plant type, click Edit → \"Look up on UW Extension\", and pick the matching article. The link shows on the type page and on each of those plants.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.23.0",
|
||||||
|
date: "2026-07-06",
|
||||||
|
changes: [
|
||||||
|
"Pantry: pulls are now recorded properly — each one saves the exact amount taken, so we can later see how fast things get used and what a dinner or trip actually consumed.",
|
||||||
|
"Pantry: if someone else deleted an item while you had the Pull dialog open, it now tells you which items couldn't be pulled instead of quietly skipping them.",
|
||||||
|
"Behind the scenes: the Pantry now runs on the same modern plumbing as Inventory and Animals, so API tokens (and the future Claude connection) can read and update it too.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.22.0",
|
||||||
|
date: "2026-07-04",
|
||||||
|
changes: [
|
||||||
|
"Inventory can now be searched and regrouped — there's a search box (matches item names, locations, and labels) and a Location / Label toggle to group your stuff either way.",
|
||||||
|
"Fixed the Suggestions page — the boxes and the \"Send suggestion\" button were rendering unstyled (dark, unreadable inputs and an invisible button). They now look right and are readable.",
|
||||||
|
"Searching now finds things stored inside containers too — search \"garage\" and you'll see the drill inside the garage toolbox, not just the toolbox.",
|
||||||
|
"Deleting a container (like a toolbox) now leaves its contents in its spot — a wrench in a deleted garage toolbox becomes a wrench loose in the garage, instead of disappearing.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.21.1",
|
||||||
|
date: "2026-06-29",
|
||||||
|
changes: [
|
||||||
|
"Pantry: trash icon on each item — click it, confirm with Yes/No. Removes the item from the inventory.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.21.0",
|
||||||
|
date: "2026-06-29",
|
||||||
|
changes: [
|
||||||
|
"Pantry: new \"Pull from pantry\" button — search for items, type quantities next to everything you're taking, pick a reason, and submit all at once. No more opening each item individually.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.20.0",
|
||||||
|
date: "2026-06-29",
|
||||||
|
changes: [
|
||||||
|
"Pantry: items can now have multiple tags — type them in the new Tags field when adding or editing an item (press Enter or comma to add each one, like \"Pre-cooked\" and \"Poultry\"). Tags show up as small chips on each item row. Previously-used tags autocomplete as you type.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.19.2",
|
||||||
|
date: "2026-06-29",
|
||||||
|
changes: [
|
||||||
|
"Pantry: \"Add new…\" now shows at the bottom of the Category and Subcategory dropdowns at all times — click it, type the new name, and it becomes the \"Add [name]\" option.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.19.1",
|
||||||
|
date: "2026-06-29",
|
||||||
|
changes: [
|
||||||
|
"Pantry: the quantity up/down arrows now step by whole numbers instead of quarters — you can still type a partial amount like 2.5 directly.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.19.0",
|
||||||
|
date: "2026-06-29",
|
||||||
|
changes: [
|
||||||
|
"Pantry: when you use something, you can now tag why with one tap — Family dinner, Dinner party, Taking on a trip, Meal prep, Gave it away — or type your own note instead.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.18.1",
|
||||||
|
date: "2026-06-29",
|
||||||
|
changes: [
|
||||||
|
"Pantry: you can now add a brand-new category or subcategory on the fly — just type the name into the Category or Subcategory dropdown and pick \"Add [name]\" at the bottom of the list. New categories are remembered in future add-item dialogs.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.18.0",
|
||||||
|
date: "2026-06-16",
|
||||||
|
changes: [
|
||||||
|
"Locations \"Quick setup\" — on the Locations page, click Quick setup and it's pre-filled with the whole house layout (rooms, basement, garage, sheds, yard). Click Create and the tree is built; it can also sweep all your existing garden areas under a single \"Yard\".",
|
||||||
|
"Reminders can now be attached to a place — e.g. \"clean gutters every fall\" on the house exterior, or \"change the furnace filter.\" Add one from a location's menu; it shows in Reminders with the spot.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.17.0",
|
||||||
|
date: "2026-06-16",
|
||||||
|
changes: [
|
||||||
|
"Mobile now works — there's a menu button (top-left) that slides out the full navigation, so you can get around on your phone.",
|
||||||
|
"Added a lightbulb button in the top bar that jumps straight to Suggestions, and made the Suggestions page readable (it was washed-out gray on the app's background).",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.16.1",
|
||||||
|
date: "2026-06-16",
|
||||||
|
changes: [
|
||||||
|
"Fixed: after you set a new password, the app could keep asking you to change it again on every visit. It now clears the moment you save your new password.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.16.0",
|
version: "0.16.0",
|
||||||
date: "2026-06-16",
|
date: "2026-06-16",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const CATEGORY_LABELS: Record<PlantCategory, string> = {
|
|||||||
GROUNDCOVER: "Groundcover",
|
GROUNDCOVER: "Groundcover",
|
||||||
VINE: "Vine",
|
VINE: "Vine",
|
||||||
ANNUAL_VEGETABLE: "Annual vegetable",
|
ANNUAL_VEGETABLE: "Annual vegetable",
|
||||||
|
ROOT_VEGETABLE: "Root vegetable",
|
||||||
ANNUAL: "Annual vegetable",
|
ANNUAL: "Annual vegetable",
|
||||||
ANNUAL_FLOWER: "Annual flower",
|
ANNUAL_FLOWER: "Annual flower",
|
||||||
PERENNIAL: "Perennial",
|
PERENNIAL: "Perennial",
|
||||||
@@ -29,6 +30,7 @@ export const CATEGORY_ORDER: PlantCategory[] = [
|
|||||||
"PERENNIAL",
|
"PERENNIAL",
|
||||||
"PERENNIAL_FLOWER",
|
"PERENNIAL_FLOWER",
|
||||||
"ANNUAL_VEGETABLE",
|
"ANNUAL_VEGETABLE",
|
||||||
|
"ROOT_VEGETABLE",
|
||||||
"ANNUAL_FLOWER",
|
"ANNUAL_FLOWER",
|
||||||
"BULB",
|
"BULB",
|
||||||
"MUSHROOM",
|
"MUSHROOM",
|
||||||
|
|||||||
82
src/lib/garden/knowledge.test.ts
Normal file
82
src/lib/garden/knowledge.test.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { knowledgeFor, tipsForMonth, uwSearchUrl, KNOWLEDGE } from "./knowledge";
|
||||||
|
|
||||||
|
describe("knowledgeFor", () => {
|
||||||
|
it("matches exact and case-insensitive names", () => {
|
||||||
|
expect(knowledgeFor("Tomato")?.name).toBe("Tomato");
|
||||||
|
expect(knowledgeFor("raspberry")?.name).toBe("Raspberry");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches plurals and richer plant names", () => {
|
||||||
|
expect(knowledgeFor("Raspberries")?.name).toBe("Raspberry");
|
||||||
|
expect(knowledgeFor("Heritage Raspberry")?.name).toBe("Raspberry");
|
||||||
|
expect(knowledgeFor("Bearded Iris")?.name).toBe("Iris");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches via aliases", () => {
|
||||||
|
expect(knowledgeFor("Zucchini")?.name).toBe("Squash");
|
||||||
|
expect(knowledgeFor("Kale")?.name).toBe("Broccoli");
|
||||||
|
expect(knowledgeFor("Pear")?.name).toBe("Apple");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for unknown plants", () => {
|
||||||
|
expect(knowledgeFor("Dusty Miller")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the most specific match — sweet potatoes are not potatoes", () => {
|
||||||
|
expect(knowledgeFor("Sweet potato")?.name).toBe("Sweet potato");
|
||||||
|
expect(knowledgeFor("Japanese Sweet Potato")?.name).toBe("Sweet potato");
|
||||||
|
expect(knowledgeFor("Potato")?.name).toBe("Potato");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tipsForMonth", () => {
|
||||||
|
it("only suggests for plants actually grown", () => {
|
||||||
|
const march = tipsForMonth(3, ["Raspberry", "Bearded Iris"]);
|
||||||
|
const actions = march.map((t) => t.action);
|
||||||
|
expect(actions).toContain("Prune raspberries");
|
||||||
|
expect(actions).not.toContain("Prune apple and pear trees");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes general homestead tips regardless of plants", () => {
|
||||||
|
const nov = tipsForMonth(11, []);
|
||||||
|
expect(nov.some((t) => t.crop === null && t.action.includes("Winter-mulch"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suggests only the best-match crop — no potato-hilling for sweet potatoes", () => {
|
||||||
|
const june = tipsForMonth(6, ["Japanese sweet potato"]);
|
||||||
|
const actions = june.map((t) => t.action);
|
||||||
|
expect(actions).toContain("Plant sweet potato slips");
|
||||||
|
expect(actions).not.toContain("Hill potatoes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes when several plants match the same crop", () => {
|
||||||
|
const july = tipsForMonth(7, ["Bearded Iris", "Iris sibirica", "Yellow Flag Iris"]);
|
||||||
|
expect(july.filter((t) => t.crop === "Iris")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every suggestion carries a UW link", () => {
|
||||||
|
for (const t of tipsForMonth(6, ["Tomato", "Garlic", "Lilac"])) {
|
||||||
|
expect(t.uwUrl).toMatch(/^https:\/\/hort\.extension\.wisc\.edu\/\?s=/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("knowledge data sanity", () => {
|
||||||
|
it("all months are 1–12 and every crop has tips + a query", () => {
|
||||||
|
for (const crop of KNOWLEDGE) {
|
||||||
|
expect(crop.tips.length).toBeGreaterThan(0);
|
||||||
|
expect(crop.uwQuery.length).toBeGreaterThan(0);
|
||||||
|
for (const tip of crop.tips) {
|
||||||
|
for (const m of tip.months) {
|
||||||
|
expect(m).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(m).toBeLessThanOrEqual(12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uwSearchUrl encodes queries", () => {
|
||||||
|
expect(uwSearchUrl("growing tomatoes")).toBe("https://hort.extension.wisc.edu/?s=growing%20tomatoes");
|
||||||
|
});
|
||||||
|
});
|
||||||
461
src/lib/garden/knowledge.ts
Normal file
461
src/lib/garden/knowledge.ts
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
// The garden brain — curated zone-4b (Eau Claire, WI) growing knowledge:
|
||||||
|
// month-by-month care timing, companion friends/foes, and crop-rotation
|
||||||
|
// families. Timing assumes ~May 15 last frost and ~Oct 1 first frost.
|
||||||
|
//
|
||||||
|
// This is starter reference data distilled from standard extension guidance;
|
||||||
|
// every entry links to a UW–Madison Extension site search (uwUrl) so the full
|
||||||
|
// article is one click away. Curated by hand — extend freely, keep it honest.
|
||||||
|
|
||||||
|
export type RotationFamily =
|
||||||
|
| "nightshade" | "legume" | "brassica" | "allium" | "cucurbit"
|
||||||
|
| "root" | "leafy" | "corn" | "perennial";
|
||||||
|
|
||||||
|
export type CareTip = {
|
||||||
|
months: number[]; // 1–12, zone 4b
|
||||||
|
action: string; // short imperative — becomes a suggested reminder title
|
||||||
|
detail?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CropKnowledge = {
|
||||||
|
name: string;
|
||||||
|
aliases?: string[];
|
||||||
|
family?: RotationFamily;
|
||||||
|
friends?: string[];
|
||||||
|
foes?: string[];
|
||||||
|
rotationNote?: string;
|
||||||
|
tips: CareTip[];
|
||||||
|
uwQuery: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function uwSearchUrl(query: string): string {
|
||||||
|
return `https://hort.extension.wisc.edu/?s=${encodeURIComponent(query)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ROTATION_LABELS: Record<RotationFamily, string> = {
|
||||||
|
nightshade: "Nightshades (tomato, pepper, potato, eggplant)",
|
||||||
|
legume: "Legumes (beans, peas)",
|
||||||
|
brassica: "Brassicas (cabbage, broccoli, kale…)",
|
||||||
|
allium: "Alliums (onion, garlic, leek)",
|
||||||
|
cucurbit: "Cucurbits (squash, cucumber, melon)",
|
||||||
|
root: "Roots (carrot, beet, radish)",
|
||||||
|
leafy: "Leafy greens (lettuce, spinach, chard)",
|
||||||
|
corn: "Corn",
|
||||||
|
perennial: "Perennial (stays put — no rotation)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const KNOWLEDGE: CropKnowledge[] = [
|
||||||
|
{
|
||||||
|
name: "Tomato",
|
||||||
|
family: "nightshade",
|
||||||
|
friends: ["Basil", "Carrot", "Onion", "Marigold"],
|
||||||
|
foes: ["Cabbage family", "Fennel", "Potato", "Corn"],
|
||||||
|
rotationNote: "Don't replant where tomatoes, peppers, or potatoes grew in the last 3 years — shared soil diseases build up.",
|
||||||
|
uwQuery: "growing tomatoes",
|
||||||
|
tips: [
|
||||||
|
{ months: [4], action: "Start tomato seeds indoors", detail: "6 weeks before the ~May 15 frost date." },
|
||||||
|
{ months: [5, 6], action: "Harden off and transplant tomatoes", detail: "Out after frost danger — late May into June." },
|
||||||
|
{ months: [6, 7, 8], action: "Water tomatoes deeply and evenly", detail: "Uneven watering invites blossom-end rot; mulch helps." },
|
||||||
|
{ months: [9], action: "Pick remaining green tomatoes before frost", detail: "They ripen indoors on the counter." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Pepper",
|
||||||
|
family: "nightshade",
|
||||||
|
friends: ["Basil", "Onion"],
|
||||||
|
foes: ["Fennel"],
|
||||||
|
rotationNote: "Rotate with the other nightshades — 3 years out of the same bed.",
|
||||||
|
uwQuery: "growing peppers",
|
||||||
|
tips: [
|
||||||
|
{ months: [3, 4], action: "Start pepper seeds indoors", detail: "Peppers are slow — 8 weeks before frost." },
|
||||||
|
{ months: [6], action: "Transplant peppers", detail: "They sulk in cold soil; wait for warm June nights." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Potato",
|
||||||
|
family: "nightshade",
|
||||||
|
friends: ["Beans", "Corn", "Cabbage"],
|
||||||
|
foes: ["Tomato", "Squash", "Cucumber", "Sunflower"],
|
||||||
|
rotationNote: "Shares blight with tomatoes — keep them apart and rotate 3 years.",
|
||||||
|
uwQuery: "growing potatoes",
|
||||||
|
tips: [
|
||||||
|
{ months: [5], action: "Plant seed potatoes", detail: "Mid-May, once soil works easily." },
|
||||||
|
{ months: [6, 7], action: "Hill potatoes", detail: "Mound soil over the row so tubers never see sun." },
|
||||||
|
{ months: [9], action: "Dig potatoes", detail: "After the vines die back; cure before storing." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Bean",
|
||||||
|
aliases: ["Green bean", "Pole bean", "Bush bean"],
|
||||||
|
family: "legume",
|
||||||
|
friends: ["Corn", "Squash", "Carrot"],
|
||||||
|
foes: ["Onion", "Garlic", "Leek"],
|
||||||
|
rotationNote: "Legumes feed the soil — a great crop to grow before heavy feeders like brassicas or corn.",
|
||||||
|
uwQuery: "growing beans",
|
||||||
|
tips: [
|
||||||
|
{ months: [5, 6], action: "Direct-sow beans", detail: "After frost; succession-sow every 2 weeks for a steady picking." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Pea",
|
||||||
|
family: "legume",
|
||||||
|
friends: ["Carrot", "Radish"],
|
||||||
|
foes: ["Onion", "Garlic"],
|
||||||
|
uwQuery: "growing peas",
|
||||||
|
tips: [
|
||||||
|
{ months: [4, 5], action: "Direct-sow peas", detail: "As early as the soil can be worked — peas love cold feet." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Garlic",
|
||||||
|
family: "allium",
|
||||||
|
friends: ["Tomato", "Cabbage family"],
|
||||||
|
foes: ["Beans", "Peas"],
|
||||||
|
uwQuery: "growing garlic",
|
||||||
|
tips: [
|
||||||
|
{ months: [10], action: "Plant garlic cloves", detail: "Mid-October, mulched well — it overwinters." },
|
||||||
|
{ months: [6], action: "Snap off garlic scapes", detail: "Sends energy to the bulb; scapes are dinner." },
|
||||||
|
{ months: [7], action: "Lift garlic", detail: "When the lower third of leaves have browned; cure in shade." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Onion",
|
||||||
|
aliases: ["Shallot", "Leek"],
|
||||||
|
family: "allium",
|
||||||
|
friends: ["Carrot", "Beet", "Cabbage family"],
|
||||||
|
foes: ["Beans", "Peas"],
|
||||||
|
uwQuery: "growing onions",
|
||||||
|
tips: [
|
||||||
|
{ months: [4, 5], action: "Set out onion plants or sets", detail: "Onions size up on day length — get them in early." },
|
||||||
|
{ months: [8, 9], action: "Harvest onions", detail: "When tops flop over; cure until necks are papery." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Carrot",
|
||||||
|
family: "root",
|
||||||
|
friends: ["Onion", "Tomato", "Pea"],
|
||||||
|
foes: ["Dill"],
|
||||||
|
uwQuery: "growing carrots",
|
||||||
|
tips: [
|
||||||
|
{ months: [4, 5, 6], action: "Sow carrots", detail: "Keep the seedbed moist until they're up — it takes patience." },
|
||||||
|
{ months: [10], action: "Finish digging carrots", detail: "A light frost sweetens them; dig before the ground locks up." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sweet potato",
|
||||||
|
aliases: ["Japanese sweet potato"],
|
||||||
|
family: "root",
|
||||||
|
rotationNote: "Not a nightshade despite the name (it's a morning glory) — rotates fine after tomatoes.",
|
||||||
|
uwQuery: "growing sweet potatoes",
|
||||||
|
tips: [
|
||||||
|
{ months: [6], action: "Plant sweet potato slips", detail: "They demand warm soil — early June here, black plastic mulch helps a zone-4 season." },
|
||||||
|
{ months: [9], action: "Dig sweet potatoes before frost", detail: "Vines die at the first touch of frost; cure warm (80–85°F) for a week to sweeten." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Beet",
|
||||||
|
family: "root",
|
||||||
|
friends: ["Onion", "Cabbage family"],
|
||||||
|
foes: ["Pole beans"],
|
||||||
|
uwQuery: "growing beets",
|
||||||
|
tips: [
|
||||||
|
{ months: [4, 5, 6, 7], action: "Sow beets", detail: "Succession-sow into midsummer for fall roots." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Lettuce",
|
||||||
|
aliases: ["Salad greens"],
|
||||||
|
family: "leafy",
|
||||||
|
friends: ["Carrot", "Radish"],
|
||||||
|
uwQuery: "growing lettuce",
|
||||||
|
tips: [
|
||||||
|
{ months: [4, 5], action: "Sow lettuce", detail: "Cool-season crop; shade cloth stretches it into summer." },
|
||||||
|
{ months: [8], action: "Sow fall lettuce", detail: "Late-summer sowing beats the September chill." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Spinach",
|
||||||
|
family: "leafy",
|
||||||
|
uwQuery: "growing spinach",
|
||||||
|
tips: [
|
||||||
|
{ months: [4], action: "Sow spinach", detail: "Bolts in heat — spring and fall are its seasons here." },
|
||||||
|
{ months: [8, 9], action: "Sow fall spinach", detail: "Overwinters under mulch for the earliest spring greens." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Broccoli",
|
||||||
|
aliases: ["Cabbage", "Cauliflower", "Kale", "Brussels sprouts", "Kohlrabi"],
|
||||||
|
family: "brassica",
|
||||||
|
friends: ["Onion", "Dill", "Chamomile"],
|
||||||
|
foes: ["Tomato", "Strawberry"],
|
||||||
|
rotationNote: "Rotate brassicas on a 3-year cycle to dodge clubroot and cabbage maggots.",
|
||||||
|
uwQuery: "growing broccoli cabbage",
|
||||||
|
tips: [
|
||||||
|
{ months: [4], action: "Start brassica seeds indoors", detail: "Cabbage, broccoli, kale — 4–5 weeks before transplanting." },
|
||||||
|
{ months: [5], action: "Transplant brassicas", detail: "They shrug off light frost; row covers stop the cabbage moths." },
|
||||||
|
{ months: [7], action: "Sow fall brassicas", detail: "A July sowing makes the best autumn broccoli and kale." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Squash",
|
||||||
|
aliases: ["Zucchini", "Pumpkin", "Winter squash"],
|
||||||
|
family: "cucurbit",
|
||||||
|
friends: ["Corn", "Beans", "Nasturtium"],
|
||||||
|
foes: ["Potato"],
|
||||||
|
uwQuery: "growing squash",
|
||||||
|
tips: [
|
||||||
|
{ months: [6], action: "Sow or transplant squash", detail: "Warm soil only — early June here." },
|
||||||
|
{ months: [9, 10], action: "Harvest winter squash before hard frost", detail: "Leave an inch of stem; cure 2 weeks for storage." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Cucumber",
|
||||||
|
family: "cucurbit",
|
||||||
|
friends: ["Beans", "Dill"],
|
||||||
|
foes: ["Potato"],
|
||||||
|
uwQuery: "growing cucumbers",
|
||||||
|
tips: [
|
||||||
|
{ months: [6], action: "Sow cucumbers", detail: "Direct-sow in warm June soil; trellis for straight fruit." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Corn",
|
||||||
|
aliases: ["Sweet corn"],
|
||||||
|
family: "corn",
|
||||||
|
friends: ["Beans", "Squash"],
|
||||||
|
foes: ["Tomato"],
|
||||||
|
uwQuery: "growing sweet corn",
|
||||||
|
tips: [
|
||||||
|
{ months: [5, 6], action: "Sow sweet corn in blocks", detail: "Blocks, not rows — corn is wind-pollinated." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Strawberry",
|
||||||
|
family: "perennial",
|
||||||
|
foes: ["Cabbage family"],
|
||||||
|
rotationNote: "Avoid beds that recently grew tomatoes/potatoes (verticillium lingers).",
|
||||||
|
uwQuery: "growing strawberries",
|
||||||
|
tips: [
|
||||||
|
{ months: [5], action: "Plant strawberries", detail: "Pinch first-year blossoms on June-bearers — bigger crops after." },
|
||||||
|
{ months: [7], action: "Renovate June-bearing strawberries", detail: "Right after harvest: mow, narrow the rows, fertilize." },
|
||||||
|
{ months: [11], action: "Mulch strawberries for winter", detail: "Straw on after the ground starts to freeze." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Raspberry",
|
||||||
|
family: "perennial",
|
||||||
|
foes: ["Potato"],
|
||||||
|
rotationNote: "Keep away from where nightshades grew — verticillium wilt.",
|
||||||
|
uwQuery: "growing raspberries",
|
||||||
|
tips: [
|
||||||
|
{ months: [3, 4], action: "Prune raspberries", detail: "Remove last year's spent canes; thin the rest to the sturdiest." },
|
||||||
|
{ months: [7, 8, 9], action: "Pick raspberries often", detail: "Every couple of days keeps the patch producing." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Blueberry",
|
||||||
|
family: "perennial",
|
||||||
|
uwQuery: "growing blueberries",
|
||||||
|
tips: [
|
||||||
|
{ months: [3], action: "Prune blueberries", detail: "While dormant; remove the oldest canes." },
|
||||||
|
{ months: [5], action: "Check blueberry soil pH", detail: "They need acid soil (pH 4.5–5.5) — sulfur takes months to work." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Currant",
|
||||||
|
aliases: ["Gooseberry"],
|
||||||
|
family: "perennial",
|
||||||
|
uwQuery: "growing currants gooseberries",
|
||||||
|
tips: [
|
||||||
|
{ months: [3], action: "Prune currants and gooseberries", detail: "Keep a mix of 1–3 year old stems; remove older wood." },
|
||||||
|
{ months: [7], action: "Pick currants", detail: "Whole strigs at once — easier to sort in the kitchen." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Grape",
|
||||||
|
family: "perennial",
|
||||||
|
uwQuery: "pruning grapes",
|
||||||
|
tips: [
|
||||||
|
{ months: [3], action: "Prune grape vines", detail: "Hard, while fully dormant — grapes fruit on new wood from year-old canes." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Apple",
|
||||||
|
aliases: ["Pear"],
|
||||||
|
family: "perennial",
|
||||||
|
uwQuery: "apple tree care",
|
||||||
|
tips: [
|
||||||
|
{ months: [3], action: "Prune apple and pear trees", detail: "Late winter, before buds break — open the center to light." },
|
||||||
|
{ months: [6], action: "Thin apples", detail: "One fruit per cluster, a hand-width apart; bigger, healthier apples." },
|
||||||
|
{ months: [11], action: "Guard young fruit trees for winter", detail: "Trunk guards against voles and rabbits; pull mulch back from bark." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Cherry",
|
||||||
|
aliases: ["Plum", "Stone fruit"],
|
||||||
|
family: "perennial",
|
||||||
|
uwQuery: "growing cherries plums",
|
||||||
|
tips: [
|
||||||
|
{ months: [3], action: "Prune stone fruit trees", detail: "Late dormant season, on a dry day." },
|
||||||
|
{ months: [7], action: "Net cherries against birds", detail: "The birds are watching the same calendar you are." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Rhubarb",
|
||||||
|
family: "perennial",
|
||||||
|
uwQuery: "growing rhubarb",
|
||||||
|
tips: [
|
||||||
|
{ months: [5, 6], action: "Harvest rhubarb", detail: "Pull stalks, don't cut; stop by July so the crown recharges." },
|
||||||
|
{ months: [9], action: "Divide crowded rhubarb", detail: "Every 5 years or so, when stalks get thin." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Asparagus",
|
||||||
|
family: "perennial",
|
||||||
|
uwQuery: "growing asparagus",
|
||||||
|
tips: [
|
||||||
|
{ months: [5, 6], action: "Harvest asparagus", detail: "Snap spears until mid-June, then let the ferns grow — they feed next year." },
|
||||||
|
{ months: [6], action: "Fertilize asparagus after harvest", detail: "The ferns do the work for next spring." },
|
||||||
|
{ months: [11], action: "Cut down asparagus ferns", detail: "After they brown; removes overwintering asparagus-beetle habitat." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Basil",
|
||||||
|
family: "leafy",
|
||||||
|
friends: ["Tomato"],
|
||||||
|
uwQuery: "growing basil",
|
||||||
|
tips: [
|
||||||
|
{ months: [4], action: "Start basil indoors", detail: "It hates cold — never rush it outside." },
|
||||||
|
{ months: [6, 7, 8], action: "Pinch basil tops", detail: "Pinch before it flowers to keep leaves coming." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Iris",
|
||||||
|
aliases: ["Bearded iris"],
|
||||||
|
family: "perennial",
|
||||||
|
uwQuery: "iris care dividing",
|
||||||
|
tips: [
|
||||||
|
{ months: [7, 8], action: "Divide crowded irises", detail: "4–6 weeks after bloom; discard old center rhizomes (check for borers)." },
|
||||||
|
{ months: [10], action: "Cut iris foliage back", detail: "Fall cleanup breaks the iris-borer cycle." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Peony",
|
||||||
|
family: "perennial",
|
||||||
|
uwQuery: "peony care",
|
||||||
|
tips: [
|
||||||
|
{ months: [9], action: "Plant or divide peonies", detail: "Eyes no deeper than 2 inches — planting deep is why peonies sulk." },
|
||||||
|
{ months: [10], action: "Cut peonies to the ground", detail: "Fall removal keeps botrytis from overwintering." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Lilac",
|
||||||
|
aliases: ["Forsythia", "Spring-flowering shrub"],
|
||||||
|
family: "perennial",
|
||||||
|
uwQuery: "pruning lilacs",
|
||||||
|
tips: [
|
||||||
|
{ months: [6], action: "Prune lilacs right after bloom", detail: "They bloom on old wood — prune in fall/winter and you cut off next year's flowers." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Bee balm",
|
||||||
|
aliases: ["Coneflower", "Black-eyed Susan", "Perennial flower"],
|
||||||
|
family: "perennial",
|
||||||
|
uwQuery: "dividing perennials",
|
||||||
|
tips: [
|
||||||
|
{ months: [5], action: "Divide summer perennials", detail: "Spring division for the ones that bloom after midsummer." },
|
||||||
|
{ months: [7, 8], action: "Deadhead perennial flowers", detail: "Keeps the show going and self-seeding polite." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Homestead-wide reminders that apply regardless of what's planted.
|
||||||
|
export const GENERAL_TIPS: (CareTip & { uwQuery: string })[] = [
|
||||||
|
{ months: [3], action: "Set up seed-starting lights", detail: "The zone-4 season starts indoors in March.", uwQuery: "starting seeds indoors" },
|
||||||
|
{ months: [5], action: "Watch for late frost", detail: "Eau Claire's average last frost is ~May 15; keep covers handy.", uwQuery: "frost protection" },
|
||||||
|
{ months: [6], action: "Mulch the vegetable beds", detail: "After soil warms: holds water, blocks weeds.", uwQuery: "mulching garden" },
|
||||||
|
{ months: [9], action: "Sow a cover crop in empty beds", detail: "Oats or rye protect and feed the soil over winter.", uwQuery: "cover crops garden" },
|
||||||
|
{ months: [10], action: "Fall garden cleanup", detail: "Remove diseased plant debris (compost the healthy stuff).", uwQuery: "fall garden cleanup" },
|
||||||
|
{ months: [11], action: "Winter-mulch perennials", detail: "After the ground freezes, not before — the goal is keeping it frozen.", uwQuery: "winter mulch perennials" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Matching — connect knowledge entries to the plants Bonna actually grows.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function norm(s: string): string {
|
||||||
|
return s.toLowerCase().replace(/[^a-z ]/g, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whole-word, plural-tolerant match: "Raspberries" hits "Raspberry",
|
||||||
|
* "Bearded Iris" hits "Iris" — but "Pear" must never hit "Pea".
|
||||||
|
*/
|
||||||
|
function nameMatches(knowledgeName: string, plantName: string): boolean {
|
||||||
|
const stem = (w: string) => (w.endsWith("ies") ? w.slice(0, -3) + "y" : w.replace(/e?s$/, ""));
|
||||||
|
const kStems = norm(knowledgeName).split(" ").filter(Boolean).map(stem);
|
||||||
|
const pStems = norm(plantName).split(" ").filter(Boolean).map(stem);
|
||||||
|
if (kStems.length === 0 || pStems.length === 0) return false;
|
||||||
|
return kStems.every((w) => pStems.includes(w));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The knowledge entry for a plant/type name, if we have one. Prefers the most
|
||||||
|
* specific match: "Japanese sweet potato" must hit Sweet potato, not Potato.
|
||||||
|
*/
|
||||||
|
export function knowledgeFor(plantName: string): CropKnowledge | null {
|
||||||
|
let best: CropKnowledge | null = null;
|
||||||
|
let bestScore = 0;
|
||||||
|
for (const crop of KNOWLEDGE) {
|
||||||
|
const names = [crop.name, ...(crop.aliases ?? [])];
|
||||||
|
for (const n of names) {
|
||||||
|
if (!nameMatches(n, plantName)) continue;
|
||||||
|
const score = norm(n).split(" ").filter(Boolean).length; // matched words
|
||||||
|
if (score > bestScore) {
|
||||||
|
best = crop;
|
||||||
|
bestScore = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MonthlySuggestion = {
|
||||||
|
crop: string | null; // null = general homestead tip
|
||||||
|
action: string;
|
||||||
|
detail?: string;
|
||||||
|
uwUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What's worth doing this month (1–12), given the plant names on hand.
|
||||||
|
* General tips always apply; crop tips only if a matching plant is grown.
|
||||||
|
*/
|
||||||
|
export function tipsForMonth(month: number, ownedNames: string[]): MonthlySuggestion[] {
|
||||||
|
const out: MonthlySuggestion[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
// Resolve each owned name to its single best crop, so "Sweet potato"
|
||||||
|
// doesn't also drag in regular Potato advice.
|
||||||
|
const grownCrops = new Set(
|
||||||
|
ownedNames.map((n) => knowledgeFor(n)?.name).filter((n): n is string => !!n),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const crop of KNOWLEDGE) {
|
||||||
|
if (!grownCrops.has(crop.name)) continue;
|
||||||
|
for (const tip of crop.tips) {
|
||||||
|
if (!tip.months.includes(month)) continue;
|
||||||
|
const key = `${crop.name}|${tip.action}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push({ crop: crop.name, action: tip.action, detail: tip.detail, uwUrl: uwSearchUrl(crop.uwQuery) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tip of GENERAL_TIPS) {
|
||||||
|
if (tip.months.includes(month)) {
|
||||||
|
out.push({ crop: null, action: tip.action, detail: tip.detail, uwUrl: uwSearchUrl(tip.uwQuery) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
25
src/lib/garden/season.test.ts
Normal file
25
src/lib/garden/season.test.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { monthInWindow } from "./season";
|
||||||
|
|
||||||
|
describe("monthInWindow", () => {
|
||||||
|
it("plain range", () => {
|
||||||
|
expect(monthInWindow(7, 6, 9)).toBe(true);
|
||||||
|
expect(monthInWindow(5, 6, 9)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps the year end", () => {
|
||||||
|
expect(monthInWindow(11, 10, 3)).toBe(true);
|
||||||
|
expect(monthInWindow(2, 10, 3)).toBe(true);
|
||||||
|
expect(monthInWindow(7, 10, 3)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("single-ended windows collapse to one month", () => {
|
||||||
|
expect(monthInWindow(6, 6, null)).toBe(true);
|
||||||
|
expect(monthInWindow(7, 6, null)).toBe(false);
|
||||||
|
expect(monthInWindow(9, null, 9)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no window at all", () => {
|
||||||
|
expect(monthInWindow(6, null, null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
10
src/lib/garden/season.ts
Normal file
10
src/lib/garden/season.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// Season-window math for PlantType bloom/harvest month ranges (1–12).
|
||||||
|
// Windows may wrap the year end (e.g. Oct–Mar).
|
||||||
|
|
||||||
|
/** Is `month` (1–12) inside the inclusive window start..end, wrapping year end? */
|
||||||
|
export function monthInWindow(month: number, start: number | null, end: number | null): boolean {
|
||||||
|
if (start == null && end == null) return false;
|
||||||
|
const s = start ?? end!;
|
||||||
|
const e = end ?? start!;
|
||||||
|
return s <= e ? month >= s && month <= e : month >= s || month <= e;
|
||||||
|
}
|
||||||
49
src/lib/garden/uw-extension.test.ts
Normal file
49
src/lib/garden/uw-extension.test.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { mapUwArticles, stripWpHtml, type UwApiItem } from "./uw-extension";
|
||||||
|
|
||||||
|
describe("stripWpHtml", () => {
|
||||||
|
it("removes tags and collapses whitespace", () => {
|
||||||
|
expect(stripWpHtml("<p>This article covers cane blight.</p>\n")).toBe(
|
||||||
|
"This article covers cane blight.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes the entities WordPress emits", () => {
|
||||||
|
expect(stripWpHtml("Bee balm — a gardener’s friend […]")).toBe(
|
||||||
|
"Bee balm — a gardener’s friend …",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapUwArticles", () => {
|
||||||
|
const raw: UwApiItem[] = [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
link: "https://hort.extension.wisc.edu/articles/growing-raspberries/",
|
||||||
|
title: { rendered: "Growing Raspberries in Wisconsin" },
|
||||||
|
excerpt: { rendered: "<p>Raspberries thrive in Wisconsin.</p>" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 102,
|
||||||
|
link: "https://hort.extension.wisc.edu/articles/cane-blight/",
|
||||||
|
title: { rendered: "" }, // no title → dropped
|
||||||
|
excerpt: { rendered: "<p>x</p>" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("maps to clean title/url/excerpt and drops titleless entries", () => {
|
||||||
|
const out = mapUwArticles(raw);
|
||||||
|
expect(out).toEqual([
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
title: "Growing Raspberries in Wisconsin",
|
||||||
|
url: "https://hort.extension.wisc.edu/articles/growing-raspberries/",
|
||||||
|
excerpt: "Raspberries thrive in Wisconsin.",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives malformed items", () => {
|
||||||
|
expect(mapUwArticles([{} as UwApiItem, null as unknown as UwApiItem])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
75
src/lib/garden/uw-extension.ts
Normal file
75
src/lib/garden/uw-extension.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
// UW–Madison Division of Extension horticulture articles
|
||||||
|
// (hort.extension.wisc.edu) — searched via the site's public WordPress REST
|
||||||
|
// API. We store only a link + title on PlantType; the content stays theirs.
|
||||||
|
import { ApiError } from "@/lib/api/authenticate";
|
||||||
|
|
||||||
|
const UW_API = "https://hort.extension.wisc.edu/wp-json/wp/v2/ext_article";
|
||||||
|
|
||||||
|
export type UwArticle = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
excerpt: string; // plain text, trimmed
|
||||||
|
};
|
||||||
|
|
||||||
|
// The slice of the WP response we read. `rendered` strings carry HTML.
|
||||||
|
export type UwApiItem = {
|
||||||
|
id: number;
|
||||||
|
link: string;
|
||||||
|
title?: { rendered?: string };
|
||||||
|
excerpt?: { rendered?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Strip tags and decode the entities WP typically emits. */
|
||||||
|
export function stripWpHtml(html: string): string {
|
||||||
|
return html
|
||||||
|
.replace(/<[^>]*>/g, " ")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/’|’/g, "’")
|
||||||
|
.replace(/‘|‘/g, "‘")
|
||||||
|
.replace(/“|“/g, "“")
|
||||||
|
.replace(/”|”/g, "”")
|
||||||
|
.replace(/–|–/g, "–")
|
||||||
|
.replace(/—|—/g, "—")
|
||||||
|
.replace(/…|…/g, "…")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.replace(/\[…\]|\[…\]/g, "…")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map raw WP items to clean articles, dropping malformed entries. */
|
||||||
|
export function mapUwArticles(items: UwApiItem[]): UwArticle[] {
|
||||||
|
return items
|
||||||
|
.filter(i => i && typeof i.id === "number" && typeof i.link === "string")
|
||||||
|
.map(i => ({
|
||||||
|
id: i.id,
|
||||||
|
title: stripWpHtml(i.title?.rendered ?? ""),
|
||||||
|
url: i.link,
|
||||||
|
excerpt: stripWpHtml(i.excerpt?.rendered ?? ""),
|
||||||
|
}))
|
||||||
|
.filter(a => a.title.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search UW Extension articles by plant/topic name. */
|
||||||
|
export async function searchUwExtension(q: string): Promise<UwArticle[]> {
|
||||||
|
const query = q.trim();
|
||||||
|
if (!query) return [];
|
||||||
|
|
||||||
|
const url = `${UW_API}?search=${encodeURIComponent(query)}&per_page=8&_fields=id,link,title,excerpt`;
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
signal: AbortSignal.timeout(8000),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(502, "Couldn't reach UW Extension — try again in a moment");
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new ApiError(502, "UW Extension lookup failed");
|
||||||
|
|
||||||
|
const json = (await res.json()) as UwApiItem[];
|
||||||
|
if (!Array.isArray(json)) throw new ApiError(502, "Unexpected reply from UW Extension");
|
||||||
|
return mapUwArticles(json);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ export const ITEM_LOG_LABELS: Record<ItemLogType, string> = {
|
|||||||
PURCHASED: "Purchased",
|
PURCHASED: "Purchased",
|
||||||
REPAIRED: "Repaired",
|
REPAIRED: "Repaired",
|
||||||
DISPOSED: "Disposed",
|
DISPOSED: "Disposed",
|
||||||
|
USED: "Used",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ITEM_LOG_COLORS: Record<ItemLogType, string> = {
|
export const ITEM_LOG_COLORS: Record<ItemLogType, string> = {
|
||||||
@@ -16,8 +17,11 @@ export const ITEM_LOG_COLORS: Record<ItemLogType, string> = {
|
|||||||
PURCHASED: "text-green-600 dark:text-green-400",
|
PURCHASED: "text-green-600 dark:text-green-400",
|
||||||
REPAIRED: "text-violet-600 dark:text-violet-400",
|
REPAIRED: "text-violet-600 dark:text-violet-400",
|
||||||
DISPOSED: "text-red-600 dark:text-red-400",
|
DISPOSED: "text-red-600 dark:text-red-400",
|
||||||
|
USED: "text-teal-600 dark:text-teal-400",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Types offered in the manual "add log" dropdown for durable items.
|
||||||
|
// USED is written by pantry pulls, not entered by hand, so it's not listed.
|
||||||
export const ITEM_LOG_TYPES: ItemLogType[] = [
|
export const ITEM_LOG_TYPES: ItemLogType[] = [
|
||||||
"NOTE", "MAINTENANCE", "MOVED", "PURCHASED", "REPAIRED", "DISPOSED",
|
"NOTE", "MAINTENANCE", "MOVED", "PURCHASED", "REPAIRED", "DISPOSED",
|
||||||
];
|
];
|
||||||
|
|||||||
35
src/lib/locations/outline.test.ts
Normal file
35
src/lib/locations/outline.test.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { parseLocationOutline } from "./outline";
|
||||||
|
|
||||||
|
describe("parseLocationOutline", () => {
|
||||||
|
it("derives depth from indentation", () => {
|
||||||
|
const nodes = parseLocationOutline("House\n Main floor\n Kitchen\n Pantry\n Garage\nYard");
|
||||||
|
expect(nodes).toEqual([
|
||||||
|
{ name: "House", depth: 0 },
|
||||||
|
{ name: "Main floor", depth: 1 },
|
||||||
|
{ name: "Kitchen", depth: 2 },
|
||||||
|
{ name: "Pantry", depth: 3 },
|
||||||
|
{ name: "Garage", depth: 1 },
|
||||||
|
{ name: "Yard", depth: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats tabs as two spaces and ignores blank lines", () => {
|
||||||
|
const nodes = parseLocationOutline("A\n\n\tB\n\t\tC");
|
||||||
|
expect(nodes.map((n) => `${n.depth}:${n.name}`)).toEqual(["0:A", "1:B", "2:C"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips leading bullets/dashes", () => {
|
||||||
|
const nodes = parseLocationOutline("- Shed\n * Brown\n - Green");
|
||||||
|
expect(nodes).toEqual([
|
||||||
|
{ name: "Shed", depth: 0 },
|
||||||
|
{ name: "Brown", depth: 1 },
|
||||||
|
{ name: "Green", depth: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles inconsistent indent widths by relative nesting", () => {
|
||||||
|
const nodes = parseLocationOutline("A\n B\n C\n D");
|
||||||
|
expect(nodes.map((n) => `${n.depth}:${n.name}`)).toEqual(["0:A", "1:B", "2:C", "1:D"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
21
src/lib/locations/outline.ts
Normal file
21
src/lib/locations/outline.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// Pure parser: an indented text outline → a flat, ordered list of nodes with a
|
||||||
|
// depth, from which a nested Location tree is built. Indentation (spaces, tabs→2)
|
||||||
|
// sets depth; leading bullets/dashes are tolerated. Used by the "Quick setup".
|
||||||
|
|
||||||
|
export type OutlineNode = { name: string; depth: number };
|
||||||
|
|
||||||
|
export function parseLocationOutline(text: string): OutlineNode[] {
|
||||||
|
const out: OutlineNode[] = [];
|
||||||
|
const indents: number[] = []; // indent width of each ancestor on the stack
|
||||||
|
for (const rawLine of text.split("\n")) {
|
||||||
|
if (!rawLine.trim()) continue;
|
||||||
|
const line = rawLine.replace(/\t/g, " ");
|
||||||
|
const indent = line.length - line.replace(/^\s+/, "").length;
|
||||||
|
const name = line.trim().replace(/^[-*•]\s+/, "").trim();
|
||||||
|
if (!name) continue;
|
||||||
|
while (indents.length && indents[indents.length - 1] >= indent) indents.pop();
|
||||||
|
out.push({ name, depth: indents.length });
|
||||||
|
indents.push(indent);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
66
src/lib/pantry/categories.ts
Normal file
66
src/lib/pantry/categories.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
export type PantryCategoryTree = {
|
||||||
|
label: string;
|
||||||
|
subcategories: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PANTRY_CATEGORIES: PantryCategoryTree[] = [
|
||||||
|
{
|
||||||
|
label: "Meat",
|
||||||
|
subcategories: ["Beef", "Poultry", "Pork", "Lamb", "Game", "Sausage & Cured", "Mixed / Other"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Seafood",
|
||||||
|
subcategories: ["Fish", "Shrimp", "Shellfish", "Other Seafood"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Produce",
|
||||||
|
subcategories: ["Berries", "Tomatoes", "Corn", "Peppers", "Leafy Greens", "Root Vegetables", "Squash & Gourds", "Beans & Peas", "Herbs", "Other Produce"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Fruit",
|
||||||
|
subcategories: ["Berries", "Stone Fruit", "Apples & Pears", "Citrus", "Tropical", "Other Fruit"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Dairy & Eggs",
|
||||||
|
subcategories: ["Butter", "Cheese", "Milk & Cream", "Eggs", "Yogurt", "Other Dairy"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Baked Goods",
|
||||||
|
subcategories: ["Bread & Rolls", "Muffins & Quick Breads", "Cookies & Bars", "Cakes & Pies", "Pastry", "Crackers", "Other Baked Goods"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Soups & Broths",
|
||||||
|
subcategories: ["Broth & Stock", "Soup", "Stew & Chili", "Other"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Prepared Meals",
|
||||||
|
subcategories: ["Casserole", "Pasta & Rice Dishes", "Breakfast", "Leftovers", "Other Meals"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Sauces & Condiments",
|
||||||
|
subcategories: ["Tomato Sauce", "Pesto", "Salsa", "Jam & Jelly", "Pickles & Ferments", "Other Sauces"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Grains & Legumes",
|
||||||
|
subcategories: ["Flour", "Rice & Grains", "Dried Beans", "Lentils", "Pasta", "Oats", "Other"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Nuts & Seeds",
|
||||||
|
subcategories: ["Nuts", "Seeds", "Nut Butter", "Other"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Beverages",
|
||||||
|
subcategories: ["Juice", "Tea & Coffee", "Other Beverages"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Other",
|
||||||
|
subcategories: ["Other"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CATEGORY_OPTIONS = PANTRY_CATEGORIES.map(c => ({ value: c.label, label: c.label }));
|
||||||
|
|
||||||
|
export function getSubcategoryOptions(category: string): { value: string; label: string }[] {
|
||||||
|
const found = PANTRY_CATEGORIES.find(c => c.label === category);
|
||||||
|
return (found?.subcategories ?? []).map(s => ({ value: s, label: s }));
|
||||||
|
}
|
||||||
103
src/lib/pantry/core.test.ts
Normal file
103
src/lib/pantry/core.test.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { normalizeTags, planBulkUse, serializePantryItem, type PantryRow } from "./core";
|
||||||
|
|
||||||
|
describe("normalizeTags", () => {
|
||||||
|
it("trims, drops empties, and dedupes", () => {
|
||||||
|
expect(normalizeTags([" Poultry ", "Pre-cooked", "", " ", "Poultry"])).toEqual([
|
||||||
|
"Poultry",
|
||||||
|
"Pre-cooked",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps distinct casings distinct (labels are case-sensitive)", () => {
|
||||||
|
expect(normalizeTags(["poultry", "Poultry"])).toEqual(["poultry", "Poultry"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("serializePantryItem", () => {
|
||||||
|
const row: PantryRow = {
|
||||||
|
id: "i1",
|
||||||
|
name: "Chicken thighs",
|
||||||
|
description: "2 lb pack",
|
||||||
|
quantity: "3.50", // Prisma Decimal stringifies like this
|
||||||
|
unit: "lbs",
|
||||||
|
storedAt: new Date("2026-06-01"),
|
||||||
|
bestBefore: new Date("2026-09-01"),
|
||||||
|
source: "STORE",
|
||||||
|
notes: null,
|
||||||
|
pantryCategory: "Meat",
|
||||||
|
pantrySubcategory: "Poultry",
|
||||||
|
pricePaid: "8.99",
|
||||||
|
priceUnit: "lb",
|
||||||
|
location: { id: "l1", name: "Chest freezer" },
|
||||||
|
plant: null,
|
||||||
|
labels: [{ label: { name: "Pre-cooked" } }],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("converts Decimals to numbers and flattens relations", () => {
|
||||||
|
const dto = serializePantryItem(row);
|
||||||
|
expect(dto.quantity).toBe(3.5);
|
||||||
|
expect(dto.pricePaid).toBe(8.99);
|
||||||
|
expect(dto.locationId).toBe("l1");
|
||||||
|
expect(dto.locationName).toBe("Chest freezer");
|
||||||
|
expect(dto.packageSize).toBe("2 lb pack");
|
||||||
|
expect(dto.tags).toEqual(["Pre-cooked"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles null price, location, and plant; formats plant with variety", () => {
|
||||||
|
const dto = serializePantryItem({
|
||||||
|
...row,
|
||||||
|
pricePaid: null,
|
||||||
|
location: null,
|
||||||
|
plant: { id: "p1", commonName: "Tomato", variety: "Roma" },
|
||||||
|
});
|
||||||
|
expect(dto.pricePaid).toBeNull();
|
||||||
|
expect(dto.locationId).toBeNull();
|
||||||
|
expect(dto.plantName).toBe("Tomato (Roma)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("planBulkUse", () => {
|
||||||
|
const items = [
|
||||||
|
{ id: "a", name: "Flour", quantity: 10, unit: "lbs" },
|
||||||
|
{ id: "b", name: "Eggs", quantity: 12, unit: "each" },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("computes remaining quantities per pull", () => {
|
||||||
|
const plan = planBulkUse(items, [
|
||||||
|
{ id: "a", amount: 2 },
|
||||||
|
{ id: "b", amount: 6 },
|
||||||
|
]);
|
||||||
|
expect(plan.skipped).toEqual([]);
|
||||||
|
expect(plan.updates).toEqual([
|
||||||
|
{ id: "a", name: "Flour", unit: "lbs", amount: 2, remaining: 8 },
|
||||||
|
{ id: "b", name: "Eggs", unit: "each", amount: 6, remaining: 6 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports pulls whose item is missing instead of silently dropping them", () => {
|
||||||
|
const plan = planBulkUse(items, [
|
||||||
|
{ id: "a", amount: 1 },
|
||||||
|
{ id: "gone", amount: 5 },
|
||||||
|
]);
|
||||||
|
expect(plan.skipped).toEqual(["gone"]);
|
||||||
|
expect(plan.updates).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates repeated pulls of the same item", () => {
|
||||||
|
const plan = planBulkUse(items, [
|
||||||
|
{ id: "b", amount: 4 },
|
||||||
|
{ id: "b", amount: 4 },
|
||||||
|
]);
|
||||||
|
expect(plan.updates).toEqual([
|
||||||
|
{ id: "b", name: "Eggs", unit: "each", amount: 8, remaining: 4 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps at zero — never goes negative, and logs only what was there", () => {
|
||||||
|
const plan = planBulkUse(items, [{ id: "a", amount: 25 }]);
|
||||||
|
expect(plan.updates).toEqual([
|
||||||
|
{ id: "a", name: "Flour", unit: "lbs", amount: 10, remaining: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
142
src/lib/pantry/core.ts
Normal file
142
src/lib/pantry/core.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
// Pure pantry logic — tag normalization, row serialization, and bulk-pull
|
||||||
|
// planning. No DB access; the service layer (src/lib/services/pantry.ts)
|
||||||
|
// applies these against Prisma.
|
||||||
|
|
||||||
|
// Disposition for a pantry pull — WHY it left inventory, kept structured so
|
||||||
|
// waste vs. use is queryable (don't fold this into freeform notes). Stored as
|
||||||
|
// the slug string on ItemLog.reason; null for old rows and API pulls that omit it.
|
||||||
|
export const PANTRY_USE_REASONS = [
|
||||||
|
{ value: "consumed", label: "Immediate use" },
|
||||||
|
{ value: "preserved", label: "Preserved / stored" },
|
||||||
|
{ value: "gifted", label: "Gave away" },
|
||||||
|
{ value: "waste", label: "Waste / spoiled" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type PantryUseReason = (typeof PANTRY_USE_REASONS)[number]["value"];
|
||||||
|
|
||||||
|
export const PANTRY_USE_REASON_VALUES = PANTRY_USE_REASONS.map(r => r.value) as [
|
||||||
|
PantryUseReason,
|
||||||
|
...PantryUseReason[],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Trim, drop empties, and dedupe a raw tag list (first occurrence wins). */
|
||||||
|
export function normalizeTags(tags: string[]): string[] {
|
||||||
|
return Array.from(new Set(tags.map(t => t.trim()).filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The DB row shape the pantry queries select. Decimals arrive as anything
|
||||||
|
// Number() can coerce (Prisma Decimal serializes via toString).
|
||||||
|
export type PantryRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
quantity: unknown;
|
||||||
|
unit: string | null;
|
||||||
|
storedAt: Date | null;
|
||||||
|
bestBefore: Date | null;
|
||||||
|
source: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
pantryCategory: string | null;
|
||||||
|
pantrySubcategory: string | null;
|
||||||
|
pricePaid: unknown | null;
|
||||||
|
priceUnit: string | null;
|
||||||
|
location: { id: string; name: string } | null;
|
||||||
|
plant: { id: string; commonName: string; variety: string | null } | null;
|
||||||
|
labels: { label: { name: string } }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PantryItemDto = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
quantity: number;
|
||||||
|
unit: string | null;
|
||||||
|
packageSize: string | null;
|
||||||
|
storedAt: Date | null;
|
||||||
|
bestBefore: Date | null;
|
||||||
|
source: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
pantryCategory: string | null;
|
||||||
|
pantrySubcategory: string | null;
|
||||||
|
pricePaid: number | null;
|
||||||
|
priceUnit: string | null;
|
||||||
|
locationId: string | null;
|
||||||
|
locationName: string | null;
|
||||||
|
plantId: string | null;
|
||||||
|
plantName: string | null;
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Flatten a pantry DB row into the client shape (Decimals → numbers). */
|
||||||
|
export function serializePantryItem(i: PantryRow): PantryItemDto {
|
||||||
|
return {
|
||||||
|
id: i.id,
|
||||||
|
name: i.name,
|
||||||
|
description: i.description,
|
||||||
|
quantity: Number(i.quantity),
|
||||||
|
unit: i.unit,
|
||||||
|
packageSize: i.description, // the UI labels `description` as "package size"
|
||||||
|
storedAt: i.storedAt,
|
||||||
|
bestBefore: i.bestBefore,
|
||||||
|
source: i.source,
|
||||||
|
notes: i.notes,
|
||||||
|
pantryCategory: i.pantryCategory,
|
||||||
|
pantrySubcategory: i.pantrySubcategory,
|
||||||
|
pricePaid: i.pricePaid != null ? Number(i.pricePaid) : null,
|
||||||
|
priceUnit: i.priceUnit,
|
||||||
|
locationId: i.location?.id ?? null,
|
||||||
|
locationName: i.location?.name ?? null,
|
||||||
|
plantId: i.plant?.id ?? null,
|
||||||
|
plantName: i.plant
|
||||||
|
? `${i.plant.commonName}${i.plant.variety ? ` (${i.plant.variety})` : ""}`
|
||||||
|
: null,
|
||||||
|
tags: (i.labels ?? []).map(l => l.label.name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BulkPull = { id: string; amount: number };
|
||||||
|
export type BulkUseUpdate = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
unit: string | null;
|
||||||
|
amount: number; // how much actually comes off (capped at what's there)
|
||||||
|
remaining: number;
|
||||||
|
};
|
||||||
|
export type BulkUsePlan = {
|
||||||
|
updates: BulkUseUpdate[];
|
||||||
|
/** Pull ids that matched no live pantry item — the caller must surface these. */
|
||||||
|
skipped: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn requested pulls into quantity updates against the items on hand.
|
||||||
|
* Repeated pulls of the same item accumulate; quantities never go below zero.
|
||||||
|
*/
|
||||||
|
export function planBulkUse(
|
||||||
|
items: { id: string; name: string; quantity: number; unit: string | null }[],
|
||||||
|
pulls: BulkPull[],
|
||||||
|
): BulkUsePlan {
|
||||||
|
const remaining = new Map(items.map(i => [i.id, i]));
|
||||||
|
const updates = new Map<string, BulkUseUpdate>();
|
||||||
|
const skipped: string[] = [];
|
||||||
|
|
||||||
|
for (const pull of pulls) {
|
||||||
|
const item = remaining.get(pull.id);
|
||||||
|
if (!item) {
|
||||||
|
skipped.push(pull.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const prev = updates.get(pull.id);
|
||||||
|
const before = prev ? prev.remaining : item.quantity;
|
||||||
|
const taken = Math.min(pull.amount, before);
|
||||||
|
updates.set(pull.id, {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
unit: item.unit,
|
||||||
|
amount: (prev?.amount ?? 0) + taken,
|
||||||
|
remaining: before - taken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { updates: Array.from(updates.values()), skipped };
|
||||||
|
}
|
||||||
@@ -164,11 +164,29 @@ export async function updateItem(ctx: AuthContext, id: string, input: ItemUpdate
|
|||||||
|
|
||||||
export async function deleteItem(ctx: AuthContext, id: string) {
|
export async function deleteItem(ctx: AuthContext, id: string) {
|
||||||
if (!isAdmin(ctx.role)) throw new ApiError(403, "Only admins can delete items");
|
if (!isAdmin(ctx.role)) throw new ApiError(403, "Only admins can delete items");
|
||||||
const item = await db.item.findUnique({ where: { id }, select: { active: true, name: true } });
|
const item = await db.item.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { active: true, name: true, locationId: true, parentItemId: true },
|
||||||
|
});
|
||||||
if (!item || !item.active) throw new ApiError(404, "Item not found");
|
if (!item || !item.active) throw new ApiError(404, "Item not found");
|
||||||
await db.item.update({ where: { id }, data: { active: false } });
|
// Deleting a container drops its contents into the container's own spot (its
|
||||||
|
// place, or the item it was nested in) instead of orphaning them — a wrench in
|
||||||
|
// a deleted garage toolbox becomes a wrench loose in the garage.
|
||||||
|
const reparented = await db.$transaction(async (tx) => {
|
||||||
|
const { count } = await tx.item.updateMany({
|
||||||
|
where: { parentItemId: id, active: true },
|
||||||
|
data: { locationId: item.locationId, parentItemId: item.parentItemId },
|
||||||
|
});
|
||||||
|
await tx.item.update({ where: { id }, data: { active: false } });
|
||||||
|
return count;
|
||||||
|
});
|
||||||
await db.auditEvent.create({
|
await db.auditEvent.create({
|
||||||
data: { action: "item.delete", entityId: id, actorId: ctx.userId, payload: { name: item.name, via: ctx.via } },
|
data: {
|
||||||
|
action: "item.delete",
|
||||||
|
entityId: id,
|
||||||
|
actorId: ctx.userId,
|
||||||
|
payload: { name: item.name, reparentedChildren: reparented, via: ctx.via },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +200,7 @@ export async function addItemLog(ctx: AuthContext, itemId: string, input: ItemLo
|
|||||||
date: input.date ? new Date(input.date) : new Date(),
|
date: input.date ? new Date(input.date) : new Date(),
|
||||||
notes: input.notes?.trim() || null,
|
notes: input.notes?.trim() || null,
|
||||||
cost: input.cost ?? null,
|
cost: input.cost ?? null,
|
||||||
|
amount: input.amount ?? null,
|
||||||
actorId: ctx.userId,
|
actorId: ctx.userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
68
src/lib/services/location-setup.ts
Normal file
68
src/lib/services/location-setup.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// Bulk Location setup — create a nested tree from a pasted outline, and
|
||||||
|
// (optionally) sweep existing top-level garden areas under a parent. DB-touching;
|
||||||
|
// parsing is pure (src/lib/locations/outline.ts).
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import type { AuthContext } from "@/lib/api/authenticate";
|
||||||
|
import { recomputeAllPaths } from "@/lib/locations/db";
|
||||||
|
import { parseLocationOutline } from "@/lib/locations/outline";
|
||||||
|
|
||||||
|
export type SetupSummary = { created: number; moved: number };
|
||||||
|
|
||||||
|
export async function bulkSetupLocations(
|
||||||
|
ctx: AuthContext,
|
||||||
|
opts: { outline: string; groupGardenUnder?: string },
|
||||||
|
): Promise<SetupSummary> {
|
||||||
|
const nodes = parseLocationOutline(opts.outline);
|
||||||
|
|
||||||
|
// find-or-create by (name, parentId), case-insensitive.
|
||||||
|
const rows = await db.location.findMany({ select: { id: true, name: true, parentId: true } });
|
||||||
|
const cache = new Map<string, string>();
|
||||||
|
for (const r of rows) cache.set(`${r.parentId ?? ""}:${r.name.toLowerCase()}`, r.id);
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
const stack: { depth: number; id: string }[] = []; // ancestor chain
|
||||||
|
for (const node of nodes) {
|
||||||
|
while (stack.length && stack[stack.length - 1].depth >= node.depth) stack.pop();
|
||||||
|
const parentId = stack.length ? stack[stack.length - 1].id : null;
|
||||||
|
const key = `${parentId ?? ""}:${node.name.toLowerCase()}`;
|
||||||
|
let id = cache.get(key);
|
||||||
|
if (!id) {
|
||||||
|
const loc = await db.location.create({ data: { name: node.name, kind: "OTHER", parentId } });
|
||||||
|
id = loc.id;
|
||||||
|
cache.set(key, id);
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
stack.push({ depth: node.depth, id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sweep existing top-level places that hold plants under the named parent.
|
||||||
|
let moved = 0;
|
||||||
|
if (opts.groupGardenUnder) {
|
||||||
|
const parent = await db.location.findFirst({
|
||||||
|
where: { active: true, parentId: null, name: { equals: opts.groupGardenUnder, mode: "insensitive" } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (parent) {
|
||||||
|
const gardenAreas = await db.location.findMany({
|
||||||
|
where: {
|
||||||
|
active: true,
|
||||||
|
parentId: null,
|
||||||
|
id: { not: parent.id },
|
||||||
|
plants: { some: { active: true } },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
for (const a of gardenAreas) {
|
||||||
|
await db.location.update({ where: { id: a.id }, data: { parentId: parent.id } });
|
||||||
|
}
|
||||||
|
moved = gardenAreas.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (created > 0 || moved > 0) await recomputeAllPaths();
|
||||||
|
await db.auditEvent.create({
|
||||||
|
data: { action: "location.bulk_setup", actorId: ctx.userId, payload: { created, moved, via: ctx.via } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { created, moved };
|
||||||
|
}
|
||||||
295
src/lib/services/pantry.ts
Normal file
295
src/lib/services/pantry.ts
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
// Pantry service — DB work + AuditEvent for consumable items (Kitchen →
|
||||||
|
// Pantry & Freezer). Shared by the /api/v1/pantry routes, the UI (which calls
|
||||||
|
// those routes), and later the MCP server. Pure logic lives in
|
||||||
|
// src/lib/pantry/core.ts.
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { ApiError, type AuthContext } from "@/lib/api/authenticate";
|
||||||
|
import {
|
||||||
|
normalizeTags,
|
||||||
|
planBulkUse,
|
||||||
|
serializePantryItem,
|
||||||
|
type PantryItemDto,
|
||||||
|
} from "@/lib/pantry/core";
|
||||||
|
import type {
|
||||||
|
PantryBulkUseInput,
|
||||||
|
PantryCreateInput,
|
||||||
|
PantryUpdateInput,
|
||||||
|
PantryUseInput,
|
||||||
|
} from "@/lib/api/schemas";
|
||||||
|
|
||||||
|
const PANTRY_SELECT = {
|
||||||
|
id: true, name: true, description: true, quantity: true, unit: true,
|
||||||
|
storedAt: true, bestBefore: true, source: true, plantId: true, notes: true,
|
||||||
|
pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true,
|
||||||
|
location: { select: { id: true, name: true } },
|
||||||
|
plant: { select: { id: true, commonName: true, variety: true } },
|
||||||
|
labels: { select: { label: { select: { id: true, name: true } } } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function toDate(s?: string | null): Date | null {
|
||||||
|
return s ? new Date(s) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upsert labels for the tag names, then replace this item's label links. */
|
||||||
|
async function syncTags(itemId: string, tags: string[]) {
|
||||||
|
const names = normalizeTags(tags);
|
||||||
|
const labels = await Promise.all(
|
||||||
|
names.map(name =>
|
||||||
|
db.label.upsert({ where: { name }, update: {}, create: { name }, select: { id: true } }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await db.labelOnItem.deleteMany({ where: { itemId } });
|
||||||
|
if (labels.length) {
|
||||||
|
await db.labelOnItem.createMany({
|
||||||
|
data: labels.map(l => ({ itemId, labelId: l.id })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPantry(_ctx: AuthContext): Promise<PantryItemDto[]> {
|
||||||
|
const items = await db.item.findMany({
|
||||||
|
where: { kind: "CONSUMABLE", active: true },
|
||||||
|
orderBy: [{ location: { name: "asc" } }, { name: "asc" }],
|
||||||
|
select: PANTRY_SELECT,
|
||||||
|
});
|
||||||
|
return items.map(serializePantryItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPantryItem(
|
||||||
|
ctx: AuthContext,
|
||||||
|
input: PantryCreateInput,
|
||||||
|
): Promise<PantryItemDto> {
|
||||||
|
const item = await db.item.create({
|
||||||
|
data: {
|
||||||
|
name: input.name,
|
||||||
|
description: input.description ?? input.packageSize ?? null,
|
||||||
|
kind: "CONSUMABLE",
|
||||||
|
quantity: input.quantity,
|
||||||
|
unit: input.unit ?? null,
|
||||||
|
locationId: input.locationId ?? null,
|
||||||
|
storedAt: toDate(input.storedAt),
|
||||||
|
bestBefore: toDate(input.bestBefore),
|
||||||
|
source: input.source ?? null,
|
||||||
|
plantId: input.plantId ?? null,
|
||||||
|
pantryCategory: input.pantryCategory ?? null,
|
||||||
|
pantrySubcategory: input.pantrySubcategory ?? null,
|
||||||
|
pricePaid: input.pricePaid ?? null,
|
||||||
|
priceUnit: input.priceUnit ?? null,
|
||||||
|
notes: input.notes ?? null,
|
||||||
|
},
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (input.tags?.length) await syncTags(item.id, input.tags);
|
||||||
|
|
||||||
|
await db.auditEvent.create({
|
||||||
|
data: {
|
||||||
|
action: "pantry.create",
|
||||||
|
entityId: item.id,
|
||||||
|
actorId: ctx.userId,
|
||||||
|
payload: { name: item.name, via: ctx.via },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const full = await db.item.findUniqueOrThrow({ where: { id: item.id }, select: PANTRY_SELECT });
|
||||||
|
return serializePantryItem(full);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePantryItem(
|
||||||
|
ctx: AuthContext,
|
||||||
|
id: string,
|
||||||
|
input: PantryUpdateInput,
|
||||||
|
): Promise<PantryItemDto> {
|
||||||
|
const existing = await db.item.findUnique({ where: { id }, select: { active: true, kind: true } });
|
||||||
|
if (!existing || !existing.active || existing.kind !== "CONSUMABLE")
|
||||||
|
throw new ApiError(404, "Pantry item not found");
|
||||||
|
|
||||||
|
await db.item.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(input.name !== undefined && { name: input.name }),
|
||||||
|
...(input.description !== undefined && { description: input.description ?? input.packageSize ?? null }),
|
||||||
|
...(input.quantity !== undefined && { quantity: input.quantity }),
|
||||||
|
...(input.unit !== undefined && { unit: input.unit ?? null }),
|
||||||
|
...(input.locationId !== undefined && { locationId: input.locationId ?? null }),
|
||||||
|
...(input.storedAt !== undefined && { storedAt: toDate(input.storedAt) }),
|
||||||
|
...(input.bestBefore !== undefined && { bestBefore: toDate(input.bestBefore) }),
|
||||||
|
...(input.source !== undefined && { source: input.source ?? null }),
|
||||||
|
...(input.plantId !== undefined && { plantId: input.plantId ?? null }),
|
||||||
|
...(input.pantryCategory !== undefined && { pantryCategory: input.pantryCategory ?? null }),
|
||||||
|
...(input.pantrySubcategory !== undefined && { pantrySubcategory: input.pantrySubcategory ?? null }),
|
||||||
|
...(input.pricePaid !== undefined && { pricePaid: input.pricePaid ?? null }),
|
||||||
|
...(input.priceUnit !== undefined && { priceUnit: input.priceUnit ?? null }),
|
||||||
|
...(input.notes !== undefined && { notes: input.notes ?? null }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (input.tags !== undefined) await syncTags(id, input.tags ?? []);
|
||||||
|
|
||||||
|
const item = await db.item.findUniqueOrThrow({ where: { id }, select: PANTRY_SELECT });
|
||||||
|
|
||||||
|
await db.auditEvent.create({
|
||||||
|
data: {
|
||||||
|
action: "pantry.update",
|
||||||
|
entityId: id,
|
||||||
|
actorId: ctx.userId,
|
||||||
|
payload: { name: item.name, via: ctx.via },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return serializePantryItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePantryItem(ctx: AuthContext, id: string): Promise<void> {
|
||||||
|
const existing = await db.item.findUnique({ where: { id }, select: { active: true, kind: true, name: true } });
|
||||||
|
if (!existing || !existing.active || existing.kind !== "CONSUMABLE")
|
||||||
|
throw new ApiError(404, "Pantry item not found");
|
||||||
|
|
||||||
|
await db.item.update({ where: { id }, data: { active: false } });
|
||||||
|
await db.auditEvent.create({
|
||||||
|
data: {
|
||||||
|
action: "pantry.delete",
|
||||||
|
entityId: id,
|
||||||
|
actorId: ctx.userId,
|
||||||
|
payload: { name: existing.name, via: ctx.via },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull a single item; wraps bulkUsePantry so both paths log identically. */
|
||||||
|
export async function usePantryItem(
|
||||||
|
ctx: AuthContext,
|
||||||
|
id: string,
|
||||||
|
input: PantryUseInput,
|
||||||
|
): Promise<{ remaining: number }> {
|
||||||
|
const result = await bulkUsePantry(ctx, {
|
||||||
|
pulls: [{ id, amount: input.amount }],
|
||||||
|
notes: input.notes ?? null,
|
||||||
|
reason: input.reason ?? null,
|
||||||
|
});
|
||||||
|
if (result.skipped.length) throw new ApiError(404, "Pantry item not found");
|
||||||
|
return { remaining: result.results[0].remaining };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BulkUseResult = {
|
||||||
|
results: { id: string; remaining: number }[];
|
||||||
|
/** Requested items that no longer exist — the UI must tell the user. */
|
||||||
|
skipped: { id: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function bulkUsePantry(
|
||||||
|
ctx: AuthContext,
|
||||||
|
input: PantryBulkUseInput,
|
||||||
|
): Promise<BulkUseResult> {
|
||||||
|
const ids = input.pulls.map(p => p.id);
|
||||||
|
const notes = input.notes?.trim() || null;
|
||||||
|
const reason = input.reason ?? null;
|
||||||
|
|
||||||
|
return db.$transaction(async tx => {
|
||||||
|
const items = await tx.item.findMany({
|
||||||
|
where: { id: { in: ids }, kind: "CONSUMABLE", active: true },
|
||||||
|
select: { id: true, name: true, quantity: true, unit: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const plan = planBulkUse(
|
||||||
|
items.map(i => ({ id: i.id, name: i.name, quantity: Number(i.quantity), unit: i.unit })),
|
||||||
|
input.pulls,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const u of plan.updates) {
|
||||||
|
await tx.item.update({ where: { id: u.id }, data: { quantity: u.remaining } });
|
||||||
|
await tx.itemLog.create({
|
||||||
|
data: {
|
||||||
|
itemId: u.id,
|
||||||
|
type: "USED",
|
||||||
|
amount: u.amount,
|
||||||
|
notes,
|
||||||
|
reason,
|
||||||
|
actorId: ctx.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.auditEvent.create({
|
||||||
|
data: {
|
||||||
|
action: "pantry.use",
|
||||||
|
actorId: ctx.userId,
|
||||||
|
payload: {
|
||||||
|
pulls: plan.updates.map(u => ({ id: u.id, name: u.name, amount: u.amount })),
|
||||||
|
skipped: plan.skipped,
|
||||||
|
notes,
|
||||||
|
reason,
|
||||||
|
via: ctx.via,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
results: plan.updates.map(u => ({ id: u.id, remaining: u.remaining })),
|
||||||
|
skipped: plan.skipped.map(id => ({ id })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distinct names + most-recent settings, categories, and labels for autocomplete. */
|
||||||
|
export async function pantryCatalog(_ctx: AuthContext, q?: string) {
|
||||||
|
const query = q?.trim() ?? "";
|
||||||
|
|
||||||
|
// All consumables, active + inactive, so old entries keep autocompleting.
|
||||||
|
const items = await db.item.findMany({
|
||||||
|
where: {
|
||||||
|
kind: "CONSUMABLE",
|
||||||
|
...(query ? { name: { contains: query, mode: "insensitive" } } : {}),
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
select: {
|
||||||
|
name: true, description: true, unit: true, source: true,
|
||||||
|
pantryCategory: true, pantrySubcategory: true,
|
||||||
|
priceUnit: true, pricePaid: true,
|
||||||
|
locationId: true, plantId: true,
|
||||||
|
location: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
take: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deduplicate by name (case-insensitive), keeping the most-recent per name.
|
||||||
|
const seen = new Map<string, (typeof items)[number]>();
|
||||||
|
for (const item of items) {
|
||||||
|
const key = item.name.toLowerCase();
|
||||||
|
if (!seen.has(key)) seen.set(key, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [allCats, allLabels] = await Promise.all([
|
||||||
|
db.item.findMany({
|
||||||
|
where: { kind: "CONSUMABLE", pantryCategory: { not: null } },
|
||||||
|
select: { pantryCategory: true, pantrySubcategory: true },
|
||||||
|
distinct: ["pantryCategory", "pantrySubcategory"],
|
||||||
|
}),
|
||||||
|
db.label.findMany({ select: { name: true }, orderBy: { name: "asc" } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const categories = Array.from(new Set(allCats.map(c => c.pantryCategory).filter(Boolean))) as string[];
|
||||||
|
const subcategories = allCats
|
||||||
|
.filter(c => c.pantrySubcategory)
|
||||||
|
.map(c => ({ category: c.pantryCategory!, subcategory: c.pantrySubcategory! }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
suggestions: Array.from(seen.values()).map(i => ({
|
||||||
|
name: i.name,
|
||||||
|
description: i.description,
|
||||||
|
unit: i.unit,
|
||||||
|
source: i.source,
|
||||||
|
pantryCategory: i.pantryCategory,
|
||||||
|
pantrySubcategory: i.pantrySubcategory,
|
||||||
|
priceUnit: i.priceUnit,
|
||||||
|
pricePaid: i.pricePaid != null ? Number(i.pricePaid) : null,
|
||||||
|
locationId: i.locationId,
|
||||||
|
locationName: i.location?.name ?? null,
|
||||||
|
plantId: i.plantId,
|
||||||
|
})),
|
||||||
|
categories,
|
||||||
|
subcategories,
|
||||||
|
labels: allLabels.map(l => l.name),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// and (later) the MCP server, so the logic lives once.
|
// and (later) the MCP server, so the logic lives once.
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { ApiError, type AuthContext } from "@/lib/api/authenticate";
|
import { ApiError, type AuthContext } from "@/lib/api/authenticate";
|
||||||
|
import { createPantryItem } from "@/lib/services/pantry";
|
||||||
import type { PlantLogInput } from "@/lib/api/schemas";
|
import type { PlantLogInput } from "@/lib/api/schemas";
|
||||||
|
|
||||||
export function listPlants(_ctx: AuthContext, opts?: { q?: string }) {
|
export function listPlants(_ctx: AuthContext, opts?: { q?: string }) {
|
||||||
@@ -32,16 +33,24 @@ export async function getPlant(_ctx: AuthContext, id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function addPlantLog(ctx: AuthContext, plantId: string, input: PlantLogInput) {
|
export async function addPlantLog(ctx: AuthContext, plantId: string, input: PlantLogInput) {
|
||||||
const plant = await db.plant.findUnique({ where: { id: plantId }, select: { active: true } });
|
const plant = await db.plant.findUnique({
|
||||||
|
where: { id: plantId },
|
||||||
|
select: { active: true, commonName: true, variety: true },
|
||||||
|
});
|
||||||
if (!plant || !plant.active) throw new ApiError(404, "Plant not found");
|
if (!plant || !plant.active) throw new ApiError(404, "Plant not found");
|
||||||
|
if (input.pantry && input.type !== "HARVEST")
|
||||||
|
throw new ApiError(400, "Only harvests can go to the pantry");
|
||||||
|
|
||||||
|
const date = input.date ? new Date(input.date) : new Date();
|
||||||
const log = await db.plantLog.create({
|
const log = await db.plantLog.create({
|
||||||
data: {
|
data: {
|
||||||
plantId,
|
plantId,
|
||||||
type: input.type,
|
type: input.type,
|
||||||
date: input.date ? new Date(input.date) : new Date(),
|
date,
|
||||||
notes: input.notes?.trim() || null,
|
notes: input.notes?.trim() || null,
|
||||||
quantity: input.quantity?.trim() || null,
|
quantity: input.quantity?.trim() || null,
|
||||||
|
amount: input.amount ?? null,
|
||||||
|
unit: input.unit?.trim() || null,
|
||||||
actorId: ctx.userId,
|
actorId: ctx.userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -53,5 +62,23 @@ export async function addPlantLog(ctx: AuthContext, plantId: string, input: Plan
|
|||||||
payload: { type: input.type, via: ctx.via },
|
payload: { type: input.type, via: ctx.via },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return log;
|
|
||||||
|
// Harvest → Pantry: land the produce where the cooking happens.
|
||||||
|
let pantryItem = null;
|
||||||
|
if (input.pantry) {
|
||||||
|
pantryItem = await createPantryItem(ctx, {
|
||||||
|
name: plant.variety ? `${plant.commonName} (${plant.variety})` : plant.commonName,
|
||||||
|
quantity: input.amount ?? 1,
|
||||||
|
unit: input.unit?.trim() || null,
|
||||||
|
locationId: input.pantry.locationId ?? null,
|
||||||
|
storedAt: date.toISOString(),
|
||||||
|
bestBefore: input.pantry.bestBefore ?? null,
|
||||||
|
source: "GARDEN",
|
||||||
|
plantId,
|
||||||
|
pantryCategory: "Garden produce",
|
||||||
|
notes: input.notes?.trim() || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...log, pantryItem };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ export function listTasks(_ctx: AuthContext) {
|
|||||||
return db.task.findMany({
|
return db.task.findMany({
|
||||||
where: { active: true },
|
where: { active: true },
|
||||||
orderBy: { nextDue: "asc" },
|
orderBy: { nextDue: "asc" },
|
||||||
include: { plant: { select: { id: true, commonName: true } } },
|
include: {
|
||||||
|
plant: { select: { id: true, commonName: true } },
|
||||||
|
item: { select: { id: true, name: true } },
|
||||||
|
animal: { select: { id: true, name: true } },
|
||||||
|
location: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,6 +25,7 @@ export async function createTask(ctx: AuthContext, input: TaskCreateInput) {
|
|||||||
plantId: input.plantId || null,
|
plantId: input.plantId || null,
|
||||||
itemId: input.itemId || null,
|
itemId: input.itemId || null,
|
||||||
animalId: input.animalId || null,
|
animalId: input.animalId || null,
|
||||||
|
locationId: input.locationId || null,
|
||||||
recurrence: input.recurrence,
|
recurrence: input.recurrence,
|
||||||
intervalDays: input.intervalDays ?? null,
|
intervalDays: input.intervalDays ?? null,
|
||||||
month: input.month ?? null,
|
month: input.month ?? null,
|
||||||
|
|||||||
75
src/lib/tasks/schedule.test.ts
Normal file
75
src/lib/tasks/schedule.test.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { nextDueDate, occurrencesInRange, type ProjectableTask } from "./schedule";
|
||||||
|
|
||||||
|
const d = (s: string) => new Date(`${s}T00:00:00`);
|
||||||
|
|
||||||
|
describe("nextDueDate", () => {
|
||||||
|
it("ONCE never recurs", () => {
|
||||||
|
expect(nextDueDate("ONCE", d("2026-07-06"), null, null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CUSTOM adds the interval (default 30)", () => {
|
||||||
|
expect(nextDueDate("CUSTOM", d("2026-07-06"), 14, null)).toEqual(d("2026-07-20"));
|
||||||
|
expect(nextDueDate("CUSTOM", d("2026-07-06"), null, null)).toEqual(d("2026-08-05"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("MONTHLY moves to next month", () => {
|
||||||
|
expect(nextDueDate("MONTHLY", d("2026-07-06"), null, null)).toEqual(d("2026-08-06"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("YEARLY lands on the 1st of the target month next year", () => {
|
||||||
|
expect(nextDueDate("YEARLY", d("2026-07-06"), null, 4)).toEqual(d("2027-04-01"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function task(overrides: Partial<ProjectableTask>): ProjectableTask {
|
||||||
|
return { nextDue: d("2026-07-10"), recurrence: "ONCE", intervalDays: null, month: null, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("occurrencesInRange", () => {
|
||||||
|
it("ONCE: shows only on its due date", () => {
|
||||||
|
const t = task({});
|
||||||
|
expect(occurrencesInRange(t, d("2026-07-01"), d("2026-07-31"))).toEqual([d("2026-07-10")]);
|
||||||
|
expect(occurrencesInRange(t, d("2026-08-01"), d("2026-08-31"))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CUSTOM: repeats by interval within the range", () => {
|
||||||
|
const t = task({ recurrence: "CUSTOM", intervalDays: 7 });
|
||||||
|
expect(occurrencesInRange(t, d("2026-07-01"), d("2026-07-31"))).toEqual([
|
||||||
|
d("2026-07-10"), d("2026-07-17"), d("2026-07-24"), d("2026-07-31"),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CUSTOM: projects into a later month without drifting", () => {
|
||||||
|
const t = task({ recurrence: "CUSTOM", intervalDays: 14 });
|
||||||
|
expect(occurrencesInRange(t, d("2026-09-01"), d("2026-09-30"))).toEqual([
|
||||||
|
d("2026-09-04"), d("2026-09-18"),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an overdue occurrence stays visible in its own (past) month", () => {
|
||||||
|
const t = task({ recurrence: "CUSTOM", intervalDays: 30, nextDue: d("2026-06-20") });
|
||||||
|
expect(occurrencesInRange(t, d("2026-06-01"), d("2026-06-30"))).toEqual([d("2026-06-20")]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("MONTHLY: same day each month, clamped at short months", () => {
|
||||||
|
const t = task({ recurrence: "MONTHLY", nextDue: d("2026-01-31") });
|
||||||
|
expect(occurrencesInRange(t, d("2026-02-01"), d("2026-02-28"))).toEqual([d("2026-02-28")]);
|
||||||
|
expect(occurrencesInRange(t, d("2026-03-01"), d("2026-03-31"))).toEqual([d("2026-03-31")]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("MONTHLY: never projects before the stored due date", () => {
|
||||||
|
const t = task({ recurrence: "MONTHLY", nextDue: d("2026-07-15") });
|
||||||
|
expect(occurrencesInRange(t, d("2026-06-01"), d("2026-06-30"))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("YEARLY: shows the stored due and the same date next year", () => {
|
||||||
|
const t = task({ recurrence: "YEARLY", nextDue: d("2026-10-01"), month: 10 });
|
||||||
|
expect(occurrencesInRange(t, d("2026-10-01"), d("2026-10-31"))).toEqual([d("2026-10-01")]);
|
||||||
|
expect(occurrencesInRange(t, d("2027-10-01"), d("2027-10-31"))).toEqual([d("2027-10-01")]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing for an inverted range", () => {
|
||||||
|
expect(occurrencesInRange(task({}), d("2026-07-31"), d("2026-07-01"))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -74,3 +74,90 @@ export const MONTH_NAMES = [
|
|||||||
"January", "February", "March", "April", "May", "June",
|
"January", "February", "March", "April", "May", "June",
|
||||||
"July", "August", "September", "October", "November", "December",
|
"July", "August", "September", "October", "November", "December",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Occurrence projection — which days does a task land on within a date range?
|
||||||
|
// The DB stores only the next due date; recurring tasks repeat from there.
|
||||||
|
// Used by the calendar to paint future (and the one overdue) occurrence(s).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ProjectableTask = {
|
||||||
|
nextDue: Date;
|
||||||
|
recurrence: RecurrenceType;
|
||||||
|
intervalDays: number | null;
|
||||||
|
month: number | null; // 1–12, for YEARLY
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Same day-of-month in the given month, clamped to its last day (Jan 31 → Feb 28). */
|
||||||
|
function onDayOfMonth(year: number, monthIdx: number, day: number): Date {
|
||||||
|
const lastDay = new Date(year, monthIdx + 1, 0).getDate();
|
||||||
|
return new Date(year, monthIdx, Math.min(day, lastDay));
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfDay(d: Date): Date {
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All days in [rangeStart, rangeEnd] (inclusive) the task lands on.
|
||||||
|
* The stored nextDue is always an occurrence (even if overdue/in the past);
|
||||||
|
* recurring tasks project forward from it, never backward.
|
||||||
|
*/
|
||||||
|
export function occurrencesInRange(
|
||||||
|
task: ProjectableTask,
|
||||||
|
rangeStart: Date,
|
||||||
|
rangeEnd: Date,
|
||||||
|
): Date[] {
|
||||||
|
const due = startOfDay(new Date(task.nextDue));
|
||||||
|
const start = startOfDay(rangeStart);
|
||||||
|
const end = startOfDay(rangeEnd);
|
||||||
|
if (start > end) return [];
|
||||||
|
|
||||||
|
const inRange = (d: Date) => d >= start && d <= end;
|
||||||
|
const out: Date[] = [];
|
||||||
|
const push = (d: Date) => {
|
||||||
|
if (inRange(d) && !out.some((o) => o.getTime() === d.getTime())) out.push(d);
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (task.recurrence) {
|
||||||
|
case "ONCE": {
|
||||||
|
push(due);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "CUSTOM": {
|
||||||
|
const step = Math.max(1, task.intervalDays ?? 30);
|
||||||
|
push(due); // the stored (possibly overdue) occurrence stays visible
|
||||||
|
let d = due;
|
||||||
|
if (d < start) {
|
||||||
|
// Jump close to the range start instead of stepping day by day.
|
||||||
|
const behind = Math.ceil((start.getTime() - d.getTime()) / (step * 86_400_000));
|
||||||
|
d = new Date(d.getTime() + behind * step * 86_400_000);
|
||||||
|
}
|
||||||
|
for (; d <= end; d = new Date(d.getTime() + step * 86_400_000)) push(d);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "MONTHLY": {
|
||||||
|
const day = due.getDate();
|
||||||
|
push(due);
|
||||||
|
let y = start.getFullYear();
|
||||||
|
let m = start.getMonth();
|
||||||
|
for (; new Date(y, m, 1) <= end; m === 11 ? (m = 0, y++) : m++) {
|
||||||
|
const d = onDayOfMonth(y, m, day);
|
||||||
|
if (d >= due) push(d);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "YEARLY": {
|
||||||
|
push(due);
|
||||||
|
const targetMonth = (task.month ?? due.getMonth() + 1) - 1;
|
||||||
|
const day = due.getDate();
|
||||||
|
for (let y = start.getFullYear(); y <= end.getFullYear(); y++) {
|
||||||
|
const d = onDayOfMonth(y, targetMonth, day);
|
||||||
|
if (d > due) push(d);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.sort((a, b) => a.getTime() - b.getTime());
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
|
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
|
||||||
export const APP_VERSION = "0.16.0";
|
export const APP_VERSION = "0.30.0";
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ const config: Config = {
|
|||||||
"./components/**/*.{ts,tsx}",
|
"./components/**/*.{ts,tsx}",
|
||||||
"./app/**/*.{ts,tsx}",
|
"./app/**/*.{ts,tsx}",
|
||||||
"./src/**/*.{ts,tsx}",
|
"./src/**/*.{ts,tsx}",
|
||||||
|
// The shared @otm/account-panel ships its own Tailwind classes (Suggestions,
|
||||||
|
// self-update, etc.). Scan its source so those utilities actually compile —
|
||||||
|
// otherwise the panel renders nearly unstyled (e.g. a bg-less Send button).
|
||||||
|
"./node_modules/@otm/account-panel/src/**/*.{ts,tsx}",
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
container: {
|
container: {
|
||||||
|
|||||||
Reference in New Issue
Block a user