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
|
||||
**PlantType** records, Wikipedia photos. Plants sit on the shared location tree.
|
||||
- **Inventory** — durable **Item**s that nest (`parentItemId`), with **Label**s
|
||||
(M:N via `LabelOnItem`), `ItemAttachment`, and `ItemLog`. Shares the Location spine.
|
||||
Harvest logs carry structured `amount`+`unit` (legacy freeform `quantity` kept
|
||||
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
|
||||
tree, and `Task`s can link to an animal (`Task.animalId`).
|
||||
- **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/`).
|
||||
- **Suggestions** — `@otm/account-panel` (Gitea npm registry) → files issues to the repo.
|
||||
- **Backup & Restore** — `/api/admin/backup` + `/api/admin/restore` (admin only).
|
||||
- **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
|
||||
|
||||
- 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)
|
||||
|
||||
> 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
|
||||
`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
|
||||
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.
|
||||
**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.
|
||||
- **Soft-delete** via `active=false`.
|
||||
- **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
|
||||
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 ----
|
||||
FROM node:20-bookworm-slim AS build
|
||||
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 &
|
||||
harvest months, pet toxicity), Wikipedia photos, and plants placed on the shared
|
||||
location tree.
|
||||
- **Inventory** — durable **items** that nest (boxes within rooms within
|
||||
buildings), with labels, attachments, and logs. Shares the same location spine
|
||||
as the garden.
|
||||
- **Animals** — pets & livestock with their own logs, placed on the shared
|
||||
location tree; reminders can be linked to an animal.
|
||||
- **Inventory** — durable **items** that nest (a toolbox holds the tools inside
|
||||
it), with labels, photos + manuals/receipts/warranty docs, value & warranty
|
||||
tracking (with expiry warnings), serial/brand, a sold record, and custom fields
|
||||
(e.g. a vehicle's VIN). Schedule recurring **maintenance** per item. **Import
|
||||
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),
|
||||
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
|
||||
permissions (read plants, log harvests, …) for phone shortcuts, Home Assistant,
|
||||
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
|
||||
(`NEXTAUTH_SECRET`, `GITEA_TOKEN`, `UPDATER_SECRET`) live in a gitignored host
|
||||
`.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
|
||||
|
||||
|
||||
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",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@otm/account-panel": "0.8.2",
|
||||
"@otm/account-panel": "0.22.1",
|
||||
"@paralleldrive/cuid2": "^3.3.0",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.17",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
@@ -1025,13 +1026,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@otm/account-panel": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "http://192.168.0.5:3022/api/packages/tonym/npm/%40otm%2Faccount-panel/-/0.8.2/account-panel-0.8.2.tgz",
|
||||
"integrity": "sha512-E7uJ3Xxm8/xz6gcUAS4TUI+eCjaVZLFs7CjF7RpwSLUn3DpIszGSE4BN0519BQOThpQ69p3m15h/b64HLMgHgw==",
|
||||
"version": "0.22.1",
|
||||
"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-JwPVqe5FPENt6nlwlZ91PX58Y2QvFINHLi385zH/f4tO5PvifDYVVqwmzF0TrQP9NaUwOiHJV2PgRdotlN3c7A==",
|
||||
"peerDependencies": {
|
||||
"@stripe/react-stripe-js": ">=2",
|
||||
"@stripe/stripe-js": ">=3",
|
||||
"next": ">=14",
|
||||
"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": {
|
||||
@@ -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": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz",
|
||||
|
||||
@@ -23,13 +23,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@otm/account-panel": "0.8.2",
|
||||
"@otm/account-panel": "0.22.1",
|
||||
"@paralleldrive/cuid2": "^3.3.0",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.17",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^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])
|
||||
}
|
||||
|
||||
// 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,
|
||||
// storage bins). The spine for Garden, Inventory, and Pantry. Plants/items/
|
||||
@@ -102,6 +116,7 @@ model Location {
|
||||
plants Plant[]
|
||||
items Item[]
|
||||
animals Animal[]
|
||||
tasks Task[]
|
||||
|
||||
@@index([parentId])
|
||||
@@index([kind])
|
||||
@@ -121,6 +136,7 @@ enum PlantCategory {
|
||||
GROUNDCOVER // strawberries, clover, creeping thyme
|
||||
VINE // grapes, hops, kiwi
|
||||
ANNUAL_VEGETABLE // tomatoes, squash, beans, peppers — seasonal veg
|
||||
ROOT_VEGETABLE // carrots, beets, sweet potatoes, radishes
|
||||
ANNUAL // legacy, treated as annual vegetable
|
||||
ANNUAL_FLOWER // zinnias, marigolds, nasturtiums, cosmos
|
||||
PERENNIAL // asparagus, rhubarb, perennial veg
|
||||
@@ -164,6 +180,7 @@ model Plant {
|
||||
updatedAt DateTime @updatedAt
|
||||
logs PlantLog[]
|
||||
tasks Task[]
|
||||
pantryItems Item[]
|
||||
|
||||
@@index([category])
|
||||
@@index([zone])
|
||||
@@ -193,6 +210,8 @@ model PlantType {
|
||||
edibleParts String? // "Fruit, young leaves"
|
||||
spacing String? // "10–15 ft", "18 in"
|
||||
imageUrl String?
|
||||
uwExtensionUrl String? // linked UW–Madison Extension article (hort.extension.wisc.edu)
|
||||
uwExtensionTitle String? // its title, shown as the link text
|
||||
notes String?
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
@@ -242,6 +261,8 @@ model Task {
|
||||
item Item? @relation(fields: [itemId], references: [id], onDelete: SetNull)
|
||||
animalId String?
|
||||
animal Animal? @relation(fields: [animalId], references: [id], onDelete: SetNull)
|
||||
locationId String?
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||||
recurrence RecurrenceType @default(YEARLY)
|
||||
intervalDays Int? // for CUSTOM: every N days
|
||||
month Int? // 1–12: for YEARLY, which month
|
||||
@@ -256,6 +277,7 @@ model Task {
|
||||
@@index([plantId])
|
||||
@@index([itemId])
|
||||
@@index([animalId])
|
||||
@@index([locationId])
|
||||
@@index([active])
|
||||
}
|
||||
|
||||
@@ -277,7 +299,9 @@ model PlantLog {
|
||||
type PlantLogType
|
||||
date DateTime @default(now())
|
||||
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?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -295,7 +319,16 @@ model PlantLog {
|
||||
|
||||
enum ItemKind {
|
||||
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 {
|
||||
@@ -314,17 +347,38 @@ model Item {
|
||||
containedItems Item[] @relation("ItemNesting")
|
||||
|
||||
// Durable attributes
|
||||
value Decimal? @db.Decimal(10, 2)
|
||||
value Decimal? @db.Decimal(10, 2) // purchase price
|
||||
purchaseDate DateTime?
|
||||
purchaseFrom String? // where it was bought
|
||||
warrantyExpiry DateTime?
|
||||
warrantyDetails String? // freeform warranty terms
|
||||
lifetimeWarranty Boolean @default(false)
|
||||
insured Boolean @default(false)
|
||||
serial String?
|
||||
modelNumber String?
|
||||
brand String?
|
||||
brand String? // brand / manufacturer
|
||||
|
||||
// Consumable attributes (Pantry phase)
|
||||
// Sold lifecycle (HomeBox parity)
|
||||
soldTo String?
|
||||
soldPrice 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?
|
||||
bestBefore DateTime?
|
||||
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
|
||||
qrSlug String? @unique // short slug for a printed QR label
|
||||
@@ -345,6 +399,8 @@ model Item {
|
||||
@@index([active])
|
||||
@@index([name])
|
||||
@@index([barcode])
|
||||
@@index([plantId])
|
||||
@@index([pantryCategory])
|
||||
}
|
||||
|
||||
model Label {
|
||||
@@ -383,6 +439,7 @@ enum ItemLogType {
|
||||
PURCHASED
|
||||
REPAIRED
|
||||
DISPOSED
|
||||
USED // consumable pulled from the pantry; `amount` holds how much
|
||||
}
|
||||
|
||||
model ItemLog {
|
||||
@@ -393,6 +450,8 @@ model ItemLog {
|
||||
date DateTime @default(now())
|
||||
notes String?
|
||||
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?
|
||||
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 Link from "next/link";
|
||||
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";
|
||||
|
||||
export const metadata = { title: "Dashboard" };
|
||||
@@ -10,13 +10,21 @@ export const metadata = { title: "Dashboard" };
|
||||
export default async function DashboardPage() {
|
||||
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.plantLog.findMany({
|
||||
orderBy: { date: "desc" },
|
||||
take: 5,
|
||||
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";
|
||||
@@ -42,6 +50,34 @@ export default async function DashboardPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
|
||||
{recentLogs.length > 0 && (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { db } from "@/lib/db";
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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 { CATEGORY_LABELS, LOG_TYPE_LABELS, LOG_TYPE_COLORS } from "@/lib/garden/categories";
|
||||
import { AddLogButton } from "@/components/garden/add-log-button";
|
||||
@@ -22,7 +22,7 @@ export default async function PlantDetailPage({ params }: { params: { id: string
|
||||
include: {
|
||||
logs: { orderBy: { date: "desc" } },
|
||||
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" } }),
|
||||
@@ -78,6 +78,19 @@ export default async function PlantDetailPage({ params }: { params: { id: string
|
||||
</Link>
|
||||
</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 && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<CalendarDays className="h-4 w-4 shrink-0" />
|
||||
@@ -99,7 +112,7 @@ export default async function PlantDetailPage({ params }: { params: { id: string
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<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>
|
||||
|
||||
{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]}`}>
|
||||
{LOG_TYPE_LABELS[log.type]}
|
||||
</span>
|
||||
{log.quantity && (
|
||||
<span className="text-xs text-muted-foreground">· {log.quantity}</span>
|
||||
{(log.amount != null || log.quantity) && (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { AddPlantButton } from "@/components/garden/add-plant-button";
|
||||
import { GardenGrid } from "@/components/garden/garden-grid";
|
||||
import { GardenJournal } from "@/components/garden/garden-journal";
|
||||
|
||||
export const metadata = { title: "Garden" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
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({
|
||||
where: { active: true },
|
||||
orderBy: [{ commonName: "asc" }],
|
||||
@@ -26,10 +29,25 @@ export default async function GardenPage() {
|
||||
orderBy: { name: "asc" },
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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} />
|
||||
</div>
|
||||
|
||||
<GardenGrid plants={plants} groups={groups} />
|
||||
<GardenGrid plants={plants} groups={groups} locations={locations} />
|
||||
|
||||
<GardenJournal entries={journal} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
ChevronLeft, Sun, Droplets, Flower2, Apple, AlertTriangle, Ruler, MapPin, Leaf,
|
||||
GraduationCap, ExternalLink, Sprout, ThumbsUp, ThumbsDown, Repeat,
|
||||
} from "lucide-react";
|
||||
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 { 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" };
|
||||
}
|
||||
|
||||
// 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 } }) {
|
||||
const type = await db.plantType.findUnique({
|
||||
where: { id: params.id },
|
||||
@@ -56,6 +129,8 @@ export default async function PlantTypeDetailPage({ params }: { params: { id: st
|
||||
toxicToPets: type.toxicToPets,
|
||||
edibleParts: type.edibleParts,
|
||||
spacing: type.spacing,
|
||||
uwExtensionUrl: type.uwExtensionUrl,
|
||||
uwExtensionTitle: type.uwExtensionTitle,
|
||||
notes: type.notes,
|
||||
};
|
||||
|
||||
@@ -113,6 +188,24 @@ export default async function PlantTypeDetailPage({ params }: { params: { id: st
|
||||
</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) && (
|
||||
<Card>
|
||||
<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 {
|
||||
ChevronLeft, MapPin, Box, Package, CalendarDays, DollarSign, Hash, Tag, FileText, Barcode,
|
||||
Store, ShieldCheck, Wrench, AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { warrantyStatus, WARRANTY_LABELS } from "@/lib/inventory/value";
|
||||
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 { DeleteItemButton } from "@/components/inventory/delete-item-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";
|
||||
|
||||
function toDateInput(d: Date | null): string {
|
||||
@@ -34,6 +37,7 @@ export default async function ItemDetailPage({ params }: { params: { id: string
|
||||
attachments: { orderBy: { createdAt: "asc" } },
|
||||
logs: { orderBy: { date: "desc" } },
|
||||
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 } }),
|
||||
@@ -43,7 +47,8 @@ export default async function ItemDetailPage({ params }: { params: { id: string
|
||||
|
||||
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 docs = item.attachments.filter((a) => a.kind !== "PHOTO");
|
||||
|
||||
@@ -60,7 +65,16 @@ export default async function ItemDetailPage({ params }: { params: { id: string
|
||||
serial: item.serial ?? "",
|
||||
value: item.value != null ? String(Number(item.value)) : "",
|
||||
purchaseDate: toDateInput(item.purchaseDate),
|
||||
purchaseFrom: item.purchaseFrom ?? "",
|
||||
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 ?? "",
|
||||
notes: item.notes ?? "",
|
||||
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 (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.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.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 });
|
||||
@@ -87,14 +104,15 @@ export default async function ItemDetailPage({ params }: { params: { id: string
|
||||
<h1 className="font-display text-2xl font-semibold">{item.name}</h1>
|
||||
{item.description && <p className="text-sm text-muted-foreground">{item.description}</p>}
|
||||
</div>
|
||||
{wStatus !== "none" && (
|
||||
<Badge variant="outline" className="ml-auto shrink-0">{WARRANTY_LABELS[wStatus]}</Badge>
|
||||
{(item.lifetimeWarranty || wStatus !== "none") && (
|
||||
<Badge variant="outline" className="ml-auto shrink-0">{warrantyLabel}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EditItemButton item={editFields} locations={locations} items={allItems.filter((i) => i.id !== item.id)} labels={labels} />
|
||||
<AddItemLogButton itemId={item.id} />
|
||||
<ScheduleMaintenanceButton itemId={item.id} itemName={item.name} />
|
||||
<AttachmentUpload itemId={item.id} />
|
||||
<DeleteItemButton id={item.id} name={item.name} />
|
||||
</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>
|
||||
)}
|
||||
|
||||
{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 && (
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="font-display text-sm font-semibold">Documents</h2>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { AddItemButton } from "@/components/inventory/item-dialog";
|
||||
import { ImportButton } from "@/components/inventory/import-button";
|
||||
import { InventoryGrid, type GridItem } from "@/components/inventory/inventory-grid";
|
||||
import { totalValue } from "@/lib/inventory/value";
|
||||
|
||||
@@ -53,8 +54,11 @@ export default async function InventoryPage() {
|
||||
{total > 0 && ` · ${total.toLocaleString(undefined, { style: "currency", currency: "USD" })} total value`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ImportButton />
|
||||
<AddItemButton locations={locations} items={itemOpts} labels={labels} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InventoryGrid items={gridItems} />
|
||||
</div>
|
||||
|
||||
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 { getSession } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { SuggestionLightbulb } from "@otm/account-panel";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { TopNav } from "@/components/layout/topnav";
|
||||
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 }) {
|
||||
const session = await getSession();
|
||||
if (!session) redirect("/login");
|
||||
|
||||
// Force a password change before anything else (provisioned accounts ship with
|
||||
// a known default + mustChangePassword=true). The target page is outside this
|
||||
// layout group, so there's no redirect loop.
|
||||
if (session.user.mustChangePassword) redirect("/change-password");
|
||||
// a known default + mustChangePassword=true). Read the flag from the DB, not the
|
||||
// JWT: the cookie is only refreshed on re-login/update(), so trusting it would
|
||||
// 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 (
|
||||
<div className="flex h-screen flex-col">
|
||||
@@ -22,6 +32,11 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
const session = await getSession();
|
||||
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 />;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,12 @@ export default async function TasksPage() {
|
||||
const tasks = await db.task.findMany({
|
||||
where: { active: true },
|
||||
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));
|
||||
@@ -104,6 +109,9 @@ function Section({ title, icon: Icon, iconClass, children }: {
|
||||
function TaskCard({ task, overdue = false }: {
|
||||
task: Awaited<ReturnType<typeof db.task.findMany>>[0] & {
|
||||
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;
|
||||
}) {
|
||||
@@ -124,10 +132,23 @@ function TaskCard({ task, overdue = false }: {
|
||||
</div>
|
||||
|
||||
{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}` : ""}
|
||||
</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 && (
|
||||
<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(),
|
||||
spacing: z.string().optional(),
|
||||
imageUrl: z.string().optional(),
|
||||
uwExtensionUrl: z.string().optional(),
|
||||
uwExtensionTitle: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
const TEXT_FIELDS = [
|
||||
"species", "careNotes", "sun", "water", "edibleParts", "spacing", "imageUrl", "notes",
|
||||
"species", "careNotes", "sun", "water", "edibleParts", "spacing", "imageUrl",
|
||||
"uwExtensionUrl", "uwExtensionTitle", "notes",
|
||||
] as const;
|
||||
|
||||
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
||||
|
||||
@@ -17,10 +17,12 @@ const createSchema = z.object({
|
||||
bloomEnd: month.optional(),
|
||||
harvestStart: 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(),
|
||||
spacing: z.string().optional(),
|
||||
imageUrl: z.string().optional(),
|
||||
uwExtensionUrl: z.string().optional(),
|
||||
uwExtensionTitle: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -66,6 +68,8 @@ export async function POST(req: Request) {
|
||||
edibleParts: data.edibleParts?.trim() || null,
|
||||
spacing: data.spacing?.trim() || null,
|
||||
imageUrl: data.imageUrl?.trim() || null,
|
||||
uwExtensionUrl: data.uwExtensionUrl?.trim() || null,
|
||||
uwExtensionTitle: data.uwExtensionTitle?.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";
|
||||
|
||||
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
|
||||
// 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 {
|
||||
: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-deep: 120 20% 97%; /* slightly lighter for cards */
|
||||
--soil: 25 35% 18%; /* #3A2B1A — warm dark brown text */
|
||||
@@ -58,6 +63,7 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
--dew: 140 18% 10%;
|
||||
--dew-deep: 140 18% 12%;
|
||||
--soil: 120 18% 88%;
|
||||
|
||||
@@ -74,6 +74,11 @@ function LoginForm() {
|
||||
<Button type="submit" className="w-full" disabled={busy}>
|
||||
{busy ? "Signing in…" : "Sign in"}
|
||||
</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>
|
||||
</CardContent>
|
||||
</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",
|
||||
];
|
||||
|
||||
const UNIT_SUGGESTIONS = ["lbs", "oz", "each", "pints", "quarts", "gallons", "bunches", "heads", "cups"];
|
||||
|
||||
const schema = z.object({
|
||||
type: z.nativeEnum(PlantLogType),
|
||||
date: 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>;
|
||||
|
||||
interface Props {
|
||||
plantId: 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 router = useRouter();
|
||||
const { toast } = useToast();
|
||||
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>({
|
||||
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) {
|
||||
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",
|
||||
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) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast({ title: "Error", description: err.error ?? "Could not save log entry.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
toast({ title: "Log entry saved!" });
|
||||
toast({
|
||||
title: "Log entry saved!",
|
||||
description: isHarvest && toPantry ? "Also added to Pantry & Freezer." : undefined,
|
||||
});
|
||||
setOpen(false);
|
||||
form.reset({ type: "OBSERVATION", date: new Date().toISOString().split("T")[0] });
|
||||
setLogType("OBSERVATION");
|
||||
resetAll();
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
@@ -110,10 +148,67 @@ export function AddLogButton({ plantId, plantName }: Props) {
|
||||
</div>
|
||||
|
||||
{logType === "HARVEST" && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="quantity">How much did you harvest?</Label>
|
||||
<Input id="quantity" placeholder="2 lbs, 1 gallon, a big handful…" {...form.register("quantity")} />
|
||||
<Label>How much did you harvest?</Label>
|
||||
<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">
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"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 { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Leaf, MapPin, CalendarDays, ChevronDown, ChevronRight, Users } from "lucide-react";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { CATEGORY_LABELS } from "@/lib/garden/categories";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Leaf, MapPin, Search, Users } from "lucide-react";
|
||||
import { GroupLogButton } from "@/components/garden/group-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 = {
|
||||
id: string;
|
||||
@@ -27,41 +28,55 @@ type Plant = {
|
||||
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.
|
||||
function placeName(p: Plant): string | null {
|
||||
return p.location?.name ?? p.zone ?? null;
|
||||
}
|
||||
|
||||
type Group = {
|
||||
id: string;
|
||||
name: string;
|
||||
_count: { plants: number };
|
||||
const LAST_VERB: Record<string, string> = {
|
||||
OBSERVATION: "noted",
|
||||
CARE: "tended",
|
||||
HARVEST: "picked",
|
||||
TREATMENT: "treated",
|
||||
TRANSPLANT: "moved",
|
||||
DIED: "died",
|
||||
};
|
||||
|
||||
type ViewMode = "type" | "location";
|
||||
|
||||
// Cluster plants that share the same commonName + variety into one row.
|
||||
function clusterByVariety(plants: Plant[]) {
|
||||
const map = new Map<string, Plant[]>();
|
||||
for (const p of plants) {
|
||||
const key = `${p.commonName}||${p.variety ?? ""}`;
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(p);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
function lastActivity(p: Plant): string | null {
|
||||
const log = p.logs[0];
|
||||
if (!log) return null;
|
||||
const days = Math.floor((Date.now() - new Date(log.date).getTime()) / 86_400_000);
|
||||
const when = days <= 0 ? "today" : days === 1 ? "yesterday" : `${days}d ago`;
|
||||
return `${LAST_VERB[log.type] ?? "logged"} ${when}`;
|
||||
}
|
||||
|
||||
export function GardenGrid({ plants, groups }: { plants: Plant[]; groups: Group[] }) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [view, setView] = useState<ViewMode>("type");
|
||||
export function GardenGrid({ plants, groups, locations }: {
|
||||
plants: Plant[];
|
||||
groups: Group[];
|
||||
locations: LocationOption[];
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [chip, setChip] = useState<string>("all"); // "all" | "place" | group id
|
||||
|
||||
function toggle(id: string) {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
let list = plants;
|
||||
if (chip !== "all" && chip !== "place") list = list.filter((p) => p.group?.id === chip);
|
||||
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) {
|
||||
return (
|
||||
@@ -74,137 +89,78 @@ export function GardenGrid({ plants, groups }: { plants: Plant[]; groups: Group[
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-1 p-1 bg-muted rounded-lg w-fit">
|
||||
<Button size="sm" variant={view === "type" ? "secondary" : "ghost"} className="h-7 text-xs" onClick={() => setView("type")}>
|
||||
By type
|
||||
</Button>
|
||||
<Button size="sm" variant={view === "location" ? "secondary" : "ghost"} className="h-7 text-xs" onClick={() => setView("location")}>
|
||||
By location
|
||||
</Button>
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="h-4 w-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Find a plant…"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{view === "type" ? (
|
||||
<TypeView plants={plants} groups={groups} expanded={expanded} toggle={toggle} />
|
||||
) : (
|
||||
<LocationView plants={plants} expanded={expanded} toggle={toggle} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeView({ plants, groups, expanded, toggle }: {
|
||||
plants: Plant[];
|
||||
groups: Group[];
|
||||
expanded: Set<string>;
|
||||
toggle: (id: string) => void;
|
||||
}) {
|
||||
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 className="flex items-center gap-1.5 flex-wrap">
|
||||
<Chip active={chip === "all"} onClick={() => setChip("all")}>
|
||||
All · {plants.length}
|
||||
</Chip>
|
||||
{groups.filter((g) => g._count.plants > 0).map((g) => (
|
||||
<Chip key={g.id} active={chip === g.id} onClick={() => setChip(g.id)}>
|
||||
{g.name}
|
||||
</Chip>
|
||||
))}
|
||||
</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>}
|
||||
<Chip active={chip === "place"} onClick={() => setChip("place")}>
|
||||
<MapPin className="h-3 w-3" /> By place
|
||||
</Chip>
|
||||
{activeGroup && (
|
||||
<span className="ml-auto">
|
||||
<GroupLogButton groupId={activeGroup.id} groupName={activeGroup.name} />
|
||||
</span>
|
||||
{!isOpen && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{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 && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pl-6">
|
||||
{plants.map((p) => <PlantCard key={p.id} plant={p} />)}
|
||||
</div>
|
||||
{chip === "place" ? (
|
||||
<PlaceSections plants={filtered} locations={locations} />
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function LocationView({ plants, expanded, toggle }: {
|
||||
plants: Plant[];
|
||||
expanded: Set<string>;
|
||||
toggle: (id: string) => void;
|
||||
function Chip({ active, onClick, children }: {
|
||||
active: boolean; onClick: () => void; children: React.ReactNode;
|
||||
}) {
|
||||
// Group by the shared Location (id). Plants not yet on the spine but with a
|
||||
// legacy zone string still group by that name so nothing disappears mid-migration.
|
||||
return (
|
||||
<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[] };
|
||||
const byKey = new Map<string, Place>();
|
||||
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: [] });
|
||||
byKey.get(key)!.plants.push(p);
|
||||
}
|
||||
|
||||
const places = Array.from(byKey.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{places.map((place) => {
|
||||
const isOpen = expanded.has(place.key);
|
||||
return (
|
||||
{places.map((place) => (
|
||||
<div key={place.key} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => toggle(place.key)}
|
||||
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" />}
|
||||
<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>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
· {place.plants.length} {place.plants.length === 1 ? "plant" : "plants"}
|
||||
</span>
|
||||
{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} />)}
|
||||
<CardGrid plants={place.plants} locations={locations} />
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
|
||||
))}
|
||||
{noPlace.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{places.length > 0 && <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">
|
||||
{noPlace.map((p) => <PlantCard key={p.id} plant={p} />)}
|
||||
</div>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">No location set</p>
|
||||
<CardGrid plants={noPlace} locations={locations} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlantCard({ plant }: { plant: Plant }) {
|
||||
function PlantCard({ plant, locations }: { plant: Plant; locations: LocationOption[] }) {
|
||||
const place = placeName(plant);
|
||||
const activity = lastActivity(plant);
|
||||
|
||||
return (
|
||||
<Link href={`/garden/${plant.id}`}>
|
||||
<Card className="hover:shadow-md transition-shadow h-full cursor-pointer overflow-hidden">
|
||||
{plant.imageUrl && (
|
||||
<div className="h-36 w-full overflow-hidden bg-muted">
|
||||
<div className="rounded-lg border bg-card overflow-hidden hover:shadow-md transition-shadow flex flex-col">
|
||||
<Link href={`/garden/${plant.id}`} className="block group">
|
||||
{plant.imageUrl ? (
|
||||
<div className="h-28 w-full overflow-hidden bg-muted">
|
||||
<img
|
||||
src={plant.imageUrl}
|
||||
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>
|
||||
)}
|
||||
{!plant.imageUrl && (
|
||||
<div className="h-36 w-full bg-[hsl(var(--leaf)/.08)] flex items-center justify-center">
|
||||
<Leaf className="h-10 w-10 text-[hsl(var(--leaf)/.3)]" />
|
||||
) : (
|
||||
<div className="h-28 w-full bg-[hsl(var(--leaf)/.08)] flex items-center justify-center">
|
||||
<Leaf className="h-9 w-9 text-[hsl(var(--leaf)/.3)]" />
|
||||
</div>
|
||||
)}
|
||||
<CardContent className="pt-3 pb-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">
|
||||
</Link>
|
||||
<div className="p-2.5 pt-2 flex-1 flex flex-col gap-1.5">
|
||||
<Link href={`/garden/${plant.id}`} className="min-w-0">
|
||||
<p className="font-medium text-sm truncate leading-tight">
|
||||
{plant.commonName}
|
||||
{plant.variety && <span className="text-muted-foreground font-normal"> · {plant.variety}</span>}
|
||||
</p>
|
||||
{plant.species && <p className="text-xs text-muted-foreground italic truncate">{plant.species}</p>}
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||
{CATEGORY_LABELS[plant.category as keyof typeof CATEGORY_LABELS]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{placeName(plant) && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<MapPin className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{placeName(plant)}</span>
|
||||
</div>
|
||||
)}
|
||||
{plant.plantedAt && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CalendarDays className="h-3 w-3 shrink-0" />
|
||||
<span>Planted {formatDate(plant.plantedAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
{plant.logs[0] && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{place && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3 shrink-0" />
|
||||
{place}
|
||||
</span>
|
||||
)}
|
||||
{place && activity && " · "}
|
||||
{activity}
|
||||
</p>
|
||||
{plant.group && (
|
||||
<Badge variant="outline" className="w-fit text-[10px] gap-1 py-0">
|
||||
<Users className="h-2.5 w-2.5" />
|
||||
{plant.group.name}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="mt-auto pt-1">
|
||||
<QuickLogActions plantId={plant.id} plantName={plant.commonName} locations={locations} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
} from "@/components/ui/select";
|
||||
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";
|
||||
|
||||
type UwMatch = { id: number; title: string; url: string; excerpt: string };
|
||||
|
||||
export type PlantTypeFields = {
|
||||
id?: string;
|
||||
commonName: string;
|
||||
@@ -32,6 +34,8 @@ export type PlantTypeFields = {
|
||||
toxicToPets: boolean | null;
|
||||
edibleParts: string | null;
|
||||
spacing: string | null;
|
||||
uwExtensionUrl: string | null;
|
||||
uwExtensionTitle: string | null;
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
@@ -43,7 +47,8 @@ function blank(): PlantTypeFields {
|
||||
commonName: "", species: null, category: "OTHER", careNotes: null,
|
||||
sun: null, water: null, bloomStart: null, bloomEnd: 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 [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [uwMatches, setUwMatches] = useState<UwMatch[] | null>(null);
|
||||
const [uwBusy, setUwBusy] = useState(false);
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -80,6 +87,28 @@ function PlantTypeDialog({
|
||||
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) {
|
||||
e.preventDefault();
|
||||
if (!f.commonName.trim()) { setError("Please enter the plant name."); return; }
|
||||
@@ -95,6 +124,8 @@ function PlantTypeDialog({
|
||||
water: f.water ?? "",
|
||||
edibleParts: f.edibleParts ?? "",
|
||||
spacing: f.spacing ?? "",
|
||||
uwExtensionUrl: f.uwExtensionUrl ?? "",
|
||||
uwExtensionTitle: f.uwExtensionTitle ?? "",
|
||||
notes: f.notes ?? "",
|
||||
toxicToPets: f.toxicToPets,
|
||||
};
|
||||
@@ -217,6 +248,56 @@ function PlantTypeDialog({
|
||||
placeholder="Fruit, young leaves" />
|
||||
</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">
|
||||
<Label>Care notes</Label>
|
||||
<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 { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
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 }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [kind, setKind] = useState("PHOTO");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const router = useRouter();
|
||||
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) {
|
||||
setBusy(true);
|
||||
const form = new FormData();
|
||||
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 });
|
||||
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" });
|
||||
}
|
||||
|
||||
const accept = KINDS.find((k) => k.value === kind)?.accept ?? "*";
|
||||
|
||||
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 = ""; }} />
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => inputRef.current?.click()}>
|
||||
<ImagePlus className="h-4 w-4 mr-1" /> {busy ? "Uploading…" : "Add photo"}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<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";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Package, MapPin, ChevronDown, ChevronRight, Box, ShieldCheck, ShieldAlert, ShieldX,
|
||||
Search, Tag,
|
||||
} from "lucide-react";
|
||||
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>;
|
||||
}
|
||||
|
||||
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 }: {
|
||||
item: GridItem;
|
||||
childrenByParent: Map<string, GridItem[]>;
|
||||
@@ -55,14 +81,7 @@ function ItemRow({ item, childrenByParent, depth }: {
|
||||
</button>
|
||||
) : <span className="w-4 shrink-0" />}
|
||||
|
||||
{item.imageUrl ? (
|
||||
// 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>
|
||||
)}
|
||||
<ItemThumb item={item} hasKids={kids.length > 0} />
|
||||
|
||||
<Link href={`/inventory/${item.id}`} className="min-w-0 flex-1">
|
||||
<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>}
|
||||
</Link>
|
||||
|
||||
<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>
|
||||
<ItemBadges item={item} />
|
||||
</div>
|
||||
{open && kids.map((k) => (
|
||||
<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[] }) {
|
||||
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) {
|
||||
return (
|
||||
<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[]>();
|
||||
for (const it of items) {
|
||||
if (it.parentItemId) {
|
||||
@@ -105,8 +261,11 @@ export function InventoryGrid({ items }: { items: GridItem[] }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Top-level items (not inside another item) grouped by location.
|
||||
const topLevel = items.filter((i) => !i.parentItemId);
|
||||
// Top-level = items with no parent, plus "orphans" whose parent isn't in the
|
||||
// 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 noLoc: GridItem[] = [];
|
||||
for (const it of topLevel) {
|
||||
@@ -115,30 +274,91 @@ export function InventoryGrid({ items }: { items: GridItem[] }) {
|
||||
g.items.push(it);
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{sorted.map((g) => (
|
||||
<div key={g.name} className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<MapPin className="h-4 w-4 text-[hsl(var(--leaf))]" />
|
||||
<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">
|
||||
{sorted.map(([id, g]) => (
|
||||
<Group key={id} icon={MapPin} name={g.name} count={g.items.length}>
|
||||
{g.items.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
|
||||
</div>
|
||||
</div>
|
||||
</Group>
|
||||
))}
|
||||
|
||||
{noLoc.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{sorted.length > 0 && <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">No location</p>}
|
||||
{noLoc.length > 0 &&
|
||||
(sorted.length > 0 ? (
|
||||
<Group icon={MapPin} name="No location" count={noLoc.length}>
|
||||
{noLoc.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
|
||||
</Group>
|
||||
) : (
|
||||
<div className="divide-y border rounded-lg overflow-hidden">
|
||||
{noLoc.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -33,7 +33,16 @@ export type ItemFields = {
|
||||
serial: string;
|
||||
value: string;
|
||||
purchaseDate: string;
|
||||
purchaseFrom: string;
|
||||
warrantyExpiry: string;
|
||||
warrantyDetails: string;
|
||||
lifetimeWarranty: boolean;
|
||||
insured: boolean;
|
||||
soldTo: string;
|
||||
soldPrice: string;
|
||||
soldDate: string;
|
||||
soldNotes: string;
|
||||
customFields: { key: string; value: string }[];
|
||||
barcode: string;
|
||||
notes: string;
|
||||
labelIds: string[];
|
||||
@@ -42,7 +51,9 @@ export type ItemFields = {
|
||||
export function blankItem(): ItemFields {
|
||||
return {
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
@@ -109,7 +120,18 @@ function ItemDialog({
|
||||
serial: f.serial.trim() || undefined,
|
||||
value: f.value ? Number(f.value) : null,
|
||||
purchaseDate: f.purchaseDate || null,
|
||||
purchaseFrom: f.purchaseFrom.trim() || undefined,
|
||||
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,
|
||||
notes: f.notes.trim() || undefined,
|
||||
labelIds: f.labelIds,
|
||||
@@ -197,7 +219,26 @@ function ItemDialog({
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<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 className="col-span-2 space-y-1.5">
|
||||
@@ -219,6 +260,31 @@ function ItemDialog({
|
||||
</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">
|
||||
<Label>Notes</Label>
|
||||
<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 { 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";
|
||||
|
||||
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 },
|
||||
];
|
||||
import { navItems, kitchenItems, settingsItems, isNavActive } from "./nav-items";
|
||||
|
||||
export function Sidebar() {
|
||||
const path = usePathname();
|
||||
@@ -38,14 +20,7 @@ export function Sidebar() {
|
||||
|
||||
<nav className="flex-1 px-2 py-3 space-y-0.5">
|
||||
{navItems.map(({ label, href, icon: Icon }) => {
|
||||
// Active on exact match or a sub-path, unless a more specific nav item
|
||||
// (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);
|
||||
const active = isNavActive(path, href, navItems);
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
@@ -64,6 +39,24 @@ export function Sidebar() {
|
||||
})}
|
||||
</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">
|
||||
{settingsItems.map(({ label, href, icon: Icon }) => {
|
||||
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";
|
||||
|
||||
import Link from "next/link";
|
||||
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 {
|
||||
DropdownMenu,
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { MobileNav } from "@/components/layout/mobile-nav";
|
||||
import { ThemeToggle } from "@/components/layout/theme-toggle";
|
||||
import { initials } from "@/lib/utils";
|
||||
|
||||
interface TopNavProps {
|
||||
@@ -19,11 +22,22 @@ interface TopNavProps {
|
||||
export function TopNav({ user }: TopNavProps) {
|
||||
return (
|
||||
<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">
|
||||
<span className="font-display font-semibold">Moon Base</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<MobileNav />
|
||||
<span className="font-display font-semibold md:hidden">Moon Base</span>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block" />
|
||||
<div className="flex items-center gap-1">
|
||||
<ThemeToggle />
|
||||
|
||||
<Link
|
||||
href="/suggestions"
|
||||
aria-label="Suggestions & feedback"
|
||||
title="Suggestions & feedback"
|
||||
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
<Lightbulb className="h-5 w-5" />
|
||||
</Link>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -35,16 +49,21 @@ export function TopNav({ user }: TopNavProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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 sessionContext();
|
||||
}
|
||||
|
||||
/** AuthContext for server components (cookie session only, full scope). */
|
||||
export async function sessionContext(): Promise<AuthContext> {
|
||||
const session = await getSession();
|
||||
if (!session) throw new ApiError(401, "Unauthorized");
|
||||
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
|
||||
// validation lives in exactly one place.
|
||||
import { z } from "zod";
|
||||
import { PANTRY_USE_REASON_VALUES } from "@/lib/pantry/core";
|
||||
|
||||
export const plantLogInput = z.object({
|
||||
type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]),
|
||||
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
|
||||
// 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({
|
||||
@@ -14,6 +24,9 @@ export const taskCreateInput = z.object({
|
||||
notes: z.string().optional(),
|
||||
category: z.enum(["GARDEN", "HOME", "EQUIPMENT", "OTHER"]).default("GARDEN"),
|
||||
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"),
|
||||
intervalDays: z.number().int().positive().optional(),
|
||||
month: z.number().int().min(1).max(12).optional(),
|
||||
@@ -34,10 +47,19 @@ export const itemCreateInput = z.object({
|
||||
parentItemId: z.string().nullable().optional(),
|
||||
value: z.coerce.number().nonnegative().nullable().optional(),
|
||||
purchaseDate: z.string().nullable().optional(),
|
||||
purchaseFrom: z.string().optional(),
|
||||
warrantyExpiry: z.string().nullable().optional(),
|
||||
warrantyDetails: z.string().optional(),
|
||||
lifetimeWarranty: z.boolean().optional(),
|
||||
insured: z.boolean().optional(),
|
||||
serial: z.string().optional(),
|
||||
modelNumber: 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(),
|
||||
notes: z.string().optional(),
|
||||
imageUrl: z.string().nullable().optional(),
|
||||
@@ -47,12 +69,53 @@ export const itemCreateInput = z.object({
|
||||
export const itemUpdateInput = itemCreateInput.partial();
|
||||
|
||||
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(),
|
||||
cost: z.coerce.number().nonnegative().nullable().optional(),
|
||||
amount: z.coerce.number().nonnegative().nullable().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 TaskCreateInput = z.infer<typeof taskCreateInput>;
|
||||
export type TaskCompleteInput = z.infer<typeof taskCompleteInput>;
|
||||
|
||||
@@ -8,6 +8,182 @@ export type 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",
|
||||
date: "2026-06-16",
|
||||
|
||||
@@ -9,6 +9,7 @@ export const CATEGORY_LABELS: Record<PlantCategory, string> = {
|
||||
GROUNDCOVER: "Groundcover",
|
||||
VINE: "Vine",
|
||||
ANNUAL_VEGETABLE: "Annual vegetable",
|
||||
ROOT_VEGETABLE: "Root vegetable",
|
||||
ANNUAL: "Annual vegetable",
|
||||
ANNUAL_FLOWER: "Annual flower",
|
||||
PERENNIAL: "Perennial",
|
||||
@@ -29,6 +30,7 @@ export const CATEGORY_ORDER: PlantCategory[] = [
|
||||
"PERENNIAL",
|
||||
"PERENNIAL_FLOWER",
|
||||
"ANNUAL_VEGETABLE",
|
||||
"ROOT_VEGETABLE",
|
||||
"ANNUAL_FLOWER",
|
||||
"BULB",
|
||||
"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",
|
||||
REPAIRED: "Repaired",
|
||||
DISPOSED: "Disposed",
|
||||
USED: "Used",
|
||||
};
|
||||
|
||||
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",
|
||||
REPAIRED: "text-violet-600 dark:text-violet-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[] = [
|
||||
"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,
|
||||
value: r.value ?? null,
|
||||
purchaseDate: toDate(r.purchaseDate),
|
||||
purchaseFrom: r.purchaseFrom?.trim() || null,
|
||||
warrantyExpiry: toDate(r.warrantyExpiry),
|
||||
warrantyDetails: r.warrantyDetails?.trim() || null,
|
||||
lifetimeWarranty: r.lifetimeWarranty ?? false,
|
||||
insured: r.insured ?? false,
|
||||
serial: r.serial?.trim() || null,
|
||||
modelNumber: r.modelNumber?.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,
|
||||
notes: r.notes?.trim() || 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.value !== undefined) data.value = r.value;
|
||||
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.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.modelNumber !== undefined) data.modelNumber = r.modelNumber?.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.notes !== undefined) data.notes = r.notes?.trim() || 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) {
|
||||
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");
|
||||
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({
|
||||
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(),
|
||||
notes: input.notes?.trim() || null,
|
||||
cost: input.cost ?? null,
|
||||
amount: input.amount ?? null,
|
||||
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.
|
||||
import { db } from "@/lib/db";
|
||||
import { ApiError, type AuthContext } from "@/lib/api/authenticate";
|
||||
import { createPantryItem } from "@/lib/services/pantry";
|
||||
import type { PlantLogInput } from "@/lib/api/schemas";
|
||||
|
||||
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) {
|
||||
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 (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({
|
||||
data: {
|
||||
plantId,
|
||||
type: input.type,
|
||||
date: input.date ? new Date(input.date) : new Date(),
|
||||
date,
|
||||
notes: input.notes?.trim() || null,
|
||||
quantity: input.quantity?.trim() || null,
|
||||
amount: input.amount ?? null,
|
||||
unit: input.unit?.trim() || null,
|
||||
actorId: ctx.userId,
|
||||
},
|
||||
});
|
||||
@@ -53,5 +62,23 @@ export async function addPlantLog(ctx: AuthContext, plantId: string, input: Plan
|
||||
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({
|
||||
where: { active: true },
|
||||
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,
|
||||
category: input.category,
|
||||
plantId: input.plantId || null,
|
||||
itemId: input.itemId || null,
|
||||
animalId: input.animalId || null,
|
||||
locationId: input.locationId || null,
|
||||
recurrence: input.recurrence,
|
||||
intervalDays: input.intervalDays ?? 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",
|
||||
"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`.
|
||||
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