Compare commits
59
Commits
3d825fba20
..
v0.6.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea371b8085 | ||
|
|
c8a7160ef2 | ||
|
|
49732b02f3 | ||
|
|
ec3f96dab1 | ||
|
|
b279f16ba0 | ||
|
|
4dc04b21d6 | ||
|
|
bb679d02be | ||
|
|
716bca0fcb | ||
|
|
1092941c45 | ||
|
|
4c82d551ea | ||
|
|
27db599444 | ||
|
|
3983c30fe1 | ||
|
|
9b7a04431c | ||
|
|
bf7e07ead9 | ||
|
|
876e72671a | ||
|
|
0bf63cf8a8 | ||
|
|
eb8e562565 | ||
|
|
1a58ef993e | ||
|
|
7c6b8d7c37 | ||
|
|
3a5c6d056c | ||
|
|
c8db5475d3 | ||
|
|
876a283d47 | ||
|
|
c80070f5c3 | ||
|
|
a58a91d8b8 | ||
|
|
e5e509081c | ||
|
|
a2e5eb111d | ||
|
|
cf50f20714 | ||
|
|
fd338384b7 | ||
|
|
de738274fa | ||
|
|
9d13d6a430 | ||
|
|
060a2f19a9 | ||
|
|
6183fb62c8 | ||
|
|
9e66c12eb7 | ||
|
|
e1c2a090fb | ||
|
|
7714b1187c | ||
|
|
a09747c314 | ||
|
|
4a924a4107 | ||
|
|
04ae809e07 | ||
|
|
8e2ddd6b72 | ||
|
|
a4be5d5061 | ||
|
|
67f67525ff | ||
|
|
fb67692a08 | ||
|
|
e8d13bede8 | ||
|
|
d4304b005c | ||
|
|
ea5d1d050c | ||
|
|
2c0c31540e | ||
|
|
5e711cfe0d | ||
|
|
d090200ec8 | ||
|
|
1a1c080d18 | ||
|
|
753f653b89 | ||
|
|
1a03763e1b | ||
|
|
700ee29f83 | ||
|
|
7eeb2f15bc | ||
|
|
02b6ec6adb | ||
|
|
68a573c4d6 | ||
|
|
76f68548b2 | ||
|
|
5dfc8b8034 | ||
|
|
310ba48e33 | ||
|
|
72e868dda1 |
@@ -1,5 +1,6 @@
|
||||
node_modules
|
||||
.next
|
||||
.worktrees
|
||||
.git
|
||||
deploy
|
||||
docs
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -59,3 +59,6 @@ scripts/sync-prod.ps1
|
||||
|
||||
# Design handoff bundle (reference only, not committed)
|
||||
.design-tmp/
|
||||
|
||||
# Git worktrees (local agent isolation)
|
||||
.worktrees/
|
||||
|
||||
@@ -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
@@ -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 85–88:
|
||||
|
||||
- **`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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -4,7 +4,7 @@ Self-hosted family coordination web app. Shared calendar, lists, notes, and gard
|
||||
|
||||
[](https://github.com/ginnoir/famapp/actions/workflows/ci.yml)
|
||||
[](https://github.com/ginnoir/famapp/releases)
|
||||
[](https://github.com/ginnoir/famapp/pkgs/container/famapp)
|
||||
[](https://gitea.ginnoir.com/ginnoir/famapp)
|
||||
[](https://nodejs.org)
|
||||
|
||||
---
|
||||
|
||||
@@ -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 1–7 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 80–84~~ — 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 7–9, 11–12, 15–19.
|
||||
|
||||
**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
@@ -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/`:
|
||||
|
||||
@@ -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
@@ -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`
|
||||
@@ -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** (~8–12 presets with emoji + color); **multi-select** |
|
||||
| 3 | Entries | **Multiple per day** allowed |
|
||||
| 4 | Stress | Optional **1–10** 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 — 1–10
|
||||
- `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 5–10 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
|
||||
@@ -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`
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 0004–0006 |
|
||||
| `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 7–9, 11–12, 15–19.
|
||||
|
||||
- [ ] **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 (80–84)
|
||||
|
||||
**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 (85–88)
|
||||
|
||||
**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, 80–84 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 0004–0006
|
||||
|
||||
**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 80–84 (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 80–84 → 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 2–4 |
|
||||
| Epic children for #10, #13, #14 | Task 3 |
|
||||
| Light checklists P2/P3 epics | Task 4 |
|
||||
| Labels + priority | Task 1 |
|
||||
| P1 task briefs 80–88 | Tasks 5–6 |
|
||||
| Phase 9 README | Task 7 |
|
||||
| ADR stubs 0004–0006 | 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, #15–17): 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, #15–17. 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 0004–0006 (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.
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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 80–84 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`
|
||||
@@ -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 5–10 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)
|
||||
@@ -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 80–84)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ADD COLUMN "assistant_enabled" boolean DEFAULT false NOT NULL;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ADD COLUMN "assistant_model" text;
|
||||
@@ -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');
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export default tseslint.config(
|
||||
ignores: [
|
||||
"node_modules/**",
|
||||
".next/**",
|
||||
".worktrees/**",
|
||||
".claude/**",
|
||||
".design-tmp/**",
|
||||
"dist/**",
|
||||
|
||||
+8
-1
@@ -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
@@ -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",
|
||||
|
||||
Generated
+1171
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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("/");
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { JournalEntryEditor } from "@/modules/journal/components/entry-editor";
|
||||
|
||||
export default function NewJournalEntryPage() {
|
||||
return <JournalEntryEditor />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user