Files
famdone/AGENTS.md
T
2026-07-31 16:13:05 +01:00

13 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
  • Coolify CRON → GET /api/weekly-cron
  • Deployment: Coolify, Cloudflare DNS

Auth

Role Auth Session Record in
Admin (parent) PB email+pass 24hr JWT fam_admins
Member (child) Invite code + device token device_token cookie only members
  • Admins (parents) have a PB auth record + fam_admins record. They authenticate via email/password login, get a session cookie (session) with { famId, userId, famSlug, memberName, role: "parent" }.
  • Members (children) exist only in the members collection. They authenticate via invite code + device token (SHA-256 hashed). The device_token cookie is set on join; no session cookie.
  • Platform superuser (_superusers) used only server-side by pb-admin.ts for cross-family queries (e.g. /admin stats dashboard). Not an app role.
  • The layout ([fam]/+layout.server.ts) derives isParent and role centrally from the session cookie — child pages use page.data.isParent or page.data.role from $app/state.

PB Collections (all scoped by famId)

  • fams — name, slug, inviteCode, stripeCustomerId, featureFlags
  • members — famId, name, color, deviceToken(hashed), deviceTokenHint
  • chore_templates — famId, name, defaultValue, defaultFrequency
  • assigned_chores — famId, memberId, templateId, frequency, value
  • completions — famId, memberId, assignedChoreId, date
  • weekly_history — famId, memberId, weekStart, pointsEarned, moneyEarned
  • rewards — famId, memberId, source, label, value, claimed, claimedAt
  • monthly_bonuses — famId, month, prizeType, prizeValue, winnerMemberId
  • settings — famId, pointsThreshold, weeklyBonus, webhookUrl

Routes

/                    Landing (SaaS marketing)
/admin               Admin panel - statistic dashboard, and any donations made
/join/:code          Member invite code
/join/:code/:member  Member invite with pre-selected member
/{fam}               Fam dashboard
/{fam}/admin         Admin panel
/{fam}/:username     Member kanban & admin dashboard (role determined by session)
/{fam}/:username/preferences  User preferences (admin→fam_admins, member→members)
/{fam}/:username/settings     Family admin settings (session required)
/{fam}/:username/chores       Chore templates & assignment grid
/{fam}/:username/rewards      Rewards overview
/{fam}/:username/bonuses      Bonus configs & evaluation
/api/*               Hono proxy (webhooks, CRON)

Data Flow

Reads (both roles)

  • Parent (admin): famStore.init() fetches all collections via PB SDK (authenticated via pb_token cookie).
  • Child (member): famStore.init() fetches all collections via PB SDK — unauthenticated/anonymous. All family-scoped collections have public listRule / viewRule (empty string = allow all), so reads work without any auth. PB SDK .subscribe() also works anonymously for public collections.
  • TopNav season pills: Read from famStore.seasons — reactive, no extra fetches needed.

Writes (both roles go through Hono proxy)

  • Chore toggle: Browser → Hono proxy → PB (auth via device token or admin JWT)
  • Admin CRUD: Form actions → Hono proxy → PB (admin JWT via sessionHeaders)
  • Member updates: Browser → Hono proxy → PB (auth via x-device-token + x-device-famid)
  • Reward creation: After completion toggle, Hono proxy creates reward if threshold met
  • Weekly CRON: Coolify → GET /api/weekly-cron on Hono → Hono queries PB, computes summaries, upserts weekly_history
  • Stripe donate: Browser → Hono /api/stripe/create-checkout → Stripe → Hono webhook → update fam
  • WhatsApp: Deferred — Hono CRON handler has pluggable notification interface

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) x-device-token header
Form action Admin Low (CRUD) High (settings, members) No — form is server-side, wait for round trip httpOnly session 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)
  • deviceToken stored as SHA-256 hash; never log raw tokens
  • 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
  • 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)
  • 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