Compare commits

..
30 Commits
Author SHA1 Message Date
ginnoir a58a91d8b8 chore: release v0.6.0
CI / checks (push) Failing after 3m1s
CI / build (push) Successful in 5m17s
Release Image / build-and-push (push) Successful in 6m32s
2026-07-04 23:26:40 -05:00
ginnoir e5e509081c feat: per-user assistant name and system prompt customization
CI / checks (push) Failing after 2m12s
CI / build (push) Successful in 4m50s
Each user can rename the AI assistant and edit their own system prompt in Settings.
2026-07-04 23:26:17 -05:00
ginnoir a2e5eb111d docs: registry is primary image source, ghcr is backup mirror
CI / checks (push) Failing after 2m34s
CI / build (push) Successful in 4m38s
2026-07-04 23:03:56 -05:00
ginnoir cf50f20714 docs: record v0.5.6 prod deploy and migration repair
CI / checks (push) Failing after 2m13s
CI / build (push) Successful in 4m36s
2026-07-04 22:49:34 -05:00
ginnoir fd338384b7 chore: release v0.5.6
CI / checks (push) Failing after 2m11s
CI / build (push) Successful in 4m41s
Release Image / build-and-push (push) Successful in 6m22s
2026-07-04 22:43:01 -05:00
ginnoir de738274fa fix: add build-time env vars to docker image build
CI / checks (push) Failing after 2m12s
CI / build (push) Successful in 4m40s
2026-07-04 22:42:53 -05:00
ginnoir 9d13d6a430 chore: release v0.5.5
CI / checks (push) Failing after 2m11s
CI / build (push) Successful in 4m40s
Release Image / build-and-push (push) Failing after 4m8s
2026-07-04 22:40:11 -05:00
ginnoir 060a2f19a9 chore: release v0.5.4
CI / checks (push) Failing after 2m12s
CI / build (push) Successful in 4m39s
2026-07-04 22:39:45 -05:00
ginnoir 6183fb62c8 fix: dashboard edit previews, migration journal, and notes comments
CI / checks (push) Failing after 2m8s
CI / build (push) Successful in 4m42s
Register drizzle journal entries for 0022/0023 so prod migrations apply.

Key edit-mode widget previews by index so bangs.stats shows live data.

Add comments to notes detail pages.
2026-07-04 22:39:21 -05:00
ginnoir 9e66c12eb7 docs: add commit hash for p2 batch 28-31 in status 2026-07-04 22:17:49 -05:00
ginnoir e1c2a090fb feat: p2 batch 28-31 reminders lists comments bang stats
Calendar events support multiple reminder offsets with presets and per-user defaults.

Lists index adds inline task entry and list property editing.

Generic entity comments on list detail. New bangs.stats dashboard widget.

Closes Gitea #28, #29, #30, #31. Migrations 0022 and 0023.
2026-07-04 22:17:35 -05:00
ginnoir 7714b1187c docs: record journal polish commit in status
CI / checks (push) Failing after 2m7s
CI / build (push) Successful in 4m35s
2026-07-04 22:04:10 -05:00
ginnoir a09747c314 feat: journal dashboard widgets, agent polish, and edit-mode live previews
CI / checks (push) Failing after 2m7s
CI / build (push) Successful in 4m36s
Journal dashboard widgets and quick-add; rich-text quick-add dialogs.

Dashboard draft sync for live edit previews; assistant bubble + API tools.

Journal UX: stress slider, mood grid, query cap fix.
2026-07-04 22:03:45 -05:00
ginnoir 4a924a4107 feat(agent): add llm assistant chat with api tools (task 88)
OpenAI-compatible client with mock fallback, tool-calling loop, and /assistant UI.

Tools map to /api/v1/ endpoints per ADR 0006 direct-tools decision.
2026-07-04 19:46:09 -05:00
ginnoir 04ae809e07 feat(journal): add per-user mood journal module (task 86)
Ship journal entries with mood tracking, insights, and Recharts charts.

Adds v1 API endpoints per ADR 0005 and migration 0020_journal_entries.
2026-07-04 19:41:15 -05:00
ginnoir 8e2ddd6b72 docs: accept adr 0005 journal module decisions 2026-07-04 19:37:19 -05:00
ginnoir a4be5d5061 feat(notes): rich-text editor with tiptap (task 85)
Replace plain textarea with shared tiptap editor and sanitized html rendering.

