Files
famdone/AGENTS.md
T
2026-08-20 07:35:31 +01:00

265 lines
16 KiB
Markdown

## Project Configuration
- **Language**: TypeScript
- **Package Manager**: pnpm
- **Add-ons**: prettier, tailwindcss, sveltekit-adapter, experimental
---
# FamChore v2 — AI Agent Reference
## Stack
- SvelteKit (SSR frontend, internal :2080) + Hono proxy (internal :3456) + nginx (container :3001)
- PocketBase (separate Coolify service at `pb.chores.app.com`, :8090)
- Stripe payments (subscriptions) — **planned in SvelteKit server routes** (`/account` + `/account/webhook`), NOT the Hono proxy. Not yet implemented (only `settings.webhookUrl` + `fams.stripeCustomerId` exist).
- Coolify CRON → `GET /api/weekly-cron` — **not implemented** (weekly settlement is manual via `complete-week`/`simulateEow`)
- Deployment: Coolify, Cloudflare DNS
## Auth
| Role | Auth | Session | Record in |
| -------------- | --------------------------------------------- | -------------------------- | ------------------------- |
| Admin (parent) | PB email+pass | 24hr JWT `pb_token` cookie | `users` (role `parent`) |
| Member (child) | Invite OTP + server-derived password | httpOnly `pb_token` cookie | `users` (role `child`) |
| Superuser | PB `_superusers` (server-side only, `pb-admin`) | — | — |
- **Admins** (parents) are `users` records (role `parent`). They authenticate via email/password login, get an httpOnly `pb_token` cookie with `{ id, name, username, role: "parent", famId, color }`.
- **Members** (children) are `users` records (role `child`); PB `username` = `{famSlug}:{handle}` (globally-unique auth identity; `handle` = whitespace-free lowercase name), URL segment = `handleOf(username)`, `name` = display name. Their PB password is **derived** server-side (`MEMBER_SECRET + famSlug + handle`); access is gated by a 20-min OTP in `otp`, then `authWithPassword`. They get the same httpOnly `pb_token` cookie. There is **no `members` collection**.
- **Platform superuser** (`_superusers`) used only server-side by `pb-admin.ts` for cross-family queries (e.g. `/admin` stats dashboard) and OTP/signup writes. Not an app role.
- The layout (`[fam]/+layout.server.ts`) derives `isParent` and `role` centrally from the session — child pages use `page.data.isParent` or `page.data.role` from `$app/state`.
- Because `pb_token` is httpOnly, the browser PB SDK is seeded from `page.data.pbToken` via `initPb(token)` in the layout `onMount` (not `document.cookie`).
## PB Collections (all scoped by `famId`; child/member = `users` row)
- `users` — auth collection; famId, role (`parent`|`child`), username (`{famSlug}:{handle}`), name, color, email (admin only)
- `otp` — famId, userId, otp, updatedAt (OTP gate for child join; display colour lives on `users.color`)
- `fams` — name, slug, stripeCustomerId, featureFlags
- `chore_templates` — famId, name, defaultValue, defaultFrequency
- `assigned_chores` — famId, userId, templateId, frequency, value
- `completions` — famId, userId, assignedChoreId, date
- `weekly_history` — famId, userId, weekStart, pointsEarned, moneyEarned
- `rewards` — famId, userId, source, label, value, claimed, claimedAt, claimable, settleDate
- `monthly_bonuses` — famId, month, prizeType, prizeValue, winnerUserId
- `settings` — famId, pointsThreshold, weeklyBonus, webhookUrl
> **Schema/migrations:** `shared/pb/schema.ts` (`SCHEMA_PLAN`) is the single source of truth for base collections. `frontend/src/lib/server/migrate.ts` only **bootstraps** a fresh/wiped PB (idempotent, skips if `fams` exists) — it has no incremental history. The native `users` auth fields/rules and the superuser-only `otp` collection are applied in `migrate.ts` (`ensureUsers`/`ensureOtp`), not `SCHEMA_PLAN`. Data is disposable (app not live), so a schema change = update `SCHEMA_PLAN` + wipe PB + reboot.
## Routes
```
/ Landing (SaaS marketing)
/admin Platform super-admin stats dashboard (and any donations)
/login · /logout Parent email/password login / logout
/signup Parent + family signup
/{famSlug}/join/{username} Member invite (OTP join), auto-fills from ?code=
/{fam} Fam dashboard
/{fam}/{username} Parent → admin overview, Child → member kanban (role from session)
/{fam}/{username}/chores Chore templates & assignment grid
/{fam}/{username}/ledger Rewards / chores / todos ledger
/{fam}/{username}/bonuses Bonus configs & evaluation
/{fam}/{username}/preferences User preferences (parent→users, member→users)
/{fam}/{username}/settings Family admin settings (parent only) — includes Stripe connect/manage + pause
/account Account/billing — payment setup & subscription management (Stripe)
/subscriptions 3-tier plan page (trial | monthly | yearly), access via settings
/account/webhook Stripe webhook handler (server route)
/api/* Hono proxy (data layer; CRON not implemented)
```
## Data Flow
### Reads (both roles)
- **Parent (admin):** `famStore.init()` fetches all collections via PB SDK (authenticated via the `pb_token` cookie / seeded `initPb(token)`).
- **Child (member):** `famStore.init()` fetches all collections via PB SDK — authenticated via their `pb_token` (role `child`). Family-scoped collections have public `listRule` / `viewRule`, so reads work regardless; `.subscribe()` works for both roles.
- **TopNav season pills:** Read from `famStore.seasons` — reactive, no extra fetches needed.
### Writes
- **Chore toggle:** Browser → Hono proxy → PB (member auth via `Authorization: Bearer <pb_token>`)
- **Admin CRUD:** Form actions / `hono.admin.*` → Hono proxy → PB (admin JWT via `sessionHeaders`)
- **Member updates:** Browser → Hono proxy → PB (auth via `Bearer <pb_token>`)
- **Reward creation:** After completion toggle, Hono proxy creates reward if threshold met
- **Weekly settlement:** NOT via CRON — manual `complete-week` action or `simulateEow` preview in settings. `/api/weekly-cron` (Coolify) is not implemented.
- **Stripe:** implemented in SvelteKit server routes — `/account` (setup/manage subscription) + `/account/webhook`. Lives in the frontend app, NOT the Hono proxy. **WhatsApp:** not implemented.
### UI reactivity
- Svelte `$state` / `$derived` / `$effect`
- PB SDK `.subscribe()` for realtime multi-user sync (public reads → works for both roles)
- `famStore.applyRecord()` for instant optimistic UI feedback from form actions
## Env Vars (SvelteKit 3.0.0-next.4)
Env vars must be declared in `frontend/src/env.ts` using `defineEnvVars` from `@sveltejs/kit/hooks`:
```ts
import { defineEnvVars } from "@sveltejs/kit/hooks";
export const variables = defineEnvVars({
DEBUG_RECORD_ID: {},
SERVER_IP: { public: true },
PB_PORT: { public: true },
});
```
Only vars with `{public: true}` are exposed to client-side code via `$app/env/public`. If you need a new client-side env var (e.g., `PUBLIC_PB_URL`), you must:
1. Add it to `.env` with `PUBLIC_` prefix
2. Add it to `frontend/src/env.ts` with `{public: true}`
The actual values come from `.env` (symlinked from project root at `frontend/.env -> ../.env`). Server-only env vars (no `{public}` flag) are available via `$app/env/private` but only in server modules.
## ⚠️ CRITICAL: Reactivity Pattern (NEVER use `$effect` to sync from famStore)
**Do NOT do this:**
```svelte
<!-- ❌ WRONG: $effect syncing local state from famStore fights Svelte reactivity -->
let items = $state(data.items);
$effect(() => {
if (data.items?.length) items = data.items;
if (famStore.initialized && famStore.items.length) items = famStore.items;
});
```
**Do this instead:**
```svelte
<!-- ✅ RIGHT: Initialize from famStore, mutate via applyRecord in callbacks -->
let items = $state(famStore.initialized ? famStore.items : (data.items || []));
```
### ⚠️ CRITICAL: `$state` vs `$derived` for famStore collections
**When a page reads data that should update via SSE (realtime from other users/actions), you MUST use `$derived` — NOT `$state`.**
This is the most commonly missed rule. Agents repeatedly initialize collections from `famStore` with `$state`, which captures a **frozen snapshot** at init time. When PocketBase pushes a record via SSE → `famStore.applyRecord()`, the `$state` variable never reacts — the UI stays stale until a page refresh.
**Rule: if the data should update without a refresh, use `$derived`.**
```svelte
// ❌ WRONG — frozen snapshot, won't react to SSE updates
let configs = $state(famStore.initialized ? famStore.bonusConfigs : data.configs);
// ✅ RIGHT — reactive, updates when famStore changes via SSE
let configs = $derived(
famStore.initialized ? famStore.bonusConfigs : (data.configs || [])
);
```
**The only exception:** high-frequency member actions (like chore toggles) that need **optimistic UI** before the server responds. Those use `$state` + `famStore.applyRecord()` for instant feedback, then reconcile on the server response. See `applyRecord` pattern below.
**All other pages (admin CRUD, bonuses, rewards, settings, etc.) must use `$derived`** so that SSE updates flow through `famStore` → `$derived` → UI automatically.
## Update Patterns
Two patterns based on who's acting:
| Pattern | Who | Frequency | Sensitivity | Optimistic? | Auth |
| ---------------------------- | ------ | -------------------- | ------------------------ | --------------------------------------------- | ------------------------- |
| Direct `fetch` + `memberApi` | Member | High (chore toggles) | None | Yes (instant UI, reconcile on response) | `Authorization: Bearer <pb_token>` |
| Form action | Admin | Low (CRUD) | High (settings, members) | No — form is server-side, wait for round trip | httpOnly `pb_token` cookie |
**Member direct fetch** — optimistic UI via local state mutation, reconciled on response:
```svelte
let completions = $state(data.completions)
async function toggle(chore) {
// optimistic update
completions = [...completions, { id: 'optimistic-...', ... }]
try {
await memberApi.toggleCompletion(token, famId, chore.id, date)
// reconcile — remove optimistic, keep server truth
} catch { /* revert */ }
}
```
**Admin form actions** — no optimistic `applyRecord` needed in `use:enhance` callbacks. The form action is a server round trip, and PB SSE pushes the change back through `famStore.handleRealtime()` within milliseconds. The famStore + SSE subscription is the single source of truth for cross-user sync. Do NOT add `$effect` watchers to bridge the gap between form actions and reactive state.
## UI Component Conventions
All admin and member pages use the following pattern:
```svelte
<ViewHeader title="..." subtitle="..." tabs={...} weeknav={...} sort={...} />
<CardGrid>
<Card {cols} title="..." accent="...">
<!-- card content → micro-layout per page -->
</Card>
</CardGrid>
```
- **Layout shell** lives in `[fam]/+layout.svelte` — Sidebar, TopNav, Footer. All fam-scoped routes inherit it.
- **Sidebar** role-aware: shows admin CTAs on `/admin/*`, member CTAs on `/[username]/*`.
- **TopNav** has `announcement` (center slot) and `actions` (right slot).
- **`Card` `cols` prop**: `1 | 2 | 3` — spans that many columns in the 3-column `CardGrid`.
- **`Button`** for all CTAs: `<Button variant="primary|secondary|ghost|danger" size="sm|md|lg">`.
- **`Accordion`** for expand/collapse sections (settings, logs).
- **Icons**: defined as SVG strings in `lib/components/icons.ts`. No icon library dependency.
- **Components** live in `frontend/src/lib/components/` and are re-exported from `index.ts`.
## Conventions
- Every collection query includes `famId = @request.auth.famId` filter
- Super admin bypasses famId filter (access via PB admin API)
- Child PB passwords are derived (`MEMBER_SECRET + famSlug + username`); the child join gate is a transient OTP in `otp`. No device tokens. Never log raw tokens/secrets.
- **Admin → Proxy**: `hono.admin.*` in `$lib/server/hono.ts` — uses `sessionHeaders(event)` (server-side only, requires `RequestEvent`)
- **Member → Proxy (server)**: `memberApi.*` in `$lib/client/api.ts` — use inside `+page.server.ts` load/actions; `BASE_URL` resolves to Hono port on server
- **Member → Proxy (browser)**: `memberApi.*` in `$lib/client/api.ts` — use inside `+page.svelte`; `BASE_URL` is empty, Vite proxies `/api/*` to Hono
- **`$page`**: import `{ page }` from `$app/state` (NOT `$app/stores` — that's the old Svelte 4 API). Reference as `page.params.fam`, `page.url.pathname` etc. without `$` prefix
- **Dates**: all user-facing dates are DDMMYY (compact, e.g. `040826` for 4 Aug 2026). Use the shared `formatDDMMYY()` helper in `frontend/src/lib/format.ts`. Never render raw `YYYY-MM-DD` to users. Exception: single human-readable dates like todo **due dates** should use `formatShortDate()` (also in `format.ts`, renders `5 Aug` / `5 Aug 26`) — the compact DDMMYY code is ambiguous and bad UI for those.
- `config.ts` at root for dev/build-time shared config (e.g. `PROXY_PORT`); runtime config via env vars
- `.env` at root tracks port values (`PROXY_PORT`, `PORT`); `.env.example` committed as template
- Docker: `docker/Dockerfile` (prod, multi-stage + nginx) + `docker/Dockerfile.dev` (PocketBase)
- Nginx routes in prod: `/api/*` → Hono (`:3456`), `/*` → SvelteKit (`:2080`)
- Ports: frontend `2080`, proxy `3456`, container ext `3001` (port `3000` is reserved)
- **Dev servers: NEVER start your own.** Always reuse the running dev servers — proxy `192.168.1.225:3456` (tsx watch, reloads on edit), frontend `localhost:2080` (vite HMR). Don't spawn `nohup pnpm dev` / `tsx watch` / extra vite instances. Only restart when the user explicitly asks.
- Environment: `FRONTEND_PORT`, `PROXY_PORT`, `PB_PORT`, `PB_EMAIL`, `PB_PASSWORD`, `DEBUG_RECORD_ID`, `STRIPE_SECRET_KEY`, `DONATION_MODAL_INTERVAL`
- Seed via JSON dump (portable for dev)
- Monorepo: SvelteKit in `frontend/`, Hono in `proxy/`, two Dockerfiles
- Decisions tracked in `MEMORY.md`
## Build Phases (must validate each before next)
### Phase 1 — Infrastructure
~~1.1 Scaffold SvelteKit + Hono monorepo~~
~~1.2 Write Dockerfiles (frontend + backend, correct port mapping)~~
~~1.3 Sort out vars (`.env` + `.env.example`)~~
~~1.5 Validate Hono `/api/*` reachable, env vars injected~~
~~1.6 Validate SvelteKit↔PB connectivity (admin API read/write)~~
### Phase 2 — Backend Core
2.1 Create PB collections via schema/migration
2.2 Super admin seed + fam signup flow
2.3 Fam admin login (email/pass → 24hr JWT)
2.4 Invite code generation + member join flow
2.5 Device token auth + route guards
2.6 Svelte reactive state management (no PB SSE)
### Phase 3 — Backend Data Streams
3.1 Chore template CRUD + assignment grid (admin)
3.2 Completion toggle (member → PB direct)
3.3 Weekly progress + history computation
3.4 Reward auto-creation on threshold
3.5 Reward claim flow + admin CRUD
3.6 Monthly bonus evaluation
3.7 CRON handler (Coolify → Hono)
3.8 Stripe checkout + webhook (SvelteKit `/account` server routes, not Hono)
3.9 Notification interface (WhatsApp deferred)
### Phase 4 — Frontend App
4.1 Member kanban (3-column, live SSE updates)
4.2 Admin dashboard (weekly overview, chart)
4.3 Admin panel (members, chores, rewards, settings)
4.4 Landing page (SaaS marketing)
4.5 Super admin stats dashboard
4.6 Donation modal
4.7 QR invite code
4.8 Polish (loading, empty, error states, responsive)
### Phase 5 deployment of production