Compare commits

..
66 Commits
Author SHA1 Message Date
ginnoirandClaude Sonnet 4.6 9c60b58947 ci(release): fix env var names for act_runner compatibility
CI / checks (push) Successful in 1m44s
CI / build (push) Successful in 3m28s
Release Image / build-and-push (push) Successful in 5m21s
gitea act_runner exposes GITHUB_* not GITEA_* variable aliases;
switch to GITHUB_REF_NAME and GITHUB_OUTPUT so tag extraction works.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 00:22:44 -05:00
ginnoirandClaude Sonnet 4.6 4084c3af91 ci: publish famapp images to valhalla registry
CI / checks (push) Successful in 1m46s
CI / build (push) Successful in 2m45s
Release Image / build-and-push (push) Failing after 49s
Add Gitea Actions workflows (ci.yml, release.yml) that build and push
to registry.ginnoir.com. Disable GitHub release creation. Update all
doc/compose references from ghcr.io to registry.ginnoir.com.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 00:16:27 -05:00
ginnoirandClaude Sonnet 4.6 4f92a98921 docs: add drizzle-best-practices to regular agent skills
CI / checks (push) Successful in 11m6s
CI / build (push) Successful in 13m28s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 05:33:07 -05:00
ginnoir a312d4ce39 fix: tighten sharing and shadcn composition
CI / checks (push) Successful in 12m55s
CI / build (push) Successful in 15m16s
2026-06-13 05:20:01 -05:00
ginnoir e1774c802d refactor(garden): replace hand-rolled badge classes with shadcn Badge 2026-06-13 03:31:37 -05:00
ginnoir 55b0e68b35 feat(shadcn): install 10 missing components 2026-06-13 03:08:56 -05:00
ginnoir 615d9c9377 fix: declare unlisted dependencies surfaced by knip
CI / checks (push) Has been cancelled
CI / build (push) Has been cancelled
sharp (icon script), @auth/core (adapter types in _core/schema),
react-resizable (css imported by dashboard-editor), and server-only
were imported but undeclared, resolving only via pnpm hoisting.
2026-06-09 15:55:36 -05:00
ginnoir 944dcdf48f refactor: remove dead code identified by knip
Delete unused Fab component (superseded by QuickAddFab), unused server
actions and queries (listDashboards, reorderDashboards, deleteCareLog,
canAccessContainer, getOverduePlants, getCareDueSoon, addItemToDefaultList,
reorderItems, getOrCreateDefaultList), unused helpers (getCurrentUser,
BrandWordmark, TopbarSpacer, DEV_LOGIN_COOKIE, no-arg ensureDefault*
wrappers, addListItem re-export), and drop export keyword from five
internal-only functions.

