Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39ea1ae5aa | ||
|
|
86444bc850 | ||
|
|
7704924d88 | ||
|
|
7ffc2f9522 | ||
|
|
f3c6137fed | ||
|
|
7bf4a801b3 | ||
|
|
3e8fe2d06d | ||
|
|
60eb101015 | ||
|
|
72d123b9a0 | ||
|
|
9bf8e508c7 | ||
|
|
d3e79edb9e | ||
|
|
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 |
@@ -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,16 @@ 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
|
||||
# IANA timezone for assistant relative dates ("Thursday at 2"). Falls back to TZ, then America/Chicago.
|
||||
HOUSEHOLD_TIMEZONE=America/Chicago
|
||||
# Optional override for agent tool → /api/v1 self-calls (defaults to http://127.0.0.1:$PORT).
|
||||
# INTERNAL_API_BASE_URL=http://127.0.0.1:3000
|
||||
|
||||
+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:
|
||||
|
||||
@@ -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.
|
||||
@@ -76,6 +76,8 @@ src/
|
||||
|
||||
**Rule for contributors (including future Codex and Claude Code sessions):** if a feature requires a change to `_core` to support a new entity type, that's a smell — extend the registry instead.
|
||||
|
||||
**Completion rule for module additions and behavior changes:** include the authenticated v1 API and OpenAPI documentation, assistant tools backed by that API, and a complete reminder lifecycle for every user-visible due date. API, agent, and UI must call the same scoped server services; create/update must synchronize unfired reminders and delete must cancel them.
|
||||
|
||||
---
|
||||
|
||||
## Data model (v1)
|
||||
|
||||
+134
@@ -1,5 +1,139 @@
|
||||
# Changelog
|
||||
|
||||
## [0.6.5](https://github.com/ginnoir/famapp/compare/v0.6.4...v0.6.5) (2026-07-09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **agent:** call api v1 via loopback instead of public url ([3e8fe2d](https://github.com/ginnoir/famapp/commit/3e8fe2d06dc31d3a1058b4b8942c46b1220a6b4c))
|
||||
|
||||
## [0.6.4](https://github.com/ginnoir/famapp/compare/v0.6.3...v0.6.4) (2026-07-09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **agent:** stop tool thrash after writes and log rounds ([72d123b](https://github.com/ginnoir/famapp/commit/72d123b9a0f67589d92614a8d2138912585b0d79))
|
||||
|
||||
## [0.6.3](https://github.com/ginnoir/famapp/compare/v0.6.2...v0.6.3) (2026-07-09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **agent:** raise tool rounds and ease calendar creates ([d3e79ed](https://github.com/ginnoir/famapp/commit/d3e79edb9ef0b931fa9d6e6629dad2677d661741))
|
||||
|
||||
## [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.
|
||||
@@ -77,6 +77,8 @@ src/
|
||||
|
||||
**Rule for contributors (including future Codex and Claude Code sessions):** if a feature requires a change to `_core` to support a new entity type, that's a smell — extend the registry instead.
|
||||
|
||||
**Completion rule for module additions and behavior changes:** include the authenticated v1 API and OpenAPI documentation, assistant tools backed by that API, and a complete reminder lifecycle for every user-visible due date. API, agent, and UI must call the same scoped server services; create/update must synchronize unfired reminders and delete must cancel them.
|
||||
|
||||
---
|
||||
|
||||
## Data model (v1)
|
||||
|
||||
@@ -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.
|
||||
@@ -46,15 +75,15 @@ Phase 9 — Post-v0.1 (see `docs/superpowers/specs/2026-07-03-backlog-triage-des
|
||||
|
||||
**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
|
||||
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:** Read AGENTS.md / CLAUDE.md / STATUS.md, open the next unchecked task in `docs/tasks/80`–`88`, stop at acceptance criteria.
|
||||
**How to resume:** P2 batch #28–#31 complete (released + deployed v0.5.6 on prod). Next: remaining P2/P3 Gitea backlog (#32+) or homelab LLM wiring (`LLM_BASE_URL` in homelabstack `.env`).
|
||||
|
||||
## Development login/testing notes
|
||||
|
||||
@@ -82,5 +111,6 @@ P2/P3 backlog is filed on Gitea only (no task briefs yet) — see `docs/issues-m
|
||||
- Repo: https://github.com/ginnoir/famapp (HTTPS remote on `origin`).
|
||||
- Local dev tooling installed: Node 22+, pnpm 10.33.3.
|
||||
- `.env` is **not** committed; copy `.env.example` → `.env` when needed.
|
||||
- Assistant relative dates use `HOUSEHOLD_TIMEZONE` (default `America/Chicago` if unset).
|
||||
- VS Code recommended extensions in `.vscode/extensions.json`; copy `.vscode/settings.json.example` → `.vscode/settings.json` for the workspace defaults.
|
||||
- Memory files (cross-session, only seen by Claude): `C:\Users\MattC\.claude\projects\C--Users-MattC-Documents-famapp\memory\`.
|
||||
|
||||
+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
@@ -1,16 +1,89 @@
|
||||
# 0004 — Rich-text editor library and storage format
|
||||
|
||||
Date: 2026-07-03
|
||||
Status: proposed
|
||||
Status: accepted (2026-07-04)
|
||||
|
||||
## Context
|
||||
|
||||
Journal (task 86) and notes overhaul (task 85) need a shared rich-text editor with emoji support. Research and choose an existing library (e.g. TipTap/ProseMirror-family) rather than building one. Gitea: [#8](https://gitea.ginnoir.com/ginnoir/famapp/issues/8) (epic [#7](https://gitea.ginnoir.com/ginnoir/famapp/issues/7)).
|
||||
Notes (task 85) and journal (task 86) need a shared rich-text editor with emoji and modern formatting. Notes today use a plain `<textarea>` and a minimal custom markdown preview (`#` headings, `-` lists only). Body is stored as plain text in `notes.body`. Mobile horizontal overflow is a known bug (Gitea #5). Gitea epic [#7](https://gitea.ginnoir.com/ginnoir/famapp/issues/7).
|
||||
|
||||
## What we decided (2026-07-04)
|
||||
|
||||
| # | Topic | Choice |
|
||||
| --- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 1 | Editor library | **TipTap** (ProseMirror family) — WYSIWYG, not home-grown |
|
||||
| 2 | Storage format | **HTML** in existing `notes.body` text column; sanitize on every render |
|
||||
| 3 | v1 formatting | **Full-featured**: headings, bold/italic, bullet + numbered lists, links, emoji, blockquotes, code blocks, tables, checklists, images, file attachments, undo/redo |
|
||||
| 4 | Images & attachments | **Upload to famapp** via existing `/api/uploads`; embed returned URLs in note HTML |
|
||||
| 5 | Shared component location | **`src/components/rich-text/`** — imported by notes now, journal later |
|
||||
| 6 | Existing plain-text notes | **Lazy migration**: on read, non-HTML bodies wrapped as paragraphs; persisted as HTML on next save. No schema change. |
|
||||
| 7 | Checklists on read surfaces | **Interactive everywhere** — index, dashboard widget, and share view can toggle checklist items (updates note via API) |
|
||||
|
||||
## Decision
|
||||
|
||||
To be filled when research completes.
|
||||
### Editor: TipTap
|
||||
|
||||
- Use TipTap with React bindings and official extensions for the v1 feature set.
|
||||
- Export contract for the shared component:
|
||||
- `RichTextEditor` — controlled `value` / `onChange` (HTML string), optional `editable`, toolbar config
|
||||
- `RichTextContent` — read-only renderer with sanitization and overflow-safe CSS
|
||||
- `RichTextChecklist` behavior — interactive task items on read surfaces (see below)
|
||||
- Journal (task 86) imports from `src/components/rich-text/` without forking.
|
||||
|
||||
### Storage: HTML in `notes.body`
|
||||
|
||||
- `notes.body` remains a `text` column; content is HTML after first save from the new editor.
|
||||
- **Sanitize** all HTML before `dangerouslySetInnerHTML` (DOMPurify or equivalent) on every read surface: editor preview, index cards, dashboard widget, share viewer.
|
||||
- Allowlist tags/attributes matching TipTap output (headings, lists, task lists, tables, links, images, code, blockquote, etc.).
|
||||
- API `/api/v1/notes` continues to accept/return `body` as a string (now HTML). Document in OpenAPI.
|
||||
|
||||
### Lazy migration for legacy notes
|
||||
|
||||
- Helper `normalizeNoteBody(body: string): string` — if body does not start with `<` (heuristic) or fails HTML detection, convert plain text to `<p>` paragraphs (preserve line breaks).
|
||||
- Run on read paths (editor load, index excerpt, widget, share) until user saves, which persists HTML.
|
||||
- No one-shot migration script required for v1.
|
||||
|
||||
### Images and attachments
|
||||
|
||||
- TipTap image + file extensions call existing upload route (`POST /api/uploads`).
|
||||
- Stored URLs embedded in HTML (`<img src="…">`, attachment links as `<a href="…">`).
|
||||
- Upload auth: session or household bearer token (same as other API calls).
|
||||
|
||||
### Interactive checklists (read surfaces)
|
||||
|
||||
- TipTap TaskList extension for authoring.
|
||||
- On index, widget, and share surfaces: render checklists with tappable checkboxes.
|
||||
- Toggle calls a server action or `PATCH /api/v1/notes/:id` that updates only the task-item `checked` state inside the HTML (parse → flip `data-checked` / class → save). Activity log optional.
|
||||
- Share links with write capability may allow toggle if share token grants write (reuse existing share write path where applicable).
|
||||
|
||||
### Mobile overflow
|
||||
|
||||
- Fix as part of `RichTextContent`: `overflow-wrap: anywhere`, `max-width: 100%`, table horizontal scroll container, no fixed-width embeds. Closes Gitea #5.
|
||||
|
||||
## Consequences
|
||||
|
||||
To be filled when research completes.
|
||||
### Positive
|
||||
|
||||
- One editor stack for notes + journal; wife-friendly WYSIWYG.
|
||||
- HTML storage is simple to render and matches TipTap's native export.
|
||||
- Lazy migration avoids a risky bulk conversion; existing notes keep working.
|
||||
- Upload reuse avoids new storage infrastructure.
|
||||
|
||||
### Trade-offs
|
||||
|
||||
- Full TipTap extension set increases bundle size — acceptable for an editor route; lazy-load editor chunk on notes/journal pages.
|
||||
- HTML in DB is less readable than markdown for debugging; mitigated by Drizzle Studio and API still returning strings.
|
||||
- Interactive checklists on read surfaces require HTML-aware toggle logic (not trivial CRUD) — budget extra implementation time in task 85.
|
||||
- Sanitization is security-critical; must test XSS vectors and keep allowlist in sync with TipTap extensions.
|
||||
|
||||
### Out of scope (ADR)
|
||||
|
||||
- Journal module UI (task 86) — consumes shared component after 85 lands.
|
||||
- Collaborative editing, comments, version history.
|
||||
|
||||
## References
|
||||
|
||||
- Task brief: `docs/tasks/85-rich-text-notes.md`
|
||||
- Gitea: [#7](https://gitea.ginnoir.com/ginnoir/famapp/issues/7) epic, [#8](https://gitea.ginnoir.com/ginnoir/famapp/issues/8) research child
|
||||
- Current notes editor: `src/modules/notes/components/note-editor.tsx`
|
||||
- Uploads: `src/app/api/uploads/route.ts`
|
||||
|
||||
@@ -1,16 +1,128 @@
|
||||
# 0005 — Journal / mood tracking research
|
||||
# 0005 — Journal / mood tracking
|
||||
|
||||
Date: 2026-07-03
|
||||
Status: proposed
|
||||
Status: accepted (2026-07-04)
|
||||
|
||||
## Context
|
||||
|
||||
Journal module (task 86) needs mood multi-select, charts over time, and insights. Research existing libraries/components for mood tracking or journaling and record build-vs-adopt. Gitea: [#20](https://gitea.ginnoir.com/ginnoir/famapp/issues/20) (epic [#19](https://gitea.ginnoir.com/ginnoir/famapp/issues/19)).
|
||||
Journal module (task 86) needs per-user entries, mood multi-select, charts over time, insights, and `/api/v1/journal` endpoints. ADR research included [Giftyaning/Mood-Tracker](https://github.com/Giftyaning/Mood-Tracker) (Gitea [#20](https://gitea.ginnoir.com/ginnoir/famapp/issues/20), epic [#19](https://gitea.ginnoir.com/ginnoir/famapp/issues/19)).
|
||||
|
||||
## Build-vs-adopt: Mood-Tracker
|
||||
|
||||
**Decision: do not adopt the repo.**
|
||||
|
||||
| Factor | Mood-Tracker | famapp need |
|
||||
| ---------- | -------------------------- | ------------------------------------------- |
|
||||
| License | None | Must have clear reuse rights |
|
||||
| Stack | Vite SPA, JS, localStorage | Next.js 15, TypeScript, Postgres, Authentik |
|
||||
| Mood model | Single-select, 5 presets | Multi-select fixed catalog |
|
||||
| Fields | Title + plain text | Optional title + TipTap HTML, stress, pills |
|
||||
| API / auth | None | `/api/v1/` + session + bearer token |
|
||||
| Code shape | One 672-line `App.jsx` | `src/modules/journal/` manifest pattern |
|
||||
|
||||
**Borrow (reimplement in TypeScript):** mood→numeric scoring, day streak, week-over-week trend, insight card layout. Not copied source.
|
||||
|
||||
## What we decided (2026-07-04)
|
||||
|
||||
| # | Topic | Choice |
|
||||
| --- | -------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Charts | **Build natively + Recharts** for mood/stress trends and correlation views |
|
||||
| 2 | Moods | **Fixed catalog** (~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
|
||||
|
||||
To be filled when research completes.
|
||||
### Module: `src/modules/journal/`
|
||||
|
||||
Standard module layout: `schema.ts`, `manifest.tsx`, `server/`, `components/`, app routes under `/journal`.
|
||||
|
||||
### Data model
|
||||
|
||||
Table `journal_entries`:
|
||||
|
||||
- `household_id` — household scope for DB consistency
|
||||
- `user_id` — **per-user privacy**; all reads/writes filter `user_id = current user`
|
||||
- `recorded_at` timestamptz — required date+time (defaults to now on create)
|
||||
- `title` text nullable — optional
|
||||
- `body` text — HTML from shared rich-text editor (ADR 0004)
|
||||
- `moods` jsonb — array of catalog mood ids (multi-select)
|
||||
- `stress` smallint nullable — 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
|
||||
|
||||
To be filled when research completes.
|
||||
### Positive
|
||||
|
||||
- Recharts gives proper time-series without maintaining custom chart code for long trends.
|
||||
- Fixed mood catalog keeps insights comparable across entries.
|
||||
- Per-user privacy matches personal journal expectations.
|
||||
- Reuses rich-text stack from task 85.
|
||||
|
||||
### Trade-offs
|
||||
|
||||
- All-entry chart points mean busy days show multiple dots; datetime x-axis required.
|
||||
- Fixed catalog may miss nuance — body text carries freeform detail.
|
||||
- Correlation stats are descriptive only (no causation); keep copy honest in UI.
|
||||
- Recharts adds bundle weight on `/journal/mood` and `/journal/insights` only (route-level code split).
|
||||
|
||||
### Out of scope (ADR)
|
||||
|
||||
- Household-shared journals
|
||||
- LLM agent tools for journal (task 88)
|
||||
- Custom user-defined mood tags
|
||||
- Share links for journal entries
|
||||
|
||||
## References
|
||||
|
||||
- Task brief: `docs/tasks/86-journal-module.md`
|
||||
- Rich text: `docs/decisions/0004-rich-text-editor.md`
|
||||
- API: `docs/decisions/0006-api-llm-agent.md`
|
||||
- Mood-Tracker research: https://github.com/Giftyaning/Mood-Tracker
|
||||
|
||||
@@ -1,16 +1,77 @@
|
||||
# 0006 — API auth and LLM agent architecture
|
||||
|
||||
Date: 2026-07-03
|
||||
Status: proposed
|
||||
Status: accepted (2026-07-04)
|
||||
|
||||
## Context
|
||||
|
||||
P1 needs a documented API with token auth coexisting with OIDC session auth, plus a provider-agnostic OpenAI-compatible agent that tool-calls the API. Decide MCP vs direct tools. Gitea: [#14](https://gitea.ginnoir.com/ginnoir/famapp/issues/14) (epic [#13](https://gitea.ginnoir.com/ginnoir/famapp/issues/13)). Tasks 87 then 88.
|
||||
P1 needs a documented HTTP API with token auth coexisting with OIDC session auth, plus a provider-agnostic OpenAI-compatible agent that tool-calls the API. Tasks 87 (API surface) and 88 (agent chat) depend on these choices. Gitea: [#14](https://gitea.ginnoir.com/ginnoir/famapp/issues/14) (epic [#13](https://gitea.ginnoir.com/ginnoir/famapp/issues/13)).
|
||||
|
||||
Existing auth: Authentik OIDC via Auth.js, database sessions, household-scoped data via `getCurrentSession()`. Mutations today are server actions; a handful of route handlers exist under `/api/` for uploads, SSE, and auth.
|
||||
|
||||
## What we decided (2026-07-04)
|
||||
|
||||
| # | Topic | Choice |
|
||||
| --- | ------------------ | --------------------------------------------------------------------------------------------------- |
|
||||
| 1 | API token model | **One shared household token** (not per-user) |
|
||||
| 1b | Token management | **Household owner only** — create, revoke, view last-used |
|
||||
| 2 | API shape | **REST JSON under `/api/v1/`** — additive versioning |
|
||||
| 3 | LLM provider | **Env-configured OpenAI-compatible endpoint** (`LLM_BASE_URL`, optional `LLM_API_KEY`, `LLM_MODEL`) |
|
||||
| 4 | Agent → API wiring | **Direct tools** (JSON-schema tools → HTTP calls). **No MCP server** in v1 |
|
||||
|
||||
## Decision
|
||||
|
||||
To be filled when research completes.
|
||||
### Token auth (coexists with OIDC session)
|
||||
|
||||
- Add a single **household-scoped API token** stored hashed in the database (`household_api_tokens` or equivalent).
|
||||
- Non-browser clients send `Authorization: Bearer <token>`.
|
||||
- `/api/v1/*` route handlers accept **either** a valid Auth.js session cookie **or** a valid household bearer token. Both resolve to the same household scope and permission checks.
|
||||
- **OIDC / browser login is unchanged.** Middleware continues to guard pages; API routes perform their own auth (session or bearer).
|
||||
- Token lifecycle UI on `/settings` (owner only): create (show raw token once), revoke, last-used timestamp.
|
||||
- Activity log for API mutations may attribute `actorId` from session when present; bearer-token calls use `actorId = null` (same pattern as share-link anonymous mutations).
|
||||
|
||||
### API shape
|
||||
|
||||
- **REST** resources under `/api/v1/` with JSON request/response bodies.
|
||||
- **Additive versioning**: new modules (journal, etc.) add routes under `/api/v1/` without breaking existing clients. No v2 until a breaking change is unavoidable.
|
||||
- Task 87 covers: calendars, events, lists, list items, notes, garden (plants + containers), bangs. Dashboards deferred unless needed.
|
||||
- **OpenAPI** spec committed as living docs (`docs/api/openapi.yaml` or generated from route definitions).
|
||||
- Handlers call the same module server queries/actions as the web app — no parallel business logic.
|
||||
|
||||
### LLM provider (task 88)
|
||||
|
||||
- Famapp does **not** host a model. The agent client points at an external **OpenAI-compatible** HTTP API configured via environment variables.
|
||||
- Compatible with Ollama, vLLM, LiteLLM proxy, or any `/v1/chat/completions`-style endpoint on the homelab.
|
||||
- CI uses a **mock/stub provider** — no live LLM calls in tests.
|
||||
|
||||
### Agent tool-calling (task 88)
|
||||
|
||||
- **Direct tools**: the in-app agent defines tool schemas (name, description, parameters) that map to `/api/v1/` HTTP methods. The agent loop calls the API with the household bearer token.
|
||||
- **No MCP server** in v1. Revisit if famapp needs to be a tool provider for Cursor or other MCP clients later.
|
||||
|
||||
## Consequences
|
||||
|
||||
To be filled when research completes.
|
||||
### Positive
|
||||
|
||||
- One token to configure for scripts, wife's automations, and the agent — simple for a two-user household.
|
||||
- REST + OpenAPI gives predictable integration and straightforward tool schema generation.
|
||||
- Session and bearer auth share household scope; web app behavior unchanged.
|
||||
- Direct tools avoid MCP transport, extra processes, and protocol overhead.
|
||||
|
||||
### Trade-offs
|
||||
|
||||
- Shared token: revoking it disables all non-browser clients at once; no per-person revocation.
|
||||
- Bearer calls lack a user `actorId` in activity log unless we add optional token metadata later.
|
||||
- Direct tools couple agent definitions to famapp's HTTP API; external MCP consumers would need a separate effort.
|
||||
|
||||
### Implementation notes (task 87)
|
||||
|
||||
- Schema: `household_api_tokens` with `household_id`, `token_hash`, `name` (e.g. "default"), `last_used_at`, `created_by` (owner), `revoked_at`.
|
||||
- Middleware: exempt `/api/v1/` from session redirect; handlers enforce auth.
|
||||
- Settings: owner-only card for token create/revoke.
|
||||
- Tests: Vitest for bearer auth resolution, 401/403 paths, and representative CRUD handlers.
|
||||
|
||||
## References
|
||||
|
||||
- Task briefs: `docs/tasks/87-api-surface.md`, `docs/tasks/88-llm-agent-chat.md`
|
||||
- Design: `docs/superpowers/specs/2026-07-03-backlog-triage-design.md`
|
||||
|
||||
@@ -26,7 +26,7 @@ What this costs us, what it buys us.
|
||||
|
||||
- [0002 — List realtime uses Postgres NOTIFY and SSE](0002-list-sse-notify.md)
|
||||
- [0003 — Release workflow (commitlint + release-it)](0003-release-workflow.md)
|
||||
- [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)
|
||||
- [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 -->
|
||||
|
||||
+3
-2
@@ -3,7 +3,7 @@
|
||||
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 |
|
||||
@@ -41,11 +41,12 @@ Design IDs from `docs/superpowers/specs/2026-07-03-backlog-triage-design.md` →
|
||||
| 17 | 34 | Backups and exports | https://gitea.ginnoir.com/ginnoir/famapp/issues/34 |
|
||||
| 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) |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -25,9 +25,9 @@ Edit mode currently shows empty placeholders; on save, content-heavy widgets (e.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Edit mode shows real widget content for registered widgets.
|
||||
- [ ] Saving layout does not cause a large content-driven reflow for content-heavy widgets.
|
||||
- [ ] E2E covers real content visible in edit mode.
|
||||
- [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
|
||||
|
||||
|
||||
@@ -30,11 +30,11 @@ Journal (86) and notes need modern formatting and emoji. Notes currently overflo
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] ADR 0004 accepted with library and storage format chosen.
|
||||
- [ ] Shared editor component in tree.
|
||||
- [ ] Notes create/edit use it.
|
||||
- [ ] Formatting renders on all notes surfaces.
|
||||
- [ ] Mobile overflow fixed; E2E green.
|
||||
- [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
|
||||
|
||||
|
||||
@@ -33,11 +33,11 @@ P1 personal journaling with mood/stress/pills tracking and stats, without blocki
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] ADR 0005 accepted.
|
||||
- [ ] Create entry with only date/time.
|
||||
- [ ] Index, detail, mood tracker, and insights views work.
|
||||
- [ ] Journal API endpoints documented and callable with token auth.
|
||||
- [ ] E2E happy path green.
|
||||
- [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
|
||||
|
||||
|
||||
@@ -28,10 +28,10 @@ Users should say "add milk to the shopping list" or "what's on the calendar Frid
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Chat UI sends prompts and shows responses.
|
||||
- [ ] Tools call the API successfully in dev against the homelab endpoint.
|
||||
- [ ] CI uses mock provider only.
|
||||
- [ ] ADR 0006 records provider and MCP decisions.
|
||||
- [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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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');
|
||||
@@ -0,0 +1,70 @@
|
||||
-- Custom SQL migration file, put your code below! --
|
||||
CREATE TABLE "pets" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"household_id" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"species" text NOT NULL,
|
||||
"breed" text,
|
||||
"birth_date" date,
|
||||
"notes" text,
|
||||
"primary_image_url" text,
|
||||
"images" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "pets_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "pet_appointments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"pet_id" uuid NOT NULL,
|
||||
"household_id" uuid NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"appointment_at" timestamp with time zone NOT NULL,
|
||||
"clinic" text,
|
||||
"notes" text,
|
||||
"calendar_event_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "pet_appointments_pet_id_pets_id_fk" FOREIGN KEY ("pet_id") REFERENCES "public"."pets"("id") ON DELETE cascade ON UPDATE no action,
|
||||
CONSTRAINT "pet_appointments_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "pet_vaccinations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"pet_id" uuid NOT NULL,
|
||||
"household_id" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"administered_on" date NOT NULL,
|
||||
"due_on" date,
|
||||
"provider" text,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "pet_vaccinations_pet_id_pets_id_fk" FOREIGN KEY ("pet_id") REFERENCES "public"."pets"("id") ON DELETE cascade ON UPDATE no action,
|
||||
CONSTRAINT "pet_vaccinations_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "pet_prescriptions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"pet_id" uuid NOT NULL,
|
||||
"household_id" uuid NOT NULL,
|
||||
"medication" text NOT NULL,
|
||||
"dosage" text NOT NULL,
|
||||
"instructions" text,
|
||||
"prescribed_on" date,
|
||||
"expires_on" date,
|
||||
"refills_remaining" integer,
|
||||
"active" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "pet_prescriptions_pet_id_pets_id_fk" FOREIGN KEY ("pet_id") REFERENCES "public"."pets"("id") ON DELETE cascade ON UPDATE no action,
|
||||
CONSTRAINT "pet_prescriptions_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "pets_household_idx" ON "pets" USING btree ("household_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "pet_appointments_pet_at_idx" ON "pet_appointments" USING btree ("pet_id", "appointment_at");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "pet_vaccinations_pet_due_idx" ON "pet_vaccinations" USING btree ("pet_id", "due_on");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "pet_prescriptions_pet_active_idx" ON "pet_prescriptions" USING btree ("pet_id", "active");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -134,6 +134,69 @@
|
||||
"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
|
||||
},
|
||||
{
|
||||
"idx": 27,
|
||||
"version": "7",
|
||||
"when": 1783648174004,
|
||||
"tag": "0027_pets_module",
|
||||
"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.5",
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,9 @@ 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";
|
||||
|
||||
@@ -99,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) {
|
||||
@@ -110,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))
|
||||
@@ -120,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({
|
||||
@@ -172,6 +184,14 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
<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("/");
|
||||
}
|
||||
+6
-32
@@ -1,10 +1,7 @@
|
||||
import { signIn } from "@/lib/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { isDevLoginEnabled } from "@/lib/dev-login-config";
|
||||
import { createDevSession } from "@/lib/dev-login";
|
||||
import { BrandMark } from "@/components/brand-mark";
|
||||
import { isDevLoginEnabled } from "@/lib/dev-login-config";
|
||||
import { devLogin, signInWithSso } from "./actions";
|
||||
|
||||
export default function LoginPage() {
|
||||
const devLoginEnabled = isDevLoginEnabled();
|
||||
@@ -23,41 +20,18 @@ export default function LoginPage() {
|
||||
</div>
|
||||
<h1 className="serif text-[28px] font-medium tracking-tight mb-2">famapp</h1>
|
||||
<p className="muted text-[13.5px] mb-6">Sign in to your household.</p>
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await signIn("authentik", { redirectTo: "/" });
|
||||
}}
|
||||
>
|
||||
<form action={signInWithSso}>
|
||||
<Button type="submit" size="lg" className="w-full">
|
||||
Sign in with SSO
|
||||
</Button>
|
||||
</form>
|
||||
{devLoginEnabled && (
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
// Auth.js may resolve either "authjs.session-token" (HTTP/dev) or
|
||||
// "__Secure-authjs.session-token" (HTTPS) depending on AUTH_URL,
|
||||
// trustHost, and proxy headers. Set both so the session is found
|
||||
// regardless — this is dev-only code, correctness > elegance.
|
||||
const { sessionToken, expires } = await createDevSession();
|
||||
const cookieStore = await cookies();
|
||||
const base = { httpOnly: true, sameSite: "lax" as const, path: "/", expires };
|
||||
cookieStore.set("authjs.session-token", sessionToken, base);
|
||||
cookieStore.set("__Secure-authjs.session-token", sessionToken, {
|
||||
...base,
|
||||
secure: true,
|
||||
});
|
||||
redirect("/");
|
||||
}}
|
||||
className="mt-3"
|
||||
>
|
||||
{devLoginEnabled ? (
|
||||
<form action={devLogin} className="mt-3">
|
||||
<Button type="submit" variant="outline" size="lg" className="w-full">
|
||||
Dev login
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getCurrentSession } from "@/lib/session";
|
||||
import { NoteEditor } from "@/modules/notes/components/note-editor";
|
||||
import { getNote } from "@/modules/notes/server/queries";
|
||||
|
||||
export default async function NotePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const note = await getNote(id).catch(() => null);
|
||||
const [{ user }, note] = await Promise.all([getCurrentSession(), getNote(id).catch(() => null)]);
|
||||
if (!note) notFound();
|
||||
|
||||
return <NoteEditor note={note} />;
|
||||
return <NoteEditor note={note} currentUserId={user.id} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { DetailBackLink } from "@/components/detail-back-link";
|
||||
import { PetForm } from "@/modules/pets/components/pet-form";
|
||||
import { getPet } from "@/modules/pets/server/queries";
|
||||
|
||||
export default async function EditPetPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const pet = await getPet(id);
|
||||
if (!pet) notFound();
|
||||
return (
|
||||
<div className="page-content max-w-xl">
|
||||
<DetailBackLink href={`/pets/${pet.id}`} label={pet.name} className="mb-3" />
|
||||
<h1 className="page-title">Edit pet</h1>
|
||||
<PetForm pet={pet} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { DetailBackLink } from "@/components/detail-back-link";
|
||||
import { getPet } from "@/modules/pets/server/queries";
|
||||
import { MedicalRecords } from "@/modules/pets/components/medical-records";
|
||||
|
||||
export default async function PetPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const pet = await getPet(id);
|
||||
if (!pet) notFound();
|
||||
return (
|
||||
<div className="page-content max-w-xl">
|
||||
<DetailBackLink href="/pets" label="Pets" className="mb-3" />
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="page-title mb-1">{pet.name}</h1>
|
||||
<p className="capitalize text-[var(--ink-mute)]">
|
||||
{[pet.species, pet.breed].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
<Link href={`/pets/${pet.id}/edit`} className="btn btn-ghost">
|
||||
Edit
|
||||
</Link>
|
||||
</div>
|
||||
<section className="card p-4">
|
||||
<h2 className="mb-2 font-semibold">Profile</h2>
|
||||
{pet.birthDate && <p>Born {pet.birthDate}</p>}
|
||||
{pet.notes && <p className="whitespace-pre-wrap">{pet.notes}</p>}
|
||||
<MedicalRecords pet={pet} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { DetailBackLink } from "@/components/detail-back-link";
|
||||
import { PetForm } from "@/modules/pets/components/pet-form";
|
||||
|
||||
export default function NewPetPage() {
|
||||
return (
|
||||
<div className="page-content max-w-xl">
|
||||
<DetailBackLink href="/pets" label="Pets" className="mb-3" />
|
||||
<h1 className="page-title">Add pet</h1>
|
||||
<PetForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Link from "next/link";
|
||||
import { listPets } from "@/modules/pets/server/queries";
|
||||
|
||||
export default async function PetsPage() {
|
||||
const pets = await listPets();
|
||||
return (
|
||||
<div className="page-content">
|
||||
<div className="mb-6 flex items-center justify-between gap-4">
|
||||
<h1 className="page-title mb-0">Pets</h1>
|
||||
<Link href="/pets/new" className="btn btn-primary">
|
||||
Add pet
|
||||
</Link>
|
||||
</div>
|
||||
{pets.length === 0 ? (
|
||||
<p className="text-sm text-[var(--ink-mute)]">No pets yet.</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{pets.map((pet) => (
|
||||
<Link
|
||||
key={pet.id}
|
||||
href={`/pets/${pet.id}`}
|
||||
className="card p-4 hover:bg-[var(--surface-2)]"
|
||||
>
|
||||
<h2 className="font-semibold">{pet.name}</h2>
|
||||
<p className="text-sm capitalize text-[var(--ink-mute)]">
|
||||
{[pet.species, pet.breed].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
} from "@/modules/_core/themes";
|
||||
import { getCurrentSession } from "@/lib/session";
|
||||
import { revokeShareLink } from "@/modules/_core/share";
|
||||
import { createHouseholdApiToken, revokeHouseholdApiToken } from "@/modules/_core/api-token";
|
||||
|
||||
export interface ThemePatch {
|
||||
palette?: Palette;
|
||||
@@ -88,3 +89,22 @@ export async function revokeShareLinkAction(formData: FormData): Promise<void> {
|
||||
await revokeShareLink(id);
|
||||
revalidatePath("/settings");
|
||||
}
|
||||
|
||||
function requireOwner(role: "owner" | "member") {
|
||||
if (role !== "owner") throw new Error("Only household owners can manage API tokens");
|
||||
}
|
||||
|
||||
export async function createApiTokenAction(): Promise<{ token: string }> {
|
||||
const { household, role, user } = await getCurrentSession();
|
||||
requireOwner(role);
|
||||
const result = await createHouseholdApiToken(household.id, user.id);
|
||||
revalidatePath("/settings");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function revokeApiTokenAction(): Promise<void> {
|
||||
const { household, role } = await getCurrentSession();
|
||||
requireOwner(role);
|
||||
await revokeHouseholdApiToken(household.id);
|
||||
revalidatePath("/settings");
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user