Compare commits

..
59 Commits
Author SHA1 Message Date
ginnoir ea371b8085 chore: release v0.6.2
CI / checks (push) Has been skipped
Release Image / build-and-push (push) Successful in 9m6s
2026-07-08 20:46:44 -05:00
ginnoir c8a7160ef2 chore: ignore local worktrees in checks 2026-07-08 20:46:02 -05:00
ginnoir 49732b02f3 fix(agent): move route selector to settings 2026-07-08 19:58:48 -05:00
ginnoir ec3f96dab1 feat(agent): let users choose model route 2026-07-08 19:50:46 -05:00
ginnoir b279f16ba0 feat(agent): refresh assistant model catalog 2026-07-08 19:37:17 -05:00
ginnoir 4dc04b21d6 fix(agent): hide unrelated models for alias fallback 2026-07-08 19:07:57 -05:00
ginnoir bb679d02be fix(agent): improve model selector legibility 2026-07-08 18:59:41 -05:00
ginnoir 716bca0fcb fix(agent): use native model selector 2026-07-08 18:52:19 -05:00
ginnoir 1092941c45 test: stabilize assistant e2e 2026-07-08 17:48:06 -05:00
ginnoir 4c82d551ea feat(agent): add assistant model selector 2026-07-08 16:46:53 -05:00
ginnoir 27db599444 feat(agent): expose available assistant models 2026-07-08 16:43:22 -05:00
ginnoir 3983c30fe1 fix(agent): reject padded assistant model ids 2026-07-08 16:39:01 -05:00
ginnoir 9b7a04431c feat(agent): route chat through selected model 2026-07-08 16:34:22 -05:00
ginnoir bf7e07ead9 feat(agent): persist assistant model preference 2026-07-08 16:24:01 -05:00
ginnoir 876e72671a fix(agent): validate assistant model requests 2026-07-08 16:15:19 -05:00
ginnoir 0bf63cf8a8 feat(agent): add llm model discovery helper 2026-07-08 16:11:22 -05:00
ginnoir eb8e562565 docs: plan assistant model selector 2026-07-08 16:05:28 -05:00
ginnoir 1a58ef993e docs: specify assistant model selector 2026-07-08 15:58:48 -05:00
ginnoir 7c6b8d7c37 ci: install docker buildx plugin for gitea release build
CI / checks (push) Failing after 3s
Debian docker.io ships no buildx plugin, so the registry-cache

build failed when the runner CLI fell back to it. Install

Docker's official CLI plus buildx plugin instead.
2026-07-05 02:09:41 -05:00
ginnoir 3a5c6d056c chore: release v0.6.1
CI / checks (push) Has been skipped
Release Image / build-and-push (push) Failing after 45s
2026-07-05 02:02:07 -05:00
ginnoir c8db5475d3 feat(agent): add voice input and photo attachments to assistant
Wire mic through Whisper-compatible transcriptions on LLM_BASE_URL.

Photos upload to MinIO and reach the vision model as base64 image_url parts.
2026-07-05 02:01:48 -05:00
ginnoir 876a283d47 ci: faster pipelines with checks-only ci and registry build cache
CI / checks (push) Failing after 2m8s
Drop duplicate Next.js builds from CI, skip release commits, and cancel stale runs.

Cache pnpm on runners and use buildx registry cache for releases.
2026-07-04 23:54:53 -05:00
ginnoir c80070f5c3 ci: skip workflow on docs-only changes
CI / checks (push) Failing after 2m15s
CI / build (push) Failing after 12m59s
Ignore pushes and PRs that only touch markdown or docs/ so the runner queue stays clear.
2026-07-04 23:40:10 -05:00
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
ginnoir 02b6ec6adb test: cover all quick-add create dialogs in e2e
CI / checks (push) Failing after 1m56s
CI / build (push) Successful in 3m42s
Add data-driven FAB tests for all ten quick-add actions plus cmd-k new event.
2026-07-04 11:53:07 -05:00
ginnoir 68a573c4d6 fix: quick add opens create ui
Add createKey to quick-add manifests and client create dialog host.

