Initial scaffold: tooling, plan, task briefs

- 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).
This commit is contained in:
ginnoir
2026-05-06 00:05:50 -05:00
commit b89690a9f2
40 changed files with 2282 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# Architecture decisions
Lightweight ADRs. Add one only when a non-obvious choice is made and the reasoning would be hard to reconstruct later. Keep each under ~300 words.
## Format
```
# NNNN — Title
Date: YYYY-MM-DD
Status: accepted | superseded by NNNN
## Context
What problem we faced.
## Decision
What we chose.
## Consequences
What this costs us, what it buys us.
```
## Index
- (none yet — add as decisions are made)
+42
View File
@@ -0,0 +1,42 @@
# 01 — Repo init & tooling
## Goal
Initialize the famapp repo with pnpm, TypeScript strict, ESLint/Prettier, and a clean baseline commit.
## Why
Every later task assumes the repo, lockfile, and lint config exist.
## Depends on
None.
## Scope
- `git init` in `C:\Users\MattC\Documents\famapp` with `main` as default branch.
- `package.json` (`"packageManager": "pnpm@<latest 10.x>"`, `"engines": { "node": ">=22" }`).
- `pnpm-workspace.yaml` (single root for now; leave room for workspaces).
- `tsconfig.json` — strict, `moduleResolution: "bundler"`, paths alias `@/* → src/*`.
- ESLint (flat config) + Prettier with sensible defaults; `eslint-config-next` deferred to task 02.
- `.gitignore` (Node, Next.js, env files, OS junk).
- `.env.example` with placeholders for: `DATABASE_URL`, `AUTH_OIDC_ISSUER`, `AUTH_OIDC_CLIENT_ID`, `AUTH_OIDC_CLIENT_SECRET`, `AUTH_SECRET`, `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `NEXT_PUBLIC_APP_URL`.
- `README.md` — short pointer to `CLAUDE.md` and `docs/tasks/`.
- Editor: `.editorconfig`, `.nvmrc` (`22`).
## Out of scope
- Installing Next.js, Drizzle, Tailwind (those are in 02 and 03).
- Husky / lint-staged (defer; small two-person project doesn't need it yet).
## Acceptance criteria
- [ ] `pnpm install` succeeds on a clean clone.
- [ ] `pnpm lint` and `pnpm format:check` run (even if no files yet) and exit 0.
- [ ] `tsc --noEmit` exits 0.
- [ ] `git log` shows exactly one initial commit.
## Notes
- Use the latest pnpm 10.x.
- Flat ESLint config (`eslint.config.mjs`) — don't use the legacy `.eslintrc`.
+38
View File
@@ -0,0 +1,38 @@
# 02 — Next.js app skeleton
## Goal
Stand up a minimal Next.js 15 (App Router) app with Tailwind and shadcn/ui, rendering a placeholder dashboard.
## Why
Provides the shell the modules will plug into.
## Depends on
- 01 (repo init)
## Scope
- Install Next.js 15 + React 19 into the existing repo (do **not** use `create-next-app` to overwrite — add deps manually or with `--use-pnpm` into the existing dir).
- App Router under `src/app/` with a root layout and a `/` page that renders "famapp" plus a stub "Dashboard coming soon".
- Tailwind v4 (or latest stable) configured against `src/**`.
- shadcn/ui initialized; install `button`, `card`, `input`, `dialog` to start.
- Path alias `@/*` working at runtime + in the editor.
- `next.config.ts` with `reactStrictMode: true` and `output: "standalone"` (required for the Docker image in task 05).
## Out of scope
- Auth, DB, modules. Just the shell.
- Custom theming beyond shadcn defaults.
## Acceptance criteria
- [ ] `pnpm dev` serves `http://localhost:3000` showing the placeholder page.
- [ ] `pnpm build` succeeds with `output: "standalone"` producing `.next/standalone/`.
- [ ] `pnpm lint` (now extending `next/core-web-vitals`) passes.
- [ ] No `any` introduced.
## Notes
- shadcn CLI writes to `src/components/ui/`. Keep it there; modules will import from it.
+38
View File
@@ -0,0 +1,38 @@
# 03 — Drizzle + Postgres setup
## Goal
Wire Drizzle ORM to a Postgres 16 instance, with migrations and a working `db` client.
## Depends on
- 02 (Next.js skeleton)
## Scope
- Install `drizzle-orm`, `drizzle-kit`, `postgres` (the `postgres` driver, not `pg`).
- `drizzle.config.ts` pointing at `src/modules/**/schema.ts` and writing to `./drizzle/`.
- `src/lib/db.ts` exporting a singleton `db` client built from `DATABASE_URL`.
- Initial schema in `src/modules/_core/schema.ts`:
- `users` (id uuid pk, email unique, display_name, created_at)
- `households` (id uuid pk, name, created_at)
- `household_members` (household_id, user_id, role enum: `owner` | `member`, pk on both)
- pnpm scripts: `db:generate`, `db:migrate`, `db:studio`.
- `docker-compose.dev.yaml` at repo root with a `famapp-db` service for local dev (Postgres 16, exposed on 5432, named volume).
## Out of scope
- Module-specific tables (calendar/lists/notes ship in their own tasks).
- Seed data (in task 07).
## Acceptance criteria
- [ ] `docker compose -f docker-compose.dev.yaml up -d` starts Postgres locally.
- [ ] `pnpm db:generate && pnpm db:migrate` produces `drizzle/0000_*.sql` and applies it.
- [ ] A throwaway script can `import { db } from "@/lib/db"` and `select` from `users` (returning empty).
- [ ] `tsc --noEmit` passes.
## Notes
- Use the `postgres` driver — works in both Node and edge-light contexts and is what Drizzle docs prefer for serverless-shaped apps.
- Keep the dev compose file separate from the production `deploy/compose.yaml` (task 05).
+72
View File
@@ -0,0 +1,72 @@
# 04 — Module loader & registry
## Goal
Implement the module system that everything else hangs off: each module declares a manifest, the loader composes them at startup, and core services iterate the registry instead of hardcoding entity types.
## Why
This is the load-bearing extensibility piece. Get this wrong and every later module needs core changes.
## Depends on
- 03 (DB)
## Scope
### Module manifest type (`src/modules/_core/module.ts`)
```ts
export type ModuleManifest = {
id: string; // "calendar", "lists", "notes"
name: string; // human-readable
nav?: { href: string; label: string; icon?: string };
entities: EntityTypeRegistration[]; // see below
dashboardWidgets?: DashboardWidget[];
quickAdds?: QuickAddAction[];
};
export type EntityTypeRegistration = {
type: string; // "calendar.event", "lists.item", "notes.note"
label: { singular: string; plural: string };
share?: ShareCapabilities; // null = not shareable
reminder?: ReminderCapabilities; // null = not remindable
search?: SearchAdapter; // null = not searchable
resolveUrl: (id: string) => string; // canonical app URL
loadForShare?: (id: string) => Promise<unknown>; // payload for /s/<token>
};
```
### Registry (`src/modules/_core/registry.ts`)
- `registerModule(manifest)` and `getRegistry()` returning frozen views.
- `getEntityType(type)` lookup.
- Import-time side-effect free: modules are listed and registered explicitly in `src/modules/index.ts`.
### Loader (`src/modules/index.ts`)
- Imports each module's manifest and calls `registerModule`. Order is deterministic.
- Stub modules for now: `calendar`, `lists`, `notes` each export an empty-but-valid manifest so the loader has something to register.
### Wiring
- Root layout reads the registry to render nav.
- A throwaway `/debug/registry` page (dev-only) dumps the loaded registry as JSON.
## Out of scope
- Real implementations of share / reminder / search adapters (later phases).
- Dynamic plugin loading from disk. Modules are statically imported.
## Acceptance criteria
- [ ] `src/modules/index.ts` registers three stub modules.
- [ ] Nav renders entries from the registry, not from a hardcoded list.
- [ ] `/debug/registry` shows all three module manifests in dev.
- [ ] Adding a fourth stub module only requires creating its folder + adding one line to `src/modules/index.ts`.
- [ ] All types exported from `_core` so other modules can import without circulars.
## Notes
- Frozen objects (`Object.freeze`) on registry exposure are cheap insurance against accidental mutation.
- Resist the urge to make this a fancy DI container. A plain map is enough.
+63
View File
@@ -0,0 +1,63 @@
# 05 — Compose stack & Caddy
## Goal
Produce the production deploy artifacts: Dockerfile, `deploy/compose.yaml`, Caddy snippet, `.env.production.example`.
## Depends on
- 02 (so `output: "standalone"` exists)
- 03 (so DB service is defined)
## Scope
### Dockerfile (multi-stage)
- Stage 1: `node:22-alpine` + pnpm, install deps, build (`pnpm build`).
- Stage 2: `node:22-alpine` runtime, copy `.next/standalone`, `.next/static`, `public/`. Run as non-root. `CMD ["node", "server.js"]`.
### `deploy/compose.yaml`
Services:
- `famapp` (built from Dockerfile)
- `famapp-db` (postgres:16, volume `famapp_db_data`)
- `authentik-server`, `authentik-worker`, `authentik-db` (postgres:16, volume `authentik_db_data`), `authentik-redis` — leave fully configured but task 06 will tune env
All services on a `famapp_net` network. famapp depends on famapp-db. No host port exposure for the DBs/redis.
### Caddy snippet (`deploy/Caddyfile.snippet`)
```
fam.ginnoir.com {
reverse_proxy famapp:3000
}
auth.ginnoir.com {
reverse_proxy authentik-server:9000
}
```
Comment at the top: "Include from main Caddyfile or paste into the existing one."
### `.env.production.example`
All vars needed by the compose stack, with comments explaining each.
## Out of scope
- Actually running the stack against ginnoir.com (Matt does that).
- Backups, log shipping (later tasks).
## Acceptance criteria
- [ ] `docker build -t famapp .` succeeds.
- [ ] `docker compose -f deploy/compose.yaml config` validates without errors.
- [ ] famapp container starts against famapp-db when given a populated `.env`.
- [ ] Image size under ~300 MB.
- [ ] No secrets committed; only `.env.production.example`.
## Notes
- Use `pnpm fetch` + `pnpm install --offline` in the build stage for cache locality.
- `output: "standalone"` (set in task 02) means we copy `.next/standalone/server.js`, not run `next start`.
+53
View File
@@ -0,0 +1,53 @@
# 06 — Authentik install + OIDC integration
## Goal
Bring up Authentik in the compose stack and integrate famapp as an OIDC client. Logging in at `fam.ginnoir.com` redirects to `auth.ginnoir.com`, returns, and creates a session.
## Depends on
- 04 (registry, so we have a `users` table to map into)
- 05 (compose stack)
## Scope
### Authentik bootstrap
- Tune env in `deploy/compose.yaml` for authentik-server/worker/db/redis per Authentik's official docs.
- `deploy/authentik/README.md` — manual one-time steps Matt runs after first boot:
1. Visit `auth.ginnoir.com/if/flow/initial-setup/`, set the akadmin password.
2. Create an OIDC Provider for famapp (RS256, redirect URI `https://fam.ginnoir.com/api/auth/callback/authentik`).
3. Create an Application bound to it; copy client ID + secret into famapp's `.env`.
4. Create the two user accounts (Matt + wife) with passkeys.
### famapp OIDC client
- Install `next-auth@beta` (v5) — Auth.js.
- `src/lib/auth.ts` configures Auth.js with a generic OIDC provider pointed at `AUTH_OIDC_ISSUER`.
- On first login, upsert into `users` table by email; create-or-attach to the single seeded household (task 07 owns the seeding; this task assumes it).
- Middleware (`src/middleware.ts`) protects everything except `/s/*` (share viewer), `/api/auth/*`, and static assets.
- A signed-in user is available via a `getCurrentUser()` server helper.
### Login UX
- `/login` page with a single "Sign in with SSO" button.
- After login, redirect to `/`.
## Out of scope
- Forward-auth / Outpost wiring for the rest of Matt's stack (separate later task).
- Account self-service in famapp (Authentik owns identity).
- Automating Authentik provider creation via Terraform/blueprints — manual is fine for now; document it well.
## Acceptance criteria
- [ ] Hitting `/` while signed out redirects to `/login`.
- [ ] Signing in with an Authentik account creates/updates a row in `users` and returns to `/`.
- [ ] `getCurrentUser()` works in server components and server actions.
- [ ] `/s/<token>` is reachable signed-out (placeholder is fine — real viewer is task 31).
- [ ] Auth secret comes from `AUTH_SECRET` env, not committed.
## Notes
- Authentik's OIDC issuer URL takes the form `https://auth.ginnoir.com/application/o/<app-slug>/`. Document this in the README so Matt copies it correctly.
- Use Auth.js's database session strategy with the Drizzle adapter so the `users` row is the source of truth.
+29
View File
@@ -0,0 +1,29 @@
# 07 — Household seeding & session
## Goal
On first boot, create the single household and attach Matt + wife to it. Every authenticated request resolves a `householdId` cheaply.
## Depends on
- 06 (auth)
## Scope
- Migration / seed script (`pnpm db:seed`) that inserts one household named "Home" if none exists.
- On first OIDC login, attach the user to the seeded household as `owner` (Matt) or `member` (everyone else). Idempotent.
- `getCurrentSession()` server helper returns `{ user, household, role }`. Throws if no household membership.
- All later modules' queries take `householdId` as the scope key.
- `/settings/household` page showing household name, member list, and a "rename household" form (owner only).
## Out of scope
- Multi-household UI (schema supports it; UI is single-household only).
- Invite flows (Authentik provisions accounts; we just attach them).
## Acceptance criteria
- [ ] `pnpm db:seed` is idempotent — running twice does not create duplicate households.
- [ ] First user to sign in becomes `owner`; subsequent users become `member`.
- [ ] `getCurrentSession()` returns a typed object with `household.id`.
- [ ] `/settings/household` renders for both roles; rename only works for owner.
+57
View File
@@ -0,0 +1,57 @@
# 10 — Calendar module
## Goal
Implement the `calendar` module: shared events with month/week/day views and CRUD.
## Depends on
- 04 (module loader), 07 (household)
## Scope
### Schema (`src/modules/calendar/schema.ts`)
- `calendar_events`:
- `id` uuid pk
- `household_id` fk
- `title` text
- `start_at` timestamptz, `end_at` timestamptz
- `all_day` boolean
- `location` text nullable
- `notes` text nullable
- `color` text nullable
- `owner_id` fk users
- `rrule` text nullable (reserved; no UI)
- `external_source` text nullable, `external_id` text nullable (reserved)
- `created_at`, `updated_at`
### Server (`src/modules/calendar/server/`)
- `listEvents({ from, to })` — household-scoped, range query.
- `createEvent`, `updateEvent`, `deleteEvent` — server actions, validate with Zod.
### UI (`src/modules/calendar/components/`)
- `/calendar` page with month / week / day toggle. Recommended lib: **FullCalendar** (`@fullcalendar/react` + day/week/month plugins) — handles the heavy lifting; we own only the data layer.
- Click a day cell → create-event dialog. Click an event → edit dialog. Drag-resize updates `end_at`.
### Manifest
- Registers entity type `calendar.event` with `share` enabled (read-only by default), `reminder` enabled, `search` enabled (title + notes + location).
- Registers nav entry `/calendar`.
- Dashboard widget: next 3 days (built in task 20, but expose the data fetcher here).
- Quick-add: "New event" → opens create dialog.
## Out of scope
- Recurrence UI (rrule column reserved, ignored on read).
- External calendar sync.
- Free/busy aggregation across users.
## Acceptance criteria
- [ ] CRUD works end-to-end with optimistic updates.
- [ ] All queries scoped to `household_id`; cross-household leakage is impossible (verify with a second seeded household in a test).
- [ ] Manifest registers all four capabilities (entity, nav, widget data, quick-add).
- [ ] One Playwright happy-path test: create → see on calendar → edit → delete.
+48
View File
@@ -0,0 +1,48 @@
# 11 — Lists module (shopping + task)
## Goal
Implement the `lists` module supporting two list types out of the box (`shopping`, `task`), with the schema designed so additional types can be added later without migration.
## Depends on
- 04, 07
## Scope
### Schema
- `lists`: `id`, `household_id`, `type` (text — not an enum, to allow extension), `name`, `archived`, `created_at`.
- `list_items`: `id`, `list_id`, `text`, `done` boolean, `qty` text nullable, `notes` text nullable, `due_at` timestamptz nullable, `assignee_id` fk users nullable, `position` int (for ordering), `created_at`, `updated_at`.
Seed: one default list of each type per household on first access (idempotent).
### Server
- `listLists({ type? })`, `getList(id)`, `createList`, `renameList`, `archiveList`.
- `addItem`, `toggleItem`, `updateItem`, `deleteItem`, `reorderItems`.
### UI
- `/lists` index showing all lists grouped by type.
- `/lists/[id]` — fast keyboard-driven entry: focus stays in input, Enter adds, checkbox toggles, swipe-left (mobile) deletes.
- Realtime: SSE subscription on the list id; updates from the other user appear without refresh. (Implementation detail: Postgres `LISTEN/NOTIFY` channel `list:<id>`; thin SSE route handler bridges it. Document the pattern in `docs/decisions/`.)
### Manifest
- Entity type `lists.list` (shareable read+write via share token), `lists.item` (not directly shareable; inherits via list).
- Quick-adds: "Add to shopping", "Add to tasks" (each adds a single item to the default list of that type).
- Dashboard widget: top 5 unchecked shopping items + open tasks assigned to me.
## Out of scope
- Reordering across lists.
- Per-item images.
- Recurring tasks (use reminders module later if needed).
## Acceptance criteria
- [ ] Both default lists are auto-seeded.
- [ ] Adding an item on one device appears on the other within ~1s via SSE.
- [ ] `type` is `text` not `enum` — adding a third type works without a migration.
- [ ] Playwright: add item → check it off → archive list.
+30
View File
@@ -0,0 +1,30 @@
# 12 — Notes module
## Goal
Lightweight shared notes with optional reminders.
## Depends on
- 04, 07
## Scope
- Schema: `notes` (`id`, `household_id`, `author_id`, `title`, `body` markdown, `pinned` bool, `remind_at` timestamptz nullable, `created_at`, `updated_at`).
- `/notes` index: pinned first, then by updated_at desc.
- `/notes/[id]` editor — minimal markdown (use `@uiw/react-md-editor` or similar; live preview optional).
- `remind_at` writes a row to `reminders` (table comes from `_core`; reminder firing comes from task 41).
- Manifest: shareable, remindable, searchable (title + body).
- Dashboard widget: pinned notes.
## Out of scope
- Rich text / WYSIWYG.
- Attachments (separate later module).
- Collaborative editing.
## Acceptance criteria
- [ ] Create / edit / pin / delete works.
- [ ] Setting `remind_at` creates a `reminders` row scoped to entity `notes.note`.
- [ ] Markdown renders safely (no raw HTML injection).
+33
View File
@@ -0,0 +1,33 @@
# 20 — Dashboard composition
## Goal
The `/` landing page composes widgets contributed by installed modules. No hardcoded widget list.
## Depends on
- 10, 11, 12
## Scope
- `DashboardWidget` type already in `_core` (from task 04). Each widget exports `{ id, priority, render }`.
- `/` reads `getRegistry().dashboardWidgets`, sorts by priority desc, renders them in a responsive grid (1 col mobile, 2-3 cols desktop).
- Widgets implemented:
- **calendar.upcoming** — next 3 days of events
- **lists.shopping** — top 5 unchecked items in default shopping list
- **lists.tasks** — my open tasks (assigned to me, due soon first)
- **notes.pinned** — pinned notes
- **core.activity** — recent activity feed (depends on task 22; until then render a stub)
- Skeleton loaders while widgets load (each widget is a server component with `loading.tsx`-equivalent Suspense boundary).
- "+" floating action button opens the quick-add menu (task 21).
## Out of scope
- User-configurable widget order or hide/show (defer; pin a sane default order).
- Drag-rearrange.
## Acceptance criteria
- [ ] Removing a module from `src/modules/index.ts` cleanly removes its widgets — no errors, no blank slot.
- [ ] Widgets load in parallel (verify in network tab — no waterfall).
- [ ] Mobile layout (375px) is usable without horizontal scroll.
+26
View File
@@ -0,0 +1,26 @@
# 21 — Quick-add registry
## Goal
A single global `+` button on the dashboard (and a `cmd+k` palette anywhere) that lists actions contributed by modules.
## Depends on
- 20
## Scope
- `QuickAddAction` type: `{ id, label, icon?, shortcut?, run: () => void | Promise<void> }`.
- Modules register via manifest. Registry exposes a sorted list.
- UI: floating `+` on dashboard opens a sheet of actions; `cmd/ctrl+k` opens a command-palette-style modal (use `cmdk` package).
- Default actions: New event, Add to shopping, Add to tasks, New note.
## Out of scope
- Fuzzy search across data (search adapter is a later concern).
- Action history / recents.
## Acceptance criteria
- [ ] Adding a new module that registers a quick-add appears in both the FAB sheet and the cmd-k palette without touching core.
- [ ] Keyboard navigation works in the palette (arrows + enter + escape).
+26
View File
@@ -0,0 +1,26 @@
# 22 — Activity log
## Goal
Generic activity feed every module writes to and the dashboard reads from.
## Depends on
- 10, 11, 12
## Scope
- Schema: `activity_log` (`id`, `household_id`, `entity_type`, `entity_id`, `actor_id`, `action`, `payload` jsonb, `created_at`). Index on `(household_id, created_at desc)`.
- `_core` exports `logActivity({ entityType, entityId, action, payload })`. Reads `householdId` and `actorId` from current session.
- Calendar/lists/notes modules call it on create/update/delete.
- Dashboard widget `core.activity` shows last 20 entries with human-readable rendering. Each entity type's manifest provides a `renderActivity(entry)` function.
## Out of scope
- Per-user activity filters.
- Retention / pruning (no growth concerns at two-user scale).
## Acceptance criteria
- [ ] Every CRUD across the three core modules writes a row.
- [ ] Widget renders entries via the registry — no `if (entityType === ...)` branches in the widget itself.
+32
View File
@@ -0,0 +1,32 @@
# 30 — Share-link service
## Goal
A generic service that issues temporary, scoped, revocable share tokens for any registered entity.
## Depends on
- 04 (registry), 07 (household)
## Scope
- Schema: `share_links` (`id`, `household_id`, `entity_type`, `entity_id`, `token` unique, `capabilities` jsonb (e.g. `{ read: true, write: false }`), `created_by`, `expires_at` nullable, `revoked_at` nullable, `created_at`).
- `_core/share.ts`:
- `createShareLink(entityType, entityId, opts)``{ url, token, expiresAt }`.
- `resolveShareToken(token)``{ entityType, entityId, capabilities } | null` (rejects expired/revoked).
- `revokeShareLink(id)`.
- Verifies the entity type is registered and shareable per its manifest's `share` capability declaration.
- Token format: 32-byte URL-safe base64. Stored hashed (sha-256) — only the URL contains the raw token.
## Out of scope
- The viewer page (task 31).
- Rate limiting (task 61).
- Per-recipient access logs.
## Acceptance criteria
- [ ] Creating a link for an entity type with `share: undefined` throws.
- [ ] Tokens are stored hashed; raw token only returned at creation.
- [ ] Expired/revoked tokens return null.
- [ ] `/settings` shows active share links per entity with revoke buttons.
+27
View File
@@ -0,0 +1,27 @@
# 31 — Public share viewer (`/s/<token>`)
## Goal
Unauthenticated route that resolves a share token and renders the entity using its manifest's `loadForShare` adapter.
## Depends on
- 30
## Scope
- `/s/[token]/page.tsx` — server component. Calls `resolveShareToken`. If valid, calls the entity's `loadForShare(id)` and renders a per-type viewer component (each module exports a `<SharedView />`).
- If the token grants write capability, render an editable view (initially only relevant for lists — recipient can check items off).
- Friendly error page for invalid/expired/revoked tokens.
- Add `noindex` headers on the route.
## Out of scope
- Auth on the share page (deliberately public; security is the unguessable token).
- Comments / reactions from anonymous viewers.
## Acceptance criteria
- [ ] Calendar event share renders title/time/location/notes — read-only.
- [ ] Shopping list share with `write: true` lets a recipient toggle items; toggles round-trip back into the household (with `actor_id = null` in activity log, action `share.toggle`).
- [ ] Middleware does not gate `/s/*`.
+28
View File
@@ -0,0 +1,28 @@
# 40 — Web Push (VAPID)
## Goal
Subscribe browsers to web push and deliver notifications to them.
## Depends on
- 06 (auth), 50 (PWA shell — needed for iOS to allow push). If 50 isn't done, this task can land but iOS won't fire until then.
## Scope
- `pnpm add web-push` for server, use built-in `PushManager` on client.
- VAPID keys generated once (script: `pnpm vapid:generate`), stored in `.env` as `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY`. Document in README.
- Schema: `push_subscriptions` (`id`, `user_id`, `endpoint` unique, `p256dh`, `auth`, `user_agent`, `created_at`).
- Service worker registers on app load; on user opt-in (button in `/settings`), prompts permission and stores subscription via server action.
- Server helper `sendPush(userId, { title, body, url })` iterates subscriptions, removes any returning 404/410.
## Out of scope
- Notification preferences per category (defer).
- ntfy fallback (task 42).
## Acceptance criteria
- [ ] Opt-in flow works in Chrome desktop and mobile, plus iOS Safari (when PWA installed).
- [ ] Stale subscriptions are pruned on send failure.
- [ ] Sending a test notification from `/settings` reaches all of the current user's subscribed devices.
+28
View File
@@ -0,0 +1,28 @@
# 41 — Reminders engine
## Goal
A generic, entity-agnostic reminder system that fires at `fire_at` and routes through the notification bus.
## Depends on
- 40 (push), 42 (bus)
## Scope
- Schema: `reminders` (`id`, `household_id`, `entity_type`, `entity_id`, `fire_at` timestamptz, `channel` text default `'auto'`, `fired_at` timestamptz nullable, `created_by`).
- Worker: a long-running tick (every 30s) that selects due un-fired reminders, calls `notify(...)`, marks `fired_at`. Runs in the same Node process via `setInterval` guarded by a Postgres advisory lock to allow future horizontal scaling.
- `_core/reminders.ts` exposes `scheduleReminder(...)`, `cancelReminder(id)`, `listReminders(entityType, entityId)`.
- Notes module's `remind_at` field calls `scheduleReminder` on save and `cancelReminder` when cleared.
- Calendar module: optional "remind me" on event create (default 30 min before).
## Out of scope
- Recurring reminders (use rrule later).
- Snooze.
## Acceptance criteria
- [ ] A note with `remind_at = now + 1min` fires within 90s and triggers a push notification.
- [ ] Editing the note's `remind_at` updates the existing reminder, not creates duplicates.
- [ ] Worker survives DB blips (caught + logged, not crashed).
+28
View File
@@ -0,0 +1,28 @@
# 42 — Notification bus + ntfy adapter
## Goal
Single `notify()` entrypoint that fans out to web push, in-app inbox, and (optionally) ntfy.
## Depends on
- 40
## Scope
- `_core/notify.ts``notify(userId, { title, body, url, channels? })`.
- Channels: `push`, `inapp`, `ntfy`. Default: `['push', 'inapp']`.
- In-app inbox: `notifications` table (`id`, `user_id`, `title`, `body`, `url`, `read_at`, `created_at`); bell icon in header with unread count and dropdown.
- ntfy adapter: if `NTFY_URL` and `NTFY_TOPIC` env are set, also POSTs there. Off by default; Matt opts in.
- Per-user toggles in `/settings` for each channel.
## Out of scope
- Email notifications.
- Per-event-type routing rules (Matt can add later as a module).
## Acceptance criteria
- [ ] `notify()` with default channels delivers push + in-app and ignores ntfy.
- [ ] Setting `NTFY_URL`+`NTFY_TOPIC` and toggling on in settings adds ntfy delivery.
- [ ] Unread count is accurate and clears on read.
+28
View File
@@ -0,0 +1,28 @@
# 50 — Manifest, icons, install prompt
## Goal
Make famapp installable as a PWA on iOS and Android.
## Depends on
- 02
## Scope
- `public/manifest.webmanifest` with name, short_name, theme_color, background_color, display: `standalone`, start_url `/`.
- App icons (192, 384, 512, maskable). Source SVG committed; export script in `scripts/`.
- `apple-touch-icon` link in root layout.
- Service worker registration (next-pwa or hand-rolled — prefer hand-rolled to avoid coupling to a library that lags Next.js releases).
- Install prompt: a small banner on first visit on supported browsers, dismissible.
## Out of scope
- Offline caching (task 51).
- Background sync.
## Acceptance criteria
- [ ] Lighthouse "Installable" check passes.
- [ ] Installing on iOS 16.4+ home screen produces a standalone window with no Safari chrome.
- [ ] App icon shows correctly on Android home screen with maskable variant.
+28
View File
@@ -0,0 +1,28 @@
# 51 — Offline shell + service worker caching
## Goal
The app shell loads without network; cached data is shown with a "stale" indicator while fresh data loads.
## Depends on
- 50
## Scope
- Service worker uses Workbox-style strategies (hand-rolled, see task 50 rationale):
- **App shell** (HTML, JS, CSS): stale-while-revalidate.
- **API GETs**: network-first with 2s timeout, fall back to cache.
- **Mutations**: never cache; if offline, show a clear "you're offline" toast.
- Offline page for navigations that have no cache.
- Version SW on every build; show a "new version available, refresh" toast when a new SW takes control.
## Out of scope
- Background sync of queued mutations (defer; usually more trouble than worth at this scale).
## Acceptance criteria
- [ ] In Chrome DevTools "Offline", reloading the app shows the cached dashboard with a stale-indicator.
- [ ] Attempting to mutate while offline shows a clear error, doesn't silently drop.
- [ ] Deploying a new build prompts active sessions to refresh.
+27
View File
@@ -0,0 +1,27 @@
# 60 — Postgres backups (pg_dump cron)
## Goal
Nightly compressed pg_dump of `famapp-db` (and `authentik-db`) to a host volume, with a retention policy.
## Depends on
- 05
## Scope
- `deploy/backups/` directory committed.
- Add a `famapp-backup` service to compose: small Alpine container with `postgresql-client` + `cron` + a script that runs `pg_dump -Fc` against both DBs into `/backups/<db>/<YYYY-MM-DD>.dump`.
- Retention: keep last 14 daily, last 8 weekly, last 6 monthly. Pure shell, no fancy tooling.
- Document restore procedure in `deploy/backups/README.md`.
## Out of scope
- Off-site replication (Matt's call; he can rsync `/backups` elsewhere).
- Encryption at rest (handled at the disk layer).
## Acceptance criteria
- [ ] After a day, dump files exist for both DBs.
- [ ] `restore.sh <dump-file>` script exists and works against a fresh DB.
- [ ] Retention script keeps the right counts.
+24
View File
@@ -0,0 +1,24 @@
# 61 — Rate limiting on share links
## Goal
Prevent token brute-forcing on `/s/<token>`.
## Depends on
- 31
## Scope
- IP + token-prefix bucket. Reject after N failed lookups per minute per IP. In-memory LRU is fine for one Node process; document switch to Redis if multi-process arrives.
- Failed `resolveShareToken` calls increment the bucket; successful resolves do not.
- Generic `lib/rate-limit.ts` so other endpoints can use the same primitive.
## Out of scope
- Captcha.
## Acceptance criteria
- [ ] 50 bad tokens from one IP in a minute returns 429 thereafter.
- [ ] Legitimate access from another IP unaffected.
+27
View File
@@ -0,0 +1,27 @@
# 62 — Structured logging
## Goal
JSON logs for the app, easy to grep and ship later.
## Depends on
- 02
## Scope
- `pino` for app logs; pretty-print only in dev.
- Log levels via `LOG_LEVEL` env (default `info`).
- Request log middleware (method, path, status, duration, userId if present).
- Errors: capture stack + cause, never log secrets.
## Out of scope
- Log shipping (Loki/etc).
- Sentry/error tracking.
## Acceptance criteria
- [ ] Production logs are single-line JSON.
- [ ] No secret values appear anywhere in the log stream during a typical session.
- [ ] Request logs include duration and userId when authenticated.
+68
View File
@@ -0,0 +1,68 @@
# Task briefs
Each file here is a self-contained brief for a sub-session (typically Sonnet) to execute one chunk of work. Briefs assume the executor has read [`/CLAUDE.md`](../../CLAUDE.md) — do not restate the stack or architecture there.
## How to use a brief
1. Read `/CLAUDE.md` first.
2. Read the task file end-to-end.
3. Stop when **all** acceptance criteria pass. Do not bolt on extra scope.
4. If a brief turns out to be wrong, update the brief in the same commit as the code.
## Brief format
Every task file has these sections:
- **Goal** — one sentence.
- **Why** — what this unlocks; how it fits the larger plan.
- **Depends on** — task numbers that must be done first.
- **Scope** — bullet list of what to build.
- **Out of scope** — explicit non-goals to prevent drift.
- **Acceptance criteria** — checkable list. Done = all checked.
- **Notes** — gotchas, recommended libs, sketches.
## Phase index
### Phase 1 — Scaffold
- [01 — Repo init & tooling](01-repo-init.md)
- [02 — Next.js app skeleton](02-nextjs-skeleton.md)
- [03 — Drizzle + Postgres setup](03-drizzle-postgres.md)
- [04 — Module loader & registry](04-module-loader.md)
- [05 — Compose stack & Caddy](05-compose-caddy.md)
- [06 — Authentik install + OIDC integration](06-authentik-oidc.md)
- [07 — Household seeding & session](07-household-seed.md)
### Phase 2 — Core modules
- [10 — Calendar module](10-calendar-module.md)
- [11 — Lists module (shopping + task)](11-lists-module.md)
- [12 — Notes module](12-notes-module.md)
### Phase 3 — Dashboard & UX
- [20 — Dashboard composition](20-dashboard.md)
- [21 — Quick-add registry](21-quick-add.md)
- [22 — Activity log](22-activity-log.md)
### Phase 4 — Sharing
- [30 — Share-link service](30-share-links.md)
- [31 — Public share viewer (`/s/<token>`)](31-share-viewer.md)
### Phase 5 — Notifications & reminders
- [40 — Web Push (VAPID)](40-web-push.md)
- [41 — Reminders engine](41-reminders.md)
- [42 — Notification bus + ntfy adapter](42-notification-bus.md)
### Phase 6 — PWA polish
- [50 — Manifest, icons, install prompt](50-pwa-shell.md)
- [51 — Offline shell + service worker caching](51-offline.md)
### Phase 7 — Hardening
- [60 — Postgres backups (pg_dump cron)](60-backups.md)
- [61 — Rate limiting on share links](61-rate-limit.md)
- [62 — Structured logging](62-logging.md)