Files
famdone/AGENTS.md
T
2026-09-12 08:45:13 +01:00

17 KiB

Project Configuration

  • Language: TypeScript
  • Package Manager: pnpm
  • Add-ons: prettier, tailwindcss, sveltekit-adapter, experimental

FamDone 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 Shared-device: pb_token_<userId> + pb_active 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. Shared-computer sessions: children keep ONE httpOnly cookie per account (pb_token_<userId>, set at join) + a pb_active cookie naming the current session; a 3-digit PIN selects among those device sessions (see shared-device.md) — it is NOT a login and mints nothing on a fresh device. Parents stay on the single pb_token. Resolution order in hooks: pb_active → pb_token. 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)
  • pins — famId, userId, pin (superuser-only shared-device PINs, plaintext so parents can read them out; all access via server endpoints)
  • 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/pins collections are applied in migrate.ts (ensureUsers/ensureOtp/ensurePins), 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}/switch          Shared-device profile picker (standalone landing when child sessions exist but none active)
/{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:

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:

<!-- ❌ 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:

<!-- ✅ 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.

// ❌ 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:

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:

<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.
  • Server data access: servicesFor(event) / createServices(pb) in $lib/server/services/; superuser ops via pbAdmin facade ($lib/server/pocketbase.ts)
  • Browser data access: fetch to same-origin /api/* SvelteKit endpoints; httpOnly pb_token cookie is the auth
  • $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: /* → SvelteKit (:2080), /pb/* → PocketBase
  • 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/ (+ root shared/), single app Dockerfile + PB Dockerfile.dev
  • 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 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