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_adminsrecord. They authenticate via email/password login, get a session cookie (session) with{ famId, userId, famSlug, memberName, role: "parent" }. - Members (children) exist only in the
memberscollection. They authenticate via invite code + device token (SHA-256 hashed). Thedevice_tokencookie is set on join; no session cookie. - Platform superuser (
_superusers) used only server-side bypb-admin.tsfor cross-family queries (e.g./adminstats dashboard). Not an app role. - The layout (
[fam]/+layout.server.ts) derivesisParentandrolecentrally from the session cookie — child pages usepage.data.isParentorpage.data.rolefrom$app/state.
PB Collections (all scoped by famId)
fams— name, slug, inviteCode, stripeCustomerId, featureFlagsmembers— famId, name, color, deviceToken(hashed), deviceTokenHintchore_templates— famId, name, defaultValue, defaultFrequencyassigned_chores— famId, memberId, templateId, frequency, valuecompletions— famId, memberId, assignedChoreId, dateweekly_history— famId, memberId, weekStart, pointsEarned, moneyEarnedrewards— famId, memberId, source, label, value, claimed, claimedAtmonthly_bonuses— famId, month, prizeType, prizeValue, winnerMemberIdsettings— 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 viapb_tokencookie). - Child (member):
famStore.init()fetches all collections via PB SDK — unauthenticated/anonymous. All family-scoped collections have publiclistRule/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-cronon 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:
- Add it to
.envwithPUBLIC_prefix - Add it to
frontend/src/env.tswith{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) andactions(right slot). Cardcolsprop:1 | 2 | 3— spans that many columns in the 3-columnCardGrid.Buttonfor all CTAs:<Button variant="primary|secondary|ghost|danger" size="sm|md|lg">.Accordionfor 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 fromindex.ts.
Conventions
- Every collection query includes
famId = @request.auth.famIdfilter - Super admin bypasses famId filter (access via PB admin API)
deviceTokenstored as SHA-256 hash; never log raw tokens- Admin → Proxy:
hono.admin.*in$lib/server/hono.ts— usessessionHeaders(event)(server-side only, requiresRequestEvent) - Member → Proxy (server):
memberApi.*in$lib/client/api.ts— use inside+page.server.tsload/actions;BASE_URLresolves to Hono port on server - Member → Proxy (browser):
memberApi.*in$lib/client/api.ts— use inside+page.svelte;BASE_URLis empty, Vite proxies/api/*to Hono $page: import{ page }from$app/state(NOT$app/stores— that's the old Svelte 4 API). Reference aspage.params.fam,page.url.pathnameetc. without$prefixconfig.tsat root for dev/build-time shared config (e.g.PROXY_PORT); runtime config via env vars.envat root tracks port values (PROXY_PORT,PORT);.env.examplecommitted 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, proxy3456, container ext3001(port3000is 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 inproxy/, 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 injected1.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)