Files
famdone/AGENTS.md
T
2026-08-17 08:55:27 +01:00

15 KiB

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 one-time donations — not implemented (only settings.webhookUrl exists)
  • 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)
/api/*               Hono proxy (data layer; webhooks/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 / WhatsApp: not implemented — only the settings.webhookUrl field exists.

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.
  • 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 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