- pnpm 10 workspace + TypeScript strict + ESLint flat + Prettier - CLAUDE.md as canonical brief - docs/tasks/ — 22 task briefs broken out by phase for sub-sessions - docs/decisions/ — ADR scaffold Implements task 01 (repo-init).
11 KiB
famapp
Self-hosted family coordination web app for Matt and his wife. Replaces a commercial family app with a shared calendar, shopping/task lists, and notes/reminders. Designed to be deliberately extensible — Matt expects to bolt on niche features over time, so the architecture treats every feature as a module.
This file is the canonical brief. Read it at the start of every session before making changes. Sub-task briefs in docs/tasks/ reference this file; do not duplicate its contents there.
Goals
- Replace current family app: shared calendar, shopping list, task list, notes/reminders.
- Self-hosted on Matt's home server, accessible from outside the house with auth.
- Wife-friendly: installable PWA, passkey/SSO login, no extra apps to install for notifications.
- "Share anything" — most entities can produce a temporary public link for outsiders.
- Architecture optimized for adding many small features later without core changes.
Stack (decided)
| Layer | Choice | Notes |
|---|---|---|
| Frontend + backend | Next.js 15 App Router + TypeScript | Single Node process, server actions for mutations |
| UI | Tailwind + shadcn/ui | |
| DB | Postgres 16 | Dedicated container per app (Matt's rule) |
| ORM | Drizzle | Lightweight, SQL-friendly, good for evolving schema |
| Auth/SSO | Authentik at auth.ginnoir.com |
Picked over Pocket-ID because it ships forward-auth (Proxy Provider) for the rest of Matt's stack (Sonarr/Radarr/Prowlarr/NZBGet/qBittorrent/Tautulli/Overseerr/FreshRSS/ntfy) which has no native OIDC. famapp itself uses OIDC. |
| Push | Web Push (VAPID) | Works in installed PWAs incl. iOS Safari 16.4+. ntfy stays optional. |
| Realtime | Postgres LISTEN/NOTIFY → SSE |
Simpler than WebSockets, fine for two users |
| Reverse proxy | Caddy (existing) | fam.ginnoir.com → famapp:3000, auth.ginnoir.com → authentik |
| Package manager | pnpm | Fast, content-addressed store, strict deps |
| Runtime | Node 22 LTS | In node:22-alpine container |
| Host | Ubuntu Server 24.04 x64, Docker + Compose | Existing self-host stack |
Domain: fam.ginnoir.com. SSO: auth.ginnoir.com.
Architecture: extensibility-first
Every feature is a self-contained module under src/modules/<name>/. A module declares its tables, routes, dashboard contributions, quick-add actions, and supported share/reminder/search behaviors via a manifest. Core services (sharing, push, activity log, search, reminders) operate generically against any registered entity — adding a new module should not require touching core code.
src/
modules/
_core/ # auth, household, sharing, push, activity log, reminders
calendar/
schema.ts # drizzle tables
server/ # server actions + queries
components/
routes.ts # registers /calendar pages + API
dashboard.tsx # widget contributed to dashboard
manifest.ts # name, icon, nav, entity types, share/reminder handlers
lists/
notes/
app/ # Next.js routes — thin, mounts modules
lib/
Core primitives every module gets
- Entity registry. Modules declare entity types; share-link, activity log, search, reminders all work against any registered entity.
- Dashboard contribution API. Each module exports a widget + priority. Dashboard composes whatever's installed.
- 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. - Permissions. Household-scoped by default. Share-tokens grant scoped read/write per entity.
- Feature flags. Env/db-driven for staging experiments.
Rule for contributors (including future Sonnet sessions): if a feature requires a change to _core to support a new entity type, that's a smell — extend the registry instead.
Data model (v1)
users,households,household_memberscalendar_events— title, start, end, all_day, location, notes, color, owner.rruletext column reserved (no recurrence in v1).external_source/external_idnullable for future Google/Apple sync.lists(type enum:shopping|task| future),list_itemsnotes— title, body, pinned, remind_atshare_links— entity_type, entity_id, token, capabilities jsonb, expires_atactivity_log— entity_type, entity_id, actor, action, payload jsonbpush_subscriptionsreminders— entity_type, entity_id, fire_at, channel. Generic; used by notes/events/anything.
All entity tables include household_id. All mutations are scoped to the caller's household.
Pages (v1)
/Dashboard — today + next 3 days events, top of shopping list, open tasks, pinned notes, recent activity, quick-add+/calendar— month/week/day, drag-create/lists/shopping,/lists/tasks— defaults; create more/notes/s/<token>— public share viewer/settings— household, push toggles, share-link management
Deploy shape
# compose.yaml (sketch — final lives in /deploy)
services:
famapp: # node:22-alpine, Next.js
famapp-db: # postgres:16
authentik-server:
authentik-worker:
authentik-db: # postgres:16 (separate per Matt's rule)
authentik-redis:
Caddy: fam.ginnoir.com → famapp:3000. auth.ginnoir.com → authentik-server:9000.
Backups: nightly pg_dump to a host volume. Out of scope for phase 1 but reserve the cron slot.
Build phases
- Scaffold — repo init, Next.js + Drizzle + Tailwind + shadcn, module loader, compose stack, Caddy snippet, Authentik wired up, seeded household with both users.
- Core modules — calendar, lists, notes (CRUD only).
- Dashboard + quick-add + activity log.
- Sharing — share-link service +
/s/<token>viewer. - Push notifications + reminders.
- PWA polish — manifest, icons, offline shell, install prompt.
- Hardening — backups (pg_dump cron), rate limiting on share links, logging.
Tasks for each phase live in docs/tasks/. Sub-sessions should pick up a task file, follow its scope, and stop at its acceptance criteria.
Conventions
- TypeScript strict. No
anywithout a written reason. - Server actions for mutations; route handlers only for webhooks/SSE/share-link viewer.
- Drizzle migrations committed under
drizzle/. Never edit a shipped migration — add a new one. - No comments unless the why is non-obvious. Names should carry intent.
- Module isolation. A module imports from
_coreandlib/only — never from a sibling module. - One module = one PR/commit boundary when possible.
- Tests: Vitest for units (where it pays off), Playwright for one happy-path E2E per module. Don't write tests for trivial CRUD.
- Secrets via
.env(gitignored) and.env.example(committed, no values).
Out of scope for v1 (do not build)
- ICS export / Google / Apple / Outlook calendar sync (data model leaves room)
- Recurrence editor (column reserved, no UI)
- Native iOS/Android app (PWA only; architecture must not preclude it)
- Migration from any existing app (starting fresh)
- Forward-auth wiring for the rest of Matt's stack (Authentik is installed; wiring the *arr apps is a separate later task)
- Multi-tenant / multi-household (schema has
household_idbut UI is single-household)
Where things live
- This brief:
CLAUDE.md - Task briefs for sub-sessions:
docs/tasks/NN-name.md - Architecture decisions worth preserving:
docs/decisions/NNNN-title.md(lightweight ADR — only when a non-obvious choice is made) - App code:
src/ - Drizzle migrations:
drizzle/ - Deploy:
deploy/(compose.yaml, Caddyfile snippet, Authentik bootstrap) - Memory (cross-session):
C:\Users\MattC\.claude\projects\C--Users-MattC-Documents-famapp\memory\