Verified with typecheck, eslint, and production build after each removal.
2026-06-09 15:55:21 -05:00
ginnoir d4ac20f511 docs: link homelabstack infra repo in claude.md
CI / checks (push) Has been cancelled
CI / build (push) Has been cancelled
2026-06-03 22:14:05 -05:00
ginnoir fd97eb306d fix: list item checkbox/delete broken by pointer capture, badge wrapping
setPointerCapture on the swipe row div was swallowing click events from
child buttons; guard now bails early when the pointer originates from a
button or input. Also added whitespace-nowrap + shrink-0 to the list
type/count badge to prevent it wrapping across two lines.
2026-06-03 22:01:33 -05:00
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
166 changed files with 11609 additions and 985 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
+49
View File
@@ -0,0 +1,49 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enable pnpm
run: |
corepack enable
corepack prepare pnpm@10.33.3 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm typecheck
- name: Lint
run: pnpm lint
- name: Format check
run: pnpm format:check
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enable pnpm
run: |
corepack enable
corepack prepare pnpm@10.33.3 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
env:
DATABASE_URL: postgres://ci_user:ci_password@localhost:5432/ci_database
AUTH_SECRET: ci-auth-secret-for-build
NEXT_PUBLIC_APP_URL: http://localhost:3000
+48
View File
@@ -0,0 +1,48 @@
name: Release Image
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Derive image tags
id: meta
shell: bash
run: |
VERSION="${GITHUB_REF_NAME#v}"
MAJOR_MINOR="$(printf '%s' "$VERSION" | awk -F. '{print $1"."$2}')"
{
echo "version=$VERSION"
echo "major_minor=$MAJOR_MINOR"
echo "image=registry.ginnoir.com/ginnoir/famapp"
} >> "$GITHUB_OUTPUT"
- name: Install docker
run: apt-get update -qq && apt-get install -y -qq docker.io
- name: Login to registry
run: |
echo "${{ secrets.REGISTRY_PUSH_PASSWORD }}" | docker login registry.ginnoir.com \
--username "${{ secrets.REGISTRY_PUSH_USERNAME }}" \
--password-stdin
- name: Build image
run: |
docker build \
-t "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}" \
-t "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.major_minor }}" \
-t "${{ steps.meta.outputs.image }}:latest" \
.
- name: Push image
run: |
docker push "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}"
docker push "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.major_minor }}"
docker push "${{ steps.meta.outputs.image }}:latest"
+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
+5 -4
View File
@@ -8,8 +8,10 @@
"requireBranch": "main"
},
"github": {
"release": true,
"releaseName": "v${version}"
"release": false
},
"hooks": {
"before:git:release": "echo 'Check: README.md reflects current modules and env vars before tagging'"
},
"plugins": {
"@release-it/conventional-changelog": {
@@ -26,8 +28,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"
}
}
}
+7 -2
View File
@@ -6,6 +6,11 @@ This file is the canonical brief. Read it at the start of every session before m
Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`, `STATUS.md`, task briefs, and dev notes synchronized so either agent can pick up the next task without relying on agent-specific memory.
## Regular agent skills
- Use the React best-practices skill for any React or Next.js page/component work, data-loading changes, bundle/performance work, or review of those areas.
- Use the shadcn skill for any UI work involving shadcn components, Tailwind styling, overlays, forms, icons, component composition, or updates to `components.json` / `src/components/ui`.
---
## Goals
@@ -31,7 +36,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 +111,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):
+30 -3
View File
@@ -6,6 +6,12 @@ This file is the canonical brief. Read it at the start of every session before m
Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`, `STATUS.md`, task briefs, and dev notes synchronized so either agent can pick up the next task without relying on agent-specific memory.
## Regular agent skills
- Use the React best-practices skill for any React or Next.js page/component work, data-loading changes, bundle/performance work, or review of those areas.
- Use the shadcn skill for any UI work involving shadcn components, Tailwind styling, overlays, forms, icons, component composition, or updates to `components.json` / `src/components/ui`.
- Use the drizzle-best-practices skill for any database schema changes, migrations, queries, or ORM usage.
---
## Goals
@@ -31,7 +37,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 +112,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:
@@ -159,6 +165,27 @@ Tasks for each phase live in [`docs/tasks/`](docs/tasks/). Sub-sessions should p
---
## Infrastructure repo
Compose stack, Caddyfile, and `.env` for the whole homelab (including famapp) live at:
```
C:\Users\MattC\Documents\homelabstack
```
GitHub: `ginnoir/homelabstack` (private). This is the source of truth for all infra files — **not** `deploy/` in this repo.
When famapp development requires an infra change (new env var, new service, Caddyfile route, etc.):
1. `cd C:\Users\MattC\Documents\homelabstack` and run `sync-prod.ps1` first (pulls current prod state)
2. Edit `docker-compose.yml`, `.env`, and/or `Caddyfile` as needed
3. Run `apply-compose.ps1` (flags: `-Compose`, `-Caddy`, `-EnvFile`) to push to valhalla
4. Commit and push the homelabstack repo
The homelabstack `CLAUDE.md` is the authoritative brief for that repo — read it before editing infra files.
---
## Where things live
- **This brief:** `CLAUDE.md`
@@ -166,5 +193,5 @@ Tasks for each phase live in [`docs/tasks/`](docs/tasks/). Sub-sessions should p
- **Architecture decisions worth preserving:** `docs/decisions/NNNN-title.md` (lightweight ADR — only when a non-obvious choice is made)
- **App code:** `src/`
- **Drizzle migrations:** `drizzle/`
- **Deploy:** `deploy/` (compose.yaml, Caddyfile snippet, Authentik bootstrap)
- **Infra (compose/Caddyfile/.env):** `C:\Users\MattC\Documents\homelabstack`
- **Memory (cross-session):** `C:\Users\MattC\.claude\projects\C--Users-MattC-Documents-famapp\memory\`
+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=registry.ginnoir.com/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 — Gitea Actions builds and pushes `registry.ginnoir.com/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
}
+20 -6
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
@@ -21,14 +35,14 @@ git tag v0.1.0
git push origin v0.1.0
```
`.github/workflows/release.yml` builds + pushes `ghcr.io/ginnoir/famapp:v0.1.0`, `:0.1`, and `:latest` to GHCR.
`.gitea/workflows/release.yml` builds + pushes `registry.ginnoir.com/ginnoir/famapp:v0.1.0`, `:0.1`, and `:latest` to the self-hosted registry.
## Deploying a release on the host
```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=registry.ginnoir.com/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:-registry.ginnoir.com/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. `registry.ginnoir.com/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
```
Gitea Actions (`release.yml`) then builds and pushes the Docker image to the self-hosted registry:
- `registry.ginnoir.com/ginnoir/famapp:v0.x.y`
- `registry.ginnoir.com/ginnoir/famapp:0.x` (minor alias)
- `registry.ginnoir.com/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=registry.ginnoir.com/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=registry.ginnoir.com/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
@@ -0,0 +1,141 @@
# React and shadcn Fixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix the security-sensitive share-link authorization gap and the highest-confidence shadcn Base UI composition issues from the audit.
**Architecture:** Add registry-level share authorization callbacks and require them in the generic share service before token creation and shared data loading. Update current modules to provide household/user-scoped authorization and share loaders, then align selected UI call sites with shadcn Base API.
**Tech Stack:** Next.js 15 App Router, TypeScript, Drizzle, shadcn/ui Base UI, Tailwind v4, Node test runner via `tsx --test`.
---
### Task 1: Share Authorization Helper
**Files:**
- Create: `tests/unit/share-authorization.test.ts`
- Create: `src/modules/_core/share-authorization.ts`
- Modify: `src/modules/_core/module.ts`
- [ ] **Step 1: Write the failing test**
Create tests for missing callbacks, denied callbacks, and allowed callbacks.
- [ ] **Step 2: Run test to verify it fails**
Run: `pnpm exec tsx --test tests/unit/share-authorization.test.ts`
Expected: failure because `share-authorization.ts` does not exist.
- [ ] **Step 3: Implement the helper and registry types**
Add `ShareContext`, `ensureEntityShareAuthorized`, `EntityTypeRegistration.canShareEntity`, and scoped `loadForShare` context typing.
- [ ] **Step 4: Run test to verify it passes**
Run: `pnpm exec tsx --test tests/unit/share-authorization.test.ts`
Expected: all tests pass.
### Task 2: Wire Authorization Into Share Service
**Files:**
- Modify: `src/modules/_core/share.ts`
- Modify: `src/app/s/[token]/page.tsx`
- [ ] **Step 1: Call `ensureEntityShareAuthorized` in `createShareLink`**
Require the active user and household to be authorized before token insertion.
- [ ] **Step 2: Pass household context into public share loading**
Call `entityReg.loadForShare(resolved.entityId, { householdId: resolved.householdId })`.
- [ ] **Step 3: Run typecheck**
Run: `pnpm typecheck`
Expected: type errors until module adapters are updated.
### Task 3: Add Module Share Adapters
**Files:**
- Modify: `src/modules/calendar/manifest.tsx`
- Modify: `src/modules/calendar/server/share-queries.ts`
- Modify: `src/modules/lists/manifest.tsx`
- Modify: `src/modules/lists/server/share-queries.ts`
- Modify: `src/modules/notes/manifest.tsx`
- Modify: `src/modules/notes/server/share-queries.ts`
- Modify: `src/modules/garden/manifest.tsx`
- Modify: `src/modules/garden/server/share-queries.ts`
- [ ] **Step 1: Implement `canShareEntity` for every shareable entity**
Use household scoping and existing private-calendar rules.
- [ ] **Step 2: Scope `loadForShare` queries by token household**
Require the public token household to match the entity household.
- [ ] **Step 3: Run typecheck**
Run: `pnpm typecheck`
Expected: pass.
### Task 4: Fix Base UI Select Composition
**Files:**
- Modify: `src/modules/calendar/components/calendar-shell.tsx`
- Modify: `src/components/theme-picker.tsx`
- [ ] **Step 1: Add `items` arrays and `SelectGroup` wrappers**
Keep existing labels and values; do not redesign layout.
- [ ] **Step 2: Run typecheck**
Run: `pnpm typecheck`
Expected: pass.
### Task 5: Targeted shadcn Menu and Icon Cleanup
**Files:**
- Modify: `src/components/dashboard-switcher.tsx`
- Modify: `src/components/dashboard-editor.tsx`
- Modify: `src/components/share-button.tsx`
- [ ] **Step 1: Wrap dropdown items in `DropdownMenuGroup`**
Preserve menu behavior.
- [ ] **Step 2: Replace icon sizing inside shadcn buttons/menu items**
Use `data-icon` where icons sit in `Button`; remove explicit `size-4 mr-*` from dropdown item icons.
- [ ] **Step 3: Run lint**
Run: `pnpm lint`
Expected: no new errors; existing warnings may remain.
### Task 6: Final Verification
**Files:**
- No direct edits.
- [ ] **Step 1: Run targeted test**
Run: `pnpm exec tsx --test tests/unit/share-authorization.test.ts`
Expected: pass.
- [ ] **Step 2: Run typecheck**
Run: `pnpm typecheck`
Expected: pass.
- [ ] **Step 3: Run lint**
Run: `pnpm lint`
Expected: exit 0; warnings only if pre-existing.
@@ -0,0 +1,35 @@
# React and shadcn Fixes Design
## Goal
Fix the audit findings that have security or correctness impact, and make a small targeted pass on shadcn Base UI composition without turning this into a broad visual rewrite.
## Approaches Considered
1. **Security-first, targeted UI cleanup.** Add a registry authorization hook for share links, implement it for current modules, fix Base `Select` usage, and clean up the compact dropdown/button issues found in the audit.
2. **Full shadcn migration sweep.** Replace all `.btn`, raw forms, raw colors, and hand-rolled overlays in one pass. This would touch many dirty files and blur behavior changes with style churn.
3. **Security only.** Fix share authorization and defer UI. This leaves known Base UI composition drift in place.
Chosen approach: **Option 1**. It fixes the exploitable path, handles the highest-confidence shadcn correctness issue, and leaves broad styling convergence to the existing shadcn-tier task docs.
## Architecture
Share authorization belongs in the entity registry, not in `_core` switch statements. Each shareable entity registration will expose `canShareEntity(id, ctx)` and the generic `createShareLink` action will require it to return true before inserting a token.
Public share rendering will also pass the token household into `loadForShare(id, ctx)` so loaders can scope database reads. This prevents a bad token row from loading an unrelated entity by id alone.
## UI Composition
The project uses shadcn `base-nova`, so `Select` roots need an `items` prop and `SelectItem` children should be wrapped in `SelectGroup`. The calendar and theme picker selects will be updated to that shape.
Compact menu/button drift will be cleaned where it does not require redesign: dropdown items should sit in `DropdownMenuGroup`, menu icons should rely on component icon sizing, and button icons should use `data-icon`.
## Testing
Add a small Node test around the new share authorization helper first. The test will prove that missing or false registry authorization rejects share creation and that approved entities pass through. Typecheck and lint will cover module hook signatures and Base UI prop usage.
## Out of Scope
- Full replacement of `.btn` and raw garden forms.
- Reworking command palette and quick-add sheet into shadcn dialogs.
- Measuring bundle deltas or adding dynamic imports for global overlays.
+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:
+104
View File
@@ -0,0 +1,104 @@
# Tier 1: Garden badges → Badge, topbar avatar → Avatar, + fallback, share-green → semantic token
## Goal
Replace ad-hoc badge and avatar styling across the app with shadcn/ui component primitives. Standardize color tokens for green success/warning indicators. Create a reusable avatar fallback pattern.
## Affected files (6)
### Task 1a: Garden health badges → Badge variant
**File:** `src/modules/garden/components/plant-detail.tsx`
- **Current state (line ~44-48):** `healthBadgeClass(status)` returns hand-crafted class names (`badge-success`, `badge-danger`, `badge-warning`) not from shadcn.
- **Line 119-123:** Badge rendered as a span with those classes.
- **Change:** Replace with `<Badge>` component:
- "healthy" → `variant="default"` (primary color)
- "sick" → `variant="destructive"`
- "sick/other" → `variant="secondary"`
- Delete `healthBadgeClass()` function. Remove the span and replace with Badge import + usage.
### Task 1b: Topbar avatar → `<Avatar>`
**File:** `src/components/topbar.tsx`
- **Current state (line ~60-75):** Manual `<span className="avatar">` with inline width/height/background styles, plus conditional img tag or initial text fallback.
- **Change:** Replace with shadcn `<Avatar>`:
```tsx
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
<Avatar style={{ width: 28, height: 28 }}>
{userRow?.image ? (
<AvatarImage src={userRow.image} alt="" />
) : (
<AvatarFallback>{initial}</AvatarFallback>
)}
</Avatar>;
```
- Remove the `getUserAvatar` function — already fetches name/email/image. Just pass the image directly.
### Task 1c: HouseholdPill avatars → Avatar with fallback
**File:** `src/components/sidebar.tsx`
- **Current state (line ~106-120):** Manual span-based avatar circles for household members. Uses `avatarColor()` hash function.
- **Change:** Replace inner spans with `<Avatar>`:
```tsx
<Avatar
style={{
width: 32,
height: 32,
marginLeft: i > 0 ? -6 : 0,
boxShadow: "0 0 0 1.5px var(--card)",
}}
>
{m.image ? (
<AvatarImage src={m.image} alt={m.name ?? m.email ?? ""} />
) : (
<AvatarFallback style={{ background: avatarColor(m.id) }}>{initial}</AvatarFallback>
)}
</Avatar>
```
- The `avatarColor()` function stays as-is — it produces the color variable for the fallback background.
### Task 1d: Create a generic Avatar fallback wrapper (reusable component)
**File:** `src/components/avatar-fallback.tsx` (new)
- **Purpose:** A thin wrapper that takes `(name|initial, image?)` and renders the right Avatar/AvatarImage/Fallback pattern with proper fallback initials.
- **Export:** `AvatarFallbackWithName` — accepts `{ name?: string; initial?: string; image?: string | null; size?: "sm" | "default" | "lg" }`
- Used by both topbar and sidebar avatars, removing code duplication.
### Task 1e: share-green → semantic token
**Files affected:**
1. `src/components/push-opt-in.tsx` line ~104: `text-green-600 dark:text-green-400` → `text-[var(--c-success)] dark:text-[var(--c-success)]`
2. `src/modules/garden/components/care-schedule-editor.tsx` line ~185: green borders for "Active" button → use a semantic border class like `border-[var(--c-success)] text-[var(--c-success)]`
3. `src/modules/garden/components/plant-widget.tsx` urgency colors: ensure overdue/due-today colors use semantic CSS vars rather than hardcoded greens/red/amber
**Changes:**
- In `src/lib/themes.ts` or the global CSS root, add: `--c-success: #16a34a; dark: --c-success: #4ade80;` (or pick from existing palette)
- Replace all `text-green-600`/`dark:text-green-400` → `text-[var(--c-success)] dark:text-[var(--c-success)]`
- Similarly for border-green-400 → `border-[var(--c-success)]`
## Acceptance criteria
1. No hardcoded badge-success/badge-danger/badge-warning class names remain in plant-detail.tsx
2. Topbar avatar renders via shadcn Avatar components (verified by DOM inspection)
3. HouseholdPill avatars use shadcn Avatar components
4. AvatarFallbackWithName component exists and is imported by both topbar.tsx and sidebar.tsx
5. All green-600/green-500 references replaced with --c-success semantic variable
6. Zero TypeScript errors, zero lint errors
## Steps
1. Edit plant-detail.tsx: replace healthBadgeClass usage with <Badge> component
2. Create src/components/avatar-fallback.tsx
3. Edit topbar.tsx: use Avatar from shadcn + AvatarFallbackWithName
4. Edit sidebar.tsx: update HouseholdPill to use Avatar components
5. Search for remaining green-600/green-500/border-green references and replace with --c-success
6. Run `pnpm lint` and `pnpm typecheck` to verify no errors
+183
View File
@@ -0,0 +1,183 @@
# Tier 2: Skeleton loading states, settings Tabs navigation, toast notifications via sonner, form FieldGroup audit, manual separators → <Separator>
## Goal
Standardize loading UX, navigation, and toast patterns across the app using shadcn components. Audit form field consistency. Replace hand-crafted separator borders with the shadcn Separator component.
---
### Task 2a: Skeleton loading states
**Files affected:**
1. `src/app/d/[slug]/page.tsx` line ~120 — inline `<Suspense fallback>` with animate-pulse divs (3 lines). Convert to `<Skeleton>` components.
2. `src/modules/garden/components/plant-detail.tsx` line 55 — `uploadingImage` state during image upload, no visual feedback other than button text. Add a skeleton overlay or shimmer on the gallery card area.
3. `src/modules/garden/components/container-detail.tsx` line 30 — same pattern: `uploadingImage` state without skeleton.
4. Any garden page with loading states that use animate-pulse instead of Skeleton.
**Changes:**
1. **d/[slug]/page.tsx:** Replace inline fallback divs:
```tsx
<Suspense fallback={
<div className="space-y-3">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-2/3" />
</div>
}>
```
2. **plant-detail.tsx / container-detail.tsx:** When `uploadingImage` is true, wrap the image area with a Skeleton overlay (absolute positioned, full-width). Or add a shimmer effect using the existing skeleton component.
---
### Task 2b: Settings Tabs navigation → `<Tabs>`
**File:** `src/app/settings/page.tsx` + `src/components/settings-section.tsx`
- **Current state:** Uses a sidebar nav (vertical list with URL hash sync). This is fine for desktop but doesn't use `<Tabs>`.
- **What needs to change:** The **garden detail pages** that have manual tab buttons need to use shadcn Tabs, NOT the settings page. The tier says "settings Tabs navigation" — checking the context: plant-detail.tsx line 164 and container-detail.tsx line 139 both have hand-crafted tab buttons with border-bottom active state. These should become `<Tabs>` + `<TabsList>` + `<TabsTrigger>` + `<TabsContent>`.
**Changes:**
1. **plant-detail.tsx (lines 162-180):** Replace manual tab buttons:
```tsx
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="mt-4">
<TabsList>
<TabsTrigger value="info">Info</TabsTrigger>
<TabsTrigger value="gallery">Gallery</TabsTrigger>
<TabsTrigger value="care">Care</TabsTrigger>
</TabsList>
<TabsContent value="info">{/* existing info section */}</TabsContent>
<TabsContent value="gallery">{/* existing gallery section */}</TabsContent>
<TabsContent value="care">{/* existing care section */}</TabsContent>
</Tabs>;
```
2. **container-detail.tsx (lines 138-155):** Same pattern — info/gallery tabs:
```tsx
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="mt-4">
<TabsList>
<TabsTrigger value="info">Info</TabsTrigger>
<TabsTrigger value="gallery">Gallery</TabsTrigger>
</TabsList>
<TabsContent value="info">{/* ... */}</TabsContent>
<TabsContent value="gallery">{/* ... */}</TabsContent>
</Tabs>
```
---
### Task 2c: Toast notifications via sonner
**Files affected:** Need to find all manual error/success messages and convert to toast calls.
1. **`src/app/layout.tsx`:** Add `<Toaster />` component to the app layout root so toasts are globally available.
2. **Places that need toast conversion (search for inline `setError` / success messages):**
- `src/modules/garden/components/plant-detail.tsx` — delete confirmation success/error → `toast.success()` / `toast.error()`
- `src/modules/garden/components/container-detail.tsx` — same pattern
- `src/modules/garden/components/care-schedule-editor.tsx` line 192 (`calendarSuccess`) and error states → toast
- `src/components/share-button.tsx` — "Share link created" dialog could be a toast instead (or keep as dialog but add toast on copy)
**Changes:**
```tsx
// In any component that needs toasts:
import { toast } from "sonner";
// On success:
toast.success("Event added to calendar");
// On error:
toast.error("Failed to create share link");
// In layout.tsx (client component or client wrapper):
import { Toaster } from "@/components/ui/sonner";
return <Toaster position="bottom-right" />;
```
**Acceptance for 2c:**
- `<Toaster>` is present in the app shell layout
- All delete confirmations show toasts on success/error
- The share dialog can remain as-is (it's a distinct UX), but copy action adds toast confirmation
---
### Task 2d: Form FieldGroup audit
**Scope:** Audit all form field components across the app. Check for consistency in label-input-error patterns.
**Files to audit:**
- `src/modules/garden/components/container-form.tsx` — garden container form fields
- `src/modules/garden/components/plant-form.tsx` — garden plant form fields
- `src/app/settings/household/*` — household edit forms
- Any other server/action-driven forms
**Audit checklist:**
1. Do all form inputs have `<Label>` from shadcn? (Not just plain text labels)
2. Are error messages rendered consistently below each field?
3. Are `Input` components from shadcn used everywhere? (No native `<input>` tags with manual styles)
4. Is there a FieldGroup wrapper pattern being used, or is it ad-hoc div nesting?
**Deliverable:** A list of inconsistencies found and fixes applied. If no major issues, document the findings. Do not introduce a FieldGroup abstraction unless one already exists in the codebase.
---
### Task 2e: Manual separators → `<Separator>`
**Files affected:**
1. `src/modules/garden/components/plant-detail.tsx` — border-bottom on tabs bar (`border-b border-[var(--ink-faint)]`) → use `<Separator>` instead
2. `src/modules/garden/components/container-detail.tsx` — same pattern for info/gallery tab separator
3. Any other manual `border-b`, `border-t`, or `border-l` borders used purely as visual separators (not structural layout borders)
**Changes:**
```tsx
import { Separator } from "@/components/ui/separator"
// Replace:
<div className="flex gap-6 border-b border-[var(--ink-faint)]">
{/* tab buttons */}
</div>
// With:
<TabsList>
<TabsTrigger value="info">Info</TabsTrigger>
<TabsTrigger value="gallery">Gallery</TabsTrigger>
</TabsList>
<Separator className="my-2" />
// For the garden tab bar:
{tab === "gallery" && (
<Separator className="my-4" orientation="horizontal" />
)}
```
**Note:** The settings sidebar uses a `.nav-divider` class which is handled by CSS — leave that alone. Only convert inline border divs to Separator components.
---
## Acceptance criteria
1. All loading states use `<Skeleton>` (no animate-pulse patterns outside skeleton.tsx)
2. Garden detail page tabs use shadcn Tabs/TabsList/TabsTrigger/TabsContent
3. Toaster component rendered in app layout, delete/cancel actions show toasts
4. Form fields audit complete with findings documented or fixed
5. Manual border separators replaced with `<Separator>` where appropriate
## Steps
1. Add Skeleton imports and replace inline animation patterns (2a)
2. Replace garden detail page tabs with shadcn Tabs (2b)
3. Add Toaster to layout, add toast() calls to action handlers (2c)
4. Audit form fields for consistency (2d)
5. Replace border separators with Separator component (2e)
6. Run `pnpm lint` and `pnpm typecheck`
+143
View File
@@ -0,0 +1,143 @@
# Tier 3: Garden empty-state component, toggle-group for notify channels
## Goal
Create a reusable EmptyState component for garden (and potentially other modules), and replace manual checkbox patterns with shadcn ToggleGroup for notification channel selection.
---
### Task 3a: Garden empty-state component
**New file:** `src/modules/garden/components/empty-state.tsx`
**Purpose:** A shared empty-state component for the garden module — replacing ad-hoc "No plants yet." / "No containers yet." paragraphs scattered across garden list/detail pages.
**Design (base on shadcn's `<EmptyState>` from `src/components/ui/empty.tsx`):**
The empty component already exists at `src/components/ui/empty.tsx` — it provides a generic EmptyState wrapper with icon + title + description slots. We should **use it directly**, not create a garden-specific one, unless the garden needs garden-themed defaults.
**Garden-specific variants needed:**
- "No plants yet" — icon: leaf/plant, title: "No plants yet", description: "Add your first plant to get started."
- "No containers yet" — icon: box/pot, title: "No containers yet", description: "Add a container to group your plants."
**Files to update:**
1. `src/modules/garden/components/plant-list.tsx` line 22: `<p className="text-sm text-[var(--ink-mute)]">No plants yet.</p>``<EmptyState ... />`
2. `src/modules/garden/components/container-list.tsx` line 39: `"No containers yet. Add one to start organising your plants."``<EmptyState ... />`
3. `src/modules/garden/components/container-detail.tsx` line 169: `<p className="text-sm text-[var(--ink-mute)]">No plants in this container yet.</p>` → inline EmptyState or keep as brief fallback (it's a sub-section, not a full page)
**Import:**
```tsx
import { EmptyState } from "@/components/ui/empty"
import { Sprout, Container } from "lucide-react"
// In plant-list:
<EmptyState
icon={Sprout}
title="No plants yet"
description="Add your first plant to get started."
/>
// In container-list:
<EmptyState
icon={Container}
title="No containers yet"
description="Add a container to start organising your plants."
/>
```
**Acceptance for 3a:**
- EmptyState imported from `@/components/ui/empty` in all garden list pages
- Zero ad-hoc empty text paragraphs remain in garden component files (check plant-list, container-list, and any other garden page)
- Garden empty states are visually consistent (same icon size, title weight, description color)
---
### Task 3b: Toggle-group for notify channels
**File:** `src/components/notify-channel-toggles.tsx`
**Current state:** Each channel is a native `<input type="checkbox">` wrapped in a label with custom styling. No grouping or visual cohesion.
**Change to ToggleGroup + ToggleGroupItem:**
```tsx
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
export function NotifyChannelToggles({
push, inapp, ntfy, ntfyConfigured,
}: { ... }) {
const channels: { key: Channel; value: boolean; label: string; disabled?: boolean }[] = [
{ key: "push", value: push, label: "Web push" },
{ key: "inapp", value: inapp, label: "In-app inbox" },
{ key: "ntfy", value: ntfy, label: "ntfy", disabled: !ntfyConfigured },
];
const enabledKeys = channels.filter(c => c.value && !c.disabled).map(c => c.key);
return (
<ToggleGroup type="multiple" defaultValue={enabledKeys} onValueChange={(v) => handleToggle(v)}>
{channels.map(({ key, value, label, disabled }) => (
<ToggleGroupItem
key={key}
value={key}
disabled={disabled || !("PushManager" in window) && key === "push"}
aria-label={label}
>
{label}
</ToggleGroupItem>
))}
</ToggleGroup>
);
}
```
**Important:** The ToggleGroup needs to integrate with the existing server action flow (`setNotifChannel`). The `onValueChange` handler should:
1. Detect which channels were added/removed from the selection
2. Call `setNotifChannel(channel, enabled)` for each change
3. Still trigger an optimistic UI update
**Alternative approach** (safer — use individual ToggleGroupItems as independent switches): Since these are truly independent toggles (enabling push doesn't require disabling inapp), we keep the current per-channel rendering but swap `<input type="checkbox">` to shadcn's `<Switch>` component from `@/components/ui/switch`:
```tsx
import { Switch } from "@/components/ui/switch";
// Each channel:
<label className="flex items-center justify-between gap-4">
<span className="text-sm">{label}</span>
<Switch
checked={value}
disabled={disabled || isPending}
onCheckedChange={(v) => toggle(key, v)}
/>
</label>;
```
**Decision:** The `<Switch>` approach is more appropriate here. ToggleGroup is for mutually-exclusive selection; Switch is for independent on/off toggles — which matches the notification channels use case perfectly. **Use `Switch` from shadcn, not ToggleGroup.**
**Acceptance for 3b:**
- Each channel uses shadcn `<Switch>` component (not native checkbox)
- Visual appearance matches the app's design system
- Server action integration works correctly (toggle enables/disables on the server)
- Disabled state handled properly for ntfy when unconfigured
---
## Acceptance criteria
1. EmptyState component from `@/components/ui/empty` used in all garden list pages
2. All ad-hoc "No plants yet." / "No containers yet." text replaced with EmptyState or kept as contextual inline text where appropriate
3. Notification channels use shadcn `<Switch>` components
4. Visual consistency across garden empty states
## Steps
1. Use existing `src/components/ui/empty.tsx` EmptyState in plant-list.tsx and container-list.tsx
2. Replace Switch component imports in notify-channel-toggles.tsx
3. Verify server action integration for toggles
4. Run `pnpm lint` and `pnpm typecheck`
+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.
+114
View File
@@ -0,0 +1,114 @@
# 76 — Shadcn component implementation (Tiers 13)
## Goal
Implement the three shadcn component adoption tiers that replace ad-hoc UI patterns across the app with shadcn/ui primitives. This is the **execution** phase — components are already installed (see `2026-06-13-shadcn-component-install.md`).
| Tier | Name | Tasks | Files touched |
| ---- | ------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------ |
| 1 | Garden badges → Badge, topbar avatar → Avatar, + fallback, semantic token | 5 sub-tasks | topbar.tsx, sidebar.tsx, plant-detail.tsx, push-opt-in.tsx, care-schedule-editor.tsx, plant-widget.tsx |
| 2 | Skeleton loading, Tabs nav, Sonner toasts, FieldGroup audit, Separator | 5 sub-tasks | d/[slug]/page.tsx, plant-detail.tsx, container-detail.tsx, layout.tsx, notify-channel-toggles.tsx |
| 3 | Garden empty-state, Switch for notify channels | 2 sub-tasks | plant-list.tsx, container-list.tsx, notify-channel-toggles.tsx |
## Depends on
- `2026-06-13-shadcn-component-install.md` — components are already in `src/components/ui/`
- The shadcn migration map at `2026-06-13-shadcn-migration.md`
## Scope by tier
### Tier 1 (detailed brief: `docs/tasks/1a-badges-avatars.md`)
**Task 1a — Garden health badges → `<Badge>`**
- File: `src/modules/garden/components/plant-detail.tsx` lines 44-48, 119-123
- Replace hand-crafted `badge-success/danger/warning` classes with shadcn `<Badge variant="default|destructive|secondary">`
- Delete the `healthBadgeClass()` helper function
**Task 1b — Topbar avatar → `<Avatar>`**
- File: `src/components/topbar.tsx` lines 60-75
- Replace manual `<span className="avatar">` with `<Avatar><AvatarImage/><AvatarFallback/></Avatar>`
**Task 1c — HouseholdPill avatars → `<Avatar>`**
- File: `src/components/sidebar.tsx` lines 106-120
- Replace inner span elements with Avatar components (keep avatarColor hash for fallback background)
**Task 1d — Generic AvatarFallbackWithName component**
- New file: `src/components/avatar-fallback.tsx`
- Thin wrapper that handles the Image/Fallback pattern with name/initial logic
- Imported by both topbar.tsx and sidebar.tsx to remove duplication
**Task 1e — share-green → semantic token**
- Files: push-opt-in.tsx (line ~104), care-schedule-editor.tsx (line ~185), plant-widget.tsx urgencyLabel, plus any other green-600/green-500 in garden components
- Add `--c-success` CSS variable to the global theme
- Replace all hardcoded green-600 references with `var(--c-success)`
### Tier 2 (detailed brief: `docs/tasks/1b-skeleton-tabs-toasts.md`)
**Task 2a — Skeleton loading states**
- d/[slug]/page.tsx inline animate-pulse → `<Skeleton>` components
- plant-detail.tsx & container-detail.tsx uploadingImage → skeleton overlay
- Any other animate-pulse patterns outside skeleton.tsx itself
**Task 2b — Settings/garden Tabs navigation → `<Tabs>`**
- File: `src/modules/garden/components/plant-detail.tsx` lines 162-180
- File: `src/modules/garden/components/container-detail.tsx` lines 138-155
- Replace manual button-based tabs with shadcn Tabs/TabsList/TabsTrigger/TabsContent
**Task 2c — Toast notifications via sonner**
- Add `<Toaster>` to app layout (`src/app/layout.tsx`)
- Convert delete confirmations, calendar schedule success/error, and share-link actions to `toast.success()`/`toast.error()` calls
**Task 2d — Form FieldGroup audit**
- Audit: container-form.tsx, plant-form.tsx, household edit forms
- Check consistency of Label + Input + error pattern
- Fix any non-shadcn form fields found
**Task 2e — Manual separators → `<Separator>`**
- Remove border-bottom on garden detail tab bars, replace with shadcn Separator (usually alongside the Tabs component)
### Tier 3 (detailed brief: `docs/tasks/1c-emptystate-switch.md`)
**Task 3a — Garden empty-state component**
- File: `src/modules/garden/components/plant-list.tsx` line 22 → use EmptyState from ui/empty.tsx
- File: `src/modules/garden/components/container-list.tsx` line 39 → use EmptyState
- Use appropriate icons (Sprout for plants, Container for containers)
**Task 3b — Notify channel toggles → `<Switch>`**
- File: `src/components/notify-channel-toggles.tsx`
- Replace native `<input type="checkbox">` with shadcn `<Switch>` component
- Keep server action integration (setNotifChannel)
- Disabled state for ntfy when unconfigured
## Acceptance criteria (all tiers)
1. Zero `badge-success`, `badge-danger`, `badge-warning` class names remain in garden code
2. All avatars rendered via shadcn Avatar components (verified by DOM/JSX inspection)
3. All loading states use `<Skeleton>` — no animate-pulse patterns outside skeleton.tsx
4. Garden detail pages use shadcn Tabs component (not manual buttons)
5. Toaster rendered in app layout, delete/cancel actions show toasts
6. EmptyState used in all garden list pages (no ad-hoc empty text paragraphs)
7. Notification channels use shadcn Switch components
8. `pnpm lint` passes with zero errors/warnings
9. `pnpm typecheck` passes with zero errors
## Execution order
Execute tiers sequentially: 1 → 2 → 3. Each tier builds on the previous one's patterns and fixes foundational issues before adding polish.
Detailed task briefs are in:
- Tier 1: `docs/tasks/1a-badges-avatars.md`
- Tier 2: `docs/tasks/1b-skeleton-tabs-toasts.md`
- Tier 3: `docs/tasks/1c-emptystate-switch.md`
+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
}
]
}
+15 -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",
@@ -68,36 +67,44 @@
"pino-pretty": "^13.1.3",
"prettier": "^3.3.3",
"release-it": "^20.2.0",
"sharp": "^0.34.5",
"tailwindcss": "^4.2.4",
"tsx": "^4.19.4",
"typescript": "^5.6.3",
"typescript-eslint": "^8.15.0"
},
"dependencies": {
"@auth/core": "0.41.2",
"@auth/drizzle-adapter": "^1.11.2",
"@base-ui/react": "^1.4.1",
"@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",
"next-themes": "^0.4.6",
"pino": "^10.3.1",
"postgres": "^3.4.9",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-grid-layout": "^2.2.3",
"react-resizable": "^3.1.3",
"server-only": "^0.0.1",
"shadcn": "^4.7.0",
"sonner": "^2.0.7",
"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"
}
}
+356 -121
View File
File diff suppressed because it is too large Load Diff
+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}` });
}
+8 -21
View File
@@ -9,7 +9,9 @@ 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 { Skeleton } from "@/components/ui/skeleton";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { dashboards } from "@/modules/_core/schema";
@@ -31,9 +33,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 +82,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 +96,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>
@@ -127,10 +114,10 @@ export default async function DashboardPage({
<CardContent>
<Suspense
fallback={
<div className="animate-pulse space-y-2">
<div className="h-3 w-3/4 rounded bg-muted" />
<div className="h-3 w-1/2 rounded bg-muted" />
<div className="h-3 w-2/3 rounded bg-muted" />
<div className="space-y-3">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-2/3" />
</div>
}
>
-29
View File
@@ -19,22 +19,6 @@ export type DashboardMeta = {
position: number;
};
export async function listDashboards(): Promise<DashboardMeta[]> {
const { user } = await getCurrentSession();
const rows = await db
.select({
id: dashboards.id,
name: dashboards.name,
slug: dashboards.slug,
isDefault: dashboards.isDefault,
position: dashboards.position,
})
.from(dashboards)
.where(eq(dashboards.userId, user.id))
.orderBy(asc(dashboards.position), asc(dashboards.createdAt));
return rows;
}
export async function getDefaultDashboardSlug(): Promise<string> {
const { user } = await getCurrentSession();
const rows = await db
@@ -160,19 +144,6 @@ export async function setDefaultDashboard(id: string): Promise<void> {
revalidatePath("/");
}
export async function reorderDashboards(orderedIds: string[]): Promise<void> {
const { user } = await getCurrentSession();
await Promise.all(
orderedIds.map((id, i) =>
db
.update(dashboards)
.set({ position: i })
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id))),
),
);
revalidatePath("/");
}
export async function saveDashboardLayout(id: string, layout: DashboardLayout): Promise<void> {
const { user } = await getCurrentSession();
// Validate each widget's config against its registered schema
+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>
);
}
+220 -2
View File
@@ -67,6 +67,7 @@
--radius-md: var(--r-md);
--radius-lg: var(--r-lg);
--radius-xl: var(--r-xl);
--color-ok-dark: var(--c-success);
}
/* ── Paper-and-ink primitives (shared across all palettes) ────────── */
@@ -98,7 +99,7 @@
--ok: #4f7a3f;
--warn: #c99a3f;
--bad: #b05246;
--c-success: var(--ok);
/* Density (regular by default; overridden via [data-density]) */
--row: 48px;
--pad: 14px;
@@ -342,6 +343,7 @@
--ok: #82b070;
--warn: #dcb46a;
--bad: #d8746a;
--c-success: var(--ok);
--background: var(--paper);
--foreground: var(--ink);
@@ -490,6 +492,8 @@
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
body {
@apply bg-background text-foreground;
@@ -521,6 +525,7 @@
display: grid;
grid-template-columns: 232px 1fr;
height: 100vh;
height: 100dvh;
background: var(--paper);
}
:where(html[data-nav="rail"]) .app {
@@ -725,7 +730,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 +1300,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;
}
}
+8
View File
@@ -15,6 +15,7 @@ import { CommandPalette } from "@/components/command-palette";
import { PwaRegister } from "@/components/pwa-register";
import { InstallPrompt } from "@/components/install-prompt";
import { AppShell } from "@/components/app-shell";
import { AppToaster } from "@/components/app-toaster";
import { DEFAULT_THEME, navStyleToDataNav } from "@/modules/_core/themes";
import type { Palette, ThemeMode, FontPair, Density, NavStyle } from "@/modules/_core/themes";
@@ -42,6 +43,7 @@ const jetbrains = JetBrains_Mono({
export const viewport: Viewport = {
themeColor: "#1F1B16",
viewportFit: "cover",
};
export const metadata: Metadata = {
@@ -54,6 +56,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",
},
};
@@ -163,6 +170,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<CommandPalette />
<InstallPrompt />
<PwaRegister />
<AppToaster position="bottom-right" />
</QuickAddProvider>
</body>
</html>
+3 -1
View File
@@ -38,7 +38,9 @@ export default async function SharePage({ params }: { params: Promise<{ token: s
return <ShareError message="This content type cannot be shared." />;
}
const data = await entityReg.loadForShare(resolved.entityId);
const data = await entityReg.loadForShare(resolved.entityId, {
householdId: resolved.householdId,
});
if (!data) {
recordFailure(rlKey);
return <ShareError />;
+6 -7
View File
@@ -9,6 +9,7 @@ import { PushOptIn } from "@/components/push-opt-in";
import { NotifyChannelToggles } from "@/components/notify-channel-toggles";
import { ResponsiveSidebar } from "@/components/settings-section";
import type { SectionId } from "@/components/settings-section";
import { AvatarFallbackWithName } from "@/components/avatar-fallback";
import { revokeShareLinkAction } from "./actions";
import { listCalendars } from "@/modules/calendar/server/queries";
import { listLists } from "@/modules/lists/server/queries";
@@ -43,7 +44,7 @@ export default async function SettingsPage({
<div className="grid gap-5 sm:grid-cols-[220px_1fr]">
<ResponsiveSidebar active={section} />
<div className="space-y-4">
{section === "household" && <HouseholdSection household={household} userName={user.name} />}
{section === "household" && <HouseholdSection household={household} user={user} />}
{section === "sharing" && <SharingSection />}
{section === "notifications" && (
<NotificationsSection user={user} vapidKey={vapidKey} ntfyConfigured={ntfyConfigured} />
@@ -58,10 +59,10 @@ export default async function SettingsPage({
function HouseholdSection({
household,
userName,
user,
}: {
household: { id: string; name: string };
userName: string | null;
user: { name: string | null; image: string | null };
}) {
return (
<>
@@ -91,11 +92,9 @@ function HouseholdSection({
</CardHeader>
<CardContent>
<div className="set-row" style={{ borderBottom: "0", padding: 0 }}>
<span className="avatar avatar-lg" style={{ background: "var(--c-household)" }}>
{(userName ?? "?").trim()[0]?.toUpperCase()}
</span>
<AvatarFallbackWithName name={user.name ?? undefined} image={user.image} />
<div className="label">
<div className="t">{userName ?? "Anonymous"}</div>
<div className="t">{user.name ?? "Anonymous"}</div>
<div className="d">Signed in via Authentik</div>
</div>
</div>
-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>
);
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useEffect, useState } from "react";
import { Toaster as Sonner, type ToasterProps } from "sonner";
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react";
export function AppToaster({ ...props }: ToasterProps) {
const [theme, setTheme] = useState<"light" | "dark">("light");
useEffect(() => {
const sync = () => {
setTheme(document.documentElement.classList.contains("dark") ? "dark" : "light");
};
sync();
const observer = new MutationObserver(sync);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect();
}, []);
return (
<Sonner
theme={theme}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
export function AvatarFallbackWithName({
name,
initial,
image,
size = "default",
className,
fallbackStyle,
title,
}: {
name?: string;
initial?: string;
image?: string | null;
size?: "sm" | "default" | "lg";
className?: string;
fallbackStyle?: React.CSSProperties;
title?: string;
}) {
const computedInitial = initial ?? name?.trim()[0]?.toUpperCase() ?? "?";
const sizeClass =
size === "sm" ? "size-7 text-xs" : size === "lg" ? "size-10 text-base" : "size-8 text-sm";
return (
<span title={title}>
<Avatar className={cn(sizeClass, className)}>
{image ? (
<AvatarImage src={image} alt={name ?? ""} />
) : (
<AvatarFallback
style={fallbackStyle}
className={cn(!fallbackStyle && "bg-[var(--c-household)]")}
>
{computedInitial}
</AvatarFallback>
)}
</Avatar>
</span>
);
}
+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 -10
View File
@@ -1,21 +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
</span>
);
}
export function BrandWordmark({ className }: { className?: string }) {
return (
<span className={cn("brand", className)}>
<BrandMark />
<span className="brand-name">famapp</span>
<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>
);
}
+3 -3
View File
@@ -139,7 +139,7 @@ export function DashboardEditor({
onClick={() => setPresetMenuOpen((o) => !o)}
disabled={isPending}
>
<LayoutGrid className="size-4 mr-1" />
<LayoutGrid data-icon="inline-start" />
Preset
</Button>
{presetMenuOpen && (
@@ -173,7 +173,7 @@ export function DashboardEditor({
)}
</div>
<Button variant="outline" size="sm" onClick={handleReset} disabled={isPending}>
<RotateCcw className="size-4 mr-1" />
<RotateCcw data-icon="inline-start" />
Reset
</Button>
<Button
@@ -182,7 +182,7 @@ export function DashboardEditor({
onClick={() => setPickerOpen(true)}
disabled={isPending}
>
<Plus className="size-4 mr-1" />
<Plus data-icon="inline-start" />
Add widget
</Button>
<Button variant="outline" size="sm" onClick={handleCancel} disabled={isPending}>
+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>
);
}
+23 -18
View File
@@ -13,6 +13,7 @@ import type { DashboardMeta } from "@/app/d/actions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
@@ -168,28 +169,32 @@ function DashboardKebab({
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setRenaming(true)}>
<PenLine className="size-4 mr-2" />
Rename
</DropdownMenuItem>
{!dashboard.isDefault && (
<DropdownMenuItem
onSelect={() => startTransition(() => setDefaultDashboard(dashboard.id))}
>
<Star className="size-4 mr-2" />
Set as default
<DropdownMenuGroup>
<DropdownMenuItem onSelect={() => setRenaming(true)}>
<PenLine />
Rename
</DropdownMenuItem>
)}
{!dashboard.isDefault && (
<DropdownMenuItem
onSelect={() => startTransition(() => setDefaultDashboard(dashboard.id))}
>
<Star />
Set as default
</DropdownMenuItem>
)}
</DropdownMenuGroup>
{canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => startTransition(() => deleteDashboard(dashboard.id))}
>
<Trash2 className="size-4 mr-2" />
Delete
</DropdownMenuItem>
<DropdownMenuGroup>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => startTransition(() => deleteDashboard(dashboard.id))}
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</>
)}
</DropdownMenuContent>
-14
View File
@@ -1,14 +0,0 @@
"use client";
import { useQuickAdd } from "@/components/quick-add-provider";
import { NavIcon } from "@/components/nav-icon";
export function Fab() {
const { openSheet } = useQuickAdd();
return (
<button type="button" className="fab" aria-label="Quick add" onClick={openSheet}>
<NavIcon name="plus" className="size-6" strokeWidth={2.4} />
</button>
);
}
+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,
+3 -4
View File
@@ -2,6 +2,7 @@
import { useTransition } from "react";
import { setNotifChannel } from "@/app/settings/notify-actions";
import { Switch } from "@/components/ui/switch";
type Channel = "push" | "inapp" | "ntfy";
@@ -46,12 +47,10 @@ export function NotifyChannelToggles({
<span className="ml-2 text-xs text-muted-foreground">(NTFY_URL not configured)</span>
)}
</span>
<input
type="checkbox"
<Switch
checked={value}
disabled={isPending || disabled}
onChange={(e) => toggle(key, e.target.checked)}
className="size-4 cursor-pointer"
onCheckedChange={(checked) => toggle(key, checked)}
/>
</label>
))}
+1 -1
View File
@@ -101,7 +101,7 @@ export function PushOptIn({ vapidKey }: { vapidKey: string }) {
if (status === "subscribed") {
return (
<div className="flex items-center gap-3">
<span className="text-sm text-green-600 dark:text-green-400">
<span className="text-sm text-[var(--c-success)] dark:text-[var(--c-success)]">
Push notifications enabled
</span>
<Button size="sm" variant="outline" onClick={sendTest} disabled={isPending}>
+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">
+12 -6
View File
@@ -17,7 +17,7 @@ const SECTIONS = [
export type SectionId = (typeof SECTIONS)[number]["id"];
export function SettingsSidebar({ active }: { active: SectionId }) {
function SettingsSidebar({ active }: { active: SectionId }) {
const router = useRouter();
const pathname = usePathname();
@@ -78,23 +78,29 @@ function SectionHashSync({ pathname }: { pathname: string | null }) {
return null;
}
export function SettingsTabsMobile({ active }: { active: SectionId }) {
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>
+23 -10
View File
@@ -2,6 +2,7 @@
import { useState, useTransition } from "react";
import { Link, Check, Copy } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
@@ -19,15 +20,23 @@ 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) {
const message = err instanceof Error ? err.message : "Failed to create share link";
setError(message);
toast.error(message);
}
});
}
@@ -35,16 +44,20 @@ export function ShareButton({
if (!shareUrl) return;
navigator.clipboard.writeText(shareUrl).then(() => {
setCopied(true);
toast.success("Copied to clipboard");
setTimeout(() => setCopied(false), 2000);
});
}
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 data-icon="inline-start" />
Share
</Button>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
@@ -58,7 +71,7 @@ export function ShareButton({
<div className="flex gap-2">
<Input readOnly value={shareUrl ?? ""} className="font-mono text-xs" />
<Button variant="outline" size="icon" onClick={copyUrl} aria-label="Copy link">
{copied ? <Check className="text-green-600" /> : <Copy />}
{copied ? <Check className="text-[var(--c-success)]" /> : <Copy />}
</Button>
</div>
</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>
);
}
+12 -17
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import { eq } from "drizzle-orm";
import { cn } from "@/lib/utils";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { getRegistry } from "@/modules/_core/registry";
@@ -7,6 +8,7 @@ import { householdMembers, households, users } from "@/modules/_core/schema";
import { BrandMark } from "@/components/brand-mark";
import { NavIcon } from "@/components/nav-icon";
import { NavLink } from "@/components/nav-link";
import { AvatarFallbackWithName } from "@/components/avatar-fallback";
type Variant = "side" | "top";
@@ -102,23 +104,16 @@ function HouseholdPill({
return (
<div className="household-pill" title={info.household.name}>
<div style={{ display: "flex" }}>
{visible.map((m, i) => {
const initial = (m.name ?? m.email ?? "?").trim()[0]?.toUpperCase() ?? "?";
return (
<span
key={m.id}
className="avatar avatar-sm"
style={{
background: avatarColor(m.id),
marginLeft: i > 0 ? -6 : 0,
boxShadow: "0 0 0 1.5px var(--card)",
}}
title={m.name ?? m.email ?? undefined}
>
{initial}
</span>
);
})}
{visible.map((m, i) => (
<AvatarFallbackWithName
key={m.id}
name={m.name ?? m.email ?? undefined}
image={m.image}
className={cn(i > 0 && "-ml-1.5", "shadow-[0_0_0_1.5px_var(--card)]")}
fallbackStyle={{ background: avatarColor(m.id) }}
title={m.name ?? m.email ?? undefined}
/>
))}
</div>
<div className="household-text" style={{ minWidth: 0 }}>
<div
+63 -33
View File
@@ -23,6 +23,7 @@ import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
@@ -41,6 +42,11 @@ interface Props {
signedIn?: boolean;
}
const PALETTE_ITEMS = PALETTES.map((p) => ({ label: p.label, value: p.id }));
const FONT_PAIR_ITEMS = FONT_PAIRS.map((f) => ({ label: f.label, value: f.id }));
const DASH_LAYOUT_ITEMS = DASH_LAYOUTS.map((d) => ({ label: d.label, value: d.id }));
const NAV_STYLE_ITEMS = NAV_STYLES.map((n) => ({ label: n.label, value: n.id }));
export function ThemePicker({
initialPalette = "clay",
initialMode = "system",
@@ -68,7 +74,11 @@ export function ThemePicker({
<div className="space-y-5">
<div className="space-y-1.5">
<Label>Theme</Label>
<Select value={t.palette} onValueChange={(v) => t.setPalette(v as Palette)}>
<Select
items={PALETTE_ITEMS}
value={t.palette}
onValueChange={(v) => t.setPalette(v as Palette)}
>
<SelectTrigger className="w-56">
<div className="flex items-center gap-2 min-w-0">
<span
@@ -84,20 +94,22 @@ export function ThemePicker({
</div>
</SelectTrigger>
<SelectContent>
{PALETTES.map((p) => (
<SelectItem key={p.id} value={p.id}>
<div className="flex items-center justify-between gap-6 w-40">
<span>{p.label}</span>
<span
className="inline-block w-5 h-4 rounded-sm shrink-0 overflow-hidden border-[0.5px]"
style={{
background: `linear-gradient(to bottom, ${p.paper} 55%, ${p.hex} 55%)`,
borderColor: "var(--hair-2)",
}}
/>
</div>
</SelectItem>
))}
<SelectGroup>
{PALETTES.map((p) => (
<SelectItem key={p.id} value={p.id}>
<div className="flex items-center justify-between gap-6 w-40">
<span>{p.label}</span>
<span
className="inline-block w-5 h-4 rounded-sm shrink-0 overflow-hidden border-[0.5px]"
style={{
background: `linear-gradient(to bottom, ${p.paper} 55%, ${p.hex} 55%)`,
borderColor: "var(--hair-2)",
}}
/>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
@@ -121,16 +133,22 @@ export function ThemePicker({
<div className="space-y-1.5">
<Label>Type pairing</Label>
<Select value={t.fontPair} onValueChange={(v) => t.setFontPair(v as FontPair)}>
<Select
items={FONT_PAIR_ITEMS}
value={t.fontPair}
onValueChange={(v) => t.setFontPair(v as FontPair)}
>
<SelectTrigger className="w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FONT_PAIRS.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.label}
</SelectItem>
))}
<SelectGroup>
{FONT_PAIRS.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
@@ -154,16 +172,22 @@ export function ThemePicker({
<div className="space-y-1.5">
<Label>Dashboard layout</Label>
<Select value={t.dashLayout} onValueChange={(v) => t.setDashLayout(v as DashLayout)}>
<Select
items={DASH_LAYOUT_ITEMS}
value={t.dashLayout}
onValueChange={(v) => t.setDashLayout(v as DashLayout)}
>
<SelectTrigger className="w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DASH_LAYOUTS.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.label}
</SelectItem>
))}
<SelectGroup>
{DASH_LAYOUTS.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
@@ -187,16 +211,22 @@ export function ThemePicker({
<div className="space-y-1.5">
<Label>Navigation</Label>
<Select value={t.navStyle} onValueChange={(v) => t.setNavStyle(v as NavStyle)}>
<Select
items={NAV_STYLE_ITEMS}
value={t.navStyle}
onValueChange={(v) => t.setNavStyle(v as NavStyle)}
>
<SelectTrigger className="w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{NAV_STYLES.map((n) => (
<SelectItem key={n.id} value={n.id}>
{n.label}
</SelectItem>
))}
<SelectGroup>
{NAV_STYLES.map((n) => (
<SelectItem key={n.id} value={n.id}>
{n.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
+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>
</>
);
}
+6 -28
View File
@@ -6,7 +6,7 @@ import { NotificationBell } from "@/components/notification-bell";
import { TopbarSearch } from "@/components/topbar-search";
import { TopbarTitle } from "@/components/topbar-title";
import { TopbarNewButton } from "@/components/topbar-new-button";
import { NavIcon } from "@/components/nav-icon";
import { AvatarFallbackWithName } from "@/components/avatar-fallback";
async function getNotifications(userId: string) {
const rows = await db
@@ -36,7 +36,6 @@ export async function Topbar() {
? await getNotifications(userId)
: { rows: [], unread: 0 };
const userRow = userId ? await getUserAvatar(userId) : null;
const initial = (userRow?.name ?? userRow?.email ?? "?").trim()[0]?.toUpperCase() ?? "?";
return (
<div className="topbar">
@@ -57,35 +56,14 @@ export async function Topbar() {
)}
<TopbarNewButton />
{userId && (
<span
className="avatar"
style={{ width: 28, height: 28, fontSize: 12, background: "var(--c-household)" }}
<AvatarFallbackWithName
name={userRow?.name ?? userRow?.email ?? undefined}
image={userRow?.image}
size="sm"
title={userRow?.name ?? userRow?.email ?? undefined}
>
{userRow?.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={userRow.image}
alt=""
style={{ width: "100%", height: "100%", borderRadius: "50%", objectFit: "cover" }}
/>
) : (
initial
)}
</span>
/>
)}
</div>
</div>
);
}
export function TopbarSpacer() {
return (
<div className="topbar">
<h1 className="serif">famapp</h1>
<div className="topbar-actions">
<NavIcon name="bell" className="size-4 text-[var(--ink-mute)]" />
</div>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className,
)}
{...props}
/>
);
}
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className,
)}
{...props}
/>
);
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="alert-action" className={cn("absolute top-2 right-2", className)} {...props} />
);
}
export { Alert, AlertTitle, AlertDescription, AlertAction };
+93
View File
@@ -0,0 +1,93 @@
"use client";
import * as React from "react";
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
import { cn } from "@/lib/utils";
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg";
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className,
)}
{...props}
/>
);
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full rounded-full object-cover", className)}
{...props}
/>
);
}
function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className,
)}
{...props}
/>
);
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className,
)}
{...props}
/>
);
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className,
)}
{...props}
/>
);
}
function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className,
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarBadge };

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