13 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.
Codex and Claude Code both work on this project. Keep AGENTS.md, CLAUDE.md, STATUS.md, task briefs, and dev notes synchronized so either agent can pick up the next task without relying on agent-specific memory.
Regular agent skills
- Use the React best-practices skill for any React or Next.js page/component work, data-loading changes, bundle/performance work, or review of those areas.
- Use the shadcn skill for any UI work involving shadcn components, Tailwind styling, overlays, forms, icons, component composition, or updates to
components.json/src/components/ui.
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 24 LTS | In node:24-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 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.
- 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 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.
Data model (v1)
users,households,household_memberscalendars— name, color, owner_id, visibility (private|household). First-class entity; users create as many as they want, each independently shareable via the share-link service.calendar_events—calendar_idfk, title, start, end, all_day, location, notes, owner.rruletext column reserved (no recurrence in v1).external_source/external_idnullable for future Google/Apple sync.lists(type text — not enum, to allow extension),list_itemsnotes— title, body, pinned, remind_atdashboards— per-user named dashboards (any user can have many),layoutjsonb of placed widgets[{ widgetId, config, x, y, w, h }].share_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 household-scoped entity tables include household_id. Sub-entities (calendar_events, list_items) inherit scope via their parent. All reads/mutations are gated by the caller's household membership and, where relevant, per-entity visibility (e.g. private calendars).
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:24-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). - Teardown after validation. After build/test/E2E validation, stop any dev servers or compose services started for the task unless the user explicitly asks to keep them running. This avoids stale instances and port conflicts in later sessions.
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)
Infrastructure repo
Compose stack, Caddyfile, and .env for the whole homelab (including famapp) live at:
C:\Users\MattC\Documents\homelabstack
GitHub: ginnoir/homelabstack (private). This is the source of truth for all infra files — not deploy/ in this repo.
When famapp development requires an infra change (new env var, new service, Caddyfile route, etc.):
cd C:\Users\MattC\Documents\homelabstackand runsync-prod.ps1first (pulls current prod state)- Edit
docker-compose.yml,.env, and/orCaddyfileas needed - Run
apply-compose.ps1(flags:-Compose,-Caddy,-EnvFile) to push to valhalla - Commit and push the homelabstack repo
The homelabstack CLAUDE.md is the authoritative brief for that repo — read it before editing infra files.
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/ - Infra (compose/Caddyfile/.env):
C:\Users\MattC\Documents\homelabstack - Memory (cross-session):
C:\Users\MattC\.claude\projects\C--Users-MattC-Documents-famapp\memory\