## Project Configuration - **Language**: TypeScript - **Package Manager**: pnpm - **Add-ons**: prettier, tailwindcss, sveltekit-adapter, experimental --- # FamChore v2 — AI Agent Reference ## Stack - SvelteKit monolith (SSR frontend + all services, internal :2080; nginx in prod container :3001). The Hono proxy was deleted — everything lives in SvelteKit server routes/services. - PocketBase (separate Coolify service at `pb.chores.app.com`, :8090) - Stripe payments (subscriptions) — implemented in **SvelteKit server routes** (public `/pricing` + inline signup checkout via `PricingPlans`, billing portal from settings Billing section, `/api/webhooks/stripe`). Dev webhook listener: `pnpm stripe:listen` (root script). Embedded Checkout needs a secure context (HTTPS/localhost) — over Tailscale/LAN HTTP use `ssh -L 2080:localhost:2080`. - Access gating — `fams.paymentMode` (`none|code|sub|canceled`) + `fams.active`; codes in superuser-only `accesscodes` (seeded `dev123`). Core logic in `frontend/src/lib/server/access.ts`, exposed as `data.famAccess` from `[fam]/+layout.server.ts`; disabled fams get a blurred overlay + locked member kanban (frontend-only); `/settings` stays unlocked so admins can apply a code. - 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`) - `accesscodes` — value (unique), name, duration, expiry, active, createdAt (superuser-only; platform access codes) - `platform` — label (`global` singleton), flags (json) — platform feature flags; **public read** (empty list/view rules), superuser-only writes. Loaded on every page via root `+layout.server.ts` as `page.data.platformFlags`; toggle via `/admin` Platform Flags card. The `debug` flag gates dev-only CTAs (e.g. settings "Revoke code"). - `fams` — name, slug, stripeCustomerId, paymentMode (`none|code|sub|canceled`), active, accessCodeId, accessCodeEnteredAt - `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 (wizard: fam → child → code → plan) /{fam}/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 /pricing 3-tier public plan page (trial | monthly | yearly), entry via settings or logged-out /api/webhooks/stripe Stripe webhook handler (server route) /api/* SvelteKit API endpoints (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 → SvelteKit `/api/completions/toggle` → PB (session cookie auth) - **Admin CRUD:** Form actions / `/api/admin/*` endpoints → PB via services (`servicesFor(event)`); PB collection rules are the security boundary - **Member updates:** Browser → SvelteKit `/api/*` routes → PB - **Reward creation:** After completion toggle, service layer 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 — `/pricing` (public plan picker; logged-in users checkout inline) + settings Billing section (billing portal) + `/api/webhooks/stripe`. **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 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 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 ` | | 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 ``` - **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: `