Compare commits

...

31 Commits

Author SHA1 Message Date
862f5627c7 v0.30.0 — #8 floating suggestion/idea widget (parity with dms + fmb)
Mount @otm/account-panel's SuggestionLightbulb at the dashboard shell root:
a bottom-right quick-capture feedback/idea button that posts to the existing
/api/suggestions route (files a Gitea issue), with "View all →" linking to the
full /suggestions triage page. Backend routes were already wired.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 14:43:59 -05:00
083e2992c9 v0.29.0 — Ramble box, dark-mode toggle, pantry pull reasons
- #10 Ramble box: new /ramble sticky-note board (post/pin/archive/delete),
  ported from FMB. New Ramble model + migration, /api/rambles routes, nav entry.
- #6 Dark-mode toggle: sun/moon button in the top bar (dark palette already
  existed in CSS, just had no control).
- #9 Pantry pull reason: structured disposition (Immediate use / Preserved /
  Gave away / Waste) on ItemLog.reason, added to single + bulk pull dialogs
  and the audit payload. New migration.
- #2 Suggestions: drop the stale white-card wrapper; render SuggestionsPanel
  bare like FMB so it's readable and thumbnails show.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 14:40:54 -05:00
2ad9ac53f8 feat(build): image builds auto-install latest @otm/account-panel
Packument ADD cache-busts only on a publish; pin stays as the dev floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqSCBQyyBbgWRXMR5qo3ZK
2026-07-10 23:05:37 -05:00
f351ed3846 chore(deps): @otm/account-panel 0.8.2 → 0.22.1 — feedback-panel copy fix (v0.28.1)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WqSCBQyyBbgWRXMR5qo3ZK
2026-07-10 22:51:16 -05:00
Bonna Moon
a50f7eaa49 v0.28.0 — Fix null toxicToPets on type create; Root vegetable category; sweet potato knowledge
The plant-type CREATE schema rejected toxicToPets: null (the dialog's
'Unknown'), failing every manually-entered type with 'expected boolean,
received null' — now nullable like the PATCH schema always was. New
ROOT_VEGETABLE PlantCategory (enum migration + labels/order). Garden
brain gains Sweet potato (slips in June, dig before frost, warm cure),
and knowledgeFor/tipsForMonth now prefer the most specific name match so
'Japanese sweet potato' gets sweet-potato advice instead of Potato's
nightshade rotation and hilling (regression-tested).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:27:51 -05:00
Bonna Moon
fe24cd73be v0.27.0 — Garden brain: zone-4b knowledge, calendar suggestions, UW citations
src/lib/garden/knowledge.ts holds hand-curated zone-4b (~May 15 frost)
knowledge for 28 crops/fruits/flowers: monthly care timing, companion
friends/foes, rotation families. Pure matchers knowledgeFor/tipsForMonth
use whole-word stem matching (tested — 'Pear' must not hit 'Pea'). The
calendar gains a "Good to do this month" list matched to owned plants,
deduped against existing task titles, with one-click Remind me → YEARLY
task via /api/v1/tasks. Plant type pages gain a "Growing in zone 4b"
card (timeline, companions, rotation). Tips cite UW Extension via site
search URLs — no fabricated article links.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:35:45 -05:00
Bonna Moon
5319e8af2f v0.26.0 — Calendar: month view of all tasks + bloom/harvest windows
New /calendar page merges every Task (garden, home, equipment, animals)
into one month grid, color-coded by category, with a click-a-day detail
panel and inline Done (reuses /api/tasks/[id]/complete). Recurring tasks
project every occurrence in view via the new occurrencesInRange in
src/lib/tasks/schedule.ts (first tests for that module — 12, including
month-end clamping). Season chips per month come from PlantType
bloom/harvest windows via src/lib/garden/season.ts (monthInWindow,
year-wrap aware). Nav gains a Calendar item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:06:58 -05:00
Bonna Moon
cc212c87c7 v0.25.0 — Garden makeover: card shelf, one-tap logging, weekly journal
The garden page is now a photo-forward shelf: every plant visible as a
card with search and filter chips (groups + By place) replacing the old
collapse trees. Each card carries quick-log buttons — droplet logs
Watered (CARE) in one tap with an inline confirmation flash, eye takes a
one-line observation, basket opens a compact harvest dialog (structured
amount + send-to-pantry). New "This week in your garden" journal feed
shows the last 14 days of activity across all plants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:26:31 -05:00
Bonna Moon
e65d28524b v0.24.0 — Garden: harvest→pantry, structured amounts, UW Extension links
Harvest logs now carry a structured amount+unit (legacy freeform quantity
kept for old rows) and can simultaneously create a GARDEN-source pantry
item via addPlantLog's new pantry input. PlantTypes can link a UW–Madison
Extension article, searched through /api/v1/plant-types/uw-search (proxy
to hort.extension.wisc.edu's WordPress REST API); the link shows on the
type page and each plant. The garden log dialog now posts to /api/v1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:14:11 -05:00
Bonna Moon
25b1ba9aeb v0.23.0 — Pantry: structured USED pull logs + move to /api/v1 service layer
Pantry now runs on the v1 API like Inventory/Animals: service layer in
src/lib/services/pantry.ts, pure logic + tests in src/lib/pantry/core.ts,
thin routes at /api/v1/pantry/* riding the items:* scopes. Pulls write
ItemLog type USED with a structured amount (queryable consumption).
Bulk-use runs its reads inside the transaction, accumulates repeated
pulls, and reports skipped items instead of silently dropping them —
the Pull dialog now surfaces those by name. Old /api/pantry routes
deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 00:14:53 -05:00
8f77b7c453 v0.22.0 — Inventory search + Location/Label grouping; Suggestions styling fix
Adds a search box (matches name, location, and labels) and a Location/Label
grouping toggle to Inventory, and fixes the unstyled Suggestions page.
(Renumbered from 0.19.0 → 0.22.0: Pantry work took 0.19–0.21 in parallel.)

Hardening from a max-effort code review of the new grid:
- Contained items resolve to their container's effective location, so search
  finds them and the flat view groups them under the right place (not "No
  location").
- Location/label groups key on id, not name, so same-named places/labels stay
  separate; synthetic bucket renamed "No labels" to avoid colliding with a real
  label; fixed duplicate React keys and two empty-state regressions.
- deleteItem now reparents a deleted container's contents into its own spot
  (its place or parent item) instead of orphaning them; the grid also treats
  any still-orphaned child as top-level so nothing silently vanishes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 20:12:21 -05:00
Bonna Moon
dd7af9e8d4 v0.21.1 — Pantry: delete items with inline confirm
Trash icon on each row; clicking shows "Delete? Yes / No" inline
(no dialog needed). Soft-deletes via active=false. Removed the
admin-only guard on DELETE /api/pantry/[id] since anyone logged in
should be able to manage their own pantry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 18:32:30 -05:00
Bonna Moon
6b75e47ee9 v0.21.0 — Pantry: bulk pull dialog
New "Pull from pantry" button opens a dialog where you search/filter
all items, type a quantity next to each one you're taking, pick or type
a reason, and submit all in one shot. Items with amounts entered
highlight; over-stock amounts show inline error. Backed by new
POST /api/pantry/bulk-use (transactional quantity deduction + ItemLog).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 18:31:14 -05:00
Bonna Moon
d3af826e3a v0.20.0 — Pantry: multi-tag items (e.g. Pre-cooked + Poultry)
Uses existing Label/LabelOnItem tables — no schema change. Tags field
in the add/edit dialog: type + Enter/comma to add, backspace to remove,
autocomplete from previously-used labels. Tags display as chips on each
item row. API: POST/PATCH accept tags[], upsert Label rows, sync
LabelOnItem. Catalog endpoint also returns all label names for
autocomplete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 18:25:07 -05:00
Bonna Moon
70df86ba59 Pantry: show plain location name, not full path
The dropdown already only shows Kitchen locations so the
"Kitchen › Freezer" prefix is redundant — just show "Freezer".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 18:12:26 -05:00
Bonna Moon
67bc23c64e v0.19.2 — Combobox: always show "Add new…" when allowCustom
Previously the "Add" option only appeared after typing a non-matching
query, which wasn't discoverable. Now the combobox always shows
"Add new…" at the bottom when allowCustom=true; clicking it focuses the
search input so you can type the new name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 18:12:05 -05:00
Bonna Moon
3b35e835c8 v0.19.1 — Pantry: quantity steppers go by whole numbers
Changed step from 0.25 to 1 on the Quantity and "how much are you
using" number inputs so the spinner arrows move by wholes. Added
noValidate on both forms (native step-mismatch validation would
otherwise block submitting typed partials like 2.5) plus manual
amount-bounds checks in the use dialog to replace the native min/max
constraints that noValidate disables.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 17:58:04 -05:00
Bonna Moon
61dc8acacf v0.19.0 — Pantry: tag what you used items for
Quick-pick chips (Family dinner, Dinner party, Taking on a trip, Meal
prep, Gave it away) in the Use dialog, alongside the existing freeform
notes field — picking one fills the note, no schema change needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 17:55:13 -05:00
Bonna Moon
c449b0c2ae v0.18.1 patch — Pantry: location dropdown shows Kitchen only
Filter locations to Kitchen and its children (fridge, freezer, pantry
shelves, etc.). Display the full path so "Kitchen › Freezer" shows
instead of just "Freezer".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 17:49:37 -05:00
Bonna Moon
e501679416 v0.18.1 — Pantry: add custom categories on the fly
Type a new name in the Category or Subcategory dropdown and pick
"Add '...'" at the bottom. Custom categories persist (stored as strings
on the item) and reappear merged into future dropdowns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 17:48:44 -05:00
Bonna Moon
608a192877 v0.6.2 — Pantry: searchable category/subcategory comboboxes 2026-06-29 17:42:54 -05:00
Bonna Moon
62a0089ec2 v0.6.1 — Pantry: categories, price tracking, and autocomplete 2026-06-29 17:37:59 -05:00
Bonna Moon
0d52ea5ad1 v0.6.0 — Kitchen: Pantry & Freezer inventory 2026-06-29 17:28:28 -05:00
Bonna Moon
f483630419 Add Change password link to user menu 2026-06-29 17:18:58 -05:00
Bonna Moon
8ec43f3561 Add password recovery page at /recover 2026-06-29 16:45:19 -05:00
ddc0a60a0c v0.18.0 — Locations Quick setup + place-based reminders (#4 groundwork)
#4 cleanup + Tony's house structure.

- Task.locationId (migration): reminders can attach to a place, not just a
  plant/item/animal. Add one from a location's menu (e.g. clean gutters on House >
  Exterior); the Reminders page + tasks service surface the linked location/item/
  animal.
- Locations "Quick setup": paste/confirm an indented outline → creates the nested
  tree (find-or-create by name+parent, idempotent), pre-filled with the Moon
  homestead house layout. Optional "sweep existing garden areas under Yard" moves
  every top-level place that holds plants under a new Yard parent — the
  'move all the yard stuff to a new top level' ask.
- Pure outline parser (src/lib/locations/outline.ts, +4 tests); bulk setup service
  + /api/locations/bulk. Verified end-to-end: nested tree + paths, garden sweep,
  place reminder.

Yard sub-bucketing deferred per Tony.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 02:33:52 -05:00
4aacffdf4e v0.17.0 — Mobile menu (#3) + Suggestions readability & header lightbulb (#2)
Addresses Gitea suggestions #3 and #2.

#3 Mobile: the sidebar was desktop-only (hidden md:flex), leaving phones with no
nav at all. Added a slide-out drawer (hamburger in the top bar, md:hidden) that
reuses the nav. Lifted navItems/settingsItems into src/components/layout/nav-items.ts
so the sidebar and mobile drawer share one source + active-state logic. No HTTPS
needed (pure responsive UI).

#2 Suggestions: the @otm/account-panel SuggestionsPanel hardcodes light neutral-*
text that assumes a white backdrop, so it washed out on the app theme — now
rendered on a white card for contrast in light/dark. Added a lightbulb button in
the header that jumps to Suggestions (like FMB).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 02:16:57 -05:00
b8af0f986b docs: refresh README + CLAUDE.md for current modules; add deploy guide for any Claude
- README/CLAUDE.md: Inventory depth (warranty/value/maintenance/sold/custom fields,
  HomeBox CSV import), Locations page, the two-world data-model rule, API-first +
  service-layer conventions, and the HTTPS-gated future work (MCP/PWA/camera)
- New docs/git-deploy-guide.md: self-contained git + deploy walkthrough for a
  Claude instance working without this repo's CLAUDE.md (NAS is a git checkout,
  push + pull/rebuild, never rsync, secrets in host .env, prisma-at-runtime gotcha)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:27:30 -05:00
80324afea2 v0.16.1 — Fix forced-password-change re-firing every visit
The dashboard gate trusted session.user.mustChangePassword from the JWT, which is
only refreshed on re-login/update() — so after a user set a new password the stale
cookie kept bouncing them back to /change-password. Read the flag from the DB (the
source of truth) so it clears immediately and never re-fires. Cheap for a 2-user
app; also re-checks the account is still active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:26:00 -05:00
5f4c482636 v0.16.0 — HomeBox CSV import + sold lifecycle + custom fields
Bring an existing HomeBox inventory straight in, and capture the fields HomeBox
has that we were missing (skipping asset IDs per Tony).

- Item: soldTo/soldPrice/soldDate/soldNotes + flexible customFields (JSON, e.g.
  VIN / oil weight for vehicles); both editable in the item dialog and shown on
  the detail page
- Pure HomeBox CSV parser (src/lib/inventory/homebox-import.ts, +5 tests) —
  RFC4180 quoting (commas/newlines/escaped quotes in fields), HomeBox's 0001-*
  null dates and 0 = empty price, semicolon labels, HB.field.* custom columns,
  nested 'A / B' location paths
- Import service + /api/v1/items/import: find-or-creates the nested locations
  and labels, creates items, recomputes location paths, returns a summary
- Inventory 'Import' button: paste/upload CSV, review every row with checkboxes
  (uncheck to skip), then import. Preview parses client-side (parser is pure)
- Verified end-to-end: nested locations, split labels, sold + custom fields,
  skip, quoted names, null-date handling. tsc + 38 tests + build green

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:01:17 -05:00
63569aabd3 v0.15.0 — Inventory depth (HomeBox parity) + Locations page
Rounds out Inventory toward HomeBox, and gives Locations a real home.

Inventory:
- Attach manuals, receipts, and warranty docs (any file), not just photos —
  attachment uploader now has a kind picker; WARRANTY kind added
- Richer purchase/warranty: purchasedFrom, warrantyDetails, lifetimeWarranty,
  insured (item dialog + detail facts + header badge)
- Schedule recurring maintenance from an item (creates a Task linked via
  itemId); item detail shows its maintenance with overdue flagged. Task create
  now accepts itemId/animalId

Locations:
- New Locations page + nav: browse/add/rename/move/delete the whole place tree
  (LocationManager over the existing /api/locations routes); shows plant/item
  counts per place. The spot to merge near-dupes before dropping legacy zone

Verified end-to-end against a running server (new fields round-trip, maintenance
task links to item, MANUAL attachment stored). tsc + 33 tests + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:34:07 -05:00
101 changed files with 7272 additions and 405 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -9,17 +9,78 @@ user-facing overview.
- **Garden** — Plant registry + care/harvest logs, plant **groups**, reusable - **Garden** — Plant registry + care/harvest logs, plant **groups**, reusable
**PlantType** records, Wikipedia photos. Plants sit on the shared location tree. **PlantType** records, Wikipedia photos. Plants sit on the shared location tree.
- **Inventory** — durable **Item**s that nest (`parentItemId`), with **Label**s Harvest logs carry structured `amount`+`unit` (legacy freeform `quantity` kept
(M:N via `LabelOnItem`), `ItemAttachment`, and `ItemLog`. Shares the Location spine. for old rows — don't regress); a harvest can simultaneously create a pantry
item (`addPlantLog`'s `pantry` input → GARDEN-source consumable). PlantTypes
can link a UWMadison Extension article (`uwExtensionUrl/Title`), searched via
`/api/v1/plant-types/uw-search` (proxy to hort.extension.wisc.edu's WP REST
API; helper `src/lib/garden/uw-extension.ts`).
- **Inventory** — durable **Item**s that nest (`parentItemId`: a toolbox is an
Item that holds Items, with a cycle guard), with **Label**s (M:N via
`LabelOnItem`), `ItemAttachment` (PHOTO/MANUAL/RECEIPT/WARRANTY), and `ItemLog`.
Items carry value/purchase/warranty (incl. `lifetimeWarranty`, `insured`),
`serial`/`modelNumber`/`brand`, a **sold** lifecycle, and freeform `customFields`
(JSON, e.g. VIN). Maintenance = a `Task` linked via `Task.itemId`. **HomeBox CSV
import** at `/api/v1/items/import` (pure parser `src/lib/inventory/homebox-import.ts`).
`kind` is DURABLE|CONSUMABLE. Shares the Location spine.
- **Pantry** (Kitchen → Pantry & Freezer) — CONSUMABLE Items with categories,
price paid, tags (shared `Label` table), and expiry. Pulls go through
`/api/v1/pantry/[id]/use` or `/bulk-use` and write `ItemLog` type `USED` with a
structured `amount` (consumption is queryable — don't regress to NOTE strings).
Service `src/lib/services/pantry.ts`; pure logic + tests in `src/lib/pantry/core.ts`.
Rides the `items:*` scopes (pantry items ARE Items).
- **Animals** — `Animal` + `AnimalLog` (pets/livestock); placed on the Location - **Animals** — `Animal` + `AnimalLog` (pets/livestock); placed on the Location
tree, and `Task`s can link to an animal (`Task.animalId`). tree, and `Task`s can link to an animal (`Task.animalId`).
- **Locations** — `Location` tree (self-ref `parentId`) shared by plants, items, animals. - **Locations** — `Location` tree (self-ref `parentId`) shared by plants, items, animals.
- **Reminders** — `Task` + `TaskCompletion`; recurrence in `src/lib/tasks/schedule.ts`. - **Reminders** — `Task` + `TaskCompletion`; recurrence in `src/lib/tasks/schedule.ts`
(incl. `occurrencesInRange` projection). **/calendar** renders a month view of
all tasks (garden + house) plus PlantType bloom/harvest windows
(`src/lib/garden/season.ts`) and garden-brain suggestions.
- **Garden brain** — `src/lib/garden/knowledge.ts`: hand-curated zone-4b
(~May 15 frost) care timing, companions/foes, rotation families for ~28
crops; pure matchers `knowledgeFor`/`tipsForMonth` (whole-word stem match —
beware "Pear"/"Pea"-style false hits, tested). Surfaced as calendar
"Good to do this month" (one-click YEARLY task via /api/v1/tasks) and a
"Growing in zone 4b" card on type pages. Tips cite UW Extension via site
search URLs — never fabricate article URLs.
- **REST API** — `/api/v1/*` authed by `ApiToken` (per-scope; logic in `src/lib/api/`). - **REST API** — `/api/v1/*` authed by `ApiToken` (per-scope; logic in `src/lib/api/`).
- **Suggestions** — `@otm/account-panel` (Gitea npm registry) → files issues to the repo. - **Suggestions** — `@otm/account-panel` (Gitea npm registry) → files issues to the repo.
- **Backup & Restore** — `/api/admin/backup` + `/api/admin/restore` (admin only). - **Backup & Restore** — `/api/admin/backup` + `/api/admin/restore` (admin only).
- **Self-update** — admin "Pull & rebuild" (see Deploy). - **Self-update** — admin "Pull & rebuild" (see Deploy).
## Data model (the one rule)
**Two nesting worlds, crossed once.** *Places* (`Location`, self-ref `parentId`,
unlimited depth — `LocationKind` is an icon hint, never a constraint) nest in
places. *Items* (things you own) nest in items, and an Item lives in a Place **or**
inside another Item. You never put a Place inside an Item. **Plants** and
**Animals** are leaves in a Place (own logs, don't nest). HomeBox + Grocy + farmOS
collapse into: **Places · Plants/Animals (leaves) · Items (durable + consumable,
nesting)**. Logs are per-domain tables (`PlantLog`/`ItemLog`/`AnimalLog`), NOT a
polymorphic table; `Task` carries nullable `plantId`/`itemId`/`animalId`.
## Architecture conventions
- **API-first.** New modules (Inventory, Animals) are built on `/api/v1/*` and the
UI calls those same endpoints — no separate UI routes. Garden/Tasks predate this
and still use `/api/plants` etc.
- **Service layer** `src/lib/services/*` does DB work + AuditEvent, taking
`(ctx: AuthContext, input)`. Routes are thin: `authenticateRequest(req, scope)`
service → `handleApiError`. The MCP server (unbuilt) will reuse these services.
- **Auth helper** `src/lib/api/authenticate.ts` accepts EITHER a NextAuth cookie
session (full scope `["*"]`) OR a Bearer PAT (limited to its `scopes`; write
implies read). Scope constants in `src/lib/api/scopes.ts` (client-safe).
- **Decimals** for money/quantity (`@db.Decimal`) — convert to `Number()` before
passing to client components (Prisma Decimal isn't serializable across the boundary).
## Blocked on a domain + HTTPS (deferred)
Camera scanning/photo-capture (`getUserMedia` needs a secure context), the **PWA**
(service worker + install need HTTPS), and the **MCP server** (Claude needs a public
HTTPS URL) all wait until there's a domain + reverse-proxy/tunnel. The app is
LAN-only over `http://NAS:8053` today. Pantry/consumables and other pure web work
can ship before then.
## Local dev ## Local dev
- DB: `postgresql://tonym@localhost:5432/moonbase_dev`; copy `.env.example``.env`. - DB: `postgresql://tonym@localhost:5432/moonbase_dev`; copy `.env.example``.env`.
@@ -31,6 +92,9 @@ user-facing overview.
## Deploy — git checkout on the NAS (NOT rsync) ## Deploy — git checkout on the NAS (NOT rsync)
> Working without this file (e.g. a Claude on another machine)? The self-contained
> version is `docs/git-deploy-guide.md`.
The NAS appdata (`/mnt/user/appdata/moonbase`) is a real **git checkout** tracking The NAS appdata (`/mnt/user/appdata/moonbase`) is a real **git checkout** tracking
`origin/master` (remote URL carries the token). Deploy by: `origin/master` (remote URL carries the token). Deploy by:
@@ -57,6 +121,9 @@ reloads. **Never rsync over the appdata dir** — it fights the checkout.
- **Every commit bumps APP_VERSION** in `src/lib/version.ts` + a plain-English - **Every commit bumps APP_VERSION** in `src/lib/version.ts` + a plain-English
entry atop `src/lib/changelog.ts` (the footer shows it + an "update available" entry atop `src/lib/changelog.ts` (the footer shows it + an "update available"
badge from `src/lib/update-check.ts`). Minor for features/migrations, patch for fixes. badge from `src/lib/update-check.ts`). Minor for features/migrations, patch for fixes.
**Fetch first**: parallel sessions push to master — check
`git show origin/master:src/lib/version.ts` before picking a number
(a v0.22.0 collision has already happened once).
- **AuditEvent on every mutation** — append-only. - **AuditEvent on every mutation** — append-only.
- **Soft-delete** via `active=false`. - **Soft-delete** via `active=false`.
- **Pure core + colocated test** — logic in `src/lib/...` with a `.test.ts`; routes thin. - **Pure core + colocated test** — logic in `src/lib/...` with a `.test.ts`; routes thin.

View File

@@ -10,6 +10,17 @@ COPY package.json package-lock.json* ./
COPY prisma ./prisma COPY prisma ./prisma
RUN npm ci RUN npm ci
# Workspace-style freshness for the shared OTM UI package: every image build
# installs the latest published @otm/account-panel on top of the lockfile, so
# rebuilds pick up package releases without needing a pin-bump commit (the
# package.json pin is the dev-time floor). The ADD pulls the registry
# packument, which only changes on a publish — so this layer stays cached
# between publishes and busts exactly when a new version exists. Moonbase
# builds on the NAS itself, so the LAN host is always reachable.
COPY .npmrc ./
ADD http://192.168.0.5:3022/api/packages/tonym/npm/@otm/account-panel /tmp/otm-account-panel.packument.json
RUN npm install --no-save --no-audit --no-fund @otm/account-panel@latest
# ---- build ---- # ---- build ----
FROM node:20-bookworm-slim AS build FROM node:20-bookworm-slim AS build
WORKDIR /app WORKDIR /app

View File

@@ -12,13 +12,18 @@ Live at **http://192.168.0.5:8053**.
reusable **plant types** (the "kind" record: care notes, sun/water, bloom & reusable **plant types** (the "kind" record: care notes, sun/water, bloom &
harvest months, pet toxicity), Wikipedia photos, and plants placed on the shared harvest months, pet toxicity), Wikipedia photos, and plants placed on the shared
location tree. location tree.
- **Inventory** — durable **items** that nest (boxes within rooms within - **Inventory** — durable **items** that nest (a toolbox holds the tools inside
buildings), with labels, attachments, and logs. Shares the same location spine it), with labels, photos + manuals/receipts/warranty docs, value & warranty
as the garden. tracking (with expiry warnings), serial/brand, a sold record, and custom fields
- **Animals** — pets & livestock with their own logs, placed on the shared (e.g. a vehicle's VIN). Schedule recurring **maintenance** per item. **Import
location tree; reminders can be linked to an animal. straight from HomeBox** via CSV. Shares the same location spine as the garden.
- **Animals** — pets & livestock with their own logs (feeding, vet, weight, meds)
and auto-computed age, placed on the shared location tree; reminders can be
linked to an animal.
- **Locations** — manage the whole place tree (areas, buildings, rooms, storage),
nested as deep as you like; everything (plants, items, animals) lives here.
- **Reminders** — one-off or recurring tasks (every N days / monthly / yearly), - **Reminders** — one-off or recurring tasks (every N days / monthly / yearly),
optionally linked to a plant or animal. optionally linked to a plant, item, or animal.
- **REST API** (`/api/v1/*`) — personal access **tokens** with per-scope - **REST API** (`/api/v1/*`) — personal access **tokens** with per-scope
permissions (read plants, log harvests, …) for phone shortcuts, Home Assistant, permissions (read plants, log harvests, …) for phone shortcuts, Home Assistant,
and automation. and automation.
@@ -69,7 +74,7 @@ Two ways to ship a change:
Do **not** rsync over the appdata dir — it would fight the checkout. Secrets Do **not** rsync over the appdata dir — it would fight the checkout. Secrets
(`NEXTAUTH_SECRET`, `GITEA_TOKEN`, `UPDATER_SECRET`) live in a gitignored host (`NEXTAUTH_SECRET`, `GITEA_TOKEN`, `UPDATER_SECRET`) live in a gitignored host
`.env` next to the compose file, never committed. Migrations apply automatically `.env` next to the compose file, never committed. Migrations apply automatically
on container start. on container start. Full deploy walkthrough: [`docs/git-deploy-guide.md`](docs/git-deploy-guide.md).
## Conventions ## Conventions

77
docs/git-deploy-guide.md Normal file
View 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
View File

@@ -9,13 +9,14 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@otm/account-panel": "0.8.2", "@otm/account-panel": "0.22.1",
"@paralleldrive/cuid2": "^3.3.0", "@paralleldrive/cuid2": "^3.3.0",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.17",
"@radix-ui/react-select": "^2.1.2", "@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
@@ -1025,13 +1026,27 @@
} }
}, },
"node_modules/@otm/account-panel": { "node_modules/@otm/account-panel": {
"version": "0.8.2", "version": "0.22.1",
"resolved": "http://192.168.0.5:3022/api/packages/tonym/npm/%40otm%2Faccount-panel/-/0.8.2/account-panel-0.8.2.tgz", "resolved": "http://192.168.0.5:3022/api/packages/tonym/npm/%40otm%2Faccount-panel/-/0.22.1/account-panel-0.22.1.tgz",
"integrity": "sha512-E7uJ3Xxm8/xz6gcUAS4TUI+eCjaVZLFs7CjF7RpwSLUn3DpIszGSE4BN0519BQOThpQ69p3m15h/b64HLMgHgw==", "integrity": "sha512-JwPVqe5FPENt6nlwlZ91PX58Y2QvFINHLi385zH/f4tO5PvifDYVVqwmzF0TrQP9NaUwOiHJV2PgRdotlN3c7A==",
"peerDependencies": { "peerDependencies": {
"@stripe/react-stripe-js": ">=2",
"@stripe/stripe-js": ">=3",
"next": ">=14", "next": ">=14",
"next-auth": ">=4", "next-auth": ">=4",
"react": ">=18" "react": ">=18",
"stripe": ">=18"
},
"peerDependenciesMeta": {
"@stripe/react-stripe-js": {
"optional": true
},
"@stripe/stripe-js": {
"optional": true
},
"stripe": {
"optional": true
}
} }
}, },
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
@@ -1492,6 +1507,43 @@
} }
} }
}, },
"node_modules/@radix-ui/react-popover": {
"version": "1.1.17",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.17.tgz",
"integrity": "sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-dismissable-layer": "1.1.13",
"@radix-ui/react-focus-guards": "1.1.4",
"@radix-ui/react-focus-scope": "1.1.10",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-popper": "1.3.1",
"@radix-ui/react-portal": "1.1.12",
"@radix-ui/react-presence": "1.1.6",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-slot": "1.3.0",
"@radix-ui/react-use-controllable-state": "1.2.3",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.7.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": { "node_modules/@radix-ui/react-popper": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz",

View File

@@ -23,13 +23,14 @@
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@otm/account-panel": "0.8.2", "@otm/account-panel": "0.22.1",
"@paralleldrive/cuid2": "^3.3.0", "@paralleldrive/cuid2": "^3.3.0",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.17",
"@radix-ui/react-select": "^2.1.2", "@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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");

