Compare commits
31 Commits
cffa739056
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 5f4c482636 | |||
| 63569aabd3 |
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": "http://192.168.0.5:3022/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,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Item" ADD COLUMN "insured" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "lifetimeWarranty" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "purchaseFrom" TEXT,
|
||||||
|
ADD COLUMN "warrantyDetails" TEXT;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Item" ADD COLUMN "customFields" JSONB,
|
||||||
|
ADD COLUMN "soldDate" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "soldNotes" TEXT,
|
||||||
|
ADD COLUMN "soldPrice" DECIMAL(10,2),
|
||||||
|
ADD COLUMN "soldTo" TEXT;
|
||||||
@@ -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 {
|
||||||
@@ -314,17 +347,38 @@ model Item {
|
|||||||
containedItems Item[] @relation("ItemNesting")
|
containedItems Item[] @relation("ItemNesting")
|
||||||
|
|
||||||
// Durable attributes
|
// Durable attributes
|
||||||
value Decimal? @db.Decimal(10, 2)
|
value Decimal? @db.Decimal(10, 2) // purchase price
|
||||||
purchaseDate DateTime?
|
purchaseDate DateTime?
|
||||||
warrantyExpiry DateTime?
|
purchaseFrom String? // where it was bought
|
||||||
serial String?
|
warrantyExpiry DateTime?
|
||||||
modelNumber String?
|
warrantyDetails String? // freeform warranty terms
|
||||||
brand String?
|
lifetimeWarranty Boolean @default(false)
|
||||||
|
insured Boolean @default(false)
|
||||||
|
serial String?
|
||||||
|
modelNumber String?
|
||||||
|
brand String? // brand / manufacturer
|
||||||
|
|
||||||
// Consumable attributes (Pantry phase)
|
// Sold lifecycle (HomeBox parity)
|
||||||
barcode String?
|
soldTo String?
|
||||||
bestBefore DateTime?
|
soldPrice Decimal? @db.Decimal(10, 2)
|
||||||
minStock Decimal? @db.Decimal(10, 2)
|
soldDate DateTime?
|
||||||
|
soldNotes String?
|
||||||
|
|
||||||
|
// Custom fields — flexible key/value (e.g. VIN, "Oil Weight": "5W-30")
|
||||||
|
customFields Json?
|
||||||
|
|
||||||
|
// Consumable attributes
|
||||||
|
barcode String?
|
||||||
|
storedAt DateTime? // date frozen / preserved / stored
|
||||||
|
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
|
||||||
@@ -345,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 {
|
||||||
@@ -383,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 {
|
||||||
@@ -393,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">
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
ChevronLeft, MapPin, Box, Package, CalendarDays, DollarSign, Hash, Tag, FileText, Barcode,
|
ChevronLeft, MapPin, Box, Package, CalendarDays, DollarSign, Hash, Tag, FileText, Barcode,
|
||||||
|
Store, ShieldCheck, Wrench, AlertCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
import { warrantyStatus, WARRANTY_LABELS } from "@/lib/inventory/value";
|
import { warrantyStatus, WARRANTY_LABELS } from "@/lib/inventory/value";
|
||||||
import { ITEM_LOG_LABELS, ITEM_LOG_COLORS } from "@/lib/inventory/item-fields";
|
import { ITEM_LOG_LABELS, ITEM_LOG_COLORS } from "@/lib/inventory/item-fields";
|
||||||
|
import { isOverdue, describeRecurrence } from "@/lib/tasks/schedule";
|
||||||
import { EditItemButton, type ItemFields } from "@/components/inventory/item-dialog";
|
import { EditItemButton, type ItemFields } from "@/components/inventory/item-dialog";
|
||||||
import { DeleteItemButton } from "@/components/inventory/delete-item-button";
|
import { DeleteItemButton } from "@/components/inventory/delete-item-button";
|
||||||
import { AddItemLogButton } from "@/components/inventory/add-item-log-button";
|
import { AddItemLogButton } from "@/components/inventory/add-item-log-button";
|
||||||
|
import { ScheduleMaintenanceButton } from "@/components/inventory/schedule-maintenance-button";
|
||||||
import { AttachmentUpload } from "@/components/inventory/attachment-upload";
|
import { AttachmentUpload } from "@/components/inventory/attachment-upload";
|
||||||
|
|
||||||
function toDateInput(d: Date | null): string {
|
function toDateInput(d: Date | null): string {
|
||||||
@@ -34,6 +37,7 @@ export default async function ItemDetailPage({ params }: { params: { id: string
|
|||||||
attachments: { orderBy: { createdAt: "asc" } },
|
attachments: { orderBy: { createdAt: "asc" } },
|
||||||
logs: { orderBy: { date: "desc" } },
|
logs: { orderBy: { date: "desc" } },
|
||||||
containedItems: { where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } },
|
containedItems: { where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } },
|
||||||
|
tasks: { where: { active: true }, orderBy: { nextDue: "asc" } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
@@ -43,7 +47,8 @@ export default async function ItemDetailPage({ params }: { params: { id: string
|
|||||||
|
|
||||||
if (!item || !item.active) notFound();
|
if (!item || !item.active) notFound();
|
||||||
|
|
||||||
const wStatus = warrantyStatus(item.warrantyExpiry, new Date());
|
const wStatus = item.lifetimeWarranty ? "active" : warrantyStatus(item.warrantyExpiry, new Date());
|
||||||
|
const warrantyLabel = item.lifetimeWarranty ? "Lifetime warranty" : WARRANTY_LABELS[wStatus];
|
||||||
const photos = item.attachments.filter((a) => a.kind === "PHOTO");
|
const photos = item.attachments.filter((a) => a.kind === "PHOTO");
|
||||||
const docs = item.attachments.filter((a) => a.kind !== "PHOTO");
|
const docs = item.attachments.filter((a) => a.kind !== "PHOTO");
|
||||||
|
|
||||||
@@ -60,7 +65,16 @@ export default async function ItemDetailPage({ params }: { params: { id: string
|
|||||||
serial: item.serial ?? "",
|
serial: item.serial ?? "",
|
||||||
value: item.value != null ? String(Number(item.value)) : "",
|
value: item.value != null ? String(Number(item.value)) : "",
|
||||||
purchaseDate: toDateInput(item.purchaseDate),
|
purchaseDate: toDateInput(item.purchaseDate),
|
||||||
|
purchaseFrom: item.purchaseFrom ?? "",
|
||||||
warrantyExpiry: toDateInput(item.warrantyExpiry),
|
warrantyExpiry: toDateInput(item.warrantyExpiry),
|
||||||
|
warrantyDetails: item.warrantyDetails ?? "",
|
||||||
|
lifetimeWarranty: item.lifetimeWarranty,
|
||||||
|
insured: item.insured,
|
||||||
|
soldTo: item.soldTo ?? "",
|
||||||
|
soldPrice: item.soldPrice != null ? String(Number(item.soldPrice)) : "",
|
||||||
|
soldDate: toDateInput(item.soldDate),
|
||||||
|
soldNotes: item.soldNotes ?? "",
|
||||||
|
customFields: Object.entries((item.customFields as Record<string, string>) ?? {}).map(([key, value]) => ({ key, value: String(value) })),
|
||||||
barcode: item.barcode ?? "",
|
barcode: item.barcode ?? "",
|
||||||
notes: item.notes ?? "",
|
notes: item.notes ?? "",
|
||||||
labelIds: item.labels.map((l) => l.labelId),
|
labelIds: item.labels.map((l) => l.labelId),
|
||||||
@@ -72,6 +86,9 @@ export default async function ItemDetailPage({ params }: { params: { id: string
|
|||||||
if (Number(item.quantity) !== 1) facts.push({ icon: <Package className="h-4 w-4" />, label: "Quantity", value: `${Number(item.quantity)}${item.unit ? ` ${item.unit}` : ""}` });
|
if (Number(item.quantity) !== 1) facts.push({ icon: <Package className="h-4 w-4" />, label: "Quantity", value: `${Number(item.quantity)}${item.unit ? ` ${item.unit}` : ""}` });
|
||||||
if (item.value != null) facts.push({ icon: <DollarSign className="h-4 w-4" />, label: "Value", value: Number(item.value).toLocaleString(undefined, { style: "currency", currency: "USD" }) });
|
if (item.value != null) facts.push({ icon: <DollarSign className="h-4 w-4" />, label: "Value", value: Number(item.value).toLocaleString(undefined, { style: "currency", currency: "USD" }) });
|
||||||
if (item.purchaseDate) facts.push({ icon: <CalendarDays className="h-4 w-4" />, label: "Purchased", value: formatDate(item.purchaseDate) });
|
if (item.purchaseDate) facts.push({ icon: <CalendarDays className="h-4 w-4" />, label: "Purchased", value: formatDate(item.purchaseDate) });
|
||||||
|
if (item.purchaseFrom) facts.push({ icon: <Store className="h-4 w-4" />, label: "From", value: item.purchaseFrom });
|
||||||
|
if (item.lifetimeWarranty || item.warrantyExpiry) facts.push({ icon: <ShieldCheck className="h-4 w-4" />, label: "Warranty", value: item.lifetimeWarranty ? "Lifetime" : `until ${formatDate(item.warrantyExpiry!)}` });
|
||||||
|
if (item.insured) facts.push({ icon: <ShieldCheck className="h-4 w-4" />, label: "Insured", value: "Yes" });
|
||||||
if (item.brand) facts.push({ icon: <Tag className="h-4 w-4" />, label: "Brand", value: item.brand });
|
if (item.brand) facts.push({ icon: <Tag className="h-4 w-4" />, label: "Brand", value: item.brand });
|
||||||
if (item.modelNumber) facts.push({ icon: <Hash className="h-4 w-4" />, label: "Model", value: item.modelNumber });
|
if (item.modelNumber) facts.push({ icon: <Hash className="h-4 w-4" />, label: "Model", value: item.modelNumber });
|
||||||
if (item.serial) facts.push({ icon: <Hash className="h-4 w-4" />, label: "Serial", value: item.serial });
|
if (item.serial) facts.push({ icon: <Hash className="h-4 w-4" />, label: "Serial", value: item.serial });
|
||||||
@@ -87,14 +104,15 @@ export default async function ItemDetailPage({ params }: { params: { id: string
|
|||||||
<h1 className="font-display text-2xl font-semibold">{item.name}</h1>
|
<h1 className="font-display text-2xl font-semibold">{item.name}</h1>
|
||||||
{item.description && <p className="text-sm text-muted-foreground">{item.description}</p>}
|
{item.description && <p className="text-sm text-muted-foreground">{item.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
{wStatus !== "none" && (
|
{(item.lifetimeWarranty || wStatus !== "none") && (
|
||||||
<Badge variant="outline" className="ml-auto shrink-0">{WARRANTY_LABELS[wStatus]}</Badge>
|
<Badge variant="outline" className="ml-auto shrink-0">{warrantyLabel}</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<EditItemButton item={editFields} locations={locations} items={allItems.filter((i) => i.id !== item.id)} labels={labels} />
|
<EditItemButton item={editFields} locations={locations} items={allItems.filter((i) => i.id !== item.id)} labels={labels} />
|
||||||
<AddItemLogButton itemId={item.id} />
|
<AddItemLogButton itemId={item.id} />
|
||||||
|
<ScheduleMaintenanceButton itemId={item.id} itemName={item.name} />
|
||||||
<AttachmentUpload itemId={item.id} />
|
<AttachmentUpload itemId={item.id} />
|
||||||
<DeleteItemButton id={item.id} name={item.name} />
|
<DeleteItemButton id={item.id} name={item.name} />
|
||||||
</div>
|
</div>
|
||||||
@@ -134,6 +152,71 @@ export default async function ItemDetailPage({ params }: { params: { id: string
|
|||||||
<Card><CardContent className="pt-4 text-sm whitespace-pre-wrap">{item.notes}</CardContent></Card>
|
<Card><CardContent className="pt-4 text-sm whitespace-pre-wrap">{item.notes}</CardContent></Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{item.warrantyDetails && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4 text-sm">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Warranty</p>
|
||||||
|
<p className="whitespace-pre-wrap">{item.warrantyDetails}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.customFields && Object.keys(item.customFields as Record<string, string>).length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4 grid grid-cols-2 gap-3 text-sm">
|
||||||
|
{Object.entries(item.customFields as Record<string, string>).map(([k, v]) => (
|
||||||
|
<div key={k} className="min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">{k}</p>
|
||||||
|
<p className="truncate">{String(v)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.soldTo && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4 text-sm space-y-1">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Sold</p>
|
||||||
|
<p>
|
||||||
|
To {item.soldTo}
|
||||||
|
{item.soldPrice != null && ` for ${Number(item.soldPrice).toLocaleString(undefined, { style: "currency", currency: "USD" })}`}
|
||||||
|
{item.soldDate && ` on ${formatDate(item.soldDate)}`}
|
||||||
|
</p>
|
||||||
|
{item.soldNotes && <p className="text-muted-foreground">{item.soldNotes}</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="font-display text-lg font-semibold">Maintenance</h2>
|
||||||
|
</div>
|
||||||
|
{item.tasks.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No maintenance scheduled. Use “Schedule maintenance” above to set a recurring reminder.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y border rounded-lg overflow-hidden">
|
||||||
|
{item.tasks.map((t) => {
|
||||||
|
const overdue = isOverdue(t.nextDue);
|
||||||
|
return (
|
||||||
|
<div key={t.id} className="flex items-center gap-2 px-3 py-2.5 text-sm">
|
||||||
|
<Wrench className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<span className="flex-1 min-w-0 truncate">{t.title}
|
||||||
|
<span className="text-xs text-muted-foreground"> · {describeRecurrence(t.recurrence, t.intervalDays, t.month)}</span>
|
||||||
|
</span>
|
||||||
|
<span className={`flex items-center gap-1 text-xs shrink-0 ${overdue ? "text-[hsl(var(--ember))]" : "text-muted-foreground"}`}>
|
||||||
|
{overdue && <AlertCircle className="h-3 w-3" />}
|
||||||
|
{overdue ? "Overdue " : "Due "}{formatDate(t.nextDue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{docs.length > 0 && (
|
{docs.length > 0 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<h2 className="font-display text-sm font-semibold">Documents</h2>
|
<h2 className="font-display text-sm font-semibold">Documents</h2>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { AddItemButton } from "@/components/inventory/item-dialog";
|
import { AddItemButton } from "@/components/inventory/item-dialog";
|
||||||
|
import { ImportButton } from "@/components/inventory/import-button";
|
||||||
import { InventoryGrid, type GridItem } from "@/components/inventory/inventory-grid";
|
import { InventoryGrid, type GridItem } from "@/components/inventory/inventory-grid";
|
||||||
import { totalValue } from "@/lib/inventory/value";
|
import { totalValue } from "@/lib/inventory/value";
|
||||||
|
|
||||||
@@ -53,7 +54,10 @@ export default async function InventoryPage() {
|
|||||||
{total > 0 && ` · ${total.toLocaleString(undefined, { style: "currency", currency: "USD" })} total value`}
|
{total > 0 && ` · ${total.toLocaleString(undefined, { style: "currency", currency: "USD" })} total value`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<AddItemButton locations={locations} items={itemOpts} labels={labels} />
|
<div className="flex items-center gap-2">
|
||||||
|
<ImportButton />
|
||||||
|
<AddItemButton locations={locations} items={itemOpts} labels={labels} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InventoryGrid items={gridItems} />
|
<InventoryGrid items={gridItems} />
|
||||||
|
|||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
35
src/app/(dashboard)/locations/page.tsx
Normal file
35
src/app/(dashboard)/locations/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { LocationManager } from "@/components/locations/location-manager";
|
||||||
|
|
||||||
|
export const metadata = { title: "Locations" };
|
||||||
|
|
||||||
|
export default async function LocationsPage() {
|
||||||
|
const locations = await db.location.findMany({
|
||||||
|
where: { active: true },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
kind: true,
|
||||||
|
parentId: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
plants: { where: { active: true } },
|
||||||
|
items: { where: { active: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-2xl">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-2xl font-semibold">Locations</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
|
Manage the places on your property — nest them as deep as you like.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<LocationManager locations={locations} />
|
||||||
|
</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 });
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { authenticateRequest, handleApiError, ApiError } from "@/lib/api/authent
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const KINDS = new Set(["PHOTO", "MANUAL", "RECEIPT"]);
|
const KINDS = new Set(["PHOTO", "MANUAL", "RECEIPT", "WARRANTY"]);
|
||||||
|
|
||||||
// POST /api/v1/items/:id/attachments — multipart upload (file, kind). Files land
|
// POST /api/v1/items/:id/attachments — multipart upload (file, kind). Files land
|
||||||
// under public/uploads/items (a persistent Docker volume), same as suggestions.
|
// under public/uploads/items (a persistent Docker volume), same as suggestions.
|
||||||
|
|||||||
22
src/app/api/v1/items/import/route.ts
Normal file
22
src/app/api/v1/items/import/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 { importHomeBoxItems } from "@/lib/services/inventory-import";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
csv: z.string().min(1),
|
||||||
|
skip: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/items/import — import a HomeBox CSV export. `skip` lists item
|
||||||
|
// names to leave out (e.g. the ones unchecked in the preview).
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const ctx = await authenticateRequest(req, "items:write");
|
||||||
|
const { csv, skip } = schema.parse(await req.json());
|
||||||
|
const summary = await importHomeBoxItems(ctx, { csv, skip });
|
||||||
|
return NextResponse.json(summary, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
return handleApiError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,33 +3,61 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { ImagePlus } from "lucide-react";
|
import { Paperclip, ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
|
const KINDS: { value: string; label: string; accept: string }[] = [
|
||||||
|
{ value: "PHOTO", label: "Photo", accept: "image/*" },
|
||||||
|
{ value: "MANUAL", label: "Manual", accept: ".pdf,image/*" },
|
||||||
|
{ value: "RECEIPT", label: "Receipt", accept: ".pdf,image/*" },
|
||||||
|
{ value: "WARRANTY", label: "Warranty doc", accept: ".pdf,image/*" },
|
||||||
|
];
|
||||||
|
|
||||||
export function AttachmentUpload({ itemId }: { itemId: string }) {
|
export function AttachmentUpload({ itemId }: { itemId: string }) {
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [kind, setKind] = useState("PHOTO");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
function pick(k: string) {
|
||||||
|
setKind(k);
|
||||||
|
// Let state settle, then open the picker with the right accept filter.
|
||||||
|
requestAnimationFrame(() => inputRef.current?.click());
|
||||||
|
}
|
||||||
|
|
||||||
async function onFile(file: File) {
|
async function onFile(file: File) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", file);
|
form.append("file", file);
|
||||||
form.append("kind", "PHOTO");
|
form.append("kind", kind);
|
||||||
const res = await fetch(`/api/v1/items/${itemId}/attachments`, { method: "POST", body: form });
|
const res = await fetch(`/api/v1/items/${itemId}/attachments`, { method: "POST", body: form });
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
if (res.ok) { toast({ title: "Photo added" }); router.refresh(); }
|
if (res.ok) { toast({ title: "Attached" }); router.refresh(); }
|
||||||
else toast({ title: "Error", description: "Upload failed.", variant: "destructive" });
|
else toast({ title: "Error", description: "Upload failed.", variant: "destructive" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accept = KINDS.find((k) => k.value === kind)?.accept ?? "*";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input ref={inputRef} type="file" accept="image/*" className="hidden"
|
<input ref={inputRef} type="file" accept={accept} className="hidden"
|
||||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) onFile(f); e.target.value = ""; }} />
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) onFile(f); e.target.value = ""; }} />
|
||||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => inputRef.current?.click()}>
|
<DropdownMenu>
|
||||||
<ImagePlus className="h-4 w-4 mr-1" /> {busy ? "Uploading…" : "Add photo"}
|
<DropdownMenuTrigger asChild>
|
||||||
</Button>
|
<Button size="sm" variant="outline" disabled={busy}>
|
||||||
|
<Paperclip className="h-4 w-4 mr-1" /> {busy ? "Uploading…" : "Attach"} <ChevronDown className="h-3 w-3 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
{KINDS.map((k) => (
|
||||||
|
<DropdownMenuItem key={k.value} onClick={() => pick(k.value)}>{k.label}</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
134
src/components/inventory/import-button.tsx
Normal file
134
src/components/inventory/import-button.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Upload, FileUp } from "lucide-react";
|
||||||
|
import { parseHomeBoxCsv, type HomeBoxItem } from "@/lib/inventory/homebox-import";
|
||||||
|
|
||||||
|
export function ImportButton() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [csv, setCsv] = useState("");
|
||||||
|
const [rows, setRows] = useState<HomeBoxItem[] | null>(null);
|
||||||
|
const [skip, setSkip] = useState<Set<string>>(new Set());
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
function preview(text: string) {
|
||||||
|
setCsv(text);
|
||||||
|
try {
|
||||||
|
const parsed = parseHomeBoxCsv(text);
|
||||||
|
setRows(parsed);
|
||||||
|
setSkip(new Set());
|
||||||
|
if (parsed.length === 0) toast({ title: "Nothing found", description: "No items parsed from that CSV.", variant: "destructive" });
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Parse error", description: "Couldn't read that CSV.", variant: "destructive" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(name: string) {
|
||||||
|
setSkip((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(name) ? next.delete(name) : next.add(name);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doImport() {
|
||||||
|
if (!rows) return;
|
||||||
|
const include = rows.filter((r) => !skip.has(r.name));
|
||||||
|
if (include.length === 0) { toast({ title: "Nothing selected" }); return; }
|
||||||
|
setBusy(true);
|
||||||
|
const res = await fetch("/api/v1/items/import", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ csv, skip: Array.from(skip) }),
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
toast({ title: "Import failed", description: err.error ?? "Try again.", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const s = await res.json();
|
||||||
|
toast({ title: "Imported!", description: `${s.created} items, ${s.locationsCreated} locations, ${s.labelsCreated} labels.` });
|
||||||
|
setOpen(false); setCsv(""); setRows(null); setSkip(new Set());
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
const includeCount = rows ? rows.length - skip.size : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
|
||||||
|
<Upload className="h-4 w-4 mr-1" /> Import
|
||||||
|
</Button>
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) { setRows(null); setCsv(""); } }}>
|
||||||
|
<DialogContent className="max-w-xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader><DialogTitle>Import from HomeBox CSV</DialogTitle></DialogHeader>
|
||||||
|
|
||||||
|
{!rows ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Paste a HomeBox CSV export, or choose a file. You'll review every item before importing.
|
||||||
|
</p>
|
||||||
|
<Textarea rows={6} value={csv} onChange={(e) => setCsv(e.target.value)} placeholder="HB.location,HB.labels,HB.name,…" className="font-mono text-xs" />
|
||||||
|
<input ref={inputRef} type="file" accept=".csv,text/csv" className="hidden"
|
||||||
|
onChange={async (e) => { const f = e.target.files?.[0]; if (f) preview(await f.text()); e.target.value = ""; }} />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => inputRef.current?.click()}><FileUp className="h-4 w-4 mr-1" />Choose file</Button>
|
||||||
|
<Button size="sm" onClick={() => preview(csv)} disabled={!csv.trim()}>Preview</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{rows.length} items found. Uncheck any you don't want, then import.
|
||||||
|
</p>
|
||||||
|
<div className="divide-y border rounded-lg max-h-[50vh] overflow-y-auto">
|
||||||
|
{rows.map((r, i) => {
|
||||||
|
const on = !skip.has(r.name);
|
||||||
|
return (
|
||||||
|
<label key={`${r.name}-${i}`} className="flex items-start gap-2.5 px-3 py-2 cursor-pointer hover:bg-accent text-sm">
|
||||||
|
<input type="checkbox" checked={on} onChange={() => toggle(r.name)} className="mt-1 accent-[hsl(var(--leaf))]" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className={`font-medium ${on ? "" : "line-through text-muted-foreground"}`}>{r.name}</p>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-0.5 items-center text-xs text-muted-foreground">
|
||||||
|
{r.locationPath && <span>{r.locationPath}</span>}
|
||||||
|
{r.labels.map((l) => <Badge key={l} variant="secondary" className="text-[10px]">{l}</Badge>)}
|
||||||
|
{r.value != null && <span>· ${r.value}</span>}
|
||||||
|
{r.soldTo && <Badge variant="outline" className="text-[10px]">sold</Badge>}
|
||||||
|
{Object.keys(r.customFields).length > 0 && <span>· {Object.keys(r.customFields).length} custom</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => { setOpen(false); setRows(null); setCsv(""); }}>Cancel</Button>
|
||||||
|
{rows && (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setRows(null)}>Back</Button>
|
||||||
|
<Button onClick={doImport} disabled={busy || includeCount === 0}>
|
||||||
|
{busy ? "Importing…" : `Import ${includeCount}`}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,7 +33,16 @@ export type ItemFields = {
|
|||||||
serial: string;
|
serial: string;
|
||||||
value: string;
|
value: string;
|
||||||
purchaseDate: string;
|
purchaseDate: string;
|
||||||
|
purchaseFrom: string;
|
||||||
warrantyExpiry: string;
|
warrantyExpiry: string;
|
||||||
|
warrantyDetails: string;
|
||||||
|
lifetimeWarranty: boolean;
|
||||||
|
insured: boolean;
|
||||||
|
soldTo: string;
|
||||||
|
soldPrice: string;
|
||||||
|
soldDate: string;
|
||||||
|
soldNotes: string;
|
||||||
|
customFields: { key: string; value: string }[];
|
||||||
barcode: string;
|
barcode: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
labelIds: string[];
|
labelIds: string[];
|
||||||
@@ -42,7 +51,9 @@ export type ItemFields = {
|
|||||||
export function blankItem(): ItemFields {
|
export function blankItem(): ItemFields {
|
||||||
return {
|
return {
|
||||||
name: "", description: "", quantity: "1", unit: "", locationId: "", parentItemId: "",
|
name: "", description: "", quantity: "1", unit: "", locationId: "", parentItemId: "",
|
||||||
brand: "", modelNumber: "", serial: "", value: "", purchaseDate: "", warrantyExpiry: "",
|
brand: "", modelNumber: "", serial: "", value: "", purchaseDate: "", purchaseFrom: "",
|
||||||
|
warrantyExpiry: "", warrantyDetails: "", lifetimeWarranty: false, insured: false,
|
||||||
|
soldTo: "", soldPrice: "", soldDate: "", soldNotes: "", customFields: [],
|
||||||
barcode: "", notes: "", labelIds: [],
|
barcode: "", notes: "", labelIds: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -109,7 +120,18 @@ function ItemDialog({
|
|||||||
serial: f.serial.trim() || undefined,
|
serial: f.serial.trim() || undefined,
|
||||||
value: f.value ? Number(f.value) : null,
|
value: f.value ? Number(f.value) : null,
|
||||||
purchaseDate: f.purchaseDate || null,
|
purchaseDate: f.purchaseDate || null,
|
||||||
|
purchaseFrom: f.purchaseFrom.trim() || undefined,
|
||||||
warrantyExpiry: f.warrantyExpiry || null,
|
warrantyExpiry: f.warrantyExpiry || null,
|
||||||
|
warrantyDetails: f.warrantyDetails.trim() || undefined,
|
||||||
|
lifetimeWarranty: f.lifetimeWarranty,
|
||||||
|
insured: f.insured,
|
||||||
|
soldTo: f.soldTo.trim() || undefined,
|
||||||
|
soldPrice: f.soldPrice ? Number(f.soldPrice) : null,
|
||||||
|
soldDate: f.soldDate || null,
|
||||||
|
soldNotes: f.soldNotes.trim() || undefined,
|
||||||
|
customFields: Object.fromEntries(
|
||||||
|
f.customFields.map((c) => [c.key.trim(), c.value.trim()]).filter(([k, v]) => k && v),
|
||||||
|
),
|
||||||
barcode: f.barcode.trim() || undefined,
|
barcode: f.barcode.trim() || undefined,
|
||||||
notes: f.notes.trim() || undefined,
|
notes: f.notes.trim() || undefined,
|
||||||
labelIds: f.labelIds,
|
labelIds: f.labelIds,
|
||||||
@@ -197,7 +219,26 @@ function ItemDialog({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Warranty until</Label>
|
<Label>Warranty until</Label>
|
||||||
<Input type="date" value={f.warrantyExpiry} onChange={(e) => set("warrantyExpiry", e.target.value)} />
|
<Input type="date" value={f.warrantyExpiry} onChange={(e) => set("warrantyExpiry", e.target.value)} disabled={f.lifetimeWarranty} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label>Purchased from</Label>
|
||||||
|
<Input value={f.purchaseFrom} onChange={(e) => set("purchaseFrom", e.target.value)} placeholder="Home Depot, Amazon, gift…" />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label>Warranty details</Label>
|
||||||
|
<Textarea rows={2} value={f.warrantyDetails} onChange={(e) => set("warrantyDetails", e.target.value)} placeholder="Coverage, claim info…" />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 flex flex-wrap gap-x-6 gap-y-2">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input type="checkbox" checked={f.lifetimeWarranty} onChange={(e) => set("lifetimeWarranty", e.target.checked)} className="accent-[hsl(var(--leaf))]" />
|
||||||
|
Lifetime warranty
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input type="checkbox" checked={f.insured} onChange={(e) => set("insured", e.target.checked)} className="accent-[hsl(var(--leaf))]" />
|
||||||
|
Insured
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-2 space-y-1.5">
|
<div className="col-span-2 space-y-1.5">
|
||||||
@@ -219,6 +260,31 @@ function ItemDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 space-y-1.5">
|
||||||
|
<Label>Custom fields</Label>
|
||||||
|
{f.customFields.map((cf, i) => (
|
||||||
|
<div key={i} className="flex gap-2">
|
||||||
|
<Input placeholder="Name (VIN…)" value={cf.key}
|
||||||
|
onChange={(e) => setF((p) => ({ ...p, customFields: p.customFields.map((c, j) => j === i ? { ...c, key: e.target.value } : c) }))} />
|
||||||
|
<Input placeholder="Value" value={cf.value}
|
||||||
|
onChange={(e) => setF((p) => ({ ...p, customFields: p.customFields.map((c, j) => j === i ? { ...c, value: e.target.value } : c) }))} />
|
||||||
|
<Button type="button" variant="outline" size="sm" className="px-2"
|
||||||
|
onClick={() => setF((p) => ({ ...p, customFields: p.customFields.filter((_, j) => j !== i) }))}>×</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="text-sm text-[hsl(var(--leaf))] hover:underline"
|
||||||
|
onClick={() => setF((p) => ({ ...p, customFields: [...p.customFields, { key: "", value: "" }] }))}>
|
||||||
|
+ Add field
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5"><Label>Sold to</Label><Input value={f.soldTo} onChange={(e) => set("soldTo", e.target.value)} placeholder="(if sold)" /></div>
|
||||||
|
<div className="space-y-1.5"><Label>Sold price ($)</Label><Input type="number" min="0" step="0.01" value={f.soldPrice} onChange={(e) => set("soldPrice", e.target.value)} /></div>
|
||||||
|
<div className="space-y-1.5"><Label>Sold date</Label><Input type="date" value={f.soldDate} onChange={(e) => set("soldDate", e.target.value)} /></div>
|
||||||
|
<div className="space-y-1.5"><Label>Sold notes</Label><Input value={f.soldNotes} onChange={(e) => set("soldNotes", e.target.value)} /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="col-span-2 space-y-1.5">
|
<div className="col-span-2 space-y-1.5">
|
||||||
<Label>Notes</Label>
|
<Label>Notes</Label>
|
||||||
<Textarea rows={2} value={f.notes} onChange={(e) => set("notes", e.target.value)} />
|
<Textarea rows={2} value={f.notes} onChange={(e) => set("notes", e.target.value)} />
|
||||||
|
|||||||
108
src/components/inventory/schedule-maintenance-button.tsx
Normal file
108
src/components/inventory/schedule-maintenance-button.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
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";
|
||||||
|
import { Wrench } from "lucide-react";
|
||||||
|
import { MONTH_NAMES } from "@/lib/tasks/schedule";
|
||||||
|
|
||||||
|
export function ScheduleMaintenanceButton({ itemId, itemName }: { itemId: string; itemName: string }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [title, setTitle] = useState(`Maintain ${itemName}`);
|
||||||
|
const [recurrence, setRecurrence] = useState("YEARLY");
|
||||||
|
const [intervalDays, setIntervalDays] = useState("90");
|
||||||
|
const [month, setMonth] = useState("1");
|
||||||
|
const [nextDue, setNextDue] = useState(new Date().toISOString().slice(0, 10));
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!title.trim()) return;
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
title: title.trim(),
|
||||||
|
category: "EQUIPMENT",
|
||||||
|
itemId,
|
||||||
|
recurrence,
|
||||||
|
nextDue: new Date(nextDue).toISOString(),
|
||||||
|
};
|
||||||
|
if (recurrence === "CUSTOM") body.intervalDays = parseInt(intervalDays);
|
||||||
|
if (recurrence === "YEARLY") body.month = parseInt(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 schedule.", variant: "destructive" }); return; }
|
||||||
|
toast({ title: "Maintenance scheduled", description: "It'll show in Reminders too." });
|
||||||
|
setOpen(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
|
||||||
|
<Wrench className="h-4 w-4 mr-1" /> Schedule maintenance
|
||||||
|
</Button>
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader><DialogTitle>Schedule maintenance</DialogTitle></DialogHeader>
|
||||||
|
<form onSubmit={submit} className="space-y-4 pt-1">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>What needs doing?</Label>
|
||||||
|
<Input value={title} onChange={(e) => setTitle(e.target.value)} autoFocus />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Repeats</Label>
|
||||||
|
<Select value={recurrence} onValueChange={setRecurrence}>
|
||||||
|
<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>
|
||||||
|
{recurrence === "CUSTOM" && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Every how many days?</Label>
|
||||||
|
<Input type="number" min="1" value={intervalDays} onChange={(e) => setIntervalDays(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{recurrence === "YEARLY" && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Which month?</Label>
|
||||||
|
<Select value={month} onValueChange={setMonth}>
|
||||||
|
<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={nextDue} onChange={(e) => setNextDue(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Schedule"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
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,27 +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 } 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: "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();
|
||||||
@@ -38,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}
|
||||||
@@ -64,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
264
src/components/locations/location-manager.tsx
Normal file
264
src/components/locations/location-manager.tsx
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Plus, MoreVertical, MapPin, Building2, DoorOpen, Box, Trees, HelpCircle, Bell } from "lucide-react";
|
||||||
|
import { buildTree, flattenForSelect } from "@/lib/locations/tree";
|
||||||
|
import { MONTH_NAMES } from "@/lib/tasks/schedule";
|
||||||
|
import { QuickSetupButton } from "@/components/locations/quick-setup-button";
|
||||||
|
|
||||||
|
type Loc = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
parentId: string | null;
|
||||||
|
_count: { plants: number; items: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_LABELS: Record<string, string> = {
|
||||||
|
AREA: "Area", BUILDING: "Building", ROOM: "Room", STORAGE: "Storage", OTHER: "Other",
|
||||||
|
};
|
||||||
|
const KIND_ICONS: Record<string, typeof MapPin> = {
|
||||||
|
AREA: Trees, BUILDING: Building2, ROOM: DoorOpen, STORAGE: Box, OTHER: HelpCircle,
|
||||||
|
};
|
||||||
|
|
||||||
|
const NONE = "__none__";
|
||||||
|
|
||||||
|
export function LocationManager({ locations }: { locations: Loc[] }) {
|
||||||
|
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 router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const tree = buildTree(locations);
|
||||||
|
const flat = flattenForSelect(tree); // depth-ordered for indentation
|
||||||
|
|
||||||
|
function openAdd(parentId?: string) {
|
||||||
|
setDialog({ mode: "add", name: "", kind: "AREA", parentId: parentId ?? "" });
|
||||||
|
}
|
||||||
|
function openEdit(loc: Loc) {
|
||||||
|
setDialog({ mode: "edit", id: loc.id, name: loc.name, kind: loc.kind, parentId: loc.parentId ?? "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!dialog || !dialog.name.trim()) return;
|
||||||
|
setBusy(true);
|
||||||
|
const body = { name: dialog.name.trim(), kind: dialog.kind, parentId: dialog.parentId || null };
|
||||||
|
const res = await fetch(dialog.mode === "add" ? "/api/locations" : `/api/locations/${dialog.id}`, {
|
||||||
|
method: dialog.mode === "add" ? "POST" : "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
toast({ title: "Error", description: err.error ?? "Could not save.", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast({ title: dialog.mode === "add" ? "Location added" : "Saved" });
|
||||||
|
setDialog(null);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(loc: Loc) {
|
||||||
|
if (!confirm(`Delete "${loc.name}"?`)) return;
|
||||||
|
const res = await fetch(`/api/locations/${loc.id}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
toast({ title: "Can't delete", description: err.error ?? "Move its contents first.", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast({ title: "Deleted" });
|
||||||
|
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).
|
||||||
|
const parentChoices = flat.filter((n) => !(dialog?.mode === "edit" && n.id === dialog.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
The shared place tree — garden areas, buildings, rooms, and storage. Plants and items live here.
|
||||||
|
</p>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{locations.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-muted-foreground border rounded-lg">
|
||||||
|
<MapPin className="h-8 w-8 mx-auto mb-2 opacity-30" />
|
||||||
|
<p className="text-sm">No locations yet.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y border rounded-lg overflow-hidden">
|
||||||
|
{flat.map((n) => {
|
||||||
|
const Icon = KIND_ICONS[n.kind] ?? MapPin;
|
||||||
|
const counts = (n as unknown as Loc)._count;
|
||||||
|
return (
|
||||||
|
<div key={n.id} className="flex items-center gap-2 px-3 py-2.5" style={{ paddingLeft: n.depth * 20 + 12 }}>
|
||||||
|
<Icon className="h-4 w-4 text-[hsl(var(--leaf))] shrink-0" />
|
||||||
|
<span className="font-medium text-sm">{n.name}</span>
|
||||||
|
<Badge variant="outline" className="text-[10px]">{KIND_LABELS[n.kind] ?? n.kind}</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{[counts?.plants ? `${counts.plants} plants` : "", counts?.items ? `${counts.items} items` : ""].filter(Boolean).join(" · ")}
|
||||||
|
</span>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"><MoreVertical className="h-4 w-4" /></Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => openAdd(n.id)}>Add sub-location</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 />
|
||||||
|
<DropdownMenuItem className="text-[hsl(var(--ember))]" onClick={() => remove(n as unknown as Loc)}>Delete</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={!!dialog} onOpenChange={(v) => { if (!v) setDialog(null); }}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader><DialogTitle>{dialog?.mode === "add" ? "Add location" : "Edit location"}</DialogTitle></DialogHeader>
|
||||||
|
{dialog && (
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); save(); }} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input value={dialog.name} onChange={(e) => setDialog({ ...dialog, name: e.target.value })} autoFocus placeholder="Back garden, Kitchen, Shelf B…" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<Select value={dialog.kind} onValueChange={(v) => setDialog({ ...dialog, kind: v })}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(KIND_LABELS).map(([k, l]) => <SelectItem key={k} value={k}>{l}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Inside</Label>
|
||||||
|
<Select value={dialog.parentId || NONE} onValueChange={(v) => setDialog({ ...dialog, parentId: v === NONE ? "" : v })}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Top level" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NONE}>— Top level</SelectItem>
|
||||||
|
{parentChoices.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>{" ".repeat(p.depth * 2)}{p.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setDialog(null)}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Save"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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({
|
||||||
@@ -14,6 +24,9 @@ export const taskCreateInput = z.object({
|
|||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
category: z.enum(["GARDEN", "HOME", "EQUIPMENT", "OTHER"]).default("GARDEN"),
|
category: z.enum(["GARDEN", "HOME", "EQUIPMENT", "OTHER"]).default("GARDEN"),
|
||||||
plantId: z.string().optional(),
|
plantId: z.string().optional(),
|
||||||
|
itemId: 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(),
|
||||||
@@ -34,10 +47,19 @@ export const itemCreateInput = z.object({
|
|||||||
parentItemId: z.string().nullable().optional(),
|
parentItemId: z.string().nullable().optional(),
|
||||||
value: z.coerce.number().nonnegative().nullable().optional(),
|
value: z.coerce.number().nonnegative().nullable().optional(),
|
||||||
purchaseDate: z.string().nullable().optional(),
|
purchaseDate: z.string().nullable().optional(),
|
||||||
|
purchaseFrom: z.string().optional(),
|
||||||
warrantyExpiry: z.string().nullable().optional(),
|
warrantyExpiry: z.string().nullable().optional(),
|
||||||
|
warrantyDetails: z.string().optional(),
|
||||||
|
lifetimeWarranty: z.boolean().optional(),
|
||||||
|
insured: z.boolean().optional(),
|
||||||
serial: z.string().optional(),
|
serial: z.string().optional(),
|
||||||
modelNumber: z.string().optional(),
|
modelNumber: z.string().optional(),
|
||||||
brand: z.string().optional(),
|
brand: z.string().optional(),
|
||||||
|
soldTo: z.string().optional(),
|
||||||
|
soldPrice: z.coerce.number().nonnegative().nullable().optional(),
|
||||||
|
soldDate: z.string().nullable().optional(),
|
||||||
|
soldNotes: z.string().optional(),
|
||||||
|
customFields: z.record(z.string()).nullable().optional(),
|
||||||
barcode: z.string().optional(),
|
barcode: z.string().optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
imageUrl: z.string().nullable().optional(),
|
imageUrl: z.string().nullable().optional(),
|
||||||
@@ -47,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,182 @@ 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",
|
||||||
|
date: "2026-06-16",
|
||||||
|
changes: [
|
||||||
|
"Import your inventory from HomeBox — on the Inventory page, click Import, paste or upload your HomeBox CSV, review every item (uncheck any you don't want), and it brings them in, creating the locations and labels for you.",
|
||||||
|
"Items can now record custom fields (like VIN or oil weight for vehicles) and a 'sold' record (who to, price, when) — and you can edit both on any item.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "0.15.0",
|
||||||
|
date: "2026-06-16",
|
||||||
|
changes: [
|
||||||
|
"Inventory got a lot deeper: attach manuals, receipts, and warranty docs (not just photos) to an item; record where you bought it, warranty details, lifetime-warranty and insured flags; and schedule recurring maintenance right from an item (it shows up in Reminders, with overdue flagged).",
|
||||||
|
"New Locations page in the menu — see and manage your whole place tree in one spot: add, rename, move, or remove garden areas, rooms, and storage, nested as deep as you like.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.14.0",
|
version: "0.14.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);
|
||||||
|
}
|
||||||
61
src/lib/inventory/homebox-import.test.ts
Normal file
61
src/lib/inventory/homebox-import.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { parseCsv, parseHomeBoxCsv, splitLocationPath } from "./homebox-import";
|
||||||
|
|
||||||
|
describe("parseCsv", () => {
|
||||||
|
it("handles quoted fields with embedded commas and quotes", () => {
|
||||||
|
const rows = parseCsv('a,"b,c","d""e"\n1,2,3');
|
||||||
|
expect(rows[0]).toEqual(["a", "b,c", 'd"e']);
|
||||||
|
expect(rows[1]).toEqual(["1", "2", "3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles newlines inside quoted fields", () => {
|
||||||
|
const rows = parseCsv('name,desc\n"Mower","200cc\nrear bag"');
|
||||||
|
expect(rows[1]).toEqual(["Mower", "200cc\nrear bag"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const HEADER =
|
||||||
|
"HB.location,HB.labels,HB.name,HB.quantity,HB.description,HB.insured,HB.purchase_price,HB.purchase_from,HB.purchase_time,HB.manufacturer,HB.lifetime_warranty,HB.warranty_expires,HB.sold_to,HB.sold_price,HB.sold_time,HB.field.VIN,HB.field.Oil Weight";
|
||||||
|
|
||||||
|
describe("parseHomeBoxCsv", () => {
|
||||||
|
it("maps columns and applies HomeBox quirks", () => {
|
||||||
|
const csv = [
|
||||||
|
HEADER,
|
||||||
|
'Living Room,Electronics,"Samsung 70""",1,,false,0,,0001-01-01,,false,0001-01-01,,0,0001-01-01,,',
|
||||||
|
'Vehicles,,2012 Equinox,1,,true,400,Dealer,2024-05-01,Chevy,false,0001-01-01,Alter,525,2024-05-24,1ABC,5W-30',
|
||||||
|
].join("\n");
|
||||||
|
const items = parseHomeBoxCsv(csv);
|
||||||
|
expect(items).toHaveLength(2);
|
||||||
|
|
||||||
|
const tv = items[0];
|
||||||
|
expect(tv.name).toBe('Samsung 70"');
|
||||||
|
expect(tv.labels).toEqual(["Electronics"]);
|
||||||
|
expect(tv.value).toBeNull(); // price 0 → null
|
||||||
|
expect(tv.purchaseDate).toBeNull(); // 0001 → null
|
||||||
|
expect(tv.insured).toBe(false);
|
||||||
|
|
||||||
|
const car = items[1];
|
||||||
|
expect(car.value).toBe(400);
|
||||||
|
expect(car.purchaseDate).toBe("2024-05-01");
|
||||||
|
expect(car.insured).toBe(true);
|
||||||
|
expect(car.soldTo).toBe("Alter");
|
||||||
|
expect(car.soldPrice).toBe(525);
|
||||||
|
expect(car.soldDate).toBe("2024-05-24");
|
||||||
|
expect(car.customFields).toEqual({ VIN: "1ABC", "Oil Weight": "5W-30" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits semicolon labels and skips nameless rows", () => {
|
||||||
|
const csv = [HEADER, "Basement,Tools; Woodworking,Planer,1,,false,0,,0001,,false,0001,,0,0001,,",
|
||||||
|
"Basement,,,1,,false,0,,0001,,false,0001,,0,0001,,"].join("\n");
|
||||||
|
const items = parseHomeBoxCsv(csv);
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0].labels).toEqual(["Tools", "Woodworking"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("splitLocationPath", () => {
|
||||||
|
it("splits nested HomeBox paths", () => {
|
||||||
|
expect(splitLocationPath("Upstairs / Print Room")).toEqual(["Upstairs", "Print Room"]);
|
||||||
|
expect(splitLocationPath("Vehicles")).toEqual(["Vehicles"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
131
src/lib/inventory/homebox-import.ts
Normal file
131
src/lib/inventory/homebox-import.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// Pure HomeBox CSV import parsing. No DB — turns a HomeBox export into normalized
|
||||||
|
// item rows the import service can create. Handles RFC4180 quoting (commas and
|
||||||
|
// newlines inside quoted fields, "" escapes), HomeBox's 0001-* null dates, its
|
||||||
|
// "0" = empty price, semicolon-separated labels, and HB.field.* custom columns.
|
||||||
|
|
||||||
|
export type HomeBoxItem = {
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
quantity: number;
|
||||||
|
locationPath: string | null; // e.g. "Upstairs / Print Room"
|
||||||
|
labels: string[];
|
||||||
|
insured: boolean;
|
||||||
|
notes: string | null;
|
||||||
|
value: number | null;
|
||||||
|
purchaseFrom: string | null;
|
||||||
|
purchaseDate: string | null;
|
||||||
|
brand: string | null;
|
||||||
|
modelNumber: string | null;
|
||||||
|
serial: string | null;
|
||||||
|
lifetimeWarranty: boolean;
|
||||||
|
warrantyExpiry: string | null;
|
||||||
|
warrantyDetails: string | null;
|
||||||
|
soldTo: string | null;
|
||||||
|
soldPrice: number | null;
|
||||||
|
soldDate: string | null;
|
||||||
|
soldNotes: string | null;
|
||||||
|
customFields: Record<string, string>;
|
||||||
|
archived: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** RFC4180-ish CSV tokenizer → array of rows of string cells. */
|
||||||
|
export function parseCsv(text: string): string[][] {
|
||||||
|
const s = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||||
|
const rows: string[][] = [];
|
||||||
|
let row: string[] = [];
|
||||||
|
let field = "";
|
||||||
|
let inQuotes = false;
|
||||||
|
let i = 0;
|
||||||
|
while (i < s.length) {
|
||||||
|
const c = s[i];
|
||||||
|
if (inQuotes) {
|
||||||
|
if (c === '"') {
|
||||||
|
if (s[i + 1] === '"') { field += '"'; i += 2; continue; }
|
||||||
|
inQuotes = false; i++; continue;
|
||||||
|
}
|
||||||
|
field += c; i++; continue;
|
||||||
|
}
|
||||||
|
if (c === '"') { inQuotes = true; i++; continue; }
|
||||||
|
if (c === ",") { row.push(field); field = ""; i++; continue; }
|
||||||
|
if (c === "\n") { row.push(field); rows.push(row); row = []; field = ""; i++; continue; }
|
||||||
|
field += c; i++;
|
||||||
|
}
|
||||||
|
if (field !== "" || row.length > 0) { row.push(field); rows.push(row); }
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanDate(v: string): string | null {
|
||||||
|
const t = v.trim();
|
||||||
|
// HomeBox exports "no date" as year 0001.
|
||||||
|
if (!t || t.startsWith("0001")) return null;
|
||||||
|
return Number.isNaN(new Date(t).getTime()) ? null : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanNum(v: string): number | null {
|
||||||
|
const t = v.trim();
|
||||||
|
if (!t) return null;
|
||||||
|
const n = Number(t);
|
||||||
|
// HomeBox exports empty price as 0.
|
||||||
|
return Number.isNaN(n) || n === 0 ? null : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTrue = (v: string) => v.trim().toLowerCase() === "true";
|
||||||
|
|
||||||
|
export function parseHomeBoxCsv(text: string): HomeBoxItem[] {
|
||||||
|
const rows = parseCsv(text).filter((r) => r.some((c) => c.trim() !== ""));
|
||||||
|
if (rows.length < 2) return [];
|
||||||
|
|
||||||
|
const header = rows[0].map((h) => h.trim());
|
||||||
|
const idx = (name: string) => header.indexOf(name);
|
||||||
|
const fieldCols = header
|
||||||
|
.map((h, i) => ({ key: h.replace(/^HB\.field\./, ""), i, isField: h.startsWith("HB.field.") }))
|
||||||
|
.filter((x) => x.isField);
|
||||||
|
|
||||||
|
const items: HomeBoxItem[] = [];
|
||||||
|
for (let r = 1; r < rows.length; r++) {
|
||||||
|
const row = rows[r];
|
||||||
|
const get = (name: string) => {
|
||||||
|
const i = idx(name);
|
||||||
|
return i >= 0 ? (row[i] ?? "").trim() : "";
|
||||||
|
};
|
||||||
|
const name = get("HB.name");
|
||||||
|
if (!name) continue;
|
||||||
|
|
||||||
|
const customFields: Record<string, string> = {};
|
||||||
|
for (const fc of fieldCols) {
|
||||||
|
const val = (row[fc.i] ?? "").trim();
|
||||||
|
if (val) customFields[fc.key] = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
name,
|
||||||
|
description: get("HB.description") || null,
|
||||||
|
quantity: Number(get("HB.quantity")) || 1,
|
||||||
|
locationPath: get("HB.location") || null,
|
||||||
|
labels: get("HB.labels").split(";").map((s) => s.trim()).filter(Boolean),
|
||||||
|
insured: isTrue(get("HB.insured")),
|
||||||
|
notes: get("HB.notes") || null,
|
||||||
|
value: cleanNum(get("HB.purchase_price")),
|
||||||
|
purchaseFrom: get("HB.purchase_from") || null,
|
||||||
|
purchaseDate: cleanDate(get("HB.purchase_time")),
|
||||||
|
brand: get("HB.manufacturer") || null,
|
||||||
|
modelNumber: get("HB.model_number") || null,
|
||||||
|
serial: get("HB.serial_number") || null,
|
||||||
|
lifetimeWarranty: isTrue(get("HB.lifetime_warranty")),
|
||||||
|
warrantyExpiry: cleanDate(get("HB.warranty_expires")),
|
||||||
|
warrantyDetails: get("HB.warranty_details") || null,
|
||||||
|
soldTo: get("HB.sold_to") || null,
|
||||||
|
soldPrice: cleanNum(get("HB.sold_price")),
|
||||||
|
soldDate: cleanDate(get("HB.sold_time")),
|
||||||
|
soldNotes: get("HB.sold_notes") || null,
|
||||||
|
customFields,
|
||||||
|
archived: isTrue(get("HB.archived")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split a HomeBox location path ("Upstairs / Print Room") into nested names. */
|
||||||
|
export function splitLocationPath(path: string): string[] {
|
||||||
|
return path.split("/").map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
141
src/lib/services/inventory-import.ts
Normal file
141
src/lib/services/inventory-import.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
// Import service for HomeBox CSV → Moonbase items. Resolves (find-or-creates)
|
||||||
|
// the nested location path and labels, then creates the items. The parsing is
|
||||||
|
// pure (src/lib/inventory/homebox-import.ts) and shared with the preview UI.
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import type { AuthContext } from "@/lib/api/authenticate";
|
||||||
|
import { recomputeAllPaths } from "@/lib/locations/db";
|
||||||
|
import { parseHomeBoxCsv, splitLocationPath, type HomeBoxItem } from "@/lib/inventory/homebox-import";
|
||||||
|
|
||||||
|
export type ImportSummary = {
|
||||||
|
created: number;
|
||||||
|
skipped: number;
|
||||||
|
locationsCreated: number;
|
||||||
|
labelsCreated: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function buildLocationCache() {
|
||||||
|
const rows = await db.location.findMany({ select: { id: true, name: true, parentId: true } });
|
||||||
|
const cache = new Map<string, string>(); // `${parentId||''}:${nameLower}` → id
|
||||||
|
for (const r of rows) cache.set(`${r.parentId ?? ""}:${r.name.toLowerCase()}`, r.id);
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveLocationPath(
|
||||||
|
path: string | null,
|
||||||
|
cache: Map<string, string>,
|
||||||
|
counter: { n: number },
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!path) return null;
|
||||||
|
const names = splitLocationPath(path);
|
||||||
|
let parentId: string | null = null;
|
||||||
|
for (const name of names) {
|
||||||
|
const key = `${parentId ?? ""}:${name.toLowerCase()}`;
|
||||||
|
const existing: string | undefined = cache.get(key);
|
||||||
|
let id: string;
|
||||||
|
if (existing) {
|
||||||
|
id = existing;
|
||||||
|
} else {
|
||||||
|
const loc = await db.location.create({ data: { name, kind: "OTHER", parentId } });
|
||||||
|
id = loc.id;
|
||||||
|
cache.set(key, id);
|
||||||
|
counter.n++;
|
||||||
|
}
|
||||||
|
parentId = id;
|
||||||
|
}
|
||||||
|
return parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildLabelCache() {
|
||||||
|
const rows = await db.label.findMany({ select: { id: true, name: true } });
|
||||||
|
const cache = new Map<string, string>();
|
||||||
|
for (const r of rows) cache.set(r.name.toLowerCase(), r.id);
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveLabels(
|
||||||
|
names: string[],
|
||||||
|
cache: Map<string, string>,
|
||||||
|
counter: { n: number },
|
||||||
|
): Promise<string[]> {
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const name of names) {
|
||||||
|
let id = cache.get(name.toLowerCase());
|
||||||
|
if (!id) {
|
||||||
|
const label = await db.label.create({ data: { name } });
|
||||||
|
id = label.id;
|
||||||
|
cache.set(name.toLowerCase(), id);
|
||||||
|
counter.n++;
|
||||||
|
}
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDate(s: string | null): Date | null {
|
||||||
|
return s ? new Date(s) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importHomeBoxItems(
|
||||||
|
ctx: AuthContext,
|
||||||
|
opts: { csv: string; skip?: string[] },
|
||||||
|
): Promise<ImportSummary> {
|
||||||
|
const parsed = parseHomeBoxCsv(opts.csv);
|
||||||
|
const skip = new Set((opts.skip ?? []).map((s) => s.trim().toLowerCase()));
|
||||||
|
|
||||||
|
const locCache = await buildLocationCache();
|
||||||
|
const labelCache = await buildLabelCache();
|
||||||
|
const locCounter = { n: 0 };
|
||||||
|
const labelCounter = { n: 0 };
|
||||||
|
let created = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const hb of parsed as HomeBoxItem[]) {
|
||||||
|
if (skip.has(hb.name.trim().toLowerCase())) { skipped++; continue; }
|
||||||
|
|
||||||
|
const locationId = await resolveLocationPath(hb.locationPath, locCache, locCounter);
|
||||||
|
const labelIds = await resolveLabels(hb.labels, labelCache, labelCounter);
|
||||||
|
|
||||||
|
await db.item.create({
|
||||||
|
data: {
|
||||||
|
name: hb.name,
|
||||||
|
description: hb.description,
|
||||||
|
quantity: hb.quantity || 1,
|
||||||
|
locationId,
|
||||||
|
value: hb.value,
|
||||||
|
purchaseDate: toDate(hb.purchaseDate),
|
||||||
|
purchaseFrom: hb.purchaseFrom,
|
||||||
|
warrantyExpiry: toDate(hb.warrantyExpiry),
|
||||||
|
warrantyDetails: hb.warrantyDetails,
|
||||||
|
lifetimeWarranty: hb.lifetimeWarranty,
|
||||||
|
insured: hb.insured,
|
||||||
|
serial: hb.serial,
|
||||||
|
modelNumber: hb.modelNumber,
|
||||||
|
brand: hb.brand,
|
||||||
|
notes: hb.notes,
|
||||||
|
soldTo: hb.soldTo,
|
||||||
|
soldPrice: hb.soldPrice,
|
||||||
|
soldDate: toDate(hb.soldDate),
|
||||||
|
soldNotes: hb.soldNotes,
|
||||||
|
customFields: Object.keys(hb.customFields).length ? hb.customFields : undefined,
|
||||||
|
active: !hb.archived,
|
||||||
|
qrSlug: createId().slice(0, 10),
|
||||||
|
...(labelIds.length ? { labels: { create: labelIds.map((labelId) => ({ labelId })) } } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New locations were created without paths — recompute breadcrumbs once.
|
||||||
|
if (locCounter.n > 0) await recomputeAllPaths();
|
||||||
|
|
||||||
|
await db.auditEvent.create({
|
||||||
|
data: {
|
||||||
|
action: "item.import",
|
||||||
|
actorId: ctx.userId,
|
||||||
|
payload: { created, skipped, source: "homebox", via: ctx.via },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { created, skipped, locationsCreated: locCounter.n, labelsCreated: labelCounter.n };
|
||||||
|
}
|
||||||
@@ -82,10 +82,19 @@ export async function createItem(ctx: AuthContext, input: ItemCreateInput) {
|
|||||||
parentItemId: r.parentItemId || null,
|
parentItemId: r.parentItemId || null,
|
||||||
value: r.value ?? null,
|
value: r.value ?? null,
|
||||||
purchaseDate: toDate(r.purchaseDate),
|
purchaseDate: toDate(r.purchaseDate),
|
||||||
|
purchaseFrom: r.purchaseFrom?.trim() || null,
|
||||||
warrantyExpiry: toDate(r.warrantyExpiry),
|
warrantyExpiry: toDate(r.warrantyExpiry),
|
||||||
|
warrantyDetails: r.warrantyDetails?.trim() || null,
|
||||||
|
lifetimeWarranty: r.lifetimeWarranty ?? false,
|
||||||
|
insured: r.insured ?? false,
|
||||||
serial: r.serial?.trim() || null,
|
serial: r.serial?.trim() || null,
|
||||||
modelNumber: r.modelNumber?.trim() || null,
|
modelNumber: r.modelNumber?.trim() || null,
|
||||||
brand: r.brand?.trim() || null,
|
brand: r.brand?.trim() || null,
|
||||||
|
soldTo: r.soldTo?.trim() || null,
|
||||||
|
soldPrice: r.soldPrice ?? null,
|
||||||
|
soldDate: toDate(r.soldDate),
|
||||||
|
soldNotes: r.soldNotes?.trim() || null,
|
||||||
|
customFields: r.customFields && Object.keys(r.customFields).length ? r.customFields : undefined,
|
||||||
barcode: r.barcode?.trim() || null,
|
barcode: r.barcode?.trim() || null,
|
||||||
notes: r.notes?.trim() || null,
|
notes: r.notes?.trim() || null,
|
||||||
imageUrl: r.imageUrl || null,
|
imageUrl: r.imageUrl || null,
|
||||||
@@ -121,10 +130,20 @@ export async function updateItem(ctx: AuthContext, id: string, input: ItemUpdate
|
|||||||
if (r.parentItemId !== undefined) data.parentItem = r.parentItemId ? { connect: { id: r.parentItemId } } : { disconnect: true };
|
if (r.parentItemId !== undefined) data.parentItem = r.parentItemId ? { connect: { id: r.parentItemId } } : { disconnect: true };
|
||||||
if (r.value !== undefined) data.value = r.value;
|
if (r.value !== undefined) data.value = r.value;
|
||||||
if (r.purchaseDate !== undefined) data.purchaseDate = toDate(r.purchaseDate);
|
if (r.purchaseDate !== undefined) data.purchaseDate = toDate(r.purchaseDate);
|
||||||
|
if (r.purchaseFrom !== undefined) data.purchaseFrom = r.purchaseFrom?.trim() || null;
|
||||||
if (r.warrantyExpiry !== undefined) data.warrantyExpiry = toDate(r.warrantyExpiry);
|
if (r.warrantyExpiry !== undefined) data.warrantyExpiry = toDate(r.warrantyExpiry);
|
||||||
|
if (r.warrantyDetails !== undefined) data.warrantyDetails = r.warrantyDetails?.trim() || null;
|
||||||
|
if (r.lifetimeWarranty !== undefined) data.lifetimeWarranty = r.lifetimeWarranty;
|
||||||
|
if (r.insured !== undefined) data.insured = r.insured;
|
||||||
if (r.serial !== undefined) data.serial = r.serial?.trim() || null;
|
if (r.serial !== undefined) data.serial = r.serial?.trim() || null;
|
||||||
if (r.modelNumber !== undefined) data.modelNumber = r.modelNumber?.trim() || null;
|
if (r.modelNumber !== undefined) data.modelNumber = r.modelNumber?.trim() || null;
|
||||||
if (r.brand !== undefined) data.brand = r.brand?.trim() || null;
|
if (r.brand !== undefined) data.brand = r.brand?.trim() || null;
|
||||||
|
if (r.soldTo !== undefined) data.soldTo = r.soldTo?.trim() || null;
|
||||||
|
if (r.soldPrice !== undefined) data.soldPrice = r.soldPrice;
|
||||||
|
if (r.soldDate !== undefined) data.soldDate = toDate(r.soldDate);
|
||||||
|
if (r.soldNotes !== undefined) data.soldNotes = r.soldNotes?.trim() || null;
|
||||||
|
if (r.customFields !== undefined)
|
||||||
|
data.customFields = r.customFields && Object.keys(r.customFields).length ? r.customFields : Prisma.DbNull;
|
||||||
if (r.barcode !== undefined) data.barcode = r.barcode?.trim() || null;
|
if (r.barcode !== undefined) data.barcode = r.barcode?.trim() || null;
|
||||||
if (r.notes !== undefined) data.notes = r.notes?.trim() || null;
|
if (r.notes !== undefined) data.notes = r.notes?.trim() || null;
|
||||||
if (r.imageUrl !== undefined) data.imageUrl = r.imageUrl || null;
|
if (r.imageUrl !== undefined) data.imageUrl = r.imageUrl || null;
|
||||||
@@ -145,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 },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,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 } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +23,9 @@ export async function createTask(ctx: AuthContext, input: TaskCreateInput) {
|
|||||||
notes: input.notes?.trim() || null,
|
notes: input.notes?.trim() || null,
|
||||||
category: input.category,
|
category: input.category,
|
||||||
plantId: input.plantId || null,
|
plantId: input.plantId || null,
|
||||||
|
itemId: input.itemId || 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.14.0";
|
export const APP_VERSION = "0.30.0";
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user