Compare commits
34 Commits
777569a21a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d7b119c1a | |||
| 862f5627c7 | |||
| 083e2992c9 | |||
| 2ad9ac53f8 | |||
| f351ed3846 | |||
|
|
a50f7eaa49 | ||
|
|
fe24cd73be | ||
|
|
5319e8af2f | ||
|
|
cc212c87c7 | ||
|
|
e65d28524b | ||
|
|
25b1ba9aeb | ||
| 8f77b7c453 | |||
|
|
dd7af9e8d4 | ||
|
|
6b75e47ee9 | ||
|
|
d3af826e3a | ||
|
|
70df86ba59 | ||
|
|
67bc23c64e | ||
|
|
3b35e835c8 | ||
|
|
61dc8acacf | ||
|
|
c449b0c2ae | ||
|
|
e501679416 | ||
|
|
608a192877 | ||
|
|
62a0089ec2 | ||
|
|
0d52ea5ad1 | ||
|
|
f483630419 | ||
|
|
8ec43f3561 | ||
| ddc0a60a0c | |||
| 4aacffdf4e | |||
| b8af0f986b | |||
| 80324afea2 | |||
| 5f4c482636 | |||
| 63569aabd3 | |||
| cffa739056 | |||
| 39be24c2fc |
2
.npmrc
2
.npmrc
@@ -1 +1 @@
|
||||
@otm:registry=http://192.168.0.5:3022/api/packages/tonym/npm/
|
||||
@otm:registry=https://git.poweredbyotm.com/api/packages/tonym/npm/
|
||||
|
||||
77
CLAUDE.md
77
CLAUDE.md
@@ -9,15 +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.
|
||||
- **Locations** — `Location` tree (self-ref `parentId`) shared by plants + items.
|
||||
- **Reminders** — `Task` + `TaskCompletion`; recurrence in `src/lib/tasks/schedule.ts`.
|
||||
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`
|
||||
(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`.
|
||||
@@ -29,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:
|
||||
|
||||
@@ -55,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
|
||||
|
||||
17
README.md
17
README.md
@@ -12,11 +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.
|
||||
- **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.
|
||||
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.
|
||||
@@ -67,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": "https://git.poweredbyotm.com/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",
|
||||
|
||||
66
prisma/migrations/20260616051637_v0_14_animals/migration.sql
Normal file
66
prisma/migrations/20260616051637_v0_14_animals/migration.sql
Normal file
@@ -0,0 +1,66 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AnimalLogType" AS ENUM ('NOTE', 'FEEDING', 'VET', 'WEIGHT', 'MEDICATION', 'BREEDING', 'ACQUIRED', 'DIED');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Task" ADD COLUMN "animalId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Animal" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"species" TEXT,
|
||||
"breed" TEXT,
|
||||
"sex" TEXT,
|
||||
"locationId" TEXT,
|
||||
"birthdate" TIMESTAMP(3),
|
||||
"acquiredAt" TIMESTAMP(3),
|
||||
"source" TEXT,
|
||||
"notes" TEXT,
|
||||
"imageUrl" TEXT,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Animal_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AnimalLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"animalId" TEXT NOT NULL,
|
||||
"type" "AnimalLogType" NOT NULL,
|
||||
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"notes" TEXT,
|
||||
"weight" DECIMAL(10,2),
|
||||
"actorId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AnimalLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Animal_locationId_idx" ON "Animal"("locationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Animal_species_idx" ON "Animal"("species");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Animal_active_idx" ON "Animal"("active");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AnimalLog_animalId_idx" ON "AnimalLog"("animalId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AnimalLog_date_idx" ON "AnimalLog"("date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Task_animalId_idx" ON "Task"("animalId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Task" ADD CONSTRAINT "Task_animalId_fkey" FOREIGN KEY ("animalId") REFERENCES "Animal"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Animal" ADD CONSTRAINT "Animal_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AnimalLog" ADD CONSTRAINT "AnimalLog_animalId_fkey" FOREIGN KEY ("animalId") REFERENCES "Animal"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -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/
|
||||
@@ -101,6 +115,8 @@ model Location {
|
||||
updatedAt DateTime @updatedAt
|
||||
plants Plant[]
|
||||
items Item[]
|
||||
animals Animal[]
|
||||
tasks Task[]
|
||||
|
||||
@@index([parentId])
|
||||
@@index([kind])
|
||||
@@ -120,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
|
||||
@@ -163,6 +180,7 @@ model Plant {
|
||||
updatedAt DateTime @updatedAt
|
||||
logs PlantLog[]
|
||||
tasks Task[]
|
||||
pantryItems Item[]
|
||||
|
||||
@@index([category])
|
||||
@@index([zone])
|
||||
@@ -192,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())
|
||||
@@ -239,6 +259,10 @@ model Task {
|
||||
plant Plant? @relation(fields: [plantId], references: [id])
|
||||
itemId String?
|
||||
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
|
||||
@@ -252,6 +276,8 @@ model Task {
|
||||
@@index([nextDue])
|
||||
@@index([plantId])
|
||||
@@index([itemId])
|
||||
@@index([animalId])
|
||||
@@index([locationId])
|
||||
@@index([active])
|
||||
}
|
||||
|
||||
@@ -273,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())
|
||||
|
||||
@@ -291,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 {
|
||||
@@ -310,17 +347,38 @@ model Item {
|
||||
containedItems Item[] @relation("ItemNesting")
|
||||
|
||||
// Durable attributes
|
||||
value Decimal? @db.Decimal(10, 2)
|
||||
purchaseDate DateTime?
|
||||
warrantyExpiry DateTime?
|
||||
serial String?
|
||||
modelNumber String?
|
||||
brand String?
|
||||
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 / manufacturer
|
||||
|
||||
// Consumable attributes (Pantry phase)
|
||||
barcode String?
|
||||
bestBefore DateTime?
|
||||
minStock Decimal? @db.Decimal(10, 2)
|
||||
// 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?
|
||||
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
|
||||
@@ -341,6 +399,8 @@ model Item {
|
||||
@@index([active])
|
||||
@@index([name])
|
||||
@@index([barcode])
|
||||
@@index([plantId])
|
||||
@@index([pantryCategory])
|
||||
}
|
||||
|
||||
model Label {
|
||||
@@ -379,6 +439,7 @@ enum ItemLogType {
|
||||
PURCHASED
|
||||
REPAIRED
|
||||
DISPOSED
|
||||
USED // consumable pulled from the pantry; `amount` holds how much
|
||||
}
|
||||
|
||||
model ItemLog {
|
||||
@@ -389,9 +450,66 @@ 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())
|
||||
|
||||
@@index([itemId])
|
||||
@@index([date])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Animals & pets — livestock and household pets, leaves in a Location with
|
||||
// their own care log (vet, feeding, weight, meds) and recurring reminders.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum AnimalLogType {
|
||||
NOTE
|
||||
FEEDING
|
||||
VET
|
||||
WEIGHT
|
||||
MEDICATION
|
||||
BREEDING
|
||||
ACQUIRED
|
||||
DIED
|
||||
}
|
||||
|
||||
model Animal {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
species String? // "Dog", "Cat", "Chicken", "Goat"
|
||||
breed String?
|
||||
sex String? // freeform: "Female", "Male", "Wether"
|
||||
locationId String?
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||||
birthdate DateTime?
|
||||
acquiredAt DateTime?
|
||||
source String?
|
||||
notes String?
|
||||
imageUrl String?
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
logs AnimalLog[]
|
||||
tasks Task[]
|
||||
|
||||
@@index([locationId])
|
||||
@@index([species])
|
||||
@@index([active])
|
||||
}
|
||||
|
||||
model AnimalLog {
|
||||
id String @id @default(cuid())
|
||||
animalId String
|
||||
animal Animal @relation(fields: [animalId], references: [id], onDelete: Cascade)
|
||||
type AnimalLogType
|
||||
date DateTime @default(now())
|
||||
notes String?
|
||||
weight Decimal? @db.Decimal(10, 2) // for WEIGHT entries
|
||||
actorId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([animalId])
|
||||
@@index([date])
|
||||
}
|
||||
|
||||
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());
|
||||
131
src/app/(dashboard)/animals/[id]/page.tsx
Normal file
131
src/app/(dashboard)/animals/[id]/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { db } from "@/lib/db";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ChevronLeft, MapPin, CalendarDays, Cake, Package, Scale, PawPrint } from "lucide-react";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { ageString } from "@/lib/animals/age";
|
||||
import { ANIMAL_LOG_LABELS, ANIMAL_LOG_COLORS } from "@/lib/animals/animal-fields";
|
||||
import { EditAnimalButton, type AnimalFields } from "@/components/animals/animal-dialog";
|
||||
import { DeleteAnimalButton } from "@/components/animals/delete-animal-button";
|
||||
import { AddAnimalLogButton } from "@/components/animals/add-animal-log-button";
|
||||
|
||||
function toDateInput(d: Date | null): string {
|
||||
return d ? new Date(d).toISOString().split("T")[0] : "";
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: { id: string } }) {
|
||||
const a = await db.animal.findUnique({ where: { id: params.id }, select: { name: true } });
|
||||
return { title: a?.name ?? "Animal" };
|
||||
}
|
||||
|
||||
export default async function AnimalDetailPage({ params }: { params: { id: string } }) {
|
||||
const [animal, locations] = await Promise.all([
|
||||
db.animal.findUnique({
|
||||
where: { id: params.id },
|
||||
include: { location: { select: { id: true, name: true } }, logs: { orderBy: { date: "desc" } } },
|
||||
}),
|
||||
db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||
]);
|
||||
|
||||
if (!animal || !animal.active) notFound();
|
||||
|
||||
const age = ageString(animal.birthdate, new Date());
|
||||
const latestWeight = animal.logs.find((l) => l.type === "WEIGHT" && l.weight != null);
|
||||
|
||||
const editFields: AnimalFields = {
|
||||
id: animal.id,
|
||||
name: animal.name,
|
||||
species: animal.species ?? "",
|
||||
breed: animal.breed ?? "",
|
||||
sex: animal.sex ?? "",
|
||||
locationId: animal.locationId ?? "",
|
||||
birthdate: toDateInput(animal.birthdate),
|
||||
acquiredAt: toDateInput(animal.acquiredAt),
|
||||
source: animal.source ?? "",
|
||||
notes: animal.notes ?? "",
|
||||
};
|
||||
|
||||
const facts: { icon: React.ReactNode; label: string; value: React.ReactNode }[] = [];
|
||||
if (animal.location) facts.push({ icon: <MapPin className="h-4 w-4" />, label: "Location", value: animal.location.name });
|
||||
if (animal.sex) facts.push({ icon: <PawPrint className="h-4 w-4" />, label: "Sex", value: animal.sex });
|
||||
if (animal.birthdate) facts.push({ icon: <Cake className="h-4 w-4" />, label: "Born", value: `${formatDate(animal.birthdate)}${age ? ` · ${age}` : ""}` });
|
||||
if (animal.acquiredAt) facts.push({ icon: <CalendarDays className="h-4 w-4" />, label: "Acquired", value: formatDate(animal.acquiredAt) });
|
||||
if (animal.source) facts.push({ icon: <Package className="h-4 w-4" />, label: "From", value: animal.source });
|
||||
if (latestWeight?.weight != null) facts.push({ icon: <Scale className="h-4 w-4" />, label: "Latest weight", value: `${Number(latestWeight.weight)} lbs` });
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/animals" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-display text-2xl font-semibold">{animal.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">{[animal.species, animal.breed].filter(Boolean).join(" · ")}</p>
|
||||
</div>
|
||||
{age && <Badge variant="secondary" className="ml-auto shrink-0">{age}</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EditAnimalButton animal={editFields} locations={locations} />
|
||||
<AddAnimalLogButton animalId={animal.id} />
|
||||
<DeleteAnimalButton id={animal.id} name={animal.name} />
|
||||
</div>
|
||||
|
||||
{animal.imageUrl && (
|
||||
<div className="rounded-lg overflow-hidden h-56 bg-muted">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={animal.imageUrl} alt={animal.name} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{facts.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
{facts.map((f, i) => (
|
||||
<div key={i} className="flex items-start gap-2">
|
||||
<span className="text-muted-foreground mt-0.5 shrink-0">{f.icon}</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{f.label}</p>
|
||||
<p className="truncate">{f.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{animal.notes && (
|
||||
<Card><CardContent className="pt-4 text-sm whitespace-pre-wrap">{animal.notes}</CardContent></Card>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h2 className="font-display text-lg font-semibold mb-3">Care log</h2>
|
||||
{animal.logs.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm border rounded-lg">
|
||||
No entries yet — log a feeding, vet visit, or weigh-in above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{animal.logs.map((log) => (
|
||||
<div key={log.id} className="flex items-start gap-3 p-3 rounded-lg border bg-card">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-xs font-semibold uppercase tracking-wide ${ANIMAL_LOG_COLORS[log.type]}`}>
|
||||
{ANIMAL_LOG_LABELS[log.type]}
|
||||
</span>
|
||||
{log.weight != null && <span className="text-xs text-muted-foreground">· {Number(log.weight)} lbs</span>}
|
||||
<span className="text-xs text-muted-foreground ml-auto">{formatDate(log.date)}</span>
|
||||
</div>
|
||||
{log.notes && <p className="text-sm mt-1">{log.notes}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
src/app/(dashboard)/animals/page.tsx
Normal file
92
src/app/(dashboard)/animals/page.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import Link from "next/link";
|
||||
import { db } from "@/lib/db";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PawPrint, MapPin, ChevronRight } from "lucide-react";
|
||||
import { ageString } from "@/lib/animals/age";
|
||||
import { AddAnimalButton } from "@/components/animals/animal-dialog";
|
||||
|
||||
export const metadata = { title: "Animals" };
|
||||
|
||||
const now = new Date();
|
||||
|
||||
export default async function AnimalsPage() {
|
||||
const [animals, locations] = await Promise.all([
|
||||
db.animal.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { name: "asc" },
|
||||
include: { location: { select: { id: true, name: true } }, _count: { select: { logs: true } } },
|
||||
}),
|
||||
db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||
]);
|
||||
|
||||
// Group by species ("Other" bucket last).
|
||||
const groups = new Map<string, typeof animals>();
|
||||
for (const a of animals) {
|
||||
const key = a.species?.trim() || "Other";
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(a);
|
||||
}
|
||||
const sorted = Array.from(groups.entries()).sort((a, b) =>
|
||||
a[0] === "Other" ? 1 : b[0] === "Other" ? -1 : a[0].localeCompare(b[0]),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold">Animals & pets</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{animals.length} {animals.length === 1 ? "animal" : "animals"}
|
||||
</p>
|
||||
</div>
|
||||
<AddAnimalButton locations={locations} />
|
||||
</div>
|
||||
|
||||
{animals.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<PawPrint className="h-12 w-12 mx-auto mb-4 opacity-20" />
|
||||
<p className="font-medium">No animals yet</p>
|
||||
<p className="text-sm mt-1">Add a pet or some livestock to start a care log.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{sorted.map(([species, list]) => (
|
||||
<div key={species} className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{species}</p>
|
||||
<div className="divide-y border rounded-lg overflow-hidden">
|
||||
{list.map((a) => {
|
||||
const age = ageString(a.birthdate, now);
|
||||
return (
|
||||
<Link key={a.id} href={`/animals/${a.id}`}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors">
|
||||
{a.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={a.imageUrl} alt="" className="h-9 w-9 rounded-full object-cover shrink-0" />
|
||||
) : (
|
||||
<span className="h-9 w-9 rounded-full bg-[hsl(var(--leaf)/.1)] flex items-center justify-center shrink-0">
|
||||
<PawPrint className="h-4 w-4 text-[hsl(var(--leaf)/.6)]" />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">{a.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{[a.breed, age].filter(Boolean).join(" · ") || a.species}
|
||||
</p>
|
||||
</div>
|
||||
{a.location?.name && (
|
||||
<Badge variant="outline" className="gap-1 text-xs">
|
||||
<MapPin className="h-2.5 w-2.5" />{a.location.name}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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,7 +54,10 @@ export default async function InventoryPage() {
|
||||
{total > 0 && ` · ${total.toLocaleString(undefined, { style: "currency", currency: "USD" })} total value`}
|
||||
</p>
|
||||
</div>
|
||||
<AddItemButton locations={locations} items={itemOpts} labels={labels} />
|
||||
<div className="flex items-center gap-2">
|
||||
<ImportButton />
|
||||
<AddItemButton locations={locations} items={itemOpts} labels={labels} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InventoryGrid items={gridItems} />
|
||||
|
||||
28
src/app/(dashboard)/kitchen/pantry/page.tsx
Normal file
28
src/app/(dashboard)/kitchen/pantry/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { sessionContext } from "@/lib/api/authenticate";
|
||||
import { listPantry } from "@/lib/services/pantry";
|
||||
import { PantryClient } from "@/components/pantry/pantry-client";
|
||||
|
||||
export const metadata = { title: "Pantry & Freezer" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function PantryPage() {
|
||||
const ctx = await sessionContext();
|
||||
const [items, locations, plants] = await Promise.all([
|
||||
listPantry(ctx),
|
||||
db.location.findMany({
|
||||
where: {
|
||||
active: true,
|
||||
OR: [
|
||||
{ name: { equals: "Kitchen", mode: "insensitive" } },
|
||||
{ path: { contains: "Kitchen", mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
orderBy: { path: "asc" },
|
||||
select: { id: true, name: true, path: true },
|
||||
}),
|
||||
db.plant.findMany({ where: { active: true }, orderBy: { commonName: "asc" }, select: { id: true, commonName: true, variety: true } }),
|
||||
]);
|
||||
|
||||
return <PantryClient initialItems={items} locations={locations} plants={plants} />;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { 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>
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function GET() {
|
||||
}
|
||||
|
||||
const [locations, plantTypes, users, apiTokens, plantGroups, plants, plantLogs, tasks, taskCompletions,
|
||||
labels, items, labelOnItems, itemAttachments, itemLogs, auditEvents] =
|
||||
labels, items, labelOnItems, itemAttachments, itemLogs, animals, animalLogs, auditEvents] =
|
||||
await Promise.all([
|
||||
db.location.findMany(),
|
||||
db.plantType.findMany(),
|
||||
@@ -27,13 +27,15 @@ export async function GET() {
|
||||
db.labelOnItem.findMany(),
|
||||
db.itemAttachment.findMany(),
|
||||
db.itemLog.findMany(),
|
||||
db.animal.findMany(),
|
||||
db.animalLog.findMany(),
|
||||
db.auditEvent.findMany({ orderBy: { createdAt: "asc" } }),
|
||||
]);
|
||||
|
||||
const manifest = {
|
||||
version: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
tables: ["locations", "plantTypes", "users", "apiTokens", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "labels", "items", "labelOnItems", "itemAttachments", "itemLogs", "auditEvents"],
|
||||
tables: ["locations", "plantTypes", "users", "apiTokens", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "labels", "items", "labelOnItems", "itemAttachments", "itemLogs", "animals", "animalLogs", "auditEvents"],
|
||||
};
|
||||
|
||||
const zip = zipSync({
|
||||
@@ -52,6 +54,8 @@ export async function GET() {
|
||||
"labelOnItems.json": strToU8(JSON.stringify(labelOnItems, null, 2)),
|
||||
"itemAttachments.json": strToU8(JSON.stringify(itemAttachments, null, 2)),
|
||||
"itemLogs.json": strToU8(JSON.stringify(itemLogs, null, 2)),
|
||||
"animals.json": strToU8(JSON.stringify(animals, null, 2)),
|
||||
"animalLogs.json": strToU8(JSON.stringify(animalLogs, null, 2)),
|
||||
"auditEvents.json": strToU8(JSON.stringify(auditEvents, null, 2)),
|
||||
});
|
||||
|
||||
|
||||
@@ -85,6 +85,8 @@ export async function POST(req: Request) {
|
||||
const labelOnItems = files["labelOnItems.json"] ? asArray("labelOnItems.json") : [];
|
||||
const itemAttachments = files["itemAttachments.json"] ? asArray("itemAttachments.json") : [];
|
||||
const itemLogs = files["itemLogs.json"] ? asArray("itemLogs.json") : [];
|
||||
const animals = files["animals.json"] ? asArray("animals.json") : [];
|
||||
const animalLogs = files["animalLogs.json"] ? asArray("animalLogs.json") : [];
|
||||
const auditEvents = asArray("auditEvents.json");
|
||||
|
||||
// Restore inside a transaction — wipe then reload in FK order.
|
||||
@@ -94,9 +96,11 @@ export async function POST(req: Request) {
|
||||
await tx.itemLog.deleteMany();
|
||||
await tx.itemAttachment.deleteMany();
|
||||
await tx.labelOnItem.deleteMany();
|
||||
await tx.animalLog.deleteMany();
|
||||
await tx.task.deleteMany();
|
||||
await tx.item.deleteMany();
|
||||
await tx.label.deleteMany();
|
||||
await tx.animal.deleteMany();
|
||||
await tx.plant.deleteMany();
|
||||
await tx.plantGroup.deleteMany();
|
||||
await tx.plantType.deleteMany();
|
||||
@@ -121,6 +125,8 @@ export async function POST(req: Request) {
|
||||
if (labelOnItems.length) await tx.labelOnItem.createMany({ data: labelOnItems as never });
|
||||
if (itemAttachments.length) await tx.itemAttachment.createMany({ data: itemAttachments as never });
|
||||
if (itemLogs.length) await tx.itemLog.createMany({ data: itemLogs as never });
|
||||
if (animals.length) await tx.animal.createMany({ data: animals as never });
|
||||
if (animalLogs.length) await tx.animalLog.createMany({ data: animalLogs as never });
|
||||
if (tasks.length) await tx.task.createMany({ data: tasks as never });
|
||||
if (taskCompletions.length) await tx.taskCompletion.createMany({ data: taskCompletions as never });
|
||||
if (auditEvents.length) await tx.auditEvent.createMany({ data: auditEvents as never });
|
||||
|
||||
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 });
|
||||
}
|
||||
15
src/app/api/v1/animals/[id]/logs/route.ts
Normal file
15
src/app/api/v1/animals/[id]/logs/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { animalLogInput } from "@/lib/api/schemas";
|
||||
import { addAnimalLog } from "@/lib/services/animals";
|
||||
|
||||
// POST /api/v1/animals/:id/logs
|
||||
export async function POST(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "animals:write");
|
||||
const input = animalLogInput.parse(await req.json());
|
||||
return NextResponse.json(await addAnimalLog(ctx, params.id, input), { status: 201 });
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
33
src/app/api/v1/animals/[id]/route.ts
Normal file
33
src/app/api/v1/animals/[id]/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { animalUpdateInput } from "@/lib/api/schemas";
|
||||
import { getAnimal, updateAnimal, deleteAnimal } from "@/lib/services/animals";
|
||||
|
||||
export async function GET(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "animals:read");
|
||||
return NextResponse.json(await getAnimal(ctx, params.id));
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "animals:write");
|
||||
const input = animalUpdateInput.parse(await req.json());
|
||||
return NextResponse.json(await updateAnimal(ctx, params.id, input));
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "animals:write");
|
||||
await deleteAnimal(ctx, params.id);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
28
src/app/api/v1/animals/route.ts
Normal file
28
src/app/api/v1/animals/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
|
||||
import { animalCreateInput } from "@/lib/api/schemas";
|
||||
import { listAnimals, createAnimal } from "@/lib/services/animals";
|
||||
|
||||
// GET /api/v1/animals?q=&species=
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "animals:read");
|
||||
const sp = new URL(req.url).searchParams;
|
||||
return NextResponse.json(
|
||||
await listAnimals(ctx, { q: sp.get("q") ?? undefined, species: sp.get("species") ?? undefined }),
|
||||
);
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/animals
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const ctx = await authenticateRequest(req, "animals:write");
|
||||
const input = animalCreateInput.parse(await req.json());
|
||||
return NextResponse.json(await createAnimal(ctx, input), { status: 201 });
|
||||
} catch (err) {
|
||||
return handleApiError(err);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
80
src/components/animals/add-animal-log-button.tsx
Normal file
80
src/components/animals/add-animal-log-button.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"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 { Textarea } from "@/components/ui/textarea";
|
||||
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 { ClipboardList } from "lucide-react";
|
||||
import { ANIMAL_LOG_TYPES, ANIMAL_LOG_LABELS } from "@/lib/animals/animal-fields";
|
||||
|
||||
export function AddAnimalLogButton({ animalId }: { animalId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [type, setType] = useState("NOTE");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [weight, setWeight] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const res = await fetch(`/api/v1/animals/${animalId}/logs`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type, notes: notes.trim() || undefined, weight: weight ? Number(weight) : null }),
|
||||
});
|
||||
setBusy(false);
|
||||
if (!res.ok) { toast({ title: "Error", description: "Could not save log.", variant: "destructive" }); return; }
|
||||
toast({ title: "Logged!" });
|
||||
setNotes(""); setWeight(""); setOpen(false);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" onClick={() => setOpen(true)}>
|
||||
<ClipboardList className="h-4 w-4 mr-1" /> Log
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader><DialogTitle>Log for this animal</DialogTitle></DialogHeader>
|
||||
<form onSubmit={submit} className="space-y-4 pt-1">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Type</Label>
|
||||
<Select value={type} onValueChange={setType}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ANIMAL_LOG_TYPES.map((t) => <SelectItem key={t} value={t}>{ANIMAL_LOG_LABELS[t]}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{type === "WEIGHT" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Weight (lbs)</Label>
|
||||
<Input type="number" min="0" step="0.01" value={weight} onChange={(e) => setWeight(e.target.value)} autoFocus />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Notes</Label>
|
||||
<Textarea rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="What happened?" />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Save log"}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
168
src/components/animals/animal-dialog.tsx
Normal file
168
src/components/animals/animal-dialog.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"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 { Textarea } from "@/components/ui/textarea";
|
||||
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 { Plus, Pencil } from "lucide-react";
|
||||
import { COMMON_SPECIES } from "@/lib/animals/animal-fields";
|
||||
|
||||
export type LocationOpt = { id: string; name: string };
|
||||
|
||||
export type AnimalFields = {
|
||||
id?: string;
|
||||
name: string;
|
||||
species: string;
|
||||
breed: string;
|
||||
sex: string;
|
||||
locationId: string;
|
||||
birthdate: string;
|
||||
acquiredAt: string;
|
||||
source: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
export function blankAnimal(): AnimalFields {
|
||||
return { name: "", species: "", breed: "", sex: "", locationId: "", birthdate: "", acquiredAt: "", source: "", notes: "" };
|
||||
}
|
||||
|
||||
const NONE = "__none__";
|
||||
|
||||
function AnimalDialog({ trigger, title, initial, locations }: {
|
||||
trigger: React.ReactNode; title: string; initial: AnimalFields; locations: LocationOpt[];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [f, setF] = useState<AnimalFields>(initial);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
function set<K extends keyof AnimalFields>(k: K, v: AnimalFields[K]) {
|
||||
setF((p) => ({ ...p, [k]: v }));
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!f.name.trim()) { setError("Please give the animal a name."); return; }
|
||||
setError("");
|
||||
setBusy(true);
|
||||
const payload = {
|
||||
name: f.name.trim(),
|
||||
species: f.species.trim() || undefined,
|
||||
breed: f.breed.trim() || undefined,
|
||||
sex: f.sex.trim() || undefined,
|
||||
locationId: f.locationId || null,
|
||||
birthdate: f.birthdate || null,
|
||||
acquiredAt: f.acquiredAt || null,
|
||||
source: f.source.trim() || undefined,
|
||||
notes: f.notes.trim() || undefined,
|
||||
};
|
||||
const res = await fetch(f.id ? `/api/v1/animals/${f.id}` : "/api/v1/animals", {
|
||||
method: f.id ? "PATCH" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
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: f.id ? "Saved!" : "Added!" });
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span onClick={() => { setF(initial); setOpen(true); }}>{trigger}</span>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader><DialogTitle>{title}</DialogTitle></DialogHeader>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label>Name *</Label>
|
||||
<Input value={f.name} onChange={(e) => set("name", e.target.value)} placeholder="Bella" />
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Species</Label>
|
||||
<Input list="species-list" value={f.species} onChange={(e) => set("species", e.target.value)} placeholder="Dog" />
|
||||
<datalist id="species-list">
|
||||
{COMMON_SPECIES.map((s) => <option key={s} value={s} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Breed</Label>
|
||||
<Input value={f.breed} onChange={(e) => set("breed", e.target.value)} placeholder="Border collie" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Sex</Label>
|
||||
<Input value={f.sex} onChange={(e) => set("sex", e.target.value)} placeholder="Female" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Location</Label>
|
||||
<Select value={f.locationId || NONE} onValueChange={(v) => set("locationId", v === NONE ? "" : v)}>
|
||||
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE}>—</SelectItem>
|
||||
{locations.map((l) => <SelectItem key={l.id} value={l.id}>{l.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Birthdate</Label>
|
||||
<Input type="date" value={f.birthdate} onChange={(e) => set("birthdate", e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Acquired</Label>
|
||||
<Input type="date" value={f.acquiredAt} onChange={(e) => set("acquiredAt", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label>Where from?</Label>
|
||||
<Input value={f.source} onChange={(e) => set("source", e.target.value)} placeholder="Breeder, shelter, hatchery…" />
|
||||
</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)} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Save"}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddAnimalButton({ locations }: { locations: LocationOpt[] }) {
|
||||
return (
|
||||
<AnimalDialog title="Add an animal" initial={blankAnimal()} locations={locations}
|
||||
trigger={<Button size="sm"><Plus className="h-4 w-4 mr-1" />Add animal</Button>} />
|
||||
);
|
||||
}
|
||||
|
||||
export function EditAnimalButton({ animal, locations }: { animal: AnimalFields; locations: LocationOpt[] }) {
|
||||
return (
|
||||
<AnimalDialog title="Edit animal" initial={animal} locations={locations}
|
||||
trigger={<Button variant="outline" size="sm"><Pencil className="h-4 w-4 mr-1" />Edit</Button>} />
|
||||
);
|
||||
}
|
||||
55
src/components/animals/delete-animal-button.tsx
Normal file
55
src/components/animals/delete-animal-button.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
export function DeleteAnimalButton({ id, name }: { id: string; name: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
async function handleDelete() {
|
||||
setBusy(true);
|
||||
const res = await fetch(`/api/v1/animals/${id}`, { method: "DELETE" });
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast({ title: "Error", description: err.error ?? "Could not remove.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
toast({ title: "Removed", description: `"${name}" removed.` });
|
||||
setOpen(false);
|
||||
router.push("/animals");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="text-[hsl(var(--ember))]" onClick={() => setOpen(true)}>
|
||||
<Trash2 className="h-4 w-4 mr-1" /> Remove
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove “{name}”?</DialogTitle>
|
||||
<DialogDescription>The animal is hidden but its full history is kept.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleDelete} disabled={busy}
|
||||
className="bg-[hsl(var(--ember))] hover:bg-[hsl(var(--ember)/.85)]">
|
||||
{busy ? "Removing…" : "Remove"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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")} />
|
||||
</div>
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<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>
|
||||
) : (
|
||||
<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>}
|
||||
<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>
|
||||
))}
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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 (
|
||||
<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>
|
||||
{place.locationId && <LocationLogButton locationId={place.locationId} locationName={place.name} />}
|
||||
</div>
|
||||
{isOpen ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pl-6">
|
||||
{place.plants.map((p) => <PlantCard key={p.id} plant={p} />)}
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => toggle(place.key)} className="pl-6 text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
{place.plants.map((p) => p.variety ? `${p.commonName} (${p.variety})` : p.commonName).join(", ")}
|
||||
</button>
|
||||
)}
|
||||
{places.map((place) => (
|
||||
<div key={place.key} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
{place.locationId && <LocationLogButton locationId={place.locationId} locationName={place.name} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<CardGrid plants={place.plants} locations={locations} />
|
||||
</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">
|
||||
{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>
|
||||
</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>
|
||||
</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"}
|
||||
</Button>
|
||||
<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">
|
||||
{g.items.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
|
||||
</div>
|
||||
</div>
|
||||
{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} />)}
|
||||
</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>
|
||||
))}
|
||||
</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,26 +2,9 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive, Lightbulb, RefreshCw, BookOpen, KeyRound, Package } 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: "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();
|
||||
@@ -37,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}
|
||||
@@ -63,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,32 +22,48 @@ 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 />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-2">
|
||||
<div className="h-6 w-6 rounded-full bg-[hsl(var(--leaf))] flex items-center justify-center text-white text-[10px] font-bold">
|
||||
{initials(user.name)}
|
||||
</div>
|
||||
<span className="text-sm hidden sm:inline">{user.name}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => signOut({ callbackUrl: "/login" })}>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<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>
|
||||
<Button variant="ghost" size="sm" className="gap-2">
|
||||
<div className="h-6 w-6 rounded-full bg-[hsl(var(--leaf))] flex items-center justify-center text-white text-[10px] font-bold">
|
||||
{initials(user.name)}
|
||||
</div>
|
||||
<span className="text-sm hidden sm:inline">{user.name}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
34
src/lib/animals/age.test.ts
Normal file
34
src/lib/animals/age.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ageString } from "./age";
|
||||
|
||||
const now = new Date("2026-06-16T00:00:00Z");
|
||||
|
||||
describe("ageString", () => {
|
||||
it("returns null without a birthdate", () => {
|
||||
expect(ageString(null, now)).toBeNull();
|
||||
expect(ageString(undefined, now)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a future birthdate", () => {
|
||||
expect(ageString(new Date("2026-07-01"), now)).toBeNull();
|
||||
});
|
||||
|
||||
it("counts days under a month", () => {
|
||||
expect(ageString(new Date("2026-06-11T00:00:00Z"), now)).toBe("5 days");
|
||||
expect(ageString(new Date("2026-06-15T00:00:00Z"), now)).toBe("1 day");
|
||||
});
|
||||
|
||||
it("counts months under two years", () => {
|
||||
expect(ageString(new Date("2026-03-16T00:00:00Z"), now)).toBe("3 months");
|
||||
expect(ageString(new Date("2025-06-16T00:00:00Z"), now)).toBe("12 months");
|
||||
});
|
||||
|
||||
it("counts whole years from two years on", () => {
|
||||
expect(ageString(new Date("2024-06-16T00:00:00Z"), now)).toBe("2 years");
|
||||
expect(ageString(new Date("2019-01-16T00:00:00Z"), now)).toBe("7 years");
|
||||
});
|
||||
|
||||
it("says newborn on the birth day", () => {
|
||||
expect(ageString(new Date("2026-06-16T00:00:00Z"), now)).toBe("newborn");
|
||||
});
|
||||
});
|
||||
22
src/lib/animals/age.ts
Normal file
22
src/lib/animals/age.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Pure: a friendly age string from a birthdate. Days under a month, months
|
||||
// under two years, then whole years.
|
||||
|
||||
export function ageString(
|
||||
birthdate: Date | string | null | undefined,
|
||||
now: Date,
|
||||
): string | null {
|
||||
if (!birthdate) return null;
|
||||
const b = new Date(birthdate);
|
||||
if (Number.isNaN(b.getTime()) || b > now) return null;
|
||||
|
||||
let months = (now.getFullYear() - b.getFullYear()) * 12 + (now.getMonth() - b.getMonth());
|
||||
if (now.getDate() < b.getDate()) months--;
|
||||
|
||||
if (months < 1) {
|
||||
const days = Math.floor((now.getTime() - b.getTime()) / 86_400_000);
|
||||
return days <= 0 ? "newborn" : `${days} day${days === 1 ? "" : "s"}`;
|
||||
}
|
||||
if (months < 24) return `${months} month${months === 1 ? "" : "s"}`;
|
||||
const years = Math.floor(months / 12);
|
||||
return `${years} years`;
|
||||
}
|
||||
31
src/lib/animals/animal-fields.ts
Normal file
31
src/lib/animals/animal-fields.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { AnimalLogType } from "@prisma/client";
|
||||
|
||||
export const ANIMAL_LOG_LABELS: Record<AnimalLogType, string> = {
|
||||
NOTE: "Note",
|
||||
FEEDING: "Feeding",
|
||||
VET: "Vet visit",
|
||||
WEIGHT: "Weight",
|
||||
MEDICATION: "Medication",
|
||||
BREEDING: "Breeding",
|
||||
ACQUIRED: "Acquired",
|
||||
DIED: "Died / removed",
|
||||
};
|
||||
|
||||
export const ANIMAL_LOG_COLORS: Record<AnimalLogType, string> = {
|
||||
NOTE: "text-slate-600 dark:text-slate-400",
|
||||
FEEDING: "text-green-600 dark:text-green-400",
|
||||
VET: "text-blue-600 dark:text-blue-400",
|
||||
WEIGHT: "text-violet-600 dark:text-violet-400",
|
||||
MEDICATION: "text-amber-600 dark:text-amber-400",
|
||||
BREEDING: "text-pink-600 dark:text-pink-400",
|
||||
ACQUIRED: "text-teal-600 dark:text-teal-400",
|
||||
DIED: "text-red-600 dark:text-red-400",
|
||||
};
|
||||
|
||||
export const ANIMAL_LOG_TYPES: AnimalLogType[] = [
|
||||
"NOTE", "FEEDING", "VET", "WEIGHT", "MEDICATION", "BREEDING", "ACQUIRED", "DIED",
|
||||
];
|
||||
|
||||
export const COMMON_SPECIES = [
|
||||
"Dog", "Cat", "Chicken", "Duck", "Goat", "Sheep", "Pig", "Rabbit", "Horse", "Cow", "Bee", "Other",
|
||||
];
|
||||
@@ -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,15 +69,84 @@ 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>;
|
||||
export type ItemCreateInput = z.infer<typeof itemCreateInput>;
|
||||
export type ItemUpdateInput = z.infer<typeof itemUpdateInput>;
|
||||
export type ItemLogInput = z.infer<typeof itemLogInput>;
|
||||
|
||||
// --- Animals & pets ---
|
||||
|
||||
export const animalCreateInput = z.object({
|
||||
name: z.string().min(1),
|
||||
species: z.string().optional(),
|
||||
breed: z.string().optional(),
|
||||
sex: z.string().optional(),
|
||||
locationId: z.string().nullable().optional(),
|
||||
birthdate: z.string().nullable().optional(),
|
||||
acquiredAt: z.string().nullable().optional(),
|
||||
source: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
imageUrl: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const animalUpdateInput = animalCreateInput.partial();
|
||||
|
||||
export const animalLogInput = z.object({
|
||||
type: z.enum(["NOTE", "FEEDING", "VET", "WEIGHT", "MEDICATION", "BREEDING", "ACQUIRED", "DIED"]),
|
||||
notes: z.string().optional(),
|
||||
weight: z.coerce.number().nonnegative().nullable().optional(),
|
||||
date: z.string().optional(),
|
||||
});
|
||||
|
||||
export type AnimalCreateInput = z.infer<typeof animalCreateInput>;
|
||||
export type AnimalUpdateInput = z.infer<typeof animalUpdateInput>;
|
||||
export type AnimalLogInput = z.infer<typeof animalLogInput>;
|
||||
|
||||
@@ -7,6 +7,7 @@ export const KNOWN_SCOPES = [
|
||||
"locations:read", "locations:write",
|
||||
"tasks:read", "tasks:write",
|
||||
"items:read", "items:write",
|
||||
"animals:read", "animals:write",
|
||||
] as const;
|
||||
|
||||
export type KnownScope = (typeof KNOWN_SCOPES)[number];
|
||||
@@ -18,5 +19,6 @@ export const SCOPE_RESOURCES: { key: string; label: string; soon?: boolean }[] =
|
||||
{ key: "plant-types", label: "Plant types" },
|
||||
{ key: "locations", label: "Locations" },
|
||||
{ key: "tasks", label: "Tasks & reminders" },
|
||||
{ key: "items", label: "Inventory", soon: true },
|
||||
{ key: "items", label: "Inventory" },
|
||||
{ key: "animals", label: "Animals & pets" },
|
||||
];
|
||||
|
||||
@@ -8,6 +8,189 @@ 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",
|
||||
changes: [
|
||||
"New Animals & Pets section — keep a record for each pet and animal on the homestead: species, breed, birthday (with age shown automatically), and where they live. Log feedings, vet visits, weigh-ins, and medications, and set recurring reminders like vaccinations. Works for the dog and cat as well as chickens or goats.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.13.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"]);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user