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:
@@ -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)
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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/*`.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user