Compare commits

...

40 Commits

Author SHA1 Message Date
7d7b119c1a chore(deps): repoint @otm npm registry to git.poweredbyotm.com (Gitea moved off NAS)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ejs8EnZkpFGysCpVxotuqf
2026-07-15 10:59:07 -05:00
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
cffa739056 docs: add Animals module to README + CLAUDE.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:28:51 -05:00
39be24c2fc v0.14.0 — Animals & Pets
Livestock and household pets as leaves on the Location tree, mirroring Garden.

- Animal model (species/breed/sex/birthdate/location, soft-delete) + AnimalLog
  (feeding, vet, weight, medication, breeding, acquired, died); Task.animalId for
  recurring care reminders
- API-first /api/v1/animals (CRUD) + logs, animals:read/write scopes; animals
  service shared with the coming MCP
- Animals pages: list grouped by species with auto-computed age, detail with
  facts + latest weight + care log; add/edit/remove + log entry. Nav entry.
  Backup/restore cover the new tables
- Pure age.ts (newborn/days/months/years) with tests; verified end-to-end against
  a running server (create, vet + weigh-in logs, read-back)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:23:53 -05:00
777569a21a docs: add README, refresh CLAUDE.md for current modules + git-checkout deploy
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:18:06 -05:00
b0118d3fec v0.13.0 — Inventory (HomeBox): durable items that nest
The first inventory vertical on the shared spine. Things you own — tools, gear,
equipment — each living in a Location or inside another Item.

- Item model (kind DURABLE|CONSUMABLE; consumable fields present but unused so
  Pantry needs no migration), self-nesting via parentItemId (toolbox holds drill,
  with a cycle guard), value/warranty/serial/brand/qty, qrSlug, photo
- Label (m2m) + ItemAttachment (photos/manuals/receipts) + ItemLog
  (maintenance/repair/move/dispose) history; Task.itemId
- API-first: /api/v1/items (CRUD), labels, logs, multipart attachments — the UI
  consumes the same scoped endpoints. items service shared with the coming MCP
- Inventory pages: tree grouped by Location with items nested inside items;
  item detail with warranty status, photos, contents, history; add/edit/delete,
  log, photo upload. Nav entry. Backup/restore cover the new tables
- Pure value.ts (warrantyStatus, totalValue) with tests; verified end-to-end
  against a running server (nest, cycle-guard 400, log 201)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:12:03 -05:00
0904790dca Security: force first-login password change, gate admin tools, validate restore
Fixes from the code review:
- #1 Wire mustChangePassword: dashboard layout redirects to a new /change-password
  page + /api/account/change-password (verifies current pw, re-hashes, clears the
  flag, refreshes the session). Provisioned admins can no longer keep the default.
- #2 Assert NEXTAUTH_SECRET in production (not during build); set it on authOptions.
- #3 Backup page now server-gated to admins (split into server page + client panel),
  matching the Updates page.
- #4 Restore validates input: strict zod schema for the users table (known fields,
  role enum) + array checks for the rest; returns generic errors instead of String(err).

No version bump/changelog — left for you to fold into the next release (you're
editing those live). Not pushed, not deployed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:59:39 -05:00
c5bf108cb9 v0.12.0 — REST API + personal access tokens
A bidirectional API so other apps (Home Assistant, phone shortcuts, scripts,
and the coming MCP server) can read and update the homestead.

- ApiToken model (sha256-hashed, scoped, with prefix/lastUsed/expiry/revoke)
- authenticateRequest() accepts EITHER a NextAuth cookie session OR a Bearer
  PAT; cookie sessions get full access, tokens are limited to their scopes
  (write implies read; resource and global wildcards). ApiError + handleApiError
- Shared service layer (plants/tasks/locations/plant-types) reused by every
  endpoint and, later, the MCP server — logic lives once
- /api/v1: plants (read + add log), plant-types, locations, tasks (read/create/
  complete), and a whoami/discovery root
- Settings → API tokens: create with per-resource read/write checkboxes, raw
  token shown once, revoke; tokens added to backup/restore
- Pure tests for scope matching + token hashing; verified end-to-end (401/403/
  201, lastUsedAt) against a running server

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:55:58 -05:00
cdb21ed58a v0.11.0 — Plant types: the reusable "kind" record
A rich-but-all-optional reference record per kind of plant (care, sun, water,
bloom & harvest windows, toxic-to-pets, edible parts, spacing), distinct from a
specific planting or a group.

- PlantType model + optional Plant.plantTypeId; migration + idempotent backfill
  that seeds 247 kinds from the built-in plant library and links plantings by
  species/common name (wired into the deploy entrypoint)
- /api/plant-types CRUD; new "Plant types" section (browse by category, edit a
  kind, see every spot you've planted it)
- New plants auto-link to their type on create; plant detail shows "More about…"
- PlantType added to backup/restore; bump APP_VERSION 0.11.0 + changelog

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:46:07 -05:00
9c0312676a v0.10.0 — Location spine: garden moved onto a shared place tree
Completes the Location feature (schema + migration landed earlier under 98cabc5).

- Hierarchical Location model is the shared spine; unlimited nesting
- Garden add/edit now picks a reusable Location (inline create) instead of
  freeform text; garden groups By Location off the real tree
- Dual-write keeps legacy Plant.zone in sync with location.name (Release A)
- /api/locations CRUD + /api/locations/[id]/log (logs a location + its sub-locations)
- Location tree pure-core (buildTree/computePath/descendantIds) with tests
- Location added to backup/restore export
- Bump APP_VERSION 0.10.0 + plain-English changelog entry

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:36:54 -05:00
168 changed files with 12921 additions and 587 deletions

BIN
.DS_Store vendored

Binary file not shown.

2
.npmrc
View File

@@ -1 +1 @@
@otm:registry=http://192.168.0.5:3022/api/packages/tonym/npm/ @otm:registry=https://git.poweredbyotm.com/api/packages/tonym/npm/

177
CLAUDE.md
View File

@@ -1,73 +1,144 @@
# Moon Base — Project Notes # Moon Base — Project Notes
Property management for the Moon homestead (Eau Claire, WI) — food forest plant Property management for the Moon homestead (Eau Claire, WI). Next.js 14 + Prisma +
registry, home maintenance tracking, and equipment logs. Next.js 14 + Prisma +
Postgres. Same conventions as FMB (`/Users/bonnamoon/Projects/fullmoonbakehouse`). Postgres. Same conventions as FMB (`/Users/bonnamoon/Projects/fullmoonbakehouse`).
Repo on Gitea: **`bonna61/Moonbase`**. Live at NAS:8053. See `README.md` for the
user-facing overview.
## Modules ## Modules (all live)
- **v0.1 — Garden:** food forest plant registry + care/harvest log. Live. - **Garden** — Plant registry + care/harvest logs, plant **groups**, reusable
- **Home maintenance** — planned next: systems, recurring tasks, repair history. **PlantType** records, Wikipedia photos. Plants sit on the shared location tree.
- **Equipment** — planned: tools/machines, service intervals, maintenance logs. Harvest logs carry structured `amount`+`unit` (legacy freeform `quantity` kept
for old rows — don't regress); a harvest can simultaneously create a pantry
item (`addPlantLog`'s `pantry` input → GARDEN-source consumable). PlantTypes
can link a 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
tree, and `Task`s can link to an animal (`Task.animalId`).
- **Locations** — `Location` tree (self-ref `parentId`) shared by plants, items, animals.
- **Reminders** — `Task` + `TaskCompletion`; recurrence in `src/lib/tasks/schedule.ts`
(incl. `occurrencesInRange` projection). **/calendar** renders a month view of
all tasks (garden + house) plus PlantType bloom/harvest windows
(`src/lib/garden/season.ts`) and garden-brain suggestions.
- **Garden brain** — `src/lib/garden/knowledge.ts`: hand-curated zone-4b
(~May 15 frost) care timing, companions/foes, rotation families for ~28
crops; pure matchers `knowledgeFor`/`tipsForMonth` (whole-word stem match —
beware "Pear"/"Pea"-style false hits, tested). Surfaced as calendar
"Good to do this month" (one-click YEARLY task via /api/v1/tasks) and a
"Growing in zone 4b" card on type pages. Tips cite UW Extension via site
search URLs — never fabricate article URLs.
- **REST API** — `/api/v1/*` authed by `ApiToken` (per-scope; logic in `src/lib/api/`).
- **Suggestions** — `@otm/account-panel` (Gitea npm registry) → files issues to the repo.
- **Backup & Restore** — `/api/admin/backup` + `/api/admin/restore` (admin only).
- **Self-update** — admin "Pull & rebuild" (see Deploy).
## Data model (the one rule)
**Two nesting worlds, crossed once.** *Places* (`Location`, self-ref `parentId`,
unlimited depth — `LocationKind` is an icon hint, never a constraint) nest in
places. *Items* (things you own) nest in items, and an Item lives in a Place **or**
inside another Item. You never put a Place inside an Item. **Plants** and
**Animals** are leaves in a Place (own logs, don't nest). HomeBox + Grocy + farmOS
collapse into: **Places · Plants/Animals (leaves) · Items (durable + consumable,
nesting)**. Logs are per-domain tables (`PlantLog`/`ItemLog`/`AnimalLog`), NOT a
polymorphic table; `Task` carries nullable `plantId`/`itemId`/`animalId`.
## Architecture conventions
- **API-first.** New modules (Inventory, Animals) are built on `/api/v1/*` and the
UI calls those same endpoints — no separate UI routes. Garden/Tasks predate this
and still use `/api/plants` etc.
- **Service layer** `src/lib/services/*` does DB work + AuditEvent, taking
`(ctx: AuthContext, input)`. Routes are thin: `authenticateRequest(req, scope)`
service → `handleApiError`. The MCP server (unbuilt) will reuse these services.
- **Auth helper** `src/lib/api/authenticate.ts` accepts EITHER a NextAuth cookie
session (full scope `["*"]`) OR a Bearer PAT (limited to its `scopes`; write
implies read). Scope constants in `src/lib/api/scopes.ts` (client-safe).
- **Decimals** for money/quantity (`@db.Decimal`) — convert to `Number()` before
passing to client components (Prisma Decimal isn't serializable across the boundary).
## Blocked on a domain + HTTPS (deferred)
Camera scanning/photo-capture (`getUserMedia` needs a secure context), the **PWA**
(service worker + install need HTTPS), and the **MCP server** (Claude needs a public
HTTPS URL) all wait until there's a domain + reverse-proxy/tunnel. The app is
LAN-only over `http://NAS:8053` today. Pantry/consumables and other pure web work
can ship before then.
## Local dev ## Local dev
- DB: `postgresql://tonym@localhost:5432/moonbase_dev` - DB: `postgresql://tonym@localhost:5432/moonbase_dev`; copy `.env.example``.env`.
- Copy `.env.example``.env` and fill in values. - `npm install`, `npm run dev` (3000). After schema edits: `npx prisma generate`.
- `npm install` then `npm run dev` (port 3000) - `npx prisma migrate deploy` provisions Bonna + Tony. `npm run db:seed` for demo
- After schema edits: `npx prisma generate` then restart dev server. data (`SEED_MODE=minimal` = accounts only).
- Seed demo data: `npm run db:seed` (creates Bonna + Tony accounts + sample plants) - Backfills for the location/plant-type refactors: `npm run db:backfill-locations`,
- Minimal seed (admin accounts only): `SEED_MODE=minimal npm run db:seed` `npm run db:backfill-plant-types`.
- Apply migrations: `npx prisma migrate deploy`
## First-run setup (brand new machine / fresh DB) ## Deploy — git checkout on the NAS (NOT rsync)
1. `npm install` > Working without this file (e.g. a Claude on another machine)? The self-contained
2. Create `.env` from `.env.example` > version is `docs/git-deploy-guide.md`.
3. `npx prisma migrate deploy` — creates the DB schema + provisions Bonna and Tony accounts
4. Default password for both is `moonbase!` with `mustChangePassword=true` — change on first login. The NAS appdata (`/mnt/user/appdata/moonbase`) is a real **git checkout** tracking
`origin/master` (remote URL carries the token). Deploy by:
```sh
git push origin master
ssh root@192.168.0.5 'cd /mnt/user/appdata/moonbase && git pull && docker compose up -d --build'
```
Or admins click **Settings → Updates → Pull & rebuild**, which signals the
privileged `updater` sidecar (docker socket + adnanh/webhook) to run the same
`git pull && compose up -d --build app` detached; the UI polls `/api/version` and
reloads. **Never rsync over the appdata dir** — it fights the checkout.
- **Secrets** (`NEXTAUTH_SECRET`, `GITEA_TOKEN`, `UPDATER_SECRET`) live in a
gitignored host `.env` next to the compose file, interpolated in — never committed.
- **Docker runtime** mirrors FMB: `node:20-bookworm-slim` + openssl, runs as root,
full `node_modules`, `next start`. Migrations run on container start via the
entrypoint (waits for Postgres first). Image gotcha: don't let `npx prisma`
resolve at runtime — with no local prisma on PATH it pulls Prisma 7, which
rejects `url` in the datasource.
## Conventions (same as FMB) ## Conventions (same as FMB)
- **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 at the top of `src/lib/changelog.ts`. Minor (0.X.0) for features/migrations, entry atop `src/lib/changelog.ts` (the footer shows it + an "update available"
patch for fixes. Write changelog for Bonna, not engineers. badge from `src/lib/update-check.ts`). Minor for features/migrations, patch for fixes.
- **AuditEvent on every mutation** — append-only, never delete. **Fetch first**: parallel sessions push to master — check
- **Soft-delete** via `active=false` for Plant (keeps log history intact). `git show origin/master:src/lib/version.ts` before picking a number
- **Pure core + colocated test** — new logic goes in `src/lib/...` as a pure (a v0.22.0 collision has already happened once).
function with a `.test.ts` next to it; API routes stay thin. - **AuditEvent on every mutation** — append-only.
- **Worktrees for parallel sessions** — same pattern as FMB. Use a worktree - **Soft-delete** via `active=false`.
(`git worktree add ../moonbase-<slug> -b <slug>`) if running two Claude sessions. - **Pure core + colocated test** — logic in `src/lib/...` with a `.test.ts`; routes thin.
## Git remote
Add Gitea remote once the repo is created there:
```sh
git remote add gitea http://192.168.0.5:3022/tonym/moonbase.git
git push gitea main
```
## Auth ## Auth
- Username OR email login (same as FMB). Accounts: `bonna` / `tony`, role ADMIN. - Username OR email login. Accounts `bonna` / `tony`, role `ADMIN`.
- `mustChangePassword=true` on provisioned accounts — user is prompted on next login - `getSession()` / `requireAuth()` / `isAdmin()` in `src/lib/auth.ts`.
(add that redirect to `src/app/(dashboard)/layout.tsx` when implementing it). - **Forced password change is wired** (provisioned accounts ship `moonbase!` +
- `getSession()` / `isAdmin()` in `src/lib/auth.ts`. `mustChangePassword=true`): the dashboard layout redirects to `/change-password`
(outside the layout group; backed by `/api/account/change-password`). Don't
## Garden module — data model remove that redirect.
- `NEXTAUTH_SECRET` is asserted at runtime in production (skipped during build).
- **Plant** — commonName, species (scientific), variety, category (PlantCategory enum),
zone (freeform text: "Back yard north fence"), plantedAt, source, notes, active.
- **PlantLog** — per-plant log: type (OBSERVATION / CARE / HARVEST / TREATMENT /
TRANSPLANT / DIED), date, notes, quantity (freeform, for harvests).
- Category labels + log type labels/colors live in `src/lib/garden/categories.ts`.
- API: `POST /api/plants` (create plant), `POST /api/plants/[id]/logs` (add log entry).
## Color palette ## Color palette
Forest / earth theme. Key CSS variables: Forest / earth theme: `--canopy` (sidebar), `--leaf` (accent), `--soil` (text),
- `--canopy` — dark forest green sidebar `--dew` (bg), `--ember` (destructive).
- `--leaf` — fresh green accent
- `--soil` / `--soil-muted` — body text
- `--dew` — pale sage background
- `--ember` — red for destructive actions

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

84
README.md Normal file
View File

@@ -0,0 +1,84 @@
# Moon Base
Property management for the Moon homestead (Eau Claire, WI). A single place to
track the food forest, household inventory, and recurring upkeep — built as a
Next.js 14 + Prisma + Postgres app, deployed self-hosted on the home NAS.
Live at **http://192.168.0.5:8053**.
## Modules
- **Garden** — plant registry with care/harvest/observation logs, plant **groups**,
reusable **plant types** (the "kind" record: care notes, sun/water, bloom &
harvest months, pet toxicity), Wikipedia photos, and plants placed on the shared
location tree.
- **Inventory** — durable **items** that nest (a toolbox holds the tools inside
it), with labels, photos + manuals/receipts/warranty docs, value & warranty
tracking (with expiry warnings), serial/brand, a sold record, and custom fields
(e.g. a vehicle's VIN). Schedule recurring **maintenance** per item. **Import
straight from HomeBox** via CSV. Shares the same location spine as the garden.
- **Animals** — pets & livestock with their own logs (feeding, vet, weight, meds)
and auto-computed age, placed on the shared location tree; reminders can be
linked to an animal.
- **Locations** — manage the whole place tree (areas, buildings, rooms, storage),
nested as deep as you like; everything (plants, items, animals) lives here.
- **Reminders** — one-off or recurring tasks (every N days / monthly / yearly),
optionally linked to a plant, item, or animal.
- **REST API** (`/api/v1/*`) — personal access **tokens** with per-scope
permissions (read plants, log harvests, …) for phone shortcuts, Home Assistant,
and automation.
- **Suggestions** — in-app feedback that files issues to Gitea (`@otm/account-panel`).
- **Backup & Restore** — download/restore the whole dataset as a zip (admin only).
- **Self-update** — admins get a "Pull & rebuild" button that updates the running
app to the latest pushed version (see Deploy).
## Tech
Next.js 14 (App Router) · Prisma · PostgreSQL · NextAuth (credentials) ·
Tailwind + shadcn/ui · Docker Compose.
## Local dev
```sh
npm install
cp .env.example .env # fill in DATABASE_URL + NEXTAUTH_SECRET
npx prisma migrate deploy # schema + provisions bonna/tony accounts
npm run db:seed # optional demo data (SEED_MODE=minimal for accounts only)
npm run dev # http://localhost:3000
```
After schema edits: `npx prisma generate` then restart. Data backfills for the
location/plant-type refactors: `npm run db:backfill-locations`,
`npm run db:backfill-plant-types`.
## Auth / first run
Username **or** email login. Provisioned accounts `bonna` / `tony` (role `ADMIN`)
ship with the default password `moonbase!` and `mustChangePassword=true` — the app
forces a password change on first sign-in.
## Deploy
The NAS appdata (`/mnt/user/appdata/moonbase`) is a **git checkout** of this repo.
Two ways to ship a change:
1. **In-app (admins):** Settings → Updates → **Pull & rebuild**. A privileged
`updater` sidecar runs `git pull && docker compose up -d --build app` and the
UI reloads on the new version.
2. **Manual:**
```sh
git push origin master
ssh root@192.168.0.5 'cd /mnt/user/appdata/moonbase && git pull && docker compose up -d --build'
```
Do **not** rsync over the appdata dir — it would fight the checkout. Secrets
(`NEXTAUTH_SECRET`, `GITEA_TOKEN`, `UPDATER_SECRET`) live in a gitignored host
`.env` next to the compose file, never committed. Migrations apply automatically
on container start. Full deploy walkthrough: [`docs/git-deploy-guide.md`](docs/git-deploy-guide.md).
## Conventions
Every commit bumps `APP_VERSION` (`src/lib/version.ts`) + a plain-English entry in
`src/lib/changelog.ts` (written for Bonna, shown in the footer). `AuditEvent` on
every mutation. Soft-delete via `active=false`. Pure logic in `src/lib/*` with
colocated tests; API routes stay thin. Repo on Gitea: `bonna61/Moonbase`.

View File

@@ -14,4 +14,7 @@ echo "Running Prisma migrations..."
echo "Running Location-spine backfill (idempotent)..." echo "Running Location-spine backfill (idempotent)..."
./node_modules/.bin/tsx prisma/scripts/backfill-locations.ts || echo " Backfill skipped/failed (non-fatal); check logs." ./node_modules/.bin/tsx prisma/scripts/backfill-locations.ts || echo " Backfill skipped/failed (non-fatal); check logs."
echo "Running plant-type backfill (idempotent)..."
./node_modules/.bin/tsx prisma/scripts/backfill-plant-types.ts || echo " Backfill skipped/failed (non-fatal); check logs."
exec "$@" exec "$@"

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": "https://git.poweredbyotm.com/api/packages/tonym/npm/%40otm%2Faccount-panel/-/0.22.1/account-panel-0.22.1.tgz",
"integrity": "sha512-E7uJ3Xxm8/xz6gcUAS4TUI+eCjaVZLFs7CjF7RpwSLUn3DpIszGSE4BN0519BQOThpQ69p3m15h/b64HLMgHgw==", "integrity": "sha512-JwPVqe5FPENt6nlwlZ91PX58Y2QvFINHLi385zH/f4tO5PvifDYVVqwmzF0TrQP9NaUwOiHJV2PgRdotlN3c7A==",
"peerDependencies": { "peerDependencies": {
"@stripe/react-stripe-js": ">=2",
"@stripe/stripe-js": ">=3",
"next": ">=14", "next": ">=14",
"next-auth": ">=4", "next-auth": ">=4",
"react": ">=18" "react": ">=18",
"stripe": ">=18"
},
"peerDependenciesMeta": {
"@stripe/react-stripe-js": {
"optional": true
},
"@stripe/stripe-js": {
"optional": true
},
"stripe": {
"optional": true
}
} }
}, },
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
@@ -1492,6 +1507,43 @@
} }
} }
}, },
"node_modules/@radix-ui/react-popover": {
"version": "1.1.17",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.17.tgz",
"integrity": "sha512-/YSAOdJ7YJvdn7bn5sdSx2egW+SKY+u7O5RyAVs94Ymrg2fg5QTSFPMRkzvhGyFuE4/qsmPBdrwYoZMZh/4f+g==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.4",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.1.4",
"@radix-ui/react-dismissable-layer": "1.1.13",
"@radix-ui/react-focus-guards": "1.1.4",
"@radix-ui/react-focus-scope": "1.1.10",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-popper": "1.3.1",
"@radix-ui/react-portal": "1.1.12",
"@radix-ui/react-presence": "1.1.6",
"@radix-ui/react-primitive": "2.1.6",
"@radix-ui/react-slot": "1.3.0",
"@radix-ui/react-use-controllable-state": "1.2.3",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.7.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": { "node_modules/@radix-ui/react-popper": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz",

View File

@@ -15,6 +15,7 @@
"db:migrate:deploy": "prisma migrate deploy", "db:migrate:deploy": "prisma migrate deploy",
"db:seed": "tsx prisma/seed.ts", "db:seed": "tsx prisma/seed.ts",
"db:backfill-locations": "tsx prisma/scripts/backfill-locations.ts", "db:backfill-locations": "tsx prisma/scripts/backfill-locations.ts",
"db:backfill-plant-types": "tsx prisma/scripts/backfill-plant-types.ts",
"db:studio": "prisma studio", "db:studio": "prisma studio",
"db:reset": "prisma migrate reset --force", "db:reset": "prisma migrate reset --force",
"db:backup": "sh scripts/db-backup.sh", "db:backup": "sh scripts/db-backup.sh",
@@ -22,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,42 @@
-- v0.11.0 — Plant types (the "kind" record)
-- A rich-but-all-optional reference record per kind of plant, plus an optional
-- link from each planting. Seeded/linked by prisma/scripts/backfill-plant-types.ts.
-- CreateTable
CREATE TABLE "PlantType" (
"id" TEXT NOT NULL,
"commonName" TEXT NOT NULL,
"species" TEXT,
"category" "PlantCategory" NOT NULL DEFAULT 'OTHER',
"careNotes" TEXT,
"sun" TEXT,
"water" TEXT,
"bloomStart" INTEGER,
"bloomEnd" INTEGER,
"harvestStart" INTEGER,
"harvestEnd" INTEGER,
"toxicToPets" BOOLEAN,
"edibleParts" TEXT,
"spacing" TEXT,
"imageUrl" TEXT,
"notes" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PlantType_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "PlantType_commonName_idx" ON "PlantType"("commonName");
CREATE INDEX "PlantType_category_idx" ON "PlantType"("category");
CREATE INDEX "PlantType_active_idx" ON "PlantType"("active");
-- AlterTable: optional link from a planting to its kind
ALTER TABLE "Plant" ADD COLUMN "plantTypeId" TEXT;
-- CreateIndex
CREATE INDEX "Plant_plantTypeId_idx" ON "Plant"("plantTypeId");
-- AddForeignKey
ALTER TABLE "Plant" ADD CONSTRAINT "Plant_plantTypeId_fkey" FOREIGN KEY ("plantTypeId") REFERENCES "PlantType"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,24 @@
-- v0.12.0 — API tokens (personal access tokens for the REST API + MCP)
-- CreateTable
CREATE TABLE "ApiToken" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"prefix" TEXT NOT NULL,
"scopes" TEXT[],
"userId" TEXT NOT NULL,
"lastUsedAt" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ApiToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ApiToken_tokenHash_key" ON "ApiToken"("tokenHash");
CREATE INDEX "ApiToken_userId_idx" ON "ApiToken"("userId");
-- AddForeignKey
ALTER TABLE "ApiToken" ADD CONSTRAINT "ApiToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,143 @@
-- CreateEnum
CREATE TYPE "ItemKind" AS ENUM ('DURABLE', 'CONSUMABLE');
-- CreateEnum
CREATE TYPE "ItemLogType" AS ENUM ('NOTE', 'MAINTENANCE', 'MOVED', 'PURCHASED', 'REPAIRED', 'DISPOSED');
-- AlterTable
ALTER TABLE "PlantGroup" ALTER COLUMN "updatedAt" DROP DEFAULT;
-- AlterTable
ALTER TABLE "Task" ADD COLUMN "itemId" TEXT;
-- CreateTable
CREATE TABLE "Item" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"kind" "ItemKind" NOT NULL DEFAULT 'DURABLE',
"quantity" DECIMAL(10,2) NOT NULL DEFAULT 1,
"unit" TEXT,
"locationId" TEXT,
"parentItemId" TEXT,
"value" DECIMAL(10,2),
"purchaseDate" TIMESTAMP(3),
"warrantyExpiry" TIMESTAMP(3),
"serial" TEXT,
"modelNumber" TEXT,
"brand" TEXT,
"barcode" TEXT,
"bestBefore" TIMESTAMP(3),
"minStock" DECIMAL(10,2),
"qrSlug" TEXT,
"imageUrl" TEXT,
"notes" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Item_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Label" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT,
CONSTRAINT "Label_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LabelOnItem" (
"itemId" TEXT NOT NULL,
"labelId" TEXT NOT NULL,
CONSTRAINT "LabelOnItem_pkey" PRIMARY KEY ("itemId","labelId")
);
-- CreateTable
CREATE TABLE "ItemAttachment" (
"id" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"url" TEXT NOT NULL,
"name" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ItemAttachment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ItemLog" (
"id" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"type" "ItemLogType" NOT NULL,
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"notes" TEXT,
"cost" DECIMAL(10,2),
"actorId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ItemLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Item_qrSlug_key" ON "Item"("qrSlug");
-- CreateIndex
CREATE INDEX "Item_locationId_idx" ON "Item"("locationId");
-- CreateIndex
CREATE INDEX "Item_parentItemId_idx" ON "Item"("parentItemId");
-- CreateIndex
CREATE INDEX "Item_kind_idx" ON "Item"("kind");
-- CreateIndex
CREATE INDEX "Item_active_idx" ON "Item"("active");
-- CreateIndex
CREATE INDEX "Item_name_idx" ON "Item"("name");
-- CreateIndex
CREATE INDEX "Item_barcode_idx" ON "Item"("barcode");
-- CreateIndex
CREATE UNIQUE INDEX "Label_name_key" ON "Label"("name");
-- CreateIndex
CREATE INDEX "LabelOnItem_labelId_idx" ON "LabelOnItem"("labelId");
-- CreateIndex
CREATE INDEX "ItemAttachment_itemId_idx" ON "ItemAttachment"("itemId");
-- CreateIndex
CREATE INDEX "ItemLog_itemId_idx" ON "ItemLog"("itemId");
-- CreateIndex
CREATE INDEX "ItemLog_date_idx" ON "ItemLog"("date");
-- CreateIndex
CREATE INDEX "Task_itemId_idx" ON "Task"("itemId");
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Item" ADD CONSTRAINT "Item_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Item" ADD CONSTRAINT "Item_parentItemId_fkey" FOREIGN KEY ("parentItemId") REFERENCES "Item"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LabelOnItem" ADD CONSTRAINT "LabelOnItem_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LabelOnItem" ADD CONSTRAINT "LabelOnItem_labelId_fkey" FOREIGN KEY ("labelId") REFERENCES "Label"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ItemAttachment" ADD CONSTRAINT "ItemAttachment_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ItemLog" ADD CONSTRAINT "ItemLog_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,66 @@
-- CreateEnum
CREATE TYPE "AnimalLogType" AS ENUM ('NOTE', 'FEEDING', 'VET', 'WEIGHT', 'MEDICATION', 'BREEDING', 'ACQUIRED', 'DIED');
-- AlterTable
ALTER TABLE "Task" ADD COLUMN "animalId" TEXT;
-- CreateTable
CREATE TABLE "Animal" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"species" TEXT,
"breed" TEXT,
"sex" TEXT,
"locationId" TEXT,
"birthdate" TIMESTAMP(3),
"acquiredAt" TIMESTAMP(3),
"source" TEXT,
"notes" TEXT,
"imageUrl" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Animal_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AnimalLog" (
"id" TEXT NOT NULL,
"animalId" TEXT NOT NULL,
"type" "AnimalLogType" NOT NULL,
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"notes" TEXT,
"weight" DECIMAL(10,2),
"actorId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AnimalLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Animal_locationId_idx" ON "Animal"("locationId");
-- CreateIndex
CREATE INDEX "Animal_species_idx" ON "Animal"("species");
-- CreateIndex
CREATE INDEX "Animal_active_idx" ON "Animal"("active");
-- CreateIndex
CREATE INDEX "AnimalLog_animalId_idx" ON "AnimalLog"("animalId");
-- CreateIndex
CREATE INDEX "AnimalLog_date_idx" ON "AnimalLog"("date");
-- CreateIndex
CREATE INDEX "Task_animalId_idx" ON "Task"("animalId");
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_animalId_fkey" FOREIGN KEY ("animalId") REFERENCES "Animal"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Animal" ADD CONSTRAINT "Animal_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AnimalLog" ADD CONSTRAINT "AnimalLog_animalId_fkey" FOREIGN KEY ("animalId") REFERENCES "Animal"("id") ON DELETE CASCADE ON UPDATE CASCADE;

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

@@ -1,3 +1,3 @@
# Please do not edit this file manually # Please do not edit this file manually
# It should be added in your version-control system (i.e. Git) # It should be added in your version-control system (i.e. Git)
provider = "postgresql" provider = "postgresql"

View File

@@ -31,6 +31,29 @@ model User {
mustChangePassword Boolean @default(false) mustChangePassword Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
apiTokens ApiToken[]
}
// ---------------------------------------------------------------------------
// API tokens — personal access tokens for the REST API + MCP server.
// Only the SHA-256 hash of the raw token is stored; the raw value (mb_…) is
// shown once at creation. `scopes` gate what a token can do (e.g. "plants:read").
// ---------------------------------------------------------------------------
model ApiToken {
id String @id @default(cuid())
name String // human label ("Home Assistant", "phone shortcut")
tokenHash String @unique // sha256(raw)
prefix String // first 8 chars of the raw token, shown in the UI
scopes String[] // e.g. ["plants:read","items:write"]; ["*"] = full
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
lastUsedAt DateTime?
expiresAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -49,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/
@@ -77,6 +114,9 @@ model Location {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
plants Plant[] plants Plant[]
items Item[]
animals Animal[]
tasks Task[]
@@index([parentId]) @@index([parentId])
@@index([kind]) @@index([kind])
@@ -96,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
@@ -126,6 +167,8 @@ model Plant {
zone String? // legacy freeform location — kept during the Location migration (dropped in Release B) zone String? // legacy freeform location — kept during the Location migration (dropped in Release B)
locationId String? // shared Location spine locationId String? // shared Location spine
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull) location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
plantTypeId String? // optional link to the "kind" reference record
plantType PlantType? @relation(fields: [plantTypeId], references: [id], onDelete: SetNull)
plantedAt DateTime? // when it went in the ground plantedAt DateTime? // when it went in the ground
source String? // where it came from ("Jung's Nursery", "from seed", "division from Mom's") source String? // where it came from ("Jung's Nursery", "from seed", "division from Mom's")
notes String? // general notes about this plant notes String? // general notes about this plant
@@ -137,14 +180,49 @@ 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])
@@index([locationId]) @@index([locationId])
@@index([plantTypeId])
@@index([active]) @@index([active])
@@index([groupId]) @@index([groupId])
} }
// PlantType — the "kind" of plant (e.g. "Lily of the valley"), holding general
// reusable reference info. Rich but every field optional; seeded from the
// built-in plant library (src/lib/garden/plant-db.ts). Individual Plant rows
// optionally point at their type.
model PlantType {
id String @id @default(cuid())
commonName String
species String? // scientific name
category PlantCategory @default(OTHER)
careNotes String? // general care: pruning, watering, feeding
sun String? // "Full sun", "Part shade", "Shade"
water String? // "Low", "Moderate", "High"
bloomStart Int? // month 112
bloomEnd Int? // month 112
harvestStart Int? // month 112
harvestEnd Int? // month 112
toxicToPets Boolean? // null = unknown
edibleParts String? // "Fruit, young leaves"
spacing String? // "1015 ft", "18 in"
imageUrl String?
uwExtensionUrl String? // linked UWMadison Extension article (hort.extension.wisc.edu)
uwExtensionTitle String? // its title, shown as the link text
notes String?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
plants Plant[]
@@index([commonName])
@@index([category])
@@index([active])
}
enum PlantLogType { enum PlantLogType {
OBSERVATION // general note — "looking healthy", "saw first flowers" OBSERVATION // general note — "looking healthy", "saw first flowers"
CARE // watering, mulching, pruning, fertilizing, chop-and-drop CARE // watering, mulching, pruning, fertilizing, chop-and-drop
@@ -179,6 +257,12 @@ model Task {
category TaskCategory @default(GARDEN) category TaskCategory @default(GARDEN)
plantId String? plantId String?
plant Plant? @relation(fields: [plantId], references: [id]) plant Plant? @relation(fields: [plantId], references: [id])
itemId String?
item Item? @relation(fields: [itemId], references: [id], onDelete: SetNull)
animalId String?
animal Animal? @relation(fields: [animalId], references: [id], onDelete: SetNull)
locationId String?
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
recurrence RecurrenceType @default(YEARLY) 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
@@ -191,6 +275,9 @@ model Task {
@@index([nextDue]) @@index([nextDue])
@@index([plantId]) @@index([plantId])
@@index([itemId])
@@index([animalId])
@@index([locationId])
@@index([active]) @@index([active])
} }
@@ -212,10 +299,217 @@ 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())
@@index([plantId]) @@index([plantId])
@@index([date]) @@index([date])
} }
// ---------------------------------------------------------------------------
// Inventory — things you own. Durable items (tools, gear) and, later,
// consumables (food, supplies) share this one table, told apart by `kind`.
// Items nest: an Item lives in a Location OR inside another Item (toolbox →
// drill). Consumable-only fields (barcode, bestBefore, minStock) are unused
// by the durable UI but present so Pantry needs no schema change.
// ---------------------------------------------------------------------------
enum ItemKind {
DURABLE // tools, gear, equipment — "where is it?"
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 {
id String @id @default(cuid())
name String
description String?
kind ItemKind @default(DURABLE)
quantity Decimal @default(1) @db.Decimal(10, 2)
unit String? // "each", "lbs", "bottles"
// Where it lives — a place, or inside another item (containers are items too).
locationId String?
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
parentItemId String?
parentItem Item? @relation("ItemNesting", fields: [parentItemId], references: [id], onDelete: SetNull)
containedItems Item[] @relation("ItemNesting")
// Durable attributes
value Decimal? @db.Decimal(10, 2) // purchase price
purchaseDate DateTime?
purchaseFrom String? // where it was bought
warrantyExpiry DateTime?
warrantyDetails String? // freeform warranty terms
lifetimeWarranty Boolean @default(false)
insured Boolean @default(false)
serial String?
modelNumber String?
brand String? // brand / manufacturer
// Sold lifecycle (HomeBox parity)
soldTo String?
soldPrice Decimal? @db.Decimal(10, 2)
soldDate DateTime?
soldNotes String?
// Custom fields — flexible key/value (e.g. VIN, "Oil Weight": "5W-30")
customFields Json?
// Consumable attributes
barcode String?
storedAt DateTime? // date frozen / preserved / stored
bestBefore DateTime? // expiry / best-by date
minStock Decimal? @db.Decimal(10, 2)
source ItemSource? // where it came from
plantId String? // link to a garden plant (harvest source)
plant Plant? @relation(fields: [plantId], references: [id], onDelete: SetNull)
pantryCategory String? // e.g. "Meat", "Produce", "Dairy"
pantrySubcategory String? // e.g. "Beef", "Poultry", "Berries"
pricePaid Decimal? @db.Decimal(10, 2) // how much was paid
priceUnit String? // "package", "lb", "oz", "kg", etc.
// Shared
qrSlug String? @unique // short slug for a printed QR label
imageUrl String?
notes String?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
labels LabelOnItem[]
attachments ItemAttachment[]
logs ItemLog[]
tasks Task[]
@@index([locationId])
@@index([parentItemId])
@@index([kind])
@@index([active])
@@index([name])
@@index([barcode])
@@index([plantId])
@@index([pantryCategory])
}
model Label {
id String @id @default(cuid())
name String @unique
color String?
items LabelOnItem[]
}
model LabelOnItem {
itemId String
labelId String
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
label Label @relation(fields: [labelId], references: [id], onDelete: Cascade)
@@id([itemId, labelId])
@@index([labelId])
}
model ItemAttachment {
id String @id @default(cuid())
itemId String
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
kind String // "PHOTO" | "MANUAL" | "RECEIPT"
url String
name String?
createdAt DateTime @default(now())
@@index([itemId])
}
enum ItemLogType {
NOTE
MAINTENANCE
MOVED
PURCHASED
REPAIRED
DISPOSED
USED // consumable pulled from the pantry; `amount` holds how much
}
model ItemLog {
id String @id @default(cuid())
itemId String
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
type ItemLogType
date DateTime @default(now())
notes String?
cost Decimal? @db.Decimal(10, 2)
amount Decimal? @db.Decimal(10, 2) // quantity consumed, for USED logs (same unit as the item)
reason String? // disposition for USED logs: consumed | preserved | gifted | waste (see pantry/core.ts)
actorId String?
createdAt DateTime @default(now())
@@index([itemId])
@@index([date])
}
// ---------------------------------------------------------------------------
// Animals & pets — livestock and household pets, leaves in a Location with
// their own care log (vet, feeding, weight, meds) and recurring reminders.
// ---------------------------------------------------------------------------
enum AnimalLogType {
NOTE
FEEDING
VET
WEIGHT
MEDICATION
BREEDING
ACQUIRED
DIED
}
model Animal {
id String @id @default(cuid())
name String
species String? // "Dog", "Cat", "Chicken", "Goat"
breed String?
sex String? // freeform: "Female", "Male", "Wether"
locationId String?
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
birthdate DateTime?
acquiredAt DateTime?
source String?
notes String?
imageUrl String?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
logs AnimalLog[]
tasks Task[]
@@index([locationId])
@@index([species])
@@index([active])
}
model AnimalLog {
id String @id @default(cuid())
animalId String
animal Animal @relation(fields: [animalId], references: [id], onDelete: Cascade)
type AnimalLogType
date DateTime @default(now())
notes String?
weight Decimal? @db.Decimal(10, 2) // for WEIGHT entries
actorId String?
createdAt DateTime @default(now())
@@index([animalId])
@@index([date])
}

View File

@@ -0,0 +1,23 @@
/**
* v0.11.0 Plant-type backfill. Seeds the PlantType library from the built-in
* plant DB and links existing plantings to their kind. Idempotent — runs on
* deploy (docker-entrypoint.sh) and can be run manually:
*
* npx tsx prisma/scripts/backfill-plant-types.ts
*/
import { PrismaClient } from "@prisma/client";
import { syncPlantTypesFromLibrary } from "../../src/lib/garden/plant-types";
const db = new PrismaClient();
async function main() {
const r = await syncPlantTypesFromLibrary(db);
console.log(`Plant types: library has ${r.types} kinds; linked ${r.linked} planting(s).`);
}
main()
.catch((e) => {
console.error("Plant-type backfill failed:", e);
process.exit(1);
})
.finally(() => db.$disconnect());

View File

@@ -1,5 +1,6 @@
import { PrismaClient, PlantCategory } from "@prisma/client"; import { PrismaClient, PlantCategory } from "@prisma/client";
import { hashSync } from "bcryptjs"; import { hashSync } from "bcryptjs";
import { syncPlantTypesFromLibrary } from "../src/lib/garden/plant-types";
const db = new PrismaClient(); const db = new PrismaClient();
@@ -56,6 +57,25 @@ async function main() {
{ commonName: "Garlic", variety: "Music", category: PlantCategory.BULB, zone: "Raised bed #1", source: "Keene Organics", plantedAt: new Date("2024-10-10") }, { commonName: "Garlic", variety: "Music", category: PlantCategory.BULB, zone: "Raised bed #1", source: "Keene Organics", plantedAt: new Date("2024-10-10") },
]; ];
// Starter Location tree — a root property node with the garden areas under it.
const root = await db.location.upsert({
where: { id: "seed-loc-root" },
update: {},
create: { id: "seed-loc-root", name: "Moon Homestead", kind: "OTHER", path: "Moon Homestead" },
});
const zoneNames = Array.from(new Set(plants.map((p) => p.zone).filter(Boolean) as string[]));
const zoneToLocId = new Map<string, string>();
for (const z of zoneNames) {
const id = `seed-loc-${z.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
await db.location.upsert({
where: { id },
update: {},
create: { id, name: z, kind: "AREA", parentId: root.id, path: `Moon Homestead ${z}` },
});
zoneToLocId.set(z, id);
}
for (const p of plants) { for (const p of plants) {
await db.plant.upsert({ await db.plant.upsert({
where: { id: `seed-${p.commonName.toLowerCase().replace(/\s/g, "-")}-${p.variety?.toLowerCase().replace(/\s/g, "-") ?? "0"}` }, where: { id: `seed-${p.commonName.toLowerCase().replace(/\s/g, "-")}-${p.variety?.toLowerCase().replace(/\s/g, "-") ?? "0"}` },
@@ -63,10 +83,15 @@ async function main() {
create: { create: {
id: `seed-${p.commonName.toLowerCase().replace(/\s/g, "-")}-${p.variety?.toLowerCase().replace(/\s/g, "-") ?? "0"}`, id: `seed-${p.commonName.toLowerCase().replace(/\s/g, "-")}-${p.variety?.toLowerCase().replace(/\s/g, "-") ?? "0"}`,
...p, ...p,
locationId: p.zone ? zoneToLocId.get(p.zone) : undefined,
}, },
}); });
} }
// Seed the PlantType library and link the sample plants to their kind.
const pt = await syncPlantTypesFromLibrary(db);
console.log(`Seeded ${pt.types} plant types; linked ${pt.linked} plantings.`);
console.log("Demo seed complete."); console.log("Demo seed complete.");
} }

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,131 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { db } from "@/lib/db";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { ChevronLeft, MapPin, CalendarDays, Cake, Package, Scale, PawPrint } from "lucide-react";
import { formatDate } from "@/lib/utils";
import { ageString } from "@/lib/animals/age";
import { ANIMAL_LOG_LABELS, ANIMAL_LOG_COLORS } from "@/lib/animals/animal-fields";
import { EditAnimalButton, type AnimalFields } from "@/components/animals/animal-dialog";
import { DeleteAnimalButton } from "@/components/animals/delete-animal-button";
import { AddAnimalLogButton } from "@/components/animals/add-animal-log-button";
function toDateInput(d: Date | null): string {
return d ? new Date(d).toISOString().split("T")[0] : "";
}
export async function generateMetadata({ params }: { params: { id: string } }) {
const a = await db.animal.findUnique({ where: { id: params.id }, select: { name: true } });
return { title: a?.name ?? "Animal" };
}
export default async function AnimalDetailPage({ params }: { params: { id: string } }) {
const [animal, locations] = await Promise.all([
db.animal.findUnique({
where: { id: params.id },
include: { location: { select: { id: true, name: true } }, logs: { orderBy: { date: "desc" } } },
}),
db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
]);
if (!animal || !animal.active) notFound();
const age = ageString(animal.birthdate, new Date());
const latestWeight = animal.logs.find((l) => l.type === "WEIGHT" && l.weight != null);
const editFields: AnimalFields = {
id: animal.id,
name: animal.name,
species: animal.species ?? "",
breed: animal.breed ?? "",
sex: animal.sex ?? "",
locationId: animal.locationId ?? "",
birthdate: toDateInput(animal.birthdate),
acquiredAt: toDateInput(animal.acquiredAt),
source: animal.source ?? "",
notes: animal.notes ?? "",
};
const facts: { icon: React.ReactNode; label: string; value: React.ReactNode }[] = [];
if (animal.location) facts.push({ icon: <MapPin className="h-4 w-4" />, label: "Location", value: animal.location.name });
if (animal.sex) facts.push({ icon: <PawPrint className="h-4 w-4" />, label: "Sex", value: animal.sex });
if (animal.birthdate) facts.push({ icon: <Cake className="h-4 w-4" />, label: "Born", value: `${formatDate(animal.birthdate)}${age ? ` · ${age}` : ""}` });
if (animal.acquiredAt) facts.push({ icon: <CalendarDays className="h-4 w-4" />, label: "Acquired", value: formatDate(animal.acquiredAt) });
if (animal.source) facts.push({ icon: <Package className="h-4 w-4" />, label: "From", value: animal.source });
if (latestWeight?.weight != null) facts.push({ icon: <Scale className="h-4 w-4" />, label: "Latest weight", value: `${Number(latestWeight.weight)} lbs` });
return (
<div className="space-y-6 max-w-2xl">
<div className="flex items-center gap-3">
<Link href="/animals" className="text-muted-foreground hover:text-foreground transition-colors">
<ChevronLeft className="h-5 w-5" />
</Link>
<div className="min-w-0">
<h1 className="font-display text-2xl font-semibold">{animal.name}</h1>
<p className="text-sm text-muted-foreground">{[animal.species, animal.breed].filter(Boolean).join(" · ")}</p>
</div>
{age && <Badge variant="secondary" className="ml-auto shrink-0">{age}</Badge>}
</div>
<div className="flex flex-wrap items-center gap-2">
<EditAnimalButton animal={editFields} locations={locations} />
<AddAnimalLogButton animalId={animal.id} />
<DeleteAnimalButton id={animal.id} name={animal.name} />
</div>
{animal.imageUrl && (
<div className="rounded-lg overflow-hidden h-56 bg-muted">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={animal.imageUrl} alt={animal.name} className="w-full h-full object-cover" />
</div>
)}
{facts.length > 0 && (
<Card>
<CardContent className="pt-4 grid grid-cols-2 gap-3 text-sm">
{facts.map((f, i) => (
<div key={i} className="flex items-start gap-2">
<span className="text-muted-foreground mt-0.5 shrink-0">{f.icon}</span>
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{f.label}</p>
<p className="truncate">{f.value}</p>
</div>
</div>
))}
</CardContent>
</Card>
)}
{animal.notes && (
<Card><CardContent className="pt-4 text-sm whitespace-pre-wrap">{animal.notes}</CardContent></Card>
)}
<div>
<h2 className="font-display text-lg font-semibold mb-3">Care log</h2>
{animal.logs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm border rounded-lg">
No entries yet log a feeding, vet visit, or weigh-in above.
</div>
) : (
<div className="space-y-2">
{animal.logs.map((log) => (
<div key={log.id} className="flex items-start gap-3 p-3 rounded-lg border bg-card">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-xs font-semibold uppercase tracking-wide ${ANIMAL_LOG_COLORS[log.type]}`}>
{ANIMAL_LOG_LABELS[log.type]}
</span>
{log.weight != null && <span className="text-xs text-muted-foreground">· {Number(log.weight)} lbs</span>}
<span className="text-xs text-muted-foreground ml-auto">{formatDate(log.date)}</span>
</div>
{log.notes && <p className="text-sm mt-1">{log.notes}</p>}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import Link from "next/link";
import { db } from "@/lib/db";
import { Badge } from "@/components/ui/badge";
import { PawPrint, MapPin, ChevronRight } from "lucide-react";
import { ageString } from "@/lib/animals/age";
import { AddAnimalButton } from "@/components/animals/animal-dialog";
export const metadata = { title: "Animals" };
const now = new Date();
export default async function AnimalsPage() {
const [animals, locations] = await Promise.all([
db.animal.findMany({
where: { active: true },
orderBy: { name: "asc" },
include: { location: { select: { id: true, name: true } }, _count: { select: { logs: true } } },
}),
db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
]);
// Group by species ("Other" bucket last).
const groups = new Map<string, typeof animals>();
for (const a of animals) {
const key = a.species?.trim() || "Other";
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(a);
}
const sorted = Array.from(groups.entries()).sort((a, b) =>
a[0] === "Other" ? 1 : b[0] === "Other" ? -1 : a[0].localeCompare(b[0]),
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-semibold">Animals &amp; pets</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{animals.length} {animals.length === 1 ? "animal" : "animals"}
</p>
</div>
<AddAnimalButton locations={locations} />
</div>
{animals.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<PawPrint className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p className="font-medium">No animals yet</p>
<p className="text-sm mt-1">Add a pet or some livestock to start a care log.</p>
</div>
) : (
<div className="space-y-6">
{sorted.map(([species, list]) => (
<div key={species} className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{species}</p>
<div className="divide-y border rounded-lg overflow-hidden">
{list.map((a) => {
const age = ageString(a.birthdate, now);
return (
<Link key={a.id} href={`/animals/${a.id}`}
className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors">
{a.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={a.imageUrl} alt="" className="h-9 w-9 rounded-full object-cover shrink-0" />
) : (
<span className="h-9 w-9 rounded-full bg-[hsl(var(--leaf)/.1)] flex items-center justify-center shrink-0">
<PawPrint className="h-4 w-4 text-[hsl(var(--leaf)/.6)]" />
</span>
)}
<div className="min-w-0 flex-1">
<p className="font-medium">{a.name}</p>
<p className="text-xs text-muted-foreground">
{[a.breed, age].filter(Boolean).join(" · ") || a.species}
</p>
</div>
{a.location?.name && (
<Badge variant="outline" className="gap-1 text-xs">
<MapPin className="h-2.5 w-2.5" />{a.location.name}
</Badge>
)}
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
</Link>
);
})}
</div>
</div>
))}
</div>
)}
</div>
);
}

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 } 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";
@@ -16,18 +16,22 @@ export async function generateMetadata({ params }: { params: { id: string } }) {
} }
export default async function PlantDetailPage({ params }: { params: { id: string } }) { export default async function PlantDetailPage({ params }: { params: { id: string } }) {
const [plant, allPlants, groups] = await Promise.all([ const [plant, locations, groups] = await Promise.all([
db.plant.findUnique({ db.plant.findUnique({
where: { id: params.id }, where: { id: params.id },
include: { logs: { orderBy: { date: "desc" } } }, include: {
logs: { orderBy: { date: "desc" } },
location: { select: { id: true, name: true } },
plantType: { select: { id: true, commonName: true, uwExtensionUrl: true, uwExtensionTitle: true } },
},
}), }),
db.plant.findMany({ where: { active: true }, select: { zone: true } }), db.location.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
db.plantGroup.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } }), db.plantGroup.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
]); ]);
if (!plant || !plant.active) notFound(); if (!plant || !plant.active) notFound();
const zones = Array.from(new Set(allPlants.map((p) => p.zone).filter(Boolean) as string[])).sort(); const placeName = plant.location?.name ?? plant.zone ?? null;
return ( return (
<div className="space-y-6 max-w-2xl"> <div className="space-y-6 max-w-2xl">
@@ -48,7 +52,7 @@ export default async function PlantDetailPage({ params }: { params: { id: string
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<EditPlantButton plant={plant} zones={zones} groups={groups} /> <EditPlantButton plant={plant} locations={locations} groups={groups} />
<DeletePlantButton plantId={plant.id} plantName={plant.commonName} /> <DeletePlantButton plantId={plant.id} plantName={plant.commonName} />
</div> </div>
@@ -60,10 +64,31 @@ export default async function PlantDetailPage({ params }: { params: { id: string
<Card> <Card>
<CardContent className="pt-4 space-y-2 text-sm"> <CardContent className="pt-4 space-y-2 text-sm">
{plant.zone && ( {placeName && (
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<MapPin className="h-4 w-4 shrink-0" /> <MapPin className="h-4 w-4 shrink-0" />
<span>{plant.zone}</span> <span>{placeName}</span>
</div>
)}
{plant.plantType && (
<div className="flex items-center gap-2 text-muted-foreground">
<BookOpen className="h-4 w-4 shrink-0" />
<Link href={`/garden/types/${plant.plantType.id}`} className="hover:text-foreground underline-offset-2 hover:underline">
More about {plant.plantType.commonName}
</Link>
</div>
)}
{plant.plantType?.uwExtensionUrl && (
<div className="flex items-center gap-2 text-muted-foreground">
<GraduationCap className="h-4 w-4 shrink-0" />
<a
href={plant.plantType.uwExtensionUrl}
target="_blank"
rel="noreferrer"
className="hover:text-foreground underline-offset-2 hover:underline truncate"
>
UW Extension: {plant.plantType.uwExtensionTitle || "article"}
</a>
</div> </div>
)} )}
{plant.plantedAt && ( {plant.plantedAt && (
@@ -87,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 ? (
@@ -104,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] = 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" }],
@@ -13,6 +16,7 @@ export default async function GardenPage() {
_count: { select: { logs: true } }, _count: { select: { logs: true } },
logs: { orderBy: { date: "desc" }, take: 1, select: { date: true, type: true } }, logs: { orderBy: { date: "desc" }, take: 1, select: { date: true, type: true } },
group: { select: { id: true, name: true } }, group: { select: { id: true, name: true } },
location: { select: { id: true, name: true } },
}, },
}), }),
db.plantGroup.findMany({ db.plantGroup.findMany({
@@ -20,12 +24,30 @@ export default async function GardenPage() {
orderBy: { name: "asc" }, orderBy: { name: "asc" },
include: { _count: { select: { plants: true } } }, include: { _count: { select: { plants: true } } },
}), }),
db.location.findMany({
where: { active: true },
orderBy: { name: "asc" },
select: { id: true, name: true },
}),
db.plantLog.findMany({
where: { date: { gte: twoWeeksAgo }, plant: { active: true } },
orderBy: { date: "desc" },
take: 10,
select: {
id: true, type: true, date: true, notes: true, quantity: true,
amount: true, unit: true,
plant: { select: { id: true, commonName: true, variety: true } },
},
}),
]); ]);
const zones = Array.from(new Set(plants.map((p) => p.zone).filter(Boolean) as string[])).sort(); 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>
@@ -33,10 +55,12 @@ export default async function GardenPage() {
{plants.length} {plants.length === 1 ? "plant" : "plants"} on your acre {plants.length} {plants.length === 1 ? "plant" : "plants"} on your acre
</p> </p>
</div> </div>
<AddPlantButton groups={groups} zones={zones} /> <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

@@ -0,0 +1,256 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { db } from "@/lib/db";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import {
ChevronLeft, Sun, Droplets, Flower2, Apple, AlertTriangle, Ruler, MapPin, Leaf,
GraduationCap, ExternalLink, Sprout, ThumbsUp, ThumbsDown, Repeat,
} from "lucide-react";
import { CATEGORY_LABELS } from "@/lib/garden/categories";
import { knowledgeFor, uwSearchUrl, ROTATION_LABELS } from "@/lib/garden/knowledge";
import { EditPlantTypeButton, type PlantTypeFields } from "@/components/garden/plant-type-dialog";
import { DeletePlantTypeButton } from "@/components/garden/delete-plant-type-button";
const MONTHS = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
function monthRange(start: number | null, end: number | null): string | null {
if (start && end) return `${MONTHS[start]}${MONTHS[end]}`;
if (start) return `from ${MONTHS[start]}`;
if (end) return `through ${MONTHS[end]}`;
return null;
}
export async function generateMetadata({ params }: { params: { id: string } }) {
const t = await db.plantType.findUnique({ where: { id: params.id }, select: { commonName: true } });
return { title: t?.commonName ?? "Plant type" };
}
// Garden-brain card: companions, antagonists, rotation family, and the
// zone-4b care timeline for this kind of plant (when we know it).
function GrowingTogether({ name }: { name: string }) {
const k = knowledgeFor(name);
if (!k) return null;
const hasCompanions = (k.friends?.length ?? 0) > 0 || (k.foes?.length ?? 0) > 0;
return (
<Card>
<CardContent className="pt-4 space-y-3 text-sm">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Growing {k.name.toLowerCase()} in zone 4b
</p>
<a
href={uwSearchUrl(k.uwQuery)}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground underline-offset-2 hover:underline"
>
UW Extension <ExternalLink className="h-3 w-3" />
</a>
</div>
{k.tips.length > 0 && (
<ul className="space-y-1">
{k.tips.map((t) => (
<li key={t.action} className="flex items-start gap-2">
<Sprout className="h-3.5 w-3.5 mt-0.5 shrink-0 text-[hsl(var(--leaf))]" />
<span>
<span className="text-muted-foreground">{t.months.map((m) => MONTHS[m]).join(", ")}:</span>{" "}
{t.action}
{t.detail && <span className="text-muted-foreground"> {t.detail}</span>}
</span>
</li>
))}
</ul>
)}
{hasCompanions && (
<div className="flex flex-col gap-1 pt-2 border-t">
{k.friends && k.friends.length > 0 && (
<p className="flex items-start gap-2">
<ThumbsUp className="h-3.5 w-3.5 mt-0.5 shrink-0 text-[hsl(var(--leaf))]" />
<span><span className="text-muted-foreground">Grows well with:</span> {k.friends.join(", ")}</span>
</p>
)}
{k.foes && k.foes.length > 0 && (
<p className="flex items-start gap-2">
<ThumbsDown className="h-3.5 w-3.5 mt-0.5 shrink-0 text-[hsl(var(--ember))]" />
<span><span className="text-muted-foreground">Keep away from:</span> {k.foes.join(", ")}</span>
</p>
)}
</div>
)}
{(k.family || k.rotationNote) && (
<p className="flex items-start gap-2 pt-2 border-t">
<Repeat className="h-3.5 w-3.5 mt-0.5 shrink-0 text-muted-foreground" />
<span>
{k.family && <span className="text-muted-foreground">Rotation family: {ROTATION_LABELS[k.family]}.</span>}
{k.rotationNote && <span> {k.rotationNote}</span>}
</span>
</p>
)}
</CardContent>
</Card>
);
}
export default async function PlantTypeDetailPage({ params }: { params: { id: string } }) {
const type = await db.plantType.findUnique({
where: { id: params.id },
include: {
plants: {
where: { active: true },
orderBy: { commonName: "asc" },
include: { location: { select: { name: true } } },
},
},
});
if (!type || !type.active) notFound();
const bloom = monthRange(type.bloomStart, type.bloomEnd);
const harvest = monthRange(type.harvestStart, type.harvestEnd);
const formFields: PlantTypeFields = {
id: type.id,
commonName: type.commonName,
species: type.species,
category: type.category,
careNotes: type.careNotes,
sun: type.sun,
water: type.water,
bloomStart: type.bloomStart,
bloomEnd: type.bloomEnd,
harvestStart: type.harvestStart,
harvestEnd: type.harvestEnd,
toxicToPets: type.toxicToPets,
edibleParts: type.edibleParts,
spacing: type.spacing,
uwExtensionUrl: type.uwExtensionUrl,
uwExtensionTitle: type.uwExtensionTitle,
notes: type.notes,
};
const facts: { icon: React.ReactNode; label: string; value: string }[] = [];
if (type.sun) facts.push({ icon: <Sun className="h-4 w-4" />, label: "Sun", value: type.sun });
if (type.water) facts.push({ icon: <Droplets className="h-4 w-4" />, label: "Water", value: type.water });
if (bloom) facts.push({ icon: <Flower2 className="h-4 w-4" />, label: "Blooms", value: bloom });
if (harvest) facts.push({ icon: <Apple className="h-4 w-4" />, label: "Harvest", value: harvest });
if (type.edibleParts) facts.push({ icon: <Apple className="h-4 w-4" />, label: "Edible", value: type.edibleParts });
if (type.spacing) facts.push({ icon: <Ruler className="h-4 w-4" />, label: "Spacing", value: type.spacing });
return (
<div className="space-y-6 max-w-2xl">
<div className="flex items-center gap-3">
<Link href="/garden/types" className="text-muted-foreground hover:text-foreground transition-colors">
<ChevronLeft className="h-5 w-5" />
</Link>
<div className="min-w-0">
<h1 className="font-display text-2xl font-semibold">{type.commonName}</h1>
{type.species && <p className="text-sm text-muted-foreground italic">{type.species}</p>}
</div>
<Badge variant="secondary" className="ml-auto shrink-0">{CATEGORY_LABELS[type.category]}</Badge>
</div>
<div className="flex items-center gap-2">
<EditPlantTypeButton plantType={formFields} />
<DeletePlantTypeButton id={type.id} name={type.commonName} />
</div>
{type.toxicToPets && (
<div 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))]">
<AlertTriangle className="h-4 w-4 shrink-0" />
Toxic to pets keep away from animals.
</div>
)}
{type.toxicToPets === false && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="text-[hsl(var(--leaf))]"></span> Safe for pets.
</div>
)}
{facts.length > 0 && (
<Card>
<CardContent className="pt-4 grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm">
{facts.map((fct, i) => (
<div key={i} className="flex items-start gap-2">
<span className="text-muted-foreground mt-0.5 shrink-0">{fct.icon}</span>
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{fct.label}</p>
<p className="truncate">{fct.value}</p>
</div>
</div>
))}
</CardContent>
</Card>
)}
{type.uwExtensionUrl && (
<a
href={type.uwExtensionUrl}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 text-sm rounded-lg border px-3 py-2.5 hover:bg-accent transition-colors"
>
<GraduationCap className="h-4 w-4 shrink-0 text-[hsl(var(--leaf))]" />
<span className="min-w-0 truncate">
<span className="text-muted-foreground">UW Extension:</span>{" "}
{type.uwExtensionTitle || "article"}
</span>
<ExternalLink className="h-3.5 w-3.5 ml-auto shrink-0 text-muted-foreground" />
</a>
)}
<GrowingTogether name={type.commonName} />
{(type.careNotes || type.notes) && (
<Card>
<CardContent className="pt-4 space-y-3 text-sm">
{type.careNotes && (
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Care</p>
<p className="whitespace-pre-wrap">{type.careNotes}</p>
</div>
)}
{type.notes && (
<div className={type.careNotes ? "pt-2 border-t" : ""}>
<p className="whitespace-pre-wrap">{type.notes}</p>
</div>
)}
</CardContent>
</Card>
)}
<div>
<h2 className="font-display text-lg font-semibold mb-3">
Your plantings {type.plants.length > 0 && <span className="text-sm text-muted-foreground font-normal">· {type.plants.length}</span>}
</h2>
{type.plants.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm border rounded-lg">
None planted yet this is general info you can keep regardless.
</div>
) : (
<div className="divide-y border rounded-lg overflow-hidden">
{type.plants.map((p) => (
<Link key={p.id} href={`/garden/${p.id}`}
className="flex items-center gap-3 px-4 py-2.5 hover:bg-accent transition-colors text-sm">
<Leaf className="h-4 w-4 shrink-0 text-[hsl(var(--leaf))]" />
<span className="font-medium">
{p.commonName}{p.variety && <span className="text-muted-foreground font-normal"> · {p.variety}</span>}
</span>
{p.location?.name && (
<span className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
<MapPin className="h-3 w-3" />{p.location.name}
</span>
)}
</Link>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import Link from "next/link";
import { db } from "@/lib/db";
import { Badge } from "@/components/ui/badge";
import { BookOpen, ChevronRight, Leaf, AlertTriangle } from "lucide-react";
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
import { AddPlantTypeButton } from "@/components/garden/plant-type-dialog";
export const metadata = { title: "Plant types" };
export default async function PlantTypesPage() {
const types = await db.plantType.findMany({
where: { active: true },
orderBy: { commonName: "asc" },
include: { _count: { select: { plants: { where: { active: true } } } } },
});
const byCategory = CATEGORY_ORDER.map((cat) => ({
category: cat,
types: types.filter((t) => t.category === cat),
})).filter((g) => g.types.length > 0);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-semibold">Plant types</h1>
<p className="text-sm text-muted-foreground mt-0.5">
General knowledge about each kind of plant care, sun, bloom &amp; harvest, toxicity.
{" "}{types.length} {types.length === 1 ? "kind" : "kinds"} in your library.
</p>
</div>
<AddPlantTypeButton />
</div>
{types.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<BookOpen className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p className="font-medium">No plant types yet</p>
<p className="text-sm mt-1">Add one, or they&apos;ll appear here as you add plants.</p>
</div>
) : (
<div className="space-y-6">
{byCategory.map((group) => (
<div key={group.category} className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{CATEGORY_LABELS[group.category]}
</p>
<div className="divide-y border rounded-lg overflow-hidden">
{group.types.map((t) => (
<Link key={t.id} href={`/garden/types/${t.id}`}
className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors">
<Leaf className="h-4 w-4 shrink-0 text-[hsl(var(--leaf))]" />
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{t.commonName}</p>
{t.species && <p className="text-xs text-muted-foreground italic truncate">{t.species}</p>}
</div>
{t.toxicToPets && (
<Badge variant="outline" className="gap-1 text-xs text-[hsl(var(--ember))] border-[hsl(var(--ember)/.4)]">
<AlertTriangle className="h-3 w-3" /> Toxic
</Badge>
)}
{t._count.plants > 0 && (
<Badge variant="secondary" className="text-xs shrink-0">
{t._count.plants} planted
</Badge>
)}
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
</Link>
))}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,275 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { db } from "@/lib/db";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import {
ChevronLeft, MapPin, Box, Package, CalendarDays, DollarSign, Hash, Tag, FileText, Barcode,
Store, ShieldCheck, Wrench, AlertCircle,
} from "lucide-react";
import { formatDate } from "@/lib/utils";
import { warrantyStatus, WARRANTY_LABELS } from "@/lib/inventory/value";
import { ITEM_LOG_LABELS, ITEM_LOG_COLORS } from "@/lib/inventory/item-fields";
import { isOverdue, describeRecurrence } from "@/lib/tasks/schedule";
import { EditItemButton, type ItemFields } from "@/components/inventory/item-dialog";
import { DeleteItemButton } from "@/components/inventory/delete-item-button";
import { AddItemLogButton } from "@/components/inventory/add-item-log-button";
import { ScheduleMaintenanceButton } from "@/components/inventory/schedule-maintenance-button";
import { AttachmentUpload } from "@/components/inventory/attachment-upload";
function toDateInput(d: Date | null): string {
return d ? new Date(d).toISOString().split("T")[0] : "";
}
export async function generateMetadata({ params }: { params: { id: string } }) {
const item = await db.item.findUnique({ where: { id: params.id }, select: { name: true } });
return { title: item?.name ?? "Item" };
}
export default async function ItemDetailPage({ params }: { params: { id: string } }) {
const [item, locations, allItems, labels] = await Promise.all([
db.item.findUnique({
where: { id: params.id },
include: {
location: { select: { id: true, name: true } },
parentItem: { select: { id: true, name: true } },
labels: { include: { label: true } },
attachments: { orderBy: { createdAt: "asc" } },
logs: { orderBy: { date: "desc" } },
containedItems: { where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } },
tasks: { where: { active: true }, orderBy: { nextDue: "asc" } },
},
}),
db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
db.item.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
db.label.findMany({ orderBy: { name: "asc" } }),
]);
if (!item || !item.active) notFound();
const wStatus = item.lifetimeWarranty ? "active" : warrantyStatus(item.warrantyExpiry, new Date());
const warrantyLabel = item.lifetimeWarranty ? "Lifetime warranty" : WARRANTY_LABELS[wStatus];
const photos = item.attachments.filter((a) => a.kind === "PHOTO");
const docs = item.attachments.filter((a) => a.kind !== "PHOTO");
const editFields: ItemFields = {
id: item.id,
name: item.name,
description: item.description ?? "",
quantity: String(Number(item.quantity)),
unit: item.unit ?? "",
locationId: item.locationId ?? "",
parentItemId: item.parentItemId ?? "",
brand: item.brand ?? "",
modelNumber: item.modelNumber ?? "",
serial: item.serial ?? "",
value: item.value != null ? String(Number(item.value)) : "",
purchaseDate: toDateInput(item.purchaseDate),
purchaseFrom: item.purchaseFrom ?? "",
warrantyExpiry: toDateInput(item.warrantyExpiry),
warrantyDetails: item.warrantyDetails ?? "",
lifetimeWarranty: item.lifetimeWarranty,
insured: item.insured,
soldTo: item.soldTo ?? "",
soldPrice: item.soldPrice != null ? String(Number(item.soldPrice)) : "",
soldDate: toDateInput(item.soldDate),
soldNotes: item.soldNotes ?? "",
customFields: Object.entries((item.customFields as Record<string, string>) ?? {}).map(([key, value]) => ({ key, value: String(value) })),
barcode: item.barcode ?? "",
notes: item.notes ?? "",
labelIds: item.labels.map((l) => l.labelId),
};
const facts: { icon: React.ReactNode; label: string; value: React.ReactNode }[] = [];
if (item.location) facts.push({ icon: <MapPin className="h-4 w-4" />, label: "Location", value: item.location.name });
if (item.parentItem) facts.push({ icon: <Box className="h-4 w-4" />, label: "Inside", value: <Link className="hover:underline" href={`/inventory/${item.parentItem.id}`}>{item.parentItem.name}</Link> });
if (Number(item.quantity) !== 1) facts.push({ icon: <Package className="h-4 w-4" />, label: "Quantity", value: `${Number(item.quantity)}${item.unit ? ` ${item.unit}` : ""}` });
if (item.value != null) facts.push({ icon: <DollarSign className="h-4 w-4" />, label: "Value", value: Number(item.value).toLocaleString(undefined, { style: "currency", currency: "USD" }) });
if (item.purchaseDate) facts.push({ icon: <CalendarDays className="h-4 w-4" />, label: "Purchased", value: formatDate(item.purchaseDate) });
if (item.purchaseFrom) facts.push({ icon: <Store className="h-4 w-4" />, label: "From", value: item.purchaseFrom });
if (item.lifetimeWarranty || item.warrantyExpiry) facts.push({ icon: <ShieldCheck className="h-4 w-4" />, label: "Warranty", value: item.lifetimeWarranty ? "Lifetime" : `until ${formatDate(item.warrantyExpiry!)}` });
if (item.insured) facts.push({ icon: <ShieldCheck className="h-4 w-4" />, label: "Insured", value: "Yes" });
if (item.brand) facts.push({ icon: <Tag className="h-4 w-4" />, label: "Brand", value: item.brand });
if (item.modelNumber) facts.push({ icon: <Hash className="h-4 w-4" />, label: "Model", value: item.modelNumber });
if (item.serial) facts.push({ icon: <Hash className="h-4 w-4" />, label: "Serial", value: item.serial });
if (item.barcode) facts.push({ icon: <Barcode className="h-4 w-4" />, label: "Barcode", value: item.barcode });
return (
<div className="space-y-6 max-w-2xl">
<div className="flex items-center gap-3">
<Link href="/inventory" className="text-muted-foreground hover:text-foreground transition-colors">
<ChevronLeft className="h-5 w-5" />
</Link>
<div className="min-w-0">
<h1 className="font-display text-2xl font-semibold">{item.name}</h1>
{item.description && <p className="text-sm text-muted-foreground">{item.description}</p>}
</div>
{(item.lifetimeWarranty || wStatus !== "none") && (
<Badge variant="outline" className="ml-auto shrink-0">{warrantyLabel}</Badge>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<EditItemButton item={editFields} locations={locations} items={allItems.filter((i) => i.id !== item.id)} labels={labels} />
<AddItemLogButton itemId={item.id} />
<ScheduleMaintenanceButton itemId={item.id} itemName={item.name} />
<AttachmentUpload itemId={item.id} />
<DeleteItemButton id={item.id} name={item.name} />
</div>
{item.labels.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{item.labels.map((l) => <Badge key={l.labelId} variant="secondary">{l.label.name}</Badge>)}
</div>
)}
{photos.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{photos.map((p) => (
// eslint-disable-next-line @next/next/no-img-element
<img key={p.id} src={p.url} alt={p.name ?? ""} className="h-28 w-full object-cover rounded-lg border" />
))}
</div>
)}
{facts.length > 0 && (
<Card>
<CardContent className="pt-4 grid grid-cols-2 gap-3 text-sm">
{facts.map((f, i) => (
<div key={i} className="flex items-start gap-2">
<span className="text-muted-foreground mt-0.5 shrink-0">{f.icon}</span>
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{f.label}</p>
<p className="truncate">{f.value}</p>
</div>
</div>
))}
</CardContent>
</Card>
)}
{item.notes && (
<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 && (
<div className="space-y-1.5">
<h2 className="font-display text-sm font-semibold">Documents</h2>
<div className="divide-y border rounded-lg overflow-hidden">
{docs.map((d) => (
<a key={d.id} href={d.url} target="_blank" rel="noreferrer"
className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent">
<FileText className="h-4 w-4 text-muted-foreground" />{d.name ?? d.kind}
<span className="ml-auto text-xs text-muted-foreground">{d.kind}</span>
</a>
))}
</div>
</div>
)}
{item.containedItems.length > 0 && (
<div className="space-y-1.5">
<h2 className="font-display text-lg font-semibold">Contains</h2>
<div className="divide-y border rounded-lg overflow-hidden">
{item.containedItems.map((c) => (
<Link key={c.id} href={`/inventory/${c.id}`} className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent">
<Package className="h-4 w-4 text-[hsl(var(--leaf))]" />{c.name}
</Link>
))}
</div>
</div>
)}
<div>
<h2 className="font-display text-lg font-semibold mb-3">History</h2>
{item.logs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm border rounded-lg">
No history yet log maintenance, a repair, or a move above.
</div>
) : (
<div className="space-y-2">
{item.logs.map((log) => (
<div key={log.id} className="flex items-start gap-3 p-3 rounded-lg border bg-card">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-xs font-semibold uppercase tracking-wide ${ITEM_LOG_COLORS[log.type]}`}>
{ITEM_LOG_LABELS[log.type]}
</span>
{log.cost != null && <span className="text-xs text-muted-foreground">· {Number(log.cost).toLocaleString(undefined, { style: "currency", currency: "USD" })}</span>}
<span className="text-xs text-muted-foreground ml-auto">{formatDate(log.date)}</span>
</div>
{log.notes && <p className="text-sm mt-1">{log.notes}</p>}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import { db } from "@/lib/db";
import { AddItemButton } from "@/components/inventory/item-dialog";
import { ImportButton } from "@/components/inventory/import-button";
import { InventoryGrid, type GridItem } from "@/components/inventory/inventory-grid";
import { totalValue } from "@/lib/inventory/value";
export const metadata = { title: "Inventory" };
export default async function InventoryPage() {
const [items, locations, labels] = await Promise.all([
db.item.findMany({
where: { active: true },
orderBy: { name: "asc" },
include: {
location: { select: { id: true, name: true } },
parentItem: { select: { id: true, name: true } },
labels: { include: { label: true } },
_count: { select: { containedItems: { where: { active: true } }, logs: true } },
},
}),
db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
db.label.findMany({ orderBy: { name: "asc" } }),
]);
// Convert Prisma Decimals to plain numbers before handing to client components.
const gridItems: GridItem[] = items.map((i) => ({
id: i.id,
name: i.name,
quantity: Number(i.quantity),
unit: i.unit,
value: i.value != null ? Number(i.value) : null,
warrantyExpiry: i.warrantyExpiry,
imageUrl: i.imageUrl,
locationId: i.locationId,
parentItemId: i.parentItemId,
location: i.location,
labels: i.labels,
_count: i._count,
}));
const total = totalValue(items.map((i) => ({
value: i.value != null ? Number(i.value) : null,
quantity: Number(i.quantity),
})));
const itemOpts = items.map((i) => ({ id: i.id, name: i.name }));
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-semibold">Inventory</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{items.length} {items.length === 1 ? "item" : "items"}
{total > 0 && ` · ${total.toLocaleString(undefined, { style: "currency", currency: "USD" })} total value`}
</p>
</div>
<div className="flex items-center gap-2">
<ImportButton />
<AddItemButton locations={locations} items={itemOpts} labels={labels} />
</div>
</div>
<InventoryGrid items={gridItems} />
</div>
);
}

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";
@@ -8,6 +10,18 @@ export default async function DashboardLayout({ children }: { children: React.Re
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
// a known default + mustChangePassword=true). Read the flag from the DB, not the
// JWT: the cookie is only refreshed on re-login/update(), so trusting it would
// keep bouncing the user back here even after they've set a new password. The
// target page is outside this layout group, so there's no redirect loop.
const account = await db.user.findUnique({
where: { id: session.user.id },
select: { active: true, mustChangePassword: true },
});
if (!account || !account.active) redirect("/login");
if (account.mustChangePassword) redirect("/change-password");
return ( return (
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
@@ -18,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

@@ -1,115 +1,14 @@
"use client"; import { Metadata } from "next";
import { redirect } from "next/navigation";
import { getSession, isAdmin } from "@/lib/auth";
import { BackupPanel } from "@/components/settings/backup-panel";
import { useState, useRef } from "react"; export const metadata: Metadata = { title: "Backup & Restore" };
import { Download, Upload, AlertTriangle, CheckCircle } from "lucide-react"; export const dynamic = "force-dynamic";
import { Button } from "@/components/ui/button";
export default function BackupPage() { export default async function BackupPage() {
const [restoreStatus, setRestoreStatus] = useState< const session = await getSession();
{ type: "success"; exportedAt: string } | { type: "error"; message: string } | null if (!session) redirect("/login");
>(null); if (!isAdmin(session.user.role)) redirect("/dashboard");
const [restoring, setRestoring] = useState(false); return <BackupPanel />;
const [dragOver, setDragOver] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
async function handleFile(file: File) {
if (!file.name.endsWith(".zip")) {
setRestoreStatus({ type: "error", message: "Please upload a .zip backup file." });
return;
}
if (!confirm("This will REPLACE all current data with the backup. Are you sure?")) return;
setRestoring(true);
setRestoreStatus(null);
try {
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/admin/restore", { method: "POST", body: form });
const json = await res.json();
if (!res.ok) throw new Error(json.error ?? "Restore failed");
setRestoreStatus({ type: "success", exportedAt: json.exportedAt });
} catch (err) {
setRestoreStatus({ type: "error", message: String(err) });
} finally {
setRestoring(false);
}
}
return (
<div className="max-w-xl space-y-8">
<div>
<h1 className="text-2xl font-bold">Backup &amp; Restore</h1>
<p className="mt-1 text-sm text-muted-foreground">
Download a full backup of all Moon Base data, or restore from a previous backup.
</p>
</div>
{/* Backup */}
<section className="rounded-lg border p-6 space-y-3">
<h2 className="font-semibold text-lg">Download Backup</h2>
<p className="text-sm text-muted-foreground">
Downloads all plants, logs, tasks, and settings as a <code>.zip</code> file. Keep it somewhere safe.
</p>
<a href="/api/admin/backup" download>
<Button className="gap-2">
<Download className="h-4 w-4" />
Download backup
</Button>
</a>
</section>
{/* Restore */}
<section className="rounded-lg border p-6 space-y-4">
<h2 className="font-semibold text-lg">Restore from Backup</h2>
<p className="text-sm text-muted-foreground">
Upload a <code>.zip</code> backup file to restore. <strong>This replaces everything</strong> all current data will be overwritten.
</p>
<div
className={`flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-10 transition-colors cursor-pointer ${
dragOver ? "border-primary bg-primary/5" : "border-border hover:border-primary/50"
}`}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files[0];
if (f) handleFile(f);
}}
>
<Upload className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{restoring ? "Restoring…" : "Drop a backup .zip here, or click to choose"}
</p>
<input
ref={inputRef}
type="file"
accept=".zip"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
/>
</div>
{restoreStatus?.type === "success" && (
<div className="flex items-start gap-2 rounded-md bg-green-50 border border-green-200 p-3 text-sm text-green-800">
<CheckCircle className="h-4 w-4 mt-0.5 shrink-0" />
<div>
Restore complete. Data from backup exported on{" "}
<strong>{new Date(restoreStatus.exportedAt).toLocaleString()}</strong> is now live.
Reload the page to see it.
</div>
</div>
)}
{restoreStatus?.type === "error" && (
<div className="flex items-start gap-2 rounded-md bg-red-50 border border-red-200 p-3 text-sm text-red-800">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<div>{restoreStatus.message}</div>
</div>
)}
</section>
</div>
);
} }

View File

@@ -0,0 +1,18 @@
import { ApiTokensPanel } from "@/components/settings/api-tokens-panel";
export const metadata = { title: "API tokens" };
export default function TokensPage() {
return (
<div className="space-y-6 max-w-2xl">
<div>
<h1 className="font-display text-2xl font-semibold">API tokens</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Personal access tokens for the Moon Base API use them with the{" "}
<code className="text-xs">Authorization: Bearer </code> header.
</p>
</div>
<ApiTokensPanel />
</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,57 @@
import { NextResponse } from "next/server";
import { compare, hash } from "bcryptjs";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
export const dynamic = "force-dynamic";
const schema = z.object({
currentPassword: z.string().min(1, "Enter your current password."),
newPassword: z.string().min(8, "New password must be at least 8 characters."),
});
export async function POST(req: Request) {
let session;
try {
session = await requireAuth();
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const parsed = schema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid input." },
{ status: 400 }
);
}
const { currentPassword, newPassword } = parsed.data;
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { passwordHash: true },
});
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!(await compare(currentPassword, user.passwordHash))) {
return NextResponse.json({ error: "Current password is incorrect." }, { status: 400 });
}
if (await compare(newPassword, user.passwordHash)) {
return NextResponse.json(
{ error: "New password must be different from your current one." },
{ status: 400 }
);
}
await db.user.update({
where: { id: session.user.id },
data: { passwordHash: await hash(newPassword, 10), mustChangePassword: false },
});
await db.auditEvent.create({
data: { action: "user.change_password", actorId: session.user.id, entityId: session.user.id },
});
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
// DELETE /api/account/tokens/:id — revoke one of your own tokens.
export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
try {
const session = await requireAuth();
const token = await db.apiToken.findUnique({ where: { id: params.id } });
if (!token || token.userId !== session.user.id)
return NextResponse.json({ error: "Not found" }, { status: 404 });
await db.apiToken.update({ where: { id: params.id }, data: { revokedAt: new Date() } });
await db.auditEvent.create({
data: {
action: "api_token.revoke",
entityId: params.id,
actorId: session.user.id,
payload: { name: token.name },
},
});
return NextResponse.json({ ok: true });
} catch (err) {
if (err instanceof Error && err.message === "Unauthorized")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
import {
generateRawToken, hashToken, tokenPrefix, KNOWN_SCOPES,
} from "@/lib/api/authenticate";
const scopeSchema = z.union([z.literal("*"), z.enum(KNOWN_SCOPES)]);
const createSchema = z.object({
name: z.string().min(1),
scopes: z.array(scopeSchema).min(1),
expiresAt: z.string().optional(),
});
// GET /api/account/tokens — list the current user's tokens (no secrets).
export async function GET() {
try {
const session = await requireAuth();
const tokens = await db.apiToken.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
select: {
id: true, name: true, prefix: true, scopes: true,
lastUsedAt: true, expiresAt: true, revokedAt: true, createdAt: true,
},
});
return NextResponse.json(tokens);
} catch (err) {
if (err instanceof Error && err.message === "Unauthorized")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}
// POST /api/account/tokens — create a token. The raw value is returned ONCE.
export async function POST(req: Request) {
try {
const session = await requireAuth();
const data = createSchema.parse(await req.json());
const raw = generateRawToken();
const token = await db.apiToken.create({
data: {
name: data.name.trim(),
tokenHash: hashToken(raw),
prefix: tokenPrefix(raw),
scopes: data.scopes,
userId: session.user.id,
expiresAt: data.expiresAt ? new Date(data.expiresAt) : null,
},
select: { id: true, name: true, prefix: true, scopes: true, expiresAt: true, createdAt: true },
});
await db.auditEvent.create({
data: {
action: "api_token.create",
entityId: token.id,
actorId: session.user.id,
payload: { name: token.name, scopes: token.scopes },
},
});
// `token` (raw) is shown to the user once and never stored in plaintext.
return NextResponse.json({ ...token, token: raw }, { status: 201 });
} catch (err) {
if (err instanceof z.ZodError)
return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 });
if (err instanceof Error && err.message === "Unauthorized")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -10,31 +10,52 @@ export async function GET() {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
const [users, plantGroups, plants, plantLogs, tasks, taskCompletions, auditEvents] = const [locations, plantTypes, users, apiTokens, plantGroups, plants, plantLogs, tasks, taskCompletions,
labels, items, labelOnItems, itemAttachments, itemLogs, animals, animalLogs, auditEvents] =
await Promise.all([ await Promise.all([
db.location.findMany(),
db.plantType.findMany(),
db.user.findMany(), db.user.findMany(),
db.apiToken.findMany(),
db.plantGroup.findMany(), db.plantGroup.findMany(),
db.plant.findMany(), db.plant.findMany(),
db.plantLog.findMany(), db.plantLog.findMany(),
db.task.findMany(), db.task.findMany(),
db.taskCompletion.findMany(), db.taskCompletion.findMany(),
db.label.findMany(),
db.item.findMany(),
db.labelOnItem.findMany(),
db.itemAttachment.findMany(),
db.itemLog.findMany(),
db.animal.findMany(),
db.animalLog.findMany(),
db.auditEvent.findMany({ orderBy: { createdAt: "asc" } }), db.auditEvent.findMany({ orderBy: { createdAt: "asc" } }),
]); ]);
const manifest = { const manifest = {
version: 1, version: 1,
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
tables: ["users", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "auditEvents"], tables: ["locations", "plantTypes", "users", "apiTokens", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "labels", "items", "labelOnItems", "itemAttachments", "itemLogs", "animals", "animalLogs", "auditEvents"],
}; };
const zip = zipSync({ const zip = zipSync({
"manifest.json": strToU8(JSON.stringify(manifest, null, 2)), "manifest.json": strToU8(JSON.stringify(manifest, null, 2)),
"locations.json": strToU8(JSON.stringify(locations, null, 2)),
"plantTypes.json": strToU8(JSON.stringify(plantTypes, null, 2)),
"users.json": strToU8(JSON.stringify(users, null, 2)), "users.json": strToU8(JSON.stringify(users, null, 2)),
"apiTokens.json": strToU8(JSON.stringify(apiTokens, null, 2)),
"plantGroups.json": strToU8(JSON.stringify(plantGroups, null, 2)), "plantGroups.json": strToU8(JSON.stringify(plantGroups, null, 2)),
"plants.json": strToU8(JSON.stringify(plants, null, 2)), "plants.json": strToU8(JSON.stringify(plants, null, 2)),
"plantLogs.json": strToU8(JSON.stringify(plantLogs, null, 2)), "plantLogs.json": strToU8(JSON.stringify(plantLogs, null, 2)),
"tasks.json": strToU8(JSON.stringify(tasks, null, 2)), "tasks.json": strToU8(JSON.stringify(tasks, null, 2)),
"taskCompletions.json": strToU8(JSON.stringify(taskCompletions, null, 2)), "taskCompletions.json": strToU8(JSON.stringify(taskCompletions, null, 2)),
"labels.json": strToU8(JSON.stringify(labels, null, 2)),
"items.json": strToU8(JSON.stringify(items, null, 2)),
"labelOnItems.json": strToU8(JSON.stringify(labelOnItems, null, 2)),
"itemAttachments.json": strToU8(JSON.stringify(itemAttachments, null, 2)),
"itemLogs.json": strToU8(JSON.stringify(itemLogs, null, 2)),
"animals.json": strToU8(JSON.stringify(animals, null, 2)),
"animalLogs.json": strToU8(JSON.stringify(animalLogs, null, 2)),
"auditEvents.json": strToU8(JSON.stringify(auditEvents, null, 2)), "auditEvents.json": strToU8(JSON.stringify(auditEvents, null, 2)),
}); });

View File

@@ -1,57 +1,135 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { unzipSync, strFromU8 } from "fflate"; import { unzipSync, strFromU8 } from "fflate";
import { z } from "zod";
import { UserRole } from "@prisma/client";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { requireAuth, isAdmin } from "@/lib/auth"; import { requireAuth, isAdmin } from "@/lib/auth";
export async function POST(req: Request) { export const dynamic = "force-dynamic";
try {
const session = await requireAuth();
if (!isAdmin(session.user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// A malformed/crafted backup must not be able to inject arbitrary auth rows.
// users is the security boundary, so it's validated strictly (known fields only,
// role constrained to the enum). Other tables are app-owned data — we only check
// they're arrays; Prisma rejects any unknown columns on insert.
class BadBackup extends Error {}
const userSchema = z
.object({
id: z.string(),
email: z.string(),
username: z.string().nullable().optional(),
name: z.string(),
passwordHash: z.string(),
role: z.nativeEnum(UserRole).optional(),
active: z.boolean().optional(),
mustChangePassword: z.boolean().optional(),
createdAt: z.string().optional(),
updatedAt: z.string().optional(),
})
.strict();
export async function POST(req: Request) {
let session;
try {
session = await requireAuth();
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!isAdmin(session.user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
try {
const form = await req.formData(); const form = await req.formData();
const file = form.get("file") as File | null; const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "No file uploaded" }, { status: 400 }); if (!file) return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
const buf = await file.arrayBuffer(); let files: Record<string, Uint8Array>;
const files = unzipSync(new Uint8Array(buf)); try {
files = unzipSync(new Uint8Array(await file.arrayBuffer()));
} catch {
throw new BadBackup("not a valid zip");
}
const parse = (name: string) => { const parse = (name: string): unknown => {
if (!files[name]) throw new Error(`Missing ${name} in zip`); if (!files[name]) throw new BadBackup(`missing ${name}`);
return JSON.parse(strFromU8(files[name])); try {
return JSON.parse(strFromU8(files[name]));
} catch {
throw new BadBackup(`invalid JSON in ${name}`);
}
};
const asArray = (name: string): unknown[] => {
const v = parse(name);
if (!Array.isArray(v)) throw new BadBackup(`${name} is not an array`);
return v;
}; };
const manifest = parse("manifest.json"); const manifest = parse("manifest.json") as { version?: number; exportedAt?: string };
if (manifest.version !== 1) { if (manifest?.version !== 1) {
return NextResponse.json({ error: "Unsupported backup version" }, { status: 400 }); return NextResponse.json({ error: "Unsupported backup version" }, { status: 400 });
} }
const users = parse("users.json"); // Older backups won't have newer tables — tolerate their absence.
const plantGroups = parse("plantGroups.json"); const locations = files["locations.json"] ? asArray("locations.json") : [];
const plants = parse("plants.json"); const plantTypes = files["plantTypes.json"] ? asArray("plantTypes.json") : [];
const plantLogs = parse("plantLogs.json"); const users = z.array(userSchema).parse(asArray("users.json"));
const tasks = parse("tasks.json"); const apiTokens = files["apiTokens.json"] ? asArray("apiTokens.json") : [];
const taskCompletions = parse("taskCompletions.json"); const plantGroups = asArray("plantGroups.json");
const auditEvents = parse("auditEvents.json"); const plants = asArray("plants.json");
const plantLogs = asArray("plantLogs.json");
const tasks = asArray("tasks.json");
const taskCompletions = asArray("taskCompletions.json");
const labels = files["labels.json"] ? asArray("labels.json") : [];
const items = files["items.json"] ? asArray("items.json") : [];
const labelOnItems = files["labelOnItems.json"] ? asArray("labelOnItems.json") : [];
const itemAttachments = files["itemAttachments.json"] ? asArray("itemAttachments.json") : [];
const itemLogs = files["itemLogs.json"] ? asArray("itemLogs.json") : [];
const animals = files["animals.json"] ? asArray("animals.json") : [];
const animalLogs = files["animalLogs.json"] ? asArray("animalLogs.json") : [];
const auditEvents = asArray("auditEvents.json");
// Restore inside a transaction — wipe then reload in FK order // Restore inside a transaction — wipe then reload in FK order.
await db.$transaction(async (tx) => { await db.$transaction(async (tx) => {
await tx.taskCompletion.deleteMany(); await tx.taskCompletion.deleteMany();
await tx.plantLog.deleteMany(); await tx.plantLog.deleteMany();
await tx.itemLog.deleteMany();
await tx.itemAttachment.deleteMany();
await tx.labelOnItem.deleteMany();
await tx.animalLog.deleteMany();
await tx.task.deleteMany(); await tx.task.deleteMany();
await tx.item.deleteMany();
await tx.label.deleteMany();
await tx.animal.deleteMany();
await tx.plant.deleteMany(); await tx.plant.deleteMany();
await tx.plantGroup.deleteMany(); await tx.plantGroup.deleteMany();
await tx.plantType.deleteMany();
await tx.location.deleteMany();
await tx.apiToken.deleteMany();
await tx.auditEvent.deleteMany(); await tx.auditEvent.deleteMany();
await tx.user.deleteMany(); await tx.user.deleteMany();
if (users.length) await tx.user.createMany({ data: users }); if (users.length) await tx.user.createMany({ data: users });
if (plantGroups.length) await tx.plantGroup.createMany({ data: plantGroups }); if (apiTokens.length) await tx.apiToken.createMany({ data: apiTokens as never });
if (plants.length) await tx.plant.createMany({ data: plants }); // Location self-references parentId; a single createMany is one statement,
if (plantLogs.length) await tx.plantLog.createMany({ data: plantLogs }); // so the FK is validated only after every row exists — order-independent.
if (tasks.length) await tx.task.createMany({ data: tasks }); if (locations.length) await tx.location.createMany({ data: locations as never });
if (taskCompletions.length) await tx.taskCompletion.createMany({ data: taskCompletions }); if (plantTypes.length) await tx.plantType.createMany({ data: plantTypes as never });
if (auditEvents.length) await tx.auditEvent.createMany({ data: auditEvents }); if (plantGroups.length) await tx.plantGroup.createMany({ data: plantGroups as never });
if (plants.length) await tx.plant.createMany({ data: plants as never });
if (plantLogs.length) await tx.plantLog.createMany({ data: plantLogs as never });
if (labels.length) await tx.label.createMany({ data: labels as never });
// Item self-references parentItemId; one createMany statement validates the
// FK only after all rows exist — order-independent.
if (items.length) await tx.item.createMany({ data: items as never });
if (labelOnItems.length) await tx.labelOnItem.createMany({ data: labelOnItems as never });
if (itemAttachments.length) await tx.itemAttachment.createMany({ data: itemAttachments as never });
if (itemLogs.length) await tx.itemLog.createMany({ data: itemLogs as never });
if (animals.length) await tx.animal.createMany({ data: animals as never });
if (animalLogs.length) await tx.animalLog.createMany({ data: animalLogs as never });
if (tasks.length) await tx.task.createMany({ data: tasks as never });
if (taskCompletions.length) await tx.taskCompletion.createMany({ data: taskCompletions as never });
if (auditEvents.length) await tx.auditEvent.createMany({ data: auditEvents as never });
}); });
await db.auditEvent.create({ await db.auditEvent.create({
@@ -64,10 +142,13 @@ export async function POST(req: Request) {
return NextResponse.json({ ok: true, exportedAt: manifest.exportedAt }); return NextResponse.json({ ok: true, exportedAt: manifest.exportedAt });
} catch (err) { } catch (err) {
if (err instanceof Error && err.message === "Unauthorized") { if (err instanceof BadBackup || err instanceof z.ZodError) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json(
{ error: "The backup file is invalid or corrupted." },
{ status: 400 }
);
} }
console.error(err); console.error("restore failed:", err);
return NextResponse.json({ error: String(err) }, { status: 500 }); return NextResponse.json({ error: "Restore failed." }, { status: 500 });
} }
} }

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,71 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
import { descendantIds } from "@/lib/locations/tree";
const schema = z.object({
type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]),
notes: z.string().optional(),
quantity: z.string().optional(),
date: z.string().optional(),
});
// POST /api/locations/[id]/log — append a log to every active plant in this
// location and its sub-locations (e.g. "watered the whole back yard").
export async function POST(req: Request, { params }: { params: { id: string } }) {
try {
const session = await requireAuth();
const location = await db.location.findUnique({ where: { id: params.id } });
if (!location || !location.active)
return NextResponse.json({ error: "Location not found" }, { status: 404 });
// This location + everything nested under it.
const allRows = await db.location.findMany({ select: { id: true, parentId: true, name: true } });
const locationIds = descendantIds(params.id, allRows, true);
const plants = await db.plant.findMany({
where: { locationId: { in: locationIds }, active: true },
select: { id: true },
});
if (plants.length === 0)
return NextResponse.json({ error: "No plants found in this location" }, { status: 404 });
const data = schema.parse(await req.json());
const date = data.date ? new Date(data.date) : new Date();
const logs = await db.$transaction(
plants.map((p) =>
db.plantLog.create({
data: {
plantId: p.id,
type: data.type,
date,
notes: data.notes?.trim() || null,
quantity: data.quantity?.trim() || null,
actorId: session.user.id,
},
}),
),
);
await db.auditEvent.create({
data: {
action: "location.log",
entityId: params.id,
actorId: session.user.id,
payload: { location: location.name, type: data.type, plantCount: logs.length },
},
});
return NextResponse.json({ count: logs.length }, { status: 201 });
} catch (err) {
if (err instanceof z.ZodError)
return NextResponse.json({ error: err.issues[0]?.message }, { status: 400 });
if (err instanceof Error && err.message === "Unauthorized")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,106 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { LocationKind } from "@prisma/client";
import { db } from "@/lib/db";
import { requireAuth, isAdmin } from "@/lib/auth";
import { recomputeAllPaths, wouldCreateCycle } from "@/lib/locations/db";
const updateSchema = z.object({
name: z.string().min(1).optional(),
kind: z.nativeEnum(LocationKind).optional(),
parentId: z.string().nullable().optional(), // null → move to top level
notes: z.string().nullable().optional(),
});
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
try {
const session = await requireAuth();
const location = await db.location.findUnique({ where: { id: params.id } });
if (!location || !location.active)
return NextResponse.json({ error: "Not found" }, { status: 404 });
const data = updateSchema.parse(await req.json());
// Guard against cycles when re-parenting.
if (data.parentId) {
if (await wouldCreateCycle(params.id, data.parentId))
return NextResponse.json(
{ error: "A location can't be moved under itself or its own descendant" },
{ status: 400 },
);
const parent = await db.location.findUnique({ where: { id: data.parentId } });
if (!parent) return NextResponse.json({ error: "Parent location not found" }, { status: 400 });
}
const updated = await db.$transaction(async (tx) => {
const u = await tx.location.update({
where: { id: params.id },
data: {
...(data.name !== undefined ? { name: data.name.trim() } : {}),
...(data.kind !== undefined ? { kind: data.kind } : {}),
...(data.parentId !== undefined ? { parentId: data.parentId } : {}),
...(data.notes !== undefined ? { notes: data.notes?.trim() || null } : {}),
},
});
await recomputeAllPaths(tx as unknown as typeof db);
await tx.auditEvent.create({
data: {
action: "location.update",
entityId: params.id,
actorId: session.user.id,
payload: { name: u.name },
},
});
return u;
});
return NextResponse.json(updated);
} catch (err) {
if (err instanceof z.ZodError)
return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 });
if (err instanceof Error && err.message === "Unauthorized")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}
export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
try {
const session = await requireAuth();
if (!isAdmin(session.user.role))
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const location = await db.location.findUnique({ where: { id: params.id } });
if (!location || !location.active)
return NextResponse.json({ error: "Not found" }, { status: 404 });
// Don't orphan live records — require the location be emptied first.
const [plantCount, childCount] = await Promise.all([
db.plant.count({ where: { locationId: params.id, active: true } }),
db.location.count({ where: { parentId: params.id, active: true } }),
]);
if (plantCount > 0 || childCount > 0)
return NextResponse.json(
{ error: "Move the plants and sub-locations out of this location first" },
{ status: 400 },
);
await db.location.update({ where: { id: params.id }, data: { active: false } });
await db.auditEvent.create({
data: {
action: "location.delete",
entityId: params.id,
actorId: session.user.id,
payload: { name: location.name },
},
});
return NextResponse.json({ ok: true });
} catch (err) {
if (err instanceof Error && err.message === "Unauthorized")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
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

@@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { LocationKind } from "@prisma/client";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
import { recomputeAllPaths } from "@/lib/locations/db";
const createSchema = z.object({
name: z.string().min(1),
kind: z.nativeEnum(LocationKind).optional(),
parentId: z.string().optional(),
notes: z.string().optional(),
});
// GET /api/locations — flat list of active locations (client builds the tree).
export async function GET() {
try {
await requireAuth();
const locations = await db.location.findMany({
where: { active: true },
orderBy: { name: "asc" },
include: { _count: { select: { plants: { where: { active: true } } } } },
});
return NextResponse.json(locations);
} catch (err) {
if (err instanceof Error && err.message === "Unauthorized")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}
// POST /api/locations — create a location node.
export async function POST(req: Request) {
try {
const session = await requireAuth();
const data = createSchema.parse(await req.json());
const name = data.name.trim();
if (!name) return NextResponse.json({ error: "Name is required" }, { status: 400 });
// Validate parent exists (if given).
if (data.parentId) {
const parent = await db.location.findUnique({ where: { id: data.parentId } });
if (!parent) return NextResponse.json({ error: "Parent location not found" }, { status: 400 });
}
const location = await db.$transaction(async (tx) => {
const created = await tx.location.create({
data: {
name,
kind: data.kind ?? LocationKind.AREA,
parentId: data.parentId || null,
notes: data.notes?.trim() || null,
},
});
await recomputeAllPaths(tx as unknown as typeof db);
await tx.auditEvent.create({
data: {
action: "location.create",
entityId: created.id,
actorId: session.user.id,
payload: { name: created.name, kind: created.kind, parentId: created.parentId },
},
});
return created;
});
return NextResponse.json(location, { status: 201 });
} catch (err) {
if (err instanceof z.ZodError)
return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 });
if (err instanceof Error && err.message === "Unauthorized")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,107 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { PlantCategory, Prisma } from "@prisma/client";
import { db } from "@/lib/db";
import { requireAuth, isAdmin } from "@/lib/auth";
const month = z.coerce.number().int().min(1).max(12).nullable();
// All optional — a PATCH only touches the fields it sends. Empty string clears
// a text field; null clears a numeric/boolean one.
const updateSchema = z.object({
commonName: z.string().min(1).optional(),
species: z.string().optional(),
category: z.nativeEnum(PlantCategory).optional(),
careNotes: z.string().optional(),
sun: z.string().optional(),
water: z.string().optional(),
bloomStart: month.optional(),
bloomEnd: month.optional(),
harvestStart: month.optional(),
harvestEnd: month.optional(),
toxicToPets: z.boolean().nullable().optional(),
edibleParts: z.string().optional(),
spacing: z.string().optional(),
imageUrl: z.string().optional(),
uwExtensionUrl: z.string().optional(),
uwExtensionTitle: z.string().optional(),
notes: z.string().optional(),
});
const TEXT_FIELDS = [
"species", "careNotes", "sun", "water", "edibleParts", "spacing", "imageUrl",
"uwExtensionUrl", "uwExtensionTitle", "notes",
] as const;
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
try {
const session = await requireAuth();
const existing = await db.plantType.findUnique({ where: { id: params.id } });
if (!existing || !existing.active)
return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = await req.json();
const data = updateSchema.parse(body);
const update: Prisma.PlantTypeUpdateInput = {};
if (data.commonName !== undefined) update.commonName = data.commonName.trim();
if (data.category !== undefined) update.category = data.category;
if (data.toxicToPets !== undefined) update.toxicToPets = data.toxicToPets;
for (const k of ["bloomStart", "bloomEnd", "harvestStart", "harvestEnd"] as const) {
if (data[k] !== undefined) update[k] = data[k];
}
for (const k of TEXT_FIELDS) {
if (data[k] !== undefined) update[k] = data[k]?.trim() || null;
}
const updated = await db.plantType.update({ where: { id: params.id }, data: update });
await db.auditEvent.create({
data: {
action: "plant_type.update",
entityId: params.id,
actorId: session.user.id,
payload: { commonName: updated.commonName },
},
});
return NextResponse.json(updated);
} catch (err) {
if (err instanceof z.ZodError)
return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 });
if (err instanceof Error && err.message === "Unauthorized")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}
export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
try {
const session = await requireAuth();
if (!isAdmin(session.user.role))
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const existing = await db.plantType.findUnique({ where: { id: params.id } });
if (!existing || !existing.active)
return NextResponse.json({ error: "Not found" }, { status: 404 });
// Soft-delete; plantings keep their plantTypeId (the row just goes inactive).
await db.plantType.update({ where: { id: params.id }, data: { active: false } });
await db.auditEvent.create({
data: {
action: "plant_type.delete",
entityId: params.id,
actorId: session.user.id,
payload: { commonName: existing.commonName },
},
});
return NextResponse.json({ ok: true });
} catch (err) {
if (err instanceof Error && err.message === "Unauthorized")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,95 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { PlantCategory } from "@prisma/client";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
const month = z.coerce.number().int().min(1).max(12);
const createSchema = z.object({
commonName: z.string().min(1),
species: z.string().optional(),
category: z.nativeEnum(PlantCategory).optional(),
careNotes: z.string().optional(),
sun: z.string().optional(),
water: z.string().optional(),
bloomStart: month.optional(),
bloomEnd: month.optional(),
harvestStart: month.optional(),
harvestEnd: month.optional(),
toxicToPets: z.boolean().nullable().optional(), // "Unknown" in the dialog arrives as null
edibleParts: z.string().optional(),
spacing: z.string().optional(),
imageUrl: z.string().optional(),
uwExtensionUrl: z.string().optional(),
uwExtensionTitle: z.string().optional(),
notes: z.string().optional(),
});
// GET /api/plant-types — list active kinds with a planting count.
export async function GET() {
try {
await requireAuth();
const types = await db.plantType.findMany({
where: { active: true },
orderBy: { commonName: "asc" },
include: { _count: { select: { plants: { where: { active: true } } } } },
});
return NextResponse.json(types);
} catch (err) {
if (err instanceof Error && err.message === "Unauthorized")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}
// POST /api/plant-types — create a kind.
export async function POST(req: Request) {
try {
const session = await requireAuth();
const data = createSchema.parse(await req.json());
const commonName = data.commonName.trim();
if (!commonName) return NextResponse.json({ error: "Name is required" }, { status: 400 });
const created = await db.plantType.create({
data: {
commonName,
species: data.species?.trim() || null,
category: data.category ?? PlantCategory.OTHER,
careNotes: data.careNotes?.trim() || null,
sun: data.sun?.trim() || null,
water: data.water?.trim() || null,
bloomStart: data.bloomStart ?? null,
bloomEnd: data.bloomEnd ?? null,
harvestStart: data.harvestStart ?? null,
harvestEnd: data.harvestEnd ?? null,
toxicToPets: data.toxicToPets ?? null,
edibleParts: data.edibleParts?.trim() || null,
spacing: data.spacing?.trim() || null,
imageUrl: data.imageUrl?.trim() || null,
uwExtensionUrl: data.uwExtensionUrl?.trim() || null,
uwExtensionTitle: data.uwExtensionTitle?.trim() || null,
notes: data.notes?.trim() || null,
},
});
await db.auditEvent.create({
data: {
action: "plant_type.create",
entityId: created.id,
actorId: session.user.id,
payload: { commonName: created.commonName, category: created.category },
},
});
return NextResponse.json(created, { status: 201 });
} catch (err) {
if (err instanceof z.ZodError)
return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 });
if (err instanceof Error && err.message === "Unauthorized")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -10,12 +10,23 @@ const updateSchema = z.object({
variety: z.string().optional(), variety: z.string().optional(),
category: z.string().min(1), category: z.string().min(1),
zone: z.string().optional(), zone: z.string().optional(),
locationId: z.string().nullable().optional(),
plantedAt: z.string().optional(), plantedAt: z.string().optional(),
source: z.string().optional(), source: z.string().optional(),
notes: z.string().optional(), notes: z.string().optional(),
groupId: z.string().nullable().optional(), groupId: z.string().nullable().optional(),
}); });
// Dual-write helper (Release A): keep the legacy `zone` string in sync with the
// chosen location's name until Release B drops the column.
async function resolveZone(locationId?: string | null, zone?: string): Promise<string | null> {
if (locationId) {
const loc = await db.location.findUnique({ where: { id: locationId }, select: { name: true } });
if (loc) return loc.name;
}
return zone?.trim() || null;
}
export async function PATCH(req: Request, { params }: { params: { id: string } }) { export async function PATCH(req: Request, { params }: { params: { id: string } }) {
try { try {
const session = await requireAuth(); const session = await requireAuth();
@@ -35,6 +46,8 @@ export async function PATCH(req: Request, { params }: { params: { id: string } }
? await fetchPlantImageUrl(species, commonName) ? await fetchPlantImageUrl(species, commonName)
: undefined; : undefined;
const zone = await resolveZone(data.locationId, data.zone);
const updated = await db.plant.update({ const updated = await db.plant.update({
where: { id: params.id }, where: { id: params.id },
data: { data: {
@@ -42,7 +55,8 @@ export async function PATCH(req: Request, { params }: { params: { id: string } }
species, species,
variety: data.variety?.trim() || null, variety: data.variety?.trim() || null,
category: data.category as PlantCategory, category: data.category as PlantCategory,
zone: data.zone?.trim() || null, zone, // dual-write: kept in sync with location.name until Release B
locationId: data.locationId ?? null,
plantedAt: data.plantedAt ? new Date(data.plantedAt) : null, plantedAt: data.plantedAt ? new Date(data.plantedAt) : null,
source: data.source?.trim() || null, source: data.source?.trim() || null,
notes: data.notes?.trim() || null, notes: data.notes?.trim() || null,

View File

@@ -10,12 +10,40 @@ const createSchema = z.object({
variety: z.string().optional(), variety: z.string().optional(),
category: z.string().min(1), category: z.string().min(1),
zone: z.string().optional(), zone: z.string().optional(),
locationId: z.string().optional(),
plantedAt: z.string().optional(), plantedAt: z.string().optional(),
source: z.string().optional(), source: z.string().optional(),
notes: z.string().optional(), notes: z.string().optional(),
groupId: z.string().optional(), groupId: z.string().optional(),
}); });
// Dual-write helper (Release A): resolve a locationId to its name so the legacy
// `zone` column stays populated until Release B drops it. Returns the zone
// string to stamp, falling back to the freeform zone when no location is given.
async function resolveZone(locationId?: string, zone?: string): Promise<string | null> {
if (locationId) {
const loc = await db.location.findUnique({ where: { id: locationId }, select: { name: true } });
if (loc) return loc.name;
}
return zone?.trim() || null;
}
// Link a new planting to its "kind" record — by species first, else common name.
async function resolvePlantTypeId(species: string | null, commonName: string): Promise<string | null> {
if (species) {
const bySpecies = await db.plantType.findFirst({
where: { active: true, species: { equals: species, mode: "insensitive" } },
select: { id: true },
});
if (bySpecies) return bySpecies.id;
}
const byCommon = await db.plantType.findFirst({
where: { active: true, commonName: { equals: commonName, mode: "insensitive" } },
select: { id: true },
});
return byCommon?.id ?? null;
}
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
const session = await requireAuth(); const session = await requireAuth();
@@ -25,6 +53,8 @@ export async function POST(req: Request) {
const species = data.species?.trim() || null; const species = data.species?.trim() || null;
const commonName = data.commonName.trim(); const commonName = data.commonName.trim();
const imageUrl = await fetchPlantImageUrl(species, commonName); const imageUrl = await fetchPlantImageUrl(species, commonName);
const zone = await resolveZone(data.locationId, data.zone);
const plantTypeId = await resolvePlantTypeId(species, commonName);
const plant = await db.plant.create({ const plant = await db.plant.create({
data: { data: {
@@ -32,7 +62,9 @@ export async function POST(req: Request) {
species, species,
variety: data.variety?.trim() || null, variety: data.variety?.trim() || null,
category: data.category as PlantCategory, category: data.category as PlantCategory,
zone: data.zone?.trim() || null, zone, // dual-write: kept in sync with location.name until Release B
locationId: data.locationId || null,
plantTypeId,
plantedAt: data.plantedAt ? new Date(data.plantedAt) : null, plantedAt: data.plantedAt ? new Date(data.plantedAt) : null,
source: data.source?.trim() || null, source: data.source?.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

@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { animalLogInput } from "@/lib/api/schemas";
import { addAnimalLog } from "@/lib/services/animals";
// POST /api/v1/animals/:id/logs
export async function POST(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "animals:write");
const input = animalLogInput.parse(await req.json());
return NextResponse.json(await addAnimalLog(ctx, params.id, input), { status: 201 });
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { animalUpdateInput } from "@/lib/api/schemas";
import { getAnimal, updateAnimal, deleteAnimal } from "@/lib/services/animals";
export async function GET(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "animals:read");
return NextResponse.json(await getAnimal(ctx, params.id));
} catch (err) {
return handleApiError(err);
}
}
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "animals:write");
const input = animalUpdateInput.parse(await req.json());
return NextResponse.json(await updateAnimal(ctx, params.id, input));
} catch (err) {
return handleApiError(err);
}
}
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "animals:write");
await deleteAnimal(ctx, params.id);
return NextResponse.json({ ok: true });
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { animalCreateInput } from "@/lib/api/schemas";
import { listAnimals, createAnimal } from "@/lib/services/animals";
// GET /api/v1/animals?q=&species=
export async function GET(req: Request) {
try {
const ctx = await authenticateRequest(req, "animals:read");
const sp = new URL(req.url).searchParams;
return NextResponse.json(
await listAnimals(ctx, { q: sp.get("q") ?? undefined, species: sp.get("species") ?? undefined }),
);
} catch (err) {
return handleApiError(err);
}
}
// POST /api/v1/animals
export async function POST(req: Request) {
try {
const ctx = await authenticateRequest(req, "animals:write");
const input = animalCreateInput.parse(await req.json());
return NextResponse.json(await createAnimal(ctx, input), { status: 201 });
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,49 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { authenticateRequest, handleApiError, ApiError } from "@/lib/api/authenticate";
export const dynamic = "force-dynamic";
const KINDS = new Set(["PHOTO", "MANUAL", "RECEIPT", "WARRANTY"]);
// POST /api/v1/items/:id/attachments — multipart upload (file, kind). Files land
// under public/uploads/items (a persistent Docker volume), same as suggestions.
export async function POST(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "items:write");
const item = await db.item.findUnique({ where: { id: params.id }, select: { active: true, imageUrl: true } });
if (!item || !item.active) throw new ApiError(404, "Item not found");
const form = await req.formData();
const file = form.get("file") as File | null;
const kind = String(form.get("kind") ?? "PHOTO").toUpperCase();
if (!file) throw new ApiError(400, "No file uploaded");
if (!KINDS.has(kind)) throw new ApiError(400, "Invalid attachment kind");
const safe = `${Date.now()}-${file.name.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
const dir = path.join(process.cwd(), "public", "uploads", "items");
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, safe), Buffer.from(await file.arrayBuffer()));
const base = (process.env.NEXT_PUBLIC_BASE_URL || process.env.NEXTAUTH_URL || "").replace(/\/+$/, "");
const url = base ? `${base}/uploads/items/${safe}` : `/uploads/items/${safe}`;
const attachment = await db.itemAttachment.create({
data: { itemId: params.id, kind, url, name: file.name },
});
// First photo becomes the item's thumbnail.
if (kind === "PHOTO" && !item.imageUrl) {
await db.item.update({ where: { id: params.id }, data: { imageUrl: url } });
}
await db.auditEvent.create({
data: { action: "item.attachment.add", entityId: params.id, actorId: ctx.userId, payload: { kind, via: ctx.via } },
});
return NextResponse.json(attachment, { 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 { itemLogInput } from "@/lib/api/schemas";
import { addItemLog } from "@/lib/services/items";
// POST /api/v1/items/:id/logs
export async function POST(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "items:write");
const input = itemLogInput.parse(await req.json());
return NextResponse.json(await addItemLog(ctx, params.id, input), { status: 201 });
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { itemUpdateInput } from "@/lib/api/schemas";
import { getItem, updateItem, deleteItem } from "@/lib/services/items";
// GET /api/v1/items/:id
export async function GET(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "items:read");
return NextResponse.json(await getItem(ctx, params.id));
} catch (err) {
return handleApiError(err);
}
}
// PATCH /api/v1/items/:id
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "items:write");
const input = itemUpdateInput.parse(await req.json());
return NextResponse.json(await updateItem(ctx, params.id, input));
} catch (err) {
return handleApiError(err);
}
}
// DELETE /api/v1/items/:id (soft-delete; admin only — enforced in the service)
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "items:write");
await deleteItem(ctx, params.id);
return NextResponse.json({ ok: true });
} catch (err) {
return handleApiError(err);
}
}

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,34 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { itemCreateInput } from "@/lib/api/schemas";
import { listItems, createItem } from "@/lib/services/items";
// GET /api/v1/items?q=&locationId=&kind=DURABLE
export async function GET(req: Request) {
try {
const ctx = await authenticateRequest(req, "items:read");
const sp = new URL(req.url).searchParams;
const kindParam = sp.get("kind");
const kind = kindParam === "DURABLE" || kindParam === "CONSUMABLE" ? kindParam : undefined;
return NextResponse.json(
await listItems(ctx, {
q: sp.get("q") ?? undefined,
locationId: sp.get("locationId") ?? undefined,
kind,
}),
);
} catch (err) {
return handleApiError(err);
}
}
// POST /api/v1/items
export async function POST(req: Request) {
try {
const ctx = await authenticateRequest(req, "items:write");
const input = itemCreateInput.parse(await req.json());
return NextResponse.json(await createItem(ctx, input), { status: 201 });
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
const createSchema = z.object({
name: z.string().min(1),
color: z.string().optional(),
});
// GET /api/v1/labels
export async function GET(req: Request) {
try {
await authenticateRequest(req, "items:read");
return NextResponse.json(await db.label.findMany({ orderBy: { name: "asc" } }));
} catch (err) {
return handleApiError(err);
}
}
// POST /api/v1/labels — create (or return existing by name).
export async function POST(req: Request) {
try {
const ctx = await authenticateRequest(req, "items:write");
const data = createSchema.parse(await req.json());
const name = data.name.trim();
const existing = await db.label.findUnique({ where: { name } });
if (existing) return NextResponse.json(existing);
const label = await db.label.create({ data: { name, color: data.color?.trim() || null } });
await db.auditEvent.create({
data: { action: "label.create", entityId: label.id, actorId: ctx.userId, payload: { name } },
});
return NextResponse.json(label, { status: 201 });
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { listLocations } from "@/lib/services/locations";
// GET /api/v1/locations
export async function GET(req: Request) {
try {
const ctx = await authenticateRequest(req, "locations:read");
return NextResponse.json(await listLocations(ctx));
} 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,14 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { listPlantTypes } from "@/lib/services/plant-types";
// GET /api/v1/plant-types?q=lily
export async function GET(req: Request) {
try {
const ctx = await authenticateRequest(req, "plant-types:read");
const q = new URL(req.url).searchParams.get("q") ?? undefined;
return NextResponse.json(await listPlantTypes(ctx, { q }));
} 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

@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { plantLogInput } from "@/lib/api/schemas";
import { addPlantLog } from "@/lib/services/plants";
// POST /api/v1/plants/:id/logs
export async function POST(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "plants:write");
const input = plantLogInput.parse(await req.json());
const log = await addPlantLog(ctx, params.id, input);
return NextResponse.json(log, { status: 201 });
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { getPlant } from "@/lib/services/plants";
// GET /api/v1/plants/:id
export async function GET(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "plants:read");
return NextResponse.json(await getPlant(ctx, params.id));
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { listPlants } from "@/lib/services/plants";
// GET /api/v1/plants?q=apple
export async function GET(req: Request) {
try {
const ctx = await authenticateRequest(req, "plants:read");
const q = new URL(req.url).searchParams.get("q") ?? undefined;
return NextResponse.json(await listPlants(ctx, { q }));
} catch (err) {
return handleApiError(err);
}
}

22
src/app/api/v1/route.ts Normal file
View File

@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
// GET /api/v1 — whoami + endpoint discovery. Handy for testing a token.
export async function GET(req: Request) {
try {
const ctx = await authenticateRequest(req);
return NextResponse.json({
ok: true,
via: ctx.via,
scopes: ctx.scopes,
endpoints: {
plants: "GET /api/v1/plants?q=, GET /api/v1/plants/:id, POST /api/v1/plants/:id/logs",
plantTypes: "GET /api/v1/plant-types?q=",
locations: "GET /api/v1/locations",
tasks: "GET /api/v1/tasks, POST /api/v1/tasks, POST /api/v1/tasks/:id/complete",
},
});
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { taskCompleteInput } from "@/lib/api/schemas";
import { completeTask } from "@/lib/services/tasks";
// POST /api/v1/tasks/:id/complete
export async function POST(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "tasks:write");
const input = taskCompleteInput.parse(await req.json().catch(() => ({})));
return NextResponse.json(await completeTask(ctx, params.id, input));
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { taskCreateInput } from "@/lib/api/schemas";
import { listTasks, createTask } from "@/lib/services/tasks";
// GET /api/v1/tasks
export async function GET(req: Request) {
try {
const ctx = await authenticateRequest(req, "tasks:read");
return NextResponse.json(await listTasks(ctx));
} catch (err) {
return handleApiError(err);
}
}
// POST /api/v1/tasks
export async function POST(req: Request) {
try {
const ctx = await authenticateRequest(req, "tasks:write");
const input = taskCreateInput.parse(await req.json());
return NextResponse.json(await createTask(ctx, input), { status: 201 });
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,13 @@
import { Metadata } from "next";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { ChangePasswordForm } from "@/components/account/change-password-form";
export const metadata: Metadata = { title: "Change password" };
export const dynamic = "force-dynamic";
export default async function ChangePasswordPage() {
const session = await getSession();
if (!session) redirect("/login");
return <ChangePasswordForm forced={session.user.mustChangePassword} />;
}

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,116 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { Leaf } from "lucide-react";
export function ChangePasswordForm({ forced }: { forced: boolean }) {
const router = useRouter();
const { update } = useSession();
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [confirm, setConfirm] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (next !== confirm) {
setError("The new passwords don't match.");
return;
}
setBusy(true);
try {
const res = await fetch("/api/account/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword: current, newPassword: next }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
setError(json.error ?? "Couldn't change your password.");
setBusy(false);
return;
}
// Refresh the session token so mustChangePassword clears, then continue.
await update();
router.push("/dashboard");
router.refresh();
} catch {
setError("Something went wrong. Please try again.");
setBusy(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center px-4 bg-background">
<div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-6 text-center">
<div className="h-14 w-14 rounded-full bg-[hsl(var(--canopy))] flex items-center justify-center mb-3">
<Leaf className="h-7 w-7 text-[hsl(var(--canopy-foreground))]" />
</div>
<h1 className="font-display text-3xl text-foreground tracking-tight">
Choose a password
</h1>
<p className="text-xs text-muted-foreground mt-1">
{forced
? "Before you continue, please set your own password."
: "Update your Moon Base password."}
</p>
</div>
<Card>
<CardContent className="pt-6">
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="current">Current password</Label>
<Input
id="current"
type="password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
autoFocus
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="next">New password</Label>
<Input
id="next"
type="password"
value={next}
onChange={(e) => setNext(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm">Confirm new password</Label>
<Input
id="confirm"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password"
required
minLength={8}
/>
</div>
{error && <p className="text-sm text-[hsl(var(--ember))]">{error}</p>}
<Button type="submit" className="w-full" disabled={busy}>
{busy ? "Saving…" : "Save password"}
</Button>
</form>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,80 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { ClipboardList } from "lucide-react";
import { ANIMAL_LOG_TYPES, ANIMAL_LOG_LABELS } from "@/lib/animals/animal-fields";
export function AddAnimalLogButton({ animalId }: { animalId: string }) {
const [open, setOpen] = useState(false);
const [type, setType] = useState("NOTE");
const [notes, setNotes] = useState("");
const [weight, setWeight] = useState("");
const [busy, setBusy] = useState(false);
const router = useRouter();
const { toast } = useToast();
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
const res = await fetch(`/api/v1/animals/${animalId}/logs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type, notes: notes.trim() || undefined, weight: weight ? Number(weight) : null }),
});
setBusy(false);
if (!res.ok) { toast({ title: "Error", description: "Could not save log.", variant: "destructive" }); return; }
toast({ title: "Logged!" });
setNotes(""); setWeight(""); setOpen(false);
router.refresh();
}
return (
<>
<Button size="sm" onClick={() => setOpen(true)}>
<ClipboardList className="h-4 w-4 mr-1" /> Log
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader><DialogTitle>Log for this animal</DialogTitle></DialogHeader>
<form onSubmit={submit} className="space-y-4 pt-1">
<div className="space-y-1.5">
<Label>Type</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{ANIMAL_LOG_TYPES.map((t) => <SelectItem key={t} value={t}>{ANIMAL_LOG_LABELS[t]}</SelectItem>)}
</SelectContent>
</Select>
</div>
{type === "WEIGHT" && (
<div className="space-y-1.5">
<Label>Weight (lbs)</Label>
<Input type="number" min="0" step="0.01" value={weight} onChange={(e) => setWeight(e.target.value)} autoFocus />
</div>
)}
<div className="space-y-1.5">
<Label>Notes</Label>
<Textarea rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="What happened?" />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Save log"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,168 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { Plus, Pencil } from "lucide-react";
import { COMMON_SPECIES } from "@/lib/animals/animal-fields";
export type LocationOpt = { id: string; name: string };
export type AnimalFields = {
id?: string;
name: string;
species: string;
breed: string;
sex: string;
locationId: string;
birthdate: string;
acquiredAt: string;
source: string;
notes: string;
};
export function blankAnimal(): AnimalFields {
return { name: "", species: "", breed: "", sex: "", locationId: "", birthdate: "", acquiredAt: "", source: "", notes: "" };
}
const NONE = "__none__";
function AnimalDialog({ trigger, title, initial, locations }: {
trigger: React.ReactNode; title: string; initial: AnimalFields; locations: LocationOpt[];
}) {
const [open, setOpen] = useState(false);
const [f, setF] = useState<AnimalFields>(initial);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const router = useRouter();
const { toast } = useToast();
function set<K extends keyof AnimalFields>(k: K, v: AnimalFields[K]) {
setF((p) => ({ ...p, [k]: v }));
}
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!f.name.trim()) { setError("Please give the animal a name."); return; }
setError("");
setBusy(true);
const payload = {
name: f.name.trim(),
species: f.species.trim() || undefined,
breed: f.breed.trim() || undefined,
sex: f.sex.trim() || undefined,
locationId: f.locationId || null,
birthdate: f.birthdate || null,
acquiredAt: f.acquiredAt || null,
source: f.source.trim() || undefined,
notes: f.notes.trim() || undefined,
};
const res = await fetch(f.id ? `/api/v1/animals/${f.id}` : "/api/v1/animals", {
method: f.id ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
setBusy(false);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast({ title: "Error", description: err.error ?? "Could not save.", variant: "destructive" });
return;
}
toast({ title: f.id ? "Saved!" : "Added!" });
setOpen(false);
router.refresh();
}
return (
<>
<span onClick={() => { setF(initial); setOpen(true); }}>{trigger}</span>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader><DialogTitle>{title}</DialogTitle></DialogHeader>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1.5">
<Label>Name *</Label>
<Input value={f.name} onChange={(e) => set("name", e.target.value)} placeholder="Bella" />
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
<div className="space-y-1.5">
<Label>Species</Label>
<Input list="species-list" value={f.species} onChange={(e) => set("species", e.target.value)} placeholder="Dog" />
<datalist id="species-list">
{COMMON_SPECIES.map((s) => <option key={s} value={s} />)}
</datalist>
</div>
<div className="space-y-1.5">
<Label>Breed</Label>
<Input value={f.breed} onChange={(e) => set("breed", e.target.value)} placeholder="Border collie" />
</div>
<div className="space-y-1.5">
<Label>Sex</Label>
<Input value={f.sex} onChange={(e) => set("sex", e.target.value)} placeholder="Female" />
</div>
<div className="space-y-1.5">
<Label>Location</Label>
<Select value={f.locationId || NONE} onValueChange={(v) => set("locationId", v === NONE ? "" : v)}>
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
<SelectItem value={NONE}></SelectItem>
{locations.map((l) => <SelectItem key={l.id} value={l.id}>{l.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Birthdate</Label>
<Input type="date" value={f.birthdate} onChange={(e) => set("birthdate", e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Acquired</Label>
<Input type="date" value={f.acquiredAt} onChange={(e) => set("acquiredAt", e.target.value)} />
</div>
<div className="col-span-2 space-y-1.5">
<Label>Where from?</Label>
<Input value={f.source} onChange={(e) => set("source", e.target.value)} placeholder="Breeder, shelter, hatchery…" />
</div>
<div className="col-span-2 space-y-1.5">
<Label>Notes</Label>
<Textarea rows={2} value={f.notes} onChange={(e) => set("notes", e.target.value)} />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" disabled={busy}>{busy ? "Saving…" : "Save"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}
export function AddAnimalButton({ locations }: { locations: LocationOpt[] }) {
return (
<AnimalDialog title="Add an animal" initial={blankAnimal()} locations={locations}
trigger={<Button size="sm"><Plus className="h-4 w-4 mr-1" />Add animal</Button>} />
);
}
export function EditAnimalButton({ animal, locations }: { animal: AnimalFields; locations: LocationOpt[] }) {
return (
<AnimalDialog title="Edit animal" initial={animal} locations={locations}
trigger={<Button variant="outline" size="sm"><Pencil className="h-4 w-4 mr-1" />Edit</Button>} />
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import { Trash2 } from "lucide-react";
export function DeleteAnimalButton({ id, name }: { id: string; name: string }) {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const router = useRouter();
const { toast } = useToast();
async function handleDelete() {
setBusy(true);
const res = await fetch(`/api/v1/animals/${id}`, { method: "DELETE" });
setBusy(false);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
toast({ title: "Error", description: err.error ?? "Could not remove.", variant: "destructive" });
return;
}
toast({ title: "Removed", description: `"${name}" removed.` });
setOpen(false);
router.push("/animals");
router.refresh();
}
return (
<>
<Button variant="outline" size="sm" className="text-[hsl(var(--ember))]" onClick={() => setOpen(true)}>
<Trash2 className="h-4 w-4 mr-1" /> Remove
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Remove &ldquo;{name}&rdquo;?</DialogTitle>
<DialogDescription>The animal is hidden but its full history is kept.</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleDelete} disabled={busy}
className="bg-[hsl(var(--ember))] hover:bg-[hsl(var(--ember)/.85)]">
{busy ? "Removing…" : "Remove"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

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"> <>
<Label htmlFor="quantity">How much did you harvest?</Label> <div className="space-y-1.5">
<Input id="quantity" placeholder="2 lbs, 1 gallon, a big handful…" {...form.register("quantity")} /> <Label>How much did you harvest?</Label>
</div> <div className="flex gap-2">
<Input
type="number"
step="any"
min="0"
placeholder="2.5"
className="w-24"
{...form.register("amount")}
/>
<Input
list="harvest-units"
placeholder="lbs, each, quarts…"
className="flex-1"
{...form.register("unit")}
/>
<datalist id="harvest-units">
{UNIT_SUGGESTIONS.map((u) => <option key={u} value={u} />)}
</datalist>
</div>
</div>
<div className="space-y-2 rounded-md border p-3">
<label className="flex items-center gap-2 text-sm font-medium cursor-pointer">
<input
type="checkbox"
className="h-4 w-4 accent-[hsl(var(--leaf))]"
checked={toPantry}
onChange={(e) => setToPantry(e.target.checked)}
/>
Add to Pantry &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

@@ -20,6 +20,7 @@ import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
import { searchPlants, type PlantSuggestion } from "@/lib/garden/plant-db"; import { searchPlants, type PlantSuggestion } from "@/lib/garden/plant-db";
type Group = { id: string; name: string }; type Group = { id: string; name: string };
type LocationOption = { id: string; name: string };
const BLANK = { const BLANK = {
commonName: "", commonName: "",
@@ -33,12 +34,19 @@ const BLANK = {
newGroupName: "", newGroupName: "",
}; };
export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[]; zones?: string[] }) { export function AddPlantButton({
groups = [],
locations = [],
}: {
groups?: Group[];
locations?: LocationOption[];
}) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [fields, setFields] = useState(BLANK); const [fields, setFields] = useState(BLANK);
const [selectedZones, setSelectedZones] = useState<string[]>([]); const [locationOptions, setLocationOptions] = useState<LocationOption[]>(locations);
const [newZoneName, setNewZoneName] = useState(""); const [selectedLocationIds, setSelectedLocationIds] = useState<string[]>([]);
const [addingZone, setAddingZone] = useState(false); const [newLocationName, setNewLocationName] = useState("");
const [addingLocation, setAddingLocation] = useState(false);
const [creatingGroup, setCreatingGroup] = useState(false); const [creatingGroup, setCreatingGroup] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -47,25 +55,46 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
// All zones including newly typed ones not yet in the DB
const allZones = Array.from(new Set([...zones, ...selectedZones]));
function set(key: keyof typeof BLANK, value: string) { function set(key: keyof typeof BLANK, value: string) {
setFields((f) => ({ ...f, [key]: value })); setFields((f) => ({ ...f, [key]: value }));
} }
function toggleZone(zone: string) { function nameFor(id: string) {
setSelectedZones((prev) => return locationOptions.find((l) => l.id === id)?.name ?? "Location";
prev.includes(zone) ? prev.filter((z) => z !== zone) : [...prev, zone] }
function toggleLocation(id: string) {
setSelectedLocationIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
); );
} }
function addNewZone() { // Persist new locations to the shared spine immediately so the plant gets a
const name = newZoneName.trim(); // real locationId (the freeform "zone" string is dual-written server-side).
async function addNewLocation() {
const name = newLocationName.trim();
if (!name) return; if (!name) return;
if (!selectedZones.includes(name)) setSelectedZones((prev) => [...prev, name]); const existing = locationOptions.find((l) => l.name.toLowerCase() === name.toLowerCase());
setNewZoneName(""); if (existing) {
setAddingZone(false); if (!selectedLocationIds.includes(existing.id)) toggleLocation(existing.id);
setNewLocationName("");
setAddingLocation(false);
return;
}
const res = await fetch("/api/locations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.ok) {
toast({ title: "Error", description: "Could not add location.", variant: "destructive" });
return;
}
const loc = await res.json();
setLocationOptions((prev) => [...prev, { id: loc.id, name: loc.name }]);
setSelectedLocationIds((prev) => [...prev, loc.id]);
setNewLocationName("");
setAddingLocation(false);
} }
function onNameChange(value: string) { function onNameChange(value: string) {
@@ -90,9 +119,9 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
function handleClose() { function handleClose() {
setOpen(false); setOpen(false);
setFields(BLANK); setFields(BLANK);
setSelectedZones([]); setSelectedLocationIds([]);
setNewZoneName(""); setNewLocationName("");
setAddingZone(false); setAddingLocation(false);
setError(""); setError("");
setSuggestions([]); setSuggestions([]);
setShowSuggestions(false); setShowSuggestions(false);
@@ -130,14 +159,14 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
groupId, groupId,
}; };
// Create one plant per selected zone (or one with no zone if none selected). // Create one plant per selected location (or one with no location if none).
const zonesToCreate = selectedZones.length > 0 ? selectedZones : [undefined]; const locsToCreate = selectedLocationIds.length > 0 ? selectedLocationIds : [undefined];
const results = await Promise.all( const results = await Promise.all(
zonesToCreate.map((zone) => locsToCreate.map((locationId) =>
fetch("/api/plants", { fetch("/api/plants", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...base, zone }), body: JSON.stringify({ ...base, locationId }),
}) })
) )
); );
@@ -149,7 +178,7 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
return; return;
} }
const count = zonesToCreate.length; const count = locsToCreate.length;
toast({ toast({
title: "Plant added!", title: "Plant added!",
description: count > 1 description: count > 1
@@ -254,23 +283,23 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
/> />
</div> </div>
{/* Multi-location picker */} {/* Multi-location picker (shared Location spine) */}
<div className="col-span-2 space-y-1.5"> <div className="col-span-2 space-y-1.5">
<Label> <Label>
Location(s) on your property Location(s) on your property
<span className="text-muted-foreground font-normal ml-1">(pick as many as you like)</span> <span className="text-muted-foreground font-normal ml-1">(pick as many as you like)</span>
</Label> </Label>
{/* Selected zones as chips */} {/* Selected locations as chips */}
{selectedZones.length > 0 && ( {selectedLocationIds.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2"> <div className="flex flex-wrap gap-1.5 mb-2">
{selectedZones.map((z) => ( {selectedLocationIds.map((id) => (
<Badge key={z} variant="secondary" className="gap-1 pr-1"> <Badge key={id} variant="secondary" className="gap-1 pr-1">
<MapPin className="h-3 w-3" /> <MapPin className="h-3 w-3" />
{z} {nameFor(id)}
<button <button
type="button" type="button"
onClick={() => toggleZone(z)} onClick={() => toggleLocation(id)}
className="ml-0.5 hover:text-destructive transition-colors" className="ml-0.5 hover:text-destructive transition-colors"
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
@@ -280,45 +309,45 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
</div> </div>
)} )}
{/* Existing zones as checkboxes */} {/* Existing locations as checkboxes */}
{allZones.length > 0 && ( {locationOptions.length > 0 && (
<div className="border rounded-md divide-y max-h-36 overflow-y-auto"> <div className="border rounded-md divide-y max-h-36 overflow-y-auto">
{allZones.map((z) => ( {locationOptions.map((loc) => (
<label <label
key={z} key={loc.id}
className="flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-accent transition-colors text-sm" className="flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-accent transition-colors text-sm"
> >
<input <input
type="checkbox" type="checkbox"
checked={selectedZones.includes(z)} checked={selectedLocationIds.includes(loc.id)}
onChange={() => toggleZone(z)} onChange={() => toggleLocation(loc.id)}
className="accent-[hsl(var(--leaf))]" className="accent-[hsl(var(--leaf))]"
/> />
{z} {loc.name}
</label> </label>
))} ))}
</div> </div>
)} )}
{/* Add new zone */} {/* Add new location */}
{addingZone ? ( {addingLocation ? (
<div className="flex gap-2 mt-1"> <div className="flex gap-2 mt-1">
<Input <Input
placeholder="e.g. Front bed, Back fence guild…" placeholder="e.g. Front bed, Back fence guild…"
value={newZoneName} value={newLocationName}
onChange={(e) => setNewZoneName(e.target.value)} onChange={(e) => setNewLocationName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addNewZone(); } }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addNewLocation(); } }}
autoFocus autoFocus
/> />
<Button type="button" size="sm" onClick={addNewZone}>Add</Button> <Button type="button" size="sm" onClick={addNewLocation}>Add</Button>
<Button type="button" variant="outline" size="sm" onClick={() => { setAddingZone(false); setNewZoneName(""); }}> <Button type="button" variant="outline" size="sm" onClick={() => { setAddingLocation(false); setNewLocationName(""); }}>
Cancel Cancel
</Button> </Button>
</div> </div>
) : ( ) : (
<button <button
type="button" type="button"
onClick={() => setAddingZone(true)} onClick={() => setAddingLocation(true)}
className="text-sm text-[hsl(var(--leaf))] hover:underline mt-1" className="text-sm text-[hsl(var(--leaf))] hover:underline mt-1"
> >
+ Add new location + Add new location
@@ -398,7 +427,7 @@ export function AddPlantButton({ groups = [], zones = [] }: { groups?: Group[];
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>Cancel</Button> <Button type="button" variant="outline" onClick={handleClose}>Cancel</Button>
<Button type="submit" disabled={busy}> <Button type="submit" disabled={busy}>
{busy ? "Saving…" : selectedZones.length > 1 ? `Add to ${selectedZones.length} locations` : "Add plant"} {busy ? "Saving…" : selectedLocationIds.length > 1 ? `Add to ${selectedLocationIds.length} locations` : "Add plant"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>

View File

@@ -0,0 +1,58 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import { Trash2 } from "lucide-react";
export function DeletePlantTypeButton({ id, name }: { id: string; name: string }) {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const router = useRouter();
const { toast } = useToast();
async function handleDelete() {
setBusy(true);
const res = await fetch(`/api/plant-types/${id}`, { method: "DELETE" });
setBusy(false);
if (!res.ok) {
toast({ title: "Error", description: "Could not delete.", variant: "destructive" });
return;
}
toast({ title: "Deleted", description: `"${name}" removed from your plant types.` });
setOpen(false);
router.push("/garden/types");
router.refresh();
}
return (
<>
<Button variant="outline" size="sm" className="text-[hsl(var(--ember))]" onClick={() => setOpen(true)}>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Delete &ldquo;{name}&rdquo;?</DialogTitle>
<DialogDescription>
This removes the plant-type reference. Your individual plantings stay put
they just won&apos;t be linked to a type anymore.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleDelete} disabled={busy}
className="bg-[hsl(var(--ember))] hover:bg-[hsl(var(--ember)/.85)]">
{busy ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -20,12 +20,14 @@ import { useToast } from "@/hooks/use-toast";
import { Pencil } from "lucide-react"; import { Pencil } from "lucide-react";
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories"; import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
type LocationOption = { id: string; name: string };
const schema = z.object({ const schema = z.object({
commonName: z.string().min(1, "Name is required"), commonName: z.string().min(1, "Name is required"),
species: z.string().optional(), species: z.string().optional(),
variety: z.string().optional(), variety: z.string().optional(),
category: z.nativeEnum(PlantCategory), category: z.nativeEnum(PlantCategory),
zone: z.string().optional(), locationId: z.string().optional(),
plantedAt: z.string().optional(), plantedAt: z.string().optional(),
source: z.string().optional(), source: z.string().optional(),
notes: z.string().optional(), notes: z.string().optional(),
@@ -37,10 +39,19 @@ function toDateInput(d: Date | null): string {
return new Date(d).toISOString().split("T")[0]; return new Date(d).toISOString().split("T")[0];
} }
export function EditPlantButton({ plant, zones = [], groups = [] }: { plant: Plant; zones?: string[]; groups?: { id: string; name: string }[] }) { export function EditPlantButton({
plant,
locations = [],
groups = [],
}: {
plant: Plant;
locations?: LocationOption[];
groups?: { id: string; name: string }[];
}) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [creatingZone, setCreatingZone] = useState(false); const [locationOptions, setLocationOptions] = useState<LocationOption[]>(locations);
const [newZoneName, setNewZoneName] = useState(""); const [creatingLocation, setCreatingLocation] = useState(false);
const [newLocationName, setNewLocationName] = useState("");
const router = useRouter(); const router = useRouter();
const { toast } = useToast(); const { toast } = useToast();
@@ -51,19 +62,44 @@ export function EditPlantButton({ plant, zones = [], groups = [] }: { plant: Pla
species: plant.species ?? "", species: plant.species ?? "",
variety: plant.variety ?? "", variety: plant.variety ?? "",
category: plant.category, category: plant.category,
zone: plant.zone ?? "", locationId: plant.locationId ?? "",
plantedAt: toDateInput(plant.plantedAt), plantedAt: toDateInput(plant.plantedAt),
source: plant.source ?? "", source: plant.source ?? "",
notes: plant.notes ?? "", notes: plant.notes ?? "",
}, },
}); });
async function addNewLocation() {
const name = newLocationName.trim();
if (!name) return;
const existing = locationOptions.find((l) => l.name.toLowerCase() === name.toLowerCase());
if (existing) {
form.setValue("locationId", existing.id);
setNewLocationName("");
setCreatingLocation(false);
return;
}
const res = await fetch("/api/locations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.ok) {
toast({ title: "Error", description: "Could not add location.", variant: "destructive" });
return;
}
const loc = await res.json();
setLocationOptions((prev) => [...prev, { id: loc.id, name: loc.name }]);
form.setValue("locationId", loc.id);
setNewLocationName("");
setCreatingLocation(false);
}
async function onSubmit(values: FormValues) { async function onSubmit(values: FormValues) {
if (newZoneName.trim()) values.zone = newZoneName.trim();
const res = await fetch(`/api/plants/${plant.id}`, { const res = await fetch(`/api/plants/${plant.id}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(values), body: JSON.stringify({ ...values, locationId: values.locationId || null }),
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
@@ -127,12 +163,12 @@ export function EditPlantButton({ plant, zones = [], groups = [] }: { plant: Pla
<div className="col-span-2 space-y-1.5"> <div className="col-span-2 space-y-1.5">
<Label>Location on your property</Label> <Label>Location on your property</Label>
{!creatingZone ? ( {!creatingLocation ? (
<Select <Select
defaultValue={plant.zone ?? ""} defaultValue={plant.locationId ?? ""}
onValueChange={(v) => { onValueChange={(v) => {
if (v === "__new__") { setCreatingZone(true); form.setValue("zone", ""); } if (v === "__new__") { setCreatingLocation(true); form.setValue("locationId", ""); }
else { form.setValue("zone", v); setNewZoneName(""); } else { form.setValue("locationId", v); setNewLocationName(""); }
}} }}
> >
<SelectTrigger> <SelectTrigger>
@@ -140,8 +176,8 @@ export function EditPlantButton({ plant, zones = [], groups = [] }: { plant: Pla
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="">No location</SelectItem> <SelectItem value="">No location</SelectItem>
{zones.map((z) => ( {locationOptions.map((loc) => (
<SelectItem key={z} value={z}>{z}</SelectItem> <SelectItem key={loc.id} value={loc.id}>{loc.name}</SelectItem>
))} ))}
<SelectItem value="__new__">+ Add new location</SelectItem> <SelectItem value="__new__">+ Add new location</SelectItem>
</SelectContent> </SelectContent>
@@ -150,11 +186,13 @@ export function EditPlantButton({ plant, zones = [], groups = [] }: { plant: Pla
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
placeholder="e.g. Front bed, Back fence guild, Raised bed #1" placeholder="e.g. Front bed, Back fence guild, Raised bed #1"
value={newZoneName} value={newLocationName}
onChange={(e) => setNewZoneName(e.target.value)} onChange={(e) => setNewLocationName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addNewLocation(); } }}
autoFocus autoFocus
/> />
<Button type="button" variant="outline" size="sm" onClick={() => { setCreatingZone(false); setNewZoneName(""); }}> <Button type="button" size="sm" onClick={addNewLocation}>Add</Button>
<Button type="button" variant="outline" size="sm" onClick={() => { setCreatingLocation(false); setNewLocationName(""); }}>
Cancel Cancel
</Button> </Button>
</div> </div>

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

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