View File

@@ -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");

View File

@@ -0,0 +1,5 @@
-- AlterEnum
ALTER TYPE "ItemLogType" ADD VALUE 'USED';
-- AlterTable
ALTER TABLE "ItemLog" ADD COLUMN "amount" DECIMAL(10,2);

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "PlantCategory" ADD VALUE 'ROOT_VEGETABLE';

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ItemLog" ADD COLUMN "reason" TEXT;

View File

@@ -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");

View File

@@ -72,6 +72,20 @@ model AuditEvent {
@@index([createdAt]) @@index([createdAt])
} }
// Ramble box — sticky-note brain dump for stray homestead thoughts ("move the
// rhubarb", "oil the chainsaw"). Not a Task and not a Location leaf; just a
// free-text jot you pin, archive (soft), or delete (hard). Ported from FMB.
model Ramble {
id String @id @default(cuid())
body String
pinned Boolean @default(false)
archivedAt DateTime? // soft-archive; null = active
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([pinned, archivedAt, createdAt])
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Location — shared hierarchical place tree (garden areas, buildings, rooms, // Location — shared hierarchical place tree (garden areas, buildings, rooms,
// storage bins). The spine for Garden, Inventory, and Pantry. Plants/items/ // storage bins). The spine for Garden, Inventory, and Pantry. Plants/items/
@@ -102,6 +116,7 @@ model Location {
plants Plant[] plants Plant[]
items Item[] items Item[]
animals Animal[] animals Animal[]
tasks Task[]
@@index([parentId]) @@index([parentId])
@@index([kind]) @@index([kind])
@@ -121,6 +136,7 @@ enum PlantCategory {
GROUNDCOVER // strawberries, clover, creeping thyme GROUNDCOVER // strawberries, clover, creeping thyme
VINE // grapes, hops, kiwi VINE // grapes, hops, kiwi
ANNUAL_VEGETABLE // tomatoes, squash, beans, peppers — seasonal veg ANNUAL_VEGETABLE // tomatoes, squash, beans, peppers — seasonal veg
ROOT_VEGETABLE // carrots, beets, sweet potatoes, radishes
ANNUAL // legacy, treated as annual vegetable ANNUAL // legacy, treated as annual vegetable
ANNUAL_FLOWER // zinnias, marigolds, nasturtiums, cosmos ANNUAL_FLOWER // zinnias, marigolds, nasturtiums, cosmos
PERENNIAL // asparagus, rhubarb, perennial veg PERENNIAL // asparagus, rhubarb, perennial veg
@@ -164,6 +180,7 @@ model Plant {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
logs PlantLog[] logs PlantLog[]
tasks Task[] tasks Task[]
pantryItems Item[]
@@index([category]) @@index([category])
@@index([zone]) @@index([zone])
@@ -193,6 +210,8 @@ model PlantType {
edibleParts String? // "Fruit, young leaves" edibleParts String? // "Fruit, young leaves"
spacing String? // "1015 ft", "18 in" spacing String? // "1015 ft", "18 in"
imageUrl String? imageUrl String?
uwExtensionUrl String? // linked UWMadison Extension article (hort.extension.wisc.edu)
uwExtensionTitle String? // its title, shown as the link text
notes String? notes String?
active Boolean @default(true) active Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -242,6 +261,8 @@ model Task {
item Item? @relation(fields: [itemId], references: [id], onDelete: SetNull) item Item? @relation(fields: [itemId], references: [id], onDelete: SetNull)
animalId String? animalId String?
animal Animal? @relation(fields: [animalId], references: [id], onDelete: SetNull) animal Animal? @relation(fields: [animalId], references: [id], onDelete: SetNull)
locationId String?
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
recurrence RecurrenceType @default(YEARLY) recurrence RecurrenceType @default(YEARLY)
intervalDays Int? // for CUSTOM: every N days intervalDays Int? // for CUSTOM: every N days
month Int? // 112: for YEARLY, which month month Int? // 112: for YEARLY, which month
@@ -256,6 +277,7 @@ model Task {
@@index([plantId]) @@index([plantId])
@@index([itemId]) @@index([itemId])
@@index([animalId]) @@index([animalId])
@@index([locationId])
@@index([active]) @@index([active])
} }
@@ -277,7 +299,9 @@ model PlantLog {
type PlantLogType type PlantLogType
date DateTime @default(now()) date DateTime @default(now())
notes String? notes String?
quantity String? // freeform for harvests: "2 lbs", "1 gallon", "a big handful" quantity String? // legacy freeform harvests ("a big handful") — new logs use amount+unit
amount Decimal? @db.Decimal(10, 2) // structured harvest quantity (queryable totals)
unit String? // "lbs", "each", "quarts" — pairs with amount
actorId String? actorId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -295,7 +319,16 @@ model PlantLog {
enum ItemKind { enum ItemKind {
DURABLE // tools, gear, equipment — "where is it?" DURABLE // tools, gear, equipment — "where is it?"
CONSUMABLE // food, feed, supplies — "do I have it?" (Pantry phase) CONSUMABLE // food, feed, supplies — "do I have it?"
}
enum ItemSource {
GARDEN // grown/harvested here
HOMEMADE // made/preserved ourselves
STORE // grocery or big-box store
FARMERS_MARKET // local market
GIFTED // given by someone
OTHER
} }
model Item { model Item {
@@ -314,17 +347,38 @@ model Item {
containedItems Item[] @relation("ItemNesting") containedItems Item[] @relation("ItemNesting")
// Durable attributes // Durable attributes
value Decimal? @db.Decimal(10, 2) value Decimal? @db.Decimal(10, 2) // purchase price
purchaseDate DateTime? purchaseDate DateTime?
purchaseFrom String? // where it was bought
warrantyExpiry DateTime? warrantyExpiry DateTime?
warrantyDetails String? // freeform warranty terms
lifetimeWarranty Boolean @default(false)
insured Boolean @default(false)
serial String? serial String?
modelNumber String? modelNumber String?
brand String? brand String? // brand / manufacturer
// Consumable attributes (Pantry phase) // Sold lifecycle (HomeBox parity)
soldTo String?
soldPrice Decimal? @db.Decimal(10, 2)
soldDate DateTime?
soldNotes String?
// Custom fields — flexible key/value (e.g. VIN, "Oil Weight": "5W-30")
customFields Json?
// Consumable attributes
barcode String? barcode String?
bestBefore DateTime? storedAt DateTime? // date frozen / preserved / stored
bestBefore DateTime? // expiry / best-by date
minStock Decimal? @db.Decimal(10, 2) minStock Decimal? @db.Decimal(10, 2)
source ItemSource? // where it came from
plantId String? // link to a garden plant (harvest source)
plant Plant? @relation(fields: [plantId], references: [id], onDelete: SetNull)
pantryCategory String? // e.g. "Meat", "Produce", "Dairy"
pantrySubcategory String? // e.g. "Beef", "Poultry", "Berries"
pricePaid Decimal? @db.Decimal(10, 2) // how much was paid
priceUnit String? // "package", "lb", "oz", "kg", etc.
// Shared // Shared
qrSlug String? @unique // short slug for a printed QR label qrSlug String? @unique // short slug for a printed QR label
@@ -345,6 +399,8 @@ model Item {
@@index([active]) @@index([active])
@@index([name]) @@index([name])
@@index([barcode]) @@index([barcode])
@@index([plantId])
@@index([pantryCategory])
} }
model Label { model Label {
@@ -383,6 +439,7 @@ enum ItemLogType {
PURCHASED PURCHASED
REPAIRED REPAIRED
DISPOSED DISPOSED
USED // consumable pulled from the pantry; `amount` holds how much
} }
model ItemLog { model ItemLog {
@@ -393,6 +450,8 @@ model ItemLog {
date DateTime @default(now()) date DateTime @default(now())
notes String? notes String?
cost Decimal? @db.Decimal(10, 2) cost Decimal? @db.Decimal(10, 2)
amount Decimal? @db.Decimal(10, 2) // quantity consumed, for USED logs (same unit as the item)
reason String? // disposition for USED logs: consumed | preserved | gifted | waste (see pantry/core.ts)
actorId String? actorId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())

14
scripts/reset-password.ts Normal file
View 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());

View 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&apos;s blooming and ready to pick.
</p>
</div>
<CalendarView tasks={calendarTasks} seasonTypes={seasonTypes} plantNames={plantNames} />
</div>
);
}

View File

@@ -2,7 +2,7 @@ import { getSession } from "@/lib/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import Link from "next/link"; import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Leaf, CalendarDays, Sprout } from "lucide-react"; import { Leaf, Sprout, AlertTriangle, UtensilsCrossed } from "lucide-react";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
export const metadata = { title: "Dashboard" }; export const metadata = { title: "Dashboard" };
@@ -10,13 +10,21 @@ export const metadata = { title: "Dashboard" };
export default async function DashboardPage() { export default async function DashboardPage() {
const session = await getSession(); const session = await getSession();
const [plantCount, recentLogs] = await Promise.all([ const thirtyDaysFromNow = new Date(Date.now() + 30 * 86_400_000);
const [plantCount, recentLogs, expiringItems] = await Promise.all([
db.plant.count({ where: { active: true } }), db.plant.count({ where: { active: true } }),
db.plantLog.findMany({ db.plantLog.findMany({
orderBy: { date: "desc" }, orderBy: { date: "desc" },
take: 5, take: 5,
include: { plant: { select: { commonName: true, variety: true } } }, include: { plant: { select: { commonName: true, variety: true } } },
}), }),
db.item.findMany({
where: { kind: "CONSUMABLE", active: true, bestBefore: { lte: thirtyDaysFromNow } },
orderBy: { bestBefore: "asc" },
select: { id: true, name: true, bestBefore: true, quantity: true, unit: true },
take: 5,
}),
]); ]);
const firstName = session?.user.name.split(" ")[0] ?? "there"; const firstName = session?.user.name.split(" ")[0] ?? "there";
@@ -42,6 +50,34 @@ export default async function DashboardPage() {
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
<Link href="/kitchen/pantry">
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<UtensilsCrossed className="h-4 w-4 text-amber-600" />
Kitchen
</CardTitle>
</CardHeader>
<CardContent>
{expiringItems.length === 0 ? (
<p className="text-sm text-muted-foreground">Nothing expiring soon</p>
) : (
<div className="space-y-1">
<p className="flex items-center gap-1.5 text-sm font-medium text-amber-700">
<AlertTriangle className="h-3.5 w-3.5" />
{expiringItems.length} expiring soon
</p>
{expiringItems.slice(0, 3).map(i => (
<p key={i.id} className="text-xs text-muted-foreground truncate">
{i.name} · {new Date(i.bestBefore!).toLocaleDateString()}
</p>
))}
</div>
)}
</CardContent>
</Card>
</Link>
</div> </div>
{recentLogs.length > 0 && ( {recentLogs.length > 0 && (

View File

@@ -3,7 +3,7 @@ import { db } from "@/lib/db";
import Link from "next/link"; import Link from "next/link";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { MapPin, CalendarDays, ChevronLeft, Sprout, Package, BookOpen } from "lucide-react"; import { MapPin, CalendarDays, ChevronLeft, Sprout, Package, BookOpen, GraduationCap } from "lucide-react";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import { CATEGORY_LABELS, LOG_TYPE_LABELS, LOG_TYPE_COLORS } from "@/lib/garden/categories"; import { CATEGORY_LABELS, LOG_TYPE_LABELS, LOG_TYPE_COLORS } from "@/lib/garden/categories";
import { AddLogButton } from "@/components/garden/add-log-button"; import { AddLogButton } from "@/components/garden/add-log-button";
@@ -22,7 +22,7 @@ export default async function PlantDetailPage({ params }: { params: { id: string
include: { include: {
logs: { orderBy: { date: "desc" } }, logs: { orderBy: { date: "desc" } },
location: { select: { id: true, name: true } }, location: { select: { id: true, name: true } },
plantType: { select: { id: true, commonName: true } }, plantType: { select: { id: true, commonName: true, uwExtensionUrl: true, uwExtensionTitle: true } },
}, },
}), }),
db.location.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } }), db.location.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
@@ -78,6 +78,19 @@ export default async function PlantDetailPage({ params }: { params: { id: string
</Link> </Link>
</div> </div>
)} )}
{plant.plantType?.uwExtensionUrl && (
<div className="flex items-center gap-2 text-muted-foreground">
<GraduationCap className="h-4 w-4 shrink-0" />
<a
href={plant.plantType.uwExtensionUrl}
target="_blank"
rel="noreferrer"
className="hover:text-foreground underline-offset-2 hover:underline truncate"
>
UW Extension: {plant.plantType.uwExtensionTitle || "article"}
</a>
</div>
)}
{plant.plantedAt && ( {plant.plantedAt && (
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<CalendarDays className="h-4 w-4 shrink-0" /> <CalendarDays className="h-4 w-4 shrink-0" />
@@ -99,7 +112,7 @@ export default async function PlantDetailPage({ params }: { params: { id: string
<div> <div>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h2 className="font-display text-lg font-semibold">Care &amp; harvest log</h2> <h2 className="font-display text-lg font-semibold">Care &amp; harvest log</h2>
<AddLogButton plantId={plant.id} plantName={plant.commonName} /> <AddLogButton plantId={plant.id} plantName={plant.commonName} locations={locations} />
</div> </div>
{plant.logs.length === 0 ? ( {plant.logs.length === 0 ? (
@@ -116,8 +129,10 @@ export default async function PlantDetailPage({ params }: { params: { id: string
<span className={`text-xs font-semibold uppercase tracking-wide ${LOG_TYPE_COLORS[log.type]}`}> <span className={`text-xs font-semibold uppercase tracking-wide ${LOG_TYPE_COLORS[log.type]}`}>
{LOG_TYPE_LABELS[log.type]} {LOG_TYPE_LABELS[log.type]}
</span> </span>
{log.quantity && ( {(log.amount != null || log.quantity) && (
<span className="text-xs text-muted-foreground">· {log.quantity}</span> <span className="text-xs text-muted-foreground">
· {log.amount != null ? `${Number(log.amount)} ${log.unit ?? ""}`.trim() : log.quantity}
</span>
)} )}
<span className="text-xs text-muted-foreground ml-auto">{formatDate(log.date)}</span> <span className="text-xs text-muted-foreground ml-auto">{formatDate(log.date)}</span>
</div> </div>

View File

@@ -1,11 +1,14 @@
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { AddPlantButton } from "@/components/garden/add-plant-button"; import { AddPlantButton } from "@/components/garden/add-plant-button";
import { GardenGrid } from "@/components/garden/garden-grid"; import { GardenGrid } from "@/components/garden/garden-grid";
import { GardenJournal } from "@/components/garden/garden-journal";
export const metadata = { title: "Garden" }; export const metadata = { title: "Garden" };
export const dynamic = "force-dynamic";
export default async function GardenPage() { export default async function GardenPage() {
const [plants, groups, locations] = await Promise.all([ const twoWeeksAgo = new Date(Date.now() - 14 * 86_400_000);
const [plants, groups, locations, recentLogs] = await Promise.all([
db.plant.findMany({ db.plant.findMany({
where: { active: true }, where: { active: true },
orderBy: [{ commonName: "asc" }], orderBy: [{ commonName: "asc" }],
@@ -26,10 +29,25 @@ export default async function GardenPage() {
orderBy: { name: "asc" }, orderBy: { name: "asc" },
select: { id: true, name: true }, select: { id: true, name: true },
}), }),
db.plantLog.findMany({
where: { date: { gte: twoWeeksAgo }, plant: { active: true } },
orderBy: { date: "desc" },
take: 10,
select: {
id: true, type: true, date: true, notes: true, quantity: true,
amount: true, unit: true,
plant: { select: { id: true, commonName: true, variety: true } },
},
}),
]); ]);
const journal = recentLogs.map((l) => ({
...l,
amount: l.amount != null ? Number(l.amount) : null,
}));
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="font-display text-2xl font-semibold">Food Forest</h1> <h1 className="font-display text-2xl font-semibold">Food Forest</h1>
@@ -40,7 +58,9 @@ export default async function GardenPage() {
<AddPlantButton groups={groups} locations={locations} /> <AddPlantButton groups={groups} locations={locations} />
</div> </div>
<GardenGrid plants={plants} groups={groups} /> <GardenGrid plants={plants} groups={groups} locations={locations} />
<GardenJournal entries={journal} />
</div> </div>
); );
} }

View File

@@ -5,8 +5,10 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { import {
ChevronLeft, Sun, Droplets, Flower2, Apple, AlertTriangle, Ruler, MapPin, Leaf, ChevronLeft, Sun, Droplets, Flower2, Apple, AlertTriangle, Ruler, MapPin, Leaf,
GraduationCap, ExternalLink, Sprout, ThumbsUp, ThumbsDown, Repeat,
} from "lucide-react"; } from "lucide-react";
import { CATEGORY_LABELS } from "@/lib/garden/categories"; import { CATEGORY_LABELS } from "@/lib/garden/categories";
import { knowledgeFor, uwSearchUrl, ROTATION_LABELS } from "@/lib/garden/knowledge";
import { EditPlantTypeButton, type PlantTypeFields } from "@/components/garden/plant-type-dialog"; import { EditPlantTypeButton, type PlantTypeFields } from "@/components/garden/plant-type-dialog";
import { DeletePlantTypeButton } from "@/components/garden/delete-plant-type-button"; import { DeletePlantTypeButton } from "@/components/garden/delete-plant-type-button";
@@ -24,6 +26,77 @@ export async function generateMetadata({ params }: { params: { id: string } }) {
return { title: t?.commonName ?? "Plant type" }; return { title: t?.commonName ?? "Plant type" };
} }
// Garden-brain card: companions, antagonists, rotation family, and the
// zone-4b care timeline for this kind of plant (when we know it).
function GrowingTogether({ name }: { name: string }) {
const k = knowledgeFor(name);
if (!k) return null;
const hasCompanions = (k.friends?.length ?? 0) > 0 || (k.foes?.length ?? 0) > 0;
return (
<Card>
<CardContent className="pt-4 space-y-3 text-sm">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Growing {k.name.toLowerCase()} in zone 4b
</p>
<a
href={uwSearchUrl(k.uwQuery)}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground underline-offset-2 hover:underline"
>
UW Extension <ExternalLink className="h-3 w-3" />
</a>
</div>
{k.tips.length > 0 && (
<ul className="space-y-1">
{k.tips.map((t) => (
<li key={t.action} className="flex items-start gap-2">
<Sprout className="h-3.5 w-3.5 mt-0.5 shrink-0 text-[hsl(var(--leaf))]" />
<span>
<span className="text-muted-foreground">{t.months.map((m) => MONTHS[m]).join(", ")}:</span>{" "}
{t.action}
{t.detail && <span className="text-muted-foreground"> {t.detail}</span>}
</span>
</li>
))}
</ul>
)}
{hasCompanions && (
<div className="flex flex-col gap-1 pt-2 border-t">
{k.friends && k.friends.length > 0 && (
<p className="flex items-start gap-2">
<ThumbsUp className="h-3.5 w-3.5 mt-0.5 shrink-0 text-[hsl(var(--leaf))]" />
<span><span className="text-muted-foreground">Grows well with:</span> {k.friends.join(", ")}</span>
</p>
)}
{k.foes && k.foes.length > 0 && (
<p className="flex items-start gap-2">
<ThumbsDown className="h-3.5 w-3.5 mt-0.5 shrink-0 text-[hsl(var(--ember))]" />
<span><span className="text-muted-foreground">Keep away from:</span> {k.foes.join(", ")}</span>
</p>
)}
</div>
)}
{(k.family || k.rotationNote) && (
<p className="flex items-start gap-2 pt-2 border-t">
<Repeat className="h-3.5 w-3.5 mt-0.5 shrink-0 text-muted-foreground" />
<span>
{k.family && <span className="text-muted-foreground">Rotation family: {ROTATION_LABELS[k.family]}.</span>}
{k.rotationNote && <span> {k.rotationNote}</span>}
</span>
</p>
)}
</CardContent>
</Card>
);
}
export default async function PlantTypeDetailPage({ params }: { params: { id: string } }) { export default async function PlantTypeDetailPage({ params }: { params: { id: string } }) {
const type = await db.plantType.findUnique({ const type = await db.plantType.findUnique({
where: { id: params.id }, where: { id: params.id },
@@ -56,6 +129,8 @@ export default async function PlantTypeDetailPage({ params }: { params: { id: st
toxicToPets: type.toxicToPets, toxicToPets: type.toxicToPets,
edibleParts: type.edibleParts, edibleParts: type.edibleParts,
spacing: type.spacing, spacing: type.spacing,
uwExtensionUrl: type.uwExtensionUrl,
uwExtensionTitle: type.uwExtensionTitle,
notes: type.notes, notes: type.notes,
}; };
@@ -113,6 +188,24 @@ export default async function PlantTypeDetailPage({ params }: { params: { id: st
</Card> </Card>
)} )}
{type.uwExtensionUrl && (
<a
href={type.uwExtensionUrl}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 text-sm rounded-lg border px-3 py-2.5 hover:bg-accent transition-colors"
>
<GraduationCap className="h-4 w-4 shrink-0 text-[hsl(var(--leaf))]" />
<span className="min-w-0 truncate">
<span className="text-muted-foreground">UW Extension:</span>{" "}
{type.uwExtensionTitle || "article"}
</span>
<ExternalLink className="h-3.5 w-3.5 ml-auto shrink-0 text-muted-foreground" />
</a>
)}
<GrowingTogether name={type.commonName} />
{(type.careNotes || type.notes) && ( {(type.careNotes || type.notes) && (
<Card> <Card>
<CardContent className="pt-4 space-y-3 text-sm"> <CardContent className="pt-4 space-y-3 text-sm">

View File

@@ -5,13 +5,16 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { import {
ChevronLeft, MapPin, Box, Package, CalendarDays, DollarSign, Hash, Tag, FileText, Barcode, ChevronLeft, MapPin, Box, Package, CalendarDays, DollarSign, Hash, Tag, FileText, Barcode,
Store, ShieldCheck, Wrench, AlertCircle,
} from "lucide-react"; } from "lucide-react";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import { warrantyStatus, WARRANTY_LABELS } from "@/lib/inventory/value"; import { warrantyStatus, WARRANTY_LABELS } from "@/lib/inventory/value";
import { ITEM_LOG_LABELS, ITEM_LOG_COLORS } from "@/lib/inventory/item-fields"; import { ITEM_LOG_LABELS, ITEM_LOG_COLORS } from "@/lib/inventory/item-fields";
import { isOverdue, describeRecurrence } from "@/lib/tasks/schedule";
import { EditItemButton, type ItemFields } from "@/components/inventory/item-dialog"; import { EditItemButton, type ItemFields } from "@/components/inventory/item-dialog";
import { DeleteItemButton } from "@/components/inventory/delete-item-button"; import { DeleteItemButton } from "@/components/inventory/delete-item-button";
import { AddItemLogButton } from "@/components/inventory/add-item-log-button"; import { AddItemLogButton } from "@/components/inventory/add-item-log-button";
import { ScheduleMaintenanceButton } from "@/components/inventory/schedule-maintenance-button";
import { AttachmentUpload } from "@/components/inventory/attachment-upload"; import { AttachmentUpload } from "@/components/inventory/attachment-upload";
function toDateInput(d: Date | null): string { function toDateInput(d: Date | null): string {
@@ -34,6 +37,7 @@ export default async function ItemDetailPage({ params }: { params: { id: string
attachments: { orderBy: { createdAt: "asc" } }, attachments: { orderBy: { createdAt: "asc" } },
logs: { orderBy: { date: "desc" } }, logs: { orderBy: { date: "desc" } },
containedItems: { where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }, containedItems: { where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } },
tasks: { where: { active: true }, orderBy: { nextDue: "asc" } },
}, },
}), }),
db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }), db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
@@ -43,7 +47,8 @@ export default async function ItemDetailPage({ params }: { params: { id: string
if (!item || !item.active) notFound(); if (!item || !item.active) notFound();
const wStatus = warrantyStatus(item.warrantyExpiry, new Date()); const wStatus = item.lifetimeWarranty ? "active" : warrantyStatus(item.warrantyExpiry, new Date());
const warrantyLabel = item.lifetimeWarranty ? "Lifetime warranty" : WARRANTY_LABELS[wStatus];
const photos = item.attachments.filter((a) => a.kind === "PHOTO"); const photos = item.attachments.filter((a) => a.kind === "PHOTO");
const docs = item.attachments.filter((a) => a.kind !== "PHOTO"); const docs = item.attachments.filter((a) => a.kind !== "PHOTO");
@@ -60,7 +65,16 @@ export default async function ItemDetailPage({ params }: { params: { id: string
serial: item.serial ?? "", serial: item.serial ?? "",
value: item.value != null ? String(Number(item.value)) : "", value: item.value != null ? String(Number(item.value)) : "",
purchaseDate: toDateInput(item.purchaseDate), purchaseDate: toDateInput(item.purchaseDate),
purchaseFrom: item.purchaseFrom ?? "",
warrantyExpiry: toDateInput(item.warrantyExpiry), warrantyExpiry: toDateInput(item.warrantyExpiry),
warrantyDetails: item.warrantyDetails ?? "",
lifetimeWarranty: item.lifetimeWarranty,
insured: item.insured,
soldTo: item.soldTo ?? "",
soldPrice: item.soldPrice != null ? String(Number(item.soldPrice)) : "",
soldDate: toDateInput(item.soldDate),
soldNotes: item.soldNotes ?? "",
customFields: Object.entries((item.customFields as Record<string, string>) ?? {}).map(([key, value]) => ({ key, value: String(value) })),
barcode: item.barcode ?? "", barcode: item.barcode ?? "",
notes: item.notes ?? "", notes: item.notes ?? "",
labelIds: item.labels.map((l) => l.labelId), labelIds: item.labels.map((l) => l.labelId),
@@ -72,6 +86,9 @@ export default async function ItemDetailPage({ params }: { params: { id: string
if (Number(item.quantity) !== 1) facts.push({ icon: <Package className="h-4 w-4" />, label: "Quantity", value: `${Number(item.quantity)}${item.unit ? ` ${item.unit}` : ""}` }); if (Number(item.quantity) !== 1) facts.push({ icon: <Package className="h-4 w-4" />, label: "Quantity", value: `${Number(item.quantity)}${item.unit ? ` ${item.unit}` : ""}` });
if (item.value != null) facts.push({ icon: <DollarSign className="h-4 w-4" />, label: "Value", value: Number(item.value).toLocaleString(undefined, { style: "currency", currency: "USD" }) }); if (item.value != null) facts.push({ icon: <DollarSign className="h-4 w-4" />, label: "Value", value: Number(item.value).toLocaleString(undefined, { style: "currency", currency: "USD" }) });
if (item.purchaseDate) facts.push({ icon: <CalendarDays className="h-4 w-4" />, label: "Purchased", value: formatDate(item.purchaseDate) }); if (item.purchaseDate) facts.push({ icon: <CalendarDays className="h-4 w-4" />, label: "Purchased", value: formatDate(item.purchaseDate) });
if (item.purchaseFrom) facts.push({ icon: <Store className="h-4 w-4" />, label: "From", value: item.purchaseFrom });
if (item.lifetimeWarranty || item.warrantyExpiry) facts.push({ icon: <ShieldCheck className="h-4 w-4" />, label: "Warranty", value: item.lifetimeWarranty ? "Lifetime" : `until ${formatDate(item.warrantyExpiry!)}` });
if (item.insured) facts.push({ icon: <ShieldCheck className="h-4 w-4" />, label: "Insured", value: "Yes" });
if (item.brand) facts.push({ icon: <Tag className="h-4 w-4" />, label: "Brand", value: item.brand }); if (item.brand) facts.push({ icon: <Tag className="h-4 w-4" />, label: "Brand", value: item.brand });
if (item.modelNumber) facts.push({ icon: <Hash className="h-4 w-4" />, label: "Model", value: item.modelNumber }); if (item.modelNumber) facts.push({ icon: <Hash className="h-4 w-4" />, label: "Model", value: item.modelNumber });
if (item.serial) facts.push({ icon: <Hash className="h-4 w-4" />, label: "Serial", value: item.serial }); if (item.serial) facts.push({ icon: <Hash className="h-4 w-4" />, label: "Serial", value: item.serial });
@@ -87,14 +104,15 @@ export default async function ItemDetailPage({ params }: { params: { id: string
<h1 className="font-display text-2xl font-semibold">{item.name}</h1> <h1 className="font-display text-2xl font-semibold">{item.name}</h1>
{item.description && <p className="text-sm text-muted-foreground">{item.description}</p>} {item.description && <p className="text-sm text-muted-foreground">{item.description}</p>}
</div> </div>
{wStatus !== "none" && ( {(item.lifetimeWarranty || wStatus !== "none") && (
<Badge variant="outline" className="ml-auto shrink-0">{WARRANTY_LABELS[wStatus]}</Badge> <Badge variant="outline" className="ml-auto shrink-0">{warrantyLabel}</Badge>
)} )}
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<EditItemButton item={editFields} locations={locations} items={allItems.filter((i) => i.id !== item.id)} labels={labels} /> <EditItemButton item={editFields} locations={locations} items={allItems.filter((i) => i.id !== item.id)} labels={labels} />
<AddItemLogButton itemId={item.id} /> <AddItemLogButton itemId={item.id} />
<ScheduleMaintenanceButton itemId={item.id} itemName={item.name} />
<AttachmentUpload itemId={item.id} /> <AttachmentUpload itemId={item.id} />
<DeleteItemButton id={item.id} name={item.name} /> <DeleteItemButton id={item.id} name={item.name} />
</div> </div>
@@ -134,6 +152,71 @@ export default async function ItemDetailPage({ params }: { params: { id: string
<Card><CardContent className="pt-4 text-sm whitespace-pre-wrap">{item.notes}</CardContent></Card> <Card><CardContent className="pt-4 text-sm whitespace-pre-wrap">{item.notes}</CardContent></Card>
)} )}
{item.warrantyDetails && (
<Card>
<CardContent className="pt-4 text-sm">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Warranty</p>
<p className="whitespace-pre-wrap">{item.warrantyDetails}</p>
</CardContent>
</Card>
)}
{item.customFields && Object.keys(item.customFields as Record<string, string>).length > 0 && (
<Card>
<CardContent className="pt-4 grid grid-cols-2 gap-3 text-sm">
{Object.entries(item.customFields as Record<string, string>).map(([k, v]) => (
<div key={k} className="min-w-0">
<p className="text-xs text-muted-foreground">{k}</p>
<p className="truncate">{String(v)}</p>
</div>
))}
</CardContent>
</Card>
)}
{item.soldTo && (
<Card>
<CardContent className="pt-4 text-sm space-y-1">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Sold</p>
<p>
To {item.soldTo}
{item.soldPrice != null && ` for ${Number(item.soldPrice).toLocaleString(undefined, { style: "currency", currency: "USD" })}`}
{item.soldDate && ` on ${formatDate(item.soldDate)}`}
</p>
{item.soldNotes && <p className="text-muted-foreground">{item.soldNotes}</p>}
</CardContent>
</Card>
)}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<h2 className="font-display text-lg font-semibold">Maintenance</h2>
</div>
{item.tasks.length === 0 ? (
<p className="text-sm text-muted-foreground">
No maintenance scheduled. Use &ldquo;Schedule maintenance&rdquo; above to set a recurring reminder.
</p>
) : (
<div className="divide-y border rounded-lg overflow-hidden">
{item.tasks.map((t) => {
const overdue = isOverdue(t.nextDue);
return (
<div key={t.id} className="flex items-center gap-2 px-3 py-2.5 text-sm">
<Wrench className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="flex-1 min-w-0 truncate">{t.title}
<span className="text-xs text-muted-foreground"> · {describeRecurrence(t.recurrence, t.intervalDays, t.month)}</span>
</span>
<span className={`flex items-center gap-1 text-xs shrink-0 ${overdue ? "text-[hsl(var(--ember))]" : "text-muted-foreground"}`}>
{overdue && <AlertCircle className="h-3 w-3" />}
{overdue ? "Overdue " : "Due "}{formatDate(t.nextDue)}
</span>
</div>
);
})}
</div>
)}
</div>
{docs.length > 0 && ( {docs.length > 0 && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<h2 className="font-display text-sm font-semibold">Documents</h2> <h2 className="font-display text-sm font-semibold">Documents</h2>

View File

@@ -1,5 +1,6 @@
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { AddItemButton } from "@/components/inventory/item-dialog"; import { AddItemButton } from "@/components/inventory/item-dialog";
import { ImportButton } from "@/components/inventory/import-button";
import { InventoryGrid, type GridItem } from "@/components/inventory/inventory-grid"; import { InventoryGrid, type GridItem } from "@/components/inventory/inventory-grid";
import { totalValue } from "@/lib/inventory/value"; import { totalValue } from "@/lib/inventory/value";
@@ -53,8 +54,11 @@ export default async function InventoryPage() {
{total > 0 && ` · ${total.toLocaleString(undefined, { style: "currency", currency: "USD" })} total value`} {total > 0 && ` · ${total.toLocaleString(undefined, { style: "currency", currency: "USD" })} total value`}
</p> </p>
</div> </div>
<div className="flex items-center gap-2">
<ImportButton />
<AddItemButton locations={locations} items={itemOpts} labels={labels} /> <AddItemButton locations={locations} items={itemOpts} labels={labels} />
</div> </div>
</div>
<InventoryGrid items={gridItems} /> <InventoryGrid items={gridItems} />
</div> </div>

View 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} />;
}

View File

@@ -1,5 +1,7 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth"; import { getSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { SuggestionLightbulb } from "@otm/account-panel";
import { Sidebar } from "@/components/layout/sidebar"; import { Sidebar } from "@/components/layout/sidebar";
import { TopNav } from "@/components/layout/topnav"; import { TopNav } from "@/components/layout/topnav";
import { Footer } from "@/components/layout/footer"; import { Footer } from "@/components/layout/footer";
@@ -7,10 +9,18 @@ import { Footer } from "@/components/layout/footer";
export default async function DashboardLayout({ children }: { children: React.ReactNode }) { export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const session = await getSession(); const session = await getSession();
if (!session) redirect("/login"); if (!session) redirect("/login");
// Force a password change before anything else (provisioned accounts ship with // Force a password change before anything else (provisioned accounts ship with
// a known default + mustChangePassword=true). The target page is outside this // a known default + mustChangePassword=true). Read the flag from the DB, not the
// layout group, so there's no redirect loop. // JWT: the cookie is only refreshed on re-login/update(), so trusting it would
if (session.user.mustChangePassword) redirect("/change-password"); // keep bouncing the user back here even after they've set a new password. The
// target page is outside this layout group, so there's no redirect loop.
const account = await db.user.findUnique({
where: { id: session.user.id },
select: { active: true, mustChangePassword: true },
});
if (!account || !account.active) redirect("/login");
if (account.mustChangePassword) redirect("/change-password");
return ( return (
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
@@ -22,6 +32,11 @@ export default async function DashboardLayout({ children }: { children: React.Re
</div> </div>
</div> </div>
<Footer /> <Footer />
{/* Floating quick-capture idea/feedback widget (parity with dms + fmb).
Mounted at the shell root — a fixed-position child of a backdrop-blur
ancestor would get trapped. Posts to /api/suggestions → files a Gitea
issue; "View all →" links to the full triage page. */}
<SuggestionLightbulb panelHref="/suggestions" />
</div> </div>
); );
} }

View 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>
);
}

View 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)}
/>
);
}

View 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&apos;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>
);
}

View File

@@ -9,5 +9,8 @@ export const dynamic = "force-dynamic";
export default async function SuggestionsPage() { export default async function SuggestionsPage() {
const session = await getSession(); const session = await getSession();
if (!session) redirect("/login"); if (!session) redirect("/login");
// Render the panel bare (matches FMB). Earlier versions needed a white-card
// wrapper for contrast, but the current @otm/account-panel is theme-aware —
// the wrapper was fighting its own styles and hiding image thumbnails.
return <SuggestionsPanel />; return <SuggestionsPanel />;
} }

View File

@@ -28,7 +28,12 @@ export default async function TasksPage() {
const tasks = await db.task.findMany({ const tasks = await db.task.findMany({
where: { active: true }, where: { active: true },
orderBy: { nextDue: "asc" }, orderBy: { nextDue: "asc" },
include: { plant: { select: { id: true, commonName: true, variety: true } } }, include: {
plant: { select: { id: true, commonName: true, variety: true } },
item: { select: { id: true, name: true } },
animal: { select: { id: true, name: true } },
location: { select: { id: true, name: true, path: true } },
},
}); });
const overdue = tasks.filter((t) => isOverdue(t.nextDue)); const overdue = tasks.filter((t) => isOverdue(t.nextDue));
@@ -104,6 +109,9 @@ function Section({ title, icon: Icon, iconClass, children }: {
function TaskCard({ task, overdue = false }: { function TaskCard({ task, overdue = false }: {
task: Awaited<ReturnType<typeof db.task.findMany>>[0] & { task: Awaited<ReturnType<typeof db.task.findMany>>[0] & {
plant: { id: string; commonName: string; variety: string | null } | null; plant: { id: string; commonName: string; variety: string | null } | null;
item: { id: string; name: string } | null;
animal: { id: string; name: string } | null;
location: { id: string; name: string; path: string | null } | null;
}; };
overdue?: boolean; overdue?: boolean;
}) { }) {
@@ -124,10 +132,23 @@ function TaskCard({ task, overdue = false }: {
</div> </div>
{task.plant && ( {task.plant && (
<Link href={`/garden/${task.plant.id}`} className="text-xs text-[hsl(var(--leaf))] hover:underline"> <Link href={`/garden/${task.plant.id}`} className="text-xs text-[hsl(var(--leaf))] hover:underline block">
{task.plant.commonName}{task.plant.variety ? ` · ${task.plant.variety}` : ""} {task.plant.commonName}{task.plant.variety ? ` · ${task.plant.variety}` : ""}
</Link> </Link>
)} )}
{task.item && (
<Link href={`/inventory/${task.item.id}`} className="text-xs text-[hsl(var(--leaf))] hover:underline block">
{task.item.name}
</Link>
)}
{task.animal && (
<Link href={`/animals/${task.animal.id}`} className="text-xs text-[hsl(var(--leaf))] hover:underline block">
{task.animal.name}
</Link>
)}
{task.location && (
<span className="text-xs text-muted-foreground">📍 {task.location.path || task.location.name}</span>
)}
{task.notes && ( {task.notes && (
<p className="text-xs text-muted-foreground mt-0.5">{task.notes}</p> <p className="text-xs text-muted-foreground mt-0.5">{task.notes}</p>

View 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 });
}
}

View 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);
}
}

View File

@@ -23,11 +23,14 @@ const updateSchema = z.object({
edibleParts: z.string().optional(), edibleParts: z.string().optional(),
spacing: z.string().optional(), spacing: z.string().optional(),
imageUrl: z.string().optional(), imageUrl: z.string().optional(),
uwExtensionUrl: z.string().optional(),
uwExtensionTitle: z.string().optional(),
notes: z.string().optional(), notes: z.string().optional(),
}); });
const TEXT_FIELDS = [ const TEXT_FIELDS = [
"species", "careNotes", "sun", "water", "edibleParts", "spacing", "imageUrl", "notes", "species", "careNotes", "sun", "water", "edibleParts", "spacing", "imageUrl",
"uwExtensionUrl", "uwExtensionTitle", "notes",
] as const; ] as const;
export async function PATCH(req: Request, { params }: { params: { id: string } }) { export async function PATCH(req: Request, { params }: { params: { id: string } }) {

View File

@@ -17,10 +17,12 @@ const createSchema = z.object({
bloomEnd: month.optional(), bloomEnd: month.optional(),
harvestStart: month.optional(), harvestStart: month.optional(),
harvestEnd: month.optional(), harvestEnd: month.optional(),
toxicToPets: z.boolean().optional(), toxicToPets: z.boolean().nullable().optional(), // "Unknown" in the dialog arrives as null
edibleParts: z.string().optional(), edibleParts: z.string().optional(),
spacing: z.string().optional(), spacing: z.string().optional(),
imageUrl: z.string().optional(), imageUrl: z.string().optional(),
uwExtensionUrl: z.string().optional(),
uwExtensionTitle: z.string().optional(),
notes: z.string().optional(), notes: z.string().optional(),
}); });
@@ -66,6 +68,8 @@ export async function POST(req: Request) {
edibleParts: data.edibleParts?.trim() || null, edibleParts: data.edibleParts?.trim() || null,
spacing: data.spacing?.trim() || null, spacing: data.spacing?.trim() || null,
imageUrl: data.imageUrl?.trim() || null, imageUrl: data.imageUrl?.trim() || null,
uwExtensionUrl: data.uwExtensionUrl?.trim() || null,
uwExtensionTitle: data.uwExtensionTitle?.trim() || null,
notes: data.notes?.trim() || null, notes: data.notes?.trim() || null,
}, },
}); });

View 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 });
}
}

View 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 });
}

View File

@@ -6,7 +6,7 @@ import { authenticateRequest, handleApiError, ApiError } from "@/lib/api/authent
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const KINDS = new Set(["PHOTO", "MANUAL", "RECEIPT"]); const KINDS = new Set(["PHOTO", "MANUAL", "RECEIPT", "WARRANTY"]);
// POST /api/v1/items/:id/attachments — multipart upload (file, kind). Files land // POST /api/v1/items/:id/attachments — multipart upload (file, kind). Files land
// under public/uploads/items (a persistent Docker volume), same as suggestions. // under public/uploads/items (a persistent Docker volume), same as suggestions.

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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 UWMadison
// 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);
}
}

View File

@@ -14,6 +14,11 @@
@layer base { @layer base {
:root { :root {
/* Force native form controls (input/textarea/select) to the light theme so
* they don't pick up the OS dark fill — otherwise an unstyled <input> renders
* with a dark system background in a dark-mode browser, hiding placeholder
* text. .dark overrides this below. */
color-scheme: light;
--dew: 120 18% 94%; /* #EEF3EE — pale sage background */ --dew: 120 18% 94%; /* #EEF3EE — pale sage background */
--dew-deep: 120 20% 97%; /* slightly lighter for cards */ --dew-deep: 120 20% 97%; /* slightly lighter for cards */
--soil: 25 35% 18%; /* #3A2B1A — warm dark brown text */ --soil: 25 35% 18%; /* #3A2B1A — warm dark brown text */
@@ -58,6 +63,7 @@
} }
.dark { .dark {
color-scheme: dark;
--dew: 140 18% 10%; --dew: 140 18% 10%;
--dew-deep: 140 18% 12%; --dew-deep: 140 18% 12%;
--soil: 120 18% 88%; --soil: 120 18% 88%;

View File

@@ -74,6 +74,11 @@ function LoginForm() {
<Button type="submit" className="w-full" disabled={busy}> <Button type="submit" className="w-full" disabled={busy}>
{busy ? "Signing in…" : "Sign in"} {busy ? "Signing in…" : "Sign in"}
</Button> </Button>
<p className="text-center text-xs text-muted-foreground">
<a href="/recover" className="underline underline-offset-2 hover:text-foreground">
Forgot password?
</a>
</p>
</form> </form>
</CardContent> </CardContent>
</Card> </Card>

121
src/app/recover/page.tsx Normal file
View 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>
);
}

View 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; // 112
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>
);
}

View File

@@ -24,24 +24,33 @@ const LOG_TYPE_ORDER: PlantLogType[] = [
"OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED", "OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED",
]; ];
const UNIT_SUGGESTIONS = ["lbs", "oz", "each", "pints", "quarts", "gallons", "bunches", "heads", "cups"];
const schema = z.object({ const schema = z.object({
type: z.nativeEnum(PlantLogType), type: z.nativeEnum(PlantLogType),
date: z.string().optional(), date: z.string().optional(),
notes: z.string().optional(), notes: z.string().optional(),
quantity: z.string().optional(), amount: z.string().optional(), // form keeps strings; parsed on submit
unit: z.string().optional(),
}); });
type FormValues = z.infer<typeof schema>; type FormValues = z.infer<typeof schema>;
interface Props { interface Props {
plantId: string; plantId: string;
plantName: string; plantName: string;
locations: { id: string; name: string }[];
} }
export function AddLogButton({ plantId, plantName }: Props) { const NO_LOCATION = "__none__";
export function AddLogButton({ plantId, plantName, locations }: Props) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
const [logType, setLogType] = useState<PlantLogType>("OBSERVATION"); const [logType, setLogType] = useState<PlantLogType>("OBSERVATION");
const [toPantry, setToPantry] = useState(false);
const [pantryLocationId, setPantryLocationId] = useState<string>(NO_LOCATION);
const [bestBefore, setBestBefore] = useState("");
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
@@ -51,21 +60,50 @@ export function AddLogButton({ plantId, plantName }: Props) {
}, },
}); });
function resetAll() {
form.reset({ type: "OBSERVATION", date: new Date().toISOString().split("T")[0] });
setLogType("OBSERVATION");
setToPantry(false);
setPantryLocationId(NO_LOCATION);
setBestBefore("");
}
async function onSubmit(values: FormValues) { async function onSubmit(values: FormValues) {
const res = await fetch(`/api/plants/${plantId}/logs`, { const isHarvest = values.type === "HARVEST";
const amount = isHarvest && values.amount ? parseFloat(values.amount) : null;
if (isHarvest && values.amount && (amount == null || isNaN(amount) || amount < 0)) {
toast({ title: "Enter the amount as a number", description: "e.g. 2 or 2.5 — pick a unit next to it.", variant: "destructive" });
return;
}
const res = await fetch(`/api/v1/plants/${plantId}/logs`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(values), body: JSON.stringify({
type: values.type,
date: values.date,
notes: values.notes,
amount: amount != null && !isNaN(amount) ? amount : undefined,
unit: isHarvest ? values.unit || undefined : undefined,
pantry: isHarvest && toPantry
? {
locationId: pantryLocationId === NO_LOCATION ? null : pantryLocationId,
bestBefore: bestBefore || null,
}
: undefined,
}),
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
toast({ title: "Error", description: err.error ?? "Could not save log entry.", variant: "destructive" }); toast({ title: "Error", description: err.error ?? "Could not save log entry.", variant: "destructive" });
return; return;
} }
toast({ title: "Log entry saved!" }); toast({
title: "Log entry saved!",
description: isHarvest && toPantry ? "Also added to Pantry & Freezer." : undefined,
});
setOpen(false); setOpen(false);
form.reset({ type: "OBSERVATION", date: new Date().toISOString().split("T")[0] }); resetAll();
setLogType("OBSERVATION");
router.refresh(); router.refresh();
} }
@@ -110,10 +148,67 @@ export function AddLogButton({ plantId, plantName }: Props) {
</div> </div>
{logType === "HARVEST" && ( {logType === "HARVEST" && (
<>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="quantity">How much did you harvest?</Label> <Label>How much did you harvest?</Label>
<Input id="quantity" placeholder="2 lbs, 1 gallon, a big handful…" {...form.register("quantity")} /> <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>
<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 &amp; Freezer
</label>
{toPantry && (
<div className="space-y-2 pt-1">
<div className="space-y-1">
<Label className="text-xs">Where will it be stored?</Label>
<Select value={pantryLocationId} onValueChange={setPantryLocationId}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value={NO_LOCATION}>No specific spot</SelectItem>
{locations.map((l) => (
<SelectItem key={l.id} value={l.id}>{l.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="best-before" className="text-xs">Best before (optional)</Label>
<Input
id="best-before"
type="date"
value={bestBefore}
onChange={(e) => setBestBefore(e.target.value)}
/>
</div>
</div>
)}
</div>
</>
)} )}
<div className="space-y-1.5"> <div className="space-y-1.5">

View File

@@ -1,15 +1,16 @@
"use client"; "use client";
import { useState } from "react"; // The garden "shelf": every plant visible as a photo card with one-tap
// logging. Search + chips replace the old collapse trees.
import { useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input";
import { Leaf, MapPin, CalendarDays, ChevronDown, ChevronRight, Users } from "lucide-react"; import { Leaf, MapPin, Search, Users } from "lucide-react";
import { formatDate } from "@/lib/utils";
import { CATEGORY_LABELS } from "@/lib/garden/categories";
import { GroupLogButton } from "@/components/garden/group-log-button"; import { GroupLogButton } from "@/components/garden/group-log-button";
import { LocationLogButton } from "@/components/garden/location-log-button"; import { LocationLogButton } from "@/components/garden/location-log-button";
import { QuickLogActions } from "@/components/garden/quick-log-actions";
import { cn } from "@/lib/utils";
type Plant = { type Plant = {
id: string; id: string;
@@ -27,41 +28,55 @@ type Plant = {
logs: { date: Date; type: string }[]; logs: { date: Date; type: string }[];
}; };
type Group = { id: string; name: string; _count: { plants: number } };
type LocationOption = { id: string; name: string };
// Dual-read (Release A): prefer the Location name, fall back to the legacy zone. // Dual-read (Release A): prefer the Location name, fall back to the legacy zone.
function placeName(p: Plant): string | null { function placeName(p: Plant): string | null {
return p.location?.name ?? p.zone ?? null; return p.location?.name ?? p.zone ?? null;
} }
type Group = { const LAST_VERB: Record<string, string> = {
id: string; OBSERVATION: "noted",
name: string; CARE: "tended",
_count: { plants: number }; HARVEST: "picked",
TREATMENT: "treated",
TRANSPLANT: "moved",
DIED: "died",
}; };
type ViewMode = "type" | "location"; function lastActivity(p: Plant): string | null {
const log = p.logs[0];
// Cluster plants that share the same commonName + variety into one row. if (!log) return null;
function clusterByVariety(plants: Plant[]) { const days = Math.floor((Date.now() - new Date(log.date).getTime()) / 86_400_000);
const map = new Map<string, Plant[]>(); const when = days <= 0 ? "today" : days === 1 ? "yesterday" : `${days}d ago`;
for (const p of plants) { return `${LAST_VERB[log.type] ?? "logged"} ${when}`;
const key = `${p.commonName}||${p.variety ?? ""}`;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(p);
}
return Array.from(map.values());
} }
export function GardenGrid({ plants, groups }: { plants: Plant[]; groups: Group[] }) { export function GardenGrid({ plants, groups, locations }: {
const [expanded, setExpanded] = useState<Set<string>>(new Set()); plants: Plant[];
const [view, setView] = useState<ViewMode>("type"); groups: Group[];
locations: LocationOption[];
}) {
const [search, setSearch] = useState("");
const [chip, setChip] = useState<string>("all"); // "all" | "place" | group id
function toggle(id: string) { const filtered = useMemo(() => {
setExpanded((prev) => { const q = search.trim().toLowerCase();
const next = new Set(prev); let list = plants;
next.has(id) ? next.delete(id) : next.add(id); if (chip !== "all" && chip !== "place") list = list.filter((p) => p.group?.id === chip);
return next; if (q) {
}); list = list.filter((p) =>
p.commonName.toLowerCase().includes(q) ||
p.variety?.toLowerCase().includes(q) ||
p.species?.toLowerCase().includes(q) ||
placeName(p)?.toLowerCase().includes(q),
);
} }
return [...list].sort((a, b) => a.commonName.localeCompare(b.commonName));
}, [plants, search, chip]);
const activeGroup = groups.find((g) => g.id === chip);
if (plants.length === 0) { if (plants.length === 0) {
return ( return (
@@ -74,137 +89,78 @@ export function GardenGrid({ plants, groups }: { plants: Plant[]; groups: Group[
} }
return ( return (
<div className="space-y-6"> <div className="space-y-4">
<div className="flex items-center gap-1 p-1 bg-muted rounded-lg w-fit"> <div className="relative">
<Button size="sm" variant={view === "type" ? "secondary" : "ghost"} className="h-7 text-xs" onClick={() => setView("type")}> <Search className="h-4 w-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
By type <Input
</Button> value={search}
<Button size="sm" variant={view === "location" ? "secondary" : "ghost"} className="h-7 text-xs" onClick={() => setView("location")}> onChange={(e) => setSearch(e.target.value)}
By location placeholder="Find a plant…"
</Button> className="pl-9"
/>
</div> </div>
{view === "type" ? ( <div className="flex items-center gap-1.5 flex-wrap">
<TypeView plants={plants} groups={groups} expanded={expanded} toggle={toggle} /> <Chip active={chip === "all"} onClick={() => setChip("all")}>
) : ( All · {plants.length}
<LocationView plants={plants} expanded={expanded} toggle={toggle} /> </Chip>
)} {groups.filter((g) => g._count.plants > 0).map((g) => (
</div> <Chip key={g.id} active={chip === g.id} onClick={() => setChip(g.id)}>
); {g.name}
} </Chip>
function TypeView({ plants, groups, expanded, toggle }: {
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> <Chip active={chip === "place"} onClick={() => setChip("place")}>
) : ( <MapPin className="h-3 w-3" /> By place
<button onClick={() => toggle(g.id)} className="pl-6 text-sm text-muted-foreground hover:text-foreground transition-colors"> </Chip>
{clusters.map((c) => c[0].variety ? `${c[0].commonName} (${c[0].variety})` : c[0].commonName).join(", ")} {activeGroup && (
</button> <span className="ml-auto">
)} <GroupLogButton groupId={activeGroup.id} groupName={activeGroup.name} />
</div>
);
})}
{ungrouped.length > 0 && (
<div className="space-y-2">
{grouped.length > 0 && <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Other plants</p>}
<div className="space-y-2">
{clusterByVariety(ungrouped).map((cluster) => (
<VarietyRow key={`${cluster[0].commonName}||${cluster[0].variety}`} plants={cluster} expanded={expanded} toggle={toggle} />
))}
</div>
</div>
)}
</div>
);
}
// A single variety that may span multiple locations.
function VarietyRow({ plants, expanded, toggle }: {
plants: Plant[];
expanded: Set<string>;
toggle: (id: string) => void;
}) {
const rep = plants[0];
const key = `variety||${rep.commonName}||${rep.variety ?? ""}`;
const isOpen = expanded.has(key);
// Collapsible row with location chips.
return (
<div className="space-y-2">
<button
onClick={() => toggle(key)}
className="flex items-start gap-2 text-left hover:text-foreground transition-colors w-full"
>
{isOpen ? <ChevronDown className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" /> : <ChevronRight className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />}
<div className="flex-1 min-w-0">
<span className="font-medium text-sm">
{rep.commonName}
{rep.variety && <span className="text-muted-foreground font-normal"> · {rep.variety}</span>}
</span> </span>
{!isOpen && (
<div className="flex flex-wrap gap-1 mt-1">
{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> </div>
</button>
{isOpen && ( {chip === "place" ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 pl-6"> <PlaceSections plants={filtered} locations={locations} />
{plants.map((p) => <PlantCard key={p.id} plant={p} />)} ) : (
</div> <CardGrid plants={filtered} locations={locations} />
)}
{filtered.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
No plants match{search ? `${search.trim()}` : ""}.
</p>
)} )}
</div> </div>
); );
} }
function LocationView({ plants, expanded, toggle }: { function Chip({ active, onClick, children }: {
plants: Plant[]; active: boolean; onClick: () => void; children: React.ReactNode;
expanded: Set<string>;
toggle: (id: string) => void;
}) { }) {
// Group by the shared Location (id). Plants not yet on the spine but with a return (
// legacy zone string still group by that name so nothing disappears mid-migration. <button
onClick={onClick}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs transition-colors",
active
? "bg-[hsl(var(--leaf))] text-white border-[hsl(var(--leaf))]"
: "bg-background hover:bg-accent text-muted-foreground",
)}
>
{children}
</button>
);
}
function CardGrid({ plants, locations }: { plants: Plant[]; locations: LocationOption[] }) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{plants.map((p) => <PlantCard key={p.id} plant={p} locations={locations} />)}
</div>
);
}
function PlaceSections({ plants, locations }: { plants: Plant[]; locations: LocationOption[] }) {
type Place = { key: string; locationId: string | null; name: string; plants: Plant[] }; type Place = { key: string; locationId: string | null; name: string; plants: Plant[] };
const byKey = new Map<string, Place>(); const byKey = new Map<string, Place>();
const noPlace: Plant[] = []; const noPlace: Plant[] = [];
@@ -216,110 +172,81 @@ function LocationView({ plants, expanded, toggle }: {
if (!byKey.has(key)) byKey.set(key, { key, locationId: p.locationId, name, plants: [] }); if (!byKey.has(key)) byKey.set(key, { key, locationId: p.locationId, name, plants: [] });
byKey.get(key)!.plants.push(p); byKey.get(key)!.plants.push(p);
} }
const places = Array.from(byKey.values()).sort((a, b) => a.name.localeCompare(b.name)); const places = Array.from(byKey.values()).sort((a, b) => a.name.localeCompare(b.name));
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{places.map((place) => { {places.map((place) => (
const isOpen = expanded.has(place.key);
return (
<div key={place.key} className="space-y-2"> <div key={place.key} className="space-y-2">
<div className="flex items-center gap-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))]" /> <MapPin className="h-4 w-4 text-[hsl(var(--leaf))]" />
<span className="font-display font-semibold">{place.name}</span> <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> <span className="text-sm text-muted-foreground">
</button> · {place.plants.length} {place.plants.length === 1 ? "plant" : "plants"}
</span>
{place.locationId && <LocationLogButton locationId={place.locationId} locationName={place.name} />} {place.locationId && <LocationLogButton locationId={place.locationId} locationName={place.name} />}
</div> </div>
{isOpen ? ( <CardGrid plants={place.plants} locations={locations} />
<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> </div>
) : ( ))}
<button onClick={() => toggle(place.key)} className="pl-6 text-sm text-muted-foreground hover:text-foreground transition-colors">
{place.plants.map((p) => p.variety ? `${p.commonName} (${p.variety})` : p.commonName).join(", ")}
</button>
)}
</div>
);
})}
{noPlace.length > 0 && ( {noPlace.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
{places.length > 0 && <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">No location set</p>} <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">No location set</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <CardGrid plants={noPlace} locations={locations} />
{noPlace.map((p) => <PlantCard key={p.id} plant={p} />)}
</div>
</div> </div>
)} )}
</div> </div>
); );
} }
function PlantCard({ plant }: { plant: Plant }) { function PlantCard({ plant, locations }: { plant: Plant; locations: LocationOption[] }) {
const place = placeName(plant);
const activity = lastActivity(plant);
return ( return (
<Link href={`/garden/${plant.id}`}> <div className="rounded-lg border bg-card overflow-hidden hover:shadow-md transition-shadow flex flex-col">
<Card className="hover:shadow-md transition-shadow h-full cursor-pointer overflow-hidden"> <Link href={`/garden/${plant.id}`} className="block group">
{plant.imageUrl && ( {plant.imageUrl ? (
<div className="h-36 w-full overflow-hidden bg-muted"> <div className="h-28 w-full overflow-hidden bg-muted">
<img <img
src={plant.imageUrl} src={plant.imageUrl}
alt={plant.commonName} alt={plant.commonName}
className="h-full w-full object-cover" className="h-full w-full object-cover group-hover:scale-[1.03] transition-transform"
/> />
</div> </div>
)} ) : (
{!plant.imageUrl && ( <div className="h-28 w-full bg-[hsl(var(--leaf)/.08)] flex items-center justify-center">
<div className="h-36 w-full bg-[hsl(var(--leaf)/.08)] flex items-center justify-center"> <Leaf className="h-9 w-9 text-[hsl(var(--leaf)/.3)]" />
<Leaf className="h-10 w-10 text-[hsl(var(--leaf)/.3)]" />
</div> </div>
)} )}
<CardContent className="pt-3 pb-4 space-y-2"> </Link>
<div className="flex items-start justify-between gap-2"> <div className="p-2.5 pt-2 flex-1 flex flex-col gap-1.5">
<div className="min-w-0"> <Link href={`/garden/${plant.id}`} className="min-w-0">
<p className="font-medium truncate"> <p className="font-medium text-sm truncate leading-tight">
{plant.commonName} {plant.commonName}
{plant.variety && <span className="text-muted-foreground font-normal"> · {plant.variety}</span>} {plant.variety && <span className="text-muted-foreground font-normal"> · {plant.variety}</span>}
</p> </p>
{plant.species && <p className="text-xs text-muted-foreground italic truncate">{plant.species}</p>}
</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>
<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>
); );
} }

View 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>
);
}

View File

@@ -14,9 +14,11 @@ import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { Plus, Pencil } from "lucide-react"; import { Plus, Pencil, Search, X, ExternalLink } from "lucide-react";
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories"; import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
type UwMatch = { id: number; title: string; url: string; excerpt: string };
export type PlantTypeFields = { export type PlantTypeFields = {
id?: string; id?: string;
commonName: string; commonName: string;
@@ -32,6 +34,8 @@ export type PlantTypeFields = {
toxicToPets: boolean | null; toxicToPets: boolean | null;
edibleParts: string | null; edibleParts: string | null;
spacing: string | null; spacing: string | null;
uwExtensionUrl: string | null;
uwExtensionTitle: string | null;
notes: string | null; notes: string | null;
}; };
@@ -43,7 +47,8 @@ function blank(): PlantTypeFields {
commonName: "", species: null, category: "OTHER", careNotes: null, commonName: "", species: null, category: "OTHER", careNotes: null,
sun: null, water: null, bloomStart: null, bloomEnd: null, sun: null, water: null, bloomStart: null, bloomEnd: null,
harvestStart: null, harvestEnd: null, toxicToPets: null, harvestStart: null, harvestEnd: null, toxicToPets: null,
edibleParts: null, spacing: null, notes: null, edibleParts: null, spacing: null, uwExtensionUrl: null,
uwExtensionTitle: null, notes: null,
}; };
} }
@@ -73,6 +78,8 @@ function PlantTypeDialog({
const [f, setF] = useState<PlantTypeFields>(initial); const [f, setF] = useState<PlantTypeFields>(initial);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [uwMatches, setUwMatches] = useState<UwMatch[] | null>(null);
const [uwBusy, setUwBusy] = useState(false);
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
@@ -80,6 +87,28 @@ function PlantTypeDialog({
setF((p) => ({ ...p, [key]: value })); setF((p) => ({ ...p, [key]: value }));
} }
async function searchUw() {
const q = f.commonName.trim();
if (!q) { setError("Enter the plant name first, then look it up."); return; }
setError("");
setUwBusy(true);
try {
const res = await fetch(`/api/v1/plant-types/uw-search?q=${encodeURIComponent(q)}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Lookup failed");
setUwMatches(data);
} catch (err) {
toast({ title: "UW Extension lookup failed", description: String(err).replace("Error: ", ""), variant: "destructive" });
} finally {
setUwBusy(false);
}
}
function pickUw(m: UwMatch) {
setF((p) => ({ ...p, uwExtensionUrl: m.url, uwExtensionTitle: m.title }));
setUwMatches(null);
}
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!f.commonName.trim()) { setError("Please enter the plant name."); return; } if (!f.commonName.trim()) { setError("Please enter the plant name."); return; }
@@ -95,6 +124,8 @@ function PlantTypeDialog({
water: f.water ?? "", water: f.water ?? "",
edibleParts: f.edibleParts ?? "", edibleParts: f.edibleParts ?? "",
spacing: f.spacing ?? "", spacing: f.spacing ?? "",
uwExtensionUrl: f.uwExtensionUrl ?? "",
uwExtensionTitle: f.uwExtensionTitle ?? "",
notes: f.notes ?? "", notes: f.notes ?? "",
toxicToPets: f.toxicToPets, toxicToPets: f.toxicToPets,
}; };
@@ -217,6 +248,56 @@ function PlantTypeDialog({
placeholder="Fruit, young leaves" /> placeholder="Fruit, young leaves" />
</div> </div>
<div className="col-span-2 space-y-1.5">
<Label>UW Extension article</Label>
{f.uwExtensionUrl ? (
<div className="flex items-center gap-2 text-sm border rounded-md px-3 py-2">
<a
href={f.uwExtensionUrl}
target="_blank"
rel="noreferrer"
className="flex-1 min-w-0 truncate underline underline-offset-2 hover:text-foreground text-muted-foreground"
>
{f.uwExtensionTitle || f.uwExtensionUrl}
</a>
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<button
type="button"
title="Remove link"
className="text-muted-foreground hover:text-destructive shrink-0"
onClick={() => setF((p) => ({ ...p, uwExtensionUrl: null, uwExtensionTitle: null }))}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<Button type="button" variant="outline" size="sm" onClick={searchUw} disabled={uwBusy} className="gap-1.5">
<Search className="h-3.5 w-3.5" />
{uwBusy ? "Searching…" : "Look up on UW Extension"}
</Button>
)}
{uwMatches && (
<div className="border rounded-md divide-y max-h-48 overflow-y-auto">
{uwMatches.length === 0 && (
<p className="text-xs text-muted-foreground p-3">
No UW Extension articles matched {f.commonName.trim()}.
</p>
)}
{uwMatches.map((m) => (
<button
key={m.id}
type="button"
onClick={() => pickUw(m)}
className="w-full text-left p-2.5 hover:bg-accent transition-colors"
>
<p className="text-sm font-medium">{m.title}</p>
{m.excerpt && <p className="text-xs text-muted-foreground line-clamp-2">{m.excerpt}</p>}
</button>
))}
</div>
)}
</div>
<div className="col-span-2 space-y-1.5"> <div className="col-span-2 space-y-1.5">
<Label>Care notes</Label> <Label>Care notes</Label>
<Textarea rows={3} value={f.careNotes ?? ""} onChange={(e) => set("careNotes", e.target.value)} <Textarea rows={3} value={f.careNotes ?? ""} onChange={(e) => set("careNotes", e.target.value)}

View 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 &amp; 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>
</>
);
}

View File

@@ -3,33 +3,61 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { ImagePlus } from "lucide-react"; import { Paperclip, ChevronDown } from "lucide-react";
const KINDS: { value: string; label: string; accept: string }[] = [
{ value: "PHOTO", label: "Photo", accept: "image/*" },
{ value: "MANUAL", label: "Manual", accept: ".pdf,image/*" },
{ value: "RECEIPT", label: "Receipt", accept: ".pdf,image/*" },
{ value: "WARRANTY", label: "Warranty doc", accept: ".pdf,image/*" },
];
export function AttachmentUpload({ itemId }: { itemId: string }) { export function AttachmentUpload({ itemId }: { itemId: string }) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [kind, setKind] = useState("PHOTO");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
function pick(k: string) {
setKind(k);
// Let state settle, then open the picker with the right accept filter.
requestAnimationFrame(() => inputRef.current?.click());
}
async function onFile(file: File) { async function onFile(file: File) {
setBusy(true); setBusy(true);
const form = new FormData(); const form = new FormData();
form.append("file", file); form.append("file", file);
form.append("kind", "PHOTO"); form.append("kind", kind);
const res = await fetch(`/api/v1/items/${itemId}/attachments`, { method: "POST", body: form }); const res = await fetch(`/api/v1/items/${itemId}/attachments`, { method: "POST", body: form });
setBusy(false); setBusy(false);
if (res.ok) { toast({ title: "Photo added" }); router.refresh(); } if (res.ok) { toast({ title: "Attached" }); router.refresh(); }
else toast({ title: "Error", description: "Upload failed.", variant: "destructive" }); else toast({ title: "Error", description: "Upload failed.", variant: "destructive" });
} }
const accept = KINDS.find((k) => k.value === kind)?.accept ?? "*";
return ( return (
<> <>
<input ref={inputRef} type="file" accept="image/*" className="hidden" <input ref={inputRef} type="file" accept={accept} className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) onFile(f); e.target.value = ""; }} /> onChange={(e) => { const f = e.target.files?.[0]; if (f) onFile(f); e.target.value = ""; }} />
<Button size="sm" variant="outline" disabled={busy} onClick={() => inputRef.current?.click()}> <DropdownMenu>
<ImagePlus className="h-4 w-4 mr-1" /> {busy ? "Uploading…" : "Add photo"} <DropdownMenuTrigger asChild>
<Button 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> </Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{KINDS.map((k) => (
<DropdownMenuItem key={k.value} onClick={() => pick(k.value)}>{k.label}</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</> </>
); );
} }

View 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&apos;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&apos;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>
</>
);
}

View File

@@ -1,10 +1,11 @@
"use client"; "use client";
import { useState } from "react"; import { useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
Package, MapPin, ChevronDown, ChevronRight, Box, ShieldCheck, ShieldAlert, ShieldX, Package, MapPin, ChevronDown, ChevronRight, Box, ShieldCheck, ShieldAlert, ShieldX,
Search, Tag,
} from "lucide-react"; } from "lucide-react";
import { warrantyStatus } from "@/lib/inventory/value"; import { warrantyStatus } from "@/lib/inventory/value";
@@ -37,6 +38,31 @@ function WarrantyBadge({ expiry }: { expiry: string | Date | null }) {
return <Badge variant="outline" className={`gap-1 text-[10px] ${map.cls}`}><Icon className="h-2.5 w-2.5" />{map.label}</Badge>; return <Badge variant="outline" className={`gap-1 text-[10px] ${map.cls}`}><Icon className="h-2.5 w-2.5" />{map.label}</Badge>;
} }
function ItemThumb({ item, hasKids }: { item: GridItem; hasKids: boolean }) {
if (item.imageUrl) {
// eslint-disable-next-line @next/next/no-img-element
return <img src={item.imageUrl} alt="" className="h-8 w-8 rounded object-cover shrink-0" />;
}
return (
<span className="h-8 w-8 rounded bg-[hsl(var(--leaf)/.08)] flex items-center justify-center shrink-0">
{hasKids ? <Box className="h-4 w-4 text-[hsl(var(--leaf)/.5)]" /> : <Package className="h-4 w-4 text-[hsl(var(--leaf)/.5)]" />}
</span>
);
}
function ItemBadges({ item }: { item: GridItem }) {
return (
<div className="flex items-center gap-1.5 shrink-0">
{item.labels.slice(0, 3).map((l) => (
<Badge key={l.label.id} variant="secondary" className="text-[10px]">{l.label.name}</Badge>
))}
<WarrantyBadge expiry={item.warrantyExpiry} />
</div>
);
}
// Tree row — used when grouping by location with no active search. Shows nested
// container contents under an expand toggle.
function ItemRow({ item, childrenByParent, depth }: { function ItemRow({ item, childrenByParent, depth }: {
item: GridItem; item: GridItem;
childrenByParent: Map<string, GridItem[]>; childrenByParent: Map<string, GridItem[]>;
@@ -55,14 +81,7 @@ function ItemRow({ item, childrenByParent, depth }: {
</button> </button>
) : <span className="w-4 shrink-0" />} ) : <span className="w-4 shrink-0" />}
{item.imageUrl ? ( <ItemThumb item={item} hasKids={kids.length > 0} />
// eslint-disable-next-line @next/next/no-img-element
<img src={item.imageUrl} alt="" className="h-8 w-8 rounded object-cover shrink-0" />
) : (
<span className="h-8 w-8 rounded bg-[hsl(var(--leaf)/.08)] flex items-center justify-center shrink-0">
{kids.length > 0 ? <Box className="h-4 w-4 text-[hsl(var(--leaf)/.5)]" /> : <Package className="h-4 w-4 text-[hsl(var(--leaf)/.5)]" />}
</span>
)}
<Link href={`/inventory/${item.id}`} className="min-w-0 flex-1"> <Link href={`/inventory/${item.id}`} className="min-w-0 flex-1">
<span className="font-medium text-sm">{item.name}</span> <span className="font-medium text-sm">{item.name}</span>
@@ -70,12 +89,7 @@ function ItemRow({ item, childrenByParent, depth }: {
{kids.length > 0 && <span className="text-xs text-muted-foreground"> · holds {kids.length}</span>} {kids.length > 0 && <span className="text-xs text-muted-foreground"> · holds {kids.length}</span>}
</Link> </Link>
<div className="flex items-center gap-1.5 shrink-0"> <ItemBadges item={item} />
{item.labels.slice(0, 3).map((l) => (
<Badge key={l.label.id} variant="secondary" className="text-[10px]">{l.label.name}</Badge>
))}
<WarrantyBadge expiry={item.warrantyExpiry} />
</div>
</div> </div>
{open && kids.map((k) => ( {open && kids.map((k) => (
<ItemRow key={k.id} item={k} childrenByParent={childrenByParent} depth={depth + 1} /> <ItemRow key={k.id} item={k} childrenByParent={childrenByParent} depth={depth + 1} />
@@ -84,7 +98,101 @@ function ItemRow({ item, childrenByParent, depth }: {
); );
} }
// Flat row — used for search results and label grouping, where the container
// tree doesn't apply. Shows a location/container hint instead of nesting.
function FlatRow({ item, hint }: { item: GridItem; hint: string | null }) {
const qty = Number(item.quantity);
return (
<div className="flex items-center gap-2 px-3 py-2.5 hover:bg-accent transition-colors">
<span className="w-4 shrink-0" />
<ItemThumb item={item} hasKids={item._count.containedItems > 0} />
<Link href={`/inventory/${item.id}`} className="min-w-0 flex-1">
<span className="font-medium text-sm">{item.name}</span>
{qty !== 1 && <span className="text-xs text-muted-foreground"> · {qty}{item.unit ? ` ${item.unit}` : ""}</span>}
{hint && <span className="block text-xs text-muted-foreground truncate">{hint}</span>}
</Link>
<ItemBadges item={item} />
</div>
);
}
function Group({ icon: Icon, name, count, children }: {
icon: typeof MapPin; name: string; count: number; children: React.ReactNode;
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium">
<Icon className="h-4 w-4 text-[hsl(var(--leaf))]" />
<span className="font-display">{name}</span>
<span className="text-xs text-muted-foreground font-normal">· {count}</span>
</div>
<div className="divide-y border rounded-lg overflow-hidden">{children}</div>
</div>
);
}
type GroupBy = "location" | "label";
// The place an item physically sits in. An item lives directly in a Place, or
// inside another Item that does — so for contained items (own location null) we
// walk up the container chain to the nearest ancestor that has a location. The
// `seen` guard keeps a corrupt cycle from looping forever.
function effectiveLocation(
it: GridItem,
byId: Map<string, GridItem>,
): { id: string; name: string } | null {
const seen = new Set<string>();
let cur: GridItem | undefined = it;
while (cur) {
if (cur.location) return cur.location;
if (!cur.parentItemId || seen.has(cur.id)) break;
seen.add(cur.id);
cur = byId.get(cur.parentItemId);
}
return null;
}
export function InventoryGrid({ items }: { items: GridItem[] }) { export function InventoryGrid({ items }: { items: GridItem[] }) {
const [query, setQuery] = useState("");
const [groupBy, setGroupBy] = useState<GroupBy>("location");
const q = query.trim().toLowerCase();
// Item lookup by id — powers the "inside X" hint and the container-chain walk
// that resolves a contained item's effective location.
const byId = useMemo(() => {
const m = new Map<string, GridItem>();
for (const it of items) m.set(it.id, it);
return m;
}, [items]);
// Match name, effective location, or any label so search finds things by where
// they live (including inside a container) or how they're tagged, not just by name.
const matched = useMemo(
() =>
items.filter((it) => {
if (!q) return true;
const loc = effectiveLocation(it, byId);
return (
it.name.toLowerCase().includes(q) ||
(loc?.name.toLowerCase().includes(q) ?? false) ||
it.labels.some((l) => l.label.name.toLowerCase().includes(q))
);
}),
[items, q, byId],
);
// Container nesting only makes sense in the unfiltered location tree; a search
// or a label view shows a flat list, so we tree only in that one case.
const treeView = groupBy === "location" && !q;
const hintFor = (it: GridItem): string | null => {
const parent = it.parentItemId ? byId.get(it.parentItemId) : undefined;
if (parent) return `in ${parent.name}`;
return it.location ? it.location.name : null;
};
// Zero inventory: show just the empty state, no search/toggle chrome to operate on.
if (items.length === 0) { if (items.length === 0) {
return ( return (
<div className="text-center py-16 text-muted-foreground"> <div className="text-center py-16 text-muted-foreground">
@@ -95,7 +203,55 @@ export function InventoryGrid({ items }: { items: GridItem[] }) {
); );
} }
// Items contained inside another item are rendered under their parent. const empty = (
<div className="text-center py-16 text-muted-foreground">
<Package className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p className="font-medium">Nothing matches</p>
<p className="text-sm mt-1">Try a different search or grouping.</p>
</div>
);
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row gap-2 sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search items, locations, or labels…"
className="w-full rounded-md border bg-background pl-9 pr-3 py-2 text-sm"
/>
</div>
<div className="flex items-center rounded-md border p-0.5 text-sm shrink-0">
{(["location", "label"] as const).map((g) => (
<button
key={g}
onClick={() => setGroupBy(g)}
className={`flex items-center gap-1.5 rounded px-3 py-1.5 transition-colors ${
groupBy === g ? "bg-[hsl(var(--leaf))] text-white" : "text-muted-foreground hover:text-foreground"
}`}
>
{g === "location" ? <MapPin className="h-3.5 w-3.5" /> : <Tag className="h-3.5 w-3.5" />}
{g === "location" ? "Location" : "Label"}
</button>
))}
</div>
</div>
{matched.length === 0 ? empty : treeView ? (
<LocationTree items={matched} />
) : groupBy === "location" ? (
<FlatByLocation items={matched} byId={byId} hintFor={hintFor} />
) : (
<FlatByLabel items={matched} hintFor={hintFor} />
)}
</div>
);
}
// Default view: top-level items grouped by location, container contents nested.
function LocationTree({ items }: { items: GridItem[] }) {
const childrenByParent = new Map<string, GridItem[]>(); const childrenByParent = new Map<string, GridItem[]>();
for (const it of items) { for (const it of items) {
if (it.parentItemId) { if (it.parentItemId) {
@@ -105,8 +261,11 @@ export function InventoryGrid({ items }: { items: GridItem[] }) {
} }
} }
// Top-level items (not inside another item) grouped by location. // Top-level = items with no parent, plus "orphans" whose parent isn't in the
const topLevel = items.filter((i) => !i.parentItemId); // set (e.g. a soft-deleted container) — otherwise those still-active children
// would render nowhere, since nothing left recurses into them.
const present = new Set(items.map((i) => i.id));
const topLevel = items.filter((i) => !i.parentItemId || !present.has(i.parentItemId));
const groups = new Map<string, { name: string; items: GridItem[] }>(); const groups = new Map<string, { name: string; items: GridItem[] }>();
const noLoc: GridItem[] = []; const noLoc: GridItem[] = [];
for (const it of topLevel) { for (const it of topLevel) {
@@ -115,30 +274,91 @@ export function InventoryGrid({ items }: { items: GridItem[] }) {
g.items.push(it); g.items.push(it);
groups.set(it.location.id, g); groups.set(it.location.id, g);
} }
const sorted = Array.from(groups.values()).sort((a, b) => a.name.localeCompare(b.name)); const sorted = Array.from(groups.entries()).sort((a, b) => a[1].name.localeCompare(b[1].name));
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{sorted.map((g) => ( {sorted.map(([id, g]) => (
<div key={g.name} className="space-y-1.5"> <Group key={id} icon={MapPin} name={g.name} count={g.items.length}>
<div className="flex items-center gap-1.5 text-sm font-medium">
<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} />)} {g.items.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
</div> </Group>
</div>
))} ))}
{noLoc.length > 0 &&
{noLoc.length > 0 && ( (sorted.length > 0 ? (
<div className="space-y-1.5"> <Group icon={MapPin} name="No location" count={noLoc.length}>
{sorted.length > 0 && <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">No location</p>} {noLoc.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
</Group>
) : (
<div className="divide-y border rounded-lg overflow-hidden"> <div className="divide-y border rounded-lg overflow-hidden">
{noLoc.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)} {noLoc.map((it) => <ItemRow key={it.id} item={it} childrenByParent={childrenByParent} depth={0} />)}
</div> </div>
))}
</div> </div>
);
}
// Flat results grouped by location (every matching item is its own row). Groups
// key on the effective location id — so a contained item shows under its
// container's place (matching the tree view), and two same-named places stay
// separate instead of merging.
function FlatByLocation({ items, byId, hintFor }: {
items: GridItem[];
byId: Map<string, GridItem>;
hintFor: (i: GridItem) => string | null;
}) {
const groups = new Map<string, { name: string; items: GridItem[] }>();
const noLoc: GridItem[] = [];
for (const it of items) {
const loc = effectiveLocation(it, byId);
if (!loc) { noLoc.push(it); continue; }
const g = groups.get(loc.id) ?? { name: loc.name, items: [] };
g.items.push(it);
groups.set(loc.id, g);
}
const sorted = Array.from(groups.entries()).sort((a, b) => a[1].name.localeCompare(b[1].name));
return (
<div className="space-y-6">
{sorted.map(([id, g]) => (
<Group key={id} icon={MapPin} name={g.name} count={g.items.length}>
{g.items.map((it) => <FlatRow key={it.id} item={it} hint={it.parentItemId ? hintFor(it) : null} />)}
</Group>
))}
{noLoc.length > 0 && (
<Group icon={MapPin} name="No location" count={noLoc.length}>
{noLoc.map((it) => <FlatRow key={it.id} item={it} hint={it.parentItemId ? hintFor(it) : null} />)}
</Group>
)}
</div>
);
}
// Flat results grouped by label. An item with multiple labels appears under each;
// items with no labels collect under a synthetic "No labels" group. Real-label
// groups key on label id, so two same-named labels stay separate and a user label
// named "No labels" can't merge into the synthetic bucket.
function FlatByLabel({ items, hintFor }: { items: GridItem[]; hintFor: (i: GridItem) => string | null }) {
const groups = new Map<string, { name: string; items: GridItem[] }>();
const unlabeled: GridItem[] = [];
for (const it of items) {
if (it.labels.length === 0) { unlabeled.push(it); continue; }
for (const { label } of it.labels) {
const g = groups.get(label.id) ?? { name: label.name, items: [] };
g.items.push(it);
groups.set(label.id, g);
}
}
const sorted = Array.from(groups.entries()).sort((a, b) => a[1].name.localeCompare(b[1].name));
return (
<div className="space-y-6">
{sorted.map(([id, g]) => (
<Group key={id} icon={Tag} name={g.name} count={g.items.length}>
{g.items.map((it) => <FlatRow key={it.id} item={it} hint={hintFor(it)} />)}
</Group>
))}
{unlabeled.length > 0 && (
<Group icon={Tag} name="No labels" count={unlabeled.length}>
{unlabeled.map((it) => <FlatRow key={it.id} item={it} hint={hintFor(it)} />)}
</Group>
)} )}
</div> </div>
); );

View File

@@ -33,7 +33,16 @@ export type ItemFields = {
serial: string; serial: string;
value: string; value: string;
purchaseDate: string; purchaseDate: string;
purchaseFrom: string;
warrantyExpiry: string; warrantyExpiry: string;
warrantyDetails: string;
lifetimeWarranty: boolean;
insured: boolean;
soldTo: string;
soldPrice: string;
soldDate: string;
soldNotes: string;
customFields: { key: string; value: string }[];
barcode: string; barcode: string;
notes: string; notes: string;
labelIds: string[]; labelIds: string[];
@@ -42,7 +51,9 @@ export type ItemFields = {
export function blankItem(): ItemFields { export function blankItem(): ItemFields {
return { return {
name: "", description: "", quantity: "1", unit: "", locationId: "", parentItemId: "", name: "", description: "", quantity: "1", unit: "", locationId: "", parentItemId: "",
brand: "", modelNumber: "", serial: "", value: "", purchaseDate: "", warrantyExpiry: "", brand: "", modelNumber: "", serial: "", value: "", purchaseDate: "", purchaseFrom: "",
warrantyExpiry: "", warrantyDetails: "", lifetimeWarranty: false, insured: false,
soldTo: "", soldPrice: "", soldDate: "", soldNotes: "", customFields: [],
barcode: "", notes: "", labelIds: [], barcode: "", notes: "", labelIds: [],
}; };
} }
@@ -109,7 +120,18 @@ function ItemDialog({
serial: f.serial.trim() || undefined, serial: f.serial.trim() || undefined,
value: f.value ? Number(f.value) : null, value: f.value ? Number(f.value) : null,
purchaseDate: f.purchaseDate || null, purchaseDate: f.purchaseDate || null,
purchaseFrom: f.purchaseFrom.trim() || undefined,
warrantyExpiry: f.warrantyExpiry || null, warrantyExpiry: f.warrantyExpiry || null,
warrantyDetails: f.warrantyDetails.trim() || undefined,
lifetimeWarranty: f.lifetimeWarranty,
insured: f.insured,
soldTo: f.soldTo.trim() || undefined,
soldPrice: f.soldPrice ? Number(f.soldPrice) : null,
soldDate: f.soldDate || null,
soldNotes: f.soldNotes.trim() || undefined,
customFields: Object.fromEntries(
f.customFields.map((c) => [c.key.trim(), c.value.trim()]).filter(([k, v]) => k && v),
),
barcode: f.barcode.trim() || undefined, barcode: f.barcode.trim() || undefined,
notes: f.notes.trim() || undefined, notes: f.notes.trim() || undefined,
labelIds: f.labelIds, labelIds: f.labelIds,
@@ -197,7 +219,26 @@ function ItemDialog({
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Warranty until</Label> <Label>Warranty until</Label>
<Input type="date" value={f.warrantyExpiry} onChange={(e) => set("warrantyExpiry", e.target.value)} /> <Input type="date" value={f.warrantyExpiry} onChange={(e) => set("warrantyExpiry", e.target.value)} disabled={f.lifetimeWarranty} />
</div>
<div className="col-span-2 space-y-1.5">
<Label>Purchased from</Label>
<Input value={f.purchaseFrom} onChange={(e) => set("purchaseFrom", e.target.value)} placeholder="Home Depot, Amazon, gift…" />
</div>
<div className="col-span-2 space-y-1.5">
<Label>Warranty details</Label>
<Textarea rows={2} value={f.warrantyDetails} onChange={(e) => set("warrantyDetails", e.target.value)} placeholder="Coverage, claim info…" />
</div>
<div className="col-span-2 flex flex-wrap gap-x-6 gap-y-2">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={f.lifetimeWarranty} onChange={(e) => set("lifetimeWarranty", e.target.checked)} className="accent-[hsl(var(--leaf))]" />
Lifetime warranty
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={f.insured} onChange={(e) => set("insured", e.target.checked)} className="accent-[hsl(var(--leaf))]" />
Insured
</label>
</div> </div>
<div className="col-span-2 space-y-1.5"> <div className="col-span-2 space-y-1.5">
@@ -219,6 +260,31 @@ function ItemDialog({
</div> </div>
</div> </div>
<div className="col-span-2 space-y-1.5">
<Label>Custom fields</Label>
{f.customFields.map((cf, i) => (
<div key={i} className="flex gap-2">
<Input placeholder="Name (VIN…)" value={cf.key}
onChange={(e) => setF((p) => ({ ...p, customFields: p.customFields.map((c, j) => j === i ? { ...c, key: e.target.value } : c) }))} />
<Input placeholder="Value" value={cf.value}
onChange={(e) => setF((p) => ({ ...p, customFields: p.customFields.map((c, j) => j === i ? { ...c, value: e.target.value } : c) }))} />
<Button type="button" variant="outline" size="sm" className="px-2"
onClick={() => setF((p) => ({ ...p, customFields: p.customFields.filter((_, j) => j !== i) }))}>×</Button>
</div>
))}
<button type="button" className="text-sm text-[hsl(var(--leaf))] hover:underline"
onClick={() => setF((p) => ({ ...p, customFields: [...p.customFields, { key: "", value: "" }] }))}>
+ Add field
</button>
</div>
<div className="col-span-2 grid grid-cols-2 gap-3">
<div className="space-y-1.5"><Label>Sold to</Label><Input value={f.soldTo} onChange={(e) => set("soldTo", e.target.value)} placeholder="(if sold)" /></div>
<div className="space-y-1.5"><Label>Sold price ($)</Label><Input type="number" min="0" step="0.01" value={f.soldPrice} onChange={(e) => set("soldPrice", e.target.value)} /></div>
<div className="space-y-1.5"><Label>Sold date</Label><Input type="date" value={f.soldDate} onChange={(e) => set("soldDate", e.target.value)} /></div>
<div className="space-y-1.5"><Label>Sold notes</Label><Input value={f.soldNotes} onChange={(e) => set("soldNotes", e.target.value)} /></div>
</div>
<div className="col-span-2 space-y-1.5"> <div className="col-span-2 space-y-1.5">
<Label>Notes</Label> <Label>Notes</Label>
<Textarea rows={2} value={f.notes} onChange={(e) => set("notes", e.target.value)} /> <Textarea rows={2} value={f.notes} onChange={(e) => set("notes", e.target.value)} />

View 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>
</>
);
}

View 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>
)}
</>
);
}

View 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 + "/")),
);
}

View File

@@ -2,27 +2,9 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive, Lightbulb, RefreshCw, BookOpen, KeyRound, Package, PawPrint } from "lucide-react"; import { Leaf } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { navItems, kitchenItems, settingsItems, isNavActive } from "./nav-items";
const navItems = [
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ label: "Garden", href: "/garden", icon: Leaf },
{ label: "Plant types", href: "/garden/types", icon: BookOpen },
{ label: "Inventory", href: "/inventory", icon: Package },
{ label: "Animals", href: "/animals", icon: PawPrint },
{ label: "Reminders", href: "/tasks", icon: Bell },
// Home maintenance and Equipment come in future releases
// { label: "Home", href: "/home", icon: Home },
// { label: "Equipment", href: "/equipment", icon: Wrench },
];
const settingsItems = [
{ label: "Suggestions", href: "/suggestions", icon: Lightbulb },
{ label: "API tokens", href: "/settings/tokens", icon: KeyRound },
{ label: "Backup & Restore", href: "/settings/backup", icon: HardDrive },
{ label: "Updates", href: "/settings/updates", icon: RefreshCw },
];
export function Sidebar() { export function Sidebar() {
const path = usePathname(); const path = usePathname();
@@ -38,14 +20,7 @@ export function Sidebar() {
<nav className="flex-1 px-2 py-3 space-y-0.5"> <nav className="flex-1 px-2 py-3 space-y-0.5">
{navItems.map(({ label, href, icon: Icon }) => { {navItems.map(({ label, href, icon: Icon }) => {
// Active on exact match or a sub-path, unless a more specific nav item const active = isNavActive(path, href, navItems);
// (e.g. /garden/types) already claims the current path.
const moreSpecific = navItems.some(
(o) => o.href.length > href.length && (path === o.href || path.startsWith(o.href + "/"))
);
const active =
path === href ||
(href !== "/dashboard" && path.startsWith(href + "/") && !moreSpecific);
return ( return (
<Link <Link
key={href} key={href}
@@ -64,6 +39,24 @@ export function Sidebar() {
})} })}
</nav> </nav>
<div className="px-2 py-2 border-t border-[hsl(var(--canopy-border))] space-y-0.5">
<p className="px-3 pt-1 pb-0.5 text-[10px] uppercase tracking-widest text-[hsl(var(--canopy-foreground))]/40">Kitchen</p>
{kitchenItems.map(({ label, href, icon: Icon }) => {
const active = path.startsWith(href);
return (
<Link key={href} href={href} className={cn(
"flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors",
active
? "bg-[hsl(var(--canopy-accent))] text-[hsl(var(--canopy-accent-foreground))]"
: "hover:bg-[hsl(var(--canopy-accent))] hover:text-[hsl(var(--canopy-accent-foreground))] text-[hsl(var(--canopy-foreground))]/80"
)}>
<Icon className="h-4 w-4 shrink-0" />
{label}
</Link>
);
})}
</div>
<div className="px-2 py-2 border-t border-[hsl(var(--canopy-border))] space-y-0.5"> <div className="px-2 py-2 border-t border-[hsl(var(--canopy-border))] space-y-0.5">
{settingsItems.map(({ label, href, icon: Icon }) => { {settingsItems.map(({ label, href, icon: Icon }) => {
const active = path.startsWith(href); const active = path.startsWith(href);

View 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>
);
}

View File

@@ -1,7 +1,8 @@
"use client"; "use client";
import Link from "next/link";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import { LogOut, User } from "lucide-react"; import { LogOut, Lightbulb, KeyRound } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@@ -10,6 +11,8 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { MobileNav } from "@/components/layout/mobile-nav";
import { ThemeToggle } from "@/components/layout/theme-toggle";
import { initials } from "@/lib/utils"; import { initials } from "@/lib/utils";
interface TopNavProps { interface TopNavProps {
@@ -19,11 +22,22 @@ interface TopNavProps {
export function TopNav({ user }: TopNavProps) { export function TopNav({ user }: TopNavProps) {
return ( return (
<header className="h-14 border-b flex items-center justify-between px-4 md:px-6 shrink-0 bg-background"> <header className="h-14 border-b flex items-center justify-between px-4 md:px-6 shrink-0 bg-background">
<div className="flex items-center gap-2 md:hidden"> <div className="flex items-center gap-2">
<span className="font-display font-semibold">Moon Base</span> <MobileNav />
<span className="font-display font-semibold md:hidden">Moon Base</span>
</div> </div>
<div className="hidden md:block" /> <div className="flex items-center gap-1">
<ThemeToggle />
<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> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -35,16 +49,21 @@ export function TopNav({ user }: TopNavProps) {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<div className="px-2 py-1.5 text-xs text-muted-foreground"> <div className="px-2 py-1.5 text-xs text-muted-foreground">{user.email}</div>
{user.email}
</div>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/change-password">
<KeyRound className="h-4 w-4 mr-2" />
Change password
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => signOut({ callbackUrl: "/login" })}> <DropdownMenuItem onClick={() => signOut({ callbackUrl: "/login" })}>
<LogOut className="h-4 w-4 mr-2" /> <LogOut className="h-4 w-4 mr-2" />
Sign out Sign out
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div>
</header> </header>
); );
} }

View 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>
);
}

View 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 &ldquo;Yard&rdquo;
</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>
</>
);
}

View 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>
);
}

View 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 &ldquo;{tagInput.trim()}&rdquo;
</button>
)}
</div>
)}
</div>
</div>
<p className="text-xs text-muted-foreground">Press Enter or comma to add. Tag things multiple ways, like &ldquo;Pre-cooked&rdquo; and &ldquo;Poultry&rdquo;.</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>
</>
);
}

View 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>
</>
);
}

View 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>
</>
);
}

View 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 &ldquo;{trimmed}&rdquo;
</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>
);
}

View File

@@ -81,6 +81,11 @@ export async function authenticateRequest(req: Request, required?: string): Prom
return { userId: tok.userId, role: tok.user.role, scopes: tok.scopes, via: "token" }; return { userId: tok.userId, role: tok.user.role, scopes: tok.scopes, via: "token" };
} }
return sessionContext();
}
/** AuthContext for server components (cookie session only, full scope). */
export async function sessionContext(): Promise<AuthContext> {
const session = await getSession(); const session = await getSession();
if (!session) throw new ApiError(401, "Unauthorized"); if (!session) throw new ApiError(401, "Unauthorized");
return { userId: session.user.id, role: session.user.role, scopes: ["*"], via: "session" }; return { userId: session.user.id, role: session.user.role, scopes: ["*"], via: "session" };

View File

@@ -1,12 +1,22 @@
// Zod schemas shared by the REST API and (later) the MCP tool definitions, so // Zod schemas shared by the REST API and (later) the MCP tool definitions, so
// validation lives in exactly one place. // validation lives in exactly one place.
import { z } from "zod"; import { z } from "zod";
import { PANTRY_USE_REASON_VALUES } from "@/lib/pantry/core";
export const plantLogInput = z.object({ export const plantLogInput = z.object({
type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]), type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]),
notes: z.string().optional(), notes: z.string().optional(),
quantity: z.string().optional(), quantity: z.string().optional(), // legacy freeform; prefer amount+unit
amount: z.coerce.number().nonnegative().nullable().optional(),
unit: z.string().optional(),
date: z.string().optional(), // ISO date; defaults to now date: z.string().optional(), // ISO date; defaults to now
// HARVEST only: also create a pantry item (GARDEN source, linked to the plant)
pantry: z
.object({
locationId: z.string().nullable().optional(),
bestBefore: z.string().nullable().optional(),
})
.optional(),
}); });
export const taskCreateInput = z.object({ export const taskCreateInput = z.object({
@@ -14,6 +24,9 @@ export const taskCreateInput = z.object({
notes: z.string().optional(), notes: z.string().optional(),
category: z.enum(["GARDEN", "HOME", "EQUIPMENT", "OTHER"]).default("GARDEN"), category: z.enum(["GARDEN", "HOME", "EQUIPMENT", "OTHER"]).default("GARDEN"),
plantId: z.string().optional(), plantId: z.string().optional(),
itemId: z.string().optional(),
animalId: z.string().optional(),
locationId: z.string().optional(),
recurrence: z.enum(["ONCE", "CUSTOM", "MONTHLY", "YEARLY"]).default("ONCE"), recurrence: z.enum(["ONCE", "CUSTOM", "MONTHLY", "YEARLY"]).default("ONCE"),
intervalDays: z.number().int().positive().optional(), intervalDays: z.number().int().positive().optional(),
month: z.number().int().min(1).max(12).optional(), month: z.number().int().min(1).max(12).optional(),
@@ -34,10 +47,19 @@ export const itemCreateInput = z.object({
parentItemId: z.string().nullable().optional(), parentItemId: z.string().nullable().optional(),
value: z.coerce.number().nonnegative().nullable().optional(), value: z.coerce.number().nonnegative().nullable().optional(),
purchaseDate: z.string().nullable().optional(), purchaseDate: z.string().nullable().optional(),
purchaseFrom: z.string().optional(),
warrantyExpiry: z.string().nullable().optional(), warrantyExpiry: z.string().nullable().optional(),
warrantyDetails: z.string().optional(),
lifetimeWarranty: z.boolean().optional(),
insured: z.boolean().optional(),
serial: z.string().optional(), serial: z.string().optional(),
modelNumber: z.string().optional(), modelNumber: z.string().optional(),
brand: z.string().optional(), brand: z.string().optional(),
soldTo: z.string().optional(),
soldPrice: z.coerce.number().nonnegative().nullable().optional(),
soldDate: z.string().nullable().optional(),
soldNotes: z.string().optional(),
customFields: z.record(z.string()).nullable().optional(),
barcode: z.string().optional(), barcode: z.string().optional(),
notes: z.string().optional(), notes: z.string().optional(),
imageUrl: z.string().nullable().optional(), imageUrl: z.string().nullable().optional(),
@@ -47,12 +69,53 @@ export const itemCreateInput = z.object({
export const itemUpdateInput = itemCreateInput.partial(); export const itemUpdateInput = itemCreateInput.partial();
export const itemLogInput = z.object({ export const itemLogInput = z.object({
type: z.enum(["NOTE", "MAINTENANCE", "MOVED", "PURCHASED", "REPAIRED", "DISPOSED"]), type: z.enum(["NOTE", "MAINTENANCE", "MOVED", "PURCHASED", "REPAIRED", "DISPOSED", "USED"]),
notes: z.string().optional(), notes: z.string().optional(),
cost: z.coerce.number().nonnegative().nullable().optional(), cost: z.coerce.number().nonnegative().nullable().optional(),
amount: z.coerce.number().nonnegative().nullable().optional(),
date: z.string().optional(), date: z.string().optional(),
}); });
// --- Pantry (consumable items; the UI's Kitchen → Pantry & Freezer) ---
export const pantryCreateInput = z.object({
name: z.string().min(1),
description: z.string().nullable().optional(),
quantity: z.coerce.number().min(0).default(1),
unit: z.string().nullable().optional(),
packageSize: z.string().nullable().optional(), // legacy alias for description
locationId: z.string().nullable().optional(),
storedAt: z.string().nullable().optional(),
bestBefore: z.string().nullable().optional(),
source: z.enum(["GARDEN", "HOMEMADE", "STORE", "FARMERS_MARKET", "GIFTED", "OTHER"]).nullable().optional(),
plantId: z.string().nullable().optional(),
pantryCategory: z.string().nullable().optional(),
pantrySubcategory: z.string().nullable().optional(),
pricePaid: z.coerce.number().nonnegative().nullable().optional(),
priceUnit: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
tags: z.array(z.string()).optional(),
});
export const pantryUpdateInput = pantryCreateInput.partial();
export const pantryUseInput = z.object({
amount: z.coerce.number().positive(),
notes: z.string().nullable().optional(),
reason: z.enum(PANTRY_USE_REASON_VALUES).nullable().optional(),
});
export const pantryBulkUseInput = z.object({
pulls: z.array(z.object({ id: z.string(), amount: z.coerce.number().positive() })).min(1),
notes: z.string().nullable().optional(),
reason: z.enum(PANTRY_USE_REASON_VALUES).nullable().optional(),
});
export type PantryCreateInput = z.infer<typeof pantryCreateInput>;
export type PantryUpdateInput = z.infer<typeof pantryUpdateInput>;
export type PantryUseInput = z.infer<typeof pantryUseInput>;
export type PantryBulkUseInput = z.infer<typeof pantryBulkUseInput>;
export type PlantLogInput = z.infer<typeof plantLogInput>; export type PlantLogInput = z.infer<typeof plantLogInput>;
export type TaskCreateInput = z.infer<typeof taskCreateInput>; export type TaskCreateInput = z.infer<typeof taskCreateInput>;
export type TaskCompleteInput = z.infer<typeof taskCompleteInput>; export type TaskCompleteInput = z.infer<typeof taskCompleteInput>;

View File

@@ -8,6 +8,182 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.30.0",
date: "2026-07-12",
changes: [
"Added the floating feedback/idea button (bottom-right corner) that the shop and bakery apps have — jot a quick idea or bug from any page, paste in a screenshot, and it files straight to the project. The header lightbulb still opens the full list.",
],
},
{
version: "0.29.0",
date: "2026-07-12",
changes: [
"New Ramble page — a sticky-note board for dumping stray thoughts and ideas that aren't tasks yet. Pin the important ones, archive the ones you're done with.",
"Dark mode is back: a sun/moon button in the top bar flips the whole app between light and dark.",
"When you pull something from the pantry you can now say why — Immediate use, Preserved, Gave away, or Waste — so we can actually see what's getting eaten vs. thrown out.",
"Cleaned up the Suggestions page so it's readable and photo thumbnails show up.",
],
},
{
version: "0.28.1",
date: "2026-07-10",
changes: [
"The little feedback window's subtitle now says “Bugs, ideas, anything.” instead of “We read every one.” — same window, less cheese.",
],
},
{
version: "0.28.0",
date: "2026-07-06",
changes: [
"Fixed: adding a new plant type by hand failed with a confusing \"expected boolean\" error when \"Toxic to pets?\" was left as Unknown. Leaving it Unknown works now.",
"New \"Root vegetable\" category for plants and plant types — carrots, beets, sweet potatoes, radishes.",
"The garden brain now knows sweet potatoes (including Japanese ones): plant slips in warm June soil, dig before the first fall frost, cure warm to sweeten.",
],
},
{
version: "0.27.0",
date: "2026-07-06",
changes: [
"The garden got a brain! The Calendar now shows \"Good to do this month\" — timely advice matched to what you actually grow, tuned for our zone (4b, ~May 15 last frost): when to prune the raspberries, start tomato seeds, plant garlic, divide the irises, and more. Each tip has a \"Remind me\" button that turns it into a yearly reminder, and a link to read more at UWMadison 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 UWMadison Extension article: open a plant type, click Edit → \"Look up on UW Extension\", and pick the matching article. The link shows on the type page and on each of those plants.",
],
},
{
version: "0.23.0",
date: "2026-07-06",
changes: [
"Pantry: pulls are now recorded properly — each one saves the exact amount taken, so we can later see how fast things get used and what a dinner or trip actually consumed.",
"Pantry: if someone else deleted an item while you had the Pull dialog open, it now tells you which items couldn't be pulled instead of quietly skipping them.",
"Behind the scenes: the Pantry now runs on the same modern plumbing as Inventory and Animals, so API tokens (and the future Claude connection) can read and update it too.",
],
},
{
version: "0.22.0",
date: "2026-07-04",
changes: [
"Inventory can now be searched and regrouped — there's a search box (matches item names, locations, and labels) and a Location / Label toggle to group your stuff either way.",
"Fixed the Suggestions page — the boxes and the \"Send suggestion\" button were rendering unstyled (dark, unreadable inputs and an invisible button). They now look right and are readable.",
"Searching now finds things stored inside containers too — search \"garage\" and you'll see the drill inside the garage toolbox, not just the toolbox.",
"Deleting a container (like a toolbox) now leaves its contents in its spot — a wrench in a deleted garage toolbox becomes a wrench loose in the garage, instead of disappearing.",
],
},
{
version: "0.21.1",
date: "2026-06-29",
changes: [
"Pantry: trash icon on each item — click it, confirm with Yes/No. Removes the item from the inventory.",
],
},
{
version: "0.21.0",
date: "2026-06-29",
changes: [
"Pantry: new \"Pull from pantry\" button — search for items, type quantities next to everything you're taking, pick a reason, and submit all at once. No more opening each item individually.",
],
},
{
version: "0.20.0",
date: "2026-06-29",
changes: [
"Pantry: items can now have multiple tags — type them in the new Tags field when adding or editing an item (press Enter or comma to add each one, like \"Pre-cooked\" and \"Poultry\"). Tags show up as small chips on each item row. Previously-used tags autocomplete as you type.",
],
},
{
version: "0.19.2",
date: "2026-06-29",
changes: [
"Pantry: \"Add new…\" now shows at the bottom of the Category and Subcategory dropdowns at all times — click it, type the new name, and it becomes the \"Add [name]\" option.",
],
},
{
version: "0.19.1",
date: "2026-06-29",
changes: [
"Pantry: the quantity up/down arrows now step by whole numbers instead of quarters — you can still type a partial amount like 2.5 directly.",
],
},
{
version: "0.19.0",
date: "2026-06-29",
changes: [
"Pantry: when you use something, you can now tag why with one tap — Family dinner, Dinner party, Taking on a trip, Meal prep, Gave it away — or type your own note instead.",
],
},
{
version: "0.18.1",
date: "2026-06-29",
changes: [
"Pantry: you can now add a brand-new category or subcategory on the fly — just type the name into the Category or Subcategory dropdown and pick \"Add [name]\" at the bottom of the list. New categories are remembered in future add-item dialogs.",
],
},
{
version: "0.18.0",
date: "2026-06-16",
changes: [
"Locations \"Quick setup\" — on the Locations page, click Quick setup and it's pre-filled with the whole house layout (rooms, basement, garage, sheds, yard). Click Create and the tree is built; it can also sweep all your existing garden areas under a single \"Yard\".",
"Reminders can now be attached to a place — e.g. \"clean gutters every fall\" on the house exterior, or \"change the furnace filter.\" Add one from a location's menu; it shows in Reminders with the spot.",
],
},
{
version: "0.17.0",
date: "2026-06-16",
changes: [
"Mobile now works — there's a menu button (top-left) that slides out the full navigation, so you can get around on your phone.",
"Added a lightbulb button in the top bar that jumps straight to Suggestions, and made the Suggestions page readable (it was washed-out gray on the app's background).",
],
},
{
version: "0.16.1",
date: "2026-06-16",
changes: [
"Fixed: after you set a new password, the app could keep asking you to change it again on every visit. It now clears the moment you save your new password.",
],
},
{
version: "0.16.0",
date: "2026-06-16",
changes: [
"Import your inventory from HomeBox — on the Inventory page, click Import, paste or upload your HomeBox CSV, review every item (uncheck any you don't want), and it brings them in, creating the locations and labels for you.",
"Items can now record custom fields (like VIN or oil weight for vehicles) and a 'sold' record (who to, price, when) — and you can edit both on any item.",
],
},
{
version: "0.15.0",
date: "2026-06-16",
changes: [
"Inventory got a lot deeper: attach manuals, receipts, and warranty docs (not just photos) to an item; record where you bought it, warranty details, lifetime-warranty and insured flags; and schedule recurring maintenance right from an item (it shows up in Reminders, with overdue flagged).",
"New Locations page in the menu — see and manage your whole place tree in one spot: add, rename, move, or remove garden areas, rooms, and storage, nested as deep as you like.",
],
},
{ {
version: "0.14.0", version: "0.14.0",
date: "2026-06-16", date: "2026-06-16",

View File

@@ -9,6 +9,7 @@ export const CATEGORY_LABELS: Record<PlantCategory, string> = {
GROUNDCOVER: "Groundcover", GROUNDCOVER: "Groundcover",
VINE: "Vine", VINE: "Vine",
ANNUAL_VEGETABLE: "Annual vegetable", ANNUAL_VEGETABLE: "Annual vegetable",
ROOT_VEGETABLE: "Root vegetable",
ANNUAL: "Annual vegetable", ANNUAL: "Annual vegetable",
ANNUAL_FLOWER: "Annual flower", ANNUAL_FLOWER: "Annual flower",
PERENNIAL: "Perennial", PERENNIAL: "Perennial",
@@ -29,6 +30,7 @@ export const CATEGORY_ORDER: PlantCategory[] = [
"PERENNIAL", "PERENNIAL",
"PERENNIAL_FLOWER", "PERENNIAL_FLOWER",
"ANNUAL_VEGETABLE", "ANNUAL_VEGETABLE",
"ROOT_VEGETABLE",
"ANNUAL_FLOWER", "ANNUAL_FLOWER",
"BULB", "BULB",
"MUSHROOM", "MUSHROOM",

View 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 112 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
View 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 UWMadison 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[]; // 112, 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 (8085°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 — 45 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.55.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 13 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: "46 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 (112), 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;
}

View 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
View File

@@ -0,0 +1,10 @@
// Season-window math for PlantType bloom/harvest month ranges (112).
// Windows may wrap the year end (e.g. OctMar).
/** Is `month` (112) 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;
}

View 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&nbsp;balm &#8212; a gardener&#8217;s friend [&#8230;]")).toBe(
"Bee balm — a gardeners 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&nbsp;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([]);
});
});

View File

@@ -0,0 +1,75 @@
// UWMadison 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(/&amp;/g, "&")
.replace(/&#8217;|&rsquo;/g, "")
.replace(/&#8216;|&lsquo;/g, "")
.replace(/&#8220;|&ldquo;/g, "“")
.replace(/&#8221;|&rdquo;/g, "”")
.replace(/&#8211;|&ndash;/g, "")
.replace(/&#8212;|&mdash;/g, "—")
.replace(/&hellip;|&#8230;/g, "…")
.replace(/&nbsp;/g, " ")
.replace(/\[&#8230;\]|\[…\]/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);
}

View 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"]);
});
});

View File

@@ -0,0 +1,131 @@
// Pure HomeBox CSV import parsing. No DB — turns a HomeBox export into normalized
// item rows the import service can create. Handles RFC4180 quoting (commas and
// newlines inside quoted fields, "" escapes), HomeBox's 0001-* null dates, its
// "0" = empty price, semicolon-separated labels, and HB.field.* custom columns.
export type HomeBoxItem = {
name: string;
description: string | null;
quantity: number;
locationPath: string | null; // e.g. "Upstairs / Print Room"
labels: string[];
insured: boolean;
notes: string | null;
value: number | null;
purchaseFrom: string | null;
purchaseDate: string | null;
brand: string | null;
modelNumber: string | null;
serial: string | null;
lifetimeWarranty: boolean;
warrantyExpiry: string | null;
warrantyDetails: string | null;
soldTo: string | null;
soldPrice: number | null;
soldDate: string | null;
soldNotes: string | null;
customFields: Record<string, string>;
archived: boolean;
};
/** RFC4180-ish CSV tokenizer → array of rows of string cells. */
export function parseCsv(text: string): string[][] {
const s = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let inQuotes = false;
let i = 0;
while (i < s.length) {
const c = s[i];
if (inQuotes) {
if (c === '"') {
if (s[i + 1] === '"') { field += '"'; i += 2; continue; }
inQuotes = false; i++; continue;
}
field += c; i++; continue;
}
if (c === '"') { inQuotes = true; i++; continue; }
if (c === ",") { row.push(field); field = ""; i++; continue; }
if (c === "\n") { row.push(field); rows.push(row); row = []; field = ""; i++; continue; }
field += c; i++;
}
if (field !== "" || row.length > 0) { row.push(field); rows.push(row); }
return rows;
}
function cleanDate(v: string): string | null {
const t = v.trim();
// HomeBox exports "no date" as year 0001.
if (!t || t.startsWith("0001")) return null;
return Number.isNaN(new Date(t).getTime()) ? null : t;
}
function cleanNum(v: string): number | null {
const t = v.trim();
if (!t) return null;
const n = Number(t);
// HomeBox exports empty price as 0.
return Number.isNaN(n) || n === 0 ? null : n;
}
const isTrue = (v: string) => v.trim().toLowerCase() === "true";
export function parseHomeBoxCsv(text: string): HomeBoxItem[] {
const rows = parseCsv(text).filter((r) => r.some((c) => c.trim() !== ""));
if (rows.length < 2) return [];
const header = rows[0].map((h) => h.trim());
const idx = (name: string) => header.indexOf(name);
const fieldCols = header
.map((h, i) => ({ key: h.replace(/^HB\.field\./, ""), i, isField: h.startsWith("HB.field.") }))
.filter((x) => x.isField);
const items: HomeBoxItem[] = [];
for (let r = 1; r < rows.length; r++) {
const row = rows[r];
const get = (name: string) => {
const i = idx(name);
return i >= 0 ? (row[i] ?? "").trim() : "";
};
const name = get("HB.name");
if (!name) continue;
const customFields: Record<string, string> = {};
for (const fc of fieldCols) {
const val = (row[fc.i] ?? "").trim();
if (val) customFields[fc.key] = val;
}
items.push({
name,
description: get("HB.description") || null,
quantity: Number(get("HB.quantity")) || 1,
locationPath: get("HB.location") || null,
labels: get("HB.labels").split(";").map((s) => s.trim()).filter(Boolean),
insured: isTrue(get("HB.insured")),
notes: get("HB.notes") || null,
value: cleanNum(get("HB.purchase_price")),
purchaseFrom: get("HB.purchase_from") || null,
purchaseDate: cleanDate(get("HB.purchase_time")),
brand: get("HB.manufacturer") || null,
modelNumber: get("HB.model_number") || null,
serial: get("HB.serial_number") || null,
lifetimeWarranty: isTrue(get("HB.lifetime_warranty")),
warrantyExpiry: cleanDate(get("HB.warranty_expires")),
warrantyDetails: get("HB.warranty_details") || null,
soldTo: get("HB.sold_to") || null,
soldPrice: cleanNum(get("HB.sold_price")),
soldDate: cleanDate(get("HB.sold_time")),
soldNotes: get("HB.sold_notes") || null,
customFields,
archived: isTrue(get("HB.archived")),
});
}
return items;
}
/** Split a HomeBox location path ("Upstairs / Print Room") into nested names. */
export function splitLocationPath(path: string): string[] {
return path.split("/").map((s) => s.trim()).filter(Boolean);
}

View File

@@ -7,6 +7,7 @@ export const ITEM_LOG_LABELS: Record<ItemLogType, string> = {
PURCHASED: "Purchased", PURCHASED: "Purchased",
REPAIRED: "Repaired", REPAIRED: "Repaired",
DISPOSED: "Disposed", DISPOSED: "Disposed",
USED: "Used",
}; };
export const ITEM_LOG_COLORS: Record<ItemLogType, string> = { export const ITEM_LOG_COLORS: Record<ItemLogType, string> = {
@@ -16,8 +17,11 @@ export const ITEM_LOG_COLORS: Record<ItemLogType, string> = {
PURCHASED: "text-green-600 dark:text-green-400", PURCHASED: "text-green-600 dark:text-green-400",
REPAIRED: "text-violet-600 dark:text-violet-400", REPAIRED: "text-violet-600 dark:text-violet-400",
DISPOSED: "text-red-600 dark:text-red-400", DISPOSED: "text-red-600 dark:text-red-400",
USED: "text-teal-600 dark:text-teal-400",
}; };
// Types offered in the manual "add log" dropdown for durable items.
// USED is written by pantry pulls, not entered by hand, so it's not listed.
export const ITEM_LOG_TYPES: ItemLogType[] = [ export const ITEM_LOG_TYPES: ItemLogType[] = [
"NOTE", "MAINTENANCE", "MOVED", "PURCHASED", "REPAIRED", "DISPOSED", "NOTE", "MAINTENANCE", "MOVED", "PURCHASED", "REPAIRED", "DISPOSED",
]; ];

View File

@@ -0,0 +1,35 @@
import { describe, it, expect } from "vitest";
import { parseLocationOutline } from "./outline";
describe("parseLocationOutline", () => {
it("derives depth from indentation", () => {
const nodes = parseLocationOutline("House\n Main floor\n Kitchen\n Pantry\n Garage\nYard");
expect(nodes).toEqual([
{ name: "House", depth: 0 },
{ name: "Main floor", depth: 1 },
{ name: "Kitchen", depth: 2 },
{ name: "Pantry", depth: 3 },
{ name: "Garage", depth: 1 },
{ name: "Yard", depth: 0 },
]);
});
it("treats tabs as two spaces and ignores blank lines", () => {
const nodes = parseLocationOutline("A\n\n\tB\n\t\tC");
expect(nodes.map((n) => `${n.depth}:${n.name}`)).toEqual(["0:A", "1:B", "2:C"]);
});
it("strips leading bullets/dashes", () => {
const nodes = parseLocationOutline("- Shed\n * Brown\n - Green");
expect(nodes).toEqual([
{ name: "Shed", depth: 0 },
{ name: "Brown", depth: 1 },
{ name: "Green", depth: 1 },
]);
});
it("handles inconsistent indent widths by relative nesting", () => {
const nodes = parseLocationOutline("A\n B\n C\n D");
expect(nodes.map((n) => `${n.depth}:${n.name}`)).toEqual(["0:A", "1:B", "2:C", "1:D"]);
});
});

View File

@@ -0,0 +1,21 @@
// Pure parser: an indented text outline → a flat, ordered list of nodes with a
// depth, from which a nested Location tree is built. Indentation (spaces, tabs→2)
// sets depth; leading bullets/dashes are tolerated. Used by the "Quick setup".
export type OutlineNode = { name: string; depth: number };
export function parseLocationOutline(text: string): OutlineNode[] {
const out: OutlineNode[] = [];
const indents: number[] = []; // indent width of each ancestor on the stack
for (const rawLine of text.split("\n")) {
if (!rawLine.trim()) continue;
const line = rawLine.replace(/\t/g, " ");
const indent = line.length - line.replace(/^\s+/, "").length;
const name = line.trim().replace(/^[-*•]\s+/, "").trim();
if (!name) continue;
while (indents.length && indents[indents.length - 1] >= indent) indents.pop();
out.push({ name, depth: indents.length });
indents.push(indent);
}
return out;
}

View File

@@ -0,0 +1,66 @@
export type PantryCategoryTree = {
label: string;
subcategories: string[];
};
export const PANTRY_CATEGORIES: PantryCategoryTree[] = [
{
label: "Meat",
subcategories: ["Beef", "Poultry", "Pork", "Lamb", "Game", "Sausage & Cured", "Mixed / Other"],
},
{
label: "Seafood",
subcategories: ["Fish", "Shrimp", "Shellfish", "Other Seafood"],
},
{
label: "Produce",
subcategories: ["Berries", "Tomatoes", "Corn", "Peppers", "Leafy Greens", "Root Vegetables", "Squash & Gourds", "Beans & Peas", "Herbs", "Other Produce"],
},
{
label: "Fruit",
subcategories: ["Berries", "Stone Fruit", "Apples & Pears", "Citrus", "Tropical", "Other Fruit"],
},
{
label: "Dairy & Eggs",
subcategories: ["Butter", "Cheese", "Milk & Cream", "Eggs", "Yogurt", "Other Dairy"],
},
{
label: "Baked Goods",
subcategories: ["Bread & Rolls", "Muffins & Quick Breads", "Cookies & Bars", "Cakes & Pies", "Pastry", "Crackers", "Other Baked Goods"],
},
{
label: "Soups & Broths",
subcategories: ["Broth & Stock", "Soup", "Stew & Chili", "Other"],
},
{
label: "Prepared Meals",
subcategories: ["Casserole", "Pasta & Rice Dishes", "Breakfast", "Leftovers", "Other Meals"],
},
{
label: "Sauces & Condiments",
subcategories: ["Tomato Sauce", "Pesto", "Salsa", "Jam & Jelly", "Pickles & Ferments", "Other Sauces"],
},
{
label: "Grains & Legumes",
subcategories: ["Flour", "Rice & Grains", "Dried Beans", "Lentils", "Pasta", "Oats", "Other"],
},
{
label: "Nuts & Seeds",
subcategories: ["Nuts", "Seeds", "Nut Butter", "Other"],
},
{
label: "Beverages",
subcategories: ["Juice", "Tea & Coffee", "Other Beverages"],
},
{
label: "Other",
subcategories: ["Other"],
},
];
export const CATEGORY_OPTIONS = PANTRY_CATEGORIES.map(c => ({ value: c.label, label: c.label }));
export function getSubcategoryOptions(category: string): { value: string; label: string }[] {
const found = PANTRY_CATEGORIES.find(c => c.label === category);
return (found?.subcategories ?? []).map(s => ({ value: s, label: s }));
}

103
src/lib/pantry/core.test.ts Normal file
View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { normalizeTags, planBulkUse, serializePantryItem, type PantryRow } from "./core";
describe("normalizeTags", () => {
it("trims, drops empties, and dedupes", () => {
expect(normalizeTags([" Poultry ", "Pre-cooked", "", " ", "Poultry"])).toEqual([
"Poultry",
"Pre-cooked",
]);
});
it("keeps distinct casings distinct (labels are case-sensitive)", () => {
expect(normalizeTags(["poultry", "Poultry"])).toEqual(["poultry", "Poultry"]);
});
});
describe("serializePantryItem", () => {
const row: PantryRow = {
id: "i1",
name: "Chicken thighs",
description: "2 lb pack",
quantity: "3.50", // Prisma Decimal stringifies like this
unit: "lbs",
storedAt: new Date("2026-06-01"),
bestBefore: new Date("2026-09-01"),
source: "STORE",
notes: null,
pantryCategory: "Meat",
pantrySubcategory: "Poultry",
pricePaid: "8.99",
priceUnit: "lb",
location: { id: "l1", name: "Chest freezer" },
plant: null,
labels: [{ label: { name: "Pre-cooked" } }],
};
it("converts Decimals to numbers and flattens relations", () => {
const dto = serializePantryItem(row);
expect(dto.quantity).toBe(3.5);
expect(dto.pricePaid).toBe(8.99);
expect(dto.locationId).toBe("l1");
expect(dto.locationName).toBe("Chest freezer");
expect(dto.packageSize).toBe("2 lb pack");
expect(dto.tags).toEqual(["Pre-cooked"]);
});
it("handles null price, location, and plant; formats plant with variety", () => {
const dto = serializePantryItem({
...row,
pricePaid: null,
location: null,
plant: { id: "p1", commonName: "Tomato", variety: "Roma" },
});
expect(dto.pricePaid).toBeNull();
expect(dto.locationId).toBeNull();
expect(dto.plantName).toBe("Tomato (Roma)");
});
});
describe("planBulkUse", () => {
const items = [
{ id: "a", name: "Flour", quantity: 10, unit: "lbs" },
{ id: "b", name: "Eggs", quantity: 12, unit: "each" },
];
it("computes remaining quantities per pull", () => {
const plan = planBulkUse(items, [
{ id: "a", amount: 2 },
{ id: "b", amount: 6 },
]);
expect(plan.skipped).toEqual([]);
expect(plan.updates).toEqual([
{ id: "a", name: "Flour", unit: "lbs", amount: 2, remaining: 8 },
{ id: "b", name: "Eggs", unit: "each", amount: 6, remaining: 6 },
]);
});
it("reports pulls whose item is missing instead of silently dropping them", () => {
const plan = planBulkUse(items, [
{ id: "a", amount: 1 },
{ id: "gone", amount: 5 },
]);
expect(plan.skipped).toEqual(["gone"]);
expect(plan.updates).toHaveLength(1);
});
it("accumulates repeated pulls of the same item", () => {
const plan = planBulkUse(items, [
{ id: "b", amount: 4 },
{ id: "b", amount: 4 },
]);
expect(plan.updates).toEqual([
{ id: "b", name: "Eggs", unit: "each", amount: 8, remaining: 4 },
]);
});
it("caps at zero — never goes negative, and logs only what was there", () => {
const plan = planBulkUse(items, [{ id: "a", amount: 25 }]);
expect(plan.updates).toEqual([
{ id: "a", name: "Flour", unit: "lbs", amount: 10, remaining: 0 },
]);
});
});

142
src/lib/pantry/core.ts Normal file
View File

@@ -0,0 +1,142 @@
// Pure pantry logic — tag normalization, row serialization, and bulk-pull
// planning. No DB access; the service layer (src/lib/services/pantry.ts)
// applies these against Prisma.
// Disposition for a pantry pull — WHY it left inventory, kept structured so
// waste vs. use is queryable (don't fold this into freeform notes). Stored as
// the slug string on ItemLog.reason; null for old rows and API pulls that omit it.
export const PANTRY_USE_REASONS = [
{ value: "consumed", label: "Immediate use" },
{ value: "preserved", label: "Preserved / stored" },
{ value: "gifted", label: "Gave away" },
{ value: "waste", label: "Waste / spoiled" },
] as const;
export type PantryUseReason = (typeof PANTRY_USE_REASONS)[number]["value"];
export const PANTRY_USE_REASON_VALUES = PANTRY_USE_REASONS.map(r => r.value) as [
PantryUseReason,
...PantryUseReason[],
];
/** Trim, drop empties, and dedupe a raw tag list (first occurrence wins). */
export function normalizeTags(tags: string[]): string[] {
return Array.from(new Set(tags.map(t => t.trim()).filter(Boolean)));
}
// The DB row shape the pantry queries select. Decimals arrive as anything
// Number() can coerce (Prisma Decimal serializes via toString).
export type PantryRow = {
id: string;
name: string;
description: string | null;
quantity: unknown;
unit: string | null;
storedAt: Date | null;
bestBefore: Date | null;
source: string | null;
notes: string | null;
pantryCategory: string | null;
pantrySubcategory: string | null;
pricePaid: unknown | null;
priceUnit: string | null;
location: { id: string; name: string } | null;
plant: { id: string; commonName: string; variety: string | null } | null;
labels: { label: { name: string } }[];
};
export type PantryItemDto = {
id: string;
name: string;
description: string | null;
quantity: number;
unit: string | null;
packageSize: string | null;
storedAt: Date | null;
bestBefore: Date | null;
source: string | null;
notes: string | null;
pantryCategory: string | null;
pantrySubcategory: string | null;
pricePaid: number | null;
priceUnit: string | null;
locationId: string | null;
locationName: string | null;
plantId: string | null;
plantName: string | null;
tags: string[];
};
/** Flatten a pantry DB row into the client shape (Decimals → numbers). */
export function serializePantryItem(i: PantryRow): PantryItemDto {
return {
id: i.id,
name: i.name,
description: i.description,
quantity: Number(i.quantity),
unit: i.unit,
packageSize: i.description, // the UI labels `description` as "package size"
storedAt: i.storedAt,
bestBefore: i.bestBefore,
source: i.source,
notes: i.notes,
pantryCategory: i.pantryCategory,
pantrySubcategory: i.pantrySubcategory,
pricePaid: i.pricePaid != null ? Number(i.pricePaid) : null,
priceUnit: i.priceUnit,
locationId: i.location?.id ?? null,
locationName: i.location?.name ?? null,
plantId: i.plant?.id ?? null,
plantName: i.plant
? `${i.plant.commonName}${i.plant.variety ? ` (${i.plant.variety})` : ""}`
: null,
tags: (i.labels ?? []).map(l => l.label.name),
};
}
export type BulkPull = { id: string; amount: number };
export type BulkUseUpdate = {
id: string;
name: string;
unit: string | null;
amount: number; // how much actually comes off (capped at what's there)
remaining: number;
};
export type BulkUsePlan = {
updates: BulkUseUpdate[];
/** Pull ids that matched no live pantry item — the caller must surface these. */
skipped: string[];
};
/**
* Turn requested pulls into quantity updates against the items on hand.
* Repeated pulls of the same item accumulate; quantities never go below zero.
*/
export function planBulkUse(
items: { id: string; name: string; quantity: number; unit: string | null }[],
pulls: BulkPull[],
): BulkUsePlan {
const remaining = new Map(items.map(i => [i.id, i]));
const updates = new Map<string, BulkUseUpdate>();
const skipped: string[] = [];
for (const pull of pulls) {
const item = remaining.get(pull.id);
if (!item) {
skipped.push(pull.id);
continue;
}
const prev = updates.get(pull.id);
const before = prev ? prev.remaining : item.quantity;
const taken = Math.min(pull.amount, before);
updates.set(pull.id, {
id: item.id,
name: item.name,
unit: item.unit,
amount: (prev?.amount ?? 0) + taken,
remaining: before - taken,
});
}
return { updates: Array.from(updates.values()), skipped };
}

View File

@@ -0,0 +1,141 @@
// Import service for HomeBox CSV → Moonbase items. Resolves (find-or-creates)
// the nested location path and labels, then creates the items. The parsing is
// pure (src/lib/inventory/homebox-import.ts) and shared with the preview UI.
import { createId } from "@paralleldrive/cuid2";
import { db } from "@/lib/db";
import type { AuthContext } from "@/lib/api/authenticate";
import { recomputeAllPaths } from "@/lib/locations/db";
import { parseHomeBoxCsv, splitLocationPath, type HomeBoxItem } from "@/lib/inventory/homebox-import";
export type ImportSummary = {
created: number;
skipped: number;
locationsCreated: number;
labelsCreated: number;
};
async function buildLocationCache() {
const rows = await db.location.findMany({ select: { id: true, name: true, parentId: true } });
const cache = new Map<string, string>(); // `${parentId||''}:${nameLower}` → id
for (const r of rows) cache.set(`${r.parentId ?? ""}:${r.name.toLowerCase()}`, r.id);
return cache;
}
async function resolveLocationPath(
path: string | null,
cache: Map<string, string>,
counter: { n: number },
): Promise<string | null> {
if (!path) return null;
const names = splitLocationPath(path);
let parentId: string | null = null;
for (const name of names) {
const key = `${parentId ?? ""}:${name.toLowerCase()}`;
const existing: string | undefined = cache.get(key);
let id: string;
if (existing) {
id = existing;
} else {
const loc = await db.location.create({ data: { name, kind: "OTHER", parentId } });
id = loc.id;
cache.set(key, id);
counter.n++;
}
parentId = id;
}
return parentId;
}
async function buildLabelCache() {
const rows = await db.label.findMany({ select: { id: true, name: true } });
const cache = new Map<string, string>();
for (const r of rows) cache.set(r.name.toLowerCase(), r.id);
return cache;
}
async function resolveLabels(
names: string[],
cache: Map<string, string>,
counter: { n: number },
): Promise<string[]> {
const ids: string[] = [];
for (const name of names) {
let id = cache.get(name.toLowerCase());
if (!id) {
const label = await db.label.create({ data: { name } });
id = label.id;
cache.set(name.toLowerCase(), id);
counter.n++;
}
ids.push(id);
}
return ids;
}
function toDate(s: string | null): Date | null {
return s ? new Date(s) : null;
}
export async function importHomeBoxItems(
ctx: AuthContext,
opts: { csv: string; skip?: string[] },
): Promise<ImportSummary> {
const parsed = parseHomeBoxCsv(opts.csv);
const skip = new Set((opts.skip ?? []).map((s) => s.trim().toLowerCase()));
const locCache = await buildLocationCache();
const labelCache = await buildLabelCache();
const locCounter = { n: 0 };
const labelCounter = { n: 0 };
let created = 0;
let skipped = 0;
for (const hb of parsed as HomeBoxItem[]) {
if (skip.has(hb.name.trim().toLowerCase())) { skipped++; continue; }
const locationId = await resolveLocationPath(hb.locationPath, locCache, locCounter);
const labelIds = await resolveLabels(hb.labels, labelCache, labelCounter);
await db.item.create({
data: {
name: hb.name,
description: hb.description,
quantity: hb.quantity || 1,
locationId,
value: hb.value,
purchaseDate: toDate(hb.purchaseDate),
purchaseFrom: hb.purchaseFrom,
warrantyExpiry: toDate(hb.warrantyExpiry),
warrantyDetails: hb.warrantyDetails,
lifetimeWarranty: hb.lifetimeWarranty,
insured: hb.insured,
serial: hb.serial,
modelNumber: hb.modelNumber,
brand: hb.brand,
notes: hb.notes,
soldTo: hb.soldTo,
soldPrice: hb.soldPrice,
soldDate: toDate(hb.soldDate),
soldNotes: hb.soldNotes,
customFields: Object.keys(hb.customFields).length ? hb.customFields : undefined,
active: !hb.archived,
qrSlug: createId().slice(0, 10),
...(labelIds.length ? { labels: { create: labelIds.map((labelId) => ({ labelId })) } } : {}),
},
});
created++;
}
// New locations were created without paths — recompute breadcrumbs once.
if (locCounter.n > 0) await recomputeAllPaths();
await db.auditEvent.create({
data: {
action: "item.import",
actorId: ctx.userId,
payload: { created, skipped, source: "homebox", via: ctx.via },
},
});
return { created, skipped, locationsCreated: locCounter.n, labelsCreated: labelCounter.n };
}

View File

@@ -82,10 +82,19 @@ export async function createItem(ctx: AuthContext, input: ItemCreateInput) {
parentItemId: r.parentItemId || null, parentItemId: r.parentItemId || null,
value: r.value ?? null, value: r.value ?? null,
purchaseDate: toDate(r.purchaseDate), purchaseDate: toDate(r.purchaseDate),
purchaseFrom: r.purchaseFrom?.trim() || null,
warrantyExpiry: toDate(r.warrantyExpiry), warrantyExpiry: toDate(r.warrantyExpiry),
warrantyDetails: r.warrantyDetails?.trim() || null,
lifetimeWarranty: r.lifetimeWarranty ?? false,
insured: r.insured ?? false,
serial: r.serial?.trim() || null, serial: r.serial?.trim() || null,
modelNumber: r.modelNumber?.trim() || null, modelNumber: r.modelNumber?.trim() || null,
brand: r.brand?.trim() || null, brand: r.brand?.trim() || null,
soldTo: r.soldTo?.trim() || null,
soldPrice: r.soldPrice ?? null,
soldDate: toDate(r.soldDate),
soldNotes: r.soldNotes?.trim() || null,
customFields: r.customFields && Object.keys(r.customFields).length ? r.customFields : undefined,
barcode: r.barcode?.trim() || null, barcode: r.barcode?.trim() || null,
notes: r.notes?.trim() || null, notes: r.notes?.trim() || null,
imageUrl: r.imageUrl || null, imageUrl: r.imageUrl || null,
@@ -121,10 +130,20 @@ export async function updateItem(ctx: AuthContext, id: string, input: ItemUpdate
if (r.parentItemId !== undefined) data.parentItem = r.parentItemId ? { connect: { id: r.parentItemId } } : { disconnect: true }; if (r.parentItemId !== undefined) data.parentItem = r.parentItemId ? { connect: { id: r.parentItemId } } : { disconnect: true };
if (r.value !== undefined) data.value = r.value; if (r.value !== undefined) data.value = r.value;
if (r.purchaseDate !== undefined) data.purchaseDate = toDate(r.purchaseDate); if (r.purchaseDate !== undefined) data.purchaseDate = toDate(r.purchaseDate);
if (r.purchaseFrom !== undefined) data.purchaseFrom = r.purchaseFrom?.trim() || null;
if (r.warrantyExpiry !== undefined) data.warrantyExpiry = toDate(r.warrantyExpiry); if (r.warrantyExpiry !== undefined) data.warrantyExpiry = toDate(r.warrantyExpiry);
if (r.warrantyDetails !== undefined) data.warrantyDetails = r.warrantyDetails?.trim() || null;
if (r.lifetimeWarranty !== undefined) data.lifetimeWarranty = r.lifetimeWarranty;
if (r.insured !== undefined) data.insured = r.insured;
if (r.serial !== undefined) data.serial = r.serial?.trim() || null; if (r.serial !== undefined) data.serial = r.serial?.trim() || null;
if (r.modelNumber !== undefined) data.modelNumber = r.modelNumber?.trim() || null; if (r.modelNumber !== undefined) data.modelNumber = r.modelNumber?.trim() || null;
if (r.brand !== undefined) data.brand = r.brand?.trim() || null; if (r.brand !== undefined) data.brand = r.brand?.trim() || null;
if (r.soldTo !== undefined) data.soldTo = r.soldTo?.trim() || null;
if (r.soldPrice !== undefined) data.soldPrice = r.soldPrice;
if (r.soldDate !== undefined) data.soldDate = toDate(r.soldDate);
if (r.soldNotes !== undefined) data.soldNotes = r.soldNotes?.trim() || null;
if (r.customFields !== undefined)
data.customFields = r.customFields && Object.keys(r.customFields).length ? r.customFields : Prisma.DbNull;
if (r.barcode !== undefined) data.barcode = r.barcode?.trim() || null; if (r.barcode !== undefined) data.barcode = r.barcode?.trim() || null;
if (r.notes !== undefined) data.notes = r.notes?.trim() || null; if (r.notes !== undefined) data.notes = r.notes?.trim() || null;
if (r.imageUrl !== undefined) data.imageUrl = r.imageUrl || null; if (r.imageUrl !== undefined) data.imageUrl = r.imageUrl || null;
@@ -145,11 +164,29 @@ export async function updateItem(ctx: AuthContext, id: string, input: ItemUpdate
export async function deleteItem(ctx: AuthContext, id: string) { export async function deleteItem(ctx: AuthContext, id: string) {
if (!isAdmin(ctx.role)) throw new ApiError(403, "Only admins can delete items"); if (!isAdmin(ctx.role)) throw new ApiError(403, "Only admins can delete items");
const item = await db.item.findUnique({ where: { id }, select: { active: true, name: true } }); const item = await db.item.findUnique({
where: { id },
select: { active: true, name: true, locationId: true, parentItemId: true },
});
if (!item || !item.active) throw new ApiError(404, "Item not found"); if (!item || !item.active) throw new ApiError(404, "Item not found");
await db.item.update({ where: { id }, data: { active: false } }); // Deleting a container drops its contents into the container's own spot (its
// place, or the item it was nested in) instead of orphaning them — a wrench in
// a deleted garage toolbox becomes a wrench loose in the garage.
const reparented = await db.$transaction(async (tx) => {
const { count } = await tx.item.updateMany({
where: { parentItemId: id, active: true },
data: { locationId: item.locationId, parentItemId: item.parentItemId },
});
await tx.item.update({ where: { id }, data: { active: false } });
return count;
});
await db.auditEvent.create({ await db.auditEvent.create({
data: { action: "item.delete", entityId: id, actorId: ctx.userId, payload: { name: item.name, via: ctx.via } }, data: {
action: "item.delete",
entityId: id,
actorId: ctx.userId,
payload: { name: item.name, reparentedChildren: reparented, via: ctx.via },
},
}); });
} }
@@ -163,6 +200,7 @@ export async function addItemLog(ctx: AuthContext, itemId: string, input: ItemLo
date: input.date ? new Date(input.date) : new Date(), date: input.date ? new Date(input.date) : new Date(),
notes: input.notes?.trim() || null, notes: input.notes?.trim() || null,
cost: input.cost ?? null, cost: input.cost ?? null,
amount: input.amount ?? null,
actorId: ctx.userId, actorId: ctx.userId,
}, },
}); });

View File

@@ -0,0 +1,68 @@
// Bulk Location setup — create a nested tree from a pasted outline, and
// (optionally) sweep existing top-level garden areas under a parent. DB-touching;
// parsing is pure (src/lib/locations/outline.ts).
import { db } from "@/lib/db";
import type { AuthContext } from "@/lib/api/authenticate";
import { recomputeAllPaths } from "@/lib/locations/db";
import { parseLocationOutline } from "@/lib/locations/outline";
export type SetupSummary = { created: number; moved: number };
export async function bulkSetupLocations(
ctx: AuthContext,
opts: { outline: string; groupGardenUnder?: string },
): Promise<SetupSummary> {
const nodes = parseLocationOutline(opts.outline);
// find-or-create by (name, parentId), case-insensitive.
const rows = await db.location.findMany({ select: { id: true, name: true, parentId: true } });
const cache = new Map<string, string>();
for (const r of rows) cache.set(`${r.parentId ?? ""}:${r.name.toLowerCase()}`, r.id);
let created = 0;
const stack: { depth: number; id: string }[] = []; // ancestor chain
for (const node of nodes) {
while (stack.length && stack[stack.length - 1].depth >= node.depth) stack.pop();
const parentId = stack.length ? stack[stack.length - 1].id : null;
const key = `${parentId ?? ""}:${node.name.toLowerCase()}`;
let id = cache.get(key);
if (!id) {
const loc = await db.location.create({ data: { name: node.name, kind: "OTHER", parentId } });
id = loc.id;
cache.set(key, id);
created++;
}
stack.push({ depth: node.depth, id });
}
// Sweep existing top-level places that hold plants under the named parent.
let moved = 0;
if (opts.groupGardenUnder) {
const parent = await db.location.findFirst({
where: { active: true, parentId: null, name: { equals: opts.groupGardenUnder, mode: "insensitive" } },
select: { id: true },
});
if (parent) {
const gardenAreas = await db.location.findMany({
where: {
active: true,
parentId: null,
id: { not: parent.id },
plants: { some: { active: true } },
},
select: { id: true },
});
for (const a of gardenAreas) {
await db.location.update({ where: { id: a.id }, data: { parentId: parent.id } });
}
moved = gardenAreas.length;
}
}
if (created > 0 || moved > 0) await recomputeAllPaths();
await db.auditEvent.create({
data: { action: "location.bulk_setup", actorId: ctx.userId, payload: { created, moved, via: ctx.via } },
});
return { created, moved };
}

295
src/lib/services/pantry.ts Normal file
View File

@@ -0,0 +1,295 @@
// Pantry service — DB work + AuditEvent for consumable items (Kitchen →
// Pantry & Freezer). Shared by the /api/v1/pantry routes, the UI (which calls
// those routes), and later the MCP server. Pure logic lives in
// src/lib/pantry/core.ts.
import { db } from "@/lib/db";
import { ApiError, type AuthContext } from "@/lib/api/authenticate";
import {
normalizeTags,
planBulkUse,
serializePantryItem,
type PantryItemDto,
} from "@/lib/pantry/core";
import type {
PantryBulkUseInput,
PantryCreateInput,
PantryUpdateInput,
PantryUseInput,
} from "@/lib/api/schemas";
const PANTRY_SELECT = {
id: true, name: true, description: true, quantity: true, unit: true,
storedAt: true, bestBefore: true, source: true, plantId: true, notes: true,
pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true,
location: { select: { id: true, name: true } },
plant: { select: { id: true, commonName: true, variety: true } },
labels: { select: { label: { select: { id: true, name: true } } } },
} as const;
function toDate(s?: string | null): Date | null {
return s ? new Date(s) : null;
}
/** Upsert labels for the tag names, then replace this item's label links. */
async function syncTags(itemId: string, tags: string[]) {
const names = normalizeTags(tags);
const labels = await Promise.all(
names.map(name =>
db.label.upsert({ where: { name }, update: {}, create: { name }, select: { id: true } }),
),
);
await db.labelOnItem.deleteMany({ where: { itemId } });
if (labels.length) {
await db.labelOnItem.createMany({
data: labels.map(l => ({ itemId, labelId: l.id })),
skipDuplicates: true,
});
}
}
export async function listPantry(_ctx: AuthContext): Promise<PantryItemDto[]> {
const items = await db.item.findMany({
where: { kind: "CONSUMABLE", active: true },
orderBy: [{ location: { name: "asc" } }, { name: "asc" }],
select: PANTRY_SELECT,
});
return items.map(serializePantryItem);
}
export async function createPantryItem(
ctx: AuthContext,
input: PantryCreateInput,
): Promise<PantryItemDto> {
const item = await db.item.create({
data: {
name: input.name,
description: input.description ?? input.packageSize ?? null,
kind: "CONSUMABLE",
quantity: input.quantity,
unit: input.unit ?? null,
locationId: input.locationId ?? null,
storedAt: toDate(input.storedAt),
bestBefore: toDate(input.bestBefore),
source: input.source ?? null,
plantId: input.plantId ?? null,
pantryCategory: input.pantryCategory ?? null,
pantrySubcategory: input.pantrySubcategory ?? null,
pricePaid: input.pricePaid ?? null,
priceUnit: input.priceUnit ?? null,
notes: input.notes ?? null,
},
select: { id: true, name: true },
});
if (input.tags?.length) await syncTags(item.id, input.tags);
await db.auditEvent.create({
data: {
action: "pantry.create",
entityId: item.id,
actorId: ctx.userId,
payload: { name: item.name, via: ctx.via },
},
});
const full = await db.item.findUniqueOrThrow({ where: { id: item.id }, select: PANTRY_SELECT });
return serializePantryItem(full);
}
export async function updatePantryItem(
ctx: AuthContext,
id: string,
input: PantryUpdateInput,
): Promise<PantryItemDto> {
const existing = await db.item.findUnique({ where: { id }, select: { active: true, kind: true } });
if (!existing || !existing.active || existing.kind !== "CONSUMABLE")
throw new ApiError(404, "Pantry item not found");
await db.item.update({
where: { id },
data: {
...(input.name !== undefined && { name: input.name }),
...(input.description !== undefined && { description: input.description ?? input.packageSize ?? null }),
...(input.quantity !== undefined && { quantity: input.quantity }),
...(input.unit !== undefined && { unit: input.unit ?? null }),
...(input.locationId !== undefined && { locationId: input.locationId ?? null }),
...(input.storedAt !== undefined && { storedAt: toDate(input.storedAt) }),
...(input.bestBefore !== undefined && { bestBefore: toDate(input.bestBefore) }),
...(input.source !== undefined && { source: input.source ?? null }),
...(input.plantId !== undefined && { plantId: input.plantId ?? null }),
...(input.pantryCategory !== undefined && { pantryCategory: input.pantryCategory ?? null }),
...(input.pantrySubcategory !== undefined && { pantrySubcategory: input.pantrySubcategory ?? null }),
...(input.pricePaid !== undefined && { pricePaid: input.pricePaid ?? null }),
...(input.priceUnit !== undefined && { priceUnit: input.priceUnit ?? null }),
...(input.notes !== undefined && { notes: input.notes ?? null }),
},
});
if (input.tags !== undefined) await syncTags(id, input.tags ?? []);
const item = await db.item.findUniqueOrThrow({ where: { id }, select: PANTRY_SELECT });
await db.auditEvent.create({
data: {
action: "pantry.update",
entityId: id,
actorId: ctx.userId,
payload: { name: item.name, via: ctx.via },
},
});
return serializePantryItem(item);
}
export async function deletePantryItem(ctx: AuthContext, id: string): Promise<void> {
const existing = await db.item.findUnique({ where: { id }, select: { active: true, kind: true, name: true } });
if (!existing || !existing.active || existing.kind !== "CONSUMABLE")
throw new ApiError(404, "Pantry item not found");
await db.item.update({ where: { id }, data: { active: false } });
await db.auditEvent.create({
data: {
action: "pantry.delete",
entityId: id,
actorId: ctx.userId,
payload: { name: existing.name, via: ctx.via },
},
});
}
/** Pull a single item; wraps bulkUsePantry so both paths log identically. */
export async function usePantryItem(
ctx: AuthContext,
id: string,
input: PantryUseInput,
): Promise<{ remaining: number }> {
const result = await bulkUsePantry(ctx, {
pulls: [{ id, amount: input.amount }],
notes: input.notes ?? null,
reason: input.reason ?? null,
});
if (result.skipped.length) throw new ApiError(404, "Pantry item not found");
return { remaining: result.results[0].remaining };
}
export type BulkUseResult = {
results: { id: string; remaining: number }[];
/** Requested items that no longer exist — the UI must tell the user. */
skipped: { id: string }[];
};
export async function bulkUsePantry(
ctx: AuthContext,
input: PantryBulkUseInput,
): Promise<BulkUseResult> {
const ids = input.pulls.map(p => p.id);
const notes = input.notes?.trim() || null;
const reason = input.reason ?? null;
return db.$transaction(async tx => {
const items = await tx.item.findMany({
where: { id: { in: ids }, kind: "CONSUMABLE", active: true },
select: { id: true, name: true, quantity: true, unit: true },
});
const plan = planBulkUse(
items.map(i => ({ id: i.id, name: i.name, quantity: Number(i.quantity), unit: i.unit })),
input.pulls,
);
for (const u of plan.updates) {
await tx.item.update({ where: { id: u.id }, data: { quantity: u.remaining } });
await tx.itemLog.create({
data: {
itemId: u.id,
type: "USED",
amount: u.amount,
notes,
reason,
actorId: ctx.userId,
},
});
}
await tx.auditEvent.create({
data: {
action: "pantry.use",
actorId: ctx.userId,
payload: {
pulls: plan.updates.map(u => ({ id: u.id, name: u.name, amount: u.amount })),
skipped: plan.skipped,
notes,
reason,
via: ctx.via,
},
},
});
return {
results: plan.updates.map(u => ({ id: u.id, remaining: u.remaining })),
skipped: plan.skipped.map(id => ({ id })),
};
});
}
/** Distinct names + most-recent settings, categories, and labels for autocomplete. */
export async function pantryCatalog(_ctx: AuthContext, q?: string) {
const query = q?.trim() ?? "";
// All consumables, active + inactive, so old entries keep autocompleting.
const items = await db.item.findMany({
where: {
kind: "CONSUMABLE",
...(query ? { name: { contains: query, mode: "insensitive" } } : {}),
},
orderBy: { updatedAt: "desc" },
select: {
name: true, description: true, unit: true, source: true,
pantryCategory: true, pantrySubcategory: true,
priceUnit: true, pricePaid: true,
locationId: true, plantId: true,
location: { select: { id: true, name: true } },
},
take: 200,
});
// Deduplicate by name (case-insensitive), keeping the most-recent per name.
const seen = new Map<string, (typeof items)[number]>();
for (const item of items) {
const key = item.name.toLowerCase();
if (!seen.has(key)) seen.set(key, item);
}
const [allCats, allLabels] = await Promise.all([
db.item.findMany({
where: { kind: "CONSUMABLE", pantryCategory: { not: null } },
select: { pantryCategory: true, pantrySubcategory: true },
distinct: ["pantryCategory", "pantrySubcategory"],
}),
db.label.findMany({ select: { name: true }, orderBy: { name: "asc" } }),
]);
const categories = Array.from(new Set(allCats.map(c => c.pantryCategory).filter(Boolean))) as string[];
const subcategories = allCats
.filter(c => c.pantrySubcategory)
.map(c => ({ category: c.pantryCategory!, subcategory: c.pantrySubcategory! }));
return {
suggestions: Array.from(seen.values()).map(i => ({
name: i.name,
description: i.description,
unit: i.unit,
source: i.source,
pantryCategory: i.pantryCategory,
pantrySubcategory: i.pantrySubcategory,
priceUnit: i.priceUnit,
pricePaid: i.pricePaid != null ? Number(i.pricePaid) : null,
locationId: i.locationId,
locationName: i.location?.name ?? null,
plantId: i.plantId,
})),
categories,
subcategories,
labels: allLabels.map(l => l.name),
};
}

View File

@@ -2,6 +2,7 @@
// and (later) the MCP server, so the logic lives once. // and (later) the MCP server, so the logic lives once.
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { ApiError, type AuthContext } from "@/lib/api/authenticate"; import { ApiError, type AuthContext } from "@/lib/api/authenticate";
import { createPantryItem } from "@/lib/services/pantry";
import type { PlantLogInput } from "@/lib/api/schemas"; import type { PlantLogInput } from "@/lib/api/schemas";
export function listPlants(_ctx: AuthContext, opts?: { q?: string }) { export function listPlants(_ctx: AuthContext, opts?: { q?: string }) {
@@ -32,16 +33,24 @@ export async function getPlant(_ctx: AuthContext, id: string) {
} }
export async function addPlantLog(ctx: AuthContext, plantId: string, input: PlantLogInput) { export async function addPlantLog(ctx: AuthContext, plantId: string, input: PlantLogInput) {
const plant = await db.plant.findUnique({ where: { id: plantId }, select: { active: true } }); const plant = await db.plant.findUnique({
where: { id: plantId },
select: { active: true, commonName: true, variety: true },
});
if (!plant || !plant.active) throw new ApiError(404, "Plant not found"); if (!plant || !plant.active) throw new ApiError(404, "Plant not found");
if (input.pantry && input.type !== "HARVEST")
throw new ApiError(400, "Only harvests can go to the pantry");
const date = input.date ? new Date(input.date) : new Date();
const log = await db.plantLog.create({ const log = await db.plantLog.create({
data: { data: {
plantId, plantId,
type: input.type, type: input.type,
date: input.date ? new Date(input.date) : new Date(), date,
notes: input.notes?.trim() || null, notes: input.notes?.trim() || null,
quantity: input.quantity?.trim() || null, quantity: input.quantity?.trim() || null,
amount: input.amount ?? null,
unit: input.unit?.trim() || null,
actorId: ctx.userId, actorId: ctx.userId,
}, },
}); });
@@ -53,5 +62,23 @@ export async function addPlantLog(ctx: AuthContext, plantId: string, input: Plan
payload: { type: input.type, via: ctx.via }, payload: { type: input.type, via: ctx.via },
}, },
}); });
return log;
// Harvest → Pantry: land the produce where the cooking happens.
let pantryItem = null;
if (input.pantry) {
pantryItem = await createPantryItem(ctx, {
name: plant.variety ? `${plant.commonName} (${plant.variety})` : plant.commonName,
quantity: input.amount ?? 1,
unit: input.unit?.trim() || null,
locationId: input.pantry.locationId ?? null,
storedAt: date.toISOString(),
bestBefore: input.pantry.bestBefore ?? null,
source: "GARDEN",
plantId,
pantryCategory: "Garden produce",
notes: input.notes?.trim() || null,
});
}
return { ...log, pantryItem };
} }

View File

@@ -7,7 +7,12 @@ export function listTasks(_ctx: AuthContext) {
return db.task.findMany({ return db.task.findMany({
where: { active: true }, where: { active: true },
orderBy: { nextDue: "asc" }, orderBy: { nextDue: "asc" },
include: { plant: { select: { id: true, commonName: true } } }, include: {
plant: { select: { id: true, commonName: true } },
item: { select: { id: true, name: true } },
animal: { select: { id: true, name: true } },
location: { select: { id: true, name: true } },
},
}); });
} }
@@ -18,6 +23,9 @@ export async function createTask(ctx: AuthContext, input: TaskCreateInput) {
notes: input.notes?.trim() || null, notes: input.notes?.trim() || null,
category: input.category, category: input.category,
plantId: input.plantId || null, plantId: input.plantId || null,
itemId: input.itemId || null,
animalId: input.animalId || null,
locationId: input.locationId || null,
recurrence: input.recurrence, recurrence: input.recurrence,
intervalDays: input.intervalDays ?? null, intervalDays: input.intervalDays ?? null,
month: input.month ?? null, month: input.month ?? null,

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { nextDueDate, occurrencesInRange, type ProjectableTask } from "./schedule";
const d = (s: string) => new Date(`${s}T00:00:00`);
describe("nextDueDate", () => {
it("ONCE never recurs", () => {
expect(nextDueDate("ONCE", d("2026-07-06"), null, null)).toBeNull();
});
it("CUSTOM adds the interval (default 30)", () => {
expect(nextDueDate("CUSTOM", d("2026-07-06"), 14, null)).toEqual(d("2026-07-20"));
expect(nextDueDate("CUSTOM", d("2026-07-06"), null, null)).toEqual(d("2026-08-05"));
});
it("MONTHLY moves to next month", () => {
expect(nextDueDate("MONTHLY", d("2026-07-06"), null, null)).toEqual(d("2026-08-06"));
});
it("YEARLY lands on the 1st of the target month next year", () => {
expect(nextDueDate("YEARLY", d("2026-07-06"), null, 4)).toEqual(d("2027-04-01"));
});
});
function task(overrides: Partial<ProjectableTask>): ProjectableTask {
return { nextDue: d("2026-07-10"), recurrence: "ONCE", intervalDays: null, month: null, ...overrides };
}
describe("occurrencesInRange", () => {
it("ONCE: shows only on its due date", () => {
const t = task({});
expect(occurrencesInRange(t, d("2026-07-01"), d("2026-07-31"))).toEqual([d("2026-07-10")]);
expect(occurrencesInRange(t, d("2026-08-01"), d("2026-08-31"))).toEqual([]);
});
it("CUSTOM: repeats by interval within the range", () => {
const t = task({ recurrence: "CUSTOM", intervalDays: 7 });
expect(occurrencesInRange(t, d("2026-07-01"), d("2026-07-31"))).toEqual([
d("2026-07-10"), d("2026-07-17"), d("2026-07-24"), d("2026-07-31"),
]);
});
it("CUSTOM: projects into a later month without drifting", () => {
const t = task({ recurrence: "CUSTOM", intervalDays: 14 });
expect(occurrencesInRange(t, d("2026-09-01"), d("2026-09-30"))).toEqual([
d("2026-09-04"), d("2026-09-18"),
]);
});
it("an overdue occurrence stays visible in its own (past) month", () => {
const t = task({ recurrence: "CUSTOM", intervalDays: 30, nextDue: d("2026-06-20") });
expect(occurrencesInRange(t, d("2026-06-01"), d("2026-06-30"))).toEqual([d("2026-06-20")]);
});
it("MONTHLY: same day each month, clamped at short months", () => {
const t = task({ recurrence: "MONTHLY", nextDue: d("2026-01-31") });
expect(occurrencesInRange(t, d("2026-02-01"), d("2026-02-28"))).toEqual([d("2026-02-28")]);
expect(occurrencesInRange(t, d("2026-03-01"), d("2026-03-31"))).toEqual([d("2026-03-31")]);
});
it("MONTHLY: never projects before the stored due date", () => {
const t = task({ recurrence: "MONTHLY", nextDue: d("2026-07-15") });
expect(occurrencesInRange(t, d("2026-06-01"), d("2026-06-30"))).toEqual([]);
});
it("YEARLY: shows the stored due and the same date next year", () => {
const t = task({ recurrence: "YEARLY", nextDue: d("2026-10-01"), month: 10 });
expect(occurrencesInRange(t, d("2026-10-01"), d("2026-10-31"))).toEqual([d("2026-10-01")]);
expect(occurrencesInRange(t, d("2027-10-01"), d("2027-10-31"))).toEqual([d("2027-10-01")]);
});
it("returns nothing for an inverted range", () => {
expect(occurrencesInRange(task({}), d("2026-07-31"), d("2026-07-01"))).toEqual([]);
});
});

View File

@@ -74,3 +74,90 @@ export const MONTH_NAMES = [
"January", "February", "March", "April", "May", "June", "January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December", "July", "August", "September", "October", "November", "December",
]; ];
// ---------------------------------------------------------------------------
// Occurrence projection — which days does a task land on within a date range?
// The DB stores only the next due date; recurring tasks repeat from there.
// Used by the calendar to paint future (and the one overdue) occurrence(s).
// ---------------------------------------------------------------------------
export type ProjectableTask = {
nextDue: Date;
recurrence: RecurrenceType;
intervalDays: number | null;
month: number | null; // 112, for YEARLY
};
/** Same day-of-month in the given month, clamped to its last day (Jan 31 → Feb 28). */
function onDayOfMonth(year: number, monthIdx: number, day: number): Date {
const lastDay = new Date(year, monthIdx + 1, 0).getDate();
return new Date(year, monthIdx, Math.min(day, lastDay));
}
function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
/**
* All days in [rangeStart, rangeEnd] (inclusive) the task lands on.
* The stored nextDue is always an occurrence (even if overdue/in the past);
* recurring tasks project forward from it, never backward.
*/
export function occurrencesInRange(
task: ProjectableTask,
rangeStart: Date,
rangeEnd: Date,
): Date[] {
const due = startOfDay(new Date(task.nextDue));
const start = startOfDay(rangeStart);
const end = startOfDay(rangeEnd);
if (start > end) return [];
const inRange = (d: Date) => d >= start && d <= end;
const out: Date[] = [];
const push = (d: Date) => {
if (inRange(d) && !out.some((o) => o.getTime() === d.getTime())) out.push(d);
};
switch (task.recurrence) {
case "ONCE": {
push(due);
break;
}
case "CUSTOM": {
const step = Math.max(1, task.intervalDays ?? 30);
push(due); // the stored (possibly overdue) occurrence stays visible
let d = due;
if (d < start) {
// Jump close to the range start instead of stepping day by day.
const behind = Math.ceil((start.getTime() - d.getTime()) / (step * 86_400_000));
d = new Date(d.getTime() + behind * step * 86_400_000);
}
for (; d <= end; d = new Date(d.getTime() + step * 86_400_000)) push(d);
break;
}
case "MONTHLY": {
const day = due.getDate();
push(due);
let y = start.getFullYear();
let m = start.getMonth();
for (; new Date(y, m, 1) <= end; m === 11 ? (m = 0, y++) : m++) {
const d = onDayOfMonth(y, m, day);
if (d >= due) push(d);
}
break;
}
case "YEARLY": {
push(due);
const targetMonth = (task.month ?? due.getMonth() + 1) - 1;
const day = due.getDate();
for (let y = start.getFullYear(); y <= end.getFullYear(); y++) {
const d = onDayOfMonth(y, targetMonth, day);
if (d > due) push(d);
}
break;
}
}
return out.sort((a, b) => a.getTime() - b.getTime());
}

View File

@@ -1,2 +1,2 @@
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`. // Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
export const APP_VERSION = "0.14.0"; export const APP_VERSION = "0.30.0";

Some files were not shown because too many files have changed in this diff Show More