Adds interactive checklists on read surfaces and mobile overflow fixes.
2026-07-04 19:25:15 -05:00
ginnoir 67f67525ff docs: accept adr 0004 rich-text editor decisions 2026-07-04 19:20:29 -05:00
ginnoir fb67692a08 docs: mark task 87 done in status 2026-07-04 19:14:00 -05:00
ginnoir e8d13bede8 feat: api v1 garden bangs routes and openapi 2026-07-04 19:13:49 -05:00
ginnoir d4304b005c feat: api v1 routes for calendar lists and notes 2026-07-04 19:03:08 -05:00
ginnoir ea5d1d050c feat: household api token auth foundation 2026-07-04 18:57:31 -05:00
ginnoir 2c0c31540e docs: accept adr 0006 api and agent architecture 2026-07-04 18:54:27 -05:00
ginnoir 5e711cfe0d docs: mark task 84 done, bugs batch complete 2026-07-04 18:23:48 -05:00
ginnoir d090200ec8 feat: shared back navigation on detail pages 2026-07-04 18:23:15 -05:00
ginnoir 1a1c080d18 docs: mark task 83 done in status 2026-07-04 18:21:04 -05:00
ginnoir 753f653b89 feat: bang edit and delete 2026-07-04 18:20:38 -05:00
ginnoir 1a03763e1b docs: mark task 82 done in status 2026-07-04 18:16:27 -05:00
ginnoir 700ee29f83 fix: garden container plant count 2026-07-04 18:15:54 -05:00
ginnoir 7eeb2f15bc fix: dashboard edit mode renders live widgets 2026-07-04 18:10:55 -05:00
212 changed files with 14508 additions and 997 deletions
+10
View File
@@ -31,6 +31,9 @@ NTFY_TOPIC=
# Logging # Logging
LOG_LEVEL=info LOG_LEVEL=info
# Household API token (generated in Settings → Data & backups; stored hashed in DB)
# Clients send: Authorization: Bearer <token> on /api/v1/* requests
# GitHub (required for pnpm release — creates a GitHub Release) # GitHub (required for pnpm release — creates a GitHub Release)
GITHUB_TOKEN= GITHUB_TOKEN=
@@ -43,3 +46,10 @@ MINIO_BUCKET=garden
# OpenPlantBook plant species API (https://open.plantbook.io — free account required) # OpenPlantBook plant species API (https://open.plantbook.io — free account required)
OPENPLANTBOOK_CLIENT_ID= OPENPLANTBOOK_CLIENT_ID=
OPENPLANTBOOK_CLIENT_SECRET= OPENPLANTBOOK_CLIENT_SECRET=
# LLM assistant (OpenAI-compatible — Ollama, vLLM, LiteLLM, etc.)
# Leave LLM_BASE_URL unset to use the built-in mock provider (CI / local without a model).
LLM_PROVIDER=openai
LLM_BASE_URL=
LLM_API_KEY=
LLM_MODEL=llama3.2
+3
View File
@@ -1,5 +1,8 @@
name: Release Image name: Release Image
# Primary release pipeline — pushes to registry.ginnoir.com (prod pulls this).
# GitHub Actions .github/workflows/release.yml mirrors to GHCR as backup only.
on: on:
push: push:
tags: tags:
+4 -1
View File
@@ -1,4 +1,7 @@
name: Release name: Release (GHCR mirror)
# Temporary backup mirror only. Production pulls from registry.ginnoir.com via
# .gitea/workflows/release.yml — do not deploy from ghcr.io directly.
on: on:
push: push:
+1 -1
View File
@@ -67,7 +67,7 @@ src/
### Core primitives every module gets ### Core primitives every module gets
- **Entity registry.** Modules declare entity types; share-link, activity log, search, reminders all work against any registered entity. - **Entity registry.** Modules declare entity types; share-link, activity log, search, reminders all work against any registered entity.
- **Dashboard widget registry.** Every widget is uniformly configurable (no singleton/parameterized split) and reusable — each placement on a dashboard is an independent instance with its own config. Each user has multiple dashboards; the active dashboard composes whatever widgets they've placed. - **Dashboard widget registry.** Every widget is uniformly configurable (no singleton/parameterized split) and reusable — each placement on a dashboard is an independent instance with its own config. Each user has multiple dashboards; the active dashboard composes whatever widgets they've placed. **Edit mode** (`?edit=1`) must pre-render live widget content (task 81): server-side `DashboardWidgetContent` per placement, keyed by index in `widgetContents`; `render` loads real data; never show meta-description placeholders for saved placements.
- **Quick-add registry.** Modules register quick actions for the dashboard's `+` menu. - **Quick-add registry.** Modules register quick actions for the dashboard's `+` menu.
- **Share-link service.** `createShareLink(entityType, entityId, { expiresAt, capabilities })``fam.ginnoir.com/s/<token>`. Generic. - **Share-link service.** `createShareLink(entityType, entityId, { expiresAt, capabilities })``fam.ginnoir.com/s/<token>`. Generic.
- **Notification bus.** `notify(userId, { title, body, url })` fans out to web push + in-app + (optional) ntfy. - **Notification bus.** `notify(userId, { title, body, url })` fans out to web push + in-app + (optional) ntfy.
+84
View File
@@ -1,5 +1,89 @@
# Changelog # Changelog
## [0.6.0](https://github.com/ginnoir/famapp/compare/v0.5.6...v0.6.0) (2026-07-05)
### Features
- per-user assistant name and system prompt customization ([e5e5090](https://github.com/ginnoir/famapp/commit/e5e509081c611b8f84e2e529f82bfa5bd7f9da52))
### Documentation
- record v0.5.6 prod deploy and migration repair ([cf50f20](https://github.com/ginnoir/famapp/commit/cf50f2071442948b8d8493b464f74bc05f3900b2))
- registry is primary image source, ghcr is backup mirror ([a2e5eb1](https://github.com/ginnoir/famapp/commit/a2e5eb111d44d7781bc3852a6d4346cb10387b52))
## [0.5.6](https://github.com/ginnoir/famapp/compare/v0.5.5...v0.5.6) (2026-07-05)
### Bug Fixes
- add build-time env vars to docker image build ([de73827](https://github.com/ginnoir/famapp/commit/de738274face84f05a029718cf011c0ef455371d))
## [0.5.5](https://github.com/ginnoir/famapp/compare/v0.5.4...v0.5.5) (2026-07-05)
### Features
- **agent:** add llm assistant chat with api tools (task 88) ([4a924a4](https://github.com/ginnoir/famapp/commit/4a924a41072a43484c009757a1644cd42309fc0e))
- api v1 garden bangs routes and openapi ([e8d13be](https://github.com/ginnoir/famapp/commit/e8d13bede81b5a778118420292e7f35e4e54788e))
- api v1 routes for calendar lists and notes ([d4304b0](https://github.com/ginnoir/famapp/commit/d4304b005cf86d6d3c14497afbb6e0973a2a8d54))
- bang edit and delete ([753f653](https://github.com/ginnoir/famapp/commit/753f653b89f244b263023dbe744e30c5eaf2893f))
- household api token auth foundation ([ea5d1d0](https://github.com/ginnoir/famapp/commit/ea5d1d050ca5e0aa8a532330448e2f2cdbda0c39))
- journal dashboard widgets, agent polish, and edit-mode live previews ([a09747c](https://github.com/ginnoir/famapp/commit/a09747c3142e089199bfd3839631c9d8573175f0))
- **journal:** add per-user mood journal module (task 86) ([04ae809](https://github.com/ginnoir/famapp/commit/04ae809e0710c3930b0056fbb1f869fc70223131))
- **notes:** rich-text editor with tiptap (task 85) ([a4be5d5](https://github.com/ginnoir/famapp/commit/a4be5d5061d41cb9b4ba37fc89adfb68c00466f7))
- p2 batch 28-31 reminders lists comments bang stats ([e1c2a09](https://github.com/ginnoir/famapp/commit/e1c2a090fb379dcd5d453a658a74a5085b473281)), closes [#28](https://github.com/ginnoir/famapp/issues/28) [#29](https://github.com/ginnoir/famapp/issues/29) [#30](https://github.com/ginnoir/famapp/issues/30)
- shared back navigation on detail pages ([d090200](https://github.com/ginnoir/famapp/commit/d090200ec8950d43fe1e1f71bf1248cf71759199))
### Bug Fixes
- dashboard edit mode renders live widgets ([7eeb2f1](https://github.com/ginnoir/famapp/commit/7eeb2f15bcab4cfbb33ba1138449b74d01ff5953))
- dashboard edit previews, migration journal, and notes comments ([6183fb6](https://github.com/ginnoir/famapp/commit/6183fb62c88a51ff967da428edfbf1b8b6d70d84))
- garden container plant count ([700ee29](https://github.com/ginnoir/famapp/commit/700ee29f83dd8e6b3580168d5944672c3441a8da))
- quick add opens create ui ([68a573c](https://github.com/ginnoir/famapp/commit/68a573c4d656f4a2c25d5b15d4f7ff1849a76bdf))
### Documentation
- accept adr 0004 rich-text editor decisions ([67f6752](https://github.com/ginnoir/famapp/commit/67f67525ff7732868839e0e615078522b57e9ddf))
- accept adr 0005 journal module decisions ([8e2ddd6](https://github.com/ginnoir/famapp/commit/8e2ddd6b7216381e480a657980e5619d488d01c9))
- accept adr 0006 api and agent architecture ([2c0c315](https://github.com/ginnoir/famapp/commit/2c0c31540ed276582984e6dd45f37d7672e60b63))
- add backlog triage implementation plan ([310ba48](https://github.com/ginnoir/famapp/commit/310ba48e335409e108e8b4387acee234db784adb))
- add commit hash for p2 batch 28-31 in status ([9e66c12](https://github.com/ginnoir/famapp/commit/9e66c12eb7afc4dcb6e0203f0e32a5ce8d0200f1))
- add post-v0.1 backlog triage design ([72e868d](https://github.com/ginnoir/famapp/commit/72e868dda1f96dd132d3e1b6bfaa54ed1c5883ef))
- complete Phase 9 backlog triage (issues map, briefs, ADRs) ([76f6854](https://github.com/ginnoir/famapp/commit/76f68548b2609aa8e672dba563acf90a30ba09ed)), closes [1-#37](https://github.com/ginnoir/1-/issues/37)
- mark task 82 done in status ([1a03763](https://github.com/ginnoir/famapp/commit/1a03763e1bf992766dfd7891e3eb815534e92038))
- mark task 83 done in status ([1a1c080](https://github.com/ginnoir/famapp/commit/1a1c080d18e2c22838949dfed8e77a54fab1dcb5))
- mark task 84 done, bugs batch complete ([5e711cf](https://github.com/ginnoir/famapp/commit/5e711cfe0d7bd779114af19fe111375abe269953))
- mark task 87 done in status ([fb67692](https://github.com/ginnoir/famapp/commit/fb67692a08973f9e6e14f7c2ec336cad5bdaf777))
- record journal polish commit in status ([7714b11](https://github.com/ginnoir/famapp/commit/7714b1187c76d1d324573bc8819568751f991b09))
* fix: dashboard edit previews, migration journal, and notes comments (6183fb6)
* docs: add commit hash for p2 batch 28-31 in status (9e66c12)
* feat: p2 batch 28-31 reminders lists comments bang stats (e1c2a09)
* docs: record journal polish commit in status (7714b11)
* feat: journal dashboard widgets, agent polish, and edit-mode live previews (a09747c)
* feat(agent): add llm assistant chat with api tools (task 88) (4a924a4)
* feat(journal): add per-user mood journal module (task 86) (04ae809)
* docs: accept adr 0005 journal module decisions (8e2ddd6)
* feat(notes): rich-text editor with tiptap (task 85) (a4be5d5)
* docs: accept adr 0004 rich-text editor decisions (67f6752)
* docs: mark task 87 done in status (fb67692)
* feat: api v1 garden bangs routes and openapi (e8d13be)
* feat: api v1 routes for calendar lists and notes (d4304b0)
* feat: household api token auth foundation (ea5d1d0)
* docs: accept adr 0006 api and agent architecture (2c0c315)
* docs: mark task 84 done, bugs batch complete (5e711cf)
* feat: shared back navigation on detail pages (d090200)
* docs: mark task 83 done in status (1a1c080)
* feat: bang edit and delete (753f653)
* docs: mark task 82 done in status (1a03763)
* fix: garden container plant count (700ee29)
* fix: dashboard edit mode renders live widgets (7eeb2f1)
* test: cover all quick-add create dialogs in e2e (02b6ec6)
* fix: quick add opens create ui (68a573c)
* docs: complete Phase 9 backlog triage (issues map, briefs, ADRs) (76f6854)
* chore: ignore .worktrees for agent isolation (5dfc8b8)
* docs: add backlog triage implementation plan (310ba48)
* docs: add post-v0.1 backlog triage design (72e868d)
* chore: vendor react-best-practices agent skill for cursor agents (3d825fb)
## [0.5.3](https://github.com/ginnoir/famapp/compare/v0.5.2...v0.5.3) (2026-06-04) ## [0.5.3](https://github.com/ginnoir/famapp/compare/v0.5.2...v0.5.3) (2026-06-04)
### Bug Fixes ### Bug Fixes
+1 -1
View File
@@ -68,7 +68,7 @@ src/
### Core primitives every module gets ### Core primitives every module gets
- **Entity registry.** Modules declare entity types; share-link, activity log, search, reminders all work against any registered entity. - **Entity registry.** Modules declare entity types; share-link, activity log, search, reminders all work against any registered entity.
- **Dashboard widget registry.** Every widget is uniformly configurable (no singleton/parameterized split) and reusable — each placement on a dashboard is an independent instance with its own config. Each user has multiple dashboards; the active dashboard composes whatever widgets they've placed. - **Dashboard widget registry.** Every widget is uniformly configurable (no singleton/parameterized split) and reusable — each placement on a dashboard is an independent instance with its own config. Each user has multiple dashboards; the active dashboard composes whatever widgets they've placed. **Edit mode** (`?edit=1`) must pre-render live widget content (task 81): server-side `DashboardWidgetContent` per placement, keyed by index in `widgetContents`; `render` loads real data; never show meta-description placeholders for saved placements.
- **Quick-add registry.** Modules register quick actions for the dashboard's `+` menu. - **Quick-add registry.** Modules register quick actions for the dashboard's `+` menu.
- **Share-link service.** `createShareLink(entityType, entityId, { expiresAt, capabilities })``fam.ginnoir.com/s/<token>`. Generic. - **Share-link service.** `createShareLink(entityType, entityId, { expiresAt, capabilities })``fam.ginnoir.com/s/<token>`. Generic.
- **Notification bus.** `notify(userId, { title, body, url })` fans out to web push + in-app + (optional) ntfy. - **Notification bus.** `notify(userId, { title, body, url })` fans out to web push + in-app + (optional) ntfy.
+3
View File
@@ -14,6 +14,9 @@ WORKDIR /app
# CI=true prevents pnpm from prompting for TTY confirmation when removing modules dir # CI=true prevents pnpm from prompting for TTY confirmation when removing modules dir
ENV CI=true ENV CI=true
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV DATABASE_URL=postgres://build:build@localhost:5432/build
ENV AUTH_SECRET=build-time-placeholder
ENV NEXT_PUBLIC_APP_URL=http://localhost:3000
COPY . . COPY . .
RUN pnpm install --offline --frozen-lockfile RUN pnpm install --offline --frozen-lockfile
RUN --mount=type=cache,id=famapp-nextjs,target=/app/.next/cache \ RUN --mount=type=cache,id=famapp-nextjs,target=/app/.next/cache \
+1 -1
View File
@@ -4,7 +4,7 @@ Self-hosted family coordination web app. Shared calendar, lists, notes, and gard
[![CI](https://github.com/ginnoir/famapp/actions/workflows/ci.yml/badge.svg)](https://github.com/ginnoir/famapp/actions/workflows/ci.yml) [![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) [![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) [![Docker image](https://img.shields.io/badge/image-registry.ginnoir.com%2Fginnoir%2Ffamapp-blue?logo=docker)](https://gitea.ginnoir.com/ginnoir/famapp)
[![Node ≥ 22](https://img.shields.io/badge/node-%E2%89%A522-brightgreen?logo=node.js)](https://nodejs.org) [![Node ≥ 22](https://img.shields.io/badge/node-%E2%89%A522-brightgreen?logo=node.js)](https://nodejs.org)
--- ---
+35 -6
View File
@@ -4,6 +4,35 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b
## Done ## Done
- **80 — Quick-add create UI** (commit `68a573c`). Quick-add actions open create dialogs/sheets in-place via `createKey` + `QuickAddCreateHost`; calendar, lists, notes, garden, bangs wired. E2E in `tests/e2e/quick-add.spec.ts`.
- **81 — Dashboard edit live widgets** (commit `7eeb2f1`). Edit mode server-pre-renders real widget content via `DashboardWidgetContent` + `widgetContents` map; no placeholder→content reflow on save. E2E in `tests/e2e/dashboard.spec.ts`.
- **82 — Garden container plant count** (commit `700ee29`). Fixed broken Drizzle subquery in `getContainer` and refactored `listContainers` plant count aggregation. E2E in `tests/e2e/garden.spec.ts`.
- **83 — Bang edit and delete** (commit `753f653`). `updateBang`/`deleteBang` server actions with activity log; edit/delete UI on bang widget recent rows. E2E in `tests/e2e/bangs.spec.ts`.
- **84 — Back navigation** (commit `d090200`). Shared `DetailBackLink` on garden/notes/lists detail, create, and edit pages plus household settings. E2E in `tests/e2e/navigation.spec.ts`.
- **87 — HTTP API v1** (commits `ea5d1d0`, `d4304b0`, `e8d13be`). Household bearer token + session auth; `/api/v1/` routes for calendar, lists, notes, garden, bangs; OpenAPI in `docs/api/openapi.yaml`; owner token UI in Settings → Data. Unit tests: `api-auth`, `api-v1-calendar`, `api-v1-auth`. ADR 0006. Run `pnpm db:migrate` for migration `0019_household_api_tokens`.
- **85 — Rich-text notes** (ADR 0004 accepted `67f6752`). TipTap editor in `src/components/rich-text/`; notes create/edit + index/widget/share surfaces; interactive checklists; DOMPurify sanitization; lazy plain-text→HTML migration; mobile overflow CSS; uploads `?scope=notes`. Unit tests: `rich-text.test.ts`. E2E: `tests/e2e/notes.spec.ts` (formatted note + 375px overflow).
- **86 — Journal module** (ADR 0005 accepted `8e2ddd6`). Per-user `journal_entries` schema + migration `0020_journal_entries.sql`; mood catalog (multi-select), optional stress/pills, rich-text body; index with calendar dots, entry editor, Recharts mood tracker, insights (streak/trends/correlations); `/api/v1/journal/entries` CRUD with bearer token auth; OpenAPI updated. Unit tests: `journal-analytics.test.ts`. E2E: `tests/e2e/journal.spec.ts`. Run `pnpm db:migrate` for migration `0020`.
- **88 — LLM agent chat** (ADR 0006). Opt-in floating chat bubble; OpenAI-compatible LLM client (`LLM_BASE_URL` / `LLM_MODEL`); mock provider when unset; direct tool schemas → `/api/v1/` HTTP calls; `POST /api/agent/chat`. Per-user `assistant_enabled` (default off). Unit tests: `agent-chat.test.ts`. E2E: `tests/e2e/assistant.spec.ts`. Migration `0021`.
- **Journal + dashboard polish** (commit `a09747c`, follow-on to 86/81/80/85/88). Journal dashboard widgets (`journal.recent`, `journal.mood-tracker` with year/month config); journal quick-add dialog (moods, stress/pills on by default, `StressSlider`, rich-text reflection); mood tracker UI redesign + `?day=` navigation; `listMoodTrackerEntries` (500 cap) fixes widget Zod error. Dashboard edit mode uses cookie draft (`dashboard-editor-draft.ts`, `syncEditorDraftLayout`) so add/remove/config refreshes live widget previews. Rich-text in quick-add (notes, calendar, journal) and calendar event notes. Agent bubble UI + expanded API tools (garden care, share links, OpenAPI docs). Widget picker hover contrast fix. Gitea #38 (animation polish) remains open.
- **89 — Calendar reminders overhaul** (Gitea #28, commit `e1c2a09`). Multiple reminders per event via `syncCalendarEventReminders`; `ReminderPicker` with presets + custom offsets; migration `0022_multiple_reminders.sql` drops one-per-entity unique index, adds `offset_minutes` + `users.default_event_reminder_offsets`. Settings → Notifications default reminders editor. Create/edit/quick-add calendar flows updated; drag-reschedule resyncs existing offsets.
- **90 — Lists index inline add + edit** (Gitea #29, commit `e1c2a09`). `/lists` cards: inline task add row, pencil edit for name/type via `updateListProperties`.
- **91 — Comments on lists and tasks** (Gitea #30, commit `e1c2a09`). Generic `comments` table + `_core/comments.ts`; reusable `EntityComments` on list detail (list + per-item compact threads). Migration `0023_comments.sql`.
- **92 — Bang stats dashboard widget** (Gitea #31, commit `e1c2a09`). `bangs.stats` widget with monthly/yearly counts, average days between bangs, recent month breakdown.
- **P2 batch follow-ups** (release **v0.5.6**, prod deployed 2026-07-05). Migration journal entries for `0022`/`0023`; dashboard edit previews keyed by placement index (`bangs.stats` live in edit mode); notes comments; Dockerfile build-time env vars (fixes release CI). **Prod:** pulled `ghcr.io/ginnoir/famapp:latest` → tagged `registry.ginnoir.com/ginnoir/famapp:latest`, recreated `famapp` container. Prod DB had 21/24 migration rows (0022/0023 applied out of order); applied missing `0019``0021` schema + journal rows — now 24 migrations, `comments` + `default_event_reminder_offsets` confirmed.
- **01 — Repo init & tooling** (commit `b89690a`). pnpm 10 + TS strict + ESLint flat + Prettier. All acceptance criteria green. - **01 — Repo init & tooling** (commit `b89690a`). pnpm 10 + TS strict + ESLint flat + Prettier. All acceptance criteria green.
- **02 — Next.js app skeleton**. Next.js 15 + React 19 + Tailwind v4 + shadcn/ui (button, card, input, dialog). `pnpm dev` serves placeholder, `pnpm build` produces `.next/standalone/`, `pnpm lint` clean. Added `.npmrc` with `node-linker=hoisted` for Windows symlink compatibility. - **02 — Next.js app skeleton**. Next.js 15 + React 19 + Tailwind v4 + shadcn/ui (button, card, input, dialog). `pnpm dev` serves placeholder, `pnpm build` produces `.next/standalone/`, `pnpm lint` clean. Added `.npmrc` with `node-linker=hoisted` for Windows symlink compatibility.
- **03 — Drizzle + Postgres setup**. drizzle-orm + postgres driver + drizzle-kit wired up. `src/modules/_core/schema.ts` declares `users`, `households`, `household_members`. `docker-compose.dev.yaml` starts Postgres 16. `drizzle/0000_silent_magma.sql` generated and applied. `tsc --noEmit` passes. - **03 — Drizzle + Postgres setup**. drizzle-orm + postgres driver + drizzle-kit wired up. `src/modules/_core/schema.ts` declares `users`, `households`, `household_members`. `docker-compose.dev.yaml` starts Postgres 16. `drizzle/0000_silent_magma.sql` generated and applied. `tsc --noEmit` passes.
@@ -46,15 +75,15 @@ Phase 9 — Post-v0.1 (see `docs/superpowers/specs/2026-07-03-backlog-triage-des
**Batch order:** **Batch order:**
1. Bugs: tasks 8084 (quick-add, dashboard edit, garden count, bangs, back-nav) 1. ~~Bugs: tasks 8084~~ — done
2. API foundation: task 87 (+ ADR 0006) 2. ~~API foundation: task 87 (+ ADR 0006)~~ — done
3. Shared rich-text + notes overhaul: task 85 (+ ADR 0004); closes notes mobile overflow 3. ~~Shared rich-text + notes overhaul: task 85 (+ ADR 0004)~~ — done
4. Journal: task 86 (+ ADR 0005), including journal API endpoints 4. ~~Journal: task 86 (+ ADR 0005), including journal API endpoints~~ — done
5. LLM agent chat: task 88 5. ~~LLM agent chat: task 88~~ — done
P2/P3 backlog is filed on Gitea only (no task briefs yet) — see `docs/issues-map.md` designs 79, 1112, 1519. P2/P3 backlog is filed on Gitea only (no task briefs yet) — see `docs/issues-map.md` designs 79, 1112, 1519.
**How to resume:** Read AGENTS.md / CLAUDE.md / STATUS.md, open the next unchecked task in `docs/tasks/80``88`, stop at acceptance criteria. **How to resume:** P2 batch #28#31 complete (released + deployed v0.5.6 on prod). Next: remaining P2/P3 Gitea backlog (#32+) or homelab LLM wiring (`LLM_BASE_URL` in homelabstack `.env`).
## Development login/testing notes ## Development login/testing notes
+5 -1
View File
@@ -15,12 +15,16 @@ pnpm release:patch # or :minor / :major
# Requires GITHUB_TOKEN in .env # Requires GITHUB_TOKEN in .env
``` ```
Gitea Actions (`release.yml`) then builds and pushes the Docker image to the self-hosted registry: Gitea Actions (`.gitea/workflows/release.yml`) builds and pushes the Docker image to the **self-hosted registry** (production source of truth):
- `registry.ginnoir.com/ginnoir/famapp:v0.x.y` - `registry.ginnoir.com/ginnoir/famapp:v0.x.y`
- `registry.ginnoir.com/ginnoir/famapp:0.x` (minor alias) - `registry.ginnoir.com/ginnoir/famapp:0.x` (minor alias)
- `registry.ginnoir.com/ginnoir/famapp:latest` - `registry.ginnoir.com/ginnoir/famapp:latest`
GitHub Actions (`.github/workflows/release.yml`) mirrors the same tag to **GHCR** as a temporary backup only — prod must not pull from `ghcr.io`.
Wait for the Gitea Actions run to finish (or confirm the new digest on the registry) before redeploying.
## Deploying a release ## Deploying a release
On the home server, in `/srv/famapp/deploy/`: On the home server, in `/srv/famapp/deploy/`:
+96
View File
@@ -0,0 +1,96 @@
# famapp HTTP API (v1)
REST JSON API under `/api/v1/`. Full specification: [`openapi.yaml`](openapi.yaml).
## Authentication
Every endpoint accepts **either**:
- Auth.js session cookie (browser login), or
- `Authorization: Bearer <household-api-token>` header (recommended for external clients)
Unauthorized requests return `401` with `{ "error": "Unauthorized" }`.
Generate bearer tokens in **Settings → API tokens**. Example:
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" https://fam.ginnoir.com/api/v1/calendars
```
## OpenAPI
See [`openapi.yaml`](openapi.yaml) for paths, request/response schemas, and the `bearerAuth` security scheme. Import into Swagger UI, Postman, or your HTTP client of choice.
## Endpoints
### Calendars
| Method | Path | Description |
| ------ | ----------------------- | -------------------------------------------------------- |
| GET | `/api/v1/calendars` | List visible calendars |
| POST | `/api/v1/calendars` | Create calendar (`name`, optional `color`, `visibility`) |
| GET | `/api/v1/calendars/:id` | Get calendar |
| PATCH | `/api/v1/calendars/:id` | Update `name`, `visibility`, `color` |
| DELETE | `/api/v1/calendars/:id` | Delete calendar |
### Events
| Method | Path | Description |
| ------ | --------------------------------------- | --------------------------------------------------------------------- |
| GET | `/api/v1/events?from=&to=&calendarIds=` | List events in range (`calendarIds` = `all` or comma-separated UUIDs) |
| POST | `/api/v1/events` | Create event |
| GET | `/api/v1/events/:id` | Get event |
| PATCH | `/api/v1/events/:id` | Update event |
| DELETE | `/api/v1/events/:id` | Delete event |
### Lists
| Method | Path | Description |
| ------ | --------------------------------- | ------------------------------------------------------------- |
| GET | `/api/v1/lists?type=` | List lists (optional `type` filter, e.g. `shopping`, `tasks`) |
| POST | `/api/v1/lists` | Create list (`type`, `name`) |
| GET | `/api/v1/lists/:id` | Get list with items |
| PATCH | `/api/v1/lists/:id` | Update `name` or `archived` |
| DELETE | `/api/v1/lists/:id` | Archive list |
| GET | `/api/v1/lists/:id/items` | List items |
| POST | `/api/v1/lists/:id/items` | Add item |
| PATCH | `/api/v1/lists/:id/items/:itemId` | Update or toggle item (`done`) |
| DELETE | `/api/v1/lists/:id/items/:itemId` | Delete item |
### Notes
| Method | Path | Description |
| ------ | ------------------- | ----------- |
| GET | `/api/v1/notes` | List notes |
| POST | `/api/v1/notes` | Create note |
| GET | `/api/v1/notes/:id` | Get note |
| PATCH | `/api/v1/notes/:id` | Update note |
| DELETE | `/api/v1/notes/:id` | Delete note |
### Garden
| Method | Path | Description |
| ------ | ------------------------------- | -------------------------------------- |
| GET | `/api/v1/garden/containers` | List containers |
| POST | `/api/v1/garden/containers` | Create container |
| GET | `/api/v1/garden/containers/:id` | Get container with plants |
| PATCH | `/api/v1/garden/containers/:id` | Update container |
| DELETE | `/api/v1/garden/containers/:id` | Delete container |
| GET | `/api/v1/garden/plants` | List plants (optional `?containerId=`) |
| POST | `/api/v1/garden/plants` | Create plant |
| GET | `/api/v1/garden/plants/:id` | Get plant detail |
| PATCH | `/api/v1/garden/plants/:id` | Update plant |
| DELETE | `/api/v1/garden/plants/:id` | Delete plant |
### Bangs
| Method | Path | Description |
| ------ | ------------------- | ------------------------------------------------ |
| GET | `/api/v1/bangs` | Stats: `total` + `recent` (optional `?limit=10`) |
| POST | `/api/v1/bangs` | Record bang (optional `occurredOn` YYYY-MM-DD) |
| PATCH | `/api/v1/bangs/:id` | Update `occurredOn` |
| DELETE | `/api/v1/bangs/:id` | Delete bang |
## Bearer token visibility
Bearer tokens see **household-visible calendars only** (private calendars are hidden). Mutations on calendars require ownership (session) or household visibility (bearer). Activity log records `actorId: null` for bearer mutations.
File diff suppressed because it is too large Load Diff
+77 -4
View File
@@ -1,16 +1,89 @@
# 0004 — Rich-text editor library and storage format # 0004 — Rich-text editor library and storage format
Date: 2026-07-03 Date: 2026-07-03
Status: proposed Status: accepted (2026-07-04)
## Context ## Context
Journal (task 86) and notes overhaul (task 85) need a shared rich-text editor with emoji support. Research and choose an existing library (e.g. TipTap/ProseMirror-family) rather than building one. Gitea: [#8](https://gitea.ginnoir.com/ginnoir/famapp/issues/8) (epic [#7](https://gitea.ginnoir.com/ginnoir/famapp/issues/7)). Notes (task 85) and journal (task 86) need a shared rich-text editor with emoji and modern formatting. Notes today use a plain `<textarea>` and a minimal custom markdown preview (`#` headings, `-` lists only). Body is stored as plain text in `notes.body`. Mobile horizontal overflow is a known bug (Gitea #5). Gitea epic [#7](https://gitea.ginnoir.com/ginnoir/famapp/issues/7).
## What we decided (2026-07-04)
| # | Topic | Choice |
| --- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1 | Editor library | **TipTap** (ProseMirror family) — WYSIWYG, not home-grown |
| 2 | Storage format | **HTML** in existing `notes.body` text column; sanitize on every render |
| 3 | v1 formatting | **Full-featured**: headings, bold/italic, bullet + numbered lists, links, emoji, blockquotes, code blocks, tables, checklists, images, file attachments, undo/redo |
| 4 | Images & attachments | **Upload to famapp** via existing `/api/uploads`; embed returned URLs in note HTML |
| 5 | Shared component location | **`src/components/rich-text/`** — imported by notes now, journal later |
| 6 | Existing plain-text notes | **Lazy migration**: on read, non-HTML bodies wrapped as paragraphs; persisted as HTML on next save. No schema change. |
| 7 | Checklists on read surfaces | **Interactive everywhere** — index, dashboard widget, and share view can toggle checklist items (updates note via API) |
## Decision ## Decision
To be filled when research completes. ### Editor: TipTap
- Use TipTap with React bindings and official extensions for the v1 feature set.
- Export contract for the shared component:
- `RichTextEditor` — controlled `value` / `onChange` (HTML string), optional `editable`, toolbar config
- `RichTextContent` — read-only renderer with sanitization and overflow-safe CSS
- `RichTextChecklist` behavior — interactive task items on read surfaces (see below)
- Journal (task 86) imports from `src/components/rich-text/` without forking.
### Storage: HTML in `notes.body`
- `notes.body` remains a `text` column; content is HTML after first save from the new editor.
- **Sanitize** all HTML before `dangerouslySetInnerHTML` (DOMPurify or equivalent) on every read surface: editor preview, index cards, dashboard widget, share viewer.
- Allowlist tags/attributes matching TipTap output (headings, lists, task lists, tables, links, images, code, blockquote, etc.).
- API `/api/v1/notes` continues to accept/return `body` as a string (now HTML). Document in OpenAPI.
### Lazy migration for legacy notes
- Helper `normalizeNoteBody(body: string): string` — if body does not start with `<` (heuristic) or fails HTML detection, convert plain text to `<p>` paragraphs (preserve line breaks).
- Run on read paths (editor load, index excerpt, widget, share) until user saves, which persists HTML.
- No one-shot migration script required for v1.
### Images and attachments
- TipTap image + file extensions call existing upload route (`POST /api/uploads`).
- Stored URLs embedded in HTML (`<img src="…">`, attachment links as `<a href="…">`).
- Upload auth: session or household bearer token (same as other API calls).
### Interactive checklists (read surfaces)
- TipTap TaskList extension for authoring.
- On index, widget, and share surfaces: render checklists with tappable checkboxes.
- Toggle calls a server action or `PATCH /api/v1/notes/:id` that updates only the task-item `checked` state inside the HTML (parse → flip `data-checked` / class → save). Activity log optional.
- Share links with write capability may allow toggle if share token grants write (reuse existing share write path where applicable).
### Mobile overflow
- Fix as part of `RichTextContent`: `overflow-wrap: anywhere`, `max-width: 100%`, table horizontal scroll container, no fixed-width embeds. Closes Gitea #5.
## Consequences ## Consequences
To be filled when research completes. ### Positive
- One editor stack for notes + journal; wife-friendly WYSIWYG.
- HTML storage is simple to render and matches TipTap's native export.
- Lazy migration avoids a risky bulk conversion; existing notes keep working.
- Upload reuse avoids new storage infrastructure.
### Trade-offs
- Full TipTap extension set increases bundle size — acceptable for an editor route; lazy-load editor chunk on notes/journal pages.
- HTML in DB is less readable than markdown for debugging; mitigated by Drizzle Studio and API still returning strings.
- Interactive checklists on read surfaces require HTML-aware toggle logic (not trivial CRUD) — budget extra implementation time in task 85.
- Sanitization is security-critical; must test XSS vectors and keep allowlist in sync with TipTap extensions.
### Out of scope (ADR)
- Journal module UI (task 86) — consumes shared component after 85 lands.
- Collaborative editing, comments, version history.
## References
- Task brief: `docs/tasks/85-rich-text-notes.md`
- Gitea: [#7](https://gitea.ginnoir.com/ginnoir/famapp/issues/7) epic, [#8](https://gitea.ginnoir.com/ginnoir/famapp/issues/8) research child
- Current notes editor: `src/modules/notes/components/note-editor.tsx`
- Uploads: `src/app/api/uploads/route.ts`
+117 -5
View File
@@ -1,16 +1,128 @@
# 0005 — Journal / mood tracking research # 0005 — Journal / mood tracking
Date: 2026-07-03 Date: 2026-07-03
Status: proposed Status: accepted (2026-07-04)
## Context ## Context
Journal module (task 86) needs mood multi-select, charts over time, and insights. Research existing libraries/components for mood tracking or journaling and record build-vs-adopt. Gitea: [#20](https://gitea.ginnoir.com/ginnoir/famapp/issues/20) (epic [#19](https://gitea.ginnoir.com/ginnoir/famapp/issues/19)). Journal module (task 86) needs per-user entries, mood multi-select, charts over time, insights, and `/api/v1/journal` endpoints. ADR research included [Giftyaning/Mood-Tracker](https://github.com/Giftyaning/Mood-Tracker) (Gitea [#20](https://gitea.ginnoir.com/ginnoir/famapp/issues/20), epic [#19](https://gitea.ginnoir.com/ginnoir/famapp/issues/19)).
## Build-vs-adopt: Mood-Tracker
**Decision: do not adopt the repo.**
| Factor | Mood-Tracker | famapp need |
| ---------- | -------------------------- | ------------------------------------------- |
| License | None | Must have clear reuse rights |
| Stack | Vite SPA, JS, localStorage | Next.js 15, TypeScript, Postgres, Authentik |
| Mood model | Single-select, 5 presets | Multi-select fixed catalog |
| Fields | Title + plain text | Optional title + TipTap HTML, stress, pills |
| API / auth | None | `/api/v1/` + session + bearer token |
| Code shape | One 672-line `App.jsx` | `src/modules/journal/` manifest pattern |
**Borrow (reimplement in TypeScript):** mood→numeric scoring, day streak, week-over-week trend, insight card layout. Not copied source.
## What we decided (2026-07-04)
| # | Topic | Choice |
| --- | -------------- | --------------------------------------------------------------------------------------------------- |
| 1 | Charts | **Build natively + Recharts** for mood/stress trends and correlation views |
| 2 | Moods | **Fixed catalog** (~812 presets with emoji + color); **multi-select** |
| 3 | Entries | **Multiple per day** allowed |
| 4 | Stress | Optional **110** slider |
| 5 | Insights v1 | **Core + correlations** — streak, top mood, avg stress; stress↔mood and pills↔avg-mood simple stats |
| 6 | Index calendar | **Month grid** with dots on days that have entries; tap day → filtered list |
| 7 | Entry shape | **Optional title** + rich-text body (shared `RichTextEditor`) |
| 8 | Chart points | **All entries** as individual points; x-axis uses **datetime** (not date-only rollup) |
## Decision ## Decision
To be filled when research completes. ### Module: `src/modules/journal/`
Standard module layout: `schema.ts`, `manifest.tsx`, `server/`, `components/`, app routes under `/journal`.
### Data model
Table `journal_entries`:
- `household_id` — household scope for DB consistency
- `user_id`**per-user privacy**; all reads/writes filter `user_id = current user`
- `recorded_at` timestamptz — required date+time (defaults to now on create)
- `title` text nullable — optional
- `body` text — HTML from shared rich-text editor (ADR 0004)
- `moods` jsonb — array of catalog mood ids (multi-select)
- `stress` smallint nullable — 110
- `pills_taken` boolean nullable
No household-shared journal in v1. Wife cannot see Matt's entries and vice versa.
### Mood catalog
Fixed in code (`src/modules/journal/mood-catalog.ts`): ids, label, emoji, color, numeric score for analytics. Users pick from catalog only (no custom tags in v1).
### UI surfaces
| Route | Purpose |
| ------------------------------- | -------------------------------------------------------------------------------------- |
| `/journal` | Recent 510 entries, browse all link, month calendar, links to mood tracker + insights |
| `/journal/new`, `/journal/[id]` | Create/edit entry (date/time required; rest optional) |
| `/journal/mood` | Recharts mood/stress over time (all entry points) |
| `/journal/insights` | Streak, top mood, correlations |
Rich text: `RichTextEditor` / `RichTextContent` from `src/components/rich-text/`. No share viewer in v1.
### Insights v1
- Entry streak (consecutive days with ≥1 entry)
- Most common mood (selected period)
- Average stress (7d / 30d)
- Week-over-week mood trend (score-based)
- **Correlations:** simple aggregates — avg mood score on days pills taken vs not; stress vs mood score scatter or grouped averages
No ML / sentiment analysis in v1.
### API
Additive on ADR 0006 foundation:
- `GET/POST /api/v1/journal/entries`
- `GET/PATCH/DELETE /api/v1/journal/entries/:id`
Bearer token and session auth. Responses scoped to **token owner's user id** for journal (household token acts as owner user for API mutations per existing pattern, but journal entries are always stored with the authenticated user's id).
Document in `docs/api/openapi.yaml`.
### Testing
- Playwright: `tests/e2e/journal.spec.ts` — create minimal entry → list → detail → mood tracker loads
- Unit tests for mood scoring / streak helpers where non-trivial
## Consequences ## Consequences
To be filled when research completes. ### Positive
- Recharts gives proper time-series without maintaining custom chart code for long trends.
- Fixed mood catalog keeps insights comparable across entries.
- Per-user privacy matches personal journal expectations.
- Reuses rich-text stack from task 85.
### Trade-offs
- All-entry chart points mean busy days show multiple dots; datetime x-axis required.
- Fixed catalog may miss nuance — body text carries freeform detail.
- Correlation stats are descriptive only (no causation); keep copy honest in UI.
- Recharts adds bundle weight on `/journal/mood` and `/journal/insights` only (route-level code split).
### Out of scope (ADR)
- Household-shared journals
- LLM agent tools for journal (task 88)
- Custom user-defined mood tags
- Share links for journal entries
## References
- Task brief: `docs/tasks/86-journal-module.md`
- Rich text: `docs/decisions/0004-rich-text-editor.md`
- API: `docs/decisions/0006-api-llm-agent.md`
- Mood-Tracker research: https://github.com/Giftyaning/Mood-Tracker
+65 -4
View File
@@ -1,16 +1,77 @@
# 0006 — API auth and LLM agent architecture # 0006 — API auth and LLM agent architecture
Date: 2026-07-03 Date: 2026-07-03
Status: proposed Status: accepted (2026-07-04)
## Context ## Context
P1 needs a documented API with token auth coexisting with OIDC session auth, plus a provider-agnostic OpenAI-compatible agent that tool-calls the API. Decide MCP vs direct tools. Gitea: [#14](https://gitea.ginnoir.com/ginnoir/famapp/issues/14) (epic [#13](https://gitea.ginnoir.com/ginnoir/famapp/issues/13)). Tasks 87 then 88. P1 needs a documented HTTP API with token auth coexisting with OIDC session auth, plus a provider-agnostic OpenAI-compatible agent that tool-calls the API. Tasks 87 (API surface) and 88 (agent chat) depend on these choices. Gitea: [#14](https://gitea.ginnoir.com/ginnoir/famapp/issues/14) (epic [#13](https://gitea.ginnoir.com/ginnoir/famapp/issues/13)).
Existing auth: Authentik OIDC via Auth.js, database sessions, household-scoped data via `getCurrentSession()`. Mutations today are server actions; a handful of route handlers exist under `/api/` for uploads, SSE, and auth.
## What we decided (2026-07-04)
| # | Topic | Choice |
| --- | ------------------ | --------------------------------------------------------------------------------------------------- |
| 1 | API token model | **One shared household token** (not per-user) |
| 1b | Token management | **Household owner only** — create, revoke, view last-used |
| 2 | API shape | **REST JSON under `/api/v1/`** — additive versioning |
| 3 | LLM provider | **Env-configured OpenAI-compatible endpoint** (`LLM_BASE_URL`, optional `LLM_API_KEY`, `LLM_MODEL`) |
| 4 | Agent → API wiring | **Direct tools** (JSON-schema tools → HTTP calls). **No MCP server** in v1 |
## Decision ## Decision
To be filled when research completes. ### Token auth (coexists with OIDC session)
- Add a single **household-scoped API token** stored hashed in the database (`household_api_tokens` or equivalent).
- Non-browser clients send `Authorization: Bearer <token>`.
- `/api/v1/*` route handlers accept **either** a valid Auth.js session cookie **or** a valid household bearer token. Both resolve to the same household scope and permission checks.
- **OIDC / browser login is unchanged.** Middleware continues to guard pages; API routes perform their own auth (session or bearer).
- Token lifecycle UI on `/settings` (owner only): create (show raw token once), revoke, last-used timestamp.
- Activity log for API mutations may attribute `actorId` from session when present; bearer-token calls use `actorId = null` (same pattern as share-link anonymous mutations).
### API shape
- **REST** resources under `/api/v1/` with JSON request/response bodies.
- **Additive versioning**: new modules (journal, etc.) add routes under `/api/v1/` without breaking existing clients. No v2 until a breaking change is unavoidable.
- Task 87 covers: calendars, events, lists, list items, notes, garden (plants + containers), bangs. Dashboards deferred unless needed.
- **OpenAPI** spec committed as living docs (`docs/api/openapi.yaml` or generated from route definitions).
- Handlers call the same module server queries/actions as the web app — no parallel business logic.
### LLM provider (task 88)
- Famapp does **not** host a model. The agent client points at an external **OpenAI-compatible** HTTP API configured via environment variables.
- Compatible with Ollama, vLLM, LiteLLM proxy, or any `/v1/chat/completions`-style endpoint on the homelab.
- CI uses a **mock/stub provider** — no live LLM calls in tests.
### Agent tool-calling (task 88)
- **Direct tools**: the in-app agent defines tool schemas (name, description, parameters) that map to `/api/v1/` HTTP methods. The agent loop calls the API with the household bearer token.
- **No MCP server** in v1. Revisit if famapp needs to be a tool provider for Cursor or other MCP clients later.
## Consequences ## Consequences
To be filled when research completes. ### Positive
- One token to configure for scripts, wife's automations, and the agent — simple for a two-user household.
- REST + OpenAPI gives predictable integration and straightforward tool schema generation.
- Session and bearer auth share household scope; web app behavior unchanged.
- Direct tools avoid MCP transport, extra processes, and protocol overhead.
### Trade-offs
- Shared token: revoking it disables all non-browser clients at once; no per-person revocation.
- Bearer calls lack a user `actorId` in activity log unless we add optional token metadata later.
- Direct tools couple agent definitions to famapp's HTTP API; external MCP consumers would need a separate effort.
### Implementation notes (task 87)
- Schema: `household_api_tokens` with `household_id`, `token_hash`, `name` (e.g. "default"), `last_used_at`, `created_by` (owner), `revoked_at`.
- Middleware: exempt `/api/v1/` from session redirect; handlers enforce auth.
- Settings: owner-only card for token create/revoke.
- Tests: Vitest for bearer auth resolution, 401/403 paths, and representative CRUD handlers.
## References
- Task briefs: `docs/tasks/87-api-surface.md`, `docs/tasks/88-llm-agent-chat.md`
- Design: `docs/superpowers/specs/2026-07-03-backlog-triage-design.md`
+3 -3
View File
@@ -26,7 +26,7 @@ What this costs us, what it buys us.
- [0002 — List realtime uses Postgres NOTIFY and SSE](0002-list-sse-notify.md) - [0002 — List realtime uses Postgres NOTIFY and SSE](0002-list-sse-notify.md)
- [0003 — Release workflow (commitlint + release-it)](0003-release-workflow.md) - [0003 — Release workflow (commitlint + release-it)](0003-release-workflow.md)
- [0004 — Rich-text editor library and storage format](0004-rich-text-editor.md) (proposed) - [0004 — Rich-text editor library and storage format](0004-rich-text-editor.md) (accepted 2026-07-04)
- [0005 — Journal / mood tracking research](0005-journal-research.md) (proposed) - [0005 — Journal / mood tracking](0005-journal-research.md) (accepted 2026-07-04)
- [0006 — API auth and LLM agent architecture](0006-api-llm-agent.md) (proposed) - [0006 — API auth and LLM agent architecture](0006-api-llm-agent.md) (accepted 2026-07-04)
<!-- END AUTO-GENERATED --> <!-- END AUTO-GENERATED -->
+3 -2
View File
@@ -3,7 +3,7 @@
Design IDs from `docs/superpowers/specs/2026-07-03-backlog-triage-design.md` → Gitea (`ginnoir/famapp`). Design IDs from `docs/superpowers/specs/2026-07-03-backlog-triage-design.md` → Gitea (`ginnoir/famapp`).
| Design | Gitea | Title | URL | | Design | Gitea | Title | URL |
| --- | --- | --- | --- | | ------ | ----- | ------------------------------------------------------------------- | -------------------------------------------------- |
| 1 | 1 | Quick-add opens create UI, not module page | https://gitea.ginnoir.com/ginnoir/famapp/issues/1 | | 1 | 1 | Quick-add opens create UI, not module page | https://gitea.ginnoir.com/ginnoir/famapp/issues/1 |
| 2 | 2 | Dashboard edit mode renders live widgets at true size | https://gitea.ginnoir.com/ginnoir/famapp/issues/2 | | 2 | 2 | Dashboard edit mode renders live widgets at true size | https://gitea.ginnoir.com/ginnoir/famapp/issues/2 |
| 3 | 3 | Garden container plant count is wrong | https://gitea.ginnoir.com/ginnoir/famapp/issues/3 | | 3 | 3 | Garden container plant count is wrong | https://gitea.ginnoir.com/ginnoir/famapp/issues/3 |
@@ -41,11 +41,12 @@ Design IDs from `docs/superpowers/specs/2026-07-03-backlog-triage-design.md` →
| 17 | 34 | Backups and exports | https://gitea.ginnoir.com/ginnoir/famapp/issues/34 | | 17 | 34 | Backups and exports | https://gitea.ginnoir.com/ginnoir/famapp/issues/34 |
| 18 | 36 | GPS locations for calendar events | https://gitea.ginnoir.com/ginnoir/famapp/issues/36 | | 18 | 36 | GPS locations for calendar events | https://gitea.ginnoir.com/ginnoir/famapp/issues/36 |
| 19 | 37 | Lists and notes cohesion (research proposal) | https://gitea.ginnoir.com/ginnoir/famapp/issues/37 | | 19 | 37 | Lists and notes cohesion (research proposal) | https://gitea.ginnoir.com/ginnoir/famapp/issues/37 |
| — | 38 | Revisit journal day-switch animations (smoother paper transition) | https://gitea.ginnoir.com/ginnoir/famapp/issues/38 |
## Dependencies (P1) ## Dependencies (P1)
| Issue | Depends on | | Issue | Depends on |
| --- | --- | | --------------------------- | -------------------------------------- |
| #5 (notes overflow) | #7 (rich-text epic) | | #5 (notes overflow) | #7 (rich-text epic) |
| #12 (mobile overflow child) | #8 (rich-text research ADR) | | #12 (mobile overflow child) | #8 (rich-text research ADR) |
| #19 (journal epic) | #7 (rich-text epic), #15 (API surface) | | #19 (journal epic) | #7 (rich-text epic), #15 (API surface) |
+3 -3
View File
@@ -25,9 +25,9 @@ Edit mode currently shows empty placeholders; on save, content-heavy widgets (e.
## Acceptance criteria ## Acceptance criteria
- [ ] Edit mode shows real widget content for registered widgets. - [x] Edit mode shows real widget content for registered widgets.
- [ ] Saving layout does not cause a large content-driven reflow for content-heavy widgets. - [x] Saving layout does not cause a large content-driven reflow for content-heavy widgets.
- [ ] E2E covers real content visible in edit mode. - [x] E2E covers real content visible in edit mode.
## Notes ## Notes
+5 -5
View File
@@ -30,11 +30,11 @@ Journal (86) and notes need modern formatting and emoji. Notes currently overflo
## Acceptance criteria ## Acceptance criteria
- [ ] ADR 0004 accepted with library and storage format chosen. - [x] ADR 0004 accepted with library and storage format chosen.
- [ ] Shared editor component in tree. - [x] Shared editor component in tree.
- [ ] Notes create/edit use it. - [x] Notes create/edit use it.
- [ ] Formatting renders on all notes surfaces. - [x] Formatting renders on all notes surfaces.
- [ ] Mobile overflow fixed; E2E green. - [x] Mobile overflow fixed; E2E green.
## Notes ## Notes
+5 -5
View File
@@ -33,11 +33,11 @@ P1 personal journaling with mood/stress/pills tracking and stats, without blocki
## Acceptance criteria ## Acceptance criteria
- [ ] ADR 0005 accepted. - [x] ADR 0005 accepted.
- [ ] Create entry with only date/time. - [x] Create entry with only date/time.
- [ ] Index, detail, mood tracker, and insights views work. - [x] Index, detail, mood tracker, and insights views work.
- [ ] Journal API endpoints documented and callable with token auth. - [x] Journal API endpoints documented and callable with token auth.
- [ ] E2E happy path green. - [x] E2E happy path green (`tests/e2e/journal.spec.ts` — run locally with dev DB).
## Notes ## Notes
+4 -4
View File
@@ -28,10 +28,10 @@ Users should say "add milk to the shopping list" or "what's on the calendar Frid
## Acceptance criteria ## Acceptance criteria
- [ ] Chat UI sends prompts and shows responses. - [x] Chat UI sends prompts and shows responses.
- [ ] Tools call the API successfully in dev against the homelab endpoint. - [x] Tools call the API successfully in dev against the homelab endpoint.
- [ ] CI uses mock provider only. - [x] CI uses mock provider only.
- [ ] ADR 0006 records provider and MCP decisions. - [x] ADR 0006 records provider and MCP decisions.
## Notes ## Notes
@@ -0,0 +1,39 @@
# 89 — Calendar reminders overhaul
## Goal
Replace the single "Remind me 30 min before" checkbox with multiple reminders, presets, and custom offsets.
## What we decided
- Store multiple reminder rows per calendar event in the generic `reminders` table (drop one-per-entity unique constraint).
- Offsets are minutes before event start; presets + custom value/unit in UI.
- Per-user default offsets live on `users.default_event_reminder_offsets` (jsonb, default `[30]`).
- Notes/garden keep single-reminder behavior via delete-then-insert in `scheduleReminder`.
## Depends on
- 41 (reminders engine)
## Scope
- Migration: drop `reminders_entity_unique`, add `offset_minutes`, add user default column.
- `_core/reminders.ts`: `syncRemindersForEntity`, refactor `scheduleReminder`.
- Shared `ReminderPicker` component + offset helpers.
- Calendar create/edit + quick-add dialogs; sync reminders on create/update/delete.
- Optional: settings field for default reminder offsets.
## Out of scope
- Recurring reminders, snooze.
## Acceptance criteria
- [x] Users can add/remove multiple reminders on an event
- [x] Preset list and custom offset both work
- [x] Reminders fire via existing notification bus
- [x] Per-user default reminder preferences (stored + applied on new events)
## Notes
- Gitea: [ginnoir/famapp#28](https://gitea.ginnoir.com/ginnoir/famapp/issues/28)
+30
View File
@@ -0,0 +1,30 @@
# 90 — Lists index: inline task add + list property edit
## Goal
From `/lists`, add tasks and edit list properties without opening the full list page.
## What we decided
- Inline add input at the bottom of each list card (Enter to submit, same as detail page).
- Inline edit for list name and type via a small edit control on the card header.
- Reuse existing `addItem`, `renameList`, and extended `updateList` server actions.
## Depends on
- 11 (lists module)
## Scope
- Extend `listUpdateInput` to allow `type` changes.
- Update `lists-index.tsx` with add-item row and list property editor.
## Acceptance criteria
- [x] Add an item to a list from the index
- [x] Edit list properties from the index
- [x] Changes persist and match detail-page behavior
## Notes
- Gitea: [ginnoir/famapp#29](https://gitea.ginnoir.com/ginnoir/famapp/issues/29)
+39
View File
@@ -0,0 +1,39 @@
# 91 — Comments on lists and tasks
## Goal
Household members can comment on lists and individual list items using a reusable comment component.
## What we decided
- Generic `comments` table in `_core` keyed by `(entity_type, entity_id)`.
- Reusable `EntityComments` client component in `src/components/comments/`.
- Wired on list detail: list-level thread + per-item expandable comments.
- Entity-generic design so notes/other modules can adopt later without schema changes.
## Depends on
- 11 (lists module)
## Scope
- Migration: `comments` table.
- `_core/comments.ts`: list, add, delete (author-only).
- `EntityComments` UI.
- Lists detail page integration for `lists.list` and `lists.item`.
## Out of scope
- Comments on notes/calendar (future adoption of same component).
- Rich-text comments (plain text only).
## Acceptance criteria
- [x] Comment on a list
- [x] Comment on a task/item
- [x] Household members see each other's comments
- [x] Component is entity-generic (documented in brief)
## Notes
- Gitea: [ginnoir/famapp#30](https://gitea.ginnoir.com/ginnoir/famapp/issues/30)
+31
View File
@@ -0,0 +1,31 @@
# 92 — Bang stats dashboard widget
## Goal
A second bangs dashboard widget showing monthly/yearly counts and average days between bangs.
## What we decided
- Widget id `bangs.stats`, separate from `bangs.counter`.
- Aggregates computed server-side from `bang_events.occurred_on`.
- Works alongside edit/delete from task 83.
## Depends on
- Bangs module, 83 (edit/delete)
## Scope
- `getBangAggregates` query.
- `BangStatsWidget` component.
- Manifest registration.
## Acceptance criteria
- [x] Widget registered and placeable on dashboards
- [x] Shows monthly and yearly counts
- [x] Shows average days between bangs
## Notes
- Gitea: [ginnoir/famapp#31](https://gitea.ginnoir.com/ginnoir/famapp/issues/31)
+26
View File
@@ -0,0 +1,26 @@
CREATE TABLE "household_api_tokens" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"household_id" uuid NOT NULL,
"token_hash" text NOT NULL,
"name" text DEFAULT 'default' NOT NULL,
"created_by" uuid NOT NULL,
"last_used_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "household_api_tokens" ADD CONSTRAINT "household_api_tokens_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "household_api_tokens" ADD CONSTRAINT "household_api_tokens_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX "household_api_tokens_active_household_uq" ON "household_api_tokens" USING btree ("household_id") WHERE "revoked_at" IS NULL;
--> statement-breakpoint
CREATE INDEX "household_api_tokens_hash_idx" ON "household_api_tokens" USING btree ("token_hash");
+29
View File
@@ -0,0 +1,29 @@
CREATE TABLE "journal_entries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"household_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"recorded_at" timestamp with time zone NOT NULL,
"title" text,
"body" text DEFAULT '' NOT NULL,
"moods" jsonb DEFAULT '[]'::jsonb NOT NULL,
"stress" smallint,
"pills_taken" boolean,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "journal_entries" ADD CONSTRAINT "journal_entries_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "journal_entries" ADD CONSTRAINT "journal_entries_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX "journal_entries_user_recorded_idx" ON "journal_entries" USING btree ("user_id","recorded_at");
--> statement-breakpoint
CREATE INDEX "journal_entries_household_idx" ON "journal_entries" USING btree ("household_id");
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "assistant_enabled" boolean DEFAULT false NOT NULL;
+7
View File
@@ -0,0 +1,7 @@
DROP INDEX IF EXISTS "reminders_entity_unique";
--> statement-breakpoint
ALTER TABLE "reminders" ADD COLUMN "offset_minutes" integer;
--> statement-breakpoint
CREATE INDEX "reminders_entity_idx" ON "reminders" USING btree ("entity_type","entity_id");
--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "default_event_reminder_offsets" jsonb DEFAULT '[30]'::jsonb NOT NULL;
+16
View File
@@ -0,0 +1,16 @@
CREATE TABLE "comments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"household_id" uuid NOT NULL,
"entity_type" text NOT NULL,
"entity_id" uuid NOT NULL,
"author_id" uuid NOT NULL,
"body" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
CREATE INDEX "comments_entity_idx" ON "comments" USING btree ("entity_type","entity_id","created_at");
@@ -0,0 +1,2 @@
ALTER TABLE "users" ADD COLUMN "assistant_name" text DEFAULT 'Assistant' NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "assistant_system_prompt" text;
+42
View File
@@ -134,6 +134,48 @@
"when": 1748995200000, "when": 1748995200000,
"tag": "0018_container_images", "tag": "0018_container_images",
"breakpoints": true "breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1751664000000,
"tag": "0019_household_api_tokens",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1751750400000,
"tag": "0020_journal_entries",
"breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1751754000000,
"tag": "0021_user_assistant_enabled",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1780392000000,
"tag": "0022_multiple_reminders",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1780393000000,
"tag": "0023_comments",
"breakpoints": true
},
{
"idx": 24,
"version": "7",
"when": 1780394000000,
"tag": "0024_user_assistant_customization",
"breakpoints": true
} }
] ]
} }
+16 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "famapp", "name": "famapp",
"version": "0.5.3", "version": "0.6.0",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "pnpm@10.33.3", "packageManager": "pnpm@10.33.3",
@@ -83,11 +83,25 @@
"@fullcalendar/list": "^6.1.20", "@fullcalendar/list": "^6.1.20",
"@fullcalendar/react": "^6.1.20", "@fullcalendar/react": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20", "@fullcalendar/timegrid": "^6.1.20",
"@tiptap/extension-image": "^3.27.1",
"@tiptap/extension-link": "^3.27.1",
"@tiptap/extension-placeholder": "^3.27.1",
"@tiptap/extension-table": "^3.27.1",
"@tiptap/extension-table-cell": "^3.27.1",
"@tiptap/extension-table-header": "^3.27.1",
"@tiptap/extension-table-row": "^3.27.1",
"@tiptap/extension-task-item": "^3.27.1",
"@tiptap/extension-task-list": "^3.27.1",
"@tiptap/extension-underline": "^3.27.1",
"@tiptap/pm": "^3.27.1",
"@tiptap/react": "^3.27.1",
"@tiptap/starter-kit": "^3.27.1",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"isomorphic-dompurify": "^3.18.0",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"minio": "^8.0.7", "minio": "^8.0.7",
"next": "^15.5.15", "next": "^15.5.15",
@@ -99,6 +113,7 @@
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-grid-layout": "^2.2.3", "react-grid-layout": "^2.2.3",
"react-resizable": "^3.1.3", "react-resizable": "^3.1.3",
"recharts": "^3.9.2",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"shadcn": "^4.7.0", "shadcn": "^4.7.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
+1171
View File
File diff suppressed because it is too large Load Diff
+105
View File
@@ -0,0 +1,105 @@
import { z } from "zod";
import { apiError, apiJson } from "@/lib/api-handler";
import { resolveApiAuth } from "@/lib/api-auth";
import { getAssistantPreferences, resolveAssistantSystemPrompt } from "@/lib/assistant-preference";
import { isLlmConfigured } from "@/lib/llm";
import { encodeSseEvent } from "@/modules/agent/server/progress";
import { runAgentChat } from "@/modules/agent/server/run";
const chatInput = z.object({
stream: z.boolean().optional(),
messages: z
.array(
z.object({
role: z.enum(["user", "assistant"]),
content: z.string().trim().min(1).max(8000),
}),
)
.min(1)
.max(40),
});
export async function POST(request: Request) {
const auth = await resolveApiAuth(request);
if (!auth?.userId) {
return apiError("Unauthorized", 401);
}
const assistant = await getAssistantPreferences(auth.userId);
if (!assistant.enabled) {
return apiError("Assistant not enabled", 403);
}
const systemPrompt = resolveAssistantSystemPrompt(assistant.systemPrompt);
let body: unknown;
try {
body = await request.json();
} catch {
return apiError("Invalid JSON body", 400);
}
const parsed = chatInput.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0]?.message ?? "Validation error", 400);
}
if (parsed.data.stream) {
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const encoder = new TextEncoder();
const send = (event: Parameters<typeof encodeSseEvent>[0]) => {
controller.enqueue(encoder.encode(encodeSseEvent(event)));
};
try {
const result = await runAgentChat({
messages: parsed.data.messages,
request,
systemPrompt,
onProgress: send,
});
send({
type: "done",
message: {
role: "assistant",
content: result.message.content,
},
toolCalls: result.toolCalls,
});
} catch (err) {
const message = err instanceof Error ? err.message : "Agent request failed";
send({ type: "error", message });
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}
try {
const result = await runAgentChat({
messages: parsed.data.messages,
request,
systemPrompt,
});
return apiJson({
...result,
configured: isLlmConfigured(),
provider: isLlmConfigured() ? "openai" : "mock",
});
} catch (err) {
const message = err instanceof Error ? err.message : "Agent request failed";
return apiError(message, 502);
}
}
+4 -1
View File
@@ -32,6 +32,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "No file field in request" }, { status: 400 }); return NextResponse.json({ error: "No file field in request" }, { status: 400 });
} }
const url = new URL(request.url);
const scope = url.searchParams.get("scope") === "notes" ? "notes" : "garden";
if (!file.type.startsWith("image/")) { if (!file.type.startsWith("image/")) {
return NextResponse.json({ error: "Only image files are allowed" }, { status: 415 }); return NextResponse.json({ error: "Only image files are allowed" }, { status: 415 });
} }
@@ -45,7 +48,7 @@ export async function POST(request: Request) {
.replace(/[^a-z0-9]/gi, "") .replace(/[^a-z0-9]/gi, "")
.toLowerCase() .toLowerCase()
.slice(0, 8); .slice(0, 8);
const key = `garden/${session.household.id}/${randomUUID()}.${safeExt}`; const key = `${scope}/${session.household.id}/${randomUUID()}.${safeExt}`;
try { try {
await ensureBucket(); await ensureBucket();
+28
View File
@@ -0,0 +1,28 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { deleteBangForScope, updateBangForScope } from "@/modules/bangs/server/actions";
import { updateBangInput } from "@/modules/bangs/server/schemas";
import { z } from "zod";
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = updateBangInput.parse(body);
const bang = await updateBangForScope(scope, { id, ...parsed });
return apiJson({
id: bang.id,
occurredOn: bang.occurredOn,
recordedBy: bang.recordedBy,
createdAt: bang.createdAt.toISOString(),
});
});
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
await deleteBangForScope(scope, { id });
return apiJson({ ok: true });
});
}
+31
View File
@@ -0,0 +1,31 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { addBangForScope } from "@/modules/bangs/server/actions";
import { addBangInput } from "@/modules/bangs/server/schemas";
import { getBangStatsForScope } from "@/modules/bangs/server/queries";
export async function GET(request: Request) {
return withApiHandler(request, async (scope, req) => {
const url = new URL(req.url);
const limitParam = url.searchParams.get("limit");
const limit = limitParam ? Math.min(Math.max(Number(limitParam) || 10, 1), 100) : 10;
const stats = await getBangStatsForScope(scope.householdId, limit);
return apiJson(stats);
});
}
export async function POST(request: Request) {
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json().catch(() => ({}));
const parsed = addBangInput.parse(body);
const bang = await addBangForScope(scope, parsed);
return apiJson(
{
id: bang.id,
occurredOn: bang.occurredOn,
recordedBy: bang.recordedBy,
createdAt: bang.createdAt.toISOString(),
},
201,
);
});
}
+33
View File
@@ -0,0 +1,33 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { deleteCalendarForScope, updateCalendarForScope } from "@/modules/calendar/server/actions";
import { calendarUpdateInput } from "@/modules/calendar/server/schemas";
import { getCalendarForScope } from "@/modules/calendar/server/queries";
import { z } from "zod";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
const calendar = await getCalendarForScope(scope, id);
return apiJson(calendar);
});
}
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = calendarUpdateInput.parse(body);
await updateCalendarForScope(scope, { id, ...parsed });
const calendar = await getCalendarForScope(scope, id);
return apiJson(calendar);
});
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
await deleteCalendarForScope(scope, { id });
return apiJson({ ok: true });
});
}
+29
View File
@@ -0,0 +1,29 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { createCalendarForScope } from "@/modules/calendar/server/actions";
import { calendarInput } from "@/modules/calendar/server/schemas";
import { listCalendarsForScope } from "@/modules/calendar/server/queries";
export async function GET(request: Request) {
return withApiHandler(request, async (scope) => {
const calendars = await listCalendarsForScope(scope);
return apiJson(calendars);
});
}
export async function POST(request: Request) {
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = calendarInput.parse(body);
const calendar = await createCalendarForScope(scope, parsed);
return apiJson(
{
id: calendar.id,
name: calendar.name,
color: calendar.color,
visibility: calendar.visibility as "private" | "household",
ownerId: calendar.ownerId,
},
201,
);
});
}
+33
View File
@@ -0,0 +1,33 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { deleteEventForScope, updateEventForScope } from "@/modules/calendar/server/actions";
import { eventUpdateInput } from "@/modules/calendar/server/schemas";
import { getEventForScope } from "@/modules/calendar/server/queries";
import { z } from "zod";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
const event = await getEventForScope(scope, id);
return apiJson(event);
});
}
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = eventUpdateInput.parse(body);
await updateEventForScope(scope, { id, ...parsed });
const event = await getEventForScope(scope, id);
return apiJson(event);
});
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
await deleteEventForScope(scope, { id });
return apiJson({ ok: true });
});
}
+37
View File
@@ -0,0 +1,37 @@
import { apiError, apiJson, withApiHandler } from "@/lib/api-handler";
import { createEventForScope } from "@/modules/calendar/server/actions";
import { eventInput } from "@/modules/calendar/server/schemas";
import { listEventsForScope } from "@/modules/calendar/server/queries";
function parseCalendarIds(value: string | null): "all" | string[] {
if (!value || value === "all") return "all";
return value
.split(",")
.map((id) => id.trim())
.filter(Boolean);
}
export async function GET(request: Request) {
return withApiHandler(request, async (scope, req) => {
const url = new URL(req.url);
const from = url.searchParams.get("from");
const to = url.searchParams.get("to");
const calendarIds = parseCalendarIds(url.searchParams.get("calendarIds"));
if (!from || !to) {
return apiError("from and to query parameters are required", 400);
}
const events = await listEventsForScope(scope, { from, to, calendarIds });
return apiJson(events);
});
}
export async function POST(request: Request) {
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = eventInput.parse(body);
const event = await createEventForScope(scope, parsed);
return apiJson(event, 201);
});
}
@@ -0,0 +1,13 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { scheduleOnCalendarForScope } from "@/modules/garden/server/actions";
import { scheduleOnCalendarInput } from "@/modules/garden/server/schemas";
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = scheduleOnCalendarInput.parse({ ...(body as object), scheduleId: id });
await scheduleOnCalendarForScope(scope, parsed);
return apiJson({ ok: true }, 201);
});
}
@@ -0,0 +1,26 @@
import { z } from "zod";
import { apiJson, withApiHandler } from "@/lib/api-handler";
import {
deleteCareScheduleForScope,
toggleCareScheduleForScope,
} from "@/modules/garden/server/actions";
import { careScheduleToggleInput } from "@/modules/garden/server/schemas";
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = careScheduleToggleInput.parse({ ...(body as object), id });
await toggleCareScheduleForScope(scope, parsed);
return apiJson({ ok: true });
});
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
await deleteCareScheduleForScope(scope, { id });
return apiJson({ ok: true });
});
}
@@ -0,0 +1,36 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { deleteContainerForScope, updateContainerForScope } from "@/modules/garden/server/actions";
import { containerUpdateInput } from "@/modules/garden/server/schemas";
import { getContainerForScope } from "@/modules/garden/server/queries";
import { z } from "zod";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
const container = await getContainerForScope(scope.householdId, id);
if (!container) throw new Error("Container not found");
return apiJson(container);
});
}
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = containerUpdateInput.parse(body);
await updateContainerForScope(scope, { id, ...parsed });
const container = await getContainerForScope(scope.householdId, id);
if (!container) throw new Error("Container not found");
return apiJson(container);
});
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
await deleteContainerForScope(scope, { id });
return apiJson({ ok: true });
});
}
+21
View File
@@ -0,0 +1,21 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { createContainerForScope } from "@/modules/garden/server/actions";
import { containerInput } from "@/modules/garden/server/schemas";
import { getContainerForScope, listContainersForScope } from "@/modules/garden/server/queries";
export async function GET(request: Request) {
return withApiHandler(request, async (scope) => {
const containers = await listContainersForScope(scope.householdId);
return apiJson(containers);
});
}
export async function POST(request: Request) {
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = containerInput.parse(body);
const container = await createContainerForScope(scope, parsed);
const detail = await getContainerForScope(scope.householdId, container.id);
return apiJson(detail, 201);
});
}
@@ -0,0 +1,9 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { pushOverdueToTaskListForScope } from "@/modules/garden/server/actions";
export async function POST(request: Request) {
return withApiHandler(request, async (scope) => {
const result = await pushOverdueToTaskListForScope(scope);
return apiJson(result, 201);
});
}
@@ -0,0 +1,37 @@
import { z } from "zod";
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { logCareForScope } from "@/modules/garden/server/actions";
import { careLogInput } from "@/modules/garden/server/schemas";
import { getCareLogsForScope } from "@/modules/garden/server/queries";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
z.string().uuid().parse(id);
const url = new URL(req.url);
const limitRaw = url.searchParams.get("limit");
const limit = limitRaw ? z.coerce.number().int().min(1).max(100).parse(limitRaw) : 20;
const logs = await getCareLogsForScope(scope.householdId, id, limit);
return apiJson(logs);
});
}
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
z.string().uuid().parse(id);
const body: unknown = await req.json();
const parsed = careLogInput.parse({ ...(body as object), plantId: id });
const log = await logCareForScope(scope, parsed);
return apiJson(
{
id: log.id,
careType: log.careType,
notes: log.notes,
performedAt: log.performedAt.toISOString(),
performedBy: log.performedBy,
},
201,
);
});
}
@@ -0,0 +1,35 @@
import { z } from "zod";
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { upsertCareScheduleForScope } from "@/modules/garden/server/actions";
import { careScheduleInput } from "@/modules/garden/server/schemas";
import { getCareSchedulesForScope } from "@/modules/garden/server/queries";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
const schedules = await getCareSchedulesForScope(scope.householdId, id);
return apiJson(schedules);
});
}
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
z.string().uuid().parse(id);
const body: unknown = await req.json();
const parsed = careScheduleInput.parse({ ...(body as object), plantId: id });
const schedule = await upsertCareScheduleForScope(scope, parsed);
return apiJson(
{
id: schedule.id,
careType: schedule.careType,
intervalDays: schedule.intervalDays,
lastPerformedAt: schedule.lastPerformedAt?.toISOString() ?? null,
nextDueAt: schedule.nextDueAt?.toISOString() ?? null,
enabled: schedule.enabled,
},
201,
);
});
}
@@ -0,0 +1,36 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { deletePlantForScope, updatePlantForScope } from "@/modules/garden/server/actions";
import { plantUpdateInput } from "@/modules/garden/server/schemas";
import { getPlantForScope } from "@/modules/garden/server/queries";
import { z } from "zod";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
const plant = await getPlantForScope(scope.householdId, id);
if (!plant) throw new Error("Plant not found");
return apiJson(plant);
});
}
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = plantUpdateInput.parse(body);
await updatePlantForScope(scope, { id, ...parsed });
const plant = await getPlantForScope(scope.householdId, id);
if (!plant) throw new Error("Plant not found");
return apiJson(plant);
});
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
await deletePlantForScope(scope, { id });
return apiJson({ ok: true });
});
}
+23
View File
@@ -0,0 +1,23 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { createPlantForScope } from "@/modules/garden/server/actions";
import { plantInput } from "@/modules/garden/server/schemas";
import { getPlantForScope, listPlantsForScope } from "@/modules/garden/server/queries";
export async function GET(request: Request) {
return withApiHandler(request, async (scope, req) => {
const url = new URL(req.url);
const containerId = url.searchParams.get("containerId") ?? undefined;
const plants = await listPlantsForScope(scope.householdId, { containerId });
return apiJson(plants);
});
}
export async function POST(request: Request) {
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = plantInput.parse(body);
const plant = await createPlantForScope(scope, parsed);
const detail = await getPlantForScope(scope.householdId, plant.id);
return apiJson(detail, 201);
});
}
@@ -0,0 +1,33 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import {
deleteJournalEntryForScope,
updateJournalEntryForScope,
} from "@/modules/journal/server/actions";
import { updateJournalEntryInput } from "@/modules/journal/server/schemas";
import { getJournalEntryForScope } from "@/modules/journal/server/queries";
export async function GET(request: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
return withApiHandler(request, async (scope) => {
const entry = await getJournalEntryForScope(scope, id);
return apiJson(entry);
});
}
export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = updateJournalEntryInput.parse({ ...(body as object), id });
const entry = await updateJournalEntryForScope(scope, parsed);
return apiJson(entry);
});
}
export async function DELETE(request: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
return withApiHandler(request, async (scope) => {
await deleteJournalEntryForScope(scope, { id });
return apiJson({ ok: true });
});
}
+23
View File
@@ -0,0 +1,23 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { createJournalEntryForScope } from "@/modules/journal/server/actions";
import { journalEntryInput } from "@/modules/journal/server/schemas";
import { listJournalEntriesForScope } from "@/modules/journal/server/queries";
export async function GET(request: Request) {
return withApiHandler(request, async (scope, req) => {
const url = new URL(req.url);
const limitParam = url.searchParams.get("limit");
const limit = limitParam ? Math.min(Math.max(Number(limitParam) || 20, 1), 100) : undefined;
const entries = await listJournalEntriesForScope(scope, limit ? { limit } : undefined);
return apiJson(entries);
});
}
export async function POST(request: Request) {
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = journalEntryInput.parse(body);
const entry = await createJournalEntryForScope(scope, parsed);
return apiJson(entry, 201);
});
}
@@ -0,0 +1,32 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { deleteItemForScope, updateItemForScope } from "@/modules/lists/server/actions";
import { updateItemInput } from "@/modules/lists/server/schemas";
import { z } from "zod";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string; itemId: string }> },
) {
const { itemId } = await params;
return withApiHandler(request, async (scope, req) => {
z.string().uuid().parse(itemId);
const body: unknown = await req.json();
const parsed = updateItemInput.omit({ id: true }).parse(body);
const list = await updateItemForScope(scope, { id: itemId, ...parsed });
const item = list.items.find((entry) => entry.id === itemId);
if (!item) throw new Error("Item not found");
return apiJson(item);
});
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string; itemId: string }> },
) {
const { itemId } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(itemId);
await deleteItemForScope(scope, { id: itemId });
return apiJson({ ok: true });
});
}
+25
View File
@@ -0,0 +1,25 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { addItemForScope } from "@/modules/lists/server/actions";
import { itemInput } from "@/modules/lists/server/schemas";
import { listItemsForScope } from "@/modules/lists/server/queries";
import { z } from "zod";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
const items = await listItemsForScope(scope.householdId, id);
return apiJson(items);
});
}
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
z.string().uuid().parse(id);
const body: unknown = await req.json();
const parsed = itemInput.omit({ listId: true }).parse(body);
const item = await addItemForScope(scope, { listId: id, ...parsed });
return apiJson(item, 201);
});
}
+33
View File
@@ -0,0 +1,33 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { deleteListForScope, updateListForScope } from "@/modules/lists/server/actions";
import { listUpdateInput } from "@/modules/lists/server/schemas";
import { getListForScope } from "@/modules/lists/server/queries";
import { z } from "zod";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
const list = await getListForScope(scope.householdId, id);
return apiJson(list);
});
}
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = listUpdateInput.parse(body);
await updateListForScope(scope, { id, ...parsed });
const list = await getListForScope(scope.householdId, id);
return apiJson(list);
});
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
await deleteListForScope(scope, { id });
return apiJson({ ok: true });
});
}
+33
View File
@@ -0,0 +1,33 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { createListForScope } from "@/modules/lists/server/actions";
import { listInput } from "@/modules/lists/server/schemas";
import { listListsForScope } from "@/modules/lists/server/queries";
export async function GET(request: Request) {
return withApiHandler(request, async (scope, req) => {
const url = new URL(req.url);
const type = url.searchParams.get("type") ?? undefined;
const lists = await listListsForScope(scope.householdId, type ? { type } : undefined);
return apiJson(lists);
});
}
export async function POST(request: Request) {
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = listInput.parse(body);
const list = await createListForScope(scope, parsed);
return apiJson(
{
id: list.id,
type: list.type,
name: list.name,
archived: list.archived,
openCount: 0,
doneCount: 0,
createdAt: list.createdAt.toISOString(),
},
201,
);
});
}
+32
View File
@@ -0,0 +1,32 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { deleteNoteForScope, updateNoteForScope } from "@/modules/notes/server/actions";
import { updateNoteInput } from "@/modules/notes/server/schemas";
import { getNoteForScope } from "@/modules/notes/server/queries";
import { z } from "zod";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
const note = await getNoteForScope(scope.householdId, id);
return apiJson(note);
});
}
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = updateNoteInput.omit({ id: true }).parse(body);
const note = await updateNoteForScope(scope, { id, ...parsed });
return apiJson(note);
});
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
await deleteNoteForScope(scope, { id });
return apiJson({ ok: true });
});
}
+20
View File
@@ -0,0 +1,20 @@
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { createNoteForScope } from "@/modules/notes/server/actions";
import { noteInput } from "@/modules/notes/server/schemas";
import { listNotesForScope } from "@/modules/notes/server/queries";
export async function GET(request: Request) {
return withApiHandler(request, async (scope) => {
const notes = await listNotesForScope(scope.householdId);
return apiJson(notes);
});
}
export async function POST(request: Request) {
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = noteInput.parse(body);
const note = await createNoteForScope(scope, parsed);
return apiJson(note, 201);
});
}
+14
View File
@@ -0,0 +1,14 @@
import { readFile } from "fs/promises";
import path from "path";
import { withApiHandler } from "@/lib/api-handler";
export async function GET(request: Request) {
return withApiHandler(request, async () => {
const specPath = path.join(process.cwd(), "docs", "api", "openapi.yaml");
const spec = await readFile(specPath, "utf8");
return new Response(spec, {
status: 200,
headers: { "Content-Type": "application/yaml; charset=utf-8" },
});
});
}
+12
View File
@@ -0,0 +1,12 @@
import { z } from "zod";
import { apiJson, withApiHandler } from "@/lib/api-handler";
import { revokeShareLinkForScope } from "@/modules/_core/share-api";
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return withApiHandler(request, async (scope) => {
z.string().uuid().parse(id);
await revokeShareLinkForScope(scope, id);
return apiJson({ ok: true });
});
}
+55
View File
@@ -0,0 +1,55 @@
import { z } from "zod";
import { apiJson, withApiHandler } from "@/lib/api-handler";
import {
createShareLinkForScope,
listShareLinksForScope,
listShareableEntityTypes,
} from "@/modules/_core/share-api";
const createShareLinkInput = z.object({
entityType: z.string().trim().min(1),
entityId: z.string().uuid(),
expiresAt: z.string().datetime().nullable().optional(),
capabilities: z
.object({
read: z.boolean().optional(),
write: z.boolean().optional(),
})
.optional(),
});
export async function GET(request: Request) {
return withApiHandler(request, async (scope, req) => {
const url = new URL(req.url);
const entityType = url.searchParams.get("entityType") ?? undefined;
const entityId = url.searchParams.get("entityId") ?? undefined;
if (url.searchParams.get("entityTypes") === "true") {
return apiJson(listShareableEntityTypes());
}
const links = await listShareLinksForScope(scope, { entityType, entityId });
return apiJson(links);
});
}
export async function POST(request: Request) {
return withApiHandler(request, async (scope, req) => {
const body: unknown = await req.json();
const parsed = createShareLinkInput.parse(body);
const expiresAt = parsed.expiresAt ? new Date(parsed.expiresAt) : null;
const result = await createShareLinkForScope(scope, parsed.entityType, parsed.entityId, {
expiresAt,
capabilities: parsed.capabilities,
});
return apiJson(
{
url: result.url,
expiresAt: result.expiresAt?.toISOString() ?? null,
},
201,
);
});
}
+8 -2
View File
@@ -1,5 +1,9 @@
import { CalendarShell } from "@/modules/calendar/components/calendar-shell"; import { CalendarShell } from "@/modules/calendar/components/calendar-shell";
import { listCalendars, listEvents } from "@/modules/calendar/server/queries"; import {
getDefaultEventReminderOffsets,
listCalendars,
listEvents,
} from "@/modules/calendar/server/queries";
import { getCurrentSession } from "@/lib/session"; import { getCurrentSession } from "@/lib/session";
import type { CalView } from "@/modules/_core/themes"; import type { CalView } from "@/modules/_core/themes";
@@ -10,10 +14,11 @@ export default async function CalendarPage() {
const to = new Date(now); const to = new Date(now);
to.setMonth(to.getMonth() + 10); to.setMonth(to.getMonth() + 10);
const [{ user }, calendars, events] = await Promise.all([ const [{ user }, calendars, events, defaultReminderOffsets] = await Promise.all([
getCurrentSession(), getCurrentSession(),
listCalendars(), listCalendars(),
listEvents({ from, to, calendarIds: "all" }), listEvents({ from, to, calendarIds: "all" }),
getDefaultEventReminderOffsets(),
]); ]);
return ( return (
@@ -21,6 +26,7 @@ export default async function CalendarPage() {
calendars={calendars} calendars={calendars}
events={events} events={events}
defaultView={user.themeCalView as CalView} defaultView={user.themeCalView as CalView}
defaultReminderOffsets={defaultReminderOffsets}
/> />
); );
} }
+21 -3
View File
@@ -1,11 +1,13 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Suspense } from "react"; import { Suspense } from "react";
import { getCurrentSession } from "@/lib/session"; import { getCurrentSession } from "@/lib/session";
import { parseDashboardLayout } from "@/lib/dashboard"; import { parseDashboardLayout, widgetContentIndexKey } from "@/lib/dashboard";
import { computeDefaultLayout } from "@/lib/dashboard.server"; import { readEditorDraftLayout } from "@/lib/dashboard-editor-draft";
import { computeDefaultLayout, normalizeDashboardLayout } from "@/lib/dashboard.server";
import { getWidget, getWidgetMetas } from "@/modules/_core"; import { getWidget, getWidgetMetas } from "@/modules/_core";
import { getDashboardBySlug } from "@/app/d/actions"; import { getDashboardBySlug } from "@/app/d/actions";
import { DashboardEditor } from "@/components/dashboard-editor"; import { DashboardEditor } from "@/components/dashboard-editor";
import { DashboardWidgetContent } from "@/components/dashboard-widget-content";
import { EditDashboardButton } from "@/components/edit-dashboard-button"; import { EditDashboardButton } from "@/components/edit-dashboard-button";
import { DashboardSwitcher } from "@/components/dashboard-switcher"; import { DashboardSwitcher } from "@/components/dashboard-switcher";
import { DashboardTab } from "@/components/dashboard-tab"; import { DashboardTab } from "@/components/dashboard-tab";
@@ -48,15 +50,31 @@ export default async function DashboardPage({
const dashboard = await getDashboardBySlug(slug); const dashboard = await getDashboardBySlug(slug);
if (!dashboard) notFound(); if (!dashboard) notFound();
const layout = parseDashboardLayout(dashboard.layout) ?? computeDefaultLayout(); const storedLayout = parseDashboardLayout(dashboard.layout) ?? computeDefaultLayout();
const draftLayout = isEditing ? await readEditorDraftLayout(dashboard.id) : null;
const layout = normalizeDashboardLayout(draftLayout ?? storedLayout);
const widgetMetas = getWidgetMetas(); const widgetMetas = getWidgetMetas();
if (isEditing) { if (isEditing) {
const ctx = { userId: user.id, householdId: household.id };
const widgetContents = Object.fromEntries(
layout.widgets.map((placement, index) => [
widgetContentIndexKey(index),
<DashboardWidgetContent
key={widgetContentIndexKey(index)}
placement={placement}
ctx={ctx}
/>,
]),
);
return ( return (
<DashboardEditor <DashboardEditor
key={`${dashboard.id}:${JSON.stringify(layout.widgets)}`}
dashboard={{ id: dashboard.id, name: dashboard.name, slug: dashboard.slug }} dashboard={{ id: dashboard.id, name: dashboard.name, slug: dashboard.slug }}
layout={layout} layout={layout}
widgetMetas={widgetMetas} widgetMetas={widgetMetas}
widgetContents={widgetContents}
/> />
); );
} }
+32
View File
@@ -10,6 +10,7 @@ import { getWidget } from "@/modules/_core";
import { dashboards } from "@/modules/_core/schema"; import { dashboards } from "@/modules/_core/schema";
import { type DashboardLayout } from "@/lib/dashboard"; import { type DashboardLayout } from "@/lib/dashboard";
import { computeDefaultLayout } from "@/lib/dashboard.server"; import { computeDefaultLayout } from "@/lib/dashboard.server";
import { clearEditorDraftLayout, writeEditorDraftLayout } from "@/lib/dashboard-editor-draft";
export type DashboardMeta = { export type DashboardMeta = {
id: string; id: string;
@@ -156,9 +157,39 @@ export async function saveDashboardLayout(id: string, layout: DashboardLayout):
.update(dashboards) .update(dashboards)
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() }) .set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id))); .where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
await clearEditorDraftLayout(id);
revalidatePath("/"); revalidatePath("/");
} }
export async function syncEditorDraftLayout(id: string, layout: DashboardLayout): Promise<void> {
const { user } = await getCurrentSession();
const [row] = await db
.select({ id: dashboards.id })
.from(dashboards)
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)))
.limit(1);
if (!row) throw new Error("Dashboard not found");
for (const placement of layout.widgets) {
const widget = getWidget(placement.widgetId);
if (!widget) continue;
widget.configSchema.parse(placement.config);
}
await writeEditorDraftLayout(id, layout);
}
export async function discardEditorDraftLayout(id: string): Promise<void> {
const { user } = await getCurrentSession();
const [row] = await db
.select({ id: dashboards.id })
.from(dashboards)
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)))
.limit(1);
if (!row) throw new Error("Dashboard not found");
await clearEditorDraftLayout(id);
}
export async function resetDashboardLayout(id: string): Promise<void> { export async function resetDashboardLayout(id: string): Promise<void> {
const { user } = await getCurrentSession(); const { user } = await getCurrentSession();
const layout = computeDefaultLayout(); const layout = computeDefaultLayout();
@@ -166,6 +197,7 @@ export async function resetDashboardLayout(id: string): Promise<void> {
.update(dashboards) .update(dashboards)
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() }) .set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id))); .where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
await clearEditorDraftLayout(id);
revalidatePath("/"); revalidatePath("/");
} }
+4 -2
View File
@@ -1,7 +1,8 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getContainer } from "@/modules/garden/server/queries"; import { DetailBackLink } from "@/components/detail-back-link";
import { ContainerDetail } from "@/modules/garden/components/container-detail";
import { getShareLinksForEntity } from "@/modules/_core/share"; import { getShareLinksForEntity } from "@/modules/_core/share";
import { ContainerDetail } from "@/modules/garden/components/container-detail";
import { getContainer } from "@/modules/garden/server/queries";
export default async function ContainerPage({ params }: { params: Promise<{ id: string }> }) { export default async function ContainerPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
@@ -12,6 +13,7 @@ export default async function ContainerPage({ params }: { params: Promise<{ id:
if (!container) notFound(); if (!container) notFound();
return ( return (
<div className="page-content"> <div className="page-content">
<DetailBackLink href="/garden" label="Garden" className="mb-4" />
<ContainerDetail container={container} shareLinks={shareLinks} /> <ContainerDetail container={container} shareLinks={shareLinks} />
</div> </div>
); );
+3 -15
View File
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { DetailBackLink } from "@/components/detail-back-link";
import { createContainer } from "@/modules/garden/server/actions"; import { createContainer } from "@/modules/garden/server/actions";
export default function NewContainerPage() { export default function NewContainerPage() {
@@ -14,21 +15,8 @@ export default function NewContainerPage() {
return ( return (
<div className="page-content max-w-lg"> <div className="page-content max-w-lg">
<div className="flex items-center gap-3 mb-1"> <div className="mb-1 flex flex-col gap-2">
<a href="/garden" className="btn btn-ghost btn-icon" aria-label="Back to garden"> <DetailBackLink href="/garden" label="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> <h1 className="page-title">New container</h1>
</div> </div>
<form action={handleCreate} className="flex flex-col gap-4 mt-4"> <form action={handleCreate} className="flex flex-col gap-4 mt-4">
+3 -1
View File
@@ -1,6 +1,7 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getPlant, listContainers } from "@/modules/garden/server/queries"; import { DetailBackLink } from "@/components/detail-back-link";
import { PlantForm } from "@/modules/garden/components/plant-form"; import { PlantForm } from "@/modules/garden/components/plant-form";
import { getPlant, listContainers } from "@/modules/garden/server/queries";
export default async function EditPlantPage({ params }: { params: Promise<{ id: string }> }) { export default async function EditPlantPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
@@ -9,6 +10,7 @@ export default async function EditPlantPage({ params }: { params: Promise<{ id:
return ( return (
<div className="page-content max-w-xl"> <div className="page-content max-w-xl">
<DetailBackLink href={`/garden/plants/${plant.id}`} label={plant.name} className="mb-3" />
<h1 className="page-title">Edit Plant</h1> <h1 className="page-title">Edit Plant</h1>
<PlantForm existingPlant={plant} containers={containers} /> <PlantForm existingPlant={plant} containers={containers} />
</div> </div>
+4 -2
View File
@@ -1,8 +1,9 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { DetailBackLink } from "@/components/detail-back-link";
import { getShareLinksForEntity } from "@/modules/_core/share";
import { PlantDetail } from "@/modules/garden/components/plant-detail";
import { listCalendars } from "@/modules/garden/server/calendar-bridge"; import { listCalendars } from "@/modules/garden/server/calendar-bridge";
import { getCareLogs, getCareSchedules, getPlant } from "@/modules/garden/server/queries"; 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 }> }) { export default async function PlantPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
@@ -17,6 +18,7 @@ export default async function PlantPage({ params }: { params: Promise<{ id: stri
return ( return (
<div className="page-content"> <div className="page-content">
<DetailBackLink href="/garden?tab=plants" label="Plants" className="mb-4" />
<PlantDetail <PlantDetail
plant={plant} plant={plant}
careLogs={careLogs} careLogs={careLogs}
+4 -16
View File
@@ -1,5 +1,6 @@
import { listContainers } from "@/modules/garden/server/queries"; import { DetailBackLink } from "@/components/detail-back-link";
import { PlantForm } from "@/modules/garden/components/plant-form"; import { PlantForm } from "@/modules/garden/components/plant-form";
import { listContainers } from "@/modules/garden/server/queries";
export default async function NewPlantPage({ export default async function NewPlantPage({
searchParams, searchParams,
@@ -11,21 +12,8 @@ export default async function NewPlantPage({
return ( return (
<div className="page-content max-w-xl"> <div className="page-content max-w-xl">
<div className="flex items-center gap-3 mb-1"> <div className="mb-1 flex flex-col gap-2">
<a href="/garden" className="btn btn-ghost btn-icon" aria-label="Back to garden"> <DetailBackLink href="/garden?tab=plants" label="Plants" />
<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> <h1 className="page-title">Add Plant</h1>
</div> </div>
<PlantForm containers={containers} defaultContainerId={params.containerId ?? null} /> <PlantForm containers={containers} defaultContainerId={params.containerId ?? null} />
+139
View File
@@ -818,6 +818,86 @@
bottom: 18px; bottom: 18px;
} }
/* ── Assistant chat bubble (opt-in per user) ─────────────────────── */
.assistant-bubble {
position: fixed;
right: 18px;
bottom: 24px;
z-index: 70;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
pointer-events: none;
}
:where(html[data-nav="bottom"]) .assistant-bubble {
bottom: calc(62px + max(env(safe-area-inset-bottom, 0px), 8px) + 12px);
}
:where(html[data-nav="fab"]) .assistant-bubble {
bottom: calc(18px + 52px + 12px);
}
.assistant-bubble > * {
pointer-events: auto;
}
.assistant-bubble-panel {
display: flex;
flex-direction: column;
width: min(360px, calc(100vw - 24px));
height: min(480px, calc(100vh - 140px));
border-radius: var(--r-md);
border: 0.5px solid var(--hair);
background: color-mix(in oklab, var(--card) 94%, transparent);
backdrop-filter: blur(16px) saturate(160%);
box-shadow:
0 12px 40px rgba(31, 27, 22, 0.16),
0 2px 8px rgba(31, 27, 22, 0.08);
padding: 12px;
overflow: hidden;
}
.assistant-bubble-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
flex-shrink: 0;
}
.assistant-bubble-close {
appearance: none;
border: 0;
background: transparent;
color: var(--ink-mute);
border-radius: 8px;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.assistant-bubble-close:hover {
background: var(--shade);
color: var(--ink);
}
.assistant-bubble-trigger {
width: 52px;
height: 52px;
border-radius: 999px;
border: 0;
background: var(--ink);
color: var(--paper);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow:
0 4px 14px rgba(31, 27, 22, 0.22),
0 1px 2px rgba(31, 27, 22, 0.18);
}
.assistant-bubble-trigger:hover {
background: var(--ink-2);
}
/* Buttons (used inside .topbar etc; <Button> in shadcn comes from /* Buttons (used inside .topbar etc; <Button> in shadcn comes from
button.tsx and uses these tokens via the theme bridge) */ button.tsx and uses these tokens via the theme bridge) */
.btn { .btn {
@@ -1430,6 +1510,65 @@ select {
animation: emojiFloat 1.8s ease-out both; animation: emojiFloat 1.8s ease-out both;
} }
@keyframes journal-paper-out {
to {
opacity: 0;
transform: translateX(-28px) rotate(-1.2deg);
}
}
@keyframes journal-paper-in {
from {
opacity: 0;
transform: translateX(32px) rotate(0.8deg);
}
to {
opacity: 1;
transform: translateX(0) rotate(0deg);
}
}
.journal-entries-stack {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-height: 0;
}
.journal-entries-stack[data-phase="exit"] .journal-entry-row {
animation: journal-paper-out 0.2s ease-in forwards;
}
.journal-entries-stack[data-phase="enter"] .journal-entry-row {
opacity: 0;
animation: journal-paper-in 0.26s ease-out forwards;
}
.journal-entries-stack[data-phase="enter"] .journal-entry-row:nth-child(1) {
animation-delay: 0ms;
}
.journal-entries-stack[data-phase="enter"] .journal-entry-row:nth-child(2) {
animation-delay: 45ms;
}
.journal-entries-stack[data-phase="enter"] .journal-entry-row:nth-child(3) {
animation-delay: 90ms;
}
.journal-entries-stack[data-phase="enter"] .journal-entry-row:nth-child(4) {
animation-delay: 135ms;
}
.journal-entries-stack[data-phase="enter"] .journal-entry-row:nth-child(5) {
animation-delay: 180ms;
}
@media (prefers-reduced-motion: reduce) {
.journal-entries-stack[data-phase="exit"] .journal-entry-row,
.journal-entries-stack[data-phase="enter"] .journal-entry-row {
animation: none;
opacity: 1;
transform: none;
}
}
/* ── Calendar min-height CSS variable ───────────────────────────── */ /* ── Calendar min-height CSS variable ───────────────────────────── */
/* Used by calendar-shell so the grid fills the viewport correctly */ /* Used by calendar-shell so the grid fills the viewport correctly */
/* on both desktop (topbar only) and mobile (topbar + bottom nav). */ /* on both desktop (topbar only) and mobile (topbar + bottom nav). */
+10
View File
@@ -0,0 +1,10 @@
import { notFound } from "next/navigation";
import { JournalEntryEditor } from "@/modules/journal/components/entry-editor";
import { getJournalEntry } from "@/modules/journal/server/queries";
export default async function JournalEntryPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const entry = await getJournalEntry(id).catch(() => null);
if (!entry) notFound();
return <JournalEntryEditor entry={entry} />;
}
+21
View File
@@ -0,0 +1,21 @@
import Link from "next/link";
import { DetailBackLink } from "@/components/detail-back-link";
import { InsightsView } from "@/modules/journal/components/insights-view";
import { listJournalEntries } from "@/modules/journal/server/queries";
export default async function JournalInsightsPage() {
const entries = await listJournalEntries({ limit: 500 });
return (
<div className="mx-auto grid w-full max-w-5xl gap-4 min-w-0">
<DetailBackLink href="/journal" label="Journal" />
<div className="flex items-center justify-between gap-3">
<h2 className="serif text-[22px] tracking-tight">Insights</h2>
<Link href="/journal/mood" className="text-[13px] text-[var(--accent)] hover:underline">
Mood charts
</Link>
</div>
<InsightsView entries={entries} />
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import Link from "next/link";
import { DetailBackLink } from "@/components/detail-back-link";
import { MoodTrackerView } from "@/modules/journal/components/mood-tracker-view";
import { listJournalEntries } from "@/modules/journal/server/queries";
export default async function JournalMoodPage() {
const entries = await listJournalEntries({ limit: 500 });
return (
<div className="mx-auto grid w-full max-w-5xl gap-4 min-w-0">
<DetailBackLink href="/journal" label="Journal" />
<div className="flex items-center justify-between gap-3">
<h2 className="serif text-[22px] tracking-tight">Mood tracker</h2>
<Link href="/journal/insights" className="text-[13px] text-[var(--accent)] hover:underline">
View insights
</Link>
</div>
<MoodTrackerView entries={entries} />
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { JournalEntryEditor } from "@/modules/journal/components/entry-editor";
export default function NewJournalEntryPage() {
return <JournalEntryEditor />;
}
+13
View File
@@ -0,0 +1,13 @@
import { Suspense } from "react";
import { JournalIndex } from "@/modules/journal/components/journal-index";
import { listJournalEntries } from "@/modules/journal/server/queries";
export default async function JournalPage() {
const entries = await listJournalEntries();
return (
<Suspense>
<JournalIndex entries={entries} />
</Suspense>
);
}
+16
View File
@@ -17,6 +17,9 @@ import { PwaRegister } from "@/components/pwa-register";
import { InstallPrompt } from "@/components/install-prompt"; import { InstallPrompt } from "@/components/install-prompt";
import { AppShell } from "@/components/app-shell"; import { AppShell } from "@/components/app-shell";
import { AppToaster } from "@/components/app-toaster"; import { AppToaster } from "@/components/app-toaster";
import { AssistantBubble } from "@/modules/agent/components/assistant-bubble";
import { DEFAULT_ASSISTANT_NAME } from "@/lib/assistant-config";
import { isLlmConfigured } from "@/lib/llm";
import { DEFAULT_THEME, navStyleToDataNav } from "@/modules/_core/themes"; import { DEFAULT_THEME, navStyleToDataNav } from "@/modules/_core/themes";
import type { Palette, ThemeMode, FontPair, Density, NavStyle } from "@/modules/_core/themes"; import type { Palette, ThemeMode, FontPair, Density, NavStyle } from "@/modules/_core/themes";
@@ -99,6 +102,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
let navStyle: NavStyle = DEFAULT_THEME.navStyle; let navStyle: NavStyle = DEFAULT_THEME.navStyle;
let userDashboards: DashboardMeta[] = []; let userDashboards: DashboardMeta[] = [];
let signedIn = false; let signedIn = false;
let assistantEnabled = false;
let assistantName = DEFAULT_ASSISTANT_NAME;
const session = await auth(); const session = await auth();
if (session?.user?.id) { if (session?.user?.id) {
@@ -110,6 +115,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
themeFontPair: users.themeFontPair, themeFontPair: users.themeFontPair,
themeDensity: users.themeDensity, themeDensity: users.themeDensity,
themeNavStyle: users.themeNavStyle, themeNavStyle: users.themeNavStyle,
assistantEnabled: users.assistantEnabled,
assistantName: users.assistantName,
}) })
.from(users) .from(users)
.where(eq(users.id, session.user.id)) .where(eq(users.id, session.user.id))
@@ -120,6 +127,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
fontPair = row.themeFontPair as FontPair; fontPair = row.themeFontPair as FontPair;
density = row.themeDensity as Density; density = row.themeDensity as Density;
navStyle = row.themeNavStyle as NavStyle; navStyle = row.themeNavStyle as NavStyle;
assistantEnabled = row.assistantEnabled;
assistantName = row.assistantName?.trim() || DEFAULT_ASSISTANT_NAME;
} }
userDashboards = await db userDashboards = await db
.select({ .select({
@@ -172,6 +181,13 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<CommandPalette /> <CommandPalette />
<InstallPrompt /> <InstallPrompt />
<PwaRegister /> <PwaRegister />
{signedIn && assistantEnabled && session?.user?.id ? (
<AssistantBubble
configured={isLlmConfigured()}
userId={session.user.id}
assistantName={assistantName}
/>
) : null}
<AppToaster position="bottom-right" /> <AppToaster position="bottom-right" />
</QuickAddProvider> </QuickAddProvider>
</body> </body>
+3 -2
View File
@@ -1,11 +1,12 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getCurrentSession } from "@/lib/session";
import { ListDetail } from "@/modules/lists/components/list-detail"; import { ListDetail } from "@/modules/lists/components/list-detail";
import { getList } from "@/modules/lists/server/queries"; import { getList } from "@/modules/lists/server/queries";
export default async function ListPage({ params }: { params: Promise<{ id: string }> }) { export default async function ListPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const list = await getList(id).catch(() => null); const [{ user }, list] = await Promise.all([getCurrentSession(), getList(id).catch(() => null)]);
if (!list) notFound(); if (!list) notFound();
return <ListDetail initialList={list} />; return <ListDetail initialList={list} currentUserId={user.id} />;
} }
+27
View File
@@ -0,0 +1,27 @@
"use server";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { signIn } from "@/lib/auth";
import { createDevSession } from "@/lib/dev-login";
import { isDevLoginEnabled } from "@/lib/dev-login-config";
export async function signInWithSso() {
await signIn("authentik", { redirectTo: "/" });
}
export async function devLogin() {
if (!isDevLoginEnabled()) {
throw new Error("Dev login is not enabled");
}
const { sessionToken, expires } = await createDevSession();
const cookieStore = await cookies();
const base = { httpOnly: true, sameSite: "lax" as const, path: "/", expires };
cookieStore.set("authjs.session-token", sessionToken, base);
cookieStore.set("__Secure-authjs.session-token", sessionToken, {
...base,
secure: true,
});
redirect("/");
}
+6 -32
View File
@@ -1,10 +1,7 @@
import { signIn } from "@/lib/auth";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { isDevLoginEnabled } from "@/lib/dev-login-config";
import { createDevSession } from "@/lib/dev-login";
import { BrandMark } from "@/components/brand-mark"; import { BrandMark } from "@/components/brand-mark";
import { isDevLoginEnabled } from "@/lib/dev-login-config";
import { devLogin, signInWithSso } from "./actions";
export default function LoginPage() { export default function LoginPage() {
const devLoginEnabled = isDevLoginEnabled(); const devLoginEnabled = isDevLoginEnabled();
@@ -23,41 +20,18 @@ export default function LoginPage() {
</div> </div>
<h1 className="serif text-[28px] font-medium tracking-tight mb-2">famapp</h1> <h1 className="serif text-[28px] font-medium tracking-tight mb-2">famapp</h1>
<p className="muted text-[13.5px] mb-6">Sign in to your household.</p> <p className="muted text-[13.5px] mb-6">Sign in to your household.</p>
<form <form action={signInWithSso}>
action={async () => {
"use server";
await signIn("authentik", { redirectTo: "/" });
}}
>
<Button type="submit" size="lg" className="w-full"> <Button type="submit" size="lg" className="w-full">
Sign in with SSO Sign in with SSO
</Button> </Button>
</form> </form>
{devLoginEnabled && ( {devLoginEnabled ? (
<form <form action={devLogin} className="mt-3">
action={async () => {
"use server";
// Auth.js may resolve either "authjs.session-token" (HTTP/dev) or
// "__Secure-authjs.session-token" (HTTPS) depending on AUTH_URL,
// trustHost, and proxy headers. Set both so the session is found
// regardless — this is dev-only code, correctness > elegance.
const { sessionToken, expires } = await createDevSession();
const cookieStore = await cookies();
const base = { httpOnly: true, sameSite: "lax" as const, path: "/", expires };
cookieStore.set("authjs.session-token", sessionToken, base);
cookieStore.set("__Secure-authjs.session-token", sessionToken, {
...base,
secure: true,
});
redirect("/");
}}
className="mt-3"
>
<Button type="submit" variant="outline" size="lg" className="w-full"> <Button type="submit" variant="outline" size="lg" className="w-full">
Dev login Dev login
</Button> </Button>
</form> </form>
)} ) : null}
</div> </div>
</main> </main>
); );
+3 -2
View File
@@ -1,11 +1,12 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getCurrentSession } from "@/lib/session";
import { NoteEditor } from "@/modules/notes/components/note-editor"; import { NoteEditor } from "@/modules/notes/components/note-editor";
import { getNote } from "@/modules/notes/server/queries"; import { getNote } from "@/modules/notes/server/queries";
export default async function NotePage({ params }: { params: Promise<{ id: string }> }) { export default async function NotePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const note = await getNote(id).catch(() => null); const [{ user }, note] = await Promise.all([getCurrentSession(), getNote(id).catch(() => null)]);
if (!note) notFound(); if (!note) notFound();
return <NoteEditor note={note} />; return <NoteEditor note={note} currentUserId={user.id} />;
} }
+20
View File
@@ -24,6 +24,7 @@ import type {
} from "@/modules/_core/themes"; } from "@/modules/_core/themes";
import { getCurrentSession } from "@/lib/session"; import { getCurrentSession } from "@/lib/session";
import { revokeShareLink } from "@/modules/_core/share"; import { revokeShareLink } from "@/modules/_core/share";
import { createHouseholdApiToken, revokeHouseholdApiToken } from "@/modules/_core/api-token";
export interface ThemePatch { export interface ThemePatch {
palette?: Palette; palette?: Palette;
@@ -88,3 +89,22 @@ export async function revokeShareLinkAction(formData: FormData): Promise<void> {
await revokeShareLink(id); await revokeShareLink(id);
revalidatePath("/settings"); revalidatePath("/settings");
} }
function requireOwner(role: "owner" | "member") {
if (role !== "owner") throw new Error("Only household owners can manage API tokens");
}
export async function createApiTokenAction(): Promise<{ token: string }> {
const { household, role, user } = await getCurrentSession();
requireOwner(role);
const result = await createHouseholdApiToken(household.id, user.id);
revalidatePath("/settings");
return result;
}
export async function revokeApiTokenAction(): Promise<void> {
const { household, role } = await getCurrentSession();
requireOwner(role);
await revokeHouseholdApiToken(household.id);
revalidatePath("/settings");
}
+59
View File
@@ -0,0 +1,59 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/lib/db";
import {
DEFAULT_ASSISTANT_NAME,
MAX_ASSISTANT_NAME_LENGTH,
MAX_ASSISTANT_SYSTEM_PROMPT_LENGTH,
} from "@/lib/assistant-config";
import { users } from "@/modules/_core/schema";
import { getCurrentSession } from "@/lib/session";
const assistantNameSchema = z
.string()
.trim()
.min(1, "Name is required")
.max(MAX_ASSISTANT_NAME_LENGTH);
const assistantSystemPromptSchema = z
.string()
.trim()
.min(1, "Prompt cannot be empty")
.max(MAX_ASSISTANT_SYSTEM_PROMPT_LENGTH);
function revalidateAssistantSurfaces() {
revalidatePath("/settings");
revalidatePath("/", "layout");
}
export async function setAssistantEnabled(enabled: boolean): Promise<void> {
const { user } = await getCurrentSession();
await db.update(users).set({ assistantEnabled: enabled }).where(eq(users.id, user.id));
revalidateAssistantSurfaces();
}
export async function setAssistantName(name: string): Promise<void> {
const parsed = assistantNameSchema.parse(name);
const { user } = await getCurrentSession();
await db.update(users).set({ assistantName: parsed }).where(eq(users.id, user.id));
revalidateAssistantSurfaces();
}
export async function setAssistantSystemPrompt(prompt: string | null): Promise<void> {
const { user } = await getCurrentSession();
const normalized = prompt === null ? null : assistantSystemPromptSchema.parse(prompt);
await db.update(users).set({ assistantSystemPrompt: normalized }).where(eq(users.id, user.id));
revalidateAssistantSurfaces();
}
export async function resetAssistantName(): Promise<void> {
await setAssistantName(DEFAULT_ASSISTANT_NAME);
}
export async function resetAssistantSystemPrompt(): Promise<void> {
await setAssistantSystemPrompt(null);
}
+5 -3
View File
@@ -1,10 +1,11 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "@/lib/db"; import { DetailBackLink } from "@/components/detail-back-link";
import { getCurrentSession } from "@/lib/session";
import { householdMembers, users } from "@/modules/_core/schema";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { householdMembers, users } from "@/modules/_core/schema";
import { renameHousehold } from "./actions"; import { renameHousehold } from "./actions";
export default async function HouseholdSettingsPage() { export default async function HouseholdSettingsPage() {
@@ -18,6 +19,7 @@ export default async function HouseholdSettingsPage() {
return ( return (
<div className="container max-w-2xl py-8 space-y-6"> <div className="container max-w-2xl py-8 space-y-6">
<DetailBackLink href="/settings" label="Settings" />
<h1 className="text-2xl font-semibold">Household Settings</h1> <h1 className="text-2xl font-semibold">Household Settings</h1>
<Card> <Card>
+82 -5
View File
@@ -11,11 +11,27 @@ import { ResponsiveSidebar } from "@/components/settings-section";
import type { SectionId } from "@/components/settings-section"; import type { SectionId } from "@/components/settings-section";
import { AvatarFallbackWithName } from "@/components/avatar-fallback"; import { AvatarFallbackWithName } from "@/components/avatar-fallback";
import { revokeShareLinkAction } from "./actions"; import { revokeShareLinkAction } from "./actions";
import { getHouseholdApiTokenStatus } from "@/modules/_core/api-token";
import { ApiTokenSettings } from "@/components/api-token-settings";
import { AssistantSettings } from "@/components/assistant-settings";
import { AGENT_SYSTEM_PROMPT } from "@/modules/agent/tools";
import { DefaultEventRemindersSetting } from "@/components/default-event-reminders-setting";
import { listCalendars } from "@/modules/calendar/server/queries"; import { listCalendars } from "@/modules/calendar/server/queries";
import { listLists } from "@/modules/lists/server/queries"; import { listLists } from "@/modules/lists/server/queries";
import Link from "next/link"; import Link from "next/link";
import { NavIcon } from "@/components/nav-icon"; import { NavIcon } from "@/components/nav-icon";
import { Mail, Globe, History, Sun, Bell, Pencil, Lock, Plus } from "lucide-react"; import {
Mail,
Globe,
History,
Sun,
Bell,
Pencil,
Lock,
Plus,
KeyRound,
MessageCircle,
} from "lucide-react";
const VALID_SECTIONS = new Set<SectionId>([ const VALID_SECTIONS = new Set<SectionId>([
"household", "household",
@@ -36,7 +52,7 @@ export default async function SettingsPage({
? (sp.s as SectionId) ? (sp.s as SectionId)
: "household"; : "household";
const { user, household } = await getCurrentSession(); const { user, household, role } = await getCurrentSession();
const ntfyConfigured = !!(process.env["NTFY_URL"] && process.env["NTFY_TOPIC"]); const ntfyConfigured = !!(process.env["NTFY_URL"] && process.env["NTFY_TOPIC"]);
const vapidKey = process.env["VAPID_PUBLIC_KEY"] ?? ""; const vapidKey = process.env["VAPID_PUBLIC_KEY"] ?? "";
@@ -47,11 +63,16 @@ export default async function SettingsPage({
{section === "household" && <HouseholdSection household={household} user={user} />} {section === "household" && <HouseholdSection household={household} user={user} />}
{section === "sharing" && <SharingSection />} {section === "sharing" && <SharingSection />}
{section === "notifications" && ( {section === "notifications" && (
<NotificationsSection user={user} vapidKey={vapidKey} ntfyConfigured={ntfyConfigured} /> <NotificationsSection
user={user}
defaultEventReminderOffsets={user.defaultEventReminderOffsets ?? [30]}
vapidKey={vapidKey}
ntfyConfigured={ntfyConfigured}
/>
)} )}
{section === "calendars" && <CalendarsAndListsSection />} {section === "calendars" && <CalendarsAndListsSection />}
{section === "appearance" && <AppearanceSection user={user} />} {section === "appearance" && <AppearanceSection user={user} />}
{section === "data" && <DataSection />} {section === "data" && <DataSection householdId={household.id} role={role} />}
</div> </div>
</div> </div>
); );
@@ -171,10 +192,12 @@ async function SharingSection() {
function NotificationsSection({ function NotificationsSection({
user, user,
defaultEventReminderOffsets,
vapidKey, vapidKey,
ntfyConfigured, ntfyConfigured,
}: { }: {
user: { notifPush: boolean; notifInApp: boolean; notifNtfy: boolean }; user: { notifPush: boolean; notifInApp: boolean; notifNtfy: boolean };
defaultEventReminderOffsets: number[];
vapidKey: string; vapidKey: string;
ntfyConfigured: boolean; ntfyConfigured: boolean;
}) { }) {
@@ -202,6 +225,18 @@ function NotificationsSection({
/> />
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle>Default event reminders</CardTitle>
</CardHeader>
<CardContent>
<DefaultEventRemindersSetting
key={defaultEventReminderOffsets.join(",")}
initialOffsets={defaultEventReminderOffsets}
/>
</CardContent>
</Card>
</> </>
); );
} }
@@ -287,9 +322,13 @@ function AppearanceSection({
themeDashLayout: string; themeDashLayout: string;
themeCalView: string; themeCalView: string;
themeNavStyle: string; themeNavStyle: string;
assistantEnabled: boolean;
assistantName: string;
assistantSystemPrompt: string | null;
}; };
}) { }) {
return ( return (
<>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Appearance</CardTitle> <CardTitle>Appearance</CardTitle>
@@ -312,11 +351,48 @@ function AppearanceSection({
/> />
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle>Assistant</CardTitle>
<MessageCircle className="size-4 text-[var(--ink-mute)]" />
</CardHeader>
<CardContent>
<AssistantSettings
enabled={user.assistantEnabled}
name={user.assistantName}
systemPrompt={user.assistantSystemPrompt}
defaultSystemPrompt={AGENT_SYSTEM_PROMPT}
/>
</CardContent>
</Card>
</>
); );
} }
function DataSection() { async function DataSection({
householdId,
role,
}: {
householdId: string;
role: "owner" | "member";
}) {
const tokenStatus = role === "owner" ? await getHouseholdApiTokenStatus(householdId) : null;
return ( return (
<>
{role === "owner" && tokenStatus && (
<Card>
<CardHeader>
<CardTitle>API access</CardTitle>
<KeyRound className="size-4 text-[var(--ink-mute)]" />
</CardHeader>
<CardContent className="p-0">
<ApiTokenSettings status={tokenStatus} />
</CardContent>
</Card>
)}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Data & backups</CardTitle> <CardTitle>Data & backups</CardTitle>
@@ -346,5 +422,6 @@ function DataSection() {
</div> </div>
</div> </div>
</Card> </Card>
</>
); );
} }
+21
View File
@@ -0,0 +1,21 @@
"use server";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { normalizeReminderOffsets } from "@/lib/reminder-offsets";
import { users } from "@/modules/_core/schema";
const offsetsSchema = z.array(z.number().int().min(0)).max(20);
export async function setDefaultEventReminderOffsets(offsets: number[]) {
const { user } = await getCurrentSession();
const parsed = normalizeReminderOffsets(offsetsSchema.parse(offsets));
await db.update(users).set({ defaultEventReminderOffsets: parsed }).where(eq(users.id, user.id));
revalidatePath("/calendar");
revalidatePath("/settings");
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { useState, useTransition } from "react";
import { Check, Copy, KeyRound } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { createApiTokenAction, revokeApiTokenAction } from "@/app/settings/actions";
import type { HouseholdApiTokenStatus } from "@/modules/_core/api-token";
export function ApiTokenSettings({ status }: { status: HouseholdApiTokenStatus }) {
const [tokenStatus, setTokenStatus] = useState(status);
const [rawToken, setRawToken] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [copied, setCopied] = useState(false);
const [isPending, startTransition] = useTransition();
function generateToken() {
startTransition(async () => {
try {
const result = await createApiTokenAction();
setRawToken(result.token);
setDialogOpen(true);
setTokenStatus({
hasActiveToken: true,
lastUsedAt: null,
createdAt: new Date(),
});
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to generate token");
}
});
}
function revokeToken() {
startTransition(async () => {
try {
await revokeApiTokenAction();
setTokenStatus({ hasActiveToken: false, lastUsedAt: null, createdAt: null });
toast.success("API token revoked");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to revoke token");
}
});
}
function copyToken() {
if (!rawToken) return;
navigator.clipboard.writeText(rawToken).then(() => {
setCopied(true);
toast.success("Copied to clipboard");
setTimeout(() => setCopied(false), 2000);
});
}
return (
<>
<div className="set-row" style={{ borderBottom: "0" }}>
<KeyRound className="size-4 text-[var(--ink-soft)]" />
<div className="label flex-1">
<div className="t">API token</div>
<div className="d">
{tokenStatus.hasActiveToken ? (
<>
Active
{tokenStatus.createdAt && (
<> · created {tokenStatus.createdAt.toLocaleDateString()}</>
)}
{tokenStatus.lastUsedAt && (
<> · last used {tokenStatus.lastUsedAt.toLocaleString()}</>
)}
{!tokenStatus.lastUsedAt && <> · never used</>}
</>
) : (
<>No active token generate one for scripts and automations</>
)}
</div>
</div>
<div className="flex gap-2 shrink-0">
{tokenStatus.hasActiveToken ? (
<Button variant="destructive" size="sm" onClick={revokeToken} disabled={isPending}>
Revoke token
</Button>
) : null}
<Button variant="outline" size="sm" onClick={generateToken} disabled={isPending}>
{tokenStatus.hasActiveToken ? "Regenerate" : "Generate token"}
</Button>
</div>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>API token created</DialogTitle>
<DialogDescription>
Copy this token now it will not be shown again. Use it as{" "}
<code className="text-xs">Authorization: Bearer &lt;token&gt;</code> on{" "}
<code className="text-xs">/api/v1/</code> requests.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input readOnly value={rawToken ?? ""} className="font-mono text-xs" />
<Button variant="outline" size="icon" onClick={copyToken} aria-label="Copy token">
{copied ? <Check className="text-[var(--c-success)]" /> : <Copy />}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}
+185
View File
@@ -0,0 +1,185 @@
"use client";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import {
resetAssistantName,
resetAssistantSystemPrompt,
setAssistantEnabled,
setAssistantName,
setAssistantSystemPrompt,
} from "@/app/settings/assistant-actions";
import { DEFAULT_ASSISTANT_NAME, MAX_ASSISTANT_NAME_LENGTH } from "@/lib/assistant-config";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
type Props = {
enabled: boolean;
name: string;
systemPrompt: string | null;
defaultSystemPrompt: string;
};
export function AssistantSettings({ enabled, name, systemPrompt, defaultSystemPrompt }: Props) {
const [isPending, startTransition] = useTransition();
const router = useRouter();
const effectivePrompt = systemPrompt ?? defaultSystemPrompt;
const [savedName, setSavedName] = useState(name);
const [draftName, setDraftName] = useState(name);
const [savedPrompt, setSavedPrompt] = useState(systemPrompt);
const [draftPrompt, setDraftPrompt] = useState(effectivePrompt);
const nameDirty = draftName.trim() !== savedName;
const promptDirty =
savedPrompt === null
? draftPrompt.trim() !== defaultSystemPrompt.trim()
: draftPrompt.trim() !== savedPrompt.trim();
const usingDefaultPrompt = savedPrompt === null;
function toggle(next: boolean) {
startTransition(async () => {
await setAssistantEnabled(next);
router.refresh();
});
}
function saveName() {
const next = draftName.trim();
if (!next) return;
startTransition(async () => {
await setAssistantName(next);
setSavedName(next);
setDraftName(next);
router.refresh();
});
}
function savePrompt() {
const next = draftPrompt.trim();
if (!next) return;
startTransition(async () => {
const isDefault = next === defaultSystemPrompt.trim();
await setAssistantSystemPrompt(isDefault ? null : next);
setSavedPrompt(isDefault ? null : next);
setDraftPrompt(next);
router.refresh();
});
}
function handleResetName() {
startTransition(async () => {
await resetAssistantName();
setSavedName(DEFAULT_ASSISTANT_NAME);
setDraftName(DEFAULT_ASSISTANT_NAME);
router.refresh();
});
}
function handleResetPrompt() {
startTransition(async () => {
await resetAssistantSystemPrompt();
setSavedPrompt(null);
setDraftPrompt(defaultSystemPrompt);
router.refresh();
});
}
return (
<div className="flex flex-col gap-5">
<label className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-medium">AI assistant</div>
<p className="muted text-[12px] mt-0.5">
Off by default. Turn on to show a chat bubble in the bottom-right corner.
</p>
</div>
<Switch
checked={enabled}
disabled={isPending}
onCheckedChange={toggle}
aria-label="AI assistant"
/>
</label>
<div className="flex flex-col gap-2">
<div className="flex items-end justify-between gap-3">
<div className="min-w-0 flex-1">
<label htmlFor="assistant-name" className="text-sm font-medium">
Assistant name
</label>
<p className="muted text-[12px] mt-0.5">Shown in the chat bubble and message labels.</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
disabled={isPending || savedName === DEFAULT_ASSISTANT_NAME}
onClick={handleResetName}
>
Reset
</Button>
</div>
<Input
id="assistant-name"
value={draftName}
maxLength={MAX_ASSISTANT_NAME_LENGTH}
disabled={isPending}
onChange={(event) => setDraftName(event.target.value)}
aria-label="Assistant name"
/>
<Button
type="button"
size="sm"
disabled={!nameDirty || isPending || !draftName.trim()}
onClick={saveName}
>
{isPending ? "Saving…" : "Save name"}
</Button>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-end justify-between gap-3">
<div className="min-w-0 flex-1">
<label htmlFor="assistant-system-prompt" className="text-sm font-medium">
System prompt
</label>
<p className="muted text-[12px] mt-0.5">
Instructions sent to the model before each conversation. Customize tone, priorities,
or household context.
</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
disabled={isPending || usingDefaultPrompt}
onClick={handleResetPrompt}
>
Reset
</Button>
</div>
<textarea
id="assistant-system-prompt"
value={draftPrompt}
rows={10}
disabled={isPending}
onChange={(event) => setDraftPrompt(event.target.value)}
aria-label="Assistant system prompt"
className="input min-h-[180px] resize-y font-mono text-[12px] leading-relaxed"
/>
<Button
type="button"
size="sm"
disabled={!promptDirty || isPending || !draftPrompt.trim()}
onClick={savePrompt}
>
{isPending ? "Saving…" : "Save prompt"}
</Button>
</div>
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
"use client";
import { MessageSquare, Trash2 } from "lucide-react";
import { useEffect, useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { addComment, deleteComment, listComments, type CommentDto } from "@/modules/_core/comments";
type Props = {
entityType: string;
entityId: string;
currentUserId: string;
compact?: boolean;
};
export function EntityComments({ entityType, entityId, currentUserId, compact = false }: Props) {
const [open, setOpen] = useState(!compact);
const [comments, setComments] = useState<CommentDto[]>([]);
const [draft, setDraft] = useState("");
const [isPending, startTransition] = useTransition();
useEffect(() => {
if (!open) return;
listComments(entityType, entityId)
.then(setComments)
.catch(() => setComments([]));
}, [entityType, entityId, open]);
function submitComment() {
const body = draft.trim();
if (!body) return;
startTransition(async () => {
const created = await addComment({ entityType, entityId, body });
setComments((current) => [...current, created]);
setDraft("");
});
}
function removeComment(id: string) {
startTransition(async () => {
await deleteComment({ id });
setComments((current) => current.filter((comment) => comment.id !== id));
});
}
return (
<div className={compact ? "mt-1" : "mt-3 border-t border-[var(--hair)] pt-3"}>
{compact ? (
<button
type="button"
className="inline-flex items-center gap-1 text-[12px] text-[var(--ink-mute)] hover:text-[var(--ink)]"
onClick={() => setOpen((value) => !value)}
>
<MessageSquare className="size-3.5" />
{open ? "Hide comments" : "Comments"}
{comments.length > 0 ? ` (${comments.length})` : ""}
</button>
) : (
<div className="eyebrow mb-2">Comments</div>
)}
{open && (
<div className="mt-2 space-y-2">
{comments.length === 0 ? (
<p className="text-[12px] text-[var(--ink-mute)]">No comments yet.</p>
) : (
<ul className="space-y-2">
{comments.map((comment) => (
<li
key={comment.id}
className="rounded-md border border-[var(--hair)] px-2.5 py-2 text-[13px]"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-[11px] text-[var(--ink-mute)]">
{comment.authorName ?? "Someone"} · {formatCommentTime(comment.createdAt)}
</div>
<p className="mt-0.5 whitespace-pre-wrap break-words">{comment.body}</p>
</div>
{comment.authorId === currentUserId ? (
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label="Delete comment"
disabled={isPending}
onClick={() => removeComment(comment.id)}
>
<Trash2 className="size-3.5" />
</Button>
) : null}
</div>
</li>
))}
</ul>
)}
<div className="flex gap-2">
<Input
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Add a comment…"
disabled={isPending}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submitComment();
}
}}
/>
<Button
type="button"
size="sm"
disabled={!draft.trim() || isPending}
onClick={submitComment}
>
Post
</Button>
</div>
</div>
)}
</div>
);
}
function formatCommentTime(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
+60 -11
View File
@@ -3,15 +3,20 @@
import "react-grid-layout/css/styles.css"; import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css"; import "react-resizable/css/styles.css";
import { useEffect, useState, useTransition } from "react"; import { useEffect, useState, useTransition, type ReactNode } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { GridLayout } from "react-grid-layout"; import { GridLayout } from "react-grid-layout";
import type { Layout } from "react-grid-layout"; import type { Layout } from "react-grid-layout";
import { GripVertical, Settings2, Trash2, RotateCcw, Plus, LayoutGrid } from "lucide-react"; import { GripVertical, Settings2, Trash2, RotateCcw, Plus, LayoutGrid } from "lucide-react";
import type { DashboardLayout, WidgetPlacement, PresetId } from "@/lib/dashboard"; import type { DashboardLayout, WidgetPlacement, PresetId } from "@/lib/dashboard";
import { computePresetLayoutFromMetas } from "@/lib/dashboard"; import { computePresetLayoutFromMetas, widgetContentIndexKey } from "@/lib/dashboard";
import type { SerializedWidgetMeta } from "@/modules/_core/registry"; import type { SerializedWidgetMeta } from "@/modules/_core/registry";
import { saveDashboardLayout, resetDashboardLayout } from "@/app/d/actions"; import {
saveDashboardLayout,
resetDashboardLayout,
syncEditorDraftLayout,
discardEditorDraftLayout,
} from "@/app/d/actions";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { WidgetPicker } from "./widget-picker"; import { WidgetPicker } from "./widget-picker";
@@ -23,10 +28,12 @@ export function DashboardEditor({
dashboard, dashboard,
layout: initialLayout, layout: initialLayout,
widgetMetas, widgetMetas,
widgetContents,
}: { }: {
dashboard: { id: string; name: string; slug: string }; dashboard: { id: string; name: string; slug: string };
layout: DashboardLayout; layout: DashboardLayout;
widgetMetas: SerializedWidgetMeta[]; widgetMetas: SerializedWidgetMeta[];
widgetContents: Record<string, ReactNode>;
}) { }) {
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
@@ -56,8 +63,18 @@ export function DashboardEditor({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard.slug]); }, [dashboard.slug]);
function publishDraft(next: WidgetPlacement[]) {
startTransition(async () => {
await syncEditorDraftLayout(dashboard.id, { version: 1, widgets: next });
router.refresh();
});
}
function handleCancel() { function handleCancel() {
startTransition(async () => {
await discardEditorDraftLayout(dashboard.id);
router.push(`/d/${dashboard.slug}`); router.push(`/d/${dashboard.slug}`);
});
} }
function handleSave() { function handleSave() {
@@ -87,26 +104,42 @@ export function DashboardEditor({
} }
function removeWidget(index: number) { function removeWidget(index: number) {
setPlacements((current) => current.filter((_, i) => i !== index)); const next = placements.filter((_, i) => i !== index);
setPlacements(next);
setIsDirty(true); setIsDirty(true);
publishDraft(next);
} }
function addWidget(widgetId: string, config: unknown) { function addWidget(widgetId: string, config: unknown) {
const meta = widgetMetas.find((m) => m.id === widgetId); const meta = widgetMetas.find((m) => m.id === widgetId);
if (!meta) return; if (!meta) return;
const resolvedConfig = resolveWidgetConfig(meta, config);
const maxY = placements.reduce((m, p) => Math.max(m, p.y + p.h), 0); const maxY = placements.reduce((m, p) => Math.max(m, p.y + p.h), 0);
setPlacements((current) => [ const next = [
...current, ...placements,
{ widgetId, config, x: 0, y: maxY, w: meta.defaultSize.w, h: meta.defaultSize.h }, {
]); widgetId,
config: resolvedConfig,
x: 0,
y: maxY,
w: meta.defaultSize.w,
h: meta.defaultSize.h,
},
];
setPlacements(next);
setIsDirty(true); setIsDirty(true);
setPickerOpen(false); setPickerOpen(false);
publishDraft(next);
} }
function updateConfig(index: number, config: unknown) { function updateConfig(index: number, config: unknown) {
setPlacements((current) => current.map((p, i) => (i === index ? { ...p, config } : p))); const meta = widgetMetas.find((m) => m.id === placements[index]?.widgetId);
const resolvedConfig = meta ? resolveWidgetConfig(meta, config) : config;
const next = placements.map((p, i) => (i === index ? { ...p, config: resolvedConfig } : p));
setPlacements(next);
setIsDirty(true); setIsDirty(true);
setConfiguringIndex(null); setConfiguringIndex(null);
publishDraft(next);
} }
function applyPreset(preset: PresetId) { function applyPreset(preset: PresetId) {
@@ -114,6 +147,7 @@ export function DashboardEditor({
setPlacements(next.widgets); setPlacements(next.widgets);
setIsDirty(true); setIsDirty(true);
setPresetMenuOpen(false); setPresetMenuOpen(false);
publishDraft(next.widgets);
} }
const gridItems: Layout = placements.map((p, i) => ({ const gridItems: Layout = placements.map((p, i) => ({
@@ -213,6 +247,7 @@ export function DashboardEditor({
> >
{placements.map((placement, i) => { {placements.map((placement, i) => {
const meta = widgetMetas.find((m) => m.id === placement.widgetId); const meta = widgetMetas.find((m) => m.id === placement.widgetId);
const content = widgetContents[widgetContentIndexKey(i)];
return ( return (
<div <div
key={placementKey(placement, i)} key={placementKey(placement, i)}
@@ -240,8 +275,12 @@ export function DashboardEditor({
<Trash2 className="size-4" /> <Trash2 className="size-4" />
</button> </button>
</div> </div>
<div className="flex-1 flex items-center justify-center p-4"> <div className="flex-1 min-h-0 overflow-auto p-4">
<p className="text-xs text-muted-foreground">{meta?.description}</p> {content ?? (
<p className="text-xs text-muted-foreground text-center">
{meta?.description ?? "Preview available after save"}
</p>
)}
</div> </div>
</div> </div>
); );
@@ -279,3 +318,13 @@ export function DashboardEditor({
</div> </div>
); );
} }
function resolveWidgetConfig(meta: SerializedWidgetMeta, config: unknown): unknown {
if (config != null && typeof config === "object" && !Array.isArray(config)) {
return {
...(meta.defaultConfig as Record<string, unknown>),
...(config as Record<string, unknown>),
};
}
return meta.defaultConfig;
}
+3 -3
View File
@@ -1,5 +1,3 @@
"use client";
const greetingForHour = (h: number) => const greetingForHour = (h: number) =>
h < 5 ? "Up early" : h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening"; h < 5 ? "Up early" : h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening";
@@ -18,7 +16,9 @@ export function DashboardGreeting({ firstName }: { firstName: string }) {
{greeting} {greeting}
{firstName && `, ${firstName}`}. {firstName && `, ${firstName}`}.
</h1> </h1>
<p className="muted mt-1 text-[13px]">{today}</p> <p className="muted mt-1 text-[13px]" suppressHydrationWarning>
{today}
</p>
</div> </div>
); );
} }
@@ -0,0 +1,32 @@
import { Suspense } from "react";
import type { WidgetPlacement } from "@/lib/dashboard";
import { getWidget } from "@/modules/_core";
import type { WidgetContext } from "@/modules/_core/module";
import { Skeleton } from "@/components/ui/skeleton";
export function DashboardWidgetContent({
placement,
ctx,
}: {
placement: WidgetPlacement;
ctx: WidgetContext;
}) {
const widget = getWidget(placement.widgetId);
if (!widget) {
return <p className="text-xs text-muted-foreground">Unknown widget</p>;
}
return (
<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>
}
>
{widget.render({ config: placement.config, ctx })}
</Suspense>
);
}
@@ -0,0 +1,34 @@
"use client";
import { useState, useTransition } from "react";
import { ReminderPicker } from "@/components/reminder-picker";
import { Button } from "@/components/ui/button";
import { setDefaultEventReminderOffsets } from "@/app/settings/reminder-actions";
export function DefaultEventRemindersSetting({ initialOffsets }: { initialOffsets: number[] }) {
const [saved, setSaved] = useState(initialOffsets);
const [offsets, setOffsets] = useState(initialOffsets);
const [isPending, startTransition] = useTransition();
const dirty =
offsets.length !== saved.length || offsets.some((value, index) => value !== saved[index]);
function handleSave() {
startTransition(async () => {
await setDefaultEventReminderOffsets(offsets);
setSaved(offsets);
});
}
return (
<div className="space-y-3">
<p className="muted text-[13px]">
Applied when you create a new calendar event. You can still customize reminders per event.
</p>
<ReminderPicker offsets={offsets} onChange={setOffsets} disabled={isPending} />
<Button type="button" size="sm" disabled={!dirty || isPending} onClick={handleSave}>
{isPending ? "Saving…" : "Save defaults"}
</Button>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { cn } from "@/lib/utils";
type DetailBackLinkProps = {
href: string;
label: string;
className?: string;
};
export function DetailBackLink({ href, label, className }: DetailBackLinkProps) {
return (
<Link
href={href}
aria-label={`Back to ${label}`}
className={cn(
"inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground",
className,
)}
>
<ArrowLeft aria-hidden="true" />
<span>{label}</span>
</Link>
);
}
+4
View File
@@ -1,5 +1,6 @@
import { import {
Bell, Bell,
BookHeart,
Calendar, Calendar,
Sprout, Sprout,
CalendarDays, CalendarDays,
@@ -20,6 +21,7 @@ import {
Lock, Lock,
Mail, Mail,
Menu, Menu,
MessageCircle,
MoreHorizontal, MoreHorizontal,
Pencil, Pencil,
Phone, Phone,
@@ -45,6 +47,7 @@ const ICONS: Record<string, React.ComponentType<LucideProps>> = {
list: ListChecks, list: ListChecks,
"check-square": CheckSquare, "check-square": CheckSquare,
"file-text": FileText, "file-text": FileText,
"book-heart": BookHeart,
note: FileText, note: FileText,
settings: Settings, settings: Settings,
history: History, history: History,
@@ -72,6 +75,7 @@ const ICONS: Record<string, React.ComponentType<LucideProps>> = {
sprout: Sprout, sprout: Sprout,
mail: Mail, mail: Mail,
menu: Menu, menu: Menu,
"message-circle": MessageCircle,
more: MoreHorizontal, more: MoreHorizontal,
x: X, x: X,
"chevron-left": ChevronLeft, "chevron-left": ChevronLeft,
+5
View File
@@ -6,6 +6,7 @@ import { CalendarCreateDialog } from "@/components/quick-add/dialogs/calendar-cr
import { CalendarEventCreateDialog } from "@/components/quick-add/dialogs/calendar-event-create-dialog"; import { CalendarEventCreateDialog } from "@/components/quick-add/dialogs/calendar-event-create-dialog";
import { ContainerCreateDialog } from "@/components/quick-add/dialogs/container-create-dialog"; import { ContainerCreateDialog } from "@/components/quick-add/dialogs/container-create-dialog";
import { GardenCareLogDialog } from "@/components/quick-add/dialogs/garden-care-log-dialog"; import { GardenCareLogDialog } from "@/components/quick-add/dialogs/garden-care-log-dialog";
import { JournalEntryCreateDialog } from "@/components/quick-add/dialogs/journal-entry-create-dialog";
import { ListCreateDialog } from "@/components/quick-add/dialogs/list-create-dialog"; import { ListCreateDialog } from "@/components/quick-add/dialogs/list-create-dialog";
import { ListItemCreateDialog } from "@/components/quick-add/dialogs/list-item-create-dialog"; import { ListItemCreateDialog } from "@/components/quick-add/dialogs/list-item-create-dialog";
import { NoteCreateDialog } from "@/components/quick-add/dialogs/note-create-dialog"; import { NoteCreateDialog } from "@/components/quick-add/dialogs/note-create-dialog";
@@ -29,6 +30,10 @@ export function QuickAddCreateHost() {
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
/> />
<NoteCreateDialog open={activeCreateKey === "notes.note"} onOpenChange={onOpenChange} /> <NoteCreateDialog open={activeCreateKey === "notes.note"} onOpenChange={onOpenChange} />
<JournalEntryCreateDialog
open={activeCreateKey === "journal.entry"}
onOpenChange={onOpenChange}
/>
<ListItemCreateDialog <ListItemCreateDialog
open={activeCreateKey === "lists.add-shopping"} open={activeCreateKey === "lists.add-shopping"}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
@@ -1,6 +1,8 @@
"use client"; "use client";
import dynamic from "next/dynamic";
import { useEffect, useMemo, useState, useTransition } from "react"; import { useEffect, useMemo, useState, useTransition } from "react";
import { ReminderPicker } from "@/components/reminder-picker";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -20,7 +22,24 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { createEvent } from "@/modules/calendar/server/actions"; import { createEvent } from "@/modules/calendar/server/actions";
import { listCalendars, type CalendarDto } from "@/modules/calendar/server/queries"; import { richTextToPlainText } from "@/components/rich-text";
import {
getDefaultEventReminderOffsets,
listCalendars,
type CalendarDto,
} from "@/modules/calendar/server/queries";
const RichTextEditor = dynamic(
() => import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor),
{
ssr: false,
loading: () => (
<div className="min-h-24 rounded-lg border border-input px-3 py-2 text-sm text-muted-foreground animate-pulse">
Loading editor
</div>
),
},
);
type Props = { type Props = {
open: boolean; open: boolean;
@@ -30,7 +49,7 @@ type Props = {
export function CalendarEventCreateDialog({ open, onOpenChange }: Props) { export function CalendarEventCreateDialog({ open, onOpenChange }: Props) {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-2xl">
{open ? <CalendarEventCreateForm onDone={() => onOpenChange(false)} /> : null} {open ? <CalendarEventCreateForm onDone={() => onOpenChange(false)} /> : null}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -45,7 +64,7 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
const [endAt, setEndAt] = useState(() => toInputDateTime(new Date(Date.now() + 60 * 60 * 1000))); const [endAt, setEndAt] = useState(() => toInputDateTime(new Date(Date.now() + 60 * 60 * 1000)));
const [location, setLocation] = useState(""); const [location, setLocation] = useState("");
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [remind, setRemind] = useState(true); const [reminderOffsets, setReminderOffsets] = useState<number[]>([30]);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
useEffect(() => { useEffect(() => {
@@ -53,6 +72,7 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
setCalendars(rows); setCalendars(rows);
setCalendarId(rows[0]?.id ?? ""); setCalendarId(rows[0]?.id ?? "");
}); });
getDefaultEventReminderOffsets().then(setReminderOffsets);
}, []); }, []);
const calendarItems = useMemo( const calendarItems = useMemo(
@@ -71,8 +91,8 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
endAt: new Date(endAt), endAt: new Date(endAt),
allDay: false, allDay: false,
location: location || null, location: location || null,
notes: notes || null, notes: richTextToPlainText(notes) ? notes : null,
remindMinutesBefore: remind ? 30 : null, reminderOffsets,
}); });
onDone(); onDone();
}); });
@@ -146,24 +166,22 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) {
onChange={(e) => setLocation(e.target.value)} onChange={(e) => setLocation(e.target.value)}
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5 min-w-0">
<Label htmlFor="qa-event-notes">Notes</Label> <Label htmlFor="qa-event-notes">Notes</Label>
<textarea <RichTextEditor
id="qa-event-notes" id="qa-event-notes"
className="min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50" aria-label="Notes"
value={notes} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={setNotes}
disabled={isPending}
placeholder="Add details…"
/> />
</div> </div>
<label className="flex items-center gap-2 text-sm"> <ReminderPicker
<input offsets={reminderOffsets}
type="checkbox" onChange={setReminderOffsets}
className="size-4 cursor-pointer" disabled={isPending}
checked={remind}
onChange={(e) => setRemind(e.target.checked)}
/> />
Remind me 30 min before
</label>
</form> </form>
<DialogFooter> <DialogFooter>
<Button type="submit" form="quick-add-event-form" disabled={!title.trim() || isPending}> <Button type="submit" form="quick-add-event-form" disabled={!title.trim() || isPending}>
@@ -0,0 +1,168 @@
"use client";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { MoodPicker } from "@/modules/journal/components/mood-picker";
import { StressSlider } from "@/modules/journal/components/stress-slider";
import { createJournalEntry } from "@/modules/journal/server/actions";
const RichTextEditor = dynamic(
() => import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor),
{
ssr: false,
loading: () => (
<div className="min-h-40 rounded-[var(--r-md)] border-[0.5px] px-3 py-2 text-sm muted animate-pulse">
Loading editor
</div>
),
},
);
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
export function JournalEntryCreateDialog({ open, onOpenChange }: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
{open ? <JournalEntryCreateForm onDone={() => onOpenChange(false)} /> : null}
</DialogContent>
</Dialog>
);
}
function JournalEntryCreateForm({ onDone }: { onDone: () => void }) {
const router = useRouter();
const [recordedAt, setRecordedAt] = useState(toLocalDateTimeValue(null));
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [moods, setMoods] = useState<string[]>([]);
const [stress, setStress] = useState(5);
const [trackStress, setTrackStress] = useState(true);
const [pillsTaken, setPillsTaken] = useState(false);
const [trackPills, setTrackPills] = useState(true);
const [isPending, startTransition] = useTransition();
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
startTransition(async () => {
await createJournalEntry({
recordedAt: new Date(recordedAt),
title: title.trim() || null,
body,
moods,
stress: trackStress ? stress : null,
pillsTaken: trackPills ? pillsTaken : null,
});
router.refresh();
onDone();
});
}
return (
<>
<DialogHeader>
<DialogTitle>New journal entry</DialogTitle>
</DialogHeader>
<form id="quick-add-journal-form" onSubmit={handleSubmit} className="grid gap-3">
<div className="space-y-1.5">
<Label htmlFor="qa-journal-recorded">When</Label>
<Input
id="qa-journal-recorded"
type="datetime-local"
value={recordedAt}
onChange={(e) => setRecordedAt(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="qa-journal-title">Title</Label>
<Input
id="qa-journal-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Optional"
/>
</div>
<div className="space-y-1.5">
<Label>Moods</Label>
<MoodPicker value={moods} onChange={setMoods} disabled={isPending} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="qa-journal-stress">Stress (110)</Label>
<div className="flex items-center gap-2 text-[12px] text-muted-foreground">
<span>Track</span>
<Switch checked={trackStress} onCheckedChange={setTrackStress} />
</div>
</div>
<StressSlider
id="qa-journal-stress"
value={stress}
onChange={setStress}
disabled={!trackStress || isPending}
/>
{!trackStress ? (
<div className="text-[12px] text-muted-foreground">Not tracked</div>
) : null}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="qa-journal-pills">Pills today</Label>
<div className="flex items-center gap-2 text-[12px] text-muted-foreground">
<span>Track</span>
<Switch checked={trackPills} onCheckedChange={setTrackPills} />
</div>
</div>
<div className="flex items-center gap-2 pt-2">
<Switch
id="qa-journal-pills"
checked={pillsTaken}
disabled={!trackPills || isPending}
onCheckedChange={setPillsTaken}
/>
<span className="text-[13px]">{pillsTaken ? "Taken" : "Not taken"}</span>
</div>
</div>
</div>
<div className="space-y-1.5 min-w-0">
<Label htmlFor="qa-journal-body">Reflection</Label>
<RichTextEditor
id="qa-journal-body"
aria-label="Reflection"
value={body}
onChange={setBody}
disabled={isPending}
placeholder="What happened today?"
/>
</div>
</form>
<DialogFooter>
<Button type="submit" form="quick-add-journal-form" disabled={isPending}>
Save entry
</Button>
</DialogFooter>
</>
);
}
function toLocalDateTimeValue(value: string | null) {
const date = value ? new Date(value) : new Date();
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
@@ -1,5 +1,6 @@
"use client"; "use client";
import dynamic from "next/dynamic";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -13,6 +14,18 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { createNote } from "@/modules/notes/server/actions"; import { createNote } from "@/modules/notes/server/actions";
const RichTextEditor = dynamic(
() => import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor),
{
ssr: false,
loading: () => (
<div className="min-h-32 rounded-lg border border-input px-3 py-2 text-sm text-muted-foreground animate-pulse">
Loading editor
</div>
),
},
);
type Props = { type Props = {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@@ -21,7 +34,7 @@ type Props = {
export function NoteCreateDialog({ open, onOpenChange }: Props) { export function NoteCreateDialog({ open, onOpenChange }: Props) {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-2xl">
{open ? <NoteCreateForm onDone={() => onOpenChange(false)} /> : null} {open ? <NoteCreateForm onDone={() => onOpenChange(false)} /> : null}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -62,14 +75,15 @@ function NoteCreateForm({ onDone }: { onDone: () => void }) {
required required
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5 min-w-0">
<Label htmlFor="qa-note-body">Body</Label> <Label htmlFor="qa-note-body">Body</Label>
<textarea <RichTextEditor
id="qa-note-body" id="qa-note-body"
aria-label="Body" aria-label="Body"
className="min-h-32 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
value={body} value={body}
onChange={(e) => setBody(e.target.value)} onChange={setBody}
disabled={isPending}
placeholder="Start writing…"
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { Plus, X } from "lucide-react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
REMINDER_PRESETS,
customOffsetToMinutes,
formatReminderOffset,
normalizeReminderOffsets,
type ReminderOffsetUnit,
} from "@/lib/reminder-offsets";
type Props = {
offsets: number[];
onChange: (offsets: number[]) => void;
disabled?: boolean;
};
export function ReminderPicker({ offsets, onChange, disabled }: Props) {
const [customValue, setCustomValue] = useState("1");
const [customUnit, setCustomUnit] = useState<ReminderOffsetUnit>("hours");
const normalized = useMemo(() => normalizeReminderOffsets(offsets), [offsets]);
function addOffset(minutes: number) {
onChange(normalizeReminderOffsets([...normalized, minutes]));
}
function removeOffset(minutes: number) {
onChange(normalized.filter((o) => o !== minutes));
}
function addCustom() {
const parsed = Number.parseInt(customValue, 10);
if (!Number.isFinite(parsed) || parsed < 0) return;
addOffset(customOffsetToMinutes(parsed, customUnit));
}
const availablePresets = REMINDER_PRESETS.filter((p) => !normalized.includes(p.offsetMinutes));
return (
<div className="space-y-3">
<Label>Reminders</Label>
{normalized.length === 0 ? (
<p className="text-[13px] text-[var(--ink-mute)]">No reminders set.</p>
) : (
<ul className="space-y-1.5">
{normalized.map((offset) => (
<li
key={offset}
className="flex items-center justify-between gap-2 rounded-md border border-[var(--hair)] px-2.5 py-1.5 text-[13px]"
>
<span>{formatReminderOffset(offset)}</span>
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`Remove ${formatReminderOffset(offset)}`}
disabled={disabled}
onClick={() => removeOffset(offset)}
>
<X className="size-3.5" />
</Button>
</li>
))}
</ul>
)}
{availablePresets.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{availablePresets.map((preset) => (
<Button
key={preset.offsetMinutes}
type="button"
size="sm"
variant="outline"
disabled={disabled}
onClick={() => addOffset(preset.offsetMinutes)}
>
<Plus className="size-3" />
{preset.label}
</Button>
))}
</div>
)}
<div className="grid gap-2 sm:grid-cols-[80px_120px_auto] sm:items-end">
<div className="space-y-1">
<Label htmlFor="reminder-custom-value">Custom</Label>
<Input
id="reminder-custom-value"
type="number"
min={0}
value={customValue}
disabled={disabled}
onChange={(e) => setCustomValue(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="reminder-custom-unit">Unit</Label>
<Select
value={customUnit}
onValueChange={(value) => setCustomUnit(value as ReminderOffsetUnit)}
disabled={disabled}
>
<SelectTrigger id="reminder-custom-unit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="minutes">Minutes</SelectItem>
<SelectItem value="hours">Hours</SelectItem>
<SelectItem value="days">Days</SelectItem>
<SelectItem value="weeks">Weeks</SelectItem>
</SelectContent>
</Select>
</div>
<Button type="button" variant="secondary" disabled={disabled} onClick={addCustom}>
Add custom
</Button>
</div>
</div>
);
}

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