FAB and command palette open in-place create UIs instead of navigating away.
2026-07-04 11:47:35 -05:00
ginnoir 76f68548b2 docs: complete Phase 9 backlog triage (issues map, briefs, ADRs)
Map design IDs to Gitea #1-#37, add tasks 80-88, stub ADRs 0004-0006,
and point STATUS at the P1 batch order.
2026-07-04 01:23:30 -05:00
ginnoir 5dfc8b8034 chore: ignore .worktrees for agent isolation 2026-07-03 23:50:43 -05:00
ginnoir 310ba48e33 docs: add backlog triage implementation plan
Executable steps for Gitea issues, P1 task briefs 80-88,
ADR stubs, and STATUS Phase 9 updates.
2026-07-03 22:31:10 -05:00
ginnoir 72e868dda1 docs: add post-v0.1 backlog triage design
Capture approved issue map, P1 batch order, E2E plans,
and implementation-pass scope for the next work batch.
2026-07-03 22:26:35 -05:00
261 changed files with 19982 additions and 975 deletions
+1
View File
@@ -1,5 +1,6 @@
node_modules
.next
.worktrees
.git
deploy
docs
+12
View File
@@ -31,6 +31,9 @@ NTFY_TOPIC=
# Logging
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_TOKEN=
@@ -43,3 +46,12 @@ MINIO_BUCKET=garden
# OpenPlantBook plant species API (https://open.plantbook.io — free account required)
OPENPLANTBOOK_CLIENT_ID=
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).
# Voice input uses POST {LLM_BASE_URL}/audio/transcriptions (Whisper-compatible).
# Photo messages use vision via the same chat/completions endpoint.
LLM_PROVIDER=openai
LLM_BASE_URL=
LLM_API_KEY=
LLM_MODEL=llama3.2
+13 -20
View File
@@ -3,10 +3,21 @@ name: CI
on:
push:
branches: [main]
paths-ignore:
- "**/*.md"
- "docs/**"
pull_request:
paths-ignore:
- "**/*.md"
- "docs/**"
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
checks:
if: "github.event_name == 'pull_request' || !startsWith(github.event.head_commit.message, 'chore: release')"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -15,6 +26,8 @@ jobs:
run: |
corepack enable
corepack prepare pnpm@10.33.3 --activate
mkdir -p /pnpm-store
pnpm config set store-dir /pnpm-store
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -27,23 +40,3 @@ jobs:
- name: Format check
run: pnpm format:check
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Enable pnpm
run: |
corepack enable
corepack prepare pnpm@10.33.3 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
env:
DATABASE_URL: postgres://ci_user:ci_password@localhost:5432/ci_database
AUTH_SECRET: ci-auth-secret-for-build
NEXT_PUBLIC_APP_URL: http://localhost:3000
+49 -12
View File
@@ -1,11 +1,18 @@
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:
push:
tags:
- "v*"
workflow_dispatch:
concurrency:
group: release-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
build-and-push:
runs-on: ubuntu-latest
@@ -24,8 +31,31 @@ jobs:
echo "image=registry.ginnoir.com/ginnoir/famapp"
} >> "$GITHUB_OUTPUT"
- name: Install docker
run: apt-get update -qq && apt-get install -y -qq docker.io
- name: Ensure docker CLI + buildx
run: |
# Debian's docker.io package ships no buildx plugin, which the
# registry-cache build below requires. Install Docker's official
# CLI + buildx plugin so the build works regardless of what the
# runner image happens to provide.
if docker buildx version >/dev/null 2>&1; then
echo "docker + buildx already available"
docker version
docker buildx version
exit 0
fi
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y -qq docker-ce-cli docker-buildx-plugin
docker version
docker buildx version
- name: Login to registry
run: |
@@ -33,16 +63,23 @@ jobs:
--username "${{ secrets.REGISTRY_PUSH_USERNAME }}" \
--password-stdin
- name: Build image
- name: Set up buildx
run: |
docker build \
-t "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}" \
-t "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.major_minor }}" \
-t "${{ steps.meta.outputs.image }}:latest" \
.
docker buildx create --name famapp-builder --use 2>/dev/null || docker buildx use famapp-builder
docker buildx inspect --bootstrap
- name: Push image
- name: Build and push image
env:
IMAGE: ${{ steps.meta.outputs.image }}
VERSION: ${{ steps.meta.outputs.version }}
MAJOR_MINOR: ${{ steps.meta.outputs.major_minor }}
CACHE: registry.ginnoir.com/ginnoir/famapp:buildcache
run: |
docker push "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}"
docker push "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.major_minor }}"
docker push "${{ steps.meta.outputs.image }}:latest"
docker buildx build \
--push \
--tag "${IMAGE}:${VERSION}" \
--tag "${IMAGE}:${MAJOR_MINOR}" \
--tag "${IMAGE}:latest" \
--cache-from "type=registry,ref=${CACHE}" \
--cache-to "type=registry,ref=${CACHE},mode=max" \
.
+11 -30
View File
@@ -3,10 +3,21 @@ name: CI
on:
push:
branches: [main]
paths-ignore:
- "**/*.md"
- "docs/**"
pull_request:
paths-ignore:
- "**/*.md"
- "docs/**"
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
checks:
if: "github.event_name == 'pull_request' || !startsWith(github.event.head_commit.message, 'chore: release')"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -27,33 +38,3 @@ jobs:
- run: pnpm lint
- run: pnpm format:check
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.33.3
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- uses: actions/cache@v4
with:
path: .next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx', 'src/**/*.css') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-
${{ runner.os }}-nextjs-
- run: pnpm build
env:
DATABASE_URL: postgres://placeholder:placeholder@localhost:5432/placeholder
AUTH_SECRET: ci-placeholder-secret
NEXT_PUBLIC_APP_URL: http://localhost:3000
+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:
push:
+3
View File
@@ -59,3 +59,6 @@ scripts/sync-prod.ps1
# Design handoff bundle (reference only, not committed)
.design-tmp/
# Git worktrees (local agent isolation)
.worktrees/
+1 -1
View File
@@ -67,7 +67,7 @@ src/
### Core primitives every module gets
- **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.
- **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.
+344
View File
@@ -0,0 +1,344 @@
# famapp Architecture Diagram
## System Overview
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ EXTERNAL LAYER │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Browser │ │ Mobile PWA │ │ Share Link │ │
│ │ (Next.js) │ │ (Installed) │ │ (Public) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └────────────────────────┴────────────────────────┘ │
│ │ │
│ HTTPS (Caddy Reverse Proxy) │
│ │ │
│ fam.ginnoir.com → :3000 │
│ auth.ginnoir.com → :9000 │
└──────────────────────────────────┼───────────────────────────────────────────┘
┌──────────────────────────────────┼───────────────────────────────────────────┐
│ APPLICATION LAYER │
├──────────────────────────────────┼───────────────────────────────────────────┤
│ │ │
│ ┌────────────────────────┴────────────────────────┐ │
│ │ Next.js 15 App Router │ │
│ │ (Node 24 LTS Container) │ │
│ └────────────────────────┬────────────────────────┘ │
│ │ │
│ ┌───────────────────────────────┼───────────────────────────────┐ │
│ │ │ │ │
│ │ ┌───────────────────────────┐│ ┌───────────────────────────┐│ │
│ │ │ Middleware ││ │ Auth (NextAuth) ││ │
│ │ │ - Rate limiting ││ │ - OIDC (Authentik) ││ │
│ │ │ - Session check ││ │ - Session management ││ │
│ │ └───────────┬───────────────┘│ └───────────┬───────────────┘│ │
│ │ │ │ │ │ │
│ │ ┌───────────┴───────────────┐│ ┌───────────┴───────────────┐│ │
│ │ │ Routes (app/) ││ │ Module Registry ││ │
│ │ │ / → /d/{slug} ││ │ - Entity types ││ │
│ │ │ /d/[slug] (dashboard) ││ │ - Dashboard widgets ││ │
│ │ │ /calendar ││ │ - Quick-add actions ││ │
│ │ │ /lists ││ │ - Share handlers ││ │
│ │ │ /notes ││ │ - Search adapters ││ │
│ │ │ /garden ││ │ - Reminder handlers ││ │
│ │ │ /s/[token] (share) ││ │ - Activity renderers ││ │
│ │ │ /settings ││ │ - Toggle hooks ││ │
│ │ │ /api/* ││ └───────────┬───────────────┘│ │
│ │ └───────────┬───────────────┘ │ │ │
│ │ │ │ │ │
│ │ ┌───────────┴───────────────┬────────────────┴───────────────┐│ │
│ │ │ │ ││ │
│ │ │ ┌───────────────────────┴──────────────────────────────┐││ │
│ │ │ │ MODULE SYSTEM (src/modules/) │││ │
│ │ │ │ │││ │
│ │ │ │ ┌────────────────────────────────────────────────┐ │││ │
│ │ │ │ │ _core/ │ │││ │
│ │ │ │ │ - schema.ts (users, households, activity_log, │ │││ │
│ │ │ │ │ share_links, dashboards, reminders, push) │ │││ │
│ │ │ │ │ - registry.ts (module registration) │ │││ │
│ │ │ │ │ - share.ts (share-link service) │ │││ │
│ │ │ │ │ - push.ts (web push notifications) │ │││ │
│ │ │ │ │ - reminders.ts (reminder scheduling) │ │││ │
│ │ │ │ │ - activity.ts (activity logging) │ │││ │
│ │ │ │ │ - themes.ts (theme configuration) │ │││ │
│ │ │ │ └────────────────────────────────────────────────┘ │││ │
│ │ │ │ │││ │
│ │ │ │ ┌────────────────────────────────────────────────┐ │││ │
│ │ │ │ │ calendar/ │ │││ │
│ │ │ │ │ - schema.ts (calendars, calendar_events) │ │││ │
│ │ │ │ │ - server/ (queries, mutations, share-queries) │ │││ │
│ │ │ │ │ - components/ (shared-view, calendar views) │ │││ │
│ │ │ │ │ - manifest.tsx (widgets: upcoming, month) │ │││ │
│ │ │ │ └────────────────────────────────────────────────┘ │││ │
│ │ │ │ │││ │
│ │ │ │ ┌────────────────────────────────────────────────┐ │││ │
│ │ │ │ │ lists/ │ │││ │
│ │ │ │ │ - schema.ts (lists, list_items) │ │││ │
│ │ │ │ │ - server/ (queries, mutations, share-queries) │ │││ │
│ │ │ │ │ - components/ (shared-view, list-widget) │ │││ │
│ │ │ │ │ - manifest.tsx (widget: list items) │ │││ │
│ │ │ │ └────────────────────────────────────────────────┘ │││ │
│ │ │ │ │││ │
│ │ │ │ ┌────────────────────────────────────────────────┐ │││ │
│ │ │ │ │ notes/ │ │││ │
│ │ │ │ │ - schema.ts (notes) │ │││ │
│ │ │ │ │ - server/ (queries, mutations, share-queries) │ │││ │
│ │ │ │ │ - components/ (shared-view) │ │││ │
│ │ │ │ │ - manifest.tsx (widget: notes) │ │││ │
│ │ │ │ └────────────────────────────────────────────────┘ │││ │
│ │ │ │ │││ │
│ │ │ │ ┌────────────────────────────────────────────────┐ │││ │
│ │ │ │ │ garden/ │ │││ │
│ │ │ │ │ - schema.ts (plants, containers, care_logs) │ │││ │
│ │ │ │ │ - server/ (queries, mutations, share-queries) │ │││ │
│ │ │ │ │ - components/ (plant-widget, shared-view) │ │││ │
│ │ │ │ │ - manifest.tsx (widgets: care-due, overview) │ │││ │
│ │ │ │ └────────────────────────────────────────────────┘ │││ │
│ │ │ │ │││ │
│ │ │ │ ┌────────────────────────────────────────────────┐ │││ │
│ │ │ │ │ bangs/ │ │││ │
│ │ │ │ │ - schema.ts (bang_events) │ │││ │
│ │ │ │ │ - server/ (queries) │ │││ │
│ │ │ │ │ - components/ (bang-widget) │ │││ │
│ │ │ │ │ - manifest.tsx (widget: bang counter) │ │││ │
│ │ │ │ └────────────────────────────────────────────────┘ │││ │
│ │ │ │ │││ │
│ │ │ └────────────────────────────────────────────────────┘││ │
│ │ │ ││ │
│ │ └─────────────────────────────────────────────────────────┘│ │
│ │ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Shared Libraries (src/lib/) │ │
│ │ - db.ts (Drizzle client) │ │
│ │ - auth.ts (NextAuth configuration) │ │
│ │ - session.ts (session helpers) │ │
│ │ - dashboard.ts (dashboard helpers) │ │
│ │ - logger.ts (pino logging) │ │
│ │ - minio.ts (S3 client for uploads) │ │
│ │ - rate-limit.ts (rate limiting) │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
└───────────────────────────────────────┬─────────────────────────────────────┘
┌───────────────────────────────────────┼─────────────────────────────────────┐
│ DATA LAYER │
├───────────────────────────────────────┼─────────────────────────────────────┤
│ │ │
│ ┌─────────────────────────────┴──────────────────────────┐ │
│ │ Postgres 16 (famapp-db) │ │
│ │ (Dedicated Container) │ │
│ └─────────────────────────────┬──────────────────────────┘ │
│ │ │
│ ┌────────────────────────────────────┼────────────────────────────┐ │
│ │ │ │ │
│ │ Core Tables │ Module Tables │ │
│ │ ┌──────────────────────────────┐ │ ┌──────────────────────┐ │ │
│ │ │ users │ │ │ calendars │ │ │
│ │ │ accounts │ │ │ calendar_events │ │ │
│ │ │ sessions │ │ │ lists │ │ │
│ │ │ verification_tokens │ │ │ list_items │ │ │
│ │ │ households │ │ │ notes │ │ │
│ │ │ household_members │ │ │ garden_plants │ │ │
│ │ │ activity_log │ │ │ garden_containers │ │ │
│ │ │ share_links │ │ │ garden_care_logs │ │ │
│ │ │ dashboards │ │ │ bang_events │ │ │
│ │ │ reminders │ │ └──────────────────────┘ │ │
│ │ │ push_subscriptions │ │ │ │
│ │ │ notifications │ │ │ │
│ │ └──────────────────────────────┘ │ │ │
│ │ │ │ │
│ └────────────────────────────────────┴────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ AUTHENTICATION LAYER │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Authentik (auth.ginnoir.com) │ │
│ │ (Separate Docker Stack) │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ authentik- │ │ authentik- │ │ authentik- │ │ │
│ │ │ server │ │ worker │ │ redis │ │ │
│ │ │ (:9000) │ │ │ │ │ │ │
│ │ └──────┬───────┘ └──────────────┘ └──────┬───────┘ │ │
│ │ │ │ │ │
│ │ └────────────────┬─────────────────┘ │ │
│ │ │ │ │
│ │ ┌───────────────────────┴──────────────────┐ │ │
│ │ │ authentik-db (Postgres 16) │ │ │
│ │ │ (Dedicated Container) │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ │ │ │
│ │ - OIDC Provider for famapp │ │
│ │ - Forward-auth for *arr apps (future) │ │
│ │ - User management │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Module System Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ MODULE REGISTRATION FLOW │
└─────────────────────────────────────────────────────────────────────────────┘
1. Module Registration (src/modules/index.ts)
┌─────────────────────────────────────────────────────────────────────┐
│ import { registerModule } from "./_core/registry"; │
│ import coreManifest from "./_core/manifest"; │
│ import calendarManifest from "./calendar/manifest"; │
│ import listsManifest from "./lists/manifest"; │
│ import notesManifest from "./notes/manifest"; │
│ import gardenManifest from "./garden/manifest"; │
│ import bangsManifest from "./bangs/manifest"; │
│ │
│ registerModule(coreManifest); │
│ registerModule(calendarManifest); │
│ registerModule(listsManifest); │
│ registerModule(notesManifest); │
│ registerModule(gardenManifest); │
│ registerModule(bangsManifest); │
└─────────────────────────────────────────────────────────────────────┘
2. Registry Storage (src/modules/_core/registry.ts)
┌─────────────────────────────────────────────────────────────────────┐
│ const modules = new Map<string, ModuleManifest>(); │
│ const entityTypes = new Map<string, EntityTypeRegistration>(); │
│ const widgets = new Map<string, DashboardWidget>(); │
│ │
│ registerModule() stores: │
│ - Module metadata (id, name, nav) │
│ - Entity types (with share/reminder/search handlers) │
│ - Dashboard widgets (with render functions) │
│ - Quick-add actions │
└─────────────────────────────────────────────────────────────────────┘
3. Module Manifest Structure (src/modules/_core/module.ts)
┌─────────────────────────────────────────────────────────────────────┐
│ ModuleManifest { │
│ id: string; // "calendar", "lists", etc. │
│ name: string; // "Calendar", "Lists", etc. │
│ nav?: { // Navigation entry │
│ href: string; // "/calendar" │
│ label: string; // "Calendar" │
│ icon?: string; // "calendar" │
│ }; │
│ entities: EntityTypeRegistration[]; // Entity type declarations │
│ dashboardWidgets?: DashboardWidget[]; // Widget declarations │
│ quickAdds?: QuickAddAction[]; // Quick-add actions │
│ } │
└─────────────────────────────────────────────────────────────────────┘
4. Entity Type Registration
┌─────────────────────────────────────────────────────────────────────┐
│ EntityTypeRegistration { │
│ type: string; // "calendar.event", "lists.item", etc. │
│ label: { singular, plural }; │
│ share?: { │
│ canShare: boolean; │
│ defaultCapabilities?: string[]; │
│ }; │
│ reminder?: { canRemind: boolean }; │
│ search?: { search: (query, householdId) => SearchResult[] }; │
│ resolveUrl: (id) => string; │
│ canShareEntity?: (id, ctx) => Promise<boolean>; │
│ loadForShare?: (id, ctx) => Promise<unknown>; │
│ renderSharedView?: (data, capabilities, token) => ReactNode; │
│ renderActivity?: (entry) => string; │
│ } │
└─────────────────────────────────────────────────────────────────────┘
5. Dashboard Widget Registration
┌─────────────────────────────────────────────────────────────────────┐
│ DashboardWidget { │
│ id: string; // "calendar.upcoming", "lists.list", etc│
│ title: string; │
│ description: string; │
│ category?: string; // "Calendar", "Lists", etc. │
│ defaultSize: { w, h }; │
│ minSize?: { w, h }; │
│ maxSize?: { w, h }; │
│ defaultPriority: number; // For default dashboard ordering │
│ configSchema: ZodType; // Zod schema for widget config │
│ defaultConfig: unknown; │
│ resolveConfigOptions?: (ctx) => Promise<unknown>; │
│ render: (props) => ReactNode; // Server component render │
│ } │
└─────────────────────────────────────────────────────────────────────┘
```
## Data Flow Patterns
### Share Link Flow
```
User creates share link
→ Module's canShareEntity() checks permissions
→ Core share.ts creates share_links record
→ Returns token: /s/<token>
Public viewer accesses /s/<token>
→ Middleware rate-limits by IP:token prefix
→ Share viewer loads share_links record
→ Module's loadForShare() loads entity data
→ Module's renderSharedView() renders public view
→ Share-link grants scoped read/write per capabilities
```
### Activity Logging Flow
```
Any entity mutation (server action)
→ Core activity.ts logs activity_log entry
→ Includes: entityType, entityId, actorId, action, payload
→ Dashboard activity widget queries activity_log
→ Module's renderActivity() formats entry for display
```
### Reminder Flow
```
Entity with remind_at field (note, event)
→ Module schedules reminder via core reminders.ts
→ reminders table stores: entityType, entityId, fireAt, channel
→ Background worker (planned) fires reminders
→ notify() fans out to: web push, in-app, optional ntfy
```
### Dashboard Widget Flow
```
User configures dashboard layout
→ dashboards.layout stores: [{ widgetId, config, x, y, w, h }]
→ Dashboard page loads layout
→ For each widget: calls module's render() with config
→ Widget component queries data via module's server/ queries
→ Renders widget in grid layout
```
## Key Design Principles
1. **Module Isolation**: Modules only import from `_core` and `lib/` - never from sibling modules
2. **Generic Core Services**: Sharing, reminders, activity log, search work against any registered entity type
3. **Household Scoping**: All entity data scoped to household_id; permissions gated by household membership
4. **Server Actions**: All mutations use Next.js server actions; route handlers only for webhooks/SSE/share viewer
5. **Type Safety**: TypeScript strict mode; Drizzle schema inference; Zod validation for all configs
6. **Extensibility**: Adding new features requires only creating a new module - no core changes needed
7. **PWA First**: Installable PWA with web(push) notifications; no native apps required
8. **Privacy-First Sharing**: Share links are temporary, scoped, and revocable
## Future (Phase 9)
Placeholders only — details land with tasks 8588:
- **`journal/` module** — per-user journal (task 86, Gitea #19)
- **Versioned HTTP API** (`/api/v1/…`) with token auth alongside OIDC session (task 87, Gitea #15)
- **LLM agent chat** — OpenAI-compatible tools over the API (task 88)
+116
View File
@@ -1,5 +1,121 @@
# Changelog
## [0.6.2](https://github.com/ginnoir/famapp/compare/v0.6.1...v0.6.2) (2026-07-09)
### Features
- **agent:** add assistant model selector ([4c82d55](https://github.com/ginnoir/famapp/commit/4c82d551ea1f0c8091a3ffc31935a8d22855bbc0))
- **agent:** add llm model discovery helper ([0bf63cf](https://github.com/ginnoir/famapp/commit/0bf63cf8a83a1adca58fa9e8bf42f16dac60687b))
- **agent:** expose available assistant models ([27db599](https://github.com/ginnoir/famapp/commit/27db599444fc0bb80d5a1650e2f5b5ba4f695c8e))
- **agent:** let users choose model route ([ec3f96d](https://github.com/ginnoir/famapp/commit/ec3f96dab170fb2333780438bdec3628cfc58116))
- **agent:** persist assistant model preference ([bf7e07e](https://github.com/ginnoir/famapp/commit/bf7e07ead91dd8c428f06551afb2113ce838cc82))
- **agent:** refresh assistant model catalog ([b279f16](https://github.com/ginnoir/famapp/commit/b279f16ba0e1537d2752d30bf5e565f4ede400af))
- **agent:** route chat through selected model ([9b7a044](https://github.com/ginnoir/famapp/commit/9b7a04431cb12609cbcd766e05fbc411599e0b34))
### Bug Fixes
- **agent:** hide unrelated models for alias fallback ([4dc04b2](https://github.com/ginnoir/famapp/commit/4dc04b21d63aaef7c773f238602f06edbbf03509))
- **agent:** improve model selector legibility ([bb679d0](https://github.com/ginnoir/famapp/commit/bb679d02be28440acbc860146cbcb3809224aa09))
- **agent:** move route selector to settings ([49732b0](https://github.com/ginnoir/famapp/commit/49732b02f31cbef57e6cd3b577bee6dbd998e029))
- **agent:** reject padded assistant model ids ([3983c30](https://github.com/ginnoir/famapp/commit/3983c30fe1b8a324f7d5826df0b24506fe054e50))
- **agent:** use native model selector ([716bca0](https://github.com/ginnoir/famapp/commit/716bca0fcba8f2026c814f668bf873cf61a0ad16))
- **agent:** validate assistant model requests ([876e726](https://github.com/ginnoir/famapp/commit/876e72671a0b82b579a9783eb86f452a4a026a52))
### Documentation
- plan assistant model selector ([eb8e562](https://github.com/ginnoir/famapp/commit/eb8e5625656a1e3fe97e19b0bb2514660cf0c5f6))
- specify assistant model selector ([1a58ef9](https://github.com/ginnoir/famapp/commit/1a58ef993e62ea2855d7d5aafc17446a0e91c33c))
## [0.6.1](https://github.com/ginnoir/famapp/compare/v0.6.0...v0.6.1) (2026-07-05)
### Features
- **agent:** add voice input and photo attachments to assistant ([c8db547](https://github.com/ginnoir/famapp/commit/c8db5475d314b239246e4786e362bb044992d660))
## [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)
### Bug Fixes
+1 -1
View File
@@ -68,7 +68,7 @@ src/
### Core primitives every module gets
- **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.
- **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.
+3
View File
@@ -14,6 +14,9 @@ WORKDIR /app
# CI=true prevents pnpm from prompting for TTY confirmation when removing modules dir
ENV CI=true
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 . .
RUN pnpm install --offline --frozen-lockfile
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)
[![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)
---
+42 -3
View File
@@ -4,6 +4,35 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b
## 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.
- **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.
@@ -42,9 +71,19 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b
## Next up
- **Ready to tag v0.1.0.** All phase 17 tasks complete and production wiring verified.
- Run `docs/tasks/09-pre-deploy-checklist.md` before pushing the tag.
- On the server: clone repo to `/srv/famapp`, copy `.env.production.example``/srv/famapp/deploy/.env`, fill in secrets (AUTH_SECRET, DB passwords, AUTHENTIK_SECRET_KEY, VAPID keys, AUTH_OIDC_CLIENT_ID/SECRET), add the Caddyfile snippet, bootstrap Authentik per `deploy/authentik/README.md`, then `docker compose -f deploy/compose.yaml up -d`.
Phase 9 — Post-v0.1 (see `docs/superpowers/specs/2026-07-03-backlog-triage-design.md`, `docs/issues-map.md`).
**Batch order:**
1. ~~Bugs: tasks 8084~~ — done
2. ~~API foundation: task 87 (+ ADR 0006)~~ — done
3. ~~Shared rich-text + notes overhaul: task 85 (+ ADR 0004)~~ — done
4. ~~Journal: task 86 (+ ADR 0005), including journal API endpoints~~ — done
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.
**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
+5 -1
View File
@@ -15,12 +15,16 @@ pnpm release:patch # or :minor / :major
# 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:0.x` (minor alias)
- `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
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
+89
View File
@@ -0,0 +1,89 @@
# 0004 — Rich-text editor library and storage format
Date: 2026-07-03
Status: accepted (2026-07-04)
## Context
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
### 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
### 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`
+128
View File
@@ -0,0 +1,128 @@
# 0005 — Journal / mood tracking
Date: 2026-07-03
Status: accepted (2026-07-04)
## Context
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
### 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
### 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
+77
View File
@@ -0,0 +1,77 @@
# 0006 — API auth and LLM agent architecture
Date: 2026-07-03
Status: accepted (2026-07-04)
## Context
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
### 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
### 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
View File
@@ -26,4 +26,7 @@ What this costs us, what it buys us.
- [0002 — List realtime uses Postgres NOTIFY and SSE](0002-list-sse-notify.md)
- [0003 — Release workflow (commitlint + release-it)](0003-release-workflow.md)
- [0004 — Rich-text editor library and storage format](0004-rich-text-editor.md) (accepted 2026-07-04)
- [0005 — Journal / mood tracking](0005-journal-research.md) (accepted 2026-07-04)
- [0006 — API auth and LLM agent architecture](0006-api-llm-agent.md) (accepted 2026-07-04)
<!-- END AUTO-GENERATED -->
+57
View File
@@ -0,0 +1,57 @@
# Issue map
Design IDs from `docs/superpowers/specs/2026-07-03-backlog-triage-design.md` → Gitea (`ginnoir/famapp`).
| Design | Gitea | Title | URL |
| ------ | ----- | ------------------------------------------------------------------- | -------------------------------------------------- |
| 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 |
| 3 | 3 | Garden container plant count is wrong | https://gitea.ginnoir.com/ginnoir/famapp/issues/3 |
| 4 | 4 | Bangs are not editable (add edit and delete) | https://gitea.ginnoir.com/ginnoir/famapp/issues/4 |
| 5 | 5 | Notes overflow horizontally on mobile | https://gitea.ginnoir.com/ginnoir/famapp/issues/5 |
| 6 | 6 | Missing back navigation on detail pages (audit + shared affordance) | https://gitea.ginnoir.com/ginnoir/famapp/issues/6 |
| 7 | 28 | Calendar reminders overhaul | https://gitea.ginnoir.com/ginnoir/famapp/issues/28 |
| 8 | 29 | Lists index: inline task add + list property edit | https://gitea.ginnoir.com/ginnoir/famapp/issues/29 |
| 9 | 30 | Comments on lists and tasks | https://gitea.ginnoir.com/ginnoir/famapp/issues/30 |
| 10 | 7 | Notes editor overhaul (shared rich-text component) | https://gitea.ginnoir.com/ginnoir/famapp/issues/7 |
| 10.1 | 8 | Research ADR: rich-text editor library and storage format | https://gitea.ginnoir.com/ginnoir/famapp/issues/8 |
| 10.2 | 9 | Shared rich-text editor component | https://gitea.ginnoir.com/ginnoir/famapp/issues/9 |
| 10.3 | 10 | Wire shared editor into notes create/edit | https://gitea.ginnoir.com/ginnoir/famapp/issues/10 |
| 10.4 | 11 | Render note formatting on all notes surfaces | https://gitea.ginnoir.com/ginnoir/famapp/issues/11 |
| 10.5 | 12 | Fix notes mobile horizontal overflow | https://gitea.ginnoir.com/ginnoir/famapp/issues/12 |
| 11 | 31 | Bang stats dashboard widget | https://gitea.ginnoir.com/ginnoir/famapp/issues/31 |
| 12 | 35 | Appearance / theming options | https://gitea.ginnoir.com/ginnoir/famapp/issues/35 |
| 13 | 19 | Journal module | https://gitea.ginnoir.com/ginnoir/famapp/issues/19 |
| 13.1 | 20 | Research ADR: journal/mood tracking build-vs-adopt | https://gitea.ginnoir.com/ginnoir/famapp/issues/20 |
| 13.2 | 21 | Journal schema + CRUD | https://gitea.ginnoir.com/ginnoir/famapp/issues/21 |
| 13.3 | 22 | Journal index (recent, browse, entry calendar) | https://gitea.ginnoir.com/ginnoir/famapp/issues/22 |
| 13.4 | 23 | Journal entry detail and create | https://gitea.ginnoir.com/ginnoir/famapp/issues/23 |
| 13.5 | 24 | Journal mood tracker view | https://gitea.ginnoir.com/ginnoir/famapp/issues/24 |
| 13.6 | 25 | Journal insights/stats views | https://gitea.ginnoir.com/ginnoir/famapp/issues/25 |
| 13.7 | 26 | Journal E2E happy path | https://gitea.ginnoir.com/ginnoir/famapp/issues/26 |
| 13.8 | 27 | Journal API endpoints | https://gitea.ginnoir.com/ginnoir/famapp/issues/27 |
| 14 | 13 | API + LLM agent chat | https://gitea.ginnoir.com/ginnoir/famapp/issues/13 |
| 14.1 | 14 | Research ADR: API auth, surface shape, and LLM agent architecture | https://gitea.ginnoir.com/ginnoir/famapp/issues/14 |
| 14.2 | 15 | API surface + token auth for existing modules | https://gitea.ginnoir.com/ginnoir/famapp/issues/15 |
| 14.3 | 16 | LLM agent chat UI | https://gitea.ginnoir.com/ginnoir/famapp/issues/16 |
| 14.4 | 17 | Map agent tool-calling to API | https://gitea.ginnoir.com/ginnoir/famapp/issues/17 |
| 14.5 | 18 | Agent smoke tests with mock provider | https://gitea.ginnoir.com/ginnoir/famapp/issues/18 |
| 15 | 32 | Pets module | https://gitea.ginnoir.com/ginnoir/famapp/issues/32 |
| 16 | 33 | Shopping/pantry module | https://gitea.ginnoir.com/ginnoir/famapp/issues/33 |
| 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 |
| 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)
| Issue | Depends on |
| --------------------------- | -------------------------------------- |
| #5 (notes overflow) | #7 (rich-text epic) |
| #12 (mobile overflow child) | #8 (rich-text research ADR) |
| #19 (journal epic) | #7 (rich-text epic), #15 (API surface) |
| #16 (agent UI) | #15 (API surface) |
| #17 (tool-calling) | #15 (API surface) |
| #27 (journal API) | #15 (API surface) |
Native Gitea issue dependencies API 404s on this instance; dependencies are also noted as comments on the issues above.
@@ -0,0 +1,714 @@
# Backlog Triage Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** File the full post-v0.1 backlog on Gitea, write P1 task briefs and ADR stubs, and point STATUS at the approved batch order — no application feature code.
**Architecture:** Issue-centric tracking on `gitea.ginnoir.com/ginnoir/famapp`. Design IDs (#1#19) map to real Gitea issue numbers via `docs/issues-map.md`. P1 work is executable from `docs/tasks/80``88`; P2/P3 are issues only. Spec: `docs/superpowers/specs/2026-07-03-backlog-triage-design.md`.
**Tech Stack:** Gitea API / `user-gitea` MCP (`CreateRepoLabel`, `CreateIssue`, `AddIssueDependency`), markdown task briefs, lightweight ADRs.
---
## File map
| Path | Responsibility |
| ----------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `docs/issues-map.md` | Design ID → Gitea issue number + URL |
| `docs/tasks/80-*.md``88-*.md` | P1 executable briefs |
| `docs/tasks/README.md` | Phase 9 index |
| `docs/decisions/0004-rich-text-editor.md` | ADR stub (full body later) |
| `docs/decisions/0005-journal-research.md` | ADR stub |
| `docs/decisions/0006-api-llm-agent.md` | ADR stub |
| `docs/decisions/README.md` | Index entries for 00040006 |
| `STATUS.md` | Next-up = Phase 9 order + links |
| `ARCHITECTURE.md` | Light placeholders for `/api/v1` and journal (only if already tracked; do not invent diagrams) |
| Gitea issues | Source of truth for backlog items |
**Auth:** Use Gitea MCP (`user-gitea`) or REST with token from Obsidian `claude/Credentials.md` (Gitea section). Header: `Authorization: token <tok>`. Base: `https://gitea.ginnoir.com/api/v1`. Owner/repo: `ginnoir` / `famapp`. **Never commit the token.**
**CreateIssue requirement:** Every issue `body` MUST include `<brief>one-line summary</brief>` (Gitea MCP enforces this).
**Fallback:** If Gitea is unreachable, write each body to `docs/issues-drafts/<slug>.md` and stop after Task 1 with a clear note — do not invent issue numbers.
---
### Task 1: Create labels
**Files:** none (Gitea only)
- [ ] **Step 1: Confirm repo has zero labels**
Run via MCP `gitea_read` / `ListRepoLabels` with `owner=ginnoir`, `repo=famapp`, or:
```powershell
$headers = @{ Authorization = "token <from-vault>" }
Invoke-RestMethod -Uri "https://gitea.ginnoir.com/api/v1/repos/ginnoir/famapp/labels" -Headers $headers
```
Expected: `[]` (or existing labels — skip creates for names that already exist).
- [ ] **Step 2: Create type labels**
For each row, MCP `gitea_write` / `CreateRepoLabel` (`owner=ginnoir`, `repo=famapp`):
| name | color | description |
| ------------- | -------- | -------------------------------- |
| `bug` | `d73a4a` | Something is broken |
| `enhancement` | `a2eeef` | Improvement to existing behavior |
| `feature` | `0e8a16` | New user-facing capability |
| `epic` | `5319e7` | Large multi-issue effort |
| `research` | `fbca04` | Investigation / ADR only |
- [ ] **Step 3: Create priority labels**
| name | color | description |
| ------------- | -------- | ----------- |
| `priority/p1` | `b60205` | Next batch |
| `priority/p2` | `ff9f1c` | After P1 |
| `priority/p3` | `cfd3d7` | Backlog |
- [ ] **Step 4: Record label IDs**
List labels again. Write a temporary note (or keep in shell) mapping name → `id` (int). All later `CreateIssue` calls pass `labels: [typeId, priorityId]` as **IDs**, not names.
---
### Task 2: File P1 bug issues (design #1#6)
**Files:**
- Create: `docs/issues-map.md` (start the table)
Create issues **in this order** so mapping is predictable. After each create, append a row to `docs/issues-map.md`:
```markdown
# Issue map
Design IDs from `docs/superpowers/specs/2026-07-03-backlog-triage-design.md` → Gitea.
| Design | Gitea | Title | URL |
| ------ | ----- | ------------------------------------------ | ------------------------------------------------- |
| 1 | N | Quick-add opens create UI, not module page | https://gitea.ginnoir.com/ginnoir/famapp/issues/N |
```
Labels for all six: `bug` + `priority/p1`.
- [ ] **Step 1: Create design #1 — Quick-add**
`CreateIssue` title: `Quick-add opens create UI, not module page`
Body:
```markdown
<brief>Quick-add actions must open create dialogs/sheets, not only navigate to module pages.</brief>
## Background / current behavior
Every quick-add entry (FAB sheet and cmd-k palette) currently navigates to its module page (e.g. "New event" → `/calendar`). Task 21 intentionally used `url` navigation as a placeholder; inline create was deferred.
Relevant code: `src/modules/_core/module.ts` (`QuickAddAction`), module manifests (`quickAdds`), `QuickAddSheet` / `CommandPalette` under `src/components/`.
## Expected behavior
Each quick-add opens the **creation UI** for that entity (dialog/sheet) from wherever the button lives, without requiring a full page navigation first. Navigation-only is not acceptable.
## Acceptance criteria
- [ ] "New event" opens the event create dialog/sheet with create controls visible.
- [ ] List/note/garden (and other registered) quick-adds open their create UI the same way.
- [ ] Cmd-k and FAB sheet behave identically for the same action.
- [ ] Playwright: extend `tests/e2e/dashboard.spec.ts` and/or add `tests/e2e/quick-add.spec.ts` — open FAB → action opens dialog/sheet, not only a route change.
## Priority
P1 (batch step 1). Task brief: `docs/tasks/80-quick-add-create-ui.md`.
```
- [ ] **Step 2: Create design #2 — Dashboard edit live widgets**
Title: `Dashboard edit mode renders live widgets at true size`
Body:
```markdown
<brief>Dashboard edit mode must render real widget content at final size, not empty placeholders.</brief>
## Background / current behavior
In edit mode (`?edit=1`), widgets render as generic placeholders at the dragged size. On save, widgets like Recent Activity expand to fit content and the dashboard reflows. What you arrange is not what you get.
Relevant: `DashboardEditor`, react-grid-layout usage, dashboard page under `src/app/d/`.
## Expected behavior
Edit mode live-loads each widget's real data and renders at true final size so layout matches the saved view.
## Acceptance criteria
- [ ] Edit mode shows real widget content (not empty shells) for registered widgets.
- [ ] Saving layout does not cause a large content-driven reflow for content-heavy widgets (e.g. Recent activity).
- [ ] Playwright: extend `tests/e2e/dashboard.spec.ts` — enter edit mode, assert real content visible (e.g. Recent activity). Prefer height-stability assertion only if non-flaky.
## Priority
P1 (batch step 2). Task brief: `docs/tasks/81-dashboard-edit-live-widgets.md`.
```
- [ ] **Step 3: Create design #3 — Garden plant count**
Title: `Garden container plant count is wrong`
Body:
```markdown
<brief>Container shows "0 plants" despite containing a plant; fix count query and audit siblings.</brief>
## Background / current behavior
A garden container (e.g. "plantie cabinet") shows "0 plants" when it contains at least one plant. Likely a query/join or container-association bug.
Relevant: `src/modules/garden/server/queries.ts`, container list/detail components.
## Expected behavior
Container plant counts match the number of plants associated with that container. Any other count displays sharing the same bug are fixed too.
## Acceptance criteria
- [ ] Container with N plants displays N (not 0).
- [ ] Audit other garden counts (lists, widgets) for the same pattern; fix or file follow-ups.
- [ ] Playwright: extend `tests/e2e/garden.spec.ts` — plant in container → count ≥ 1.
## Priority
P1 (batch step 3). Task brief: `docs/tasks/82-garden-plant-count.md`.
```
- [ ] **Step 4: Create design #4 — Bang edit/delete**
Title: `Bangs are not editable (add edit and delete)`
Body:
```markdown
<brief>Bang entries cannot be corrected after creation; add edit and delete.</brief>
## Background / current behavior
Bang events can be recorded but not corrected (e.g. wrong date). Only create exists in `src/modules/bangs/server/actions.ts` and the widget UI.
## Expected behavior
Users can edit (at least date/fields present today) and delete bang entries.
## Acceptance criteria
- [ ] Edit a bang (e.g. change date) and see the update in the widget/list.
- [ ] Delete a bang and it no longer appears.
- [ ] Activity log entries for update/delete if other modules log similarly.
- [ ] Playwright: new `tests/e2e/bangs.spec.ts` — record → edit date → assert → delete → assert gone.
## Priority
P1 (batch step 4). Task brief: `docs/tasks/83-bang-edit-delete.md`. Related (not blocking): bang stats widget (design #11).
```
- [ ] **Step 5: Create design #5 — Notes mobile overflow**
Title: `Notes overflow horizontally on mobile`
Body:
```markdown
<brief>Note content scrolls sideways on small viewports instead of wrapping.</brief>
## Background / current behavior
On mobile, note content overflows horizontally. Fix belongs with the shared rich-text / notes overhaul (design #10) so formatting and overflow are solved together.
## Expected behavior
Note content wraps; no horizontal page scroll from note body on small viewports.
## Acceptance criteria
- [ ] Long / wide note content at 375px width does not cause horizontal overflow.
- [ ] Playwright: extend `tests/e2e/notes.spec.ts``scrollWidth` within viewport tolerance (same pattern as dashboard mobile check).
- [ ] Closed together with design #10 / task `85-rich-text-notes.md`.
## Priority
P1 (batch step 7, with #10). **Blocked by** design #10 (rich-text notes overhaul).
```
- [ ] **Step 6: Create design #6 — Back navigation**
Title: `Missing back navigation on detail pages (audit + shared affordance)`
Body:
```markdown
<brief>Audit detail routes lacking back affordances; add a shared header/back pattern.</brief>
## Background / current behavior
Individual plant pages (and other detail routes) have no in-page "back" except global nav. Affects many detail pages under `src/app/`.
## Expected behavior
Every detail page has a consistent back control to its parent list/index. Prefer a shared header/back component.
## Acceptance criteria
- [ ] Audit all routes in `src/app/` and list detail pages missing back in the PR description.
- [ ] Shared back affordance applied consistently.
- [ ] Playwright: `tests/e2e/navigation.spec.ts` or per-module — open plant/note/list (and other) detail routes → Back visible and returns to parent.
## Priority
P1 (batch step 5). Task brief: `docs/tasks/84-back-navigation.md`.
```
- [ ] **Step 7: Update `docs/issues-map.md` with all six rows**
---
### Task 3: File P1 epic issues (#10, #13, #14 and children)
Labels: parent epics use `epic` + `priority/p1`. Children use `enhancement` or `feature` or `research` as noted + `priority/p1`.
- [ ] **Step 1: Create design #10 parent — Notes editor / shared rich-text**
Title: `Notes editor overhaul (shared rich-text component)`
Labels: `epic`, `priority/p1`
Body: include `<brief>…</brief>`, checklist of children (research ADR, shared editor, notes wiring, render everywhere, mobile overflow), links to task `85` and ADR `0004`, note that #5 closes with this epic.
- [ ] **Step 2: Create #10 children** (five issues), titles:
1. `Research ADR: rich-text editor library and storage format` — labels `research`, `priority/p1`
2. `Shared rich-text editor component``enhancement`, `priority/p1`
3. `Wire shared editor into notes create/edit``enhancement`, `priority/p1`
4. `Render note formatting on all notes surfaces``enhancement`, `priority/p1`
5. `Fix notes mobile horizontal overflow``bug`, `priority/p1` (can reference design #5; may close #5 when done)
Each body: brief, parent epic link (fill Gitea # after parent exists), acceptance criteria one-liners, task `85` / ADR `0004`.
- [ ] **Step 3: Create design #14 parent — API + LLM agent chat**
Title: `API + LLM agent chat`
Labels: `epic`, `priority/p1`
Body: checklist — ADR → API surface for existing modules → (pause for editor/journal) → agent UI → tool-calling → smoke tests. Task briefs `87`, `88`. ADR `0006`. API early, agent late.
- [ ] **Step 4: Create #14 children**
1. `Research ADR: API auth, surface shape, and LLM agent architecture``research`, `priority/p1`
2. `API surface + token auth for existing modules``feature`, `priority/p1`
3. `LLM agent chat UI``feature`, `priority/p1`
4. `Map agent tool-calling to API``feature`, `priority/p1`
5. `Agent smoke tests with mock provider``enhancement`, `priority/p1`
- [ ] **Step 5: Create design #13 parent — Journal module**
Title: `Journal module`
Labels: `epic`, `priority/p1`
Body: requirements from design (per-user journal, recent entries, browse, calendar of entry days, entry fields, mood tracker, insights, research first). Depends on #10 and API foundation (#14 API child). Task `86`, ADR `0005`.
- [ ] **Step 6: Create #13 children**
1. `Research ADR: journal/mood tracking build-vs-adopt``research`, `priority/p1`
2. `Journal schema + CRUD``feature`, `priority/p1`
3. `Journal index (recent, browse, entry calendar)``feature`, `priority/p1`
4. `Journal entry detail and create``feature`, `priority/p1`
5. `Journal mood tracker view``feature`, `priority/p1`
6. `Journal insights/stats views``feature`, `priority/p1`
7. `Journal E2E happy path``enhancement`, `priority/p1`
8. `Journal API endpoints``feature`, `priority/p1`
- [ ] **Step 7: Wire dependencies** (`AddIssueDependency`)
| Issue (index) | Depends on (depends_on_id) |
| ---------------------------------- | ---------------------------------------- |
| Design #5 (notes overflow) | Design #10 parent |
| Design #10 child "mobile overflow" | Design #10 research ADR child (optional) |
| Design #13 parent | Design #10 parent |
| Design #13 parent | Design #14 API surface child |
| Design #14 agent UI child | Design #14 API surface child |
| Design #14 tool-calling child | Design #14 API surface child |
| Design #13 journal API child | Design #14 API surface child |
Use real Gitea indexes from `docs/issues-map.md`.
- [ ] **Step 8: Update `docs/issues-map.md`** with all epic + child rows (use design IDs like `10`, `10.1`, `13.2`, `14.1`).
---
### Task 4: File P2 and P3 issues
- [ ] **Step 1: P2 enhancements/features** (labels as in design)
| Design | Title | Labels |
| ------ | ------------------------------------------------- | ---------------------------- |
| 7 | Calendar reminders overhaul | `enhancement`, `priority/p2` |
| 8 | Lists index: inline task add + list property edit | `enhancement`, `priority/p2` |
| 9 | Comments on lists and tasks | `feature`, `priority/p2` |
| 11 | Bang stats dashboard widget | `enhancement`, `priority/p2` |
Each body: brief, background, expected, acceptance criteria (high level), note "full `docs/tasks/` when P2 batch starts", related links (#7 → task 41; #11 related to #4; #9 related to #19).
- [ ] **Step 2: P2 epics with light checklists only** (no child issues)
| Design | Title |
| ------ | ---------------------- |
| 15 | Pets module |
| 16 | Shopping/pantry module |
| 17 | Backups & exports |
Labels: `epic`, `priority/p2`. Body includes a markdown checklist of future sub-work (not filed as issues yet). #17 references `docs/tasks/60-backups.md`.
- [ ] **Step 3: P3**
| Design | Title | Labels |
| ------ | ------------------------------------------ | ------------------------- |
| 12 | Appearance / theming options | `epic`, `priority/p3` |
| 18 | GPS locations for calendar events | `research`, `priority/p3` |
| 19 | Lists ↔ notes cohesion (research proposal) | `research`, `priority/p3` |
- [ ] **Step 4: Finish `docs/issues-map.md`** for designs 79, 1112, 1519.
- [ ] **Step 5: Commit map**
```bash
git add docs/issues-map.md
git commit -m "docs: map design backlog IDs to Gitea issues"
```
If only drafts were written under `docs/issues-drafts/`, commit those instead and stop — do not invent Gitea numbers.
---
### Task 5: Write bug task briefs (8084)
**Files:**
- Create: `docs/tasks/80-quick-add-create-ui.md`
- Create: `docs/tasks/81-dashboard-edit-live-widgets.md`
- Create: `docs/tasks/82-garden-plant-count.md`
- Create: `docs/tasks/83-bang-edit-delete.md`
- Create: `docs/tasks/84-back-navigation.md`
Each file uses the standard sections: Goal, Why, Depends on, Scope, Out of scope, Acceptance criteria, Notes.
- [ ] **Step 1: Write `80-quick-add-create-ui.md`**
```markdown
# 80 — Quick-add opens create UI
## Goal
Make every quick-add action open the entity's creation dialog/sheet instead of only navigating to a module page.
## Why
Task 21 shipped URL navigation as a placeholder. Users expect "New event" to start creating an event immediately.
## Depends on
- 21 (quick-add registry)
## Scope
- Extend `QuickAddAction` / client handling so actions can open create UI (dialog/sheet) in-place.
- Wire calendar, lists, notes, garden, bangs (and any other registered) quick-adds to their create UIs.
- Keep FAB sheet and cmd-k behavior identical per action.
- Playwright coverage per design #1 / Gitea issue (see `docs/issues-map.md`).
## Out of scope
- Fuzzy search across entities.
- New entity types.
## Acceptance criteria
- [ ] Each registered quick-add opens create UI with create controls visible.
- [ ] No quick-add is navigation-only unless explicitly documented as view-only (none in v1 set).
- [ ] E2E: FAB → action opens dialog/sheet (not only route change).
## Notes
- Gitea: design #1 (URL in `docs/issues-map.md`).
- Prefer reusing existing create dialogs/sheets over new ones.
```
Fill Gitea issue number into Notes after map exists (replace "design #1" with `ginnoir/famapp#N`).
- [ ] **Step 2: Write `81-dashboard-edit-live-widgets.md`**
Same structure: Goal = edit mode renders live widgets at true size; Depends on 26; Scope = DashboardEditor loads real widget components/data; Acceptance = real content in edit mode + E2E; Notes → design #2.
- [ ] **Step 3: Write `82-garden-plant-count.md`**
Goal = fix container plant count; Depends on 71/72; Scope = find and fix query/join, audit sibling counts; E2E garden.spec; Notes → design #3.
- [ ] **Step 4: Write `83-bang-edit-delete.md`**
Goal = edit + delete bangs; Depends on bangs module (no prior task number — note "bangs module in tree"); new `bangs.spec.ts`; Notes → design #4.
- [ ] **Step 5: Write `84-back-navigation.md`**
Goal = shared back affordance on all detail pages; Scope = audit `src/app/`, shared component, apply consistently; E2E navigation; Notes → design #6.
- [ ] **Step 6: Commit**
```bash
git add docs/tasks/80-quick-add-create-ui.md docs/tasks/81-dashboard-edit-live-widgets.md docs/tasks/82-garden-plant-count.md docs/tasks/83-bang-edit-delete.md docs/tasks/84-back-navigation.md
git commit -m "docs: add P1 bug task briefs 80-84"
```
---
### Task 6: Write feature task briefs (8588)
**Files:**
- Create: `docs/tasks/85-rich-text-notes.md`
- Create: `docs/tasks/86-journal-module.md`
- Create: `docs/tasks/87-api-surface.md`
- Create: `docs/tasks/88-llm-agent-chat.md`
- [ ] **Step 1: Write `85-rich-text-notes.md`**
Include: Goal = shared rich-text editor + full notes overhaul + mobile overflow; Depends on 12, 8084 not required but batch order is bugs first; Scope = ADR 0004, shared component, notes create/edit, render on index/widgets/share, fix overflow (closes design #5); Out of scope = journal (86); Acceptance includes E2E notes formatting + mobile overflow; Notes link design #10 and #5.
- [ ] **Step 2: Write `86-journal-module.md`**
Include full journal requirements from the design spec (recent entries, browse, calendar, fields, mood multi-select, pills boolean, mood tracker, insights, research ADR 0005, journal API endpoints). Depends on 85 and 87. E2E `journal.spec.ts`.
- [ ] **Step 3: Write `87-api-surface.md`**
Goal = documented API + token auth for existing modules (calendar, lists, notes, garden, bangs). Depends on ADR 0006 (research child of #14). Coexistence with OIDC session. Additive versioning. Vitest for auth/handlers. Batch step 6 (after bugs).
- [ ] **Step 4: Write `88-llm-agent-chat.md`**
Goal = provider-agnostic OpenAI-compatible agent chat with tool-calling to API. Depends on 87. Mock provider in CI. No live LLM in CI.
- [ ] **Step 5: Commit**
```bash
git add docs/tasks/85-rich-text-notes.md docs/tasks/86-journal-module.md docs/tasks/87-api-surface.md docs/tasks/88-llm-agent-chat.md
git commit -m "docs: add P1 feature task briefs 85-88"
```
---
### Task 7: Update `docs/tasks/README.md`
**Files:**
- Modify: `docs/tasks/README.md`
- [ ] **Step 1: Append Phase 9 section** after Phase 8:
```markdown
### Phase 9 — Post-v0.1
Batch order (bugs → API → editor/notes → journal → agent). Design: [`docs/superpowers/specs/2026-07-03-backlog-triage-design.md`](../superpowers/specs/2026-07-03-backlog-triage-design.md). Issue map: [`docs/issues-map.md`](../issues-map.md).
- [80 — Quick-add create UI](80-quick-add-create-ui.md)
- [81 — Dashboard edit live widgets](81-dashboard-edit-live-widgets.md)
- [82 — Garden plant count](82-garden-plant-count.md)
- [83 — Bang edit/delete](83-bang-edit-delete.md)
- [84 — Back navigation](84-back-navigation.md)
- [85 — Rich-text notes overhaul](85-rich-text-notes.md)
- [86 — Journal module](86-journal-module.md)
- [87 — API surface](87-api-surface.md)
- [88 — LLM agent chat](88-llm-agent-chat.md)
```
- [ ] **Step 2: Commit**
```bash
git add docs/tasks/README.md
git commit -m "docs: index Phase 9 post-v0.1 task briefs"
```
---
### Task 8: Stub ADRs 00040006
**Files:**
- Create: `docs/decisions/0004-rich-text-editor.md`
- Create: `docs/decisions/0005-journal-research.md`
- Create: `docs/decisions/0006-api-llm-agent.md`
- Modify: `docs/decisions/README.md` (index entries)
Each stub uses ADR format with `Status: proposed` and a short Context that points at the Gitea research issue and task brief. Decision/Consequences sections state: "To be filled when research completes."
Example for 0004:
```markdown
# 0004 — Rich-text editor library and storage format
Date: 2026-07-03
Status: proposed
## 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: design #10 research child (see `docs/issues-map.md`).
## Decision
To be filled when research completes.
## Consequences
To be filled when research completes.
```
Mirror for 0005 (journal build-vs-adopt) and 0006 (API auth + LLM agent architecture).
- [ ] **Step 1: Write three stubs**
- [ ] **Step 2: Add index lines** in `docs/decisions/README.md` under the AUTO-GENERATED block (or after it if the generator would wipe them — prefer manual entries matching existing style):
```markdown
- [0004 — Rich-text editor library and storage format](0004-rich-text-editor.md) (proposed)
- [0005 — Journal / mood tracking research](0005-journal-research.md) (proposed)
- [0006 — API auth and LLM agent architecture](0006-api-llm-agent.md) (proposed)
```
- [ ] **Step 3: Commit**
```bash
git add docs/decisions/0004-rich-text-editor.md docs/decisions/0005-journal-research.md docs/decisions/0006-api-llm-agent.md docs/decisions/README.md
git commit -m "docs: stub ADRs 0004-0006 for P1 cross-cutting choices"
```
---
### Task 9: Update STATUS.md
**Files:**
- Modify: `STATUS.md`
- [ ] **Step 1: Replace "Next up" section** with:
```markdown
## Next up
Phase 9 — Post-v0.1 (see `docs/superpowers/specs/2026-07-03-backlog-triage-design.md`, `docs/issues-map.md`).
**Batch order:**
1. Bugs: tasks 8084 (quick-add, dashboard edit, garden count, bangs, back-nav)
2. API foundation: task 87 (+ ADR 0006)
3. Shared rich-text + notes overhaul: task 85 (+ ADR 0004); closes notes mobile overflow
4. Journal: task 86 (+ ADR 0005), including journal API endpoints
5. LLM agent chat: task 88
P2/P3 backlog is filed on Gitea only (no task briefs yet).
**How to resume:** Read AGENTS.md / CLAUDE.md / STATUS.md, open the next unchecked task in `docs/tasks/80``88`, stop at acceptance criteria.
```
- [ ] **Step 2: Commit**
```bash
git add STATUS.md
git commit -m "docs: point STATUS at Phase 9 backlog batch order"
```
---
### Task 10: Light ARCHITECTURE.md touch
**Files:**
- Modify: `ARCHITECTURE.md` only if it is already part of the repo tracking story (it may be untracked — if untracked, add a minimal "Future" note under module list, or skip if the file is incomplete/unrelated). Prefer: add two bullets under module system / routes:
- Future: `journal/` module (task 86)
- Future: versioned HTTP API (`/api/v1/…`) with token auth alongside OIDC session (task 87)
Do **not** invent detailed diagrams.
- [ ] **Step 1: Apply minimal placeholders or skip with a note in the commit message if ARCHITECTURE.md is not ready**
- [ ] **Step 2: Commit if changed**
```bash
git add ARCHITECTURE.md
git commit -m "docs: note future journal module and versioned API in architecture"
```
---
### Task 11: Vault write-back
**Files:**
- Modify: `C:\Users\MattC\Documents\Obsidian Vault\Famapp\_Claude.md`
- [ ] **Step 1: Append a session block**
```markdown
## 2026-07-03 Backlog triage
- Design: `docs/superpowers/specs/2026-07-03-backlog-triage-design.md`
- Plan: `docs/superpowers/plans/2026-07-03-backlog-triage.md`
- Issue map: `docs/issues-map.md` (Gitea `ginnoir/famapp`)
- P1 order: bugs 8084 → API 87 → rich-text notes 85 → journal 86 → agent 88
- P2/P3: Gitea issues only until their batch
```
- [ ] **Step 2: Do not commit vault files into famapp git**
---
### Task 12: Final verification
- [ ] **Step 1: Verify Gitea**
Open `https://gitea.ginnoir.com/ginnoir/famapp/issues` — confirm open issues exist for all design IDs in `docs/issues-map.md`, labels present, dependencies on #5/#13/#14 children look correct.
- [ ] **Step 2: Verify repo files**
```bash
git status
ls docs/tasks/8*.md
ls docs/decisions/0004*.md docs/decisions/0005*.md docs/decisions/0006*.md
```
Expected: `80``88` present, ADR stubs present, clean working tree (except unrelated untracked files).
- [ ] **Step 3: Cross-link pass**
In each of `80``88`, Notes section contains the real Gitea issue URL or `#N` from `docs/issues-map.md`. Fix and amend only if this commit is still local and was created by this session; otherwise new commit `docs: link task briefs to Gitea issue numbers`.
---
## Self-review (plan vs spec)
| Spec requirement | Plan task |
| ------------------------------- | ---------------------------------------------------- |
| Issues for all 19 items | Tasks 24 |
| Epic children for #10, #13, #14 | Task 3 |
| Light checklists P2/P3 epics | Task 4 |
| Labels + priority | Task 1 |
| P1 task briefs 8088 | Tasks 56 |
| Phase 9 README | Task 7 |
| ADR stubs 00040006 | Task 8 |
| STATUS update | Task 9 |
| ARCHITECTURE light touch | Task 10 |
| E2E plans in issues/briefs | Embedded in issue bodies + brief acceptance criteria |
| No feature code | Entire plan is docs/issues only |
| Fallback drafts | Task 1/4 fallback |
| Vault write-back | Task 11 |
| API early order in STATUS | Task 9 |
No TBD placeholders remain in executable steps. Issue bodies for epic children are specified by title + required sections; executor fills `<brief>` and Gitea parent links using the map.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,197 @@
# Backlog triage, issues, E2E coverage, and next-batch planning
Date: 2026-07-03
Status: approved (design); implementation pass not started
## Goal
Turn the post-v0.1 backlog into an actionable state: one Gitea issue per item (epics with sub-issues where needed), Playwright regression plans for every bug, P1 `docs/tasks/` specs, ADR placeholders for cross-cutting choices, STATUS/ARCHITECTURE updates, and a prioritized next-batch plan.
## Decisions (interview)
| Topic | Choice |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| Rich text for journal | Pull shared editor ADR + component into **P1**; full notes overhaul in the same batch (editor, render formatting everywhere, mobile overflow). |
| Batch ordering | **Bugs first**, then features. |
| Spec depth | **Full backlog issues now**; full `docs/tasks/` only for P1; P2/P3 get issues + light epic checklists. |
| Organization model | **Issue-centric**: Gitea is source of truth for work items; task briefs are the agent execution format for P1. |
| API timing | **API early**: after bugs, API foundation for existing modules; journal and notes extend it; LLM agent last. |
## Approaches considered
1. **Issue-centric (chosen).** Gitea issues own tracking; P1 task briefs link from issues; ADRs gate cross-cutting work; E2E criteria live on issues and land with PRs.
2. **Task-doc-centric.** `docs/tasks/` primary; Gitea thin stubs. Weaker epic/sub-issue tracking.
3. **Monolithic batch plan.** One mega-issue per priority tier. Fights one-issue-per-item and partial progress.
## Issue map and labels
**Labels:** `bug` | `enhancement` | `feature` | `epic` | `research`
**Priority labels:** `priority/p1` | `priority/p2` | `priority/p3`
| # | Title | Label | Priority | Notes |
| --- | ----------------------------------------------------- | --------------------------- | -------- | ------------------------------------------- |
| 1 | Quick-add opens create UI, not module page | bug | P1 | |
| 2 | Dashboard edit mode renders live widgets at true size | bug | P1 | |
| 3 | Garden container plant count wrong | bug | P1 | Audit sibling counts |
| 4 | Bangs not editable (add edit + delete) | bug | P1 | |
| 5 | Notes overflow horizontally on mobile | bug | P1 | Closed with / blocked by #10 |
| 6 | Missing back navigation — audit + shared affordance | bug | P1 | Sweep all detail routes |
| 7 | Calendar reminders overhaul | enhancement | P2 | Builds on task 41 |
| 8 | Lists index: inline task add + list property edit | enhancement | P2 | |
| 9 | Comments on lists and tasks | feature | P2 | Consider generic comment component |
| 10 | Notes editor overhaul (shared rich-text) | enhancement / epic children | P1 | Pulled into P1; ADR 0004 |
| 11 | Bang stats dashboard widget | enhancement | P2 | Related to #4; not blocking |
| 12 | Appearance / theming options | epic | P3 | Feasibility first |
| 13 | Journal module | epic | P1 | Sub-issues; depends on #10 + API foundation |
| 14 | API + LLM agent chat | epic | P1 | API children early; agent children last |
| 15 | Pets module | epic | P2 | Garden-shaped; light checklist |
| 16 | Shopping/pantry module | epic | P2 | Design before build; light checklist |
| 17 | Backups & exports | epic | P2 | Extends task 60; light checklist |
| 18 | GPS locations for calendar events | research | P3 | Issue only |
| 19 | Lists ↔ notes cohesion | research | P3 | Proposal/ADR later; not code |
### Epic children (file as separate issues)
**#10 Shared rich-text / notes overhaul**
1. Research ADR (library choice, storage format, shared component contract)
2. Shared editor component
3. Notes create/edit wiring
4. Render formatting on all notes surfaces (index, widgets, share view)
5. Mobile overflow fix (closes #5)
**#13 Journal**
1. Research ADR (mood/journal libs; build-vs-adopt)
2. Schema + CRUD
3. Index (recent entries, browse all, entry-day calendar)
4. Entry detail + create (date/time required; all else optional)
5. Mood tracker view
6. Insights/stats views
7. E2E happy path
8. Journal API endpoints (additive on API foundation)
**#14 API + LLM**
1. Architecture ADR (token auth vs OIDC session; API shape; OpenAI-compatible provider; MCP yes/no)
2. API surface + token auth + docs for **existing** modules (calendar, lists, notes, garden, bangs; dashboards as needed)
3. Agent chat UI
4. Tool-calling mapped to API
5. Smoke tests (mock provider in CI)
P2/P3 epics (#12, #1517): epic body holds a light checklist only — no child issues until that batch.
### Issue body template
Every issue includes:
- Background / current behavior
- Expected behavior or feature description
- Acceptance criteria (including E2E where applicable)
- Priority label
- Links: `docs/tasks/…` when present, blocked-by / related issues
## P1 batch order
Strict sequence:
| Step | Work | Issues | Task brief(s) |
| ---- | -------------------------------------------------------------------------------------- | -------------------- | -------------------------------------------------------- |
| 1 | Quick-add opens create dialogs/sheets | #1 | `80-quick-add-create-ui.md` |
| 2 | Dashboard edit mode live widgets | #2 | `81-dashboard-edit-live-widgets.md` |
| 3 | Garden plant count | #3 | `82-garden-plant-count.md` |
| 4 | Bang edit + delete | #4 | `83-bang-edit-delete.md` |
| 5 | Back-nav audit + shared header | #6 | `84-back-navigation.md` |
| 6 | API ADR + token auth + endpoints for existing modules | #14 (API children) | ADR `0006-api-llm-agent.md` + `87-api-surface.md` |
| 7 | Shared rich-text + full notes overhaul (+ mobile overflow); extend notes API if needed | #10, #5 | ADR `0004-rich-text-editor.md` + `85-rich-text-notes.md` |
| 8 | Journal module (UI + journal API in same epic) | #13 (+ children) | ADR `0005-journal-research.md` + `86-journal-module.md` |
| 9 | LLM agent chat | #14 (agent children) | `88-llm-agent-chat.md` |
**Rules**
- API is **additive**; no freeze that blocks journal. New modules document/register endpoints as they ship.
- #14 epic stays open across steps 6 and 9 (API early, agent late).
- #13 depends on #10 (editor) and on API foundation existing.
- #5 closes with #10.
- Task numbering continues after 76 (80+). Do not renumber existing tasks.
- `docs/tasks/README.md` gains **Phase 9 — Post-v0.1**.
- P2/P3: Gitea issues only until their batch; STATUS may list them under Backlog.
## E2E plan
Follow `tests/e2e/*.spec.ts` patterns (dev-login storage state, happy-path assertions). Regression tests land **with each bug/feature PR**, not as a separate upfront suite.
### Bugs
| Bug | Spec strategy |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
| Quick-add create UI | Extend `dashboard.spec.ts` and/or `quick-add.spec.ts`: FAB action opens dialog/sheet with create controls, not only navigation. |
| Dashboard edit live size | Extend `dashboard.spec.ts`: `?edit=1` shows real widget content (e.g. Recent activity); prefer stable height assertion only if non-flaky. |
| Garden plant count | Extend `garden.spec.ts`: plant in container → count ≥ 1. |
| Bang edit/delete | New `bangs.spec.ts`: record → edit date → assert → delete → assert gone. |
| Notes mobile overflow | Extend `notes.spec.ts` at 375px: no horizontal overflow; lands with #10. |
| Back navigation | `navigation.spec.ts` or per-module: detail routes expose Back and return to parent. |
### P1 features
| Feature | Coverage |
| ----------------- | --------------------------------------------------------------------------------- |
| API surface | Vitest for auth + route handlers; optional Playwright smoke only if useful. |
| Rich-text / notes | `notes.spec.ts`: formatted note visible on detail + other surfaces. |
| Journal | New `journal.spec.ts`: minimal entry → recent list → detail → mood tracker loads. |
| LLM agent | Smoke with **mock/stub** provider in test env; no live LLM in CI. |
## Docs and ADRs
### P1 task briefs (implementation pass)
| File | Covers |
| ----------------------- | --------------------------------------------------------------------------- |
| `80``84` | One brief per bug (quick-add, dashboard edit, plant count, bangs, back-nav) |
| `85-rich-text-notes.md` | Shared editor + full notes overhaul + mobile overflow |
| `86-journal-module.md` | Journal epic (split later only if execution needs it) |
| `87-api-surface.md` | Token auth + API for existing modules |
| `88-llm-agent-chat.md` | Agent UI + OpenAI-compatible tools |
Format: Goal / Why / Depends on / Scope / Out of scope / Acceptance criteria / Notes. Each brief links its Gitea issue(s).
### ADRs
| ADR | Topic | Gates |
| -------------------------- | --------------------------------------------------------------------- | ------------------- |
| `0004-rich-text-editor.md` | Library (e.g. TipTap), storage format, shared component contract | `85`, journal body |
| `0005-journal-research.md` | Build-vs-adopt for mood/journal UI and charts | `86` implementation |
| `0006-api-llm-agent.md` | Token auth vs OIDC; API shape; OpenAI-compatible client; MCP decision | `87` then `88` |
Triage pass stubs titles in `docs/decisions/README.md` (and stub files pointing at epic issues). Full ADR bodies when that step runs.
P2/P3 research (#18, #19, appearance) remain issue-only until their batch.
### STATUS / ARCHITECTURE
- **STATUS.md:** replace stale “Ready to tag v0.1.0” next-up with Phase 9 batch order, link to this design doc and Gitea issues.
- **ARCHITECTURE.md:** light placeholders only (`/api/v1`, journal module) — no invented detail. Full diagrams when modules land.
## Implementation pass (next execution session)
No application feature code in this pass.
1. Commit this design doc (done when design is accepted).
2. File Gitea issues on `gitea.ginnoir.com/ginnoir/famapp` for all 19 items; full children for epics #10, #13, #14; light checklists on P2/P3 epics #12, #1517. Labels + priority as above. On auth failure: write `docs/issues-drafts/<slug>.md` and report.
3. Write P1 task briefs `80``88` and update `docs/tasks/README.md` Phase 9.
4. Stub ADRs 00040006 (title + pointer to epic issues).
5. Update STATUS.md Next-up; light ARCHITECTURE touch if needed.
6. Vault write-back to `Famapp/_Claude.md`.
**Out of scope for implementation pass:** Playwright code, schema changes, module implementation, full ADR research bodies.
**Success criteria:** every backlog item has a Gitea URL (or draft file); P1 has executable task briefs; STATUS points agents at step 1 (bugs).
## Out of scope (this design)
- Implementing any bug fix or feature
- Renumbering or rewriting existing `docs/tasks/01``76`
- P2/P3 full task briefs or ADRs
- Live LLM integration in CI
- Forward-auth for non-famapp services
- Multi-household UI
@@ -0,0 +1,151 @@
# Assistant model selector design
Date: 2026-07-08
Status: approved for planning
## Context
The AI assistant chat currently uses one environment-configured model through `LLM_MODEL`.
The chat UI posts messages to `/api/agent/chat`, and the server creates the OpenAI-compatible
client without any request-time model choice.
ginnoir wants a model selector in the assistant chat. The selector should discover available
models from the configured OpenAI-compatible provider and save the selected model as the user's
default.
## Goals
- Show a compact model selector in the assistant chat panel.
- Discover models from the provider's `/models` endpoint server-side.
- Persist the selected model per user so it works across browser sessions and devices.
- Keep `LLM_MODEL` as the fallback when discovery fails, no model is saved, or the saved model
is no longer available.
- Preserve mock-provider behavior in CI and local setups without `LLM_BASE_URL`.
## Non-goals
- Model hosting, training, or fine-tuning.
- Multiple LLM providers in the same deployment.
- Per-message experimental settings beyond selecting the model ID.
- Exposing arbitrary browser-supplied model IDs to the provider.
## User experience
When the assistant bubble opens, the chat panel loads available model IDs from the server.
The selector appears near the existing assistant status and clear-chat controls. It should be
visible but compact enough not to reduce the message area materially.
Changing the selector immediately saves the user's default model. The next message uses that
model, and future assistant sessions start with the saved selection when it is still available.
If model discovery fails, the panel remains usable with the `LLM_MODEL` fallback and shows a
muted status that model discovery is unavailable. If the saved model has disappeared from the
provider, the server and UI fall back to `LLM_MODEL`.
## Architecture
### Configuration
`getLlmConfig()` remains the source for provider, base URL, API key, and fallback model.
No additional allowlist environment variable is required because model IDs come from the
provider's OpenAI-compatible `/models` endpoint.
### Persistence
Add nullable `assistant_model` storage to `users`.
The existing assistant preference loader should return:
- assistant enabled state
- assistant display name
- assistant system prompt
- saved assistant model ID
The value is nullable. `null` means "use the environment fallback model."
### Model discovery API
Add `GET /api/agent/models`.
Behavior:
- Require the same authenticated user/session or API auth shape as the chat endpoint.
- Require assistant access to be enabled for the user.
- If `LLM_BASE_URL` is missing or the provider is mock, return the fallback model as the only
available model.
- Fetch `${LLM_BASE_URL}/models` with `Authorization: Bearer ${LLM_API_KEY}` when configured.
- Accept OpenAI-style payloads with a top-level `data` array.
- Normalize each model to `{ id: string, label: string }`, using the ID as the label.
- Deduplicate, sort consistently, and include the fallback model if the provider omitted it.
- If discovery fails, return the fallback model plus a degraded status instead of failing the
chat UI.
The response should include enough metadata for the UI:
```json
{
"models": [{ "id": "llama3.2", "label": "llama3.2" }],
"selectedModel": "llama3.2",
"fallbackModel": "llama3.2",
"degraded": false
}
```
### Saving the default model
Add a server action for updating the user's assistant model, matching the existing assistant
settings actions. The update path must:
- Accept a model ID string or `null`.
- Validate length and basic shape before touching the database.
- Validate the requested model against the current discovered model list.
- Save `null` when the selected model matches the fallback so `LLM_MODEL` changes take effect for
users who have not chosen a non-default model.
- Revalidate assistant surfaces after saving.
### Chat request flow
Extend `clientChatInputSchema` with optional `model`.
The chat route should:
- Parse `model` from the request body.
- Resolve the effective model from request model, saved user default, and fallback model.
- Validate request model and saved user default against discovered models.
- Reject an invalid request model with `400`.
- Silently fall back when the saved user default is no longer available.
- Pass the effective model into `runAgentChat`.
`runAgentChat` should accept an optional model override. `createLlmClient` should support an
override object or equivalent path that replaces only the model while preserving the configured
provider, base URL, and API key.
## Error handling
- Missing auth: `401`.
- Assistant disabled: `403`.
- Invalid posted model: `400`.
- Provider `/models` failure: return fallback model from the model-discovery API with
`degraded: true`; do not block chat startup.
- LLM completion failure after a valid model is selected: keep the existing chat error behavior.
## Testing
Unit tests:
- Model discovery normalizes OpenAI-compatible `/models` responses.
- Discovery falls back to `LLM_MODEL` for mock or failed provider states.
- Chat input schema accepts an optional valid model string and rejects invalid shapes.
- Chat route rejects a model not returned by discovery.
- `runAgentChat` passes the effective model override into the LLM client path.
E2E smoke:
- After assistant opt-in, opening the assistant shows the model selector.
- Sending a message still renders the user message and assistant response with the mock provider.
## Rollout notes
This is additive. Existing deployments without a provider `/models` endpoint continue to use
`LLM_MODEL`. The database migration is nullable, so existing users keep current behavior until
they choose a model.
+36
View File
@@ -0,0 +1,36 @@
# 80 — Quick-add opens create UI
## Goal
Make every quick-add action open the entity's creation dialog/sheet instead of only navigating to a module page.
## Why
Task 21 shipped URL navigation as a placeholder. Users expect "New event" to start creating an event immediately.
## Depends on
- 21 (quick-add registry)
## Scope
- Extend `QuickAddAction` / client handling so actions can open create UI (dialog/sheet) in-place.
- Wire calendar, lists, notes, garden, bangs (and any other registered) quick-adds to their create UIs.
- Keep FAB sheet and cmd-k behavior identical per action.
- Playwright: extend `tests/e2e/dashboard.spec.ts` and/or add `tests/e2e/quick-add.spec.ts`.
## Out of scope
- Fuzzy search across entities.
- New entity types.
## Acceptance criteria
- [ ] Each registered quick-add opens create UI with create controls visible.
- [ ] No quick-add is navigation-only unless explicitly documented as view-only (none in v1 set).
- [ ] E2E: FAB → action opens dialog/sheet (not only route change).
## Notes
- Gitea: [ginnoir/famapp#1](https://gitea.ginnoir.com/ginnoir/famapp/issues/1)
- Prefer reusing existing create dialogs/sheets over new ones.
@@ -0,0 +1,34 @@
# 81 — Dashboard edit mode live widgets
## Goal
In dashboard edit mode, render each widget's real data at its true final size so layout matches the saved view.
## Why
Edit mode currently shows empty placeholders; on save, content-heavy widgets (e.g. Recent activity) expand and the grid reflows.
## Depends on
- 26 (customizable layout + widget configuration)
## Scope
- `DashboardEditor` / edit mode loads real widget components and data (not empty shells).
- Widgets size to content consistently between edit and view modes.
- Playwright: extend `tests/e2e/dashboard.spec.ts` — enter `?edit=1`, assert real content visible. Prefer height-stability assertion only if non-flaky.
## Out of scope
- Redesigning the widget picker or config schemas.
- New widget types.
## Acceptance criteria
- [x] Edit mode shows real widget content for registered widgets.
- [x] Saving layout does not cause a large content-driven reflow for content-heavy widgets.
- [x] E2E covers real content visible in edit mode.
## Notes
- Gitea: [ginnoir/famapp#2](https://gitea.ginnoir.com/ginnoir/famapp/issues/2)
+33
View File
@@ -0,0 +1,33 @@
# 82 — Garden container plant count
## Goal
Fix container plant counts so a container with N plants displays N, and audit sibling counts for the same bug.
## Why
Containers (e.g. "plantie cabinet") show "0 plants" despite containing plants — likely a query/join or association bug.
## Depends on
- 71 (garden containers), 72 (garden plants)
## Scope
- Find and fix the count query in `src/modules/garden/server/queries.ts` (and related UI).
- Audit other garden counts (lists, widgets) for the same pattern; fix or file follow-ups.
- Playwright: extend `tests/e2e/garden.spec.ts` — plant in container → count ≥ 1.
## Out of scope
- New garden features.
## Acceptance criteria
- [ ] Container with N plants displays N (not 0).
- [ ] Sibling counts audited; fixed or follow-ups filed.
- [ ] E2E covers plant-in-container count.
## Notes
- Gitea: [ginnoir/famapp#3](https://gitea.ginnoir.com/ginnoir/famapp/issues/3)
+35
View File
@@ -0,0 +1,35 @@
# 83 — Bang edit and delete
## Goal
Allow editing and deleting bang entries after creation (e.g. wrong date).
## Why
Bangs can be recorded but not corrected. Only create exists today.
## Depends on
- Bangs module (in tree; no prior task number)
## Scope
- Server actions for update and delete.
- UI on the bang widget (and any list surface) for edit/delete.
- Activity log for update/delete if other modules log similarly.
- Playwright: new `tests/e2e/bangs.spec.ts` — record → edit date → assert → delete → assert gone.
## Out of scope
- Bang stats widget (design #11 / Gitea #31) — separate P2 issue.
## Acceptance criteria
- [ ] Edit a bang (e.g. change date) and see the update.
- [ ] Delete a bang and it no longer appears.
- [ ] E2E covers edit and delete.
## Notes
- Gitea: [ginnoir/famapp#4](https://gitea.ginnoir.com/ginnoir/famapp/issues/4)
- Related (not blocking): #31 bang stats widget.
+34
View File
@@ -0,0 +1,34 @@
# 84 — Back navigation on detail pages
## Goal
Audit detail routes lacking back affordances and add a shared header/back pattern consistently.
## Why
Individual plant pages (and other detail routes) have no in-page "back" except global nav.
## Depends on
- Existing app routes under `src/app/`
## Scope
- Audit all routes in `src/app/` for detail pages missing back; list them in the PR description.
- Shared back affordance (header/back component).
- Apply consistently to plant, note, list, and other detail pages.
- Playwright: `tests/e2e/navigation.spec.ts` or per-module — Back visible and returns to parent.
## Out of scope
- Redesigning global nav or dashboard tabs.
## Acceptance criteria
- [ ] Audit list included in PR.
- [ ] Shared back control on detail pages.
- [ ] E2E covers representative detail routes.
## Notes
- Gitea: [ginnoir/famapp#6](https://gitea.ginnoir.com/ginnoir/famapp/issues/6)
+42
View File
@@ -0,0 +1,42 @@
# 85 — Rich-text notes overhaul
## Goal
Land a shared rich-text editor (ADR 0004), wire it through notes create/edit and all display surfaces, and fix mobile horizontal overflow.
## Why
Journal (86) and notes need modern formatting and emoji. Notes currently overflow sideways on mobile. P1 pulls the full notes overhaul into this batch.
## Depends on
- 12 (notes module)
- ADR `0004-rich-text-editor.md` (research child: Gitea #8)
- Batch order: after bugs 8084 and API foundation 87 is preferred for notes API extension, but editor work can proceed once ADR is accepted
## Scope
- Research and accept ADR 0004 (library e.g. TipTap, storage format, shared component contract).
- Shared editor component used by notes (and later journal).
- Notes create/edit use the shared editor.
- Render formatting on index, widgets, and share view.
- Fix mobile overflow (closes Gitea #5 / design #5).
- Playwright: extend `tests/e2e/notes.spec.ts` — formatted note visible; 375px no horizontal overflow.
## Out of scope
- Journal module (86) — consumes the shared editor after this lands.
- Appearance/theming epic.
## Acceptance criteria
- [x] ADR 0004 accepted with library and storage format chosen.
- [x] Shared editor component in tree.
- [x] Notes create/edit use it.
- [x] Formatting renders on all notes surfaces.
- [x] Mobile overflow fixed; E2E green.
## Notes
- Gitea epic: [#7](https://gitea.ginnoir.com/ginnoir/famapp/issues/7); children #8#12; overflow bug [#5](https://gitea.ginnoir.com/ginnoir/famapp/issues/5)
- Design: `docs/superpowers/specs/2026-07-03-backlog-triage-design.md`
+45
View File
@@ -0,0 +1,45 @@
# 86 — Journal module
## Goal
Ship a per-user journal module with entries, mood tracking, insights, and journal API endpoints on the existing API foundation.
## Why
P1 personal journaling with mood/stress/pills tracking and stats, without blocking on P2 work.
## Depends on
- 85 (shared rich-text editor)
- 87 (API surface for existing modules — journal adds endpoints the same way)
- ADR `0005-journal-research.md` (Gitea #20)
## Scope
- Research ADR 0005 (mood/journal libs; build-vs-adopt).
- Schema + CRUD (per-user entries).
- Index: last 510 entries compact, browse all, small calendar of days with entries.
- Entry detail and create: **date (default today) and time (default now) required**; everything else optional.
- Fields: rich note text (shared editor); numerical stress; mood with color/emoji options and **multi-select**; took-my-pills-today boolean.
- Module-level mood-tracker view over time.
- Per-user insights/stats (trends, streaks, correlations — propose specifics in ADR/implementation).
- Journal API endpoints (additive on 87).
- Playwright: new `tests/e2e/journal.spec.ts` — minimal entry → recent list → detail → mood tracker loads.
## Out of scope
- Household-shared journals (per-user only).
- LLM agent tools for journal (88 can add later).
## Acceptance criteria
- [x] ADR 0005 accepted.
- [x] Create entry with only date/time.
- [x] Index, detail, mood tracker, and insights views work.
- [x] Journal API endpoints documented and callable with token auth.
- [x] E2E happy path green (`tests/e2e/journal.spec.ts` — run locally with dev DB).
## Notes
- Gitea epic: [#19](https://gitea.ginnoir.com/ginnoir/famapp/issues/19); children #20#27
- Depends on #7 (rich-text) and #15 (API surface)
+39
View File
@@ -0,0 +1,39 @@
# 87 — API surface + token auth
## Goal
Documented HTTP API for existing modules (calendar, lists, notes, garden, bangs) with token auth suitable for non-browser clients, coexisting with OIDC session auth.
## Why
API-early batch order: foundation lands after bugs so journal and notes can extend it; LLM agent (88) maps tools onto it.
## Depends on
- ADR `0006-api-llm-agent.md` research half (Gitea #14) — auth model and API shape decided before implementation
## Scope
- Architecture ADR 0006: token auth vs OIDC session coexistence; API shape; additive versioning (no freeze that blocks journal).
- Token auth for non-browser clients.
- Endpoints for existing entities: events, lists/items, notes, garden, bangs (dashboards as needed).
- API docs (OpenAPI or equivalent living doc).
- Vitest for auth + route handlers; optional Playwright smoke only if useful.
## Out of scope
- LLM agent chat UI and tool-calling (88).
- Journal endpoints (86 adds them).
## Acceptance criteria
- [ ] ADR 0006 documents auth coexistence and API shape.
- [ ] Token-authenticated client can read/write covered entities.
- [ ] Session (OIDC) auth still works for the web app.
- [ ] Docs list endpoints and auth.
- [ ] Unit/integration tests for auth and handlers.
## Notes
- Gitea: epic [#13](https://gitea.ginnoir.com/ginnoir/famapp/issues/13); research [#14](https://gitea.ginnoir.com/ginnoir/famapp/issues/14); implementation [#15](https://gitea.ginnoir.com/ginnoir/famapp/issues/15)
- Batch step 6 (after bugs 8084)
+39
View File
@@ -0,0 +1,39 @@
# 88 — LLM agent chat
## Goal
Natural-language chat that acts on the app via tool-calling against the API, using a provider-agnostic OpenAI-compatible endpoint (self-hosted on the homelab).
## Why
Users should say "add milk to the shopping list" or "what's on the calendar Friday?" and have the agent use the API.
## Depends on
- 87 (API surface)
- ADR `0006-api-llm-agent.md` agent half (MCP yes/no, tool mapping)
## Scope
- Agent chat UI in the app.
- OpenAI-compatible client (provider-agnostic).
- Tool-calling mapped to API endpoints from 87 (and journal if present).
- Decide MCP vs direct tools in ADR 0006.
- Smoke tests with **mock/stub** provider in CI — no live LLM in CI.
## Out of scope
- Training or fine-tuning models.
- Forward-auth for other homelab apps.
## Acceptance criteria
- [x] Chat UI sends prompts and shows responses.
- [x] Tools call the API successfully in dev against the homelab endpoint.
- [x] CI uses mock provider only.
- [x] ADR 0006 records provider and MCP decisions.
## Notes
- Gitea: [#16](https://gitea.ginnoir.com/ginnoir/famapp/issues/16), [#17](https://gitea.ginnoir.com/ginnoir/famapp/issues/17), [#18](https://gitea.ginnoir.com/ginnoir/famapp/issues/18)
- Depends on [#15](https://gitea.ginnoir.com/ginnoir/famapp/issues/15)
@@ -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)
+14
View File
@@ -79,3 +79,17 @@ Every task file has these sections:
- [73 — Garden care tracking](73-garden-care-tracking.md)
- [74 — Garden integrations](74-garden-integrations.md)
- [75 — Garden dashboard](75-garden-dashboard.md)
### Phase 9 — Post-v0.1
Batch order (bugs → API → editor/notes → journal → agent). Design: [`docs/superpowers/specs/2026-07-03-backlog-triage-design.md`](../superpowers/specs/2026-07-03-backlog-triage-design.md). Issue map: [`docs/issues-map.md`](../issues-map.md).
- [80 — Quick-add create UI](80-quick-add-create-ui.md)
- [81 — Dashboard edit live widgets](81-dashboard-edit-live-widgets.md)
- [82 — Garden plant count](82-garden-plant-count.md)
- [83 — Bang edit/delete](83-bang-edit-delete.md)
- [84 — Back navigation](84-back-navigation.md)
- [85 — Rich-text notes overhaul](85-rich-text-notes.md)
- [86 — Journal module](86-journal-module.md)
- [87 — API surface](87-api-surface.md)
- [88 — LLM agent chat](88-llm-agent-chat.md)
+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;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "assistant_model" text;
+5
View File
@@ -0,0 +1,5 @@
ALTER TABLE "users" ADD COLUMN "assistant_model_route" text;--> statement-breakpoint
UPDATE "users"
SET "assistant_model_route" = "assistant_model",
"assistant_model" = NULL
WHERE "assistant_model" IN ('auto', 'uncensored');
+57 -1
View File
@@ -134,6 +134,62 @@
"when": 1748995200000,
"tag": "0018_container_images",
"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
},
{
"idx": 25,
"version": "7",
"when": 1783560000000,
"tag": "0025_assistant_model",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1783561000000,
"tag": "0026_assistant_model_route",
"breakpoints": true
}
]
}
}
+1
View File
@@ -8,6 +8,7 @@ export default tseslint.config(
ignores: [
"node_modules/**",
".next/**",
".worktrees/**",
".claude/**",
".design-tmp/**",
"dist/**",
+8 -1
View File
@@ -178,7 +178,14 @@ const nextConfig: NextConfig = {
output: "standalone",
// Keep pino and pino-pretty as native Node.js requires so their worker-thread
// transport and stream internals work correctly inside the standalone bundle.
serverExternalPackages: ["pino", "pino-pretty", "drizzle-orm", "postgres"],
serverExternalPackages: [
"pino",
"pino-pretty",
"drizzle-orm",
"postgres",
"isomorphic-dompurify",
"jsdom",
],
};
export default nextConfig;
+16 -1
View File
@@ -1,6 +1,6 @@
{
"name": "famapp",
"version": "0.5.3",
"version": "0.6.2",
"private": true,
"type": "module",
"packageManager": "pnpm@10.33.3",
@@ -83,11 +83,25 @@
"@fullcalendar/list": "^6.1.20",
"@fullcalendar/react": "^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",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"drizzle-orm": "^0.45.2",
"isomorphic-dompurify": "^3.18.0",
"lucide-react": "^1.14.0",
"minio": "^8.0.7",
"next": "^15.5.15",
@@ -99,6 +113,7 @@
"react-dom": "^19.2.5",
"react-grid-layout": "^2.2.3",
"react-resizable": "^3.1.3",
"recharts": "^3.9.2",
"server-only": "^0.0.1",
"shadcn": "^4.7.0",
"sonner": "^2.0.7",
+1171
View File
File diff suppressed because it is too large Load Diff
+107
View File
@@ -0,0 +1,107 @@
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 { listLlmModels, resolveAssistantModel } from "@/lib/llm/models";
import { clientChatInputSchema } from "@/modules/agent/messages";
import { encodeSseEvent } from "@/modules/agent/server/progress";
import { runAgentChat } from "@/modules/agent/server/run";
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 = clientChatInputSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0]?.message ?? "Validation error", 400);
}
const modelList = await listLlmModels({ route: assistant.modelRoute });
const modelResolution = resolveAssistantModel({
requestedModel: parsed.data.model,
savedModel: assistant.model,
fallbackModel: modelList.fallbackModel,
models: modelList.models,
});
if (!modelResolution.ok) {
return apiError(modelResolution.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,
model: modelResolution.model,
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,
model: modelResolution.model,
});
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);
}
}
+47
View File
@@ -0,0 +1,47 @@
import { apiError, apiJson } from "@/lib/api-handler";
import { resolveApiAuth } from "@/lib/api-auth";
import { getAssistantPreferences } from "@/lib/assistant-preference";
import { isValidAssistantModelRoute, listLlmModels, resolveAssistantModel } from "@/lib/llm/models";
export const dynamic = "force-dynamic";
function noStore(response: Response): Response {
response.headers.set("Cache-Control", "no-store");
return response;
}
export async function GET(request: Request) {
const auth = await resolveApiAuth(request);
if (!auth?.userId) {
return noStore(apiError("Unauthorized", 401));
}
const assistant = await getAssistantPreferences(auth.userId);
if (!assistant.enabled) {
return noStore(apiError("Assistant not enabled", 403));
}
const requestedRoute = new URL(request.url).searchParams.get("route");
if (requestedRoute !== null && !isValidAssistantModelRoute(requestedRoute)) {
return noStore(apiError("Invalid assistant model route", 400));
}
const modelRoute = requestedRoute ?? assistant.modelRoute;
const modelList = await listLlmModels({ route: modelRoute });
const resolved = resolveAssistantModel({
requestedModel: null,
savedModel: requestedRoute === null ? assistant.model : null,
fallbackModel: modelList.fallbackModel,
models: modelList.models,
});
return noStore(
apiJson({
models: modelList.models,
selectedModel: resolved.model,
fallbackModel: modelList.fallbackModel,
route: modelList.route,
degraded: modelList.degraded,
}),
);
}
+51
View File
@@ -0,0 +1,51 @@
import { apiError, apiJson } from "@/lib/api-handler";
import { resolveApiAuth } from "@/lib/api-auth";
import { getAssistantPreferences } from "@/lib/assistant-preference";
import { transcribeAudioFile } from "@/lib/llm/transcribe";
export const runtime = "nodejs";
export const maxDuration = 120;
const MAX_AUDIO_BYTES = 25 * 1024 * 1024;
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);
}
let formData: FormData;
try {
formData = await request.formData();
} catch {
return apiError("Invalid multipart body", 400);
}
const file = formData.get("file");
if (!(file instanceof File)) {
return apiError("No audio file in request", 400);
}
if (!file.type.startsWith("audio/") && file.type !== "video/webm") {
return apiError("Only audio recordings are allowed", 415);
}
if (file.size > MAX_AUDIO_BYTES) {
return apiError("Recording exceeds 25 MB limit", 413);
}
const filename = file.name.trim() || "recording.wav";
try {
const text = await transcribeAudioFile(file, filename);
return apiJson({ text });
} catch (err) {
const message = err instanceof Error ? err.message : "Transcription failed";
return apiError(message, 502);
}
}
+6 -1
View File
@@ -32,6 +32,11 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "No file field in request" }, { status: 400 });
}
const url = new URL(request.url);
const scopeParam = url.searchParams.get("scope");
const scope =
scopeParam === "notes" ? "notes" : scopeParam === "assistant" ? "assistant" : "garden";
if (!file.type.startsWith("image/")) {
return NextResponse.json({ error: "Only image files are allowed" }, { status: 415 });
}
@@ -45,7 +50,7 @@ export async function POST(request: Request) {
.replace(/[^a-z0-9]/gi, "")
.toLowerCase()
.slice(0, 8);
const key = `garden/${session.household.id}/${randomUUID()}.${safeExt}`;
const key = `${scope}/${session.household.id}/${randomUUID()}.${safeExt}`;
try {
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 { listCalendars, listEvents } from "@/modules/calendar/server/queries";
import {
getDefaultEventReminderOffsets,
listCalendars,
listEvents,
} from "@/modules/calendar/server/queries";
import { getCurrentSession } from "@/lib/session";
import type { CalView } from "@/modules/_core/themes";
@@ -10,10 +14,11 @@ export default async function CalendarPage() {
const to = new Date(now);
to.setMonth(to.getMonth() + 10);
const [{ user }, calendars, events] = await Promise.all([
const [{ user }, calendars, events, defaultReminderOffsets] = await Promise.all([
getCurrentSession(),
listCalendars(),
listEvents({ from, to, calendarIds: "all" }),
getDefaultEventReminderOffsets(),
]);
return (
@@ -21,6 +26,7 @@ export default async function CalendarPage() {
calendars={calendars}
events={events}
defaultView={user.themeCalView as CalView}
defaultReminderOffsets={defaultReminderOffsets}
/>
);
}
+21 -3
View File
@@ -1,11 +1,13 @@
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { getCurrentSession } from "@/lib/session";
import { parseDashboardLayout } from "@/lib/dashboard";
import { computeDefaultLayout } from "@/lib/dashboard.server";
import { parseDashboardLayout, widgetContentIndexKey } from "@/lib/dashboard";
import { readEditorDraftLayout } from "@/lib/dashboard-editor-draft";
import { computeDefaultLayout, normalizeDashboardLayout } from "@/lib/dashboard.server";
import { getWidget, getWidgetMetas } from "@/modules/_core";
import { getDashboardBySlug } from "@/app/d/actions";
import { DashboardEditor } from "@/components/dashboard-editor";
import { DashboardWidgetContent } from "@/components/dashboard-widget-content";
import { EditDashboardButton } from "@/components/edit-dashboard-button";
import { DashboardSwitcher } from "@/components/dashboard-switcher";
import { DashboardTab } from "@/components/dashboard-tab";
@@ -48,15 +50,31 @@ export default async function DashboardPage({
const dashboard = await getDashboardBySlug(slug);
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();
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 (
<DashboardEditor
key={`${dashboard.id}:${JSON.stringify(layout.widgets)}`}
dashboard={{ id: dashboard.id, name: dashboard.name, slug: dashboard.slug }}
layout={layout}
widgetMetas={widgetMetas}
widgetContents={widgetContents}
/>
);
}
+32
View File
@@ -10,6 +10,7 @@ import { getWidget } from "@/modules/_core";
import { dashboards } from "@/modules/_core/schema";
import { type DashboardLayout } from "@/lib/dashboard";
import { computeDefaultLayout } from "@/lib/dashboard.server";
import { clearEditorDraftLayout, writeEditorDraftLayout } from "@/lib/dashboard-editor-draft";
export type DashboardMeta = {
id: string;
@@ -156,9 +157,39 @@ export async function saveDashboardLayout(id: string, layout: DashboardLayout):
.update(dashboards)
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
await clearEditorDraftLayout(id);
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> {
const { user } = await getCurrentSession();
const layout = computeDefaultLayout();
@@ -166,6 +197,7 @@ export async function resetDashboardLayout(id: string): Promise<void> {
.update(dashboards)
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
await clearEditorDraftLayout(id);
revalidatePath("/");
}
+4 -2
View File
@@ -1,7 +1,8 @@
import { notFound } from "next/navigation";
import { getContainer } from "@/modules/garden/server/queries";
import { ContainerDetail } from "@/modules/garden/components/container-detail";
import { DetailBackLink } from "@/components/detail-back-link";
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 }> }) {
const { id } = await params;
@@ -12,6 +13,7 @@ export default async function ContainerPage({ params }: { params: Promise<{ id:
if (!container) notFound();
return (
<div className="page-content">
<DetailBackLink href="/garden" label="Garden" className="mb-4" />
<ContainerDetail container={container} shareLinks={shareLinks} />
</div>
);
+3 -15
View File
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation";
import { DetailBackLink } from "@/components/detail-back-link";
import { createContainer } from "@/modules/garden/server/actions";
export default function NewContainerPage() {
@@ -14,21 +15,8 @@ export default function NewContainerPage() {
return (
<div className="page-content max-w-lg">
<div className="flex items-center gap-3 mb-1">
<a href="/garden" className="btn btn-ghost btn-icon" aria-label="Back to garden">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</a>
<div className="mb-1 flex flex-col gap-2">
<DetailBackLink href="/garden" label="Garden" />
<h1 className="page-title">New container</h1>
</div>
<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 { getPlant, listContainers } from "@/modules/garden/server/queries";
import { DetailBackLink } from "@/components/detail-back-link";
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 }> }) {
const { id } = await params;
@@ -9,6 +10,7 @@ export default async function EditPlantPage({ params }: { params: Promise<{ id:
return (
<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>
<PlantForm existingPlant={plant} containers={containers} />
</div>
+4 -2
View File
@@ -1,8 +1,9 @@
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 { getCareLogs, getCareSchedules, getPlant } from "@/modules/garden/server/queries";
import { PlantDetail } from "@/modules/garden/components/plant-detail";
import { getShareLinksForEntity } from "@/modules/_core/share";
export default async function PlantPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
@@ -17,6 +18,7 @@ export default async function PlantPage({ params }: { params: Promise<{ id: stri
return (
<div className="page-content">
<DetailBackLink href="/garden?tab=plants" label="Plants" className="mb-4" />
<PlantDetail
plant={plant}
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 { listContainers } from "@/modules/garden/server/queries";
export default async function NewPlantPage({
searchParams,
@@ -11,21 +12,8 @@ export default async function NewPlantPage({
return (
<div className="page-content max-w-xl">
<div className="flex items-center gap-3 mb-1">
<a href="/garden" className="btn btn-ghost btn-icon" aria-label="Back to garden">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</a>
<div className="mb-1 flex flex-col gap-2">
<DetailBackLink href="/garden?tab=plants" label="Plants" />
<h1 className="page-title">Add Plant</h1>
</div>
<PlantForm containers={containers} defaultContainerId={params.containerId ?? null} />
+139
View File
@@ -818,6 +818,86 @@
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
button.tsx and uses these tokens via the theme bridge) */
.btn {
@@ -1430,6 +1510,65 @@ select {
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 ───────────────────────────── */
/* Used by calendar-shell so the grid fills the viewport correctly */
/* 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>
);
}
+22
View File
@@ -11,11 +11,15 @@ import type { DashboardMeta } from "@/app/d/actions";
import { getQuickAdds } from "@/modules/_core";
import { QuickAddProvider } from "@/components/quick-add-provider";
import { QuickAddSheet } from "@/components/quick-add-sheet";
import { QuickAddCreateHost } from "@/components/quick-add/create-host";
import { CommandPalette } from "@/components/command-palette";
import { PwaRegister } from "@/components/pwa-register";
import { InstallPrompt } from "@/components/install-prompt";
import { AppShell } from "@/components/app-shell";
import { AppToaster } from "@/components/app-toaster";
import { 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 type { Palette, ThemeMode, FontPair, Density, NavStyle } from "@/modules/_core/themes";
@@ -98,6 +102,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
let navStyle: NavStyle = DEFAULT_THEME.navStyle;
let userDashboards: DashboardMeta[] = [];
let signedIn = false;
let assistantEnabled = false;
let assistantName = DEFAULT_ASSISTANT_NAME;
let assistantModel: string | null = null;
const session = await auth();
if (session?.user?.id) {
@@ -109,6 +116,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
themeFontPair: users.themeFontPair,
themeDensity: users.themeDensity,
themeNavStyle: users.themeNavStyle,
assistantEnabled: users.assistantEnabled,
assistantName: users.assistantName,
assistantModel: users.assistantModel,
})
.from(users)
.where(eq(users.id, session.user.id))
@@ -119,6 +129,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
fontPair = row.themeFontPair as FontPair;
density = row.themeDensity as Density;
navStyle = row.themeNavStyle as NavStyle;
assistantEnabled = row.assistantEnabled;
assistantName = row.assistantName?.trim() || DEFAULT_ASSISTANT_NAME;
assistantModel = row.assistantModel?.trim() || null;
}
userDashboards = await db
.select({
@@ -167,9 +180,18 @@ export default async function RootLayout({ children }: { children: React.ReactNo
{children}
</AppShell>
<QuickAddSheet />
<QuickAddCreateHost />
<CommandPalette />
<InstallPrompt />
<PwaRegister />
{signedIn && assistantEnabled && session?.user?.id ? (
<AssistantBubble
configured={isLlmConfigured()}
userId={session.user.id}
assistantName={assistantName}
assistantModel={assistantModel}
/>
) : null}
<AppToaster position="bottom-right" />
</QuickAddProvider>
</body>
+3 -2
View File
@@ -1,11 +1,12 @@
import { notFound } from "next/navigation";
import { getCurrentSession } from "@/lib/session";
import { ListDetail } from "@/modules/lists/components/list-detail";
import { getList } from "@/modules/lists/server/queries";
export default async function ListPage({ params }: { params: Promise<{ id: string }> }) {
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();
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("/");
}

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