Compare commits

...
56 Commits
Author SHA1 Message Date
ginnoir a12208d2fe chore: release v0.5.3
Release / build-and-push (push) Has been cancelled
2026-06-03 20:18:21 -05:00
ginnoir 14175d3faa fix: use next_public_app_url for share link base url instead of localhost fallback 2026-06-03 20:16:59 -05:00
ginnoir c3e78932fe chore: release v0.5.2
Release / build-and-push (push) Has been cancelled
2026-06-03 20:06:55 -05:00
ginnoir a332935ba7 fix: render dashboard greeting and date client-side to respect local timezone 2026-06-03 20:06:25 -05:00
ginnoir 51c8d3c92d chore: release v0.5.1
Release / build-and-push (push) Has been cancelled
2026-06-03 19:55:57 -05:00
ginnoir 62c3d33350 fix: dashboard switcher, platform-aware search shortcut, mobile search, static topbar
- Always show dashboard tab strip + add button (was gated behind length > 1)
- Search shortcut now shows Ctrl+K on Windows/Linux, ⌘K on Mac
- Add icon-only search button in topbar for mobile viewports
- Lock html/body overflow and use 100dvh to prevent topbar scrolling off-screen on mobile
2026-06-03 19:55:00 -05:00
ginnoir 2408d37f8e chore: release v0.5.0
Release / build-and-push (push) Has been cancelled
2026-06-03 19:20:37 -05:00
ginnoir f47c1c3a23 fix: replace settings mobile tabs with 2-col grid and remove fab 2026-06-03 19:17:17 -05:00
ginnoir 6e92648a41 feat: mobile experience overhaul — touch targets, calendar mobile layout, swipe gestures 2026-06-03 18:50:15 -05:00
ginnoir 6802ae08db fix: db:seed script points to seed.mjs not nonexistent seed.ts 2026-06-03 16:19:29 -05:00
ginnoir b935ff209c chore: release v0.4.10
Release / build-and-push (push) Has been cancelled
2026-06-03 16:16:01 -05:00
ginnoir 3fb5ddc5dd chore: upgrade node 22 to 24 lts 2026-06-03 16:15:00 -05:00
ginnoir ca536b5c33 chore: release v0.4.9
Release / build-and-push (push) Has been cancelled
2026-06-03 16:00:38 -05:00
ginnoir ec2f3930ad feat: proper logo, favicon, and pwa icons
- replace placeholder house svg with clean geometric house mark
  on indigo-700 (#4338CA) — roof triangle, body, door cutout
- add favicon.svg with prefers-color-scheme dark variant
- add icon-16/32 sizes; regenerate all pngs from svg via sharp
- update BrandMark component to use inline svg house instead of 'f'
- wire favicon + sized pngs into layout metadata
- fix middleware to allow icon/manifest/favicon paths without auth
  (android launcher fetches icons in a cookieless system context)
- fix getCurrentSession() to redirect to /login instead of throwing
  when session cookie is stale/invalid
2026-06-03 15:59:34 -05:00
ginnoir 10cad3df17 chore: release v0.4.8
Release / build-and-push (push) Has been cancelled
2026-06-02 20:26:57 -05:00
ginnoir 77db935329 chore: register 0018_container_images in drizzle migration journal 2026-06-02 20:19:57 -05:00
ginnoir c6a34f7471 feat: share links visible on plants/containers; container image gallery
- Fix ShareButton silently swallowing errors from createShareLink; now
  shows inline error text so failures are visible to the user
- Add getShareLinksForEntity server action and EntityShareLink type to
  _core/share.ts
- Add ShareLinkList component — renders active share links per entity
  with per-row Revoke; renders nothing when empty
- Wire ShareLinkList into plant and container detail pages (loaded
  server-side in parallel with the entity fetch)
- Add images jsonb column to garden_containers schema + migration 0018
- Add addContainerImage / removeContainerImage / setContainerPrimaryImage
  server actions mirroring the plant image pattern (10-image cap, first
  upload auto-sets cover)
- Update ContainerDetailDto, listContainers, getContainer to include images
- Rewrite ContainerDetail with Info/Gallery tabs; Gallery tab mirrors
  plant gallery (3-col grid, star/X overlays, upload button, counter)
- Update ContainerShareData and container renderSharedView to show cover
  image hero and secondary image grid on public share pages
2026-06-02 20:15:29 -05:00
ginnoir 076e35aced docs: add contributing guide, env reference, and operations runbook 2026-06-02 19:53:18 -05:00
ginnoir 8bf00c326d docs: update decisions index and task index for garden module and adr backfill
Populates the decisions index with the two existing ADRs (list SSE/notify,
release workflow). Adds task 09 to the phase 1 list and adds the phase 8
garden module task entries to the task index.
2026-06-02 19:52:55 -05:00
ginnoir 6a1ec8bb7b docs: add professional readme with full feature coverage and release pipeline hook
Replaces the 8-line placeholder with a complete README covering all four
modules, platform capabilities, quick start, dev setup, configuration
reference, and deployment links. Badges use dynamic shields.io URLs so
version/CI status update automatically on each release.

Wires README review into the release pipeline via a release-it
before:git:release hook and a new item in the pre-deploy checklist.
2026-06-02 19:52:24 -05:00
ginnoir fe4673f1cd chore: replace personal domains and ips with generic placeholders 2026-06-02 19:33:49 -05:00
ginnoir 4ec1f34150 chore: gitignore maintainer deploy scripts, scrub personal domain from dev.mjs 2026-06-02 19:30:27 -05:00
ginnoir 7938425850 chore: gitignore maintainer-specific production deploy files
deploy/compose.yaml, deploy/Caddyfile, and deploy/Caddyfile.dev.snippet
contain homelab-specific config and are not useful as public templates.
Public-facing equivalents: compose.example.yaml and Caddyfile.snippet.
2026-06-02 19:22:31 -05:00
ginnoir 13d94b3751 chore(deploy): separate maintainer config from public deployment templates
- rename Caddyfile.snippet → Caddyfile (maintainer's full production file)
- restore Caddyfile.snippet as a minimal public reference (famapp blocks only)
- add compose.example.yaml: standalone famapp + authentik template for new
  deployers; compose.yaml remains the maintainer's homelab monolith
- update deploy/README.md: file table, setup steps referencing example files
- apply-compose.ps1: point Caddy push at deploy/Caddyfile (not snippet)
2026-06-02 19:19:27 -05:00
ginnoir aae3e30843 fix(deploy): separate reactive resume auth_secret from famapp
- rename AUTH_SECRET to RESUME_AUTH_SECRET in production .env and compose
  app service — famapp's AUTH_SECRET is now unambiguous
- remove orphaned stale AUTH_SECRET entry from .env
- apply-compose.ps1: pull + restart famapp immediately after pushing compose
  rather than waiting for watchtower; uses dc alias via bash -ic
- update .env.production.example to reflect corrected variable names
2026-06-02 19:12:34 -05:00
ginnoir 23a14e1650 chore: replace placeholder deploy config with actual production files
- deploy/compose.yaml: replace generated placeholder with actual monolith
  compose (Foundry, Caddy, media stack, famapp, Authentik, Watchtower, etc.)
- deploy/Caddyfile.snippet: replace with full production Caddyfile
- .env.production.example: update variable names to match production
  (famapp_MINIO_ROOT_USER/PASSWORD/BUCKET, CF_API_TOKEN, etc.)
- scripts/apply-compose.ps1: add -Compose/-Caddy flags; push Caddyfile
  and reload caddy in addition to compose restart
2026-06-02 19:03:53 -05:00
ginnoir cce3bac5b2 chore: repo cleanup and prod sync tooling
- Remove stale deploy/docker-entrypoint.sh (Dockerfile copies root version)
- Remove scripts/seed.ts (superseded by seed.mjs)
- Add src/app/error.tsx and global-error.tsx (untracked error boundaries)
- Gitignore deploy/.prod/ for synced production configs
- Add scripts/sync-prod.ps1 to pull compose/env/Caddyfile from valhalla
- Add scripts/apply-compose.ps1 to push compose changes and restart services
2026-06-02 18:51:59 -05:00
ginnoir 31c5805b34 chore: release v0.4.7 2026-06-02 18:30:48 -05:00
ginnoir 989bae8f1e feat(garden): add share button to plant and container detail views 2026-06-02 18:30:28 -05:00
ginnoir 090366422d chore: release v0.4.6
Release / build-and-push (push) Has been cancelled
2026-06-02 18:16:26 -05:00
ginnoir 4049a73c6b fix(garden): accept relative urls for plant image uploads
Zod v4 changed .url() to use the WHATWG URL spec, which rejects relative
paths like /api/uploads/... that our upload API returns. Relax image URL
validators to z.string().min(1) since these are internal paths.
2026-06-02 18:15:12 -05:00
ginnoir 272d3cbbe6 chore: release v0.4.5
Release / build-and-push (push) Has been cancelled
2026-06-02 04:27:39 -05:00
ginnoir b5a966e3a9 feat(bangs): add bang counter dashboard widget
Adds a new "bangs" module with a dashboard widget that tracks household
bang events. The widget shows the total count, a configurable recent-bang
list, and an "Add Bang" button that accepts a backdatable date.

On submission, a full celebration sequence fires: screen flash, widget
shake, count pop animation, synthesized firework sound (Web Audio API),
50 full-screen emoji particles via portal, canvas-confetti star bursts,
side cannons, and a timed sequence of 7 firework bursts.

- New bang_events table (migration 0017)
- canvas-confetti dependency for fireworks/confetti effects
- CSS keyframe animations: shake, countPop, emojiFloat
- Fixes pre-existing Drizzle snapshot chain collision (0007/0008)
- Adds OpenPlantBook env vars to compose.yaml
2026-06-02 04:26:22 -05:00
ginnoir 48063e4b7f chore: release v0.4.4
Release / build-and-push (push) Has been cancelled
2026-06-02 03:48:52 -05:00
ginnoir 6eab68560e fix(garden): remove trailing slashes from openplantbook api urls and update label
Trailing slashes on /plant/search/ and /plant/detail/ caused Django to
return an HTML redirect page instead of JSON. SDK uses no trailing slashes.

Also updates the helper text to reference OpenPlantBook instead of Perenual.
2026-06-02 03:48:38 -05:00
ginnoir 7d0f02996f chore: release v0.4.3
Release / build-and-push (push) Has been cancelled
2026-06-02 03:25:03 -05:00
ginnoir 32e97b0677 fix(garden): replace perenual with openplantbook for species lookup
Perenual's free tier does not serve care instructions. OpenPlantBook
provides watering, sunlight, and other care data at no cost.

- OAuth2 client_credentials token fetch with module-level cache
- Search via /api/v1/plant/search/, detail via /api/v1/plant/detail/{pid}/?include=care
- SpeciesSuggestion shape unchanged; no frontend or schema changes required
- Env vars: OPENPLANTBOOK_CLIENT_ID + OPENPLANTBOOK_CLIENT_SECRET (replaces PERENUAL_API_KEY)
2026-06-02 03:24:20 -05:00
ginnoir e0b423aad5 chore: remove dead code and unused dependencies
- Delete src/placeholder.ts (empty stub with no references)
- Remove zod-to-json-schema dep (not imported anywhere in src/)
- Remove @eslint/eslintrc devDep (project uses flat config with @eslint/js)
- Remove @types/react-grid-layout devDep (react-grid-layout v2 ships own types)
2026-06-02 02:37:56 -05:00
ginnoir 389ff6040b ci: cache next.js build output and parallelize checks with build 2026-06-02 02:05:34 -05:00
ginnoir 65d36bab4e fix: drive bottom nav from module registry so all modules appear on mobile
Release / build-and-push (push) Has been cancelled
The bottom nav was hardcoded; the sidebar already read from the module
registry. Converted BottomNav to a server component that calls
getRegistry() using the same pattern as Sidebar, so any module with a
nav field automatically populates both nav surfaces.

Bumps version to 0.4.2.
2026-06-02 01:55:36 -05:00
ginnoir 298e6b868d chore: register migration 0016 in drizzle journal
Release / build-and-push (push) Has been cancelled
2026-06-02 00:27:10 -05:00
ginnoir 6d45b7e836 chore: bump version to 0.4.0
Release / build-and-push (push) Has been cancelled
2026-06-02 00:00:03 -05:00
ginnoir 7f2c5a44dd feat: garden care reminder bodies, task sync, upload cleanup
- care reminders now fire with plant-specific body ("Time to water Pothos")
  via title/body columns on the reminders table (migration 0016)
- bi-directional task sync: checking off a garden-linked task list item
  creates a care log; logging care from garden marks the linked task done
  (list_items.metadata stores gardenPlantId + gardenCareType linkage)
- upload error response no longer leaks debug detail; error message
  corrected from "5 MB" to "100 MB"
2026-06-01 23:58:00 -05:00
ginnoir dd980c6932 fix: garden ui polish - form styles, back nav, species dropdown, upload limit 2026-06-01 23:38:52 -05:00
ginnoir 6b25d67537 feat: garden dashboard widgets and manifest completion (task 75)
- getCareDueWidgetRows query: schedule rows due within N days, filtered
  by optional container list, ordered by next_due_at
- getGardenOverviewStats query: plant/container/overdue counts + next care
- CareDueWidget: compact list sorted by urgency, inline log-care button,
  truncates at 10 rows with View all link
- GardenOverviewWidget: stat row (plants, containers, overdue) + next care
  line + garden link
- plant-widget.tsx server component: InlineLogButton uses form action to
  call logCare without client JS or navigation
- manifest: garden.care-due and garden.overview widgets registered;
  resolveConfigOptions returns container list for care-due config picker
- quickAdds: icons updated (leaf/box/droplets), log-care URL ->
  /garden?logCare=1
2026-06-01 22:43:53 -05:00
ginnoir 080d26816e test: add reproducer for garden dashboard widgets (task 75) 2026-06-01 22:41:25 -05:00
ginnoir 0b0deff700 feat: garden calendar + lists integrations (task 74)
- calendar-bridge.ts and lists-bridge.ts isolate cross-module deps
- scheduleOnCalendar action: creates event from care schedule with 30-min
  slot, title convention (Water/Fertilize/Repot/Prune/custom), reminder opt
- pushOverdueToTaskList action: inserts one task per overdue (plant+schedule)
  pair into the default task list, skips duplicate text entries
- CareScheduleEditor: per-row calendar 📅 button opens inline form with
  calendar select + optional reminder minutes + success link to /calendar
- Garden page header: PushOverdueButton with auto-dismissing feedback toast
2026-06-01 20:33:15 -05:00
ginnoir eabcebdb00 test: add reproducer for garden integrations (task 74) 2026-06-01 20:30:18 -05:00
ginnoir 0fef3d4ab6 feat: garden care tracking - logs, schedules, reminders (task 73)
- logCare/deleteCareLog actions with schedule update and reminder wiring
- upsertCareSchedule/deleteCareSchedule/toggleCareSchedule actions
- updateScheduleAfterCare helper cancels old reminder, schedules new one
- getCareLogs/getCareSchedules/getOverduePlants/getCareDueSoon queries
- CareLogForm, CareScheduleEditor, CareHistoryList components
- Plant detail Care tab wired up with schedule editor + log form + history
- Log plant care quick-add added to garden manifest
2026-06-01 20:23:43 -05:00
ginnoir fe282ba470 test: add reproducer for garden care tracking (task 73) 2026-06-01 20:23:30 -05:00
ginnoir f3e38c576c feat: garden plants crud (task 72)
- perenual species lookup with 24h db cache (api key optional)
- /api/garden/species-search route handler
- createPlant / updatePlant / deletePlant server actions
- addPlantImage / removePlantImage / setPrimaryImage actions (max 10)
- listPlants query grouped by container with last-watered subquery
- getPlant query with care log summary and active schedules
- plant-form client component with species autofill and image upload
- plant-list server component grouped by container with unassigned bucket
- plant-detail client component with info/gallery/care tabs
- species-search debounced combobox component
- /garden/plants/new, /[id], /[id]/edit pages
- garden.plant entity registered with search and share support
- /garden page updated to containers/plants tabs (default: containers)
2026-06-01 20:13:20 -05:00
ginnoir a86f5471ce test: add reproducer for garden plants crud (task 72) 2026-06-01 20:04:22 -05:00
ginnoir 5f6b756342 feat: garden containers crud (task 71)
- add listContainers/getContainer/searchContainers queries
- add createContainer/updateContainer/deleteContainer server actions with logActivity
- add ContainerList, ContainerDetail, ContainerForm client components
- add /garden, /garden/containers/[id], /garden/containers/new pages
- register garden.container entity with share + search in manifest
- add sprout icon to NavIcon registry
- add garden e2e test spec
2026-06-01 19:28:34 -05:00
ginnoir 2fd0677c5f feat: add garden module infrastructure (task 70)
- Add MinIO service to compose.yaml with named volume
- Add generic /api/uploads POST+GET route with auth, 5 MB, image/* guards
- Add src/lib/minio.ts singleton client with ensureBucket helper
- Add garden Drizzle schema: containers, plants, care_logs, care_schedules, species_cache
- Register garden module in src/modules/index.ts and src/lib/db.ts
- Apply migration 0015_garden_schema.sql
- Add MINIO_* and PERENUAL_API_KEY to .env.example
- Add task briefs 70-75 for the full garden feature arc
- Add uploads.spec.ts E2E test (RED → GREEN on route auth guard)
2026-06-01 19:23:19 -05:00
ginnoir 5dcd49d4c9 fix: remove duplicate changelog header and drop release-it header config 2026-06-01 18:47:47 -05:00
ginnoir 29795e0d51 chore: format pre-existing files missed by lint-staged 2026-06-01 18:46:53 -05:00
120 changed files with 9641 additions and 714 deletions
+12 -2
View File
@@ -1,5 +1,5 @@
# Public app URL (used for OIDC redirect URIs, share links, etc.)
NEXT_PUBLIC_APP_URL=https://fam.ginnoir.com
NEXT_PUBLIC_APP_URL=https://fam.yourdomain.com
# Postgres
DATABASE_URL=postgres://famapp:famapp@localhost:5432/famapp
@@ -15,7 +15,7 @@ DEV_LOGIN_NAME=Dev User
DEV_HOUSEHOLD_NAME=Home
# OIDC (Authentik)
AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/
AUTH_OIDC_ISSUER=https://auth.yourdomain.com/application/o/famapp/
AUTH_OIDC_CLIENT_ID=replace-me
AUTH_OIDC_CLIENT_SECRET=replace-me
@@ -33,3 +33,13 @@ LOG_LEVEL=info
# GitHub (required for pnpm release — creates a GitHub Release)
GITHUB_TOKEN=
# MinIO object storage (used for plant/container image uploads)
MINIO_ENDPOINT=http://localhost:9000
MINIO_ROOT_USER=famapp
MINIO_ROOT_PASSWORD=changeme
MINIO_BUCKET=garden
# OpenPlantBook plant species API (https://open.plantbook.io — free account required)
OPENPLANTBOOK_CLIENT_ID=
OPENPLANTBOOK_CLIENT_SECRET=
+23 -17
View File
@@ -1,51 +1,57 @@
# ── famapp ────────────────────────────────────────────────────────────────────
# These vars are consumed by the famapp service in docker-compose.yml.
# Image tag to deploy. Pin to a specific version after first deploy
# (e.g. ghcr.io/ginnoir/famapp:v0.1.0). `latest` is fine for staging/initial.
FAMAPP_IMAGE=ghcr.io/ginnoir/famapp:latest
# `always` pulls on every `up`; set `missing` if you want to skip pulls.
FAMAPP_PULL_POLICY=always
# Authentik image tag. Bump in lockstep with Authentik release notes.
AUTHENTIK_IMAGE_TAG=2024.12.3
# Run drizzle migrations on container start. Leave true.
RUN_MIGRATIONS=true
# Public URL for the app — used by Auth.js for OIDC redirect URIs.
AUTH_URL=https://fam.ginnoir.com
# Public URL for the app
AUTH_URL=https://fam.yourdomain.com
# famapp Postgres credentials (used to build DATABASE_URL inside compose.yaml)
# famapp Postgres
FAMAPP_DB_USER=famapp
FAMAPP_DB_PASSWORD=replace-with-strong-password
FAMAPP_DB_NAME=famapp
# Auth.js session secret — generate with: openssl rand -base64 32
# NOTE: Reactive Resume uses RESUME_AUTH_SECRET; AUTH_SECRET is famapp-only.
AUTH_SECRET=replace-with-openssl-rand-base64-32
# OIDC provider (Authentik) — fill in after bootstrapping Authentik
AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/
AUTH_OIDC_ISSUER=https://auth.yourdomain.com/application/o/famapp/
AUTH_OIDC_CLIENT_ID=replace-me
AUTH_OIDC_CLIENT_SECRET=replace-me
# Web Push VAPID keys — generate with: pnpm vapid:generate (run from the repo)
# Web Push VAPID keys — generate with: pnpm vapid:generate
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
# Must be "mailto:<address>" or a URL
VAPID_SUBJECT=mailto:3nigma.matt@gmail.com
VAPID_SUBJECT=mailto:your-email@example.com
# ntfy push fallback — uses your existing ntfy instance at ntfy.ginnoir.com
# Leave NTFY_URL blank to disable ntfy fanout.
NTFY_URL=https://ntfy.ginnoir.com
# ntfy push fallback
NTFY_URL=https://ntfy.yourdomain.com
NTFY_TOPIC=famapp
# Log level: error | warn | info | debug
LOG_LEVEL=info
# ── Authentik ─────────────────────────────────────────────────────────────────
# MinIO object storage (garden image uploads)
famapp_MINIO_ROOT_USER=famapp
famapp_MINIO_ROOT_PASSWORD=replace-with-strong-password
famapp_MINIO_BUCKET=garden
# Authentik Postgres credentials (separate DB per Matt's rule)
# OpenPlantBook API (optional — used for plant species lookup)
famapp_OPENPLANTBOOK_CLIENT_ID=
famapp_OPENPLANTBOOK_CLIENT_SECRET=
# ── Authentik ─────────────────────────────────────────────────────────────────
AUTHENTIK_DB_USER=authentik
AUTHENTIK_DB_PASSWORD=replace-with-strong-password
AUTHENTIK_DB_NAME=authentik
# Authentik secret key — generate with: openssl rand -base64 60
AUTHENTIK_SECRET_KEY=replace-with-openssl-rand-base64-60
# ── Cloudflare (Caddy DNS-01 TLS) ─────────────────────────────────────────────
CF_API_TOKEN=replace-with-cloudflare-api-token
+26 -4
View File
@@ -6,7 +6,7 @@ on:
pull_request:
jobs:
verify:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -17,7 +17,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
@@ -28,10 +28,32 @@ jobs:
- run: pnpm format:check
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.33.3
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- uses: actions/cache@v4
with:
path: .next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx', 'src/**/*.css') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-
${{ runner.os }}-nextjs-
- run: pnpm build
env:
# Build only — no DB connection. Provide a placeholder so any
# module-level reads of DATABASE_URL don't crash the build.
DATABASE_URL: postgres://placeholder:placeholder@localhost:5432/placeholder
AUTH_SECRET: ci-placeholder-secret
NEXT_PUBLIC_APP_URL: http://localhost:3000
+8
View File
@@ -49,5 +49,13 @@ tests/.auth/
# Backups
deploy/backups/data/
# Production configs and deploy scripts — maintainer-specific, never commit
deploy/.prod/
deploy/compose.yaml
deploy/Caddyfile
deploy/Caddyfile.dev.snippet
scripts/apply-compose.ps1
scripts/sync-prod.ps1
# Design handoff bundle (reference only, not committed)
.design-tmp/
+1 -1
View File
@@ -1 +1 @@
22
24
+4 -2
View File
@@ -11,6 +11,9 @@
"release": true,
"releaseName": "v${version}"
},
"hooks": {
"before:git:release": "echo 'Check: README.md reflects current modules and env vars before tagging'"
},
"plugins": {
"@release-it/conventional-changelog": {
"preset": {
@@ -26,8 +29,7 @@
{ "type": "test", "section": "Tests", "hidden": true }
]
},
"infile": "CHANGELOG.md",
"header": "# Changelog\n\nAll notable changes to famapp are documented here.\n"
"infile": "CHANGELOG.md"
}
}
}
+2 -2
View File
@@ -31,7 +31,7 @@ Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`,
| Realtime | Postgres `LISTEN/NOTIFY` → SSE | Simpler than WebSockets, fine for two users |
| Reverse proxy | Caddy (existing) | `fam.ginnoir.com` → famapp:3000, `auth.ginnoir.com` → authentik |
| Package manager | pnpm | Fast, content-addressed store, strict deps |
| Runtime | Node 22 LTS | In `node:22-alpine` container |
| Runtime | Node 24 LTS | In `node:24-alpine` container |
| Host | Ubuntu Server 24.04 x64, Docker + Compose | Existing self-host stack |
Domain: `fam.ginnoir.com`. SSO: `auth.ginnoir.com`.
@@ -106,7 +106,7 @@ All household-scoped entity tables include `household_id`. Sub-entities (`calend
```yaml
# compose.yaml (sketch — final lives in /deploy)
services:
famapp: # node:22-alpine, Next.js
famapp: # node:24-alpine, Next.js
famapp-db: # postgres:16
authentik-server:
authentik-worker:
+72 -6
View File
@@ -1,5 +1,77 @@
# Changelog
## [0.5.3](https://github.com/ginnoir/famapp/compare/v0.5.2...v0.5.3) (2026-06-04)
### Bug Fixes
- use next_public_app_url for share link base url instead of localhost fallback ([14175d3](https://github.com/ginnoir/famapp/commit/14175d3faa146ebff7c825848837c75047a8eb71))
## [0.5.2](https://github.com/ginnoir/famapp/compare/v0.5.1...v0.5.2) (2026-06-03)
### Bug Fixes
- **dashboard:** render greeting and date client-side so local timezone is used instead of server UTC
## [0.5.1](https://github.com/ginnoir/famapp/compare/v0.5.0...v0.5.1) (2026-06-03)
### Bug Fixes
- **dashboard:** always show tab strip and add-dashboard button regardless of dashboard count
- **search:** show Ctrl+K on Windows/Linux instead of ⌘K
- **mobile:** add search icon button in topbar for mobile viewports
- **mobile:** lock html/body overflow and use 100dvh to prevent topbar scrolling off-screen
## [0.5.0](https://github.com/ginnoir/famapp/compare/v0.4.10...v0.5.0) (2026-06-03)
### Features
- **mobile:** full mobile experience overhaul — bottom nav, touch targets, calendar mobile layout with day/week/agenda views and calendars bottom sheet, bidirectional swipe gestures on list items (right to complete, left to delete), iOS safe-area and keyboard support, input zoom prevention
- **mobile:** settings navigation replaced with 2-column icon grid (no horizontal scroll)
- **mobile:** remove floating action button in favour of persistent topbar quick-add button
## [0.4.10](https://github.com/ginnoir/famapp/compare/v0.4.9...v0.4.10) (2026-06-03)
## [0.4.8](https://github.com/ginnoir/famapp/compare/v0.4.6...v0.4.8) (2026-06-03)
### Features
- **garden:** add share button to plant and container detail views ([989bae8](https://github.com/ginnoir/famapp/commit/989bae8f1e2d5577da3e8777b6a35320c6da274c))
- share links visible on plants/containers; container image gallery ([c6a34f7](https://github.com/ginnoir/famapp/commit/c6a34f74717505e39ac4a88077728efa9fc50478))
### Bug Fixes
- **deploy:** separate reactive resume auth_secret from famapp ([aae3e30](https://github.com/ginnoir/famapp/commit/aae3e30843f54406244b637073aaf5e9ccc6b1d3))
### Documentation
- add contributing guide, env reference, and operations runbook ([076e35a](https://github.com/ginnoir/famapp/commit/076e35aced9303973da71ddd0ebded13f6c98833))
- add professional readme with full feature coverage and release pipeline hook ([6a1ec8b](https://github.com/ginnoir/famapp/commit/6a1ec8bb7be7ce64e82e5a4cee6b9f4ef92c7af8))
- update decisions index and task index for garden module and adr backfill ([8bf00c3](https://github.com/ginnoir/famapp/commit/8bf00c326db591851bdf8eee2fcedbfbed53d076))
## [0.4.6](https://github.com/ginnoir/famapp/compare/v0.4.5...v0.4.6) (2026-06-02)
### Bug Fixes
- **garden:** accept relative urls for plant image uploads ([6ca2e52](https://github.com/ginnoir/famapp/commit/6ca2e52e0a63d94826d9b1e208bcdded9c75fb07))
## [0.4.5](https://github.com/ginnoir/famapp/compare/v0.4.4...v0.4.5) (2026-06-02)
### Features
- **bangs:** add bang counter dashboard widget ([c850521](https://github.com/ginnoir/famapp/commit/c850521f3e993a5410f37cf29eb635cbc9540fd1))
## [0.4.4](https://github.com/ginnoir/famapp/compare/v0.4.3...v0.4.4) (2026-06-02)
### Bug Fixes
- **garden:** remove trailing slashes from openplantbook api urls and update label ([c744921](https://github.com/ginnoir/famapp/commit/c744921e6cfcf0269992cfd9afea47cc55082602))
## [0.4.3](https://github.com/ginnoir/famapp/compare/v0.4.2...v0.4.3) (2026-06-02)
### Bug Fixes
- **garden:** replace perenual with openplantbook for species lookup ([8722c20](https://github.com/ginnoir/famapp/commit/8722c205e6257a0590b48d0a858ad71d56ab07ac))
All notable changes to famapp are documented here.
## [0.3.0](https://github.com/ginnoir/famapp/compare/v0.2.0...v0.3.0) (2026-06-01)
@@ -18,12 +90,6 @@ All notable changes to famapp are documented here.
- add github_token to .env.example ([5630537](https://github.com/ginnoir/famapp/commit/563053727908186a9816416fdf54c914002011de))
# Changelog
All notable changes to famapp are documented here.
<!-- release-it prepends new entries above this line -->
## Pre-release history
Changes from before the release workflow was established (2026-06-01):
+2 -2
View File
@@ -31,7 +31,7 @@ Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`,
| Realtime | Postgres `LISTEN/NOTIFY` → SSE | Simpler than WebSockets, fine for two users |
| Reverse proxy | Caddy (existing) | `fam.ginnoir.com` → famapp:3000, `auth.ginnoir.com` → authentik |
| Package manager | pnpm | Fast, content-addressed store, strict deps |
| Runtime | Node 22 LTS | In `node:22-alpine` container |
| Runtime | Node 24 LTS | In `node:24-alpine` container |
| Host | Ubuntu Server 24.04 x64, Docker + Compose | Existing self-host stack |
Domain: `fam.ginnoir.com`. SSO: `auth.ginnoir.com`.
@@ -106,7 +106,7 @@ All household-scoped entity tables include `household_id`. Sub-entities (`calend
```yaml
# compose.yaml (sketch — final lives in /deploy)
services:
famapp: # node:22-alpine, Next.js
famapp: # node:24-alpine, Next.js
famapp-db: # postgres:16
authentik-server:
authentik-worker:
+4 -3
View File
@@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# ── Stage 1: fetch dependencies ───────────────────────────────────────────────
FROM node:22-alpine AS deps
FROM node:24-alpine AS deps
RUN corepack enable && corepack prepare pnpm@10.33.3 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml .npmrc ./
@@ -16,10 +16,11 @@ ENV CI=true
ENV NEXT_TELEMETRY_DISABLED=1
COPY . .
RUN pnpm install --offline --frozen-lockfile
RUN pnpm build
RUN --mount=type=cache,id=famapp-nextjs,target=/app/.next/cache \
pnpm build
# ── Stage 3: production runtime ───────────────────────────────────────────────
FROM node:22-alpine AS runner
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
+155 -5
View File
@@ -1,8 +1,158 @@
# famapp
Self-hosted family coordination: shared calendar, lists, notes.
Self-hosted family coordination web app. Shared calendar, lists, notes, and garden tracker — installable as a PWA, protected by OIDC single sign-on.
- **Plan & architecture:** [`CLAUDE.md`](CLAUDE.md)
- **Current progress:** [`STATUS.md`](STATUS.md)
- **Task briefs (for sub-sessions):** [`docs/tasks/`](docs/tasks/)
- **Architecture decisions:** [`docs/decisions/`](docs/decisions/)
[![CI](https://github.com/ginnoir/famapp/actions/workflows/ci.yml/badge.svg)](https://github.com/ginnoir/famapp/actions/workflows/ci.yml)
[![Latest release](https://img.shields.io/github/v/release/ginnoir/famapp?sort=semver)](https://github.com/ginnoir/famapp/releases)
[![Docker image](https://img.shields.io/badge/image-ghcr.io%2Fginnoir%2Ffamapp-blue?logo=docker)](https://github.com/ginnoir/famapp/pkgs/container/famapp)
[![Node ≥ 22](https://img.shields.io/badge/node-%E2%89%A522-brightgreen?logo=node.js)](https://nodejs.org)
---
## Features
### Modules
| Module | What it does |
| ------------ | ------------------------------------------------------------------------------------------ |
| **Calendar** | Month / week / day views, drag-to-create, household and private calendars, event reminders |
| **Lists** | Shopping and task lists with real-time sync, reorder, keyboard-first item entry |
| **Notes** | Markdown notes with pinning, safe preview, and optional remind-at scheduling |
| **Garden** | Plant and container tracking, care schedules, care logs, species lookup via OpenPlantBook |
### Platform capabilities
| Capability | Detail |
| ---------------------- | ------------------------------------------------------------------------------------------- |
| **Dashboard** | Per-user named dashboards, drag-resize widget grid, configurable widget instances |
| **Quick-add** | `Cmd/Ctrl+K` command palette and `+` FAB — every module registers its own actions |
| **Share links** | Any entity produces a temporary public link (`/s/<token>`), scoped read or read-write |
| **Activity log** | Household-scoped feed of every create / update / delete across all modules |
| **Reminders** | Generic reminder engine ticked every 30 s; notes and calendar events both use it |
| **Push notifications** | VAPID web push + in-app inbox + optional ntfy fallback |
| **Themes** | Two palettes (`default` / `warm`) × light / dark / system, zero flash on load |
| **PWA / offline** | Installable on iOS and Android, offline shell, stale-while-revalidate caching |
| **Structured logging** | JSON logs via pino in production, pretty-printed in dev, `LOG_LEVEL` configurable |
| **Nightly backups** | `pg_dump` cron for both databases, 14-day daily / 8-week weekly / 6-month monthly retention |
---
## Requirements
- **Docker** with the Compose plugin (v2)
- **Caddy** (or any reverse proxy that handles HTTPS)
- **Authentik** — famapp delegates all authentication to an Authentik OIDC provider; see the [bootstrap guide](deploy/authentik/README.md)
- A domain with DNS pointing to your host
---
## Quick start
Full details are in [`deploy/README.md`](deploy/README.md). The short version:
```bash
# 1. Clone and enter the repo
git clone https://github.com/ginnoir/famapp.git /srv/famapp
cd /srv/famapp
# 2. Create your environment file
cp .env.production.example deploy/.env
# Edit deploy/.env — see Configuration below for required values
# 3. Configure your reverse proxy
# Add deploy/Caddyfile.snippet to your Caddyfile, then reload Caddy
# 4. Bootstrap Authentik and record the OIDC client id/secret in deploy/.env
# See deploy/authentik/README.md
# 5. Start the stack
docker compose -f deploy/compose.example.yaml up -d
```
The container runs database migrations automatically on start. The first user to sign in becomes the household owner; a second sign-in joins the same household as a member.
Pin `FAMAPP_IMAGE` in `deploy/.env` after the first deploy:
```
FAMAPP_IMAGE=ghcr.io/ginnoir/famapp:v0.4.7
```
---
## Development setup
Requires Node ≥ 22 and pnpm 10.
```bash
git clone https://github.com/ginnoir/famapp.git
cd famapp
pnpm install
# Copy the example env and fill in dev values (set ENABLE_DEV_LOGIN=true)
cp .env.example .env
# Start the local database, run migrations, seed, and launch the dev server
pnpm dev:local
```
Then open `http://localhost:3000/login` and click **Dev login**.
Additional dev commands:
| Command | Purpose |
| --------------------- | ---------------------------------------------- |
| `pnpm dev:reset` | Drop and reseed the local database |
| `pnpm db:studio` | Open Drizzle Studio against the local database |
| `pnpm db:generate` | Generate a new migration after schema changes |
| `pnpm typecheck` | Run `tsc --noEmit` |
| `pnpm lint` | ESLint |
| `pnpm test:e2e` | Playwright E2E suite |
| `pnpm vapid:generate` | Print VAPID key env vars to stdout |
| `pnpm gen:icons` | Regenerate PWA icons from `public/icon.svg` |
See [`docs/dev-login.md`](docs/dev-login.md) for push notification and E2E testing setup.
---
## Configuration
Copy `.env.production.example` to `deploy/.env` and fill in values. Key variables:
| Variable | Required | How to generate |
| -------------------------------- | -------- | -------------------------------------------------------------------- |
| `AUTH_URL` | Yes | Public HTTPS URL for the app (e.g. `https://fam.yourdomain.com`) |
| `AUTH_SECRET` | Yes | `openssl rand -base64 32` |
| `AUTH_OIDC_ISSUER` | Yes | From Authentik — `https://auth.yourdomain.com/application/o/famapp/` |
| `AUTH_OIDC_CLIENT_ID` | Yes | From Authentik application |
| `AUTH_OIDC_CLIENT_SECRET` | Yes | From Authentik application |
| `FAMAPP_DB_PASSWORD` | Yes | Strong random password |
| `AUTHENTIK_DB_PASSWORD` | Yes | Strong random password |
| `AUTHENTIK_SECRET_KEY` | Yes | `openssl rand -base64 60` |
| `VAPID_PUBLIC_KEY` | Push | `pnpm vapid:generate` |
| `VAPID_PRIVATE_KEY` | Push | `pnpm vapid:generate` |
| `VAPID_SUBJECT` | Push | `mailto:your-email@example.com` |
| `NTFY_URL` / `NTFY_TOPIC` | Optional | ntfy push fallback |
| `famapp_OPENPLANTBOOK_CLIENT_ID` | Optional | OpenPlantBook API — plant species lookup |
| `LOG_LEVEL` | Optional | `error` / `warn` / `info` / `debug` (default: `info`) |
---
## Architecture
famapp uses an extensibility-first module system: every feature lives under `src/modules/<name>/` and declares its tables, routes, dashboard widgets, quick-add actions, and share/reminder/search behaviors via a manifest. Core services (sharing, push, reminders, activity log, search) operate generically — adding a new module does not require touching core code.
See [`CLAUDE.md`](CLAUDE.md) for the full architecture brief and [`docs/decisions/`](docs/decisions/) for ADRs.
---
## Deployment
| Resource | Link |
| -------------------- | -------------------------------------------------------------------------------- |
| Full host setup | [`deploy/README.md`](deploy/README.md) |
| Authentik bootstrap | [`deploy/authentik/README.md`](deploy/authentik/README.md) |
| Backup / restore | [`deploy/backups/README.md`](deploy/backups/README.md) |
| Pre-deploy checklist | [`docs/tasks/09-pre-deploy-checklist.md`](docs/tasks/09-pre-deploy-checklist.md) |
| Changelog | [`CHANGELOG.md`](CHANGELOG.md) |
Cutting a release: tag `vX.Y.Z` on `main` and push — CI builds and pushes `ghcr.io/ginnoir/famapp:vX.Y.Z` automatically.
+7 -6
View File
@@ -1,10 +1,11 @@
# famapp — paste into your existing Caddyfile.
# famapp runs on 3010, authentik-server on 9200 (both bound to the host by deploy/compose.yaml).
# Add these blocks to your existing Caddyfile.
# Assumes famapp and authentik-server are reachable by Caddy
# (e.g. on a shared Docker network).
fam.ginnoir.com {
reverse_proxy 192.168.1.69:3010
fam.yourdomain.com {
reverse_proxy famapp:3000
}
auth.ginnoir.com {
reverse_proxy 192.168.1.69:9200
auth.yourdomain.com {
reverse_proxy authentik-server:9000
}
+19 -5
View File
@@ -2,16 +2,30 @@
Trunk-based: `main` is always green. Production deploys only from version tags (`vX.Y.Z`). The dev-login flow is retained for local development behind a double gate (`NODE_ENV !== "production"` **and** `ENABLE_DEV_LOGIN=true`); a startup assertion in `src/lib/dev-login-config.ts` makes a misconfigured prod fail loud instead of silently exposing it.
## Files in this directory
| File | Purpose |
| ----------------------- | -------------------------------------------------------------------------- |
| `compose.example.yaml` | **Start here.** Standalone famapp + Authentik stack for new deployments. |
| `compose.yaml` | Maintainer's production compose (full homelab monolith — not a template). |
| `Caddyfile.snippet` | Reverse proxy blocks to add to your Caddyfile. |
| `Caddyfile.dev.snippet` | Dev machine proxy block (maintainer-specific). |
| `Caddyfile` | Maintainer's full production Caddyfile (not a template). |
| `authentik/README.md` | Authentik bootstrap guide. |
| `backups/` | Backup container scripts (used by `famapp-backup` in the example compose). |
## One-time host setup
1. Install Docker + Compose plugin on the host.
2. `git clone` this repo to e.g. `/srv/famapp`.
3. Copy `.env.production.example``/srv/famapp/deploy/.env` and fill in real values.
3. Copy `deploy/compose.example.yaml``/srv/famapp/deploy/compose.yaml`.
4. Copy `.env.production.example``/srv/famapp/deploy/.env` and fill in real values.
- `openssl rand -base64 32` for `AUTH_SECRET`.
- `openssl rand -base64 60` for `AUTHENTIK_SECRET_KEY`.
- `pnpm vapid:generate` (locally) for the three VAPID lines.
4. Bootstrap Authentik per `deploy/authentik/README.md`. Save the OIDC client id/secret into `.env`.
5. Wire Caddy with `deploy/Caddyfile.snippet`.
- `openssl rand -hex 64` for the MinIO passwords.
- `pnpm vapid:generate` (locally, from the repo) for the three VAPID lines.
5. Bootstrap Authentik per `deploy/authentik/README.md`. Save the OIDC client id/secret into `.env`.
6. Wire Caddy (or any reverse proxy) using `deploy/Caddyfile.snippet`.
## Cutting a release
@@ -28,7 +42,7 @@ git push origin v0.1.0
```bash
cd /srv/famapp/deploy
# pin to the tag you just cut
echo 'FAMAPP_IMAGE=ghcr.io/ginnoir/famapp:v0.1.0' >> .env # or edit in place
sed -i 's|FAMAPP_IMAGE=.*|FAMAPP_IMAGE=ghcr.io/ginnoir/famapp:v0.1.0|' .env
docker compose pull famapp
docker compose up -d famapp
docker compose logs -f famapp # watch migrations + boot
+7 -7
View File
@@ -6,13 +6,13 @@ Run these steps once after the first `docker compose up -d` in the `deploy/` dir
## 1. Set the admin password
Visit `https://auth.ginnoir.com/if/flow/initial-setup/` and set the **akadmin** password.
Visit `https://auth.yourdomain.com/if/flow/initial-setup/` and set the **akadmin** password.
---
## 2. Create the OIDC provider
1. Log in to the Authentik Admin UI at `https://auth.ginnoir.com/if/admin/`.
1. Log in to the Authentik Admin UI at `https://auth.yourdomain.com/if/admin/`.
2. Go to **Applications → Providers → Create**.
3. Choose **OAuth2/OpenID Provider**.
4. Configure:
@@ -21,7 +21,7 @@ Visit `https://auth.ginnoir.com/if/flow/initial-setup/` and set the **akadmin**
- **Client type:** `Confidential`
- **Client ID:** (auto-generated — copy this)
- **Client Secret:** (auto-generated — copy this)
- **Redirect URIs:** `https://fam.ginnoir.com/api/auth/callback/authentik`
- **Redirect URIs:** `https://fam.yourdomain.com/api/auth/callback/authentik`
- **Signing Key:** `authentik Self-signed Certificate`
- **Token validity:** 24 hours (or your preference)
5. Save and note the **Issuer URL** shown on the provider detail page.
@@ -29,13 +29,13 @@ Visit `https://auth.ginnoir.com/if/flow/initial-setup/` and set the **akadmin**
The issuer URL will look like:
```
https://auth.ginnoir.com/application/o/famapp/
https://auth.yourdomain.com/application/o/famapp/
```
Set this (and the client ID/secret) in famapp's `.env` / production secrets:
```env
AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/
AUTH_OIDC_ISSUER=https://auth.yourdomain.com/application/o/famapp/
AUTH_OIDC_CLIENT_ID=<client-id>
AUTH_OIDC_CLIENT_SECRET=<client-secret>
```
@@ -49,7 +49,7 @@ AUTH_OIDC_CLIENT_SECRET=<client-secret>
- **Name:** `famapp`
- **Slug:** `famapp`
- **Provider:** select the `famapp` provider created above
- **Launch URL:** `https://fam.ginnoir.com`
- **Launch URL:** `https://fam.yourdomain.com`
3. Save.
---
@@ -68,7 +68,7 @@ AUTH_OIDC_CLIENT_SECRET=<client-secret>
Each user can enroll a passkey from their Authentik profile:
1. Sign in as the user at `https://auth.ginnoir.com`.
1. Sign in as the user at `https://auth.yourdomain.com`.
2. Go to **Settings → MFA Devices → Add → WebAuthn Device**.
3. Follow the browser prompt to register a Touch ID / Face ID / hardware key.
+196
View File
@@ -0,0 +1,196 @@
# Standalone famapp deployment template.
# Copy this to compose.yaml (or reference with -f), fill in .env from
# .env.production.example, then: docker compose up -d
#
# Caddy (or any reverse proxy) should sit in front — see Caddyfile.snippet.
# If Caddy runs in a separate compose stack, add famapp to that stack's
# external network instead of exposing ports directly.
name: famapp
networks:
famapp_net:
volumes:
famapp_db_data:
authentik_db_data:
authentik_redis_data:
garden_uploads:
backups:
services:
famapp:
image: ${FAMAPP_IMAGE:-ghcr.io/ginnoir/famapp:latest}
pull_policy: ${FAMAPP_PULL_POLICY:-always}
restart: unless-stopped
environment:
NODE_ENV: production
AUTH_URL: ${AUTH_URL}
DATABASE_URL: postgres://${FAMAPP_DB_USER}:${FAMAPP_DB_PASSWORD}@famapp-db:5432/${FAMAPP_DB_NAME}
AUTH_SECRET: ${AUTH_SECRET}
AUTH_OIDC_ISSUER: ${AUTH_OIDC_ISSUER}
AUTH_OIDC_CLIENT_ID: ${AUTH_OIDC_CLIENT_ID}
AUTH_OIDC_CLIENT_SECRET: ${AUTH_OIDC_CLIENT_SECRET}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
VAPID_SUBJECT: ${VAPID_SUBJECT}
NTFY_URL: ${NTFY_URL:-}
NTFY_TOPIC: ${NTFY_TOPIC:-}
LOG_LEVEL: ${LOG_LEVEL:-info}
RUN_MIGRATIONS: ${RUN_MIGRATIONS:-true}
MINIO_ENDPOINT: http://famapp-minio:9000
MINIO_ROOT_USER: ${famapp_MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${famapp_MINIO_ROOT_PASSWORD}
MINIO_BUCKET: ${famapp_MINIO_BUCKET:-garden}
OPENPLANTBOOK_CLIENT_ID: ${famapp_OPENPLANTBOOK_CLIENT_ID:-}
OPENPLANTBOOK_CLIENT_SECRET: ${famapp_OPENPLANTBOOK_CLIENT_SECRET:-}
ports:
- "${FAMAPP_PORT:-3000}:3000"
depends_on:
famapp-db:
condition: service_healthy
famapp-minio:
condition: service_healthy
networks:
- famapp_net
famapp-db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${FAMAPP_DB_USER}
POSTGRES_PASSWORD: ${FAMAPP_DB_PASSWORD}
POSTGRES_DB: ${FAMAPP_DB_NAME}
volumes:
- famapp_db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${FAMAPP_DB_USER} -d ${FAMAPP_DB_NAME}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- famapp_net
famapp-minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
restart: unless-stopped
environment:
MINIO_ROOT_USER: ${famapp_MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${famapp_MINIO_ROOT_PASSWORD}
volumes:
- garden_uploads:/data
ports:
- "9000:9000"
- "9001:9001"
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:9000/minio/health/live || exit 1"]
interval: 30s
timeout: 20s
retries: 3
start_period: 30s
networks:
- famapp_net
famapp-backup:
image: alpine:3.20
restart: unless-stopped
environment:
FAMAPP_DB_HOST: famapp-db
FAMAPP_DB_PORT: "5432"
FAMAPP_DB_USER: ${FAMAPP_DB_USER}
FAMAPP_DB_PASSWORD: ${FAMAPP_DB_PASSWORD}
FAMAPP_DB_NAME: ${FAMAPP_DB_NAME}
AUTHENTIK_DB_HOST: authentik-db
AUTHENTIK_DB_PORT: "5432"
AUTHENTIK_DB_USER: ${AUTHENTIK_DB_USER:-authentik}
AUTHENTIK_DB_PASSWORD: ${AUTHENTIK_DB_PASSWORD}
AUTHENTIK_DB_NAME: ${AUTHENTIK_DB_NAME:-authentik}
volumes:
- backups:/backups
- ./backups:/scripts:ro
depends_on:
famapp-db:
condition: service_healthy
authentik-db:
condition: service_healthy
networks:
- famapp_net
entrypoint: ["sh", "/scripts/entrypoint.sh"]
authentik-server:
image: ghcr.io/goauthentik/server:${AUTHENTIK_IMAGE_TAG:-2024.12.3}
restart: unless-stopped
command: server
environment:
AUTHENTIK_REDIS__HOST: authentik-redis
AUTHENTIK_POSTGRESQL__HOST: authentik-db
AUTHENTIK_POSTGRESQL__USER: ${AUTHENTIK_DB_USER:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASSWORD}
AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_DB_NAME:-authentik}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
ports:
- "9200:9000"
depends_on:
authentik-db:
condition: service_healthy
authentik-redis:
condition: service_healthy
networks:
- famapp_net
authentik-worker:
image: ghcr.io/goauthentik/server:${AUTHENTIK_IMAGE_TAG:-2024.12.3}
restart: unless-stopped
command: worker
environment:
AUTHENTIK_REDIS__HOST: authentik-redis
AUTHENTIK_POSTGRESQL__HOST: authentik-db
AUTHENTIK_POSTGRESQL__USER: ${AUTHENTIK_DB_USER:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASSWORD}
AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_DB_NAME:-authentik}
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
depends_on:
authentik-db:
condition: service_healthy
authentik-redis:
condition: service_healthy
networks:
- famapp_net
authentik-db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${AUTHENTIK_DB_USER:-authentik}
POSTGRES_PASSWORD: ${AUTHENTIK_DB_PASSWORD}
POSTGRES_DB: ${AUTHENTIK_DB_NAME:-authentik}
volumes:
- authentik_db_data:/var/lib/postgresql/data
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${AUTHENTIK_DB_USER:-authentik} -d ${AUTHENTIK_DB_NAME:-authentik}",
]
interval: 10s
timeout: 5s
retries: 5
networks:
- famapp_net
authentik-redis:
image: redis:7-alpine
restart: unless-stopped
command: --save 60 1 --loglevel warning
volumes:
- authentik_redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- famapp_net
-11
View File
@@ -1,11 +0,0 @@
#!/bin/sh
set -e
if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then
echo "running migrations..."
node /app/scripts/migrate.mjs
else
echo "skipping migrations (RUN_MIGRATIONS=$RUN_MIGRATIONS)"
fi
exec node /app/server.js
+14
View File
@@ -11,5 +11,19 @@ services:
volumes:
- famapp-db-data:/var/lib/postgresql/data
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
restart: unless-stopped
environment:
MINIO_ROOT_USER: famapp
MINIO_ROOT_PASSWORD: changeme
ports:
- "9000:9000"
- "9001:9001"
volumes:
- famapp-minio-data:/data
volumes:
famapp-db-data:
famapp-minio-data:
+158
View File
@@ -0,0 +1,158 @@
# Contributing
## Prerequisites
- **Node.js** 24 LTS (`node --version` should print `v24.x.x`)
- **pnpm** 10 (`pnpm --version` should print `10.x.x`)
- **Docker** with the Compose plugin (used for the local Postgres + MinIO containers)
## Setup
```bash
git clone https://github.com/ginnoir/famapp.git
cd famapp
pnpm install
cp .env.example .env
# Edit .env — minimum required for local dev:
# NEXT_PUBLIC_APP_URL=http://127.0.0.1:3000
# DATABASE_URL=postgres://famapp:famapp@localhost:5432/famapp
# ENABLE_DEV_LOGIN=true
# AUTH_SECRET=<any-32-char-string-for-local>
```
Start everything (database + migrations + seed + dev server):
```bash
pnpm dev:local
```
Then open `http://localhost:3000/login` and click **Dev login**. See `docs/dev-login.md` for the full local setup including HTTPS/PWA testing.
## Available scripts
<!-- AUTO-GENERATED from package.json scripts -->
| Command | Description |
| --------------------- | ----------------------------------------------------------------------------------------- |
| `pnpm dev` | Next.js dev server (localhost only) |
| `pnpm dev:network` | Dev server bound to `0.0.0.0` (LAN access for phone testing) |
| `pnpm dev:local` | Full local stack — starts DB container, runs migrations, seeds, launches dev server |
| `pnpm dev:reset` | Tear down local DB and start fresh (destructive — deletes all local data) |
| `pnpm build` | Production Next.js build |
| `pnpm start` | Start the production build locally |
| `pnpm lint` | ESLint check |
| `pnpm lint:fix` | ESLint with auto-fix |
| `pnpm format` | Prettier — format all files |
| `pnpm format:check` | Prettier — check only (used in CI) |
| `pnpm typecheck` | TypeScript type check (`tsc --noEmit`) |
| `pnpm test:e2e` | Playwright end-to-end tests |
| `pnpm db:generate` | Generate a new Drizzle migration from schema changes |
| `pnpm db:migrate` | Apply pending Drizzle migrations |
| `pnpm db:seed` | Seed the database with dev fixtures |
| `pnpm db:studio` | Open Drizzle Studio (local DB browser) |
| `pnpm gen:icons` | Regenerate PWA icon set from source |
| `pnpm vapid:generate` | Generate VAPID key pair for Web Push |
| `pnpm release` | Interactive release (prompts for semver bump, tags, publishes changelog + GitHub Release) |
| `pnpm release:patch` | Non-interactive patch release |
| `pnpm release:minor` | Non-interactive minor release |
| `pnpm release:major` | Non-interactive major release |
| `pnpm release:dry` | Dry-run release — preview without writing |
<!-- END AUTO-GENERATED -->
## Running tests
### Type check + lint (CI equivalent)
```bash
pnpm typecheck
pnpm lint
pnpm format:check
```
### End-to-end tests
The app must be running first (`pnpm dev:local`). Generate a Playwright auth state file, then run tests:
```powershell
# Generate auth state (run once after starting the app)
New-Item -ItemType Directory -Force tests\.auth | Out-Null
@'
const { chromium } = require('@playwright/test');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('http://127.0.0.1:3000/login');
await page.getByRole('button', { name: 'Dev login' }).click();
await page.waitForURL('http://127.0.0.1:3000/');
await page.context().storageState({ path: 'tests/.auth/dev-user.json' });
await browser.close();
})();
'@ | node -
# Run tests
$env:PLAYWRIGHT_STORAGE_STATE='tests/.auth/dev-user.json'
pnpm test:e2e
```
### Writing tests
- Unit tests: Vitest under `tests/unit/` — only where it pays off (utilities, pure logic).
- E2E tests: Playwright under `tests/e2e/` — one happy-path test per module. Do not write brittle selector-heavy tests for trivial CRUD.
## Code style
- **TypeScript strict** — no `any` without a written reason.
- **No comments** unless the _why_ is non-obvious. Names carry intent.
- **Immutable** — always return new objects; never mutate in place.
- **Module isolation** — a module imports from `_core` and `lib/` only; never from a sibling module.
- **File size** — 200400 lines typical, 800 hard cap.
Formatting and lint run automatically on staged files via `lint-staged` at commit time. You can also run them manually with `pnpm lint:fix` and `pnpm format`.
## Commit format
Commits must follow [Conventional Commits](https://www.conventionalcommits.org/). `commitlint` enforces this at the `commit-msg` hook.
```
<type>: <short description>
[optional body]
```
Allowed types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `revert`
Examples:
```
feat: add plant species search to garden module
fix: calendar event end time off by one day
chore: bump next to 15.6
```
## Adding a module
Every feature lives under `src/modules/<name>/`. A new module needs:
- `schema.ts` — Drizzle tables
- `server/` — server actions and queries
- `components/` — React components
- `manifest.ts` — registers the module with the core registry (nav, entity types, dashboard widget, quick-add actions)
See `CLAUDE.md` for the full architectural brief. The module loader in `src/modules/_core/` discovers manifests automatically — no changes to core code required for a new module.
## Schema changes
1. Edit the relevant `schema.ts`.
2. Run `pnpm db:generate` to create a new migration file under `drizzle/`.
3. Commit the migration alongside the schema change.
4. Never edit a shipped migration — always add a new one.
## PR checklist
- [ ] `pnpm typecheck` passes
- [ ] `pnpm lint` passes
- [ ] `pnpm build` succeeds
- [ ] New Drizzle migration committed if schema changed
- [ ] E2E test added or updated if a user-visible flow changed
- [ ] `docs/tasks/09-pre-deploy-checklist.md` reviewed if touching auth or env
+112
View File
@@ -0,0 +1,112 @@
# Environment Variables
<!-- AUTO-GENERATED from .env.example and deploy/compose.example.yaml -->
## Quick start
```bash
cp .env.example .env
# Fill in required values, then:
pnpm dev:local
```
## Core
| Variable | Required | Description | Example / Default |
| --------------------- | -------- | ------------------------------------------------------------------ | ------------------------------------------------ |
| `NEXT_PUBLIC_APP_URL` | Yes | Public URL of the app — used in OIDC redirect URIs and share links | `https://fam.yourdomain.com` |
| `DATABASE_URL` | Yes | Postgres connection string | `postgres://famapp:famapp@localhost:5432/famapp` |
| `AUTH_SECRET` | Yes | Auth.js signing secret — generate with `openssl rand -base64 32` | — |
## OIDC (Authentik)
Required in production. Obtain from the Authentik admin panel after bootstrapping the provider — see `deploy/authentik/README.md`.
| Variable | Required | Description | Example |
| ------------------------- | ---------- | --------------------------------- | --------------------------------------------------- |
| `AUTH_OIDC_ISSUER` | Yes (prod) | Authentik OIDC issuer URL | `https://auth.yourdomain.com/application/o/famapp/` |
| `AUTH_OIDC_CLIENT_ID` | Yes (prod) | OIDC client ID from Authentik | — |
| `AUTH_OIDC_CLIENT_SECRET` | Yes (prod) | OIDC client secret from Authentik | — |
## Dev login
Local development only. Never set `ENABLE_DEV_LOGIN=true` in production — a startup assertion in `src/lib/dev-login-config.ts` will crash the container if you do.
| Variable | Required | Description | Default |
| -------------------- | -------- | ------------------------------------------------- | ------------------ |
| `ENABLE_DEV_LOGIN` | No | Show a one-click **Dev login** button on `/login` | `false` |
| `DEV_LOGIN_EMAIL` | No | Email for the auto-created dev session | `dev@famapp.local` |
| `DEV_LOGIN_NAME` | No | Display name for the dev user | `Dev User` |
| `DEV_HOUSEHOLD_NAME` | No | Household created for the dev user | `Home` |
See `docs/dev-login.md` for the full local run procedure.
## Web Push (VAPID)
All three are required together. Generate them once with `pnpm vapid:generate` and copy all three lines into `.env`.
| Variable | Required | Description |
| ------------------- | -------- | --------------------------------------------- | ------------------------ |
| `VAPID_PUBLIC_KEY` | Yes | VAPID public key — also passed to the browser |
| `VAPID_PRIVATE_KEY` | Yes | VAPID private key — server only |
| `VAPID_SUBJECT` | Yes | Contact URI for push servers | `mailto:you@example.com` |
## ntfy (optional)
Leave both blank to disable the ntfy notification channel. Web Push is the primary channel.
| Variable | Required | Description | Example |
| ------------ | -------- | ------------------------ | ----------------- |
| `NTFY_URL` | No | ntfy server base URL | `https://ntfy.sh` |
| `NTFY_TOPIC` | No | ntfy topic to publish to | `famapp-alerts` |
## Logging
| Variable | Required | Description | Values |
| ----------- | -------- | ------------------ | ----------------------------------------------------------- |
| `LOG_LEVEL` | No | Pino log verbosity | `trace`, `debug`, `info`, `warn`, `error` (default: `info`) |
## MinIO object storage
Required when the garden module is enabled. The dev compose stack starts a local MinIO instance automatically via `pnpm dev:local`.
| Variable | Required | Description | Default |
| --------------------- | -------- | ------------------------------------ | ----------------------- |
| `MINIO_ENDPOINT` | Yes | MinIO server base URL | `http://localhost:9000` |
| `MINIO_ROOT_USER` | Yes | MinIO root access key | `famapp` |
| `MINIO_ROOT_PASSWORD` | Yes | MinIO root secret key | `changeme` |
| `MINIO_BUCKET` | No | Bucket used for garden image uploads | `garden` |
## OpenPlantBook
Optional. Enables plant species lookup in the garden module. Free account at <https://open.plantbook.io>.
| Variable | Required | Description |
| ----------------------------- | -------- | -------------------- |
| `OPENPLANTBOOK_CLIENT_ID` | No | OAuth2 client ID |
| `OPENPLANTBOOK_CLIENT_SECRET` | No | OAuth2 client secret |
## Release tooling
Only needed on the machine that cuts releases (`pnpm release`).
| Variable | Required | Description |
| -------------- | ------------- | ------------------------------------------------ |
| `GITHUB_TOKEN` | Yes (release) | Personal access token — creates a GitHub Release |
## Production-only compose variables
Used by `deploy/compose.example.yaml`. Set in `deploy/.env` on the server — not in the local `.env`.
| Variable | Description |
| ------------------------------------------------------------------- | ----------------------------------------------------------------- |
| `FAMAPP_IMAGE` | Docker image tag to deploy (e.g. `ghcr.io/ginnoir/famapp:v0.4.7`) |
| `FAMAPP_PORT` | Host port to bind (default: `3000`) |
| `FAMAPP_PULL_POLICY` | Docker pull policy (default: `always`) |
| `FAMAPP_DB_USER` / `FAMAPP_DB_PASSWORD` / `FAMAPP_DB_NAME` | Postgres credentials for the famapp database |
| `AUTHENTIK_DB_USER` / `AUTHENTIK_DB_PASSWORD` / `AUTHENTIK_DB_NAME` | Postgres credentials for the Authentik database |
| `AUTHENTIK_SECRET_KEY` | Authentik signing key — generate with `openssl rand -base64 60` |
| `AUTHENTIK_IMAGE_TAG` | Authentik server image tag (default: `2024.12.3`) |
| `RUN_MIGRATIONS` | Set `false` to skip auto-migration on start (default: `true`) |
<!-- END AUTO-GENERATED -->
+158
View File
@@ -0,0 +1,158 @@
# Runbook
Operational reference for famapp in production. See `deploy/README.md` for the one-time host setup and `docs/tasks/09-pre-deploy-checklist.md` for the pre-deploy checklist.
## Cutting a release
From `main` on the dev machine, with a clean working tree:
```bash
pnpm release:patch # or :minor / :major
# — bumps package.json version
# — prepends to CHANGELOG.md
# — creates and pushes a signed git tag (v0.x.y)
# — creates a GitHub Release with generated notes
# Requires GITHUB_TOKEN in .env
```
CI (`release.yml`) then builds and pushes the Docker image to GHCR:
- `ghcr.io/ginnoir/famapp:v0.x.y`
- `ghcr.io/ginnoir/famapp:0.x` (minor alias)
- `ghcr.io/ginnoir/famapp:latest`
## Deploying a release
On the home server, in `/srv/famapp/deploy/`:
```bash
# Pin the new tag
sed -i 's|FAMAPP_IMAGE=.*|FAMAPP_IMAGE=ghcr.io/ginnoir/famapp:v0.x.y|' .env
# Pull and restart only the app container
docker compose pull famapp
docker compose up -d famapp
# Watch the boot log — migrations run before the server starts
docker compose logs -f famapp
```
The container entrypoint runs `node scripts/migrate.mjs` automatically. A healthy boot ends with a log line like `ready on http://0.0.0.0:3000`.
## Health check
```bash
# Container status
docker compose ps
# App response (200 = healthy)
curl -sf https://fam.yourdomain.com/ -o /dev/null -w "%{http_code}\n"
# Recent app logs
docker compose logs --tail=100 famapp
# Database connectivity
docker compose exec famapp-db pg_isready -U famapp -d famapp
```
## Rollback
1. Find the previous working tag in `CHANGELOG.md` or `docker images`.
2. Pin it in `deploy/.env`:
```bash
sed -i 's|FAMAPP_IMAGE=.*|FAMAPP_IMAGE=ghcr.io/ginnoir/famapp:v0.x.y|' .env
```
3. Restart the container:
```bash
docker compose up -d famapp
```
If the rollback target predates a migration that has already been applied to the database, restore from backup first — see **Backups** below. To skip auto-migration on a given start (rarely needed):
```bash
RUN_MIGRATIONS=false docker compose up -d famapp
```
## Backups
The `famapp-backup` container runs nightly `pg_dump` for both `famapp` and `authentik` databases into the `backups` volume.
```bash
# Check last backup run
docker compose logs famapp-backup | tail -20
# List backup files
docker compose exec famapp-backup ls -lh /backups
# Manual backup now
docker compose exec famapp-backup sh /scripts/backup.sh
# Restore from a backup file
docker compose exec famapp-backup sh /scripts/restore.sh famapp_2026-06-01.sql.gz
```
See `deploy/backups/README.md` for retention policy and full restore details.
## Common issues
### App container exits immediately
```bash
docker compose logs famapp
```
Likely causes:
- **Missing required env var** — the app throws on startup if `AUTH_SECRET`, `DATABASE_URL`, or `AUTH_OIDC_*` are absent.
- **`ENABLE_DEV_LOGIN=true` in production** — `src/lib/dev-login-config.ts` throws an assertion error on import. Remove the variable from the production env.
- **Database not ready** — Postgres healthcheck should prevent this, but if the DB is slow to start, increase `start_period` in `compose.yaml`.
### Migrations fail on boot
```bash
docker compose logs famapp | grep -i migration
```
- Schema is ahead of code: roll back the image or forward-migrate manually.
- Database unreachable: confirm `famapp-db` is healthy (`docker compose ps`).
### OIDC login fails
1. Confirm `AUTH_OIDC_ISSUER` matches the Authentik provider URL exactly (trailing slash matters).
2. Confirm the OIDC client redirect URI in Authentik includes `https://fam.yourdomain.com/api/auth/callback/oidc`.
3. Check Authentik logs: `docker compose logs authentik-server | tail -50`.
### Push notifications not arriving
1. Confirm `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, and `VAPID_SUBJECT` are all set in the production env.
2. Verify the browser's push subscription is still valid (Settings → notifications → re-enable).
3. If using ntfy as a secondary channel, confirm `NTFY_URL` and `NTFY_TOPIC` are set.
### MinIO / garden image uploads failing
```bash
docker compose logs famapp-minio | tail -30
```
- Confirm `MINIO_ENDPOINT` is `http://famapp-minio:9000` in the compose env (not `localhost`).
- Confirm the `garden` bucket exists — create it manually via the MinIO console at port `9001` if it is missing.
### Out of disk space
```bash
df -h /var/lib/docker
docker system prune --volumes # removes stopped containers, dangling images, unused volumes
```
Old backup files accumulate in the `backups` volume. The retention script (`deploy/backups/retain.sh`) runs nightly — check its logs if the volume keeps growing.
## Authentik admin access
The Authentik admin UI is at `https://auth.yourdomain.com`. Log in with the superuser credentials set during bootstrap — see `deploy/authentik/README.md`.
To reset the Authentik admin password from the CLI:
```bash
docker compose exec authentik-server ak create_recovery_key 1 akadmin
# prints a one-time recovery URL
```
+5 -1
View File
@@ -22,4 +22,8 @@ What this costs us, what it buys us.
## Index
- (none yet — add as decisions are made)
<!-- AUTO-GENERATED -->
- [0002 — List realtime uses Postgres NOTIFY and SSE](0002-list-sse-notify.md)
- [0003 — Release workflow (commitlint + release-it)](0003-release-workflow.md)
<!-- END AUTO-GENERATED -->
+9 -8
View File
@@ -42,11 +42,11 @@ pnpm dev:local
The script prints three URLs at startup:
| URL | Use for |
|-----|---------|
| `http://localhost:3000` | Browser on this machine |
| `http://192.168.1.74:3000` | Phone on the same WiFi (general UI testing) |
| `https://dev.ginnoir.com` | Push notifications + PWA install (needs Caddy — see below) |
| URL | Use for |
| ---------------------------- | ---------------------------------------------------------- |
| `http://localhost:3000` | Browser on this machine |
| `http://192.168.x.y:3000` | Phone on the same WiFi (general UI testing) |
| `https://dev.yourdomain.com` | Push notifications + PWA install (needs Caddy — see below) |
Then open `/login` and click **Dev login**.
@@ -55,6 +55,7 @@ Then open `/login` and click **Dev login**.
`next dev` has hot module replacement — most `.ts`/`.tsx` changes apply instantly without a restart.
A full restart (`Ctrl+C``pnpm dev:local`) is needed for:
- `.env` changes
- `next.config.ts` changes
@@ -73,11 +74,11 @@ Route through the existing Caddy server on the home server instead — no extra
**Step 1 — DHCP reservation**
Set a reservation on the router so the dev machine always gets `192.168.1.74`.
Set a reservation on the router so the dev machine always gets `192.168.x.y`.
**Step 2 — DNS record**
Add a `dev.ginnoir.com` A record pointing to the same public IP as `fam.ginnoir.com`.
Add a `dev.yourdomain.com` A record pointing to the same public IP as `fam.yourdomain.com`.
**Step 3 — Windows Firewall**
@@ -96,7 +97,7 @@ Paste `deploy/Caddyfile.dev.snippet` into the home server Caddyfile and reload:
caddy reload --config /path/to/Caddyfile
```
After this, `https://dev.ginnoir.com` proxies to the dev machine with a real Let's Encrypt cert.
After this, `https://dev.yourdomain.com` proxies to the dev machine with a real Let's Encrypt cert.
## Current Local E2E Procedure
+4
View File
@@ -2,6 +2,10 @@
Run this before every production deploy (first deploy and each tagged release). Dev-login is intentionally retained behind a double gate; this checklist is what keeps that gate honest.
## README
- [ ] `README.md` reflects the current module list and any env vars added since the last release.
## Env hygiene
- [ ] Server `.env` (next to `deploy/compose.yaml`) does **not** set:
+173
View File
@@ -0,0 +1,173 @@
# 70 — Garden infrastructure (MinIO + upload route + schema)
## Goal
Lay the infrastructure foundation for the garden module: add a MinIO object-storage container to the compose stack, create a generic file-upload API route, define the Drizzle schema for all four garden tables, and run the migration.
No UI or business logic — just the plumbing that every subsequent garden task depends on.
## Depends on
- 03 (Drizzle + Postgres), 07 (household seed)
## Scope
### compose.yaml additions (`deploy/compose.yaml`)
Add a `minio` service and a `garden-uploads` named volume:
```yaml
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
volumes:
- garden-uploads:/data
ports:
- "9000:9000" # API
- "9001:9001" # Console (dev only — restrict in prod)
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 30s
timeout: 10s
retries: 3
volumes:
garden-uploads:
```
Add `MINIO_ENDPOINT`, `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`, `MINIO_BUCKET` to `.env.example`.
### MinIO client (`src/lib/minio.ts`)
Thin singleton using the `minio` npm package:
- Connect using `MINIO_ENDPOINT`, `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`.
- Export `minioClient` and `MINIO_BUCKET` constant.
- On first use, create the bucket if it does not exist (`bucketExists` + `makeBucket`).
### Upload API route (`src/app/api/uploads/route.ts`)
- `POST /api/uploads` — accepts `multipart/form-data` with a single `file` field.
- Validates: authenticated session required (401 if not); max file size 5 MB (413 if exceeded); MIME type must match `image/*` (415 if not).
- Generates a storage key: `garden/<householdId>/<randomUUID>.<ext>`.
- Streams to MinIO via `putObject`.
- Returns `{ url: "/api/uploads/<key>" }`.
- `GET /api/uploads/[...key]` — proxies the object back from MinIO using `getObject`. Sets `Cache-Control: public, max-age=31536000, immutable`.
This route is generic — not garden-specific. Other modules can reuse it.
### Schema (`src/modules/garden/schema.ts`)
Four tables, all `household_id`-scoped:
**`garden_containers`**
- `id` uuid pk
- `household_id` fk households cascade
- `name` text not null
- `type` text not null default `'other'` — free text, convention: `'shelf' | 'terrarium' | 'raised-bed' | 'window-box' | 'single-pot' | 'outdoor' | 'other'`
- `location_notes` text nullable
- `cover_image_url` text nullable
- `created_at`, `updated_at` timestamptz
Index on `household_id`.
**`garden_plants`**
- `id` uuid pk
- `household_id` fk households cascade
- `container_id` uuid nullable fk `garden_containers` set-null on delete
- `name` text not null
- `scientific_name` text nullable
- `species_id` text nullable — external Perenual species ID, stored as string
- `category` text not null default `'other'` — free text, convention: `'succulent' | 'tropical' | 'herb' | 'vegetable' | 'tree' | 'flower' | 'other'`
- `notes` text nullable
- `acquisition_date` date nullable
- `growth_stage` text nullable — convention: `'seedling' | 'juvenile' | 'mature' | 'flowering' | 'fruiting' | 'dormant'`
- `health_status` text not null default `'healthy'` — convention: `'healthy' | 'stressed' | 'sick' | 'dormant'`
- `sunlight` text nullable
- `watering_notes` text nullable
- `fertilizing_notes` text nullable
- `primary_image_url` text nullable
- `images` jsonb not null default `'[]'` — typed as `string[]`, stores upload URL paths
- `created_at`, `updated_at` timestamptz
Indexes on `(household_id)`, `(household_id, container_id)`.
**`garden_care_logs`**
- `id` uuid pk
- `plant_id` uuid fk `garden_plants` cascade
- `household_id` fk households cascade
- `care_type` text not null — convention: `'watered' | 'fertilized' | 'repotted' | 'pruned' | 'misted' | 'inspected' | 'treated' | 'propagated' | 'custom'`
- `performed_by` uuid fk users set-null on delete
- `notes` text nullable
- `performed_at` timestamptz not null default now()
- `created_at` timestamptz
Indexes on `(plant_id, performed_at desc)`, `(household_id)`.
**`garden_care_schedules`**
- `id` uuid pk
- `plant_id` uuid fk `garden_plants` cascade
- `household_id` fk households cascade
- `care_type` text not null
- `interval_days` int not null
- `last_performed_at` timestamptz nullable
- `next_due_at` timestamptz nullable — recomputed after every care log entry
- `enabled` boolean not null default true
- `created_at`, `updated_at` timestamptz
Unique index on `(plant_id, care_type)` — one schedule per plant per care type.
Index on `(household_id, next_due_at)` for dashboard queries.
### Drizzle migration
Generate and commit under `drizzle/`. Run `pnpm db:migrate` to apply.
### Module scaffold (`src/modules/garden/`)
Create the directory with stubs:
- `schema.ts` (complete, from above)
- `manifest.tsx` (minimal stub: `id: "garden"`, `name: "Garden"`, empty `entities: []`)
- `server/actions.ts` (empty stub)
- `server/queries.ts` (empty stub)
Register in `src/modules/index.ts`:
```typescript
import gardenManifest from "./garden/manifest";
registerModule(gardenManifest);
```
### Environment
Add to `.env.example`:
```
MINIO_ENDPOINT=http://localhost:9000
MINIO_ROOT_USER=famapp
MINIO_ROOT_PASSWORD=changeme
MINIO_BUCKET=garden
PERENUAL_API_KEY= # filled in task 72
```
## Out of scope
- Any garden-specific UI.
- Perenual API integration (task 72).
- Care logic (tasks 7374).
## Acceptance criteria
- [ ] `docker compose up minio` starts and passes health-check.
- [ ] `POST /api/uploads` rejects unauthenticated requests with 401.
- [ ] `POST /api/uploads` rejects files > 5 MB with 413.
- [ ] `POST /api/uploads` accepts a valid JPEG and returns `{ url: "..." }`; `GET` of that URL returns the image bytes.
- [ ] `pnpm db:migrate` applies cleanly; all four tables exist with the correct columns.
- [ ] `garden` module appears in `/debug/registry`.
+82
View File
@@ -0,0 +1,82 @@
# 71 — Garden containers
## Goal
Implement full CRUD for **containers** — the grouping layer above individual plants (shelves, terrariums, raised beds, etc.). A container has a name, type, location notes, and an optional cover photo.
## Depends on
- 70 (garden infrastructure — schema, MinIO, upload route)
## Scope
### Server (`src/modules/garden/server/`)
Add to `actions.ts`:
- `createContainer(input: { name, type, locationNotes?, coverImageUrl? })` — Zod-validated, `getCurrentSession`, insert, `logActivity`, `revalidatePath("/garden")`.
- `updateContainer(input: { id, name?, type?, locationNotes?, coverImageUrl? })` — patch, household membership check, logActivity.
- `deleteContainer(input: { id })` — household membership check; plants with this `container_id` have it set to null (handled by schema `ON DELETE SET NULL`); logActivity; revalidatePath.
Add to `queries.ts`:
- `listContainers()` — returns all containers for the current household with a computed `plantCount`.
- `getContainer(id)` — returns the container + all its plants (with last-watered date derived from care logs — use a lateral join or subquery).
### UI (`src/modules/garden/components/`)
**`container-list.tsx`** (server component)
- Grid of cards. Each card: cover photo (placeholder icon if none), name, type badge, plant count.
- "New container" button → sheet/dialog.
**`container-detail.tsx`** (server component)
- Header: cover photo, name, type, location notes, edit/delete buttons.
- Plant grid (placeholder for now — task 72 fills in the plant cards).
- "+ Add plant to this container" button — links to `/garden/plants/new?containerId=<id>`.
**`container-form.tsx`** (client component)
- Fields: name (required), type (select — 7 options), location notes (textarea), cover photo (file input → `POST /api/uploads` → stores returned URL).
- Used for both create and edit via an optional `existing` prop.
### Pages (`src/app/garden/`)
- `page.tsx``/garden` root page: two tabs — "Plants" (placeholder for task 72) and "Containers". Containers tab renders `<ContainerList />`.
- `containers/[id]/page.tsx` — renders `<ContainerDetail />`.
- `containers/new/page.tsx` — renders `<ContainerForm />` in create mode; redirects to `/garden` on success.
### Nav
Add `/garden` to the bottom nav and sidebar. Use the `sprout` Lucide icon (or nearest available). Register in the garden manifest:
```typescript
nav: { href: "/garden", label: "Garden", icon: "sprout" }
```
### Activity log
`renderActivity` for `garden.container` entity type in the manifest:
- `create``Created container "${name}"`
- `update``Updated container "${name}"`
- `delete``Deleted container`
### Share links
Containers are shareable (read-only). Add `loadForShare` and `renderSharedView` to the entity registration so a share link shows the container's name, type, location notes, cover photo, and plant names. Add `loadContainerForShare(id)` in `src/modules/garden/server/share-queries.ts`.
## Out of scope
- Plant cards inside the container detail (task 72 fills those in).
- Care tracking (tasks 7374).
## Acceptance criteria
- [ ] Create / edit / delete containers works end-to-end.
- [ ] Cover photo uploads to MinIO and renders on the card and detail page.
- [ ] Deleting a container nullifies `container_id` on its plants rather than deleting the plants.
- [ ] `/garden` page renders with a Containers tab.
- [ ] `garden.container` entity is registered with share support in the manifest.
- [ ] Activity log entries appear for create / update / delete.
+117
View File
@@ -0,0 +1,117 @@
# 72 — Garden plants
## Goal
Implement full CRUD for individual **plants**. Each plant has a name, category, care notes, health status, up to 10 photos, an optional container assignment, and species data pulled from the **Perenual API** when the user searches for a known species.
## Depends on
- 70 (schema, MinIO, upload route), 71 (containers — container assignment picker)
## Scope
### Perenual API wrapper (`src/modules/garden/server/species-lookup.ts`)
Perenual (perenual.com) provides a free-tier plant species API. Key endpoint: `GET /api/species-list?key=<KEY>&q=<query>`.
Implement:
```typescript
export type SpeciesSuggestion = {
id: string;
common_name: string;
scientific_name: string;
watering: string; // e.g. "frequent", "average", "minimum"
sunlight: string[]; // e.g. ["full sun", "part shade"]
cycle: string; // e.g. "Perennial"
default_image_url: string | null;
};
export async function searchSpecies(query: string): Promise<SpeciesSuggestion[]>;
export async function getSpeciesById(id: string): Promise<SpeciesSuggestion | null>;
```
Cache results for 24 hours in a `garden_species_cache` table (columns: `species_id` text pk, `data` jsonb, `cached_at` timestamptz). Add this table to the schema in `src/modules/garden/schema.ts` and include it in the migration from task 70 (or add a new migration).
If the API is unreachable or returns an error, log the failure and return `[]` / `null`. `PERENUAL_API_KEY` from env; if absent, `searchSpecies` returns `[]` silently so the app works without the key.
### Server (`src/modules/garden/server/`)
Add to `actions.ts`:
- `createPlant(input)` — Zod-validated, `getCurrentSession`, insert, `logActivity`, `revalidatePath("/garden")`.
- `updatePlant(input: { id, ...partials })` — patch, household check, logActivity.
- `deletePlant(input: { id })` — household check, logActivity; care logs + schedules cascade-delete via DB.
- `addPlantImage(input: { id, url })` — appends URL to `images` jsonb array; reject if already 10 images.
- `removePlantImage(input: { id, url })` — removes URL from array; if it was `primary_image_url`, set primary to first remaining or null.
- `setPrimaryImage(input: { id, url })` — sets `primary_image_url`; url must already be in `images`.
Add to `queries.ts`:
- `listPlants({ containerId? })` — all plants in household optionally filtered by container. Include last-care timestamps per type via a lateral subquery.
- `getPlant(id)` — full plant data + container name + care log summary (last 5 entries) + active schedules.
### UI (`src/modules/garden/components/`)
**`plant-list.tsx`** (server component)
- Grouped by container first; "Unassigned" group for plants with no container.
- Each card: primary image thumbnail, name, health badge, "last watered X days ago", overdue care indicator.
- Replaces the placeholder in the "Plants" tab on `/garden/page.tsx` from task 71.
**`plant-detail.tsx`** (server component)
- Three-tab layout: **Info**, **Gallery**, **Care** (Care tab filled in by task 73).
- Info tab: all fields, container link, health/stage badges, species info.
- Gallery tab: photo grid, primary image star-toggle, delete individual image, upload new image.
**`plant-form.tsx`** (client component)
- Fields: name (required), category (select), container (select, nullable), health status (select), growth stage (select).
- Species search combobox: debounced search → `searchSpecies` → select → auto-fills scientific name, sunlight, watering notes. User can override.
- Care notes textareas: watering notes, fertilizing notes, general notes.
- Acquisition date picker.
- Image uploader: drag-and-drop or click, previews, max 10. First uploaded image auto-set as primary.
- `containerId` query-param pre-fill (from task 71 "Add plant to container" button).
**`species-search.tsx`** (client component)
- Debounced combobox calling a `/api/garden/species-search` route handler. Fires `onSelect(suggestion)` on pick.
### Pages (`src/app/garden/`)
- `plants/new/page.tsx``<PlantForm />` in create mode.
- `plants/[id]/page.tsx``<PlantDetail />` with edit/delete controls.
- `plants/[id]/edit/page.tsx``<PlantForm />` in edit mode.
### Activity log
`renderActivity` for `garden.plant`:
- `create``Added plant "${name}"`
- `update``Updated "${name}"`
- `delete``Removed plant "${name}"`
### Search
Register `search` on the `garden.plant` entity using `ilike` on `name || ' ' || coalesce(scientific_name, '')`.
### Share links
Plants are shareable (read-only). The shared view shows name, scientific name, primary image, gallery, health status, and care notes. Add `loadPlantForShare(id)` in `src/modules/garden/server/share-queries.ts`.
## Out of scope
- Care log UI and schedules (task 73).
- Dashboard widgets (task 75).
## Acceptance criteria
- [ ] Create / edit / delete plants works end-to-end.
- [ ] Species search returns Perenual results and auto-fills form fields on selection.
- [ ] App works correctly via manual entry when `PERENUAL_API_KEY` is absent.
- [ ] Up to 10 images upload; primary image can be set and changed; individual images can be removed.
- [ ] Plant list on `/garden` groups by container with an "Unassigned" bucket.
- [ ] `garden.plant` entity is registered with search and share support.
- [ ] Activity log entries appear for create / update / delete.
- [ ] Plant share link renders the read-only view correctly.
+75
View File
@@ -0,0 +1,75 @@
# 73 — Garden care tracking
## Goal
Implement the **care log** and **care schedule** systems. Users can log a care event for any plant (watered, fertilized, repotted, etc.), set up recurring schedules with an interval, and receive reminders when care is due.
## Depends on
- 70 (schema), 72 (plants exist to attach care records to)
- 41 (reminders system — `scheduleReminder` / `cancelReminder`)
## Scope
### Care log actions (`src/modules/garden/server/actions.ts`)
- `logCare(input: { plantId, careType, notes?, performedAt? })` — Zod-validated; `performedAt` defaults to now(). Insert into `garden_care_logs`. After inserting: call `updateScheduleAfterCare(plantId, careType)`, call `logActivity`, `revalidatePath`.
- `deleteCareLog(input: { id })` — household check, delete.
### Care schedule actions (`src/modules/garden/server/actions.ts`)
- `upsertCareSchedule(input: { plantId, careType, intervalDays, enabled? })` — upsert on `(plant_id, care_type)`. Recompute `next_due_at`: if `last_performed_at` exists use `last_performed_at + intervalDays`, else `now() + intervalDays`. Wire reminder: `scheduleReminder({ entityType: "garden.schedule", entityId: scheduleRow.id, fireAt: next_due_at })`.
- `deleteCareSchedule(input: { id })``cancelReminder("garden.schedule", id)`, delete.
- `toggleCareSchedule(input: { id, enabled })` — flip `enabled`; cancel reminder if disabling, reschedule if enabling.
### Schedule update helper (`src/modules/garden/server/care-schedule.ts`)
`updateScheduleAfterCare(plantId, careType)` — finds the schedule row, sets `last_performed_at = now()`, `next_due_at = now() + interval_days`, cancels old reminder, schedules new one.
Uses `entityType = "garden.schedule"` + `entityId = schedule.id` to satisfy the unique constraint on `(entity_type, entity_id)` in the reminders table — one reminder per schedule row, not per plant.
### Queries (`src/modules/garden/server/queries.ts`)
- `getCareLogs(plantId, limit?)` — most recent N logs ordered `performed_at desc`.
- `getCareSchedules(plantId)` — all schedules with computed `daysUntilDue` and `isOverdue`.
- `getOverduePlants(householdId)` — plants with at least one enabled schedule where `next_due_at < now()`, sorted most-overdue first.
- `getCareDueSoon(householdId, withinDays)` — plants with care due within N days.
### UI (`src/modules/garden/components/`)
**`care-log-form.tsx`** (client component) — plant picker + care type select + optional notes + optional performed-at datetime (defaults to now). Used as a sheet and inline in the plant detail Care tab.
**`care-schedule-editor.tsx`** (client component) — list of active schedules per plant (care type, interval, next due, enabled toggle, delete). "Add schedule" form with care type + interval days.
**`care-history-list.tsx`** (server component) — chronological log entries: care type icon, "X days ago", performed-by avatar, notes.
### Plant detail Care tab (update `plant-detail.tsx` from task 72)
The Care tab (previously placeholder) renders: `<CareScheduleEditor />` → "Log care" button → `<CareHistoryList />`.
### Quick-add (update manifest)
```typescript
{
id: "garden.log-care",
label: "Log plant care",
icon: "droplets",
url: "/garden",
}
```
## Out of scope
- Calendar/lists integration (task 74).
- Dashboard widget (task 75).
## Acceptance criteria
- [ ] Logging care inserts a log row and updates `last_performed_at` + `next_due_at` on the matching schedule.
- [ ] Creating a schedule with interval 7 sets `next_due_at` to 7 days from now; a reminder is scheduled.
- [ ] After logging care, old reminder is cancelled and a new one scheduled.
- [ ] Disabling a schedule cancels its reminder; re-enabling reschedules it.
- [ ] Deleting a schedule cancels its reminder.
- [ ] `getOverduePlants` returns correctly sorted results.
- [ ] Care tab in plant detail shows schedule editor, log form, and history.
- [ ] "Log plant care" appears in the quick-add sheet.
+83
View File
@@ -0,0 +1,83 @@
# 74 — Garden integrations (calendar + lists)
## Goal
Wire the garden module into the two existing coordination modules:
1. **Calendar**: schedule a plant care event on the household calendar from within a care schedule row.
2. **Lists**: push all overdue care tasks to the household task list in one tap.
## Depends on
- 73 (care schedules + overdue queries)
- 10 (calendar module — `createEvent`)
- 11 (lists module — `addItem`)
## Scope
### Calendar integration
#### Server action (`src/modules/garden/server/actions.ts`)
`scheduleOnCalendar(input: { scheduleId, calendarId, reminderMinutesBefore? })`:
- Look up the schedule + plant; household check.
- `startAt` = `next_due_at` (reject with user-facing error if null).
- `endAt` = `startAt + 30 min`; `allDay = false`.
- Title convention: `"Water ${plant.name}"` / `"Fertilize ${plant.name}"` / `"Repot ${plant.name}"` / `"${careType} — ${plant.name}"` for other types.
- Notes: `"Scheduled from garden. Next due: ${next_due_at.toLocaleDateString()}"`.
- Call `createCalendarEvent(...)` from the bridge below.
#### Bridge file (`src/modules/garden/server/calendar-bridge.ts`)
```typescript
export { createEvent as createCalendarEvent } from "@/modules/calendar/server/actions";
export { listCalendars } from "@/modules/calendar/server/queries";
```
This keeps the cross-module dependency explicit and swappable without touching `_core`.
#### UI
In `care-schedule-editor.tsx`, add an "Add to calendar" button per schedule row. Clicking it opens a popover with: calendar select (from `listCalendars`) + optional reminder-minutes input + "Schedule" button. Show a success toast linking to `/calendar`.
---
### Lists integration
#### Server action (`src/modules/garden/server/actions.ts`)
`pushOverdueToTaskList(): Promise<{ added: number }>`:
- Call `getOverduePlants(householdId)`.
- Find the household default task list via the bridge below.
- For each overdue `(plant, schedule)` pair insert a list item:
- `text`: same title convention as the calendar integration.
- `notes`: `"Overdue by ${daysOverdue} day(s)"`.
- `dueAt`: `schedule.next_due_at`.
- Skip pairs where an identical `text` item already exists in the list (prevent duplicates).
- Return `{ added: N }`.
#### Bridge file (`src/modules/garden/server/lists-bridge.ts`)
```typescript
export { addItem as addListItem } from "@/modules/lists/server/actions";
export { listLists } from "@/modules/lists/server/queries";
```
#### UI
Add an "Add overdue to task list" button to the `/garden` page header (available before the dashboard widget in task 75). Shows a toast: "Added N tasks to your task list." If N = 0, toast says "No overdue care tasks."
## Out of scope
- Bi-directional sync (checking off a list item does not mark the plant as cared-for).
- Recurring calendar events (garden creates single events only).
## Acceptance criteria
- [ ] "Add to calendar" creates a calendar event visible at `/calendar` with the correct title.
- [ ] "Add overdue to task list" pushes one item per overdue schedule, skips existing duplicates.
- [ ] Items appear in the task list at `/lists`.
- [ ] Both actions enforce household scope.
- [ ] Both are graceful no-ops when nothing is overdue / due.
+144
View File
@@ -0,0 +1,144 @@
# 75 — Garden dashboard, widgets, and manifest completion
## Goal
Complete the garden module: full manifest registration, two dashboard widgets, search integration, quick-add polish, and a Playwright happy-path test.
## Depends on
- 7074 (all previous garden tasks)
## Scope
### Manifest completion (`src/modules/garden/manifest.tsx`)
Replace the task-70 stub with the full manifest.
**Entities:**
```typescript
entities: [
{
type: "garden.container",
label: { singular: "Container", plural: "Containers" },
share: { canShare: true, defaultCapabilities: ["read"] },
search: { search: searchContainers },
resolveUrl: (id) => `/garden/containers/${id}`,
loadForShare: loadContainerForShare,
renderSharedView: ...,
renderActivity: ...,
},
{
type: "garden.plant",
label: { singular: "Plant", plural: "Plants" },
share: { canShare: true, defaultCapabilities: ["read"] },
search: { search: searchPlants },
resolveUrl: (id) => `/garden/plants/${id}`,
loadForShare: loadPlantForShare,
renderSharedView: ...,
renderActivity: ...,
},
]
```
`garden.schedule` is internal (reminders only) — not registered as a shareable or searchable entity.
**Quick adds** (consolidate from tasks 7173):
- `{ id: "garden.add-plant", label: "Add plant", icon: "leaf", url: "/garden/plants/new" }`
- `{ id: "garden.add-container", label: "Add container", icon: "box", url: "/garden/containers/new" }`
- `{ id: "garden.log-care", label: "Log plant care", icon: "droplets", url: "/garden?logCare=1" }`
**Nav**: `{ href: "/garden", label: "Garden", icon: "sprout" }`.
---
### Widget 1: `garden.care-due`
**Title**: "Plants needing care" | **Category**: "Garden"
**Default size**: `{ w: 4, h: 4 }` | **Min size**: `{ w: 3, h: 2 }`
Config schema:
```typescript
z.object({
containerIds: z.union([z.literal("all"), z.array(z.string().uuid())]),
daysAhead: z.number().int().min(0).max(30).default(0),
});
```
Default config: `{ containerIds: "all", daysAhead: 0 }`.
`resolveConfigOptions`: returns `{ containers: [{ id, name }] }`.
Render:
- Empty state if nothing is due: "All plants are on schedule."
- Otherwise: compact list sorted by days overdue. Each row: plant name, care type icon, urgency badge ("X days overdue" / "due today"), inline "Log care" button (calls `logCare` action + revalidates — no navigation).
- Truncate at 10 rows; "View all" link to `/garden`.
- Rows with `next_due_at` within `daysAhead` days show as upcoming in a lighter style below overdue rows.
---
### Widget 2: `garden.overview`
**Title**: "Garden overview" | **Category**: "Garden"
**Default size**: `{ w: 3, h: 2 }` | **Min size**: `{ w: 2, h: 2 }`
Config schema: `z.object({})`.
Render:
- Stat row: total plants, total containers, overdue care count.
- "Next care" line: "Next: water [Plant] in N days" / "today" / "overdue".
- Link to `/garden`.
---
### Widget component (`src/modules/garden/components/plant-widget.tsx`)
Server component implementing both widgets, following the pattern in `src/modules/lists/components/list-widget.tsx`.
---
### Search adapters
Implement `searchContainers` and `searchPlants` using `ilike` on name fields, returning `SearchResult[]`. Both appear in the command palette.
---
### Notification body improvement (optional)
If `tickReminders` in `src/modules/_core/reminders.ts` can be extended without structural changes — add a registry of entity-type resolvers and register `"garden.schedule"` to resolve to `"Time to water [plant name]"`. If it requires core changes, defer and note as known limitation.
---
### Playwright E2E test (`tests/garden.spec.ts`)
Happy path:
1. Log in as the seeded user.
2. Create container "Living Room Shelf".
3. Create plant "Pothos" in that container (manual entry, no species lookup).
4. Add watering schedule: every 7 days.
5. Log care: watered.
6. Verify schedule `next_due_at` updates to ~7 days from now.
7. Confirm plant appears grouped under "Living Room Shelf" on `/garden`.
8. Add `garden.care-due` widget to the default dashboard.
9. Confirm no overdue items (just watered).
## Out of scope
- Health trend charts.
- Weather-based watering adjustments.
- Social/community features.
## Acceptance criteria
- [ ] Both widgets register and appear in the widget picker.
- [ ] `garden.care-due` shows overdue plants sorted by urgency; inline log button works.
- [ ] `garden.overview` shows correct counts.
- [ ] Both entities appear in command palette search.
- [ ] All three quick-adds appear in the `+` sheet.
- [ ] Garden nav link appears in sidebar and bottom nav.
- [ ] Playwright happy-path test passes.
+10
View File
@@ -33,6 +33,7 @@ Every task file has these sections:
- [06 — Authentik install + OIDC integration](06-authentik-oidc.md)
- [07 — Household seeding & session](07-household-seed.md)
- [08 — Theming infrastructure (multi-theme + dark mode)](08-theming.md)
- [09 — Pre-deploy checklist (recurring)](09-pre-deploy-checklist.md)
### Phase 2 — Core modules
@@ -69,3 +70,12 @@ Every task file has these sections:
- [60 — Postgres backups (pg_dump cron)](60-backups.md)
- [61 — Rate limiting on share links](61-rate-limit.md)
- [62 — Structured logging](62-logging.md)
### Phase 8 — Garden module
- [70 — Garden infrastructure (MinIO + upload route + schema)](70-garden-infrastructure.md)
- [71 — Garden containers](71-garden-containers.md)
- [72 — Garden plants](72-garden-plants.md)
- [73 — Garden care tracking](73-garden-care-tracking.md)
- [74 — Garden integrations](74-garden-integrations.md)
- [75 — Garden dashboard](75-garden-dashboard.md)
+74
View File
@@ -0,0 +1,74 @@
-- Task 70: Garden module — containers, plants, care logs, care schedules, species cache
CREATE TABLE "garden_containers" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"household_id" uuid NOT NULL REFERENCES "households"("id") ON DELETE CASCADE,
"name" text NOT NULL,
"type" text NOT NULL DEFAULT 'other',
"location_notes" text,
"cover_image_url" text,
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
);
CREATE INDEX "garden_containers_household_idx" ON "garden_containers" ("household_id");
CREATE TABLE "garden_plants" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"household_id" uuid NOT NULL REFERENCES "households"("id") ON DELETE CASCADE,
"container_id" uuid REFERENCES "garden_containers"("id") ON DELETE SET NULL,
"name" text NOT NULL,
"scientific_name" text,
"species_id" text,
"category" text NOT NULL DEFAULT 'other',
"notes" text,
"acquisition_date" date,
"growth_stage" text,
"health_status" text NOT NULL DEFAULT 'healthy',
"sunlight" text,
"watering_notes" text,
"fertilizing_notes" text,
"primary_image_url" text,
"images" jsonb NOT NULL DEFAULT '[]',
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
);
CREATE INDEX "garden_plants_household_idx" ON "garden_plants" ("household_id");
CREATE INDEX "garden_plants_household_container_idx" ON "garden_plants" ("household_id", "container_id");
CREATE TABLE "garden_care_logs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plant_id" uuid NOT NULL REFERENCES "garden_plants"("id") ON DELETE CASCADE,
"household_id" uuid NOT NULL REFERENCES "households"("id") ON DELETE CASCADE,
"care_type" text NOT NULL,
"performed_by" uuid REFERENCES "users"("id") ON DELETE SET NULL,
"notes" text,
"performed_at" timestamp with time zone NOT NULL DEFAULT now(),
"created_at" timestamp with time zone NOT NULL DEFAULT now()
);
CREATE INDEX "garden_care_logs_plant_performed_idx" ON "garden_care_logs" ("plant_id", "performed_at");
CREATE INDEX "garden_care_logs_household_idx" ON "garden_care_logs" ("household_id");
CREATE TABLE "garden_care_schedules" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"plant_id" uuid NOT NULL REFERENCES "garden_plants"("id") ON DELETE CASCADE,
"household_id" uuid NOT NULL REFERENCES "households"("id") ON DELETE CASCADE,
"care_type" text NOT NULL,
"interval_days" integer NOT NULL,
"last_performed_at" timestamp with time zone,
"next_due_at" timestamp with time zone,
"enabled" boolean NOT NULL DEFAULT true,
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX "garden_care_schedules_plant_type_uq" ON "garden_care_schedules" ("plant_id", "care_type");
CREATE INDEX "garden_care_schedules_household_due_idx" ON "garden_care_schedules" ("household_id", "next_due_at");
CREATE TABLE "garden_species_cache" (
"species_id" text PRIMARY KEY NOT NULL,
"data" jsonb NOT NULL,
"cached_at" timestamp with time zone NOT NULL DEFAULT now()
);
+6
View File
@@ -0,0 +1,6 @@
-- Add title/body to reminders for rich notification bodies
ALTER TABLE reminders ADD COLUMN IF NOT EXISTS title text;
ALTER TABLE reminders ADD COLUMN IF NOT EXISTS body text;
-- Add metadata to list_items for garden task linking
ALTER TABLE list_items ADD COLUMN IF NOT EXISTS metadata jsonb;
+13
View File
@@ -0,0 +1,13 @@
CREATE TABLE "bang_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"household_id" uuid NOT NULL,
"recorded_by" uuid,
"occurred_on" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "bang_events" ADD CONSTRAINT "bang_events_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "bang_events" ADD CONSTRAINT "bang_events_recorded_by_users_id_fk" FOREIGN KEY ("recorded_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
--> statement-breakpoint
CREATE INDEX "bang_events_household_occurred_idx" ON "bang_events" USING btree ("household_id","occurred_on");
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "garden_containers" ADD COLUMN "images" jsonb DEFAULT '[]'::jsonb NOT NULL;
+2 -2
View File
@@ -1,6 +1,6 @@
{
"id": "fc208bad-8bfd-452d-876f-7e853f699931",
"prevId": "cb06cd57-d5a0-4fb7-bbf3-335173a1168c",
"id": "ff1f5ff1-7539-4c5e-a20d-f01c719ef198",
"prevId": "fc208bad-8bfd-452d-876f-7e853f699931",
"version": "7",
"dialect": "postgresql",
"tables": {
File diff suppressed because it is too large Load Diff
+28
View File
@@ -106,6 +106,34 @@
"when": 1778600000000,
"tag": "0014_paper_ink_theme",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1748736000000,
"tag": "0015_garden_schema",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1748822400000,
"tag": "0016_garden_improvements",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1780391596552,
"tag": "0017_bang_counter",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1748995200000,
"tag": "0018_container_images",
"breakpoints": true
}
]
}
+9 -8
View File
@@ -1,11 +1,11 @@
{
"name": "famapp",
"version": "0.3.0",
"version": "0.5.3",
"private": true,
"type": "module",
"packageManager": "pnpm@10.33.3",
"engines": {
"node": ">=22"
"node": ">=24"
},
"scripts": {
"dev": "next dev",
@@ -22,7 +22,7 @@
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:seed": "tsx --env-file=.env scripts/seed.ts",
"db:seed": "node --env-file=.env scripts/seed.mjs",
"db:studio": "drizzle-kit studio",
"gen:icons": "node scripts/generate-icons.mjs",
"vapid:generate": "node scripts/vapid-generate.mjs",
@@ -48,15 +48,14 @@
"devDependencies": {
"@commitlint/cli": "^21.0.2",
"@commitlint/config-conventional": "^21.0.2",
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.59.1",
"@release-it/conventional-changelog": "^11.0.1",
"@tailwindcss/postcss": "^4.2.4",
"@types/node": "^22.9.0",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^24.0.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-grid-layout": "^2.1.0",
"@types/web-push": "^3.6.4",
"dotenv-cli": "^11.0.0",
"drizzle-kit": "^0.31.10",
@@ -79,13 +78,16 @@
"@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/list": "^6.1.20",
"@fullcalendar/react": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"drizzle-orm": "^0.45.2",
"lucide-react": "^1.14.0",
"minio": "^8.0.7",
"next": "^15.5.15",
"next-auth": "5.0.0-beta.31",
"pino": "^10.3.1",
@@ -97,7 +99,6 @@
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"web-push": "^3.6.7",
"zod": "^4.4.3",
"zod-to-json-schema": "^3.25.2"
"zod": "^4.4.3"
}
}
+310 -118
View File
@@ -23,12 +23,18 @@ importers:
'@fullcalendar/interaction':
specifier: ^6.1.20
version: 6.1.20(@fullcalendar/core@6.1.20)
'@fullcalendar/list':
specifier: ^6.1.20
version: 6.1.20(@fullcalendar/core@6.1.20)
'@fullcalendar/react':
specifier: ^6.1.20
version: 6.1.20(@fullcalendar/core@6.1.20)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@fullcalendar/timegrid':
specifier: ^6.1.20
version: 6.1.20(@fullcalendar/core@6.1.20)
canvas-confetti:
specifier: ^1.9.4
version: 1.9.4
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -44,6 +50,9 @@ importers:
lucide-react:
specifier: ^1.14.0
version: 1.14.0(react@19.2.5)
minio:
specifier: ^8.0.7
version: 8.0.7
next:
specifier: ^15.5.15
version: 15.5.15(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -67,7 +76,7 @@ importers:
version: 2.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
shadcn:
specifier: ^4.7.0
version: 4.7.0(@types/node@22.19.17)(typescript@5.9.3)
version: 4.7.0(@types/node@24.12.4)(typescript@5.9.3)
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
@@ -80,19 +89,13 @@ importers:
zod:
specifier: ^4.4.3
version: 4.4.3
zod-to-json-schema:
specifier: ^3.25.2
version: 3.25.2(zod@4.4.3)
devDependencies:
'@commitlint/cli':
specifier: ^21.0.2
version: 21.0.2(@types/node@22.19.17)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)
version: 21.0.2(@types/node@24.12.4)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)
'@commitlint/config-conventional':
specifier: ^21.0.2
version: 21.0.2
'@eslint/eslintrc':
specifier: ^3.3.5
version: 3.3.5
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1(eslint@9.39.4(jiti@2.7.0))
@@ -101,22 +104,22 @@ importers:
version: 1.59.1
'@release-it/conventional-changelog':
specifier: ^11.0.1
version: 11.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@20.2.0(@types/node@22.19.17))
version: 11.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@20.2.0(@types/node@24.12.4))
'@tailwindcss/postcss':
specifier: ^4.2.4
version: 4.2.4
'@types/canvas-confetti':
specifier: ^1.9.0
version: 1.9.0
'@types/node':
specifier: ^22.9.0
version: 22.19.17
specifier: ^24.0.0
version: 24.12.4
'@types/react':
specifier: ^19.2.14
version: 19.2.14
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
'@types/react-grid-layout':
specifier: ^2.1.0
version: 2.1.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@types/web-push':
specifier: ^3.6.4
version: 3.6.4
@@ -149,7 +152,7 @@ importers:
version: 3.8.3
release-it:
specifier: ^20.2.0
version: 20.2.0(@types/node@22.19.17)
version: 20.2.0(@types/node@24.12.4)
tailwindcss:
specifier: ^4.2.4
version: 4.2.4
@@ -976,6 +979,11 @@ packages:
peerDependencies:
'@fullcalendar/core': ~6.1.20
'@fullcalendar/list@6.1.20':
resolution: {integrity: sha512-7Hzkbb7uuSqrXwTyD0Ld/7SwWNxPD6SlU548vtkIpH55rZ4qquwtwYdMPgorHos5OynHA4OUrZNcH51CjrCf2g==}
peerDependencies:
'@fullcalendar/core': ~6.1.20
'@fullcalendar/react@6.1.20':
resolution: {integrity: sha512-1w0pZtceaUdfAnxMSCGHCQalhi+mR1jOe76sXzyAXpcPz/Lf0zHSdcGK/U2XpZlnQgQtBZW+d+QBnnzVQKCxAA==}
peerDependencies:
@@ -1430,6 +1438,9 @@ packages:
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
'@nodable/entities@2.1.1':
resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -1847,6 +1858,9 @@ packages:
'@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
'@types/canvas-confetti@1.9.0':
resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -1856,8 +1870,8 @@ packages:
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/node@22.19.17':
resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==}
'@types/node@24.12.4':
resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==}
'@types/normalize-package-data@2.4.4':
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
@@ -1871,10 +1885,6 @@ packages:
peerDependencies:
'@types/react': ^19.2.0
'@types/react-grid-layout@2.1.0':
resolution: {integrity: sha512-pHEjVg9ert6BDFHFQ1IEdLUkd2gasJvyti5lV2kE46N/R07ZiaSZpAXeXJAA1MXy/Qby23fZmiuEgZkITxPXug==}
deprecated: This is a stub types definition. react-grid-layout provides its own type definitions, so you do not need this installed.
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
@@ -2175,6 +2185,9 @@ packages:
async-retry@1.3.3:
resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==}
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
@@ -2210,6 +2223,9 @@ packages:
before-after-hook@4.0.0:
resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==}
block-stream2@2.1.0:
resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==}
bn.js@4.12.3:
resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==}
@@ -2228,11 +2244,18 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
browser-or-node@2.1.1:
resolution: {integrity: sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==}
browserslist@4.28.2:
resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer-crc32@1.0.0:
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
engines: {node: '>=8.0.0'}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
@@ -2274,6 +2297,9 @@ packages:
caniuse-lite@1.0.30001791:
resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==}
canvas-confetti@1.9.4:
resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -2520,6 +2546,10 @@ packages:
supports-color:
optional: true
decode-uri-component@0.2.2:
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'}
dedent@1.7.2:
resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==}
peerDependencies:
@@ -3019,6 +3049,13 @@ packages:
fast-wrap-ansi@0.2.0:
resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==}
fast-xml-builder@1.2.0:
resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==}
fast-xml-parser@5.8.0:
resolution: {integrity: sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==}
hasBin: true
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
@@ -3050,6 +3087,10 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
filter-obj@1.1.0:
resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==}
engines: {node: '>=0.10.0'}
finalhandler@2.1.1:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'}
@@ -3341,6 +3382,10 @@ packages:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
ipaddr.js@2.4.0:
resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==}
engines: {node: '>= 10'}
is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
@@ -3736,6 +3781,9 @@ packages:
lodash.uniqby@4.7.0:
resolution: {integrity: sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww==}
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
log-symbols@6.0.0:
resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==}
engines: {node: '>=18'}
@@ -3801,10 +3849,18 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-db@1.54.0:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
mime-types@3.0.2:
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
engines: {node: '>=18'}
@@ -3830,6 +3886,10 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minio@8.0.7:
resolution: {integrity: sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==}
engines: {node: ^16 || ^18 || >=20}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -4084,6 +4144,10 @@ packages:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
path-expression-matcher@1.5.0:
resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==}
engines: {node: '>=14.0.0'}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
@@ -4236,6 +4300,10 @@ packages:
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
engines: {node: '>=0.6'}
query-string@7.1.3:
resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==}
engines: {node: '>=6'}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -4426,6 +4494,10 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
sax@1.6.0:
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
engines: {node: '>=11.0.0'}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -4555,6 +4627,10 @@ packages:
spdx-license-ids@3.0.23:
resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==}
split-on-first@1.1.0:
resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==}
engines: {node: '>=6'}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
@@ -4578,9 +4654,19 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
stream-chain@2.2.5:
resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==}
stream-json@1.9.1:
resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==}
strict-event-emitter@0.5.1:
resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==}
strict-uri-encode@2.0.0:
resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==}
engines: {node: '>=4'}
string-argv@0.3.2:
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
engines: {node: '>=0.6.19'}
@@ -4655,6 +4741,9 @@ packages:
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
engines: {node: '>=14.16'}
strnum@2.3.0:
resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==}
styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'}
@@ -4694,6 +4783,9 @@ packages:
resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==}
engines: {node: '>=20'}
through2@4.0.2:
resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -4811,8 +4903,8 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
undici@7.24.5:
resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==}
@@ -4963,6 +5055,18 @@ packages:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'}
xml-naming@0.1.0:
resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==}
engines: {node: '>=16.0.0'}
xml2js@0.6.2:
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
engines: {node: '>=4.0.0'}
xmlbuilder@11.0.1:
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
engines: {node: '>=4.0'}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -5251,11 +5355,11 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@commitlint/cli@21.0.2(@types/node@22.19.17)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)':
'@commitlint/cli@21.0.2(@types/node@24.12.4)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)':
dependencies:
'@commitlint/format': 21.0.1
'@commitlint/lint': 21.0.2
'@commitlint/load': 21.0.2(@types/node@22.19.17)(typescript@5.9.3)
'@commitlint/load': 21.0.2(@types/node@24.12.4)(typescript@5.9.3)
'@commitlint/read': 21.0.2(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)
'@commitlint/types': 21.0.1
tinyexec: 1.2.4
@@ -5300,14 +5404,14 @@ snapshots:
'@commitlint/rules': 21.0.2
'@commitlint/types': 21.0.1
'@commitlint/load@21.0.2(@types/node@22.19.17)(typescript@5.9.3)':
'@commitlint/load@21.0.2(@types/node@24.12.4)(typescript@5.9.3)':
dependencies:
'@commitlint/config-validator': 21.0.1
'@commitlint/execute-rule': 21.0.1
'@commitlint/resolve-extends': 21.0.1
'@commitlint/types': 21.0.1
cosmiconfig: 9.0.1(typescript@5.9.3)
cosmiconfig-typescript-loader: 6.3.0(@types/node@22.19.17)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3)
cosmiconfig-typescript-loader: 6.3.0(@types/node@24.12.4)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3)
es-toolkit: 1.47.0
is-plain-obj: 4.1.0
picocolors: 1.1.1
@@ -5714,6 +5818,10 @@ snapshots:
dependencies:
'@fullcalendar/core': 6.1.20
'@fullcalendar/list@6.1.20(@fullcalendar/core@6.1.20)':
dependencies:
'@fullcalendar/core': 6.1.20
'@fullcalendar/react@6.1.20(@fullcalendar/core@6.1.20)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@fullcalendar/core': 6.1.20
@@ -5846,140 +5954,140 @@ snapshots:
'@inquirer/ansi@2.0.7': {}
'@inquirer/checkbox@5.2.1(@types/node@22.19.17)':
'@inquirer/checkbox@5.2.1(@types/node@24.12.4)':
dependencies:
'@inquirer/ansi': 2.0.7
'@inquirer/core': 11.2.1(@types/node@22.19.17)
'@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/figures': 2.0.7
'@inquirer/type': 4.0.7(@types/node@22.19.17)
'@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/confirm@6.0.12(@types/node@22.19.17)':
'@inquirer/confirm@6.0.12(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 11.1.9(@types/node@22.19.17)
'@inquirer/type': 4.0.5(@types/node@22.19.17)
'@inquirer/core': 11.1.9(@types/node@24.12.4)
'@inquirer/type': 4.0.5(@types/node@24.12.4)
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/core@11.1.9(@types/node@22.19.17)':
'@inquirer/core@11.1.9(@types/node@24.12.4)':
dependencies:
'@inquirer/ansi': 2.0.5
'@inquirer/figures': 2.0.5
'@inquirer/type': 4.0.5(@types/node@22.19.17)
'@inquirer/type': 4.0.5(@types/node@24.12.4)
cli-width: 4.1.0
fast-wrap-ansi: 0.2.0
mute-stream: 3.0.0
signal-exit: 4.1.0
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/core@11.2.1(@types/node@22.19.17)':
'@inquirer/core@11.2.1(@types/node@24.12.4)':
dependencies:
'@inquirer/ansi': 2.0.7
'@inquirer/figures': 2.0.7
'@inquirer/type': 4.0.7(@types/node@22.19.17)
'@inquirer/type': 4.0.7(@types/node@24.12.4)
cli-width: 4.1.0
fast-wrap-ansi: 0.2.0
mute-stream: 3.0.0
signal-exit: 4.1.0
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/editor@5.2.2(@types/node@22.19.17)':
'@inquirer/editor@5.2.2(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@22.19.17)
'@inquirer/external-editor': 3.0.3(@types/node@22.19.17)
'@inquirer/type': 4.0.7(@types/node@22.19.17)
'@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/external-editor': 3.0.3(@types/node@24.12.4)
'@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/expand@5.1.1(@types/node@22.19.17)':
'@inquirer/expand@5.1.1(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@22.19.17)
'@inquirer/type': 4.0.7(@types/node@22.19.17)
'@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/external-editor@3.0.3(@types/node@22.19.17)':
'@inquirer/external-editor@3.0.3(@types/node@24.12.4)':
dependencies:
chardet: 2.1.1
iconv-lite: 0.7.2
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/figures@2.0.5': {}
'@inquirer/figures@2.0.7': {}
'@inquirer/input@5.1.2(@types/node@22.19.17)':
'@inquirer/input@5.1.2(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@22.19.17)
'@inquirer/type': 4.0.7(@types/node@22.19.17)
'@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/number@4.1.1(@types/node@22.19.17)':
'@inquirer/number@4.1.1(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@22.19.17)
'@inquirer/type': 4.0.7(@types/node@22.19.17)
'@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/password@5.1.1(@types/node@22.19.17)':
'@inquirer/password@5.1.1(@types/node@24.12.4)':
dependencies:
'@inquirer/ansi': 2.0.7
'@inquirer/core': 11.2.1(@types/node@22.19.17)
'@inquirer/type': 4.0.7(@types/node@22.19.17)
'@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/prompts@8.4.2(@types/node@22.19.17)':
'@inquirer/prompts@8.4.2(@types/node@24.12.4)':
dependencies:
'@inquirer/checkbox': 5.2.1(@types/node@22.19.17)
'@inquirer/confirm': 6.0.12(@types/node@22.19.17)
'@inquirer/editor': 5.2.2(@types/node@22.19.17)
'@inquirer/expand': 5.1.1(@types/node@22.19.17)
'@inquirer/input': 5.1.2(@types/node@22.19.17)
'@inquirer/number': 4.1.1(@types/node@22.19.17)
'@inquirer/password': 5.1.1(@types/node@22.19.17)
'@inquirer/rawlist': 5.3.1(@types/node@22.19.17)
'@inquirer/search': 4.2.1(@types/node@22.19.17)
'@inquirer/select': 5.2.1(@types/node@22.19.17)
'@inquirer/checkbox': 5.2.1(@types/node@24.12.4)
'@inquirer/confirm': 6.0.12(@types/node@24.12.4)
'@inquirer/editor': 5.2.2(@types/node@24.12.4)
'@inquirer/expand': 5.1.1(@types/node@24.12.4)
'@inquirer/input': 5.1.2(@types/node@24.12.4)
'@inquirer/number': 4.1.1(@types/node@24.12.4)
'@inquirer/password': 5.1.1(@types/node@24.12.4)
'@inquirer/rawlist': 5.3.1(@types/node@24.12.4)
'@inquirer/search': 4.2.1(@types/node@24.12.4)
'@inquirer/select': 5.2.1(@types/node@24.12.4)
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/rawlist@5.3.1(@types/node@22.19.17)':
'@inquirer/rawlist@5.3.1(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@22.19.17)
'@inquirer/type': 4.0.7(@types/node@22.19.17)
'@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/search@4.2.1(@types/node@22.19.17)':
'@inquirer/search@4.2.1(@types/node@24.12.4)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@22.19.17)
'@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/figures': 2.0.7
'@inquirer/type': 4.0.7(@types/node@22.19.17)
'@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/select@5.2.1(@types/node@22.19.17)':
'@inquirer/select@5.2.1(@types/node@24.12.4)':
dependencies:
'@inquirer/ansi': 2.0.7
'@inquirer/core': 11.2.1(@types/node@22.19.17)
'@inquirer/core': 11.2.1(@types/node@24.12.4)
'@inquirer/figures': 2.0.7
'@inquirer/type': 4.0.7(@types/node@22.19.17)
'@inquirer/type': 4.0.7(@types/node@24.12.4)
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/type@4.0.5(@types/node@22.19.17)':
'@inquirer/type@4.0.5(@types/node@24.12.4)':
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@inquirer/type@4.0.7(@types/node@22.19.17)':
'@inquirer/type@4.0.7(@types/node@24.12.4)':
optionalDependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@jridgewell/gen-mapping@0.3.13':
dependencies:
@@ -6076,6 +6184,8 @@ snapshots:
'@noble/hashes@1.8.0': {}
'@nodable/entities@2.1.1': {}
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -6333,7 +6443,7 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@release-it/conventional-changelog@11.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@20.2.0(@types/node@22.19.17))':
'@release-it/conventional-changelog@11.0.1(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@20.2.0(@types/node@24.12.4))':
dependencies:
'@conventional-changelog/git-client': 2.7.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)
concat-stream: 2.0.0
@@ -6341,7 +6451,7 @@ snapshots:
conventional-changelog-angular: 8.3.1
conventional-changelog-conventionalcommits: 9.3.1
conventional-recommended-bump: 11.2.0
release-it: 20.2.0(@types/node@22.19.17)
release-it: 20.2.0(@types/node@24.12.4)
semver: 7.7.4
transitivePeerDependencies:
- conventional-commits-filter
@@ -6445,15 +6555,17 @@ snapshots:
tslib: 2.8.1
optional: true
'@types/canvas-confetti@1.9.0': {}
'@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {}
'@types/json5@0.0.29': {}
'@types/node@22.19.17':
'@types/node@24.12.4':
dependencies:
undici-types: 6.21.0
undici-types: 7.16.0
'@types/normalize-package-data@2.4.4': {}
@@ -6465,20 +6577,13 @@ snapshots:
dependencies:
'@types/react': 19.2.14
'@types/react-grid-layout@2.1.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
react-grid-layout: 2.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
transitivePeerDependencies:
- react
- react-dom
'@types/react@19.2.14':
dependencies:
csstype: 3.2.3
'@types/set-cookie-parser@2.4.10':
dependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@types/statuses@2.0.6': {}
@@ -6486,7 +6591,7 @@ snapshots:
'@types/web-push@3.6.4':
dependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
@@ -6785,6 +6890,8 @@ snapshots:
dependencies:
retry: 0.13.1
async@3.2.6: {}
atomic-sleep@1.0.0: {}
available-typed-arrays@1.0.7:
@@ -6805,6 +6912,10 @@ snapshots:
before-after-hook@4.0.0: {}
block-stream2@2.1.0:
dependencies:
readable-stream: 3.6.2
bn.js@4.12.3: {}
body-parser@2.2.2:
@@ -6834,6 +6945,8 @@ snapshots:
dependencies:
fill-range: 7.1.1
browser-or-node@2.1.1: {}
browserslist@4.28.2:
dependencies:
baseline-browser-mapping: 2.10.27
@@ -6842,6 +6955,8 @@ snapshots:
node-releases: 2.0.38
update-browserslist-db: 1.2.3(browserslist@4.28.2)
buffer-crc32@1.0.0: {}
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
@@ -6888,6 +7003,8 @@ snapshots:
caniuse-lite@1.0.30001791: {}
canvas-confetti@1.9.4: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -7054,9 +7171,9 @@ snapshots:
object-assign: 4.1.1
vary: 1.1.2
cosmiconfig-typescript-loader@6.3.0(@types/node@22.19.17)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3):
cosmiconfig-typescript-loader@6.3.0(@types/node@24.12.4)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3):
dependencies:
'@types/node': 22.19.17
'@types/node': 24.12.4
cosmiconfig: 9.0.1(typescript@5.9.3)
jiti: 2.6.1
typescript: 5.9.3
@@ -7114,6 +7231,8 @@ snapshots:
dependencies:
ms: 2.1.3
decode-uri-component@0.2.2: {}
dedent@1.7.2: {}
deep-is@0.1.4: {}
@@ -7766,6 +7885,19 @@ snapshots:
dependencies:
fast-string-width: 3.0.2
fast-xml-builder@1.2.0:
dependencies:
path-expression-matcher: 1.5.0
xml-naming: 0.1.0
fast-xml-parser@5.8.0:
dependencies:
'@nodable/entities': 2.1.1
fast-xml-builder: 1.2.0
path-expression-matcher: 1.5.0
strnum: 2.3.0
xml-naming: 0.1.0
fastq@1.20.1:
dependencies:
reusify: 1.1.0
@@ -7795,6 +7927,8 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
filter-obj@1.1.0: {}
finalhandler@2.1.1:
dependencies:
debug: 4.4.3
@@ -8085,6 +8219,8 @@ snapshots:
ipaddr.js@1.9.1: {}
ipaddr.js@2.4.0: {}
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.9
@@ -8429,6 +8565,8 @@ snapshots:
lodash.uniqby@4.7.0: {}
lodash@4.18.1: {}
log-symbols@6.0.0:
dependencies:
chalk: 5.6.2
@@ -8486,8 +8624,14 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.2
mime-db@1.52.0: {}
mime-db@1.54.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
mime-types@3.0.2:
dependencies:
mime-db: 1.54.0
@@ -8508,11 +8652,27 @@ snapshots:
minimist@1.2.8: {}
minio@8.0.7:
dependencies:
async: 3.2.6
block-stream2: 2.1.0
browser-or-node: 2.1.1
buffer-crc32: 1.0.0
eventemitter3: 5.0.4
fast-xml-parser: 5.8.0
ipaddr.js: 2.4.0
lodash: 4.18.1
mime-types: 2.1.35
query-string: 7.1.3
stream-json: 1.9.1
through2: 4.0.2
xml2js: 0.6.2
ms@2.1.3: {}
msw@2.14.3(@types/node@22.19.17)(typescript@5.9.3):
msw@2.14.3(@types/node@24.12.4)(typescript@5.9.3):
dependencies:
'@inquirer/confirm': 6.0.12(@types/node@22.19.17)
'@inquirer/confirm': 6.0.12(@types/node@24.12.4)
'@mswjs/interceptors': 0.41.8
'@open-draft/deferred-promise': 3.0.0
'@types/statuses': 2.0.6
@@ -8798,6 +8958,8 @@ snapshots:
path-exists@4.0.0: {}
path-expression-matcher@1.5.0: {}
path-key@3.1.1: {}
path-key@4.0.0: {}
@@ -8957,6 +9119,13 @@ snapshots:
dependencies:
side-channel: 1.1.0
query-string@7.1.3:
dependencies:
decode-uri-component: 0.2.2
filter-obj: 1.1.0
split-on-first: 1.1.0
strict-uri-encode: 2.0.0
queue-microtask@1.2.3: {}
quick-format-unescaped@4.0.4: {}
@@ -9076,9 +9245,9 @@ snapshots:
gopd: 1.2.0
set-function-name: 2.0.2
release-it@20.2.0(@types/node@22.19.17):
release-it@20.2.0(@types/node@24.12.4):
dependencies:
'@inquirer/prompts': 8.4.2(@types/node@22.19.17)
'@inquirer/prompts': 8.4.2(@types/node@24.12.4)
'@octokit/rest': 22.0.1
'@phun-ky/typeof': 2.0.3
async-retry: 1.3.3
@@ -9183,6 +9352,8 @@ snapshots:
safer-buffer@2.1.2: {}
sax@1.6.0: {}
scheduler@0.27.0: {}
secure-json-parse@4.1.0: {}
@@ -9242,7 +9413,7 @@ snapshots:
setprototypeof@1.2.0: {}
shadcn@4.7.0(@types/node@22.19.17)(typescript@5.9.3):
shadcn@4.7.0(@types/node@24.12.4)(typescript@5.9.3):
dependencies:
'@babel/core': 7.29.0
'@babel/parser': 7.29.3
@@ -9263,7 +9434,7 @@ snapshots:
fuzzysort: 3.1.0
https-proxy-agent: 7.0.6
kleur: 4.1.5
msw: 2.14.3(@types/node@22.19.17)(typescript@5.9.3)
msw: 2.14.3(@types/node@24.12.4)(typescript@5.9.3)
node-fetch: 3.3.2
open: 11.0.0
ora: 8.2.0
@@ -9409,6 +9580,8 @@ snapshots:
spdx-license-ids@3.0.23: {}
split-on-first@1.1.0: {}
split2@4.2.0: {}
stable-hash@0.0.5: {}
@@ -9424,8 +9597,16 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
stream-chain@2.2.5: {}
stream-json@1.9.1:
dependencies:
stream-chain: 2.2.5
strict-event-emitter@0.5.1: {}
strict-uri-encode@2.0.0: {}
string-argv@0.3.2: {}
string-width@4.2.3:
@@ -9523,6 +9704,8 @@ snapshots:
strip-json-comments@5.0.3: {}
strnum@2.3.0: {}
styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.5):
dependencies:
client-only: 0.0.1
@@ -9548,6 +9731,10 @@ snapshots:
dependencies:
real-require: 0.2.0
through2@4.0.2:
dependencies:
readable-stream: 3.6.2
tiny-invariant@1.3.3: {}
tinyexec@1.2.4: {}
@@ -9685,7 +9872,7 @@ snapshots:
has-symbols: 1.1.0
which-boxed-primitive: 1.1.1
undici-types@6.21.0: {}
undici-types@7.16.0: {}
undici@7.24.5: {}
@@ -9863,6 +10050,15 @@ snapshots:
is-wsl: 3.1.1
powershell-utils: 0.1.0
xml-naming@0.1.0: {}
xml2js@0.6.2:
dependencies:
sax: 1.6.0
xmlbuilder: 11.0.1
xmlbuilder@11.0.1: {}
y18n@5.0.8: {}
yallist@3.1.1: {}
@@ -9905,10 +10101,6 @@ snapshots:
dependencies:
zod: 3.25.76
zod-to-json-schema@3.25.2(zod@4.4.3):
dependencies:
zod: 4.4.3
zod-validation-error@4.0.2(zod@4.4.3):
dependencies:
zod: 4.4.3
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<style>
@media (prefers-color-scheme: dark) { .bg { fill: #6366F1; } }
</style>
<rect class="bg" width="512" height="512" rx="96" fill="#4338CA"/>
<rect x="130" y="238" width="252" height="190" fill="white"/>
<polygon points="256,82 68,254 444,254" fill="white"/>
<rect x="216" y="338" width="80" height="90" rx="8" fill="#4338CA"/>
</svg>

After

Width:  |  Height:  |  Size: 444 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 496 B

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 547 B

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 546 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 13 KiB

+4 -10
View File
@@ -1,12 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<!-- Background -->
<rect width="512" height="512" rx="80" fill="#4F46E5"/>
<!-- House body -->
<polygon points="256,108 396,234 364,234 364,392 148,392 148,234 116,234" fill="white"/>
<!-- Door -->
<rect x="212" y="296" width="88" height="96" rx="6" fill="#4F46E5"/>
<!-- Left window -->
<rect x="164" y="254" width="66" height="54" rx="6" fill="#4F46E5" opacity="0.55"/>
<!-- Right window -->
<rect x="282" y="254" width="66" height="54" rx="6" fill="#4F46E5" opacity="0.55"/>
<rect width="512" height="512" rx="96" fill="#4338CA"/>
<rect x="130" y="238" width="252" height="190" fill="white"/>
<polygon points="256,82 68,254 444,254" fill="white"/>
<rect x="216" y="338" width="80" height="90" rx="8" fill="#4338CA"/>
</svg>

Before

Width:  |  Height:  |  Size: 594 B

After

Width:  |  Height:  |  Size: 345 B

+8 -5
View File
@@ -4,23 +4,26 @@
"description": "Family coordination app",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#4F46E5",
"background_color": "#4338CA",
"theme_color": "#4338CA",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-384.png",
"sizes": "384x384",
"type": "image/png"
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512-maskable.png",
+4 -8
View File
@@ -19,11 +19,7 @@ function getLanIp() {
.flat()
.filter((n) => n.family === "IPv4" && !n.internal)
.map((n) => n.address);
return (
all.find((a) => a.startsWith("192.168.") || a.startsWith("10.")) ??
all[0] ??
"unknown"
);
return all.find((a) => a.startsWith("192.168.") || a.startsWith("10.")) ?? all[0] ?? "unknown";
}
/** Poll TCP host:port until it accepts a connection or the timeout expires. */
@@ -43,8 +39,8 @@ function waitForTcp(host, port, timeoutMs = 30_000) {
reject(
new Error(
`Postgres not ready on ${host}:${port} after ${timeoutMs / 1000}s. ` +
"Is Docker running? Check: docker compose -f docker-compose.dev.yaml logs"
)
"Is Docker running? Check: docker compose -f docker-compose.dev.yaml logs",
),
);
} else {
setTimeout(attempt, 500);
@@ -89,7 +85,7 @@ export async function main() {
console.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log(` Local → http://localhost:3000`);
console.log(` LAN → http://${lanIp}:3000 (phone on same WiFi)`);
console.log(` HTTPS → https://dev.ginnoir.com (push/PWA — needs Caddy snippet)`);
console.log(` HTTPS → https://dev.<your-domain> (push/PWA — needs Caddyfile.dev.snippet)`);
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
// 6. Spawn Next.js dev server bound to all interfaces.
+27 -79
View File
@@ -1,100 +1,48 @@
/**
* Generates placeholder PNG icons for the famapp PWA.
* Rasterizes public/icon.svg into all required PNG icon sizes.
*
* Usage: node scripts/generate-icons.mjs
*
* Produces:
* public/icon-16.png
* public/icon-32.png
* public/icon-180.png (apple-touch-icon)
* public/icon-192.png
* public/icon-384.png
* public/icon-512.png
* public/icon-512-maskable.png (same image; OS applies its own mask)
* public/icon-180.png (apple-touch-icon)
*
* All images are solid indigo (#4F46E5) squares — replace with a real
* branded export from icon.svg when assets are finalised.
* public/icon-512-maskable.png (square bg, content in safe zone)
*/
import { deflateSync } from "zlib";
import { writeFileSync } from "fs";
import { resolve, dirname } from "path";
import sharp from "sharp";
import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
const __dir = dirname(fileURLToPath(import.meta.url));
const publicDir = resolve(__dir, "../public");
const __dirname = dirname(fileURLToPath(import.meta.url));
const pub = join(__dirname, "..", "public");
// Build CRC-32 lookup table once.
const CRC_TABLE = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
CRC_TABLE[i] = c;
}
const svg = readFileSync(join(pub, "icon.svg"));
function crc32(buf) {
let crc = 0xffffffff;
for (let i = 0; i < buf.length; i++) crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function u32(n) {
const b = Buffer.alloc(4);
b.writeUInt32BE(n, 0);
return b;
}
function pngChunk(type, data) {
const typeBytes = Buffer.from(type, "ascii");
const crc = u32(crc32(Buffer.concat([typeBytes, data])));
return Buffer.concat([u32(data.length), typeBytes, data, crc]);
}
/**
* Returns a Buffer containing a valid PNG of size×size filled with (r, g, b).
*/
function solidPNG(size, r, g, b) {
const PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ihdr = pngChunk(
"IHDR",
Buffer.concat([
u32(size),
u32(size),
Buffer.from([8, 2, 0, 0, 0]), // 8-bit RGB, no interlace
]),
);
// One filter byte (0 = None) followed by size×3 RGB bytes per row.
const rowLen = 1 + size * 3;
const raw = Buffer.alloc(size * rowLen);
for (let y = 0; y < size; y++) {
const base = y * rowLen;
raw[base] = 0;
for (let x = 0; x < size; x++) {
raw[base + 1 + x * 3] = r;
raw[base + 2 + x * 3] = g;
raw[base + 3 + x * 3] = b;
}
}
const idat = pngChunk("IDAT", deflateSync(raw));
const iend = pngChunk("IEND", Buffer.alloc(0));
return Buffer.concat([PNG_SIG, ihdr, idat, iend]);
}
// Indigo-600 (#4F46E5)
const [R, G, B] = [0x4f, 0x46, 0xe5];
const icons = [
const sizes = [
{ name: "icon-16.png", size: 16 },
{ name: "icon-32.png", size: 32 },
{ name: "icon-180.png", size: 180 },
{ name: "icon-192.png", size: 192 },
{ name: "icon-384.png", size: 384 },
{ name: "icon-512.png", size: 512 },
{ name: "icon-512-maskable.png", size: 512 },
{ name: "icon-180.png", size: 180 },
];
for (const { name, size } of icons) {
const out = resolve(publicDir, name);
writeFileSync(out, solidPNG(size, R, G, B));
for (const { name, size } of sizes) {
await sharp(svg).resize(size, size).png().toFile(join(pub, name));
console.log(` ✓ public/${name} (${size}×${size})`);
}
// Maskable: remove rounded corners so the background fills the full canvas,
// allowing the OS to apply any mask shape without clipping the icon corners.
const maskableSvg = readFileSync(join(pub, "icon.svg"), "utf-8").replace('rx="96"', "");
await sharp(Buffer.from(maskableSvg))
.resize(512, 512)
.png()
.toFile(join(pub, "icon-512-maskable.png"));
console.log(` ✓ public/icon-512-maskable.png (512×512 maskable)`);
-30
View File
@@ -1,30 +0,0 @@
import postgres from "postgres";
import { drizzle } from "drizzle-orm/postgres-js";
import { households } from "@/modules/_core/schema";
import { ensureDefaultCalendars } from "@/modules/calendar/server/defaults";
import { ensureDefaultLists } from "@/modules/lists/server/defaults";
const client = postgres(process.env["DATABASE_URL"]!);
const db = drizzle(client);
async function seed() {
const [existing] = await db.select().from(households).limit(1);
if (existing) {
console.log(`Household already exists ("${existing.name}"), skipping.`);
} else {
await db.insert(households).values({ name: "Home" });
console.log('Seeded household "Home".');
}
await ensureDefaultCalendars();
console.log("Ensured default calendars.");
await ensureDefaultLists();
console.log("Ensured default lists.");
await client.end();
process.exit(0);
}
seed().catch((err) => {
console.error(err);
process.exit(1);
});
+5 -1
View File
@@ -36,7 +36,11 @@ for (const sub of subs) {
try {
await webPush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify({ title: "famapp test", body: "Push notifications are working!", url: "/settings" }),
JSON.stringify({
title: "famapp test",
body: "Push notifications are working!",
url: "/settings",
}),
);
console.log(`Sent to ${sub.endpoint.slice(0, 60)}...`);
} catch (err: unknown) {
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { getCurrentSession } from "@/lib/session";
import { searchSpecies } from "@/modules/garden/server/species-lookup";
export async function GET(request: Request) {
try {
await getCurrentSession();
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const q = searchParams.get("q") ?? "";
const results = await searchSpecies(q);
return NextResponse.json(results);
}
+34
View File
@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { minioClient, MINIO_BUCKET } from "@/lib/minio";
export const runtime = "nodejs";
export async function GET(_request: Request, { params }: { params: Promise<{ key: string[] }> }) {
const { key } = await params;
const objectKey = key.join("/");
try {
const stat = await minioClient.statObject(MINIO_BUCKET, objectKey);
const stream = await minioClient.getObject(MINIO_BUCKET, objectKey);
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
}
const buffer = Buffer.concat(chunks);
const metaData = stat.metaData as Record<string, string> | undefined;
const contentType =
metaData?.["content-type"] ?? metaData?.["Content-Type"] ?? "application/octet-stream";
return new Response(buffer, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
"Content-Length": String(buffer.length),
},
});
} catch {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
}
+62
View File
@@ -0,0 +1,62 @@
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import { getCurrentSession } from "@/lib/session";
import { ensureBucket, minioClient, MINIO_BUCKET } from "@/lib/minio";
export const runtime = "nodejs";
export const maxDuration = 60;
export const config = {
api: { bodyParser: { sizeLimit: "100mb" } },
};
const MAX_FILE_SIZE = 100 * 1024 * 1024;
export async function POST(request: Request) {
let session: Awaited<ReturnType<typeof getCurrentSession>>;
try {
session = await getCurrentSession();
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let formData: FormData;
try {
formData = await request.formData();
} catch {
return NextResponse.json({ error: "Invalid multipart body" }, { status: 400 });
}
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "No file field in request" }, { status: 400 });
}
if (!file.type.startsWith("image/")) {
return NextResponse.json({ error: "Only image files are allowed" }, { status: 415 });
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: "File exceeds 100 MB limit" }, { status: 413 });
}
const rawExt = file.name.split(".").pop() ?? "bin";
const safeExt = rawExt
.replace(/[^a-z0-9]/gi, "")
.toLowerCase()
.slice(0, 8);
const key = `garden/${session.household.id}/${randomUUID()}.${safeExt}`;
try {
await ensureBucket();
const buffer = Buffer.from(await file.arrayBuffer());
await minioClient.putObject(MINIO_BUCKET, key, buffer, buffer.length, {
"Content-Type": file.type,
});
} catch (err) {
console.error("Upload failed", err);
return NextResponse.json({ error: "Upload service unavailable" }, { status: 503 });
}
return NextResponse.json({ url: `/api/uploads/${key}` });
}
+3 -17
View File
@@ -9,6 +9,7 @@ import { DashboardEditor } from "@/components/dashboard-editor";
import { EditDashboardButton } from "@/components/edit-dashboard-button";
import { DashboardSwitcher } from "@/components/dashboard-switcher";
import { DashboardTab } from "@/components/dashboard-tab";
import { DashboardGreeting } from "@/components/dashboard-greeting";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
@@ -31,9 +32,6 @@ const smColSpan: Record<number, string> = {
12: "sm:col-span-12",
};
const greetingForHour = (h: number) =>
h < 5 ? "Up early" : h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening";
export default async function DashboardPage({
params,
searchParams,
@@ -83,17 +81,11 @@ export default async function DashboardPage({
const dashLayout = (user.themeDashLayout as DashLayout) ?? "classic";
const containerCls =
dashLayout === "glance" ? "max-w-[760px] mx-auto" : dashLayout === "split" ? "" : "";
const greeting = greetingForHour(new Date().getHours());
const today = new Date().toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
});
const firstName = (user.name ?? "").split(" ")[0] ?? "";
return (
<div className={containerCls}>
{userDashboards.length > 1 && (
{userDashboards.length >= 1 && (
<div className="flex items-center gap-1 mb-4 overflow-x-auto">
{userDashboards.map((d) => (
<DashboardTab key={d.id} slug={d.slug} name={d.name} />
@@ -103,13 +95,7 @@ export default async function DashboardPage({
)}
<div className="mb-6 flex items-end justify-between gap-4 flex-wrap">
<div>
<h1 className="serif text-[26px] sm:text-[30px] leading-tight tracking-tight">
{greeting}
{firstName && `, ${firstName}`}.
</h1>
<p className="muted mt-1 text-[13px]">{today}</p>
</div>
<DashboardGreeting firstName={firstName} />
<EditDashboardButton />
</div>
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useEffect } from "react";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
<h2 className="text-xl font-semibold">Something went wrong</h2>
<button
onClick={reset}
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
>
Try again
</button>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { notFound } from "next/navigation";
import { getContainer } from "@/modules/garden/server/queries";
import { ContainerDetail } from "@/modules/garden/components/container-detail";
import { getShareLinksForEntity } from "@/modules/_core/share";
export default async function ContainerPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [container, shareLinks] = await Promise.all([
getContainer(id),
getShareLinksForEntity("garden.container", id),
]);
if (!container) notFound();
return (
<div className="page-content">
<ContainerDetail container={container} shareLinks={shareLinks} />
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { redirect } from "next/navigation";
import { createContainer } from "@/modules/garden/server/actions";
export default function NewContainerPage() {
async function handleCreate(formData: FormData) {
"use server";
await createContainer({
name: formData.get("name") as string,
type: (formData.get("type") as string) || "other",
locationNotes: (formData.get("locationNotes") as string) || null,
});
redirect("/garden");
}
return (
<div className="page-content max-w-lg">
<div className="flex items-center gap-3 mb-1">
<a href="/garden" className="btn btn-ghost btn-icon" aria-label="Back to garden">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</a>
<h1 className="page-title">New container</h1>
</div>
<form action={handleCreate} className="flex flex-col gap-4 mt-4">
<div className="flex flex-col gap-1">
<label htmlFor="name" className="text-sm font-medium">
Name
</label>
<input id="name" name="name" required maxLength={120} className="input" />
</div>
<div className="flex flex-col gap-1">
<label htmlFor="type" className="text-sm font-medium">
Type
</label>
<select id="type" name="type" defaultValue="other">
{(
[
["shelf", "Shelf"],
["terrarium", "Terrarium"],
["raised-bed", "Raised bed"],
["window-box", "Window box"],
["single-pot", "Single pot"],
["outdoor", "Outdoor"],
["other", "Other"],
] as const
).map(([v, l]) => (
<option key={v} value={v}>
{l}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="locationNotes" className="text-sm font-medium">
Location notes
</label>
<textarea
id="locationNotes"
name="locationNotes"
maxLength={500}
rows={2}
className="input"
/>
</div>
<div className="flex gap-2 justify-end">
<a href="/garden" className="btn btn-ghost">
Cancel
</a>
<button type="submit" className="btn btn-primary">
Create
</button>
</div>
</form>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import Link from "next/link";
import { listContainers, listPlants } from "@/modules/garden/server/queries";
import { ContainerList } from "@/modules/garden/components/container-list";
import { PlantList } from "@/modules/garden/components/plant-list";
import { PushOverdueButton } from "@/modules/garden/components/push-overdue-button";
type Props = {
searchParams: Promise<{ tab?: string }>;
};
export default async function GardenPage({ searchParams }: Props) {
const { tab = "containers" } = await searchParams;
const isPlants = tab === "plants";
const [containers, plants] = await Promise.all([
listContainers(),
isPlants ? listPlants() : Promise.resolve([]),
]);
return (
<div className="page-content">
<div className="flex items-center justify-between mb-1">
<h1 className="page-title mb-0">Garden</h1>
<PushOverdueButton />
</div>
<div className="flex gap-6 border-b border-[var(--ink-faint)] mb-6">
<Link
href="/garden?tab=containers"
className={`pb-2 text-sm font-medium transition-colors ${
!isPlants
? "border-b-2 border-[var(--ink)] text-[var(--ink)]"
: "text-[var(--ink-mute)]"
}`}
>
Containers
</Link>
<Link
href="/garden?tab=plants"
className={`pb-2 text-sm font-medium transition-colors ${
isPlants ? "border-b-2 border-[var(--ink)] text-[var(--ink)]" : "text-[var(--ink-mute)]"
}`}
>
Plants
</Link>
</div>
{isPlants ? <PlantList plants={plants} /> : <ContainerList containers={containers} />}
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { notFound } from "next/navigation";
import { getPlant, listContainers } from "@/modules/garden/server/queries";
import { PlantForm } from "@/modules/garden/components/plant-form";
export default async function EditPlantPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [plant, containers] = await Promise.all([getPlant(id), listContainers()]);
if (!plant) notFound();
return (
<div className="page-content max-w-xl">
<h1 className="page-title">Edit Plant</h1>
<PlantForm existingPlant={plant} containers={containers} />
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { notFound } from "next/navigation";
import { listCalendars } from "@/modules/garden/server/calendar-bridge";
import { getCareLogs, getCareSchedules, getPlant } from "@/modules/garden/server/queries";
import { PlantDetail } from "@/modules/garden/components/plant-detail";
import { getShareLinksForEntity } from "@/modules/_core/share";
export default async function PlantPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [plant, careLogs, careSchedules, calendars, shareLinks] = await Promise.all([
getPlant(id),
getCareLogs(id),
getCareSchedules(id),
listCalendars(),
getShareLinksForEntity("garden.plant", id),
]);
if (!plant) notFound();
return (
<div className="page-content">
<PlantDetail
plant={plant}
careLogs={careLogs}
careSchedules={careSchedules}
calendars={calendars}
shareLinks={shareLinks}
/>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { listContainers } from "@/modules/garden/server/queries";
import { PlantForm } from "@/modules/garden/components/plant-form";
export default async function NewPlantPage({
searchParams,
}: {
searchParams: Promise<{ containerId?: string }>;
}) {
const params = await searchParams;
const containers = await listContainers();
return (
<div className="page-content max-w-xl">
<div className="flex items-center gap-3 mb-1">
<a href="/garden" className="btn btn-ghost btn-icon" aria-label="Back to garden">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</a>
<h1 className="page-title">Add Plant</h1>
</div>
<PlantForm containers={containers} defaultContainerId={params.containerId ?? null} />
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useEffect } from "react";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<html>
<body className="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
<h2 className="text-xl font-semibold">Something went wrong</h2>
<button
onClick={reset}
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
>
Try again
</button>
</body>
</html>
);
}
+217 -1
View File
@@ -490,6 +490,8 @@
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
body {
@apply bg-background text-foreground;
@@ -521,6 +523,7 @@
display: grid;
grid-template-columns: 232px 1fr;
height: 100vh;
height: 100dvh;
background: var(--paper);
}
:where(html[data-nav="rail"]) .app {
@@ -725,7 +728,7 @@
}
:where(html[data-nav="bottom"]) .scroll-area,
:where(html[data-nav="fab"]) .scroll-area {
padding: 12px 14px 88px;
padding: 12px 14px 74px;
}
.scroll-area::-webkit-scrollbar {
@@ -1295,3 +1298,216 @@
color: var(--ink-mute);
font-variant-numeric: tabular-nums;
}
/* ── Form controls ──────────────────────────────────────────────── */
.input,
select {
width: 100%;
padding: 7px 10px;
border-radius: var(--r-sm);
border: 1px solid var(--hair-2);
background: var(--card);
color: var(--ink);
font: inherit;
font-size: 13.5px;
line-height: 1.4;
box-shadow: inset 0 1px 2px rgba(31, 27, 22, 0.05);
transition:
border-color 0.12s,
box-shadow 0.12s;
appearance: none;
-webkit-appearance: none;
outline: none;
}
.input:focus,
select:focus {
border-color: var(--ink-soft);
box-shadow:
inset 0 1px 2px rgba(31, 27, 22, 0.05),
0 0 0 2px rgba(31, 27, 22, 0.08);
}
.input::placeholder {
color: var(--ink-faint);
}
select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238a7e6e' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 30px;
cursor: pointer;
}
.dark .input,
.dark select {
background: var(--paper-2);
border-color: var(--hair-2);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.12);
}
.dark .input:focus,
.dark select:focus {
border-color: var(--ink-soft);
box-shadow:
inset 0 1px 2px rgba(0, 0, 0, 0.12),
0 0 0 2px rgba(244, 239, 229, 0.08);
}
/* ─── Bang Counter celebration animations ─────────────────────────────────── */
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
10% {
transform: translateX(-6px) rotate(-1deg);
}
20% {
transform: translateX(6px) rotate(1deg);
}
30% {
transform: translateX(-5px) rotate(-0.8deg);
}
40% {
transform: translateX(5px) rotate(0.8deg);
}
50% {
transform: translateX(-4px) rotate(-0.5deg);
}
60% {
transform: translateX(4px) rotate(0.5deg);
}
70% {
transform: translateX(-2px);
}
80% {
transform: translateX(2px);
}
90% {
transform: translateX(-1px);
}
}
@keyframes countPop {
0% {
transform: scale(1);
}
30% {
transform: scale(1.45);
}
60% {
transform: scale(0.9);
}
80% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
@keyframes emojiFloat {
0% {
transform: translateY(0) scale(1);
opacity: 1;
}
50% {
transform: translateY(-60px) scale(1.2);
opacity: 0.9;
}
100% {
transform: translateY(-120px) scale(0.7);
opacity: 0;
}
}
.animate-shake {
animation: shake 0.35s ease-in-out both;
}
.animate-count-pop {
animation: countPop 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}
.animate-emoji-float {
animation: emojiFloat 1.8s ease-out both;
}
/* ── Calendar min-height CSS variable ───────────────────────────── */
/* Used by calendar-shell so the grid fills the viewport correctly */
/* on both desktop (topbar only) and mobile (topbar + bottom nav). */
:root {
--cal-topbar-h: 57px;
--cal-nav-h: 0px;
}
:where(html[data-nav="bottom"]),
:where(html[data-nav="fab"]) {
--cal-nav-h: 62px;
}
/* ── Mobile overrides (≤ 759 px) ────────────────────────────────── */
@media (max-width: 759px) {
/* Prevent iOS Safari from zooming into inputs (requires ≥ 16 px) */
.input,
input,
select,
textarea {
font-size: 16px;
}
/* Topbar: tighten padding; avatar is redundant (Settings in bottom nav) */
.topbar {
padding: 10px 14px;
gap: 8px;
}
.topbar-actions > .avatar {
display: none;
}
/* Keyboard shortcut hints are meaningless on touch */
.kbd {
display: none;
}
/* Eliminate 300 ms tap delay on interactive controls */
.btn,
.checkbox,
.nav-item,
.list-row,
.fab,
.bottom-nav button,
.bottom-nav a {
touch-action: manipulation;
}
/* Momentum scrolling + prevent page bounce fighting in-app scroll */
.scroll-area {
-webkit-overflow-scrolling: touch;
overscroll-behavior-y: contain;
}
/* Checkbox: keep the 18 px visual but expand the tap area to 44 px */
.checkbox {
position: relative;
flex-shrink: 0;
}
.checkbox::after {
content: "";
position: absolute;
inset: -13px; /* 18 + 2×13 = 44 px tap target */
}
/* Delete button on list rows: always visible on touch (no hover state) */
.list-row-delete {
opacity: 1 !important;
}
}
/* Landscape phone: compress bottom nav to icon-only to reclaim vertical space */
@media (max-width: 759px) and (orientation: landscape) and (max-height: 500px) {
.bottom-nav {
height: 44px;
padding: 2px 8px env(safe-area-inset-bottom, 2px);
}
.bottom-nav .nav-label {
display: none;
}
:where(html[data-nav="bottom"]) .scroll-area {
padding-bottom: 52px;
}
}
+6
View File
@@ -42,6 +42,7 @@ const jetbrains = JetBrains_Mono({
export const viewport: Viewport = {
themeColor: "#1F1B16",
viewportFit: "cover",
};
export const metadata: Metadata = {
@@ -54,6 +55,11 @@ export const metadata: Metadata = {
title: "famapp",
},
icons: {
icon: [
{ url: "/favicon.svg", type: "image/svg+xml" },
{ url: "/icon-32.png", sizes: "32x32", type: "image/png" },
{ url: "/icon-16.png", sizes: "16x16", type: "image/png" },
],
apple: "/icon-180.png",
},
};
-2
View File
@@ -4,7 +4,6 @@ import type { DashboardMeta } from "@/app/d/actions";
import { Sidebar } from "@/components/sidebar";
import { Topbar } from "@/components/topbar";
import { BottomNav } from "@/components/bottom-nav";
import { Fab } from "@/components/fab";
import { NavModeProvider } from "@/components/nav-mode-provider";
const BARE_PREFIXES = ["/s/", "/login"];
@@ -38,7 +37,6 @@ export async function AppShell({ signedIn, navStyle, children }: Props) {
<div className="scroll-area">{children}</div>
</main>
<BottomNav />
<Fab />
<NavModeProvider navStyle={navStyle} />
</div>
);
+11 -11
View File
@@ -1,19 +1,19 @@
"use client";
import { getRegistry } from "@/modules/_core/registry";
import { NavLink } from "@/components/nav-link";
const ITEMS: Array<{ href: string; label: string; icon: string }> = [
{ href: "/", label: "Dashboard", icon: "home" },
{ href: "/calendar", label: "Calendar", icon: "calendar" },
{ href: "/lists", label: "Lists", icon: "list" },
{ href: "/notes", label: "Notes", icon: "note" },
{ href: "/settings", label: "Settings", icon: "settings" },
];
export async function BottomNav() {
const { modules } = getRegistry();
const moduleNav = modules.flatMap((m) => (m.nav ? [m.nav] : []));
const items = [
{ href: "/", label: "Dashboard", icon: "home" },
...moduleNav.map((n) => ({ href: n.href, label: n.label, icon: n.icon ?? "circle" })),
{ href: "/settings", label: "Settings", icon: "settings" },
];
export function BottomNav() {
return (
<nav className="bottom-nav" aria-label="Primary navigation">
{ITEMS.map((it) => (
{items.map((it) => (
<NavLink key={it.href} href={it.href} icon={it.icon} label={it.label} variant="bottom" />
))}
</nav>
+12 -1
View File
@@ -1,12 +1,23 @@
import { cn } from "@/lib/utils";
export function BrandMark({ size = "md", className }: { size?: "sm" | "md"; className?: string }) {
const iconSize = size === "sm" ? 14 : 17;
return (
<span
className={cn("brand-mark", size === "sm" && "brand-mark-sm", className)}
aria-hidden="true"
>
f
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
width={iconSize}
height={iconSize}
fill="currentColor"
>
<polygon points="12,2 1,12 23,12" />
<rect x="3.5" y="11" width="17" height="12" />
<rect x="9.5" y="16" width="5" height="7" rx="1" style={{ fill: "var(--ink)" }} />
</svg>
</span>
);
}
+24
View File
@@ -0,0 +1,24 @@
"use client";
const greetingForHour = (h: number) =>
h < 5 ? "Up early" : h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening";
export function DashboardGreeting({ firstName }: { firstName: string }) {
const now = new Date();
const greeting = greetingForHour(now.getHours());
const today = now.toLocaleDateString(undefined, {
weekday: "long",
month: "long",
day: "numeric",
});
return (
<div>
<h1 className="serif text-[26px] sm:text-[30px] leading-tight tracking-tight">
{greeting}
{firstName && `, ${firstName}`}.
</h1>
<p className="muted mt-1 text-[13px]">{today}</p>
</div>
);
}
+2
View File
@@ -1,6 +1,7 @@
import {
Bell,
Calendar,
Sprout,
CalendarDays,
CheckSquare,
ChevronDown,
@@ -68,6 +69,7 @@ const ICONS: Record<string, React.ComponentType<LucideProps>> = {
phone: Phone,
filter: Filter,
sun: Sun,
sprout: Sprout,
mail: Mail,
menu: Menu,
more: MoreHorizontal,
+7 -1
View File
@@ -29,8 +29,14 @@ const QUICK_ADD_ICON: Record<string, string> = {
"file-plus": "note",
};
function useShortcutLabel() {
if (typeof navigator === "undefined") return "⌘K";
return /Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘K" : "Ctrl+K";
}
export function QuickAddSheet() {
const { sheetOpen, closeSheet, actions } = useQuickAdd();
const shortcut = useShortcutLabel();
const router = useRouter();
const backdropRef = useRef<HTMLDivElement>(null);
@@ -110,7 +116,7 @@ export function QuickAddSheet() {
}}
>
<Sparkles className="size-3" />
<span>Pick what to add or use K to search.</span>
<span>Pick what to add or use {shortcut} to search.</span>
</div>
<div className="max-h-[60vh] overflow-y-auto">
+10 -4
View File
@@ -81,20 +81,26 @@ function SectionHashSync({ pathname }: { pathname: string | null }) {
export function SettingsTabsMobile({ active }: { active: SectionId }) {
const router = useRouter();
return (
<div className="seg w-full overflow-x-auto" style={{ display: "flex" }}>
<div className="grid grid-cols-2 gap-1.5 mb-1">
{SECTIONS.map((s) => (
<button
key={s.id}
type="button"
role="tab"
aria-selected={active === s.id}
aria-current={active === s.id ? "page" : undefined}
className={cn(
"flex items-center gap-2.5 px-3 py-2.5 rounded-[var(--r-md)] border-[0.5px] text-left text-[13px] font-medium transition-colors",
active === s.id
? "bg-[var(--card)] border-[var(--hair-2)] text-[var(--ink)] shadow-[var(--shadow-1)]"
: "border-transparent text-[var(--ink-soft)] hover:bg-[var(--shade)]",
)}
onClick={() => {
const url = new URL(window.location.href);
url.searchParams.set("s", s.id);
router.replace(url.pathname + "?" + url.searchParams.toString(), { scroll: false });
}}
>
{s.label.split(" ")[0]}
<NavIcon name={s.icon} className="size-4 shrink-0" />
<span>{s.label.split(" ")[0]}</span>
</button>
))}
</div>
+18 -9
View File
@@ -19,15 +19,21 @@ export function ShareButton({
const [open, setOpen] = useState(false);
const [shareUrl, setShareUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
function share() {
setError(null);
startTransition(async () => {
const result = await createShareLink(entityType, entityId, {
capabilities: { read: true, write: canWrite },
});
setShareUrl(result.url);
setOpen(true);
try {
const result = await createShareLink(entityType, entityId, {
capabilities: { read: true, write: canWrite },
});
setShareUrl(result.url);
setOpen(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create share link");
}
});
}
@@ -41,10 +47,13 @@ export function ShareButton({
return (
<>
<Button variant="outline" onClick={share} disabled={isPending}>
<Link />
Share
</Button>
<div className="flex flex-col items-end gap-1">
<Button variant="outline" onClick={share} disabled={isPending}>
<Link />
Share
</Button>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { revokeShareLink } from "@/modules/_core/share";
import type { EntityShareLink } from "@/modules/_core/share";
type Props = {
links: EntityShareLink[];
};
export function ShareLinkList({ links }: Props) {
const [isPending, startTransition] = useTransition();
const router = useRouter();
if (links.length === 0) return null;
function handleRevoke(id: string) {
startTransition(async () => {
await revokeShareLink(id);
router.refresh();
});
}
return (
<div className="flex flex-col gap-1 pt-1 border-t border-[var(--ink-faint)]">
<p className="text-xs font-semibold uppercase text-[var(--ink-mute)] tracking-wide">
Active share links ({links.length})
</p>
{links.map((link) => (
<div key={link.id} className="flex items-center justify-between gap-2 text-sm py-0.5">
<div className="flex flex-col">
<span className="text-[var(--ink-mute)]">
Created {new Date(link.createdAt).toLocaleDateString()}
</span>
{link.expiresAt && (
<span className="text-xs text-[var(--ink-mute)]">
Expires {new Date(link.expiresAt).toLocaleDateString()}
</span>
)}
</div>
<button
className="btn btn-ghost btn-sm text-red-500 hover:text-red-600"
onClick={() => handleRevoke(link.id)}
disabled={isPending}
>
Revoke
</button>
</div>
))}
</div>
);
}
+36 -16
View File
@@ -3,25 +3,45 @@
import { useQuickAdd } from "@/components/quick-add-provider";
import { NavIcon } from "@/components/nav-icon";
function useShortcutLabel() {
if (typeof navigator === "undefined") return "⌘K";
return /Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘K" : "Ctrl+K";
}
export function TopbarSearch() {
const { openPalette } = useQuickAdd();
const shortcut = useShortcutLabel();
return (
<button
type="button"
onClick={openPalette}
aria-label="Search (⌘K)"
className="hidden md:flex items-center gap-2 h-8 px-2.5 rounded-md text-[13px]"
style={{
border: "0.5px solid var(--hair-2)",
background: "var(--card)",
color: "var(--ink-mute)",
width: 220,
}}
>
<NavIcon name="search" className="size-3.5" />
<span style={{ flex: 1, textAlign: "left" }}>Search</span>
<span className="kbd">K</span>
</button>
<>
{/* Desktop: pill with shortcut hint */}
<button
type="button"
onClick={openPalette}
aria-label={`Search (${shortcut})`}
className="hidden md:flex items-center gap-2 h-8 px-2.5 rounded-md text-[13px]"
style={{
border: "0.5px solid var(--hair-2)",
background: "var(--card)",
color: "var(--ink-mute)",
width: 220,
}}
>
<NavIcon name="search" className="size-3.5" />
<span style={{ flex: 1, textAlign: "left" }}>Search</span>
<span className="kbd">{shortcut}</span>
</button>
{/* Mobile: icon-only */}
<button
type="button"
onClick={openPalette}
aria-label="Search"
className="flex md:hidden items-center justify-center h-8 w-8 rounded-md"
style={{ color: "var(--ink-mute)" }}
>
<NavIcon name="search" className="size-4" />
</button>
</>
);
}
+8 -1
View File
@@ -4,9 +4,16 @@ import * as coreSchema from "@/modules/_core/schema";
import * as calendarSchema from "@/modules/calendar/schema";
import * as listsSchema from "@/modules/lists/schema";
import * as notesSchema from "@/modules/notes/schema";
import * as gardenSchema from "@/modules/garden/schema";
const client = postgres(process.env["DATABASE_URL"]!);
const schema = { ...coreSchema, ...calendarSchema, ...listsSchema, ...notesSchema };
const schema = {
...coreSchema,
...calendarSchema,
...listsSchema,
...notesSchema,
...gardenSchema,
};
export const db = drizzle(client, { schema });
+29
View File
@@ -0,0 +1,29 @@
import { Client } from "minio";
const rawEndpoint = process.env.MINIO_ENDPOINT ?? "http://localhost:9000";
const endpointUrl = new URL(rawEndpoint);
export const minioClient = new Client({
endPoint: endpointUrl.hostname,
port: endpointUrl.port
? parseInt(endpointUrl.port)
: endpointUrl.protocol === "https:"
? 443
: 80,
useSSL: endpointUrl.protocol === "https:",
accessKey: process.env.MINIO_ROOT_USER ?? "",
secretKey: process.env.MINIO_ROOT_PASSWORD ?? "",
});
export const MINIO_BUCKET = process.env.MINIO_BUCKET ?? "garden";
let bucketReady = false;
export async function ensureBucket(): Promise<void> {
if (bucketReady) return;
const exists = await minioClient.bucketExists(MINIO_BUCKET);
if (!exists) {
await minioClient.makeBucket(MINIO_BUCKET);
}
bucketReady = true;
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { eq } from "drizzle-orm";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { householdMembers, households, users } from "@/modules/_core/schema";
@@ -13,7 +14,7 @@ export interface CurrentSession {
export async function getCurrentSession(): Promise<CurrentSession> {
const session = await auth();
if (!session?.user?.id) throw new Error("Not authenticated");
if (!session?.user?.id) redirect("/login");
const [row] = await db
.select({
+2 -2
View File
@@ -1,8 +1,8 @@
import { NextResponse, type NextRequest } from "next/server";
import { consume } from "@/lib/rate-limit";
const PUBLIC_PREFIXES = ["/api/auth/", "/s/"];
const PUBLIC_PATHS = new Set(["/login"]);
const PUBLIC_PREFIXES = ["/api/auth/", "/s/", "/icon-", "/favicon"];
const PUBLIC_PATHS = new Set(["/login", "/manifest.webmanifest", "/offline.html"]);
const SESSION_COOKIE_NAMES = ["authjs.session-token", "__Secure-authjs.session-token"];
// Token-prefix length used as part of the rate-limit bucket key.
+20
View File
@@ -73,6 +73,26 @@ export function getWidgetMetas(): SerializedWidgetMeta[] {
);
}
// ─── Item toggle hooks ────────────────────────────────────────────────────────
type ItemTogglePayload = {
itemId: string;
metadata: Record<string, unknown>;
done: boolean;
userId: string;
};
type ItemToggleHook = (payload: ItemTogglePayload) => Promise<void>;
const itemToggleHooks = new Map<string, ItemToggleHook>();
export function registerItemToggleHook(id: string, hook: ItemToggleHook): void {
itemToggleHooks.set(id, hook);
}
export async function fireItemToggleHooks(payload: ItemTogglePayload): Promise<void> {
await Promise.allSettled([...itemToggleHooks.values()].map((h) => h(payload)));
}
export function getQuickAdds(): SerializedQuickAddItem[] {
return [...modules.values()].flatMap((manifest) =>
(manifest.quickAdds ?? []).map(({ id, label, icon, url }) => ({
+13 -3
View File
@@ -13,6 +13,8 @@ export async function scheduleReminder(input: {
fireAt: Date;
createdBy: string;
channel?: string;
title?: string;
body?: string;
}) {
await db
.insert(reminders)
@@ -22,12 +24,20 @@ export async function scheduleReminder(input: {
entityId: input.entityId,
fireAt: input.fireAt,
channel: input.channel ?? "auto",
title: input.title ?? null,
body: input.body ?? null,
createdBy: input.createdBy,
firedAt: null,
})
.onConflictDoUpdate({
target: [reminders.entityType, reminders.entityId],
set: { fireAt: input.fireAt, firedAt: null, createdBy: input.createdBy },
set: {
fireAt: input.fireAt,
title: input.title ?? null,
body: input.body ?? null,
firedAt: null,
createdBy: input.createdBy,
},
});
}
@@ -82,8 +92,8 @@ export async function tickReminders() {
if (!reminder.createdBy) return;
try {
await notify(reminder.createdBy, {
title: "Reminder",
body: `You have a reminder`,
title: reminder.title ?? "Reminder",
body: reminder.body ?? "You have a reminder",
url: reminder.entityType === "notes.note" ? `/notes/${reminder.entityId}` : "/",
channels: ["push", "inapp"],
});
+2
View File
@@ -166,6 +166,8 @@ export const reminders = pgTable(
entityId: uuid("entity_id").notNull(),
fireAt: timestamp("fire_at", { withTimezone: true }).notNull(),
channel: text("channel").notNull().default("auto"),
title: text("title"),
body: text("body"),
firedAt: timestamp("fired_at", { withTimezone: true }),
createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+44 -1
View File
@@ -20,7 +20,8 @@ function hashToken(raw: string): string {
}
function buildUrl(token: string): string {
const base = process.env["NEXTAUTH_URL"] ?? "http://localhost:3000";
const base =
process.env["NEXT_PUBLIC_APP_URL"] ?? process.env["AUTH_URL"] ?? "http://localhost:3000";
return `${base}/s/${token}`;
}
@@ -100,6 +101,48 @@ export async function revokeShareLink(id: string): Promise<void> {
export type ActiveShareLink = typeof shareLinks.$inferSelect;
export type EntityShareLink = {
id: string;
createdAt: string;
expiresAt: string | null;
capabilities: ShareLinkCapabilities;
};
export async function getShareLinksForEntity(
entityType: string,
entityId: string,
): Promise<EntityShareLink[]> {
const { household } = await getCurrentSession();
const now = new Date();
const rows = await db
.select({
id: shareLinks.id,
createdAt: shareLinks.createdAt,
expiresAt: shareLinks.expiresAt,
capabilities: shareLinks.capabilities,
})
.from(shareLinks)
.where(
and(
eq(shareLinks.householdId, household.id),
eq(shareLinks.entityType, entityType),
eq(shareLinks.entityId, entityId),
isNull(shareLinks.revokedAt),
),
)
.orderBy(shareLinks.createdAt);
return rows
.filter((r) => !r.expiresAt || r.expiresAt > now)
.map((r) => ({
id: r.id,
createdAt: r.createdAt.toISOString(),
expiresAt: r.expiresAt?.toISOString() ?? null,
capabilities: r.capabilities,
}));
}
export async function getActiveShareLinks(): Promise<ActiveShareLink[]> {
const { household } = await getCurrentSession();
const now = new Date();
@@ -0,0 +1,332 @@
"use client";
import { useState, useTransition, useRef, useCallback } from "react";
import { createPortal } from "react-dom";
import confetti from "canvas-confetti";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import type { BangStatsDto } from "../server/queries";
import { addBang } from "../server/actions";
// ─── Celebration engine ───────────────────────────────────────────────────────
function launchFirework(originX: number, originY: number) {
confetti({
particleCount: 80,
startVelocity: 45,
spread: 360,
origin: { x: originX, y: originY },
colors: ["#ff0", "#f0f", "#0ff", "#f60", "#0f6", "#06f"],
shapes: ["circle", "square"],
gravity: 0.8,
scalar: 1.2,
ticks: 200,
});
}
function launchSideCannon(side: "left" | "right") {
confetti({
particleCount: 120,
angle: side === "left" ? 60 : 120,
spread: 55,
origin: { x: side === "left" ? 0 : 1, y: 0.65 },
colors: ["#ff4e50", "#fc913a", "#f9d423", "#ede574", "#e1f5c4"],
shapes: ["circle", "square", "star"],
scalar: 1.1,
ticks: 300,
});
}
function launchStarBurst() {
const defaults = {
spread: 360,
ticks: 100,
gravity: 0,
decay: 0.94,
startVelocity: 30,
shapes: ["star"] as confetti.Shape[],
colors: ["FFE400", "FFBD00", "E89400", "FFCA6C", "FDFFB8"],
};
function shoot() {
confetti({ ...defaults, particleCount: 40, scalar: 1.2, shapes: ["star"] });
confetti({ ...defaults, particleCount: 15, scalar: 0.75, shapes: ["circle"] });
}
shoot();
setTimeout(shoot, 100);
setTimeout(shoot, 200);
}
function synthFireworkSound() {
try {
const ctx = new AudioContext();
// Rising "pew" tone
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.setValueAtTime(200, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(900, ctx.currentTime + 0.12);
gain.gain.setValueAtTime(0.35, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.18);
osc.start(ctx.currentTime);
osc.stop(ctx.currentTime + 0.18);
// Noise "boom"
const bufferSize = ctx.sampleRate * 0.4;
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
const noise = ctx.createBufferSource();
noise.buffer = buffer;
const noiseGain = ctx.createGain();
const bpFilter = ctx.createBiquadFilter();
bpFilter.type = "bandpass";
bpFilter.frequency.value = 150;
noise.connect(bpFilter);
bpFilter.connect(noiseGain);
noiseGain.connect(ctx.destination);
noiseGain.gain.setValueAtTime(0.6, ctx.currentTime + 0.15);
noiseGain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.6);
noise.start(ctx.currentTime + 0.15);
noise.stop(ctx.currentTime + 0.6);
} catch {
// Autoplay policy blocked — silently skip
}
}
type EmojiParticle = {
id: number;
emoji: string;
x: number;
y: number;
size: number;
delay: number;
};
const CELEBRATION_EMOJIS = [
"💥",
"🎉",
"🔥",
"✨",
"🎊",
"🥳",
"💦",
"😈",
"🎆",
"⭐",
"🍆",
"💫",
"🌟",
"🎇",
"🍒",
];
function EmojiOverlay({ particles }: { particles: EmojiParticle[] }) {
if (particles.length === 0) return null;
return createPortal(
<div className="pointer-events-none fixed inset-0 z-[9999] overflow-hidden" aria-hidden>
{particles.map((p) => (
<span
key={p.id}
className="absolute select-none animate-emoji-float"
style={{
left: `${p.x}%`,
top: `${p.y}%`,
fontSize: `${p.size}rem`,
animationDelay: `${p.delay}ms`,
}}
>
{p.emoji}
</span>
))}
</div>,
document.body,
);
}
// ─── Component ────────────────────────────────────────────────────────────────
type Props = {
stats: BangStatsDto;
maxRecentBangs: number;
};
export function BangWidget({ stats, maxRecentBangs }: Props) {
const [open, setOpen] = useState(false);
const [dateValue, setDateValue] = useState(todayValue());
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [emojiParticles, setEmojiParticles] = useState<EmojiParticle[]>([]);
const [countPop, setCountPop] = useState(false);
const [shake, setShake] = useState(false);
const [flash, setFlash] = useState(false);
const particleIdRef = useRef(0);
const spawnEmojis = useCallback(() => {
const batch: EmojiParticle[] = Array.from({ length: 50 }, () => ({
id: ++particleIdRef.current,
emoji: CELEBRATION_EMOJIS[Math.floor(Math.random() * CELEBRATION_EMOJIS.length)]!,
x: Math.random() * 100,
y: 20 + Math.random() * 70, // keep out of very top/bottom edges so float-up is visible
size: 1.5 + Math.random() * 2,
delay: Math.random() * 600, // stagger spawning over 600ms so it feels like a cascade
}));
setEmojiParticles((prev) => [...prev, ...batch]);
setTimeout(() => {
const ids = new Set(batch.map((p) => p.id));
setEmojiParticles((prev) => prev.filter((p) => !ids.has(p.id)));
}, 2400);
}, []);
const triggerCelebration = useCallback(() => {
setFlash(true);
setTimeout(() => setFlash(false), 120);
setShake(true);
setTimeout(() => setShake(false), 350);
setCountPop(true);
setTimeout(() => setCountPop(false), 400);
synthFireworkSound();
spawnEmojis();
launchStarBurst();
launchSideCannon("left");
setTimeout(() => launchSideCannon("right"), 150);
setTimeout(() => launchFirework(0.3, 0.7), 200);
setTimeout(() => launchFirework(0.7, 0.65), 450);
setTimeout(() => launchFirework(0.5, 0.6), 700);
setTimeout(() => launchFirework(0.2, 0.75), 950);
setTimeout(() => launchFirework(0.8, 0.7), 1100);
setTimeout(() => {
launchSideCannon("left");
launchSideCannon("right");
}, 1300);
setTimeout(() => launchFirework(0.5, 0.5), 1600);
}, [spawnEmojis]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
startTransition(async () => {
try {
await addBang({ occurredOn: dateValue });
setOpen(false);
triggerCelebration();
} catch {
setError("Something went wrong. Try again.");
}
});
}
return (
<div className={`relative flex flex-col gap-3 overflow-hidden ${shake ? "animate-shake" : ""}`}>
{flash && (
<div className="pointer-events-none absolute inset-0 z-20 rounded-xl bg-white/70" />
)}
<EmojiOverlay particles={emojiParticles} />
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span
className={`text-5xl font-bold leading-none tabular-nums ${countPop ? "animate-count-pop" : ""}`}
>
{stats.total}
</span>
<span className="text-xs text-[var(--ink-mute)] mt-1">
{stats.total === 1 ? "bang" : "bangs"}
</span>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<button
type="button"
className="btn btn-primary flex items-center gap-1.5 text-sm px-3 py-1.5"
/>
}
>
<span aria-hidden>💥</span> Add Bang
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Record a bang</DialogTitle>
</DialogHeader>
<form id="bang-form" onSubmit={handleSubmit} className="flex flex-col gap-3 pt-1">
<div className="flex flex-col gap-1">
<label htmlFor="bang-date" className="text-sm font-medium">
Date
</label>
<input
id="bang-date"
type="date"
value={dateValue}
onChange={(e) => setDateValue(e.target.value)}
className="input input-sm"
required
/>
<p className="text-xs text-[var(--ink-mute)]">
Change if you&apos;re documenting a bang from a previous day.
</p>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</form>
<DialogFooter showCloseButton>
<button
type="submit"
form="bang-form"
disabled={isPending}
className="btn btn-primary btn-sm"
>
{isPending ? "Recording…" : "🎆 Record it"}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{stats.recent.length > 0 && (
<div className="flex flex-col gap-0.5 border-t border-[var(--surface-3)] pt-2">
<p className="text-xs font-medium text-[var(--ink-mute)] mb-1">
Last {Math.min(stats.recent.length, maxRecentBangs)}
</p>
{stats.recent.map((bang) => (
<div key={bang.id} className="flex items-center justify-between gap-2 py-0.5">
<span className="text-sm">{formatBangDate(bang.occurredOn)}</span>
{bang.recordedByName && (
<span className="max-w-[40%] truncate text-xs text-[var(--ink-mute)]">
{bang.recordedByName}
</span>
)}
</div>
))}
</div>
)}
{stats.total === 0 && (
<p className="text-sm text-[var(--ink-mute)]">No bangs yet. Add the first one!</p>
)}
</div>
);
}
function todayValue(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function formatBangDate(iso: string): string {
const [year, month, day] = iso.split("-").map(Number);
const d = new Date(year!, month! - 1, day!);
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
+46
View File
@@ -0,0 +1,46 @@
import { z } from "zod";
import type { ModuleManifest, WidgetContext } from "../_core/module";
import { getBangStats } from "./server/queries";
import { BangWidget } from "./components/bang-widget";
const bangWidgetConfigSchema = z.object({
maxRecentBangs: z.number().int().min(1).max(20).default(5),
});
async function BangWidgetServer({ config, ctx }: { config: unknown; ctx: WidgetContext }) {
const parsed = bangWidgetConfigSchema.parse(config);
const stats = await getBangStats(ctx.householdId, parsed.maxRecentBangs);
return <BangWidget stats={stats} maxRecentBangs={parsed.maxRecentBangs} />;
}
const bangsManifest: ModuleManifest = {
id: "bangs",
name: "Bangs",
entities: [
{
type: "bangs.event",
label: { singular: "Bang", plural: "Bangs" },
resolveUrl: () => "/",
renderActivity: (entry) => {
const date = entry.payload?.occurredOn as string | undefined;
return date ? `Recorded a bang on ${date}` : "Recorded a bang";
},
},
],
dashboardWidgets: [
{
id: "bangs.counter",
title: "Bang Counter",
description: "Track and celebrate bangs with your household.",
category: "Fun",
defaultSize: { w: 3, h: 3 },
minSize: { w: 2, h: 2 },
defaultPriority: 60,
configSchema: bangWidgetConfigSchema,
defaultConfig: { maxRecentBangs: 5 },
render: (props) => <BangWidgetServer {...props} />,
},
],
};
export default bangsManifest;
+18
View File
@@ -0,0 +1,18 @@
import { index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { households, users } from "../_core/schema";
export const bangEvents = pgTable(
"bang_events",
{
id: uuid("id").primaryKey().defaultRandom(),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
recordedBy: uuid("recorded_by").references(() => users.id, { onDelete: "set null" }),
occurredOn: text("occurred_on").notNull(), // YYYY-MM-DD, text to avoid timezone confusion
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index("bang_events_household_occurred_idx").on(t.householdId, t.occurredOn)],
);
export type BangEvent = typeof bangEvents.$inferSelect;
+50
View File
@@ -0,0 +1,50 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { logActivity } from "@/modules/_core/activity";
import { bangEvents } from "../schema";
const addBangInput = z.object({
occurredOn: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD")
.optional(),
});
function todayString(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
export async function addBang(input: z.input<typeof addBangInput> = {}) {
const parsed = addBangInput.parse(input);
const { user, household } = await getCurrentSession();
const occurredOn = parsed.occurredOn ?? todayString();
const [bang] = await db
.insert(bangEvents)
.values({
householdId: household.id,
recordedBy: user.id,
occurredOn,
})
.returning();
if (!bang) throw new Error("Bang was not recorded");
await logActivity({
entityType: "bangs.event",
entityId: bang.id,
action: "create",
payload: { occurredOn },
});
revalidatePath("/");
revalidatePath("/d/[slug]", "page");
return bang;
}
+39
View File
@@ -0,0 +1,39 @@
import { count, desc, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { users } from "@/modules/_core/schema";
import { bangEvents } from "../schema";
export type BangStatsDto = {
total: number;
recent: RecentBangDto[];
};
export type RecentBangDto = {
id: string;
occurredOn: string;
recordedByName: string | null;
};
export async function getBangStats(householdId: string, limit: number): Promise<BangStatsDto> {
const [totalRow] = await db
.select({ total: count() })
.from(bangEvents)
.where(eq(bangEvents.householdId, householdId));
const recent = await db
.select({
id: bangEvents.id,
occurredOn: bangEvents.occurredOn,
recordedByName: users.name,
})
.from(bangEvents)
.leftJoin(users, eq(bangEvents.recordedBy, users.id))
.where(eq(bangEvents.householdId, householdId))
.orderBy(desc(bangEvents.occurredOn), desc(bangEvents.createdAt))
.limit(limit);
return {
total: totalRow?.total ?? 0,
recent,
};
}
+371 -252
View File
@@ -4,10 +4,11 @@ import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import timeGridPlugin from "@fullcalendar/timegrid";
import listPlugin from "@fullcalendar/list";
import type { DateSelectArg, EventClickArg, EventDropArg } from "@fullcalendar/core";
import type { EventResizeDoneArg } from "@fullcalendar/interaction";
import { CalendarPlus, Check, Eye, EyeOff, Plus, Trash2 } from "lucide-react";
import { useMemo, useState, useTransition } from "react";
import { CalendarPlus, Check, Eye, EyeOff, Plus, Trash2, X } from "lucide-react";
import { useMemo, useState, useEffect, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -44,6 +45,8 @@ type EventDraft = {
remindMinutesBefore: number | null;
};
type MobileView = "day" | "week" | "agenda";
const DEFAULT_COLOR = "#B85C3C";
const VIEW_MAP: Record<CalView, string> = {
@@ -52,8 +55,13 @@ const VIEW_MAP: Record<CalView, string> = {
day: "timeGridDay",
};
const MOBILE_VIEW_MAP: Record<MobileView, string> = {
day: "timeGridDay",
week: "timeGridWeek",
agenda: "listWeek",
};
function withAlpha(hex: string, alpha: number): string {
// Accept #RGB / #RRGGBB / non-hex (return as-is for non-hex e.g. var(--))
if (!hex.startsWith("#")) return hex;
let h = hex.slice(1);
if (h.length === 3)
@@ -88,6 +96,17 @@ export function CalendarShell({
);
const [lastCalendarId, setLastCalendarId] = useState(calendars[0]?.id ?? "");
const [isPending, startTransition] = useTransition();
const [isMobile, setIsMobile] = useState(false);
const [showCalendarsPanel, setShowCalendarsPanel] = useState(false);
const [mobileView, setMobileView] = useState<MobileView>("day");
useEffect(() => {
const mq = window.matchMedia("(max-width: 759px)");
const apply = () => setIsMobile(mq.matches);
apply();
mq.addEventListener("change", apply);
return () => mq.removeEventListener("change", apply);
}, []);
const defaultCalendarId = calendarRows.some((calendar) => calendar.id === lastCalendarId)
? lastCalendarId
@@ -289,106 +308,49 @@ export function CalendarShell({
});
}
return (
<div className="grid min-h-[calc(100vh-57px)] grid-cols-1 lg:grid-cols-[280px_1fr]">
<aside className="border-b bg-sidebar p-4 lg:border-r lg:border-b-0">
<div className="mb-4 flex items-center justify-between">
<h1 className="text-xl font-semibold">Calendar</h1>
<Button size="icon-sm" variant="outline" onClick={() => openNewEvent()}>
<Plus />
<span className="sr-only">New event</span>
</Button>
</div>
<div className="space-y-2">
{calendarRows.map((calendar) => (
<div key={calendar.id} className="rounded-lg border bg-background p-2">
<div className="flex items-center gap-2">
<button
type="button"
className="grid size-7 place-items-center rounded-md border"
onClick={() => toggleCalendar(calendar.id)}
aria-label={`${visibleIds.has(calendar.id) ? "Hide" : "Show"} ${calendar.name}`}
>
{visibleIds.has(calendar.id) ? <Eye /> : <EyeOff />}
</button>
<span
className="size-3 rounded-full"
style={{ backgroundColor: calendar.color ?? DEFAULT_COLOR }}
/>
<Input
aria-label={`${calendar.name} name`}
value={calendar.name}
onChange={(event) => updateCalendarName(calendar, event.target.value)}
onBlur={() => commitCalendarName(calendar)}
/>
</div>
<div className="mt-2 grid grid-cols-[1fr_1fr_auto] gap-2">
<Input
aria-label={`${calendar.name} color`}
type="color"
value={calendar.color ?? DEFAULT_COLOR}
onChange={(event) => updateCalendar(calendar, { color: event.target.value })}
/>
<Select
value={calendar.visibility}
onValueChange={(value) =>
updateCalendar(calendar, {
visibility: value as "private" | "household",
})
}
>
<SelectTrigger aria-label={`${calendar.name} visibility`}>
<SelectValue>
{calendar.visibility === "household" ? "Household" : "Private"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="household">Household</SelectItem>
<SelectItem value="private">Private</SelectItem>
</SelectContent>
</Select>
<Button
size="icon"
variant="ghost"
onClick={() => removeCalendar(calendar.id)}
aria-label={`Delete ${calendar.name}`}
>
<Trash2 />
</Button>
</div>
</div>
))}
</div>
<div className="mt-4 rounded-lg border bg-background p-3">
<div className="mb-3 flex items-center gap-2 text-sm font-medium">
<CalendarPlus className="size-4" />
New calendar
</div>
<div className="space-y-2">
<Label htmlFor="calendar-name">Name</Label>
<Input
id="calendar-name"
value={calendarName}
onChange={(event) => setCalendarName(event.target.value)}
/>
<div className="grid grid-cols-[1fr_1fr] gap-2">
// Shared calendar list + new-calendar form rendered in both the desktop aside
// and the mobile bottom-sheet panel.
const calendarsPanelContent = (
<>
<div className="space-y-2">
{calendarRows.map((calendar) => (
<div key={calendar.id} className="rounded-lg border bg-background p-2">
<div className="flex items-center gap-2">
<button
type="button"
className="grid size-7 place-items-center rounded-md border"
onClick={() => toggleCalendar(calendar.id)}
aria-label={`${visibleIds.has(calendar.id) ? "Hide" : "Show"} ${calendar.name}`}
>
{visibleIds.has(calendar.id) ? <Eye /> : <EyeOff />}
</button>
<span
className="size-3 rounded-full"
style={{ backgroundColor: calendar.color ?? DEFAULT_COLOR }}
/>
<Input
aria-label="Calendar color"
aria-label={`${calendar.name} name`}
value={calendar.name}
onChange={(event) => updateCalendarName(calendar, event.target.value)}
onBlur={() => commitCalendarName(calendar)}
/>
</div>
<div className="mt-2 grid grid-cols-[1fr_1fr_auto] gap-2">
<Input
aria-label={`${calendar.name} color`}
type="color"
value={calendarColor}
onChange={(event) => setCalendarColorValue(event.target.value)}
value={calendar.color ?? DEFAULT_COLOR}
onChange={(event) => updateCalendar(calendar, { color: event.target.value })}
/>
<Select
value={calendarVisibility}
value={calendar.visibility}
onValueChange={(value) =>
setCalendarVisibilityValue(value as "private" | "household")
updateCalendar(calendar, { visibility: value as "private" | "household" })
}
>
<SelectTrigger aria-label="Calendar visibility">
<SelectTrigger aria-label={`${calendar.name} visibility`}>
<SelectValue>
{calendarVisibility === "household" ? "Household" : "Private"}
{calendar.visibility === "household" ? "Household" : "Private"}
</SelectValue>
</SelectTrigger>
<SelectContent>
@@ -396,179 +358,336 @@ export function CalendarShell({
<SelectItem value="private">Private</SelectItem>
</SelectContent>
</Select>
<Button
size="icon"
variant="ghost"
onClick={() => removeCalendar(calendar.id)}
aria-label={`Delete ${calendar.name}`}
>
<Trash2 />
</Button>
</div>
<Button className="w-full" onClick={addCalendar} disabled={!calendarName}>
<Check />
Create calendar
</Button>
</div>
))}
</div>
<div className="mt-4 rounded-lg border bg-background p-3">
<div className="mb-3 flex items-center gap-2 text-sm font-medium">
<CalendarPlus className="size-4" />
New calendar
</div>
<div className="space-y-2">
<Label htmlFor="calendar-name">Name</Label>
<Input
id="calendar-name"
value={calendarName}
onChange={(event) => setCalendarName(event.target.value)}
/>
<div className="grid grid-cols-[1fr_1fr] gap-2">
<Input
aria-label="Calendar color"
type="color"
value={calendarColor}
onChange={(event) => setCalendarColorValue(event.target.value)}
/>
<Select
value={calendarVisibility}
onValueChange={(value) =>
setCalendarVisibilityValue(value as "private" | "household")
}
>
<SelectTrigger aria-label="Calendar visibility">
<SelectValue>
{calendarVisibility === "household" ? "Household" : "Private"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="household">Household</SelectItem>
<SelectItem value="private">Private</SelectItem>
</SelectContent>
</Select>
</div>
<Button className="w-full" onClick={addCalendar} disabled={!calendarName}>
<Check />
Create calendar
</Button>
</div>
</div>
</>
);
const fcEvents = visibleEvents.map((event) => {
const color =
calendarRows.find((calendar) => calendar.id === event.calendarId)?.color ?? DEFAULT_COLOR;
return {
id: event.id,
title: event.title,
start: event.startAt,
end: event.endAt,
allDay: event.allDay,
backgroundColor: withAlpha(color, 0.14),
borderColor: color,
textColor: "var(--ink-2)",
extendedProps: { calendarId: event.calendarId, color },
};
});
// Shared event form content — rendered inside both the desktop centered modal
// and the mobile bottom-sheet.
const eventFormContent = selectedEvent && (
<div className="grid gap-3">
<Label htmlFor="event-title">Title</Label>
<Input
id="event-title"
value={selectedEvent.title}
onChange={(event) => setSelectedEvent({ ...selectedEvent, title: event.target.value })}
/>
<Label htmlFor="event-calendar">Calendar</Label>
<Select
value={selectedEvent.calendarId}
onValueChange={(value) => setSelectedEvent({ ...selectedEvent, calendarId: value ?? "" })}
>
<SelectTrigger id="event-calendar">
<SelectValue>
{calendarRows.find((c) => c.id === selectedEvent.calendarId)?.name ??
"Select a calendar"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{calendarRows.map((calendar) => (
<SelectItem key={calendar.id} value={calendar.id}>
{calendar.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="event-start">Start</Label>
<Input
id="event-start"
type="datetime-local"
value={selectedEvent.startAt}
onChange={(event) =>
setSelectedEvent({ ...selectedEvent, startAt: event.target.value })
}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="event-end">End</Label>
<Input
id="event-end"
type="datetime-local"
value={selectedEvent.endAt}
onChange={(event) => setSelectedEvent({ ...selectedEvent, endAt: event.target.value })}
/>
</div>
</div>
<Label htmlFor="event-location">Location</Label>
<Input
id="event-location"
value={selectedEvent.location}
onChange={(event) => setSelectedEvent({ ...selectedEvent, location: event.target.value })}
/>
<Label htmlFor="event-notes">Notes</Label>
<textarea
id="event-notes"
className="min-h-20 rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
value={selectedEvent.notes}
onChange={(event) => setSelectedEvent({ ...selectedEvent, notes: event.target.value })}
/>
{!selectedEvent.id && (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="size-4 cursor-pointer"
checked={selectedEvent.remindMinutesBefore !== null}
onChange={(e) =>
setSelectedEvent({
...selectedEvent,
remindMinutesBefore: e.target.checked ? 30 : null,
})
}
/>
Remind me 30 min before
</label>
)}
<div className="flex items-center justify-between gap-2 pt-2">
<div className="flex gap-2">
{selectedEvent.id && (
<Button variant="destructive" onClick={removeSelectedEvent}>
Delete event
</Button>
)}
</div>
<div className="flex gap-2">
{selectedEvent.id && (
<ShareButton entityType="calendar.event" entityId={selectedEvent.id} />
)}
<Button onClick={saveSelectedEvent} disabled={!selectedEvent.title || isPending}>
Save event
</Button>
</div>
</div>
</div>
);
return (
<div
className="grid grid-cols-1 lg:grid-cols-[280px_1fr]"
style={{ minHeight: "calc(100dvh - var(--cal-topbar-h) - var(--cal-nav-h))" }}
>
{/* Desktop aside — hidden on mobile */}
<aside
className={`${isMobile ? "hidden" : ""} border-b bg-sidebar p-4 lg:border-r lg:border-b-0`}
>
<div className="mb-4 flex items-center justify-between">
<h1 className="text-xl font-semibold">Calendar</h1>
<Button size="icon-sm" variant="outline" onClick={() => openNewEvent()}>
<Plus />
<span className="sr-only">New event</span>
</Button>
</div>
{calendarsPanelContent}
</aside>
<section className="fc-skin min-w-0 p-4">
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView={VIEW_MAP[defaultView]}
headerToolbar={{
left: "prev,next today",
center: "title",
right: "dayGridMonth,timeGridWeek,timeGridDay",
}}
buttonText={{
today: "Today",
month: "Month",
week: "Week",
day: "Day",
}}
dayHeaderFormat={{ weekday: "short" }}
selectable
editable
eventResizableFromStart
select={handleDateSelect}
eventClick={openExistingEvent}
eventDrop={moveEvent}
eventResize={moveEvent}
events={visibleEvents.map((event) => {
const color =
calendarRows.find((calendar) => calendar.id === event.calendarId)?.color ??
DEFAULT_COLOR;
return {
id: event.id,
title: event.title,
start: event.startAt,
end: event.endAt,
allDay: event.allDay,
backgroundColor: withAlpha(color, 0.14),
borderColor: color,
textColor: "var(--ink-2)",
extendedProps: { calendarId: event.calendarId, color },
};
})}
eventClassNames={(arg) => {
const id = String(arg.event.extendedProps["calendarId"] ?? "");
return id ? [`fc-cal-${id.slice(0, 8)}`] : [];
}}
height="auto"
/>
{/* Calendar section */}
<section className="fc-skin flex min-w-0 flex-col">
{/* Mobile toolbar: view switcher + calendars button + new event */}
{isMobile && (
<div
className="flex items-center gap-2 border-b px-3 py-2"
style={{ borderColor: "var(--hair)" }}
>
<div className="seg flex-1">
{(["day", "week", "agenda"] as MobileView[]).map((v) => (
<button
key={v}
type="button"
aria-selected={mobileView === v}
onClick={() => setMobileView(v)}
>
{v.charAt(0).toUpperCase() + v.slice(1)}
</button>
))}
</div>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => setShowCalendarsPanel(true)}
>
<Eye size={14} />
Cals
</button>
<button type="button" className="btn btn-primary btn-sm" onClick={() => openNewEvent()}>
<Plus size={14} />
New
</button>
</div>
)}
<div className="flex-1 p-4">
<FullCalendar
key={isMobile ? `mobile-${mobileView}` : `desktop-${defaultView}`}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin]}
initialView={isMobile ? MOBILE_VIEW_MAP[mobileView] : VIEW_MAP[defaultView]}
headerToolbar={
isMobile
? { left: "prev,next", center: "title", right: "today" }
: {
left: "prev,next today",
center: "title",
right: "dayGridMonth,timeGridWeek,timeGridDay",
}
}
buttonText={{ today: "Today", month: "Month", week: "Week", day: "Day", list: "List" }}
dayHeaderFormat={{ weekday: "short" }}
selectable={!isMobile}
editable={!isMobile}
eventResizableFromStart={!isMobile}
select={handleDateSelect}
eventClick={openExistingEvent}
eventDrop={moveEvent}
eventResize={moveEvent}
events={fcEvents}
eventClassNames={(arg) => {
const id = String(arg.event.extendedProps["calendarId"] ?? "");
return id ? [`fc-cal-${id.slice(0, 8)}`] : [];
}}
height="auto"
/>
</div>
</section>
{/* Mobile: calendars bottom sheet */}
{isMobile && showCalendarsPanel && (
<div
className="fixed inset-0 z-[45] bg-black/40"
onClick={() => setShowCalendarsPanel(false)}
>
<div
className="absolute inset-x-0 overflow-auto rounded-t-[var(--r-xl)]"
style={{
bottom: "var(--cal-nav-h)",
maxHeight: "72dvh",
background: "var(--card)",
paddingBottom: "env(safe-area-inset-bottom)",
}}
onClick={(e) => e.stopPropagation()}
>
<div
className="flex items-center justify-between border-b p-4"
style={{ borderColor: "var(--hair)" }}
>
<h3 className="serif text-[16px] font-medium">Calendars</h3>
<button
type="button"
className="btn btn-ghost btn-icon"
aria-label="Close calendars panel"
onClick={() => setShowCalendarsPanel(false)}
>
<X size={16} />
</button>
</div>
<div className="p-4">{calendarsPanelContent}</div>
</div>
</div>
)}
{/* Event form — centered modal on desktop, bottom sheet on mobile */}
{selectedEvent && (
<div className="fixed inset-0 z-50 grid place-items-center bg-black/20 p-4">
<div className="w-full max-w-lg rounded-lg bg-popover p-4 text-popover-foreground shadow-lg">
<div
className="fixed inset-0 z-50 flex bg-black/20"
style={
isMobile
? { alignItems: "flex-end" }
: { alignItems: "center", justifyContent: "center" }
}
onClick={(e) => {
if (e.target === e.currentTarget) setSelectedEvent(null);
}}
>
<div
className="overflow-auto bg-popover text-popover-foreground shadow-lg"
style={{
borderRadius: isMobile ? "var(--r-xl) var(--r-xl) 0 0" : "var(--r-lg)",
width: isMobile ? "100%" : undefined,
maxWidth: isMobile ? undefined : "512px",
maxHeight: "90dvh",
padding: "1rem",
paddingBottom: isMobile ? "calc(1rem + env(safe-area-inset-bottom))" : "1rem",
}}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">
{selectedEvent.id ? "Edit event" : "New event"}
</h2>
<Button size="icon-sm" variant="ghost" onClick={() => setSelectedEvent(null)}>
<span aria-hidden>×</span>
<X size={16} />
<span className="sr-only">Close</span>
</Button>
</div>
<div className="grid gap-3">
<Label htmlFor="event-title">Title</Label>
<Input
id="event-title"
value={selectedEvent.title}
onChange={(event) =>
setSelectedEvent({ ...selectedEvent, title: event.target.value })
}
/>
<Label htmlFor="event-calendar">Calendar</Label>
<Select
value={selectedEvent.calendarId}
onValueChange={(value) =>
setSelectedEvent({ ...selectedEvent, calendarId: value ?? "" })
}
>
<SelectTrigger id="event-calendar">
<SelectValue>
{calendarRows.find((c) => c.id === selectedEvent.calendarId)?.name ??
"Select a calendar"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{calendarRows.map((calendar) => (
<SelectItem key={calendar.id} value={calendar.id}>
{calendar.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="event-start">Start</Label>
<Input
id="event-start"
type="datetime-local"
value={selectedEvent.startAt}
onChange={(event) =>
setSelectedEvent({ ...selectedEvent, startAt: event.target.value })
}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="event-end">End</Label>
<Input
id="event-end"
type="datetime-local"
value={selectedEvent.endAt}
onChange={(event) =>
setSelectedEvent({ ...selectedEvent, endAt: event.target.value })
}
/>
</div>
</div>
<Label htmlFor="event-location">Location</Label>
<Input
id="event-location"
value={selectedEvent.location}
onChange={(event) =>
setSelectedEvent({ ...selectedEvent, location: event.target.value })
}
/>
<Label htmlFor="event-notes">Notes</Label>
<textarea
id="event-notes"
className="min-h-20 rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
value={selectedEvent.notes}
onChange={(event) =>
setSelectedEvent({ ...selectedEvent, notes: event.target.value })
}
/>
{!selectedEvent.id && (
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="size-4 cursor-pointer"
checked={selectedEvent.remindMinutesBefore !== null}
onChange={(e) =>
setSelectedEvent({
...selectedEvent,
remindMinutesBefore: e.target.checked ? 30 : null,
})
}
/>
Remind me 30 min before
</label>
)}
<div className="flex items-center justify-between gap-2 pt-2">
<div className="flex gap-2">
{selectedEvent.id && (
<Button variant="destructive" onClick={removeSelectedEvent}>
Delete event
</Button>
)}
</div>
<div className="flex gap-2">
{selectedEvent.id && (
<ShareButton entityType="calendar.event" entityId={selectedEvent.id} />
)}
<Button onClick={saveSelectedEvent} disabled={!selectedEvent.title || isPending}>
Save event
</Button>
</div>
</div>
</div>
{eventFormContent}
</div>
</div>
)}
@@ -0,0 +1,45 @@
import type { CareLogDto } from "../server/queries";
const CARE_ICONS: Record<string, string> = {
watering: "💧",
fertilizing: "🌱",
repotting: "🪴",
pruning: "✂️",
"pest-control": "🐛",
other: "📋",
};
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return "Today";
if (days === 1) return "Yesterday";
return `${days} days ago`;
}
type Props = {
logs: CareLogDto[];
};
export function CareHistoryList({ logs }: Props) {
if (logs.length === 0) {
return <p className="text-sm text-[var(--ink-mute)]">No care events logged yet.</p>;
}
return (
<ul className="flex flex-col gap-2">
{logs.map((log) => (
<li key={log.id} className="flex gap-3 items-start text-sm">
<span className="text-lg leading-none mt-0.5" aria-hidden>
{CARE_ICONS[log.careType] ?? "📋"}
</span>
<div className="flex flex-col gap-0.5">
<span className="font-medium capitalize">{log.careType}</span>
<span className="text-xs text-[var(--ink-mute)]">{timeAgo(log.performedAt)}</span>
{log.notes && <p className="text-xs text-[var(--ink-mute)]">{log.notes}</p>}
</div>
</li>
))}
</ul>
);
}
@@ -0,0 +1,97 @@
"use client";
import { useState, useTransition } from "react";
import { logCare } from "../server/actions";
const CARE_TYPES = ["watering", "fertilizing", "repotting", "pruning", "pest-control", "other"];
type Props = {
plantId: string;
defaultCareType?: string;
onSuccess?: () => void;
};
export function CareLogForm({ plantId, defaultCareType = "watering", onSuccess }: Props) {
const [careType, setCareType] = useState(defaultCareType);
const [notes, setNotes] = useState("");
const [performedAt, setPerformedAt] = useState("");
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
startTransition(async () => {
try {
await logCare({
plantId,
careType,
notes: notes || null,
performedAt: performedAt || undefined,
});
setNotes("");
setPerformedAt("");
onSuccess?.();
} catch {
setError("Failed to log care. Please try again.");
}
});
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label htmlFor="care-type" className="text-sm font-medium">
Care type
</label>
<select
id="care-type"
value={careType}
onChange={(e) => setCareType(e.target.value)}
className="input input-sm"
required
>
{CARE_TYPES.map((t) => (
<option key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1).replace("-", " ")}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="performed-at" className="text-sm font-medium">
Date &amp; time (optional defaults to now)
</label>
<input
id="performed-at"
type="datetime-local"
value={performedAt}
onChange={(e) => setPerformedAt(e.target.value)}
className="input input-sm"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="care-notes" className="text-sm font-medium">
Notes (optional)
</label>
<textarea
id="care-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
maxLength={2000}
className="input input-sm resize-none"
placeholder="Any observations…"
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<button type="submit" className="btn btn-primary btn-sm self-start" disabled={isPending}>
{isPending ? "Logging…" : "Log care"}
</button>
</form>
);
}
@@ -0,0 +1,272 @@
"use client";
import Link from "next/link";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
deleteCareSchedule,
scheduleOnCalendar,
toggleCareSchedule,
upsertCareSchedule,
} from "../server/actions";
import type { CalendarDto } from "../server/calendar-bridge";
import type { CareScheduleDto } from "../server/queries";
const CARE_TYPES = ["watering", "fertilizing", "repotting", "pruning", "pest-control", "other"];
type Props = {
plantId: string;
schedules: CareScheduleDto[];
calendars: CalendarDto[];
};
function daysLabel(n: number | null): string {
if (n === null) return "No due date";
if (n < 0) return `${Math.abs(n)} day${Math.abs(n) !== 1 ? "s" : ""} overdue`;
if (n === 0) return "Due today";
return `Due in ${n} day${n !== 1 ? "s" : ""}`;
}
export function CareScheduleEditor({ plantId, schedules, calendars }: Props) {
const [showForm, setShowForm] = useState(false);
const [careType, setCareType] = useState("watering");
const [intervalDays, setIntervalDays] = useState("7");
const [formError, setFormError] = useState<string | null>(null);
const [calendarOpenId, setCalendarOpenId] = useState<string | null>(null);
const [selectedCalendarId, setSelectedCalendarId] = useState(calendars[0]?.id ?? "");
const [reminderMinutes, setReminderMinutes] = useState("");
const [calendarSuccess, setCalendarSuccess] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const router = useRouter();
function handleAddSchedule(e: React.FormEvent) {
e.preventDefault();
const days = parseInt(intervalDays, 10);
if (isNaN(days) || days < 1 || days > 365) {
setFormError("Interval must be between 1 and 365 days.");
return;
}
setFormError(null);
startTransition(async () => {
try {
await upsertCareSchedule({ plantId, careType, intervalDays: days });
setShowForm(false);
setCareType("watering");
setIntervalDays("7");
router.refresh();
} catch {
setFormError("Failed to save schedule.");
}
});
}
function handleDelete(id: string) {
startTransition(async () => {
await deleteCareSchedule({ id });
router.refresh();
});
}
function handleToggle(id: string, enabled: boolean) {
startTransition(async () => {
await toggleCareSchedule({ id, enabled });
router.refresh();
});
}
function handleScheduleOnCalendar(scheduleId: string) {
if (!selectedCalendarId) return;
startTransition(async () => {
try {
await scheduleOnCalendar({
scheduleId,
calendarId: selectedCalendarId,
reminderMinutesBefore: reminderMinutes ? parseInt(reminderMinutes, 10) : undefined,
});
setCalendarOpenId(null);
setCalendarSuccess(scheduleId);
setTimeout(() => setCalendarSuccess(null), 4000);
} catch (err) {
setFormError(err instanceof Error ? err.message : "Failed to add to calendar.");
}
});
}
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-[var(--ink-mute)] uppercase tracking-wide">
Schedules
</p>
<button
className="btn btn-ghost btn-xs"
onClick={() => setShowForm((v) => !v)}
disabled={isPending}
>
{showForm ? "Cancel" : "Add schedule"}
</button>
</div>
{showForm && (
<form
onSubmit={handleAddSchedule}
className="flex flex-col gap-2 p-3 bg-[var(--surface-2)] rounded-lg"
>
<div className="flex gap-2">
<div className="flex flex-col gap-1 flex-1">
<label htmlFor="sched-care-type" className="text-xs font-medium">
Care type
</label>
<select
id="sched-care-type"
value={careType}
onChange={(e) => setCareType(e.target.value)}
className="input input-xs"
>
{CARE_TYPES.map((t) => (
<option key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1).replace("-", " ")}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1 w-28">
<label htmlFor="sched-interval" className="text-xs font-medium">
Interval (days)
</label>
<input
id="sched-interval"
type="number"
min={1}
max={365}
value={intervalDays}
onChange={(e) => setIntervalDays(e.target.value)}
className="input input-xs"
required
/>
</div>
</div>
{formError && <p className="text-xs text-red-500">{formError}</p>}
<button type="submit" className="btn btn-primary btn-xs self-start" disabled={isPending}>
{isPending ? "Saving…" : "Add"}
</button>
</form>
)}
{schedules.length === 0 && !showForm && (
<p className="text-sm text-[var(--ink-mute)]">No schedules yet.</p>
)}
{schedules.map((s) => (
<div key={s.id} className="flex flex-col gap-1">
<div
className={`flex items-center justify-between gap-2 p-2 rounded-lg border ${
s.isOverdue
? "border-red-300 bg-red-50 dark:bg-red-950/20"
: "border-[var(--ink-faint)]"
}`}
>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium capitalize">{s.careType}</span>
<span className="text-xs text-[var(--ink-mute)]">
Every {s.intervalDays} days · {daysLabel(s.daysUntilDue)}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{calendars.length > 0 && s.nextDueAt && (
<button
onClick={() => setCalendarOpenId(calendarOpenId === s.id ? null : s.id)}
disabled={isPending}
title="Add to calendar"
className="text-xs text-[var(--ink-mute)] hover:text-[var(--ink)]"
>
📅
</button>
)}
<button
onClick={() => handleToggle(s.id, !s.enabled)}
disabled={isPending}
title={s.enabled ? "Disable" : "Enable"}
className={`text-xs px-2 py-0.5 rounded-full border transition-colors ${
s.enabled
? "border-green-400 text-green-700 dark:text-green-400"
: "border-[var(--ink-faint)] text-[var(--ink-mute)]"
}`}
>
{s.enabled ? "Active" : "Paused"}
</button>
<button
onClick={() => handleDelete(s.id)}
disabled={isPending}
title="Delete schedule"
className="text-[var(--ink-mute)] hover:text-red-500 text-xs"
>
</button>
</div>
</div>
{calendarOpenId === s.id && (
<div className="flex flex-col gap-2 p-3 bg-[var(--surface-2)] rounded-lg border border-[var(--ink-faint)] ml-2">
<div className="flex gap-2">
<div className="flex flex-col gap-1 flex-1">
<label htmlFor={`cal-select-${s.id}`} className="text-xs font-medium">
Calendar
</label>
<select
id={`cal-select-${s.id}`}
value={selectedCalendarId}
onChange={(e) => setSelectedCalendarId(e.target.value)}
className="input input-xs"
>
{calendars.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1 w-28">
<label htmlFor={`remind-${s.id}`} className="text-xs font-medium">
Remind (min)
</label>
<input
id={`remind-${s.id}`}
type="number"
min={0}
max={1440}
placeholder="optional"
value={reminderMinutes}
onChange={(e) => setReminderMinutes(e.target.value)}
className="input input-xs"
/>
</div>
</div>
<div className="flex gap-2 items-center">
<button
onClick={() => handleScheduleOnCalendar(s.id)}
disabled={isPending || !selectedCalendarId}
className="btn btn-primary btn-xs"
>
{isPending ? "Scheduling…" : "Schedule"}
</button>
<button onClick={() => setCalendarOpenId(null)} className="btn btn-ghost btn-xs">
Cancel
</button>
</div>
</div>
)}
{calendarSuccess === s.id && (
<p className="text-xs text-green-600 dark:text-green-400 ml-2">
Event added to calendar.{" "}
<Link href="/calendar" className="underline">
View in calendar
</Link>
</p>
)}
</div>
))}
</div>
);
}
@@ -0,0 +1,266 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { ShareButton } from "@/components/share-button";
import { ShareLinkList } from "@/components/share-link-list";
import type { EntityShareLink } from "@/modules/_core/share";
import {
addContainerImage,
deleteContainer,
removeContainerImage,
setContainerPrimaryImage,
} from "../server/actions";
import { ContainerForm } from "./container-form";
import type { ContainerDetailDto } from "../server/queries";
type Tab = "info" | "gallery";
type Props = {
container: ContainerDetailDto;
shareLinks: EntityShareLink[];
};
export function ContainerDetail({ container, shareLinks }: Props) {
const [tab, setTab] = useState<Tab>("info");
const [editing, setEditing] = useState(false);
const [confirming, setConfirming] = useState(false);
const [isPending, startTransition] = useTransition();
const [galleryError, setGalleryError] = useState<string | null>(null);
const [uploadingImage, setUploadingImage] = useState(false);
const router = useRouter();
function handleDelete() {
startTransition(async () => {
await deleteContainer({ id: container.id });
router.push("/garden");
router.refresh();
});
}
async function handleImageUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setGalleryError(null);
setUploadingImage(true);
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/uploads", { method: "POST", body: fd });
if (!res.ok) throw new Error("Upload failed");
const data = (await res.json()) as { url: string };
await addContainerImage({ id: container.id, url: data.url });
router.refresh();
} catch {
setGalleryError("Image upload failed.");
} finally {
setUploadingImage(false);
e.target.value = "";
}
}
function handleRemoveImage(url: string) {
startTransition(async () => {
await removeContainerImage({ id: container.id, url });
router.refresh();
});
}
function handleSetPrimary(url: string) {
startTransition(async () => {
await setContainerPrimaryImage({ id: container.id, url });
router.refresh();
});
}
return (
<div className="flex flex-col gap-6">
{container.coverImageUrl && (
<img
src={container.coverImageUrl}
alt=""
className="w-full max-h-48 object-cover rounded-lg"
/>
)}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">{container.name}</h1>
<p className="text-sm text-[var(--ink-mute)] capitalize mt-1">{container.type}</p>
{container.locationNotes && <p className="text-sm mt-2">{container.locationNotes}</p>}
</div>
<div className="flex flex-col items-end gap-2 shrink-0">
<div className="flex gap-2">
<ShareButton entityType="garden.container" entityId={container.id} />
<button className="btn btn-ghost btn-sm" onClick={() => setEditing((v) => !v)}>
Edit
</button>
{confirming ? (
<div className="flex gap-1">
<button
className="btn btn-danger btn-sm"
onClick={handleDelete}
disabled={isPending}
>
Confirm
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setConfirming(false)}
disabled={isPending}
>
Cancel
</button>
</div>
) : (
<button className="btn btn-ghost btn-sm" onClick={() => setConfirming(true)}>
Delete
</button>
)}
</div>
<ShareLinkList links={shareLinks} />
</div>
</div>
{editing && (
<div className="card p-4">
<h2 className="font-medium mb-3">Edit container</h2>
<ContainerForm
existing={container}
onSuccess={() => {
setEditing(false);
router.refresh();
}}
onCancel={() => setEditing(false)}
/>
</div>
)}
{/* Tabs */}
<div className="flex gap-6 border-b border-[var(--ink-faint)]">
{(["info", "gallery"] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`pb-2 text-sm font-medium capitalize transition-colors ${
tab === t
? "border-b-2 border-[var(--ink)] text-[var(--ink)]"
: "text-[var(--ink-mute)] hover:text-[var(--ink)]"
}`}
>
{t}
</button>
))}
</div>
{/* Info */}
{tab === "info" && (
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Plants ({container.plantCount})</h2>
<a
href={`/garden/plants/new?containerId=${container.id}`}
className="btn btn-ghost btn-sm"
>
+ Add plant
</a>
</div>
{container.plants.length === 0 ? (
<p className="text-sm text-[var(--ink-mute)]">No plants in this container yet.</p>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{container.plants.map((p) => (
<a
key={p.id}
href={`/garden/plants/${p.id}`}
className="card p-3 hover:bg-[var(--surface-2)] transition-colors"
>
<div className="flex items-center gap-3">
{p.primaryImageUrl && (
<img
src={p.primaryImageUrl}
alt=""
className="w-10 h-10 rounded-full object-cover shrink-0"
/>
)}
<div>
<p className="font-medium text-sm">{p.name}</p>
{p.scientificName && (
<p className="text-xs text-[var(--ink-mute)] italic">{p.scientificName}</p>
)}
</div>
<span
className={`ml-auto text-xs badge ${p.healthStatus === "healthy" ? "badge-success" : "badge-warning"}`}
>
{p.healthStatus}
</span>
</div>
</a>
))}
</div>
)}
</div>
)}
{/* Gallery */}
{tab === "gallery" && (
<div className="flex flex-col gap-4">
{container.images.length === 0 ? (
<p className="text-sm text-[var(--ink-mute)]">No photos yet.</p>
) : (
<div className="grid grid-cols-3 gap-2">
{container.images.map((url) => (
<div key={url} className="relative group">
<img src={url} alt="" className="w-full aspect-square object-cover rounded-lg" />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 rounded-lg flex items-center justify-center gap-3 transition-opacity">
<button
onClick={() => handleSetPrimary(url)}
disabled={isPending}
title="Set as cover"
className={`text-lg leading-none ${url === container.coverImageUrl ? "text-yellow-400" : "text-white"}`}
>
</button>
<button
onClick={() => handleRemoveImage(url)}
disabled={isPending}
title="Remove"
className="text-white text-lg leading-none"
>
</button>
</div>
{url === container.coverImageUrl && (
<span className="absolute top-1 left-1 text-xs px-1 bg-black/60 text-yellow-300 rounded">
Cover
</span>
)}
</div>
))}
</div>
)}
{galleryError && <p className="text-sm text-red-500">{galleryError}</p>}
<div className="flex items-center gap-3">
{container.images.length < 10 && (
<label className="btn btn-ghost btn-sm cursor-pointer">
{uploadingImage ? "Uploading…" : "Upload photo"}
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
disabled={uploadingImage}
/>
</label>
)}
<span className="text-xs text-[var(--ink-mute)]">
{container.images.length}/10 photos
</span>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,112 @@
"use client";
import { useRef, useState, useTransition } from "react";
import { createContainer, updateContainer } from "../server/actions";
import type { ContainerDto } from "../server/queries";
const CONTAINER_TYPES = [
{ value: "shelf", label: "Shelf" },
{ value: "terrarium", label: "Terrarium" },
{ value: "raised-bed", label: "Raised bed" },
{ value: "window-box", label: "Window box" },
{ value: "single-pot", label: "Single pot" },
{ value: "outdoor", label: "Outdoor" },
{ value: "other", label: "Other" },
];
type Props = {
existing?: ContainerDto;
onSuccess?: () => void;
onCancel?: () => void;
};
export function ContainerForm({ existing, onSuccess, onCancel }: Props) {
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const formRef = useRef<HTMLFormElement>(null);
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const input = {
name: fd.get("name") as string,
type: fd.get("type") as string,
locationNotes: (fd.get("locationNotes") as string) || null,
};
setError(null);
startTransition(async () => {
try {
if (existing) {
await updateContainer({ id: existing.id, ...input });
} else {
await createContainer(input);
}
formRef.current?.reset();
onSuccess?.();
} catch {
setError("Something went wrong. Please try again.");
}
});
}
return (
<form ref={formRef} onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label htmlFor="container-name" className="text-sm font-medium">
Name
</label>
<input
id="container-name"
name="name"
required
maxLength={120}
defaultValue={existing?.name}
placeholder="e.g. Living Room Shelf"
className="input"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="container-type" className="text-sm font-medium">
Type
</label>
<select id="container-type" name="type" defaultValue={existing?.type ?? "other"}>
{CONTAINER_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="container-notes" className="text-sm font-medium">
Location notes
</label>
<textarea
id="container-notes"
name="locationNotes"
maxLength={500}
rows={2}
defaultValue={existing?.locationNotes ?? ""}
placeholder="Optional — e.g. south-facing window"
className="input"
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-2 justify-end">
{onCancel && (
<button type="button" onClick={onCancel} className="btn btn-ghost" disabled={isPending}>
Cancel
</button>
)}
<button type="submit" className="btn btn-primary" disabled={isPending}>
{isPending ? "Saving…" : existing ? "Save" : "Create"}
</button>
</div>
</form>
);
}
@@ -0,0 +1,65 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ContainerForm } from "./container-form";
import type { ContainerDto } from "../server/queries";
type Props = { containers: ContainerDto[] };
export function ContainerList({ containers }: Props) {
const [showNew, setShowNew] = useState(false);
const router = useRouter();
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Containers</h2>
<button className="btn btn-primary btn-sm" onClick={() => setShowNew(true)}>
New container
</button>
</div>
{showNew && (
<div className="card p-4">
<h3 className="font-medium mb-3">New container</h3>
<ContainerForm
onSuccess={() => {
setShowNew(false);
router.refresh();
}}
onCancel={() => setShowNew(false)}
/>
</div>
)}
{containers.length === 0 && !showNew && (
<p className="text-sm text-[var(--ink-mute)]">
No containers yet. Add one to start organising your plants.
</p>
)}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{containers.map((c) => (
<Link
key={c.id}
href={`/garden/containers/${c.id}`}
className="card p-4 hover:bg-[var(--surface-2)] transition-colors"
>
{c.coverImageUrl && (
<img src={c.coverImageUrl} alt="" className="w-full h-32 object-cover rounded mb-3" />
)}
<div className="flex items-start justify-between gap-2">
<span className="font-medium leading-tight">{c.name}</span>
<span className="badge badge-outline text-xs shrink-0 capitalize">{c.type}</span>
</div>
<p className="text-sm text-[var(--ink-mute)] mt-1">
{c.plantCount} {c.plantCount === 1 ? "plant" : "plants"}
</p>
</Link>
))}
</div>
</div>
);
}
@@ -0,0 +1,294 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { deletePlant, addPlantImage, removePlantImage, setPrimaryImage } from "../server/actions";
import type { CalendarDto } from "../server/calendar-bridge";
import type { CareLogDto, CareScheduleDto, PlantDetailDto } from "../server/queries";
import { ShareButton } from "@/components/share-button";
import { ShareLinkList } from "@/components/share-link-list";
import type { EntityShareLink } from "@/modules/_core/share";
import { CareHistoryList } from "./care-history-list";
import { CareLogForm } from "./care-log-form";
import { CareScheduleEditor } from "./care-schedule-editor";
type Tab = "info" | "gallery" | "care";
type Props = {
plant: PlantDetailDto;
careLogs: CareLogDto[];
careSchedules: CareScheduleDto[];
calendars: CalendarDto[];
shareLinks: EntityShareLink[];
};
function InfoRow({
label,
value,
children,
}: {
label: string;
value?: string | null;
children?: React.ReactNode;
}) {
if (!value && !children) return null;
return (
<div className="flex gap-3">
<span className="text-sm text-[var(--ink-mute)] w-28 shrink-0">{label}</span>
<span className="text-sm">{children ?? value}</span>
</div>
);
}
function healthBadgeClass(status: string): string {
if (status === "healthy") return "badge-success";
if (status === "sick") return "badge-danger";
return "badge-warning";
}
export function PlantDetail({ plant, careLogs, careSchedules, calendars, shareLinks }: Props) {
const [tab, setTab] = useState<Tab>("info");
const [confirming, setConfirming] = useState(false);
const [isPending, startTransition] = useTransition();
const [galleryError, setGalleryError] = useState<string | null>(null);
const [uploadingImage, setUploadingImage] = useState(false);
const router = useRouter();
function handleDelete() {
startTransition(async () => {
await deletePlant({ id: plant.id });
router.push("/garden");
router.refresh();
});
}
async function handleImageUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setGalleryError(null);
setUploadingImage(true);
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/uploads", { method: "POST", body: fd });
if (!res.ok) throw new Error("Upload failed");
const data = (await res.json()) as { url: string };
await addPlantImage({ id: plant.id, url: data.url });
router.refresh();
} catch {
setGalleryError("Image upload failed.");
} finally {
setUploadingImage(false);
e.target.value = "";
}
}
function handleRemoveImage(url: string) {
startTransition(async () => {
await removePlantImage({ id: plant.id, url });
router.refresh();
});
}
function handleSetPrimary(url: string) {
startTransition(async () => {
await setPrimaryImage({ id: plant.id, url });
router.refresh();
});
}
return (
<div className="flex flex-col gap-5">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4">
{plant.primaryImageUrl && (
<img
src={plant.primaryImageUrl}
alt=""
className="w-16 h-16 rounded-lg object-cover shrink-0"
/>
)}
<div>
<h1 className="text-2xl font-bold">{plant.name}</h1>
{plant.scientificName && (
<p className="text-sm text-[var(--ink-mute)] italic mt-0.5">{plant.scientificName}</p>
)}
<div className="flex flex-wrap gap-2 mt-1">
<span className={`text-xs badge ${healthBadgeClass(plant.healthStatus)}`}>
{plant.healthStatus}
</span>
{plant.growthStage && (
<span className="text-xs badge badge-outline capitalize">{plant.growthStage}</span>
)}
</div>
</div>
</div>
<div className="flex flex-col items-end gap-2 shrink-0">
<div className="flex gap-2">
<ShareButton entityType="garden.plant" entityId={plant.id} />
<Link href={`/garden/plants/${plant.id}/edit`} className="btn btn-ghost btn-sm">
Edit
</Link>
{confirming ? (
<div className="flex gap-1">
<button
className="btn btn-danger btn-sm"
onClick={handleDelete}
disabled={isPending}
>
Confirm
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setConfirming(false)}
disabled={isPending}
>
Cancel
</button>
</div>
) : (
<button className="btn btn-ghost btn-sm" onClick={() => setConfirming(true)}>
Delete
</button>
)}
</div>
<ShareLinkList links={shareLinks} />
</div>
</div>
{/* Tabs */}
<div className="flex gap-6 border-b border-[var(--ink-faint)]">
{(["info", "gallery", "care"] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`pb-2 text-sm font-medium capitalize transition-colors ${
tab === t
? "border-b-2 border-[var(--ink)] text-[var(--ink)]"
: "text-[var(--ink-mute)]"
}`}
>
{t}
</button>
))}
</div>
{/* Info */}
{tab === "info" && (
<div className="flex flex-col gap-2">
<InfoRow label="Category" value={plant.category} />
{plant.containerName && (
<InfoRow label="Container">
<Link href={`/garden/containers/${plant.containerId}`} className="underline">
{plant.containerName}
</Link>
</InfoRow>
)}
<InfoRow label="Acquired" value={plant.acquisitionDate} />
<InfoRow label="Sunlight" value={plant.sunlight} />
<InfoRow label="Watering" value={plant.wateringNotes} />
<InfoRow label="Fertilizing" value={plant.fertilizingNotes} />
<InfoRow label="Notes" value={plant.notes} />
{plant.recentCareLogs.length > 0 && (
<div className="mt-2">
<p className="text-xs font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-1">
Recent care
</p>
<ul className="text-sm space-y-1">
{plant.recentCareLogs.map((log) => (
<li key={log.id} className="flex gap-2 flex-wrap">
<span className="capitalize">{log.careType}</span>
<span className="text-[var(--ink-mute)]">
{new Date(log.performedAt).toLocaleDateString()}
</span>
{log.notes && <span className="text-[var(--ink-mute)]"> {log.notes}</span>}
</li>
))}
</ul>
</div>
)}
</div>
)}
{/* Gallery */}
{tab === "gallery" && (
<div className="flex flex-col gap-4">
{plant.images.length === 0 ? (
<p className="text-sm text-[var(--ink-mute)]">No photos yet.</p>
) : (
<div className="grid grid-cols-3 gap-2">
{plant.images.map((url) => (
<div key={url} className="relative group">
<img src={url} alt="" className="w-full aspect-square object-cover rounded-lg" />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 rounded-lg flex items-center justify-center gap-3 transition-opacity">
<button
onClick={() => handleSetPrimary(url)}
disabled={isPending}
title="Set as primary"
className={`text-lg leading-none ${url === plant.primaryImageUrl ? "text-yellow-400" : "text-white"}`}
>
</button>
<button
onClick={() => handleRemoveImage(url)}
disabled={isPending}
title="Remove"
className="text-white text-lg leading-none"
>
</button>
</div>
{url === plant.primaryImageUrl && (
<span className="absolute top-1 left-1 text-xs px-1 bg-black/60 text-yellow-300 rounded">
Primary
</span>
)}
</div>
))}
</div>
)}
{galleryError && <p className="text-sm text-red-500">{galleryError}</p>}
<div className="flex items-center gap-3">
{plant.images.length < 10 && (
<label className="btn btn-ghost btn-sm cursor-pointer">
{uploadingImage ? "Uploading…" : "Upload photo"}
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
disabled={uploadingImage}
/>
</label>
)}
<span className="text-xs text-[var(--ink-mute)]">{plant.images.length}/10 photos</span>
</div>
</div>
)}
{/* Care */}
{tab === "care" && (
<div className="flex flex-col gap-6">
<CareScheduleEditor plantId={plant.id} schedules={careSchedules} calendars={calendars} />
<div className="border-t border-[var(--ink-faint)] pt-4">
<p className="text-sm font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-3">
Log care
</p>
<CareLogForm plantId={plant.id} onSuccess={() => router.refresh()} />
</div>
<div className="border-t border-[var(--ink-faint)] pt-4">
<p className="text-sm font-semibold text-[var(--ink-mute)] uppercase tracking-wide mb-3">
History
</p>
<CareHistoryList logs={careLogs} />
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,367 @@
"use client";
import { useState, useTransition, useRef } from "react";
import { useRouter } from "next/navigation";
import { createPlant, updatePlant } from "../server/actions";
import { SpeciesSearch } from "./species-search";
import type { PlantDetailDto, ContainerDto } from "../server/queries";
import type { SpeciesSuggestion } from "../server/species-lookup";
const CATEGORIES = [
{ value: "vegetable", label: "Vegetable" },
{ value: "fruit", label: "Fruit" },
{ value: "herb", label: "Herb" },
{ value: "flower", label: "Flower" },
{ value: "succulent", label: "Succulent" },
{ value: "cactus", label: "Cactus" },
{ value: "tropical", label: "Tropical" },
{ value: "tree", label: "Tree" },
{ value: "shrub", label: "Shrub" },
{ value: "other", label: "Other" },
];
const HEALTH_STATUSES = [
{ value: "healthy", label: "Healthy" },
{ value: "needs-attention", label: "Needs attention" },
{ value: "sick", label: "Sick" },
{ value: "dormant", label: "Dormant" },
];
const GROWTH_STAGES = [
{ value: "", label: "Unknown" },
{ value: "seedling", label: "Seedling" },
{ value: "sprout", label: "Sprout" },
{ value: "vegetative", label: "Vegetative" },
{ value: "budding", label: "Budding" },
{ value: "flowering", label: "Flowering" },
{ value: "ripening", label: "Ripening" },
{ value: "dormant", label: "Dormant" },
];
type Props = {
existingPlant?: PlantDetailDto;
defaultContainerId?: string | null;
containers: ContainerDto[];
};
export function PlantForm({ existingPlant, defaultContainerId, containers }: Props) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [images, setImages] = useState<string[]>(existingPlant?.images ?? []);
const [uploading, setUploading] = useState(false);
const [selectedSpeciesId, setSelectedSpeciesId] = useState(existingPlant?.speciesId ?? "");
const scientificNameRef = useRef<HTMLInputElement>(null);
const sunlightRef = useRef<HTMLInputElement>(null);
const wateringNotesRef = useRef<HTMLTextAreaElement>(null);
function handleSpeciesSelect(species: SpeciesSuggestion) {
setSelectedSpeciesId(species.id);
if (scientificNameRef.current) scientificNameRef.current.value = species.scientific_name;
if (sunlightRef.current) sunlightRef.current.value = species.sunlight.join(", ");
if (wateringNotesRef.current) wateringNotesRef.current.value = species.watering;
}
async function handleImageSelect(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
if (images.length >= 10) {
setError("Maximum 10 images allowed.");
return;
}
setUploading(true);
setError(null);
try {
const urls: string[] = [];
for (const file of files) {
if (images.length + urls.length >= 10) break;
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/uploads", { method: "POST", body: fd });
if (!res.ok) throw new Error("Upload failed");
const data = (await res.json()) as { url: string };
urls.push(data.url);
}
setImages((prev) => [...prev, ...urls]);
} catch {
setError("Image upload failed. Please try again.");
} finally {
setUploading(false);
e.target.value = "";
}
}
function handleRemoveImage(url: string) {
setImages((prev) => prev.filter((u) => u !== url));
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
const input = {
name: fd.get("name") as string,
category: (fd.get("category") as string) || "other",
containerId: (fd.get("containerId") as string) || null,
healthStatus: (fd.get("healthStatus") as string) || "healthy",
growthStage: (fd.get("growthStage") as string) || null,
scientificName: scientificNameRef.current?.value || null,
speciesId: selectedSpeciesId || null,
sunlight: sunlightRef.current?.value || null,
wateringNotes: wateringNotesRef.current?.value || null,
fertilizingNotes: (fd.get("fertilizingNotes") as string) || null,
notes: (fd.get("notes") as string) || null,
acquisitionDate: (fd.get("acquisitionDate") as string) || null,
images,
primaryImageUrl: existingPlant?.primaryImageUrl ?? images[0] ?? null,
};
setError(null);
startTransition(async () => {
try {
if (existingPlant) {
await updatePlant({ id: existingPlant.id, ...input });
router.push(`/garden/plants/${existingPlant.id}`);
router.refresh();
} else {
const plant = await createPlant(input);
router.push(`/garden/plants/${plant.id}`);
}
} catch {
setError("Something went wrong. Please try again.");
}
});
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4 max-w-xl">
<div className="flex flex-col gap-1">
<label htmlFor="plant-name" className="text-sm font-medium">
Name <span className="text-red-500">*</span>
</label>
<input
id="plant-name"
name="name"
required
maxLength={120}
defaultValue={existingPlant?.name}
className="input"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium">Species lookup (optional)</label>
<SpeciesSearch
onSelect={handleSpeciesSelect}
initialValue={existingPlant?.scientificName ?? ""}
/>
<p className="text-xs text-[var(--ink-mute)]">
Search OpenPlantBook to auto-fill scientific name and care notes.
</p>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="plant-scientific-name" className="text-sm font-medium">
Scientific name
</label>
<input
id="plant-scientific-name"
name="scientificName"
ref={scientificNameRef}
maxLength={200}
defaultValue={existingPlant?.scientificName ?? ""}
className="input"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1">
<label htmlFor="plant-category" className="text-sm font-medium">
Category
</label>
<select
id="plant-category"
name="category"
defaultValue={existingPlant?.category ?? "other"}
>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="plant-container" className="text-sm font-medium">
Container
</label>
<select
id="plant-container"
name="containerId"
defaultValue={existingPlant?.containerId ?? defaultContainerId ?? ""}
>
<option value="">None</option>
{containers.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1">
<label htmlFor="plant-health" className="text-sm font-medium">
Health status
</label>
<select
id="plant-health"
name="healthStatus"
defaultValue={existingPlant?.healthStatus ?? "healthy"}
>
{HEALTH_STATUSES.map((h) => (
<option key={h.value} value={h.value}>
{h.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="plant-stage" className="text-sm font-medium">
Growth stage
</label>
<select
id="plant-stage"
name="growthStage"
defaultValue={existingPlant?.growthStage ?? ""}
>
{GROWTH_STAGES.map((g) => (
<option key={g.value} value={g.value}>
{g.label}
</option>
))}
</select>
</div>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="plant-acquired" className="text-sm font-medium">
Acquisition date
</label>
<input
id="plant-acquired"
type="date"
name="acquisitionDate"
defaultValue={existingPlant?.acquisitionDate ?? ""}
className="input"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="plant-sunlight" className="text-sm font-medium">
Sunlight requirements
</label>
<input
id="plant-sunlight"
name="sunlight"
ref={sunlightRef}
maxLength={200}
defaultValue={existingPlant?.sunlight ?? ""}
placeholder="e.g. full sun, part shade"
className="input"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="plant-watering" className="text-sm font-medium">
Watering notes
</label>
<textarea
id="plant-watering"
name="wateringNotes"
ref={wateringNotesRef}
maxLength={2000}
rows={2}
defaultValue={existingPlant?.wateringNotes ?? ""}
className="input"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="plant-fertilizing" className="text-sm font-medium">
Fertilizing notes
</label>
<textarea
id="plant-fertilizing"
name="fertilizingNotes"
maxLength={2000}
rows={2}
defaultValue={existingPlant?.fertilizingNotes ?? ""}
className="input"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="plant-notes" className="text-sm font-medium">
General notes
</label>
<textarea
id="plant-notes"
name="notes"
maxLength={2000}
rows={3}
defaultValue={existingPlant?.notes ?? ""}
className="input"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Photos ({images.length}/10)</label>
{images.length > 0 && (
<div className="grid grid-cols-4 gap-2">
{images.map((url) => (
<div key={url} className="relative group">
<img src={url} alt="" className="w-full aspect-square object-cover rounded" />
<button
type="button"
onClick={() => handleRemoveImage(url)}
className="absolute top-0.5 right-0.5 bg-black/60 text-white rounded text-xs px-1 opacity-0 group-hover:opacity-100"
>
</button>
</div>
))}
</div>
)}
{images.length < 10 && (
<label className="btn btn-ghost btn-sm w-fit cursor-pointer">
{uploading ? "Uploading…" : "Add photos"}
<input
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleImageSelect}
disabled={uploading}
/>
</label>
)}
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-2 justify-end">
<a
href={existingPlant ? `/garden/plants/${existingPlant.id}` : "/garden"}
className="btn btn-ghost"
>
Cancel
</a>
<button type="submit" className="btn btn-primary" disabled={isPending || uploading}>
{isPending ? "Saving…" : existingPlant ? "Save" : "Create"}
</button>
</div>
</form>
);
}
@@ -0,0 +1,106 @@
import Link from "next/link";
import type { PlantListItemDto } from "../server/queries";
type Props = {
plants: PlantListItemDto[];
};
function daysAgo(isoString: string): string {
const diff = Date.now() - new Date(isoString).getTime();
const days = Math.floor(diff / 86_400_000);
if (days === 0) return "today";
if (days === 1) return "yesterday";
return `${days}d ago`;
}
function healthBadgeClass(status: string): string {
if (status === "healthy") return "badge-success";
if (status === "sick") return "badge-danger";
return "badge-warning";
}
export function PlantList({ plants }: Props) {
if (plants.length === 0) {
return (
<div className="flex flex-col items-center gap-3 py-8 text-center">
<p className="text-sm text-[var(--ink-mute)]">No plants yet.</p>
<Link href="/garden/plants/new" className="btn btn-primary btn-sm">
Add your first plant
</Link>
</div>
);
}
const groups = new Map<string | null, { name: string | null; items: PlantListItemDto[] }>();
for (const plant of plants) {
const key = plant.containerId;
if (!groups.has(key)) groups.set(key, { name: plant.containerName, items: [] });
groups.get(key)!.items.push(plant);
}
const entries = [...groups.entries()].sort(([a], [b]) => {
if (a === null) return 1;
if (b === null) return -1;
return 0;
});
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<span className="text-sm text-[var(--ink-mute)]">
{plants.length} {plants.length === 1 ? "plant" : "plants"}
</span>
<Link href="/garden/plants/new" className="btn btn-primary btn-sm">
Add plant
</Link>
</div>
{entries.map(([containerId, group]) => (
<div key={containerId ?? "_unassigned"}>
<h3 className="text-xs font-semibold text-[var(--ink-mute)] uppercase tracking-wider mb-2">
{group.name ?? "Unassigned"}
</h3>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{group.items.map((plant) => (
<Link
key={plant.id}
href={`/garden/plants/${plant.id}`}
className="card p-3 hover:bg-[var(--surface-2)] transition-colors"
>
<div className="flex items-center gap-3">
{plant.primaryImageUrl ? (
<img
src={plant.primaryImageUrl}
alt=""
className="w-12 h-12 rounded-lg object-cover shrink-0"
/>
) : (
<div className="w-12 h-12 rounded-lg bg-[var(--surface-2)] shrink-0 flex items-center justify-center text-xl">
🌱
</div>
)}
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{plant.name}</p>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
<span className={`text-xs badge ${healthBadgeClass(plant.healthStatus)}`}>
{plant.healthStatus}
</span>
{plant.lastWateredAt && (
<span className="text-xs text-[var(--ink-mute)]">
Watered {daysAgo(plant.lastWateredAt)}
</span>
)}
</div>
{plant.hasOverdueCare && (
<p className="text-xs text-amber-600 mt-0.5">Care overdue</p>
)}
</div>
</div>
</Link>
))}
</div>
</div>
))}
</div>
);
}

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