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

63 KiB
Raw Blame History

FamDone v2 — Development Memory

2026-08-17 — Schema collapse to single source of truth (Option 1)

  • Decision: abandoned incremental migration history. SCHEMA_PLAN (shared/pb/schema.ts) is now the true, current schema; migrate.ts collapsed from 2096 → 183 lines to a fresh-only bootstrap (ensureSchema() skips if fams exists — no incremental steps). The app isn't live and data is disposable, so there's nothing to preserve; a schema change = update SCHEMA_PLAN + wipe PB + reboot.
  • Folded the net effect of ~40 hand-written steps into SCHEMA_PLAN (verified against the live PB): fams.{payday,lastIssued,paydayTime,timezone} (dropped stale seasons), settings.simulateEow, messages.clientId, assigned_chores.{isTodo,startDate,completeBy}. bonus_configs.memberId, weekly_history/rewards/assigned_chores/completions.memberId → users.
  • Out of SCHEMA_PLAN (handled in migrate.ts): the native users auth collection (custom famId/role/username/color fields + username unique index + password-auth identity + famId-scoped rules, via ensureUsers) and the superuser-only otp collection (null rules — col() can't express null, via ensureOtp).
  • Gotcha: col() coerces rules.listRule ?? "" → can't emit null rules, so otp stays out of the plan. ensureSchema needs the live PB to confirm the true schema (SCHEMA_PLAN was stale before this).
  • Verify path: wipe dev PB + restart frontend (needs user OK) so migrateOnBoot rebuilds from SCHEMA_PLAN.

2026-08-17 — Hono proxy removed: everything runs in SvelteKit services

  • Decision: deleted the proxy/ Hono service entirely. All business logic (admin CRUD, member kanban, weekly summary/EOW, bonus evaluation/trigger/progress, rewards claim/issue, chat, payday settlement, debug data-gen) now lives in frontend/src/lib/server/services/, grouped by app area (not by role): fam.ts, chores.ts, completions.ts, rewards.ts, bonuses.ts, chat.ts, settings.ts, crud.ts, debug.ts, plus a generic per-resource crud.ts. createServices(pb, user) returns a per-feature binder; servicesFor(event) is the shorthand for loads/form actions. No role guard — PB collection rules on the acting user's token are the security boundary (the admin/member split no longer exists as separate files).
  • Wiring: hono.admin.* (form actions/loads) and memberApi.*/chat are now direct service calls or SvelteKit /api/* routes (completions/toggle, members/rewards/[id]/claim, members, fam/[famId]/payday, chat, admin/[famId]/assigned-chores). Browser admin calls (chores grid) hit SvelteKit /api/admin/*. The /api/* routes read auth straight from event.locals (locals.user + createPbClient(locals.pbToken)) — a separate actingClient helper was dropped as redundant since hooks.server.ts already resolves the session. No Authorization header; the httpOnly pb_token cookie is the auth for same-origin calls.
  • Migration relocated: proxy/src/migrate.ts → frontend/src/lib/server/migrate.ts (env now via $app/env/private + PB_ENDPOINT from pocketbase.ts), run once per process by migrate-boot.ts, kicked off in hooks.server.ts (void migrateOnBoot()). Schema source of truth remains shared/pb/schema.ts.
  • Infra: pnpm-workspace.yaml (only frontend), root package.json (dev = pnpm --filter frontend dev), docker/Dockerfile (no proxy build/deploy), docker/entrypoint.sh (no proxy start; app runs schema migration on boot), docker/nginx.conf (/api/ block removed → falls through to location / → SvelteKit :3000; /pb/api/ unchanged). Removed PROXY_URL env + PROXY_PORT from shared/config.ts and frontend/src/env.ts. Dead memberApi.myChores/requestAll removed.
  • Typecheck: frontend svelte-check = 12 pre-existing canary errors (.svelte implicit-any, qrcode decl, RewardType/Frequency casts, signup string|undefined); zero errors in the migration's files. pnpm build (adapter-node) succeeds.
  • Note: dev servers were left running; the now-deleted proxy tsx watch (:3456) will error and the frontend dev server needs a restart to drop PROXY_URL/load migrateOnBoot + the removed /api Vite proxy.

UI Component Architecture (Jul 2026)

Layout Hierarchy

+layout.svelte  ← global styles, meta, favicon
├── /login, /signup, /join/*  ← auth pages (no shell)
└── [fam]/+layout.svelte  ← Shell: Sidebar + TopNav + Footer + claim toast
    ├── [fam]/+page.svelte                   ← fam dashboard
    ├── [fam]/{username}/+page.svelte       ← parent=admin overview, child=kanban
    ├── [fam]/{username}/chores/+page.svelte  ← parent only
    ├── [fam]/{username}/ledger/+page.svelte ← parent only (rewards/chores/todos)
    ├── [fam]/{username}/bonuses/+page.svelte ← parent only
    ├── [fam]/{username}/settings/+page.svelte ← parent only
    └── [fam]/{username}/preferences/+page.svelte ← both roles

Sidebar (collapsible to mini-mode)

  • Header: app name (FamDone)
  • Admin CTAs: Dashboard, Chores, Rewards (badge count), Bonuses
  • Member CTAs: Dashboard, Preferences
  • Footer: family name, Settings (admin only), Log out
  • Role-aware: items differ based on admin vs member route

TopNav

  • Slot announcement (center) — system/family messages
  • Slot actions (right) — user status, claim/message

Page Content

  • ViewHeader — title + subtitle + tool bar (tabs, weeknav, sort)
  • CardGrid — 3-column grid, Cards span columns via cols prop
  • Card — 1/2/3 col span, micro-layout per page
  • Accordion — for settings / log sections
  • Button — consistent CTAs with variant (primary/secondary/ghost/danger) and size (sm/md/lg)

Components (frontend/src/lib/components/)

  • Sidebar.svelte, TopNav.svelte, Footer.svelte
  • ViewHeader.svelte, Card.svelte, CardGrid.svelte
  • Button.svelte, Accordion.svelte
  • icons.ts — SVG icon strings (no icon library dep)

Role-Based Auth (Jul 2026)

  • Members have role field ('parent' | 'child', added to members collection)
  • Parents authenticate via email/password (PB session JWT), children via device token
  • /api/admin/signup now creates a member record for the parent with role: 'parent'
  • /api/admin/login returns memberName, memberColor, role alongside session info
  • /api/members/verify-token returns role for the frontend
  • requireAdmin middleware checks users (role='parent' && id = userId, scoped by famId)
  • Removed fam_admins collection (Aug 2026): parent identity fully lives on the users record (famId, role, name, color, email). requireAdmin, authorizeFamReq, resolveChatActor, admin /profile get/patch, and the legacy proxy /signup//login now read/write users instead. Signup no longer creates a fam_admins row; superadmin stats derive parentEmail from users role='parent'. Migrate step 34 drops the collection.
  • Removed fams.inviteCode (Aug 2026): join flow is OTP-based (user_configs.otp), so the stored/displayed/regenerated invite code was never consumed. Removed randomCode() at signup, the /regen-invite endpoint + hono.admin.regenInvite action, the inviteCode type/field, and added migrate step 34 to drop the field.
  • Frontend sidebar is role-aware: isParent = session !== null
  • No more /admin prefix — admin pages live under /{fam}/{parent-username}/chores etc.

Routes (Jul 2026)

/                              Landing (SaaS marketing)
/signup                        Signup (creates parent user + member)
/login                         Login (returns member info, redirects to /{fam}/{memberName})
/join/:code                    Member invite (child)
/join/:code/:member            Member invite with pre-selected name
/admin                         Super admin dashboard (unchanged)
/{fam}                         Fam dashboard
/{fam}/{username}              Parent → admin overview, Child → kanban
/{fam}/{username}/chores       Parent: chore management
/{fam}/{username}/ledger      Parent: rewards / chores / todos ledger
/{fam}/{username}/bonuses      Parent: bonus configs
/{fam}/{username}/settings     Parent: family settings
/{fam}/{username}/preferences  Both: edit name/color
/api/*                         Hono proxy

Architecture Decisions

2026-06-23 — Monorepo & Docker Setup

  • Ports: Frontend = 2080, Proxy = 3456, Container ext = 3001. Port 3000 reserved/conflict.
  • Shared config: config.ts at root for dev/build-time values (e.g. PROXY_PORT). Runtime config via env vars. .env tracks ports, .env.example committed.
  • Docker: 2 Dockerfiles — Dockerfile (prod, multi-stage with nginx) and Dockerfile.dev (PocketBase for dev).
  • Nginx: Prod container uses nginx to route /api/* → Hono (:3456), /* → SvelteKit (:2080).
  • Dev workflow: pnpm dev at root runs SvelteKit + Hono in parallel. PocketBase via Dockerfile.dev.
  • Proxy runtime: Uses process.env.PROXY_PORT instead of importing config.ts (avoids rootDir issues in tsc).

2026-06-23 — Hono Proxy for All Data; Svelte Reactivity Only

  • All data operations (reads and writes) go through the Hono proxy, never directly to PB SDK.
  • UI reactivity is purely Svelte $state / $derived / $effect — no PB SDK .subscribe() / SSE.
  • The /debug page's PB SDK subscribe() was experimental only; final apps fetch via Hono proxy and update Svelte state reactively.
  • Bug: Both join/[code]/+page.server.ts and join/[code]/[member]/+page.server.ts called setSessionCookie() with userId: memberId (the child's PB record ID). This made event.locals.session truthy for children, causing [username]/+page.server.ts to enter the admin branch and call hono.admin.* endpoints. The proxy's requireAdmin checked fam_admins for the child's member ID (which doesn't exist) and returned 401.
  • Fix: Removed setSessionCookie() from both join pages. Children only get a device_token cookie. The session cookie is only for email/password-authenticated parents, set by /login and /signup.
  • Lesson: Children must never get a session cookie. The auth table in AGENTS.md says "Member → device token, no expiry" — the code must match.

2026-08-03 — Family Timezone Setting + Tz-Aware Week Math

  • Feature: fams.timezone (IANA name or "auto") added via migrate.ts 5d/5e (field + backfill "auto"). Exposed in settings Payday card (dropdown from COMMON_TIMEZONES, ~40 entries, + "Auto (detected)"). Set via PATCH /api/admin/:famId/fam alongside payday/paydayTime.
  • Shared module timezone.ts (root, imported by both proxy and frontend): resolveTz, dateStrInTz, weekdayInTz, todayInTz, addDaysStr (pure UTC), weekStart(payday, tz), wallClockToUtc (iterative 4-pass Intl, DST-safe), COMMON_TIMEZONES.
  • Bug fixed ("5 days left on Monday"): old mondayOf/addDays/daysLeft used local setDate + toISOString(). On BST Sunday Aug 9 local midnight → UTC Aug 8, so Monday showed 5 days left instead of 6. Now the child page computes weekStart/todayIso/daysLeft via weekStart(1, famTz) + addDaysStr + todayInTz, giving 6. Verified: Mon Aug 3 → weekStart 2026-08-03, weekEnd 2026-08-09, daysLeft 6.
  • releaseWeek time gate: now tz-aware. weekStart(payday, tz) for idempotency check; target = new Date(wallClockToUtc(weekStartToday, paydayTime, tz)). Verified: payday Mon 20:00 Europe/London (BST) → target 2026-08-03T19:00:00Z; auto resolves to server tz. Returns {settled:false, notYet:true, weekStart, target} before the time.
  • Proxy threads tz through weekly-summary, eow-preview, bonus-configs/progress, evaluateFam, tallies, manual trigger, complete-week, releaseWeek. my-chores returns timezone; child +page.server.ts passes it to data.timezone; parent passes data.fam.timezone.
  • Frontend child page: rawFamTz = data.timezone || data.fam?.timezone || 'auto', famTz = resolveTz(rawFamTz). todayChild, todayIso, mondayOf, addDays, isPaydayToday (via weekdayInTz), paydayTarget (via wallClockToUtc), today all tz-aware. mondayOf now delegates to tzWeekStart(1, famTz).
  • Smoke-tested live (fam v56f0f8o147kj1x): GET fam returns timezone, PATCH sets Europe/London, time-gate returns correct target, auto resolves to server tz, settings page SSR shows the dropdown, child page 200. Fam restored to payday=0, paydayTime=18:00, timezone=auto, lastIssued=2026-08-02.
  • Typecheck: proxy tsc --noEmit 28 errors (all pre-existing rootDir/.ts-import/key: never/implicit-any baseline); frontend svelte-check 9 errors (baseline; no new errors in edited files).

2026-08-04 — Terminology: "Payday" + Countdown to Settlement Day

  • Decision: Standardize the user-facing term on "payday" for the weekly settlement event/day. "EOW" is ambiguous (window-close vs settlement day) and is now dropped from user-facing strings. Keep "week" for the Sun→Sat earning window. Internal identifiers (eow*, simulateEow, eowPreview, CSS eow-*) left as-is.
  • daysLeft now counts to settlement day: daysLeft on the child dashboard counts to weekStart + 7 (the next payday day) instead of weekEnd = weekStart + 6. So Tue Aug 4 with payday=Sun shows 5 days until payday. Hero label updated to "days until payday".
  • User-facing renames: hero days left → days until payday; debug card Simulate End-of-Week → Simulate Payday; error Failed to preview EOW → Failed to preview payday; settings hint reworded to lead with "Payday:".

2026-08-04 — DDMMYY Date Rule + Debug Payday Preview Fix

  • Rule added (AGENTS.md): all user-facing dates are DDMMYY (compact, e.g. 040826 for 4 Aug 2026). Shared helper formatDDMMYY() in frontend/src/lib/format.ts (extracted from the local copy in chores/+page.svelte). Never render raw YYYY-MM-DD to users.
  • Bug: the admin "Preview payday" card showed all-time totals (e.g. "280 pts £288.00 14 chores" for zooney) because the proxy's eow-preview reward queries (rewardPointsList/rewardCashList) had NO date filter, summing every claimed reward ever. complete-week (the real settlement snapshot) scopes with date >= ws.
  • Fix: added && date >= '${ws}' to both reward queries in eow-preview (proxy/src/index.ts) so the preview matches what the rollover actually records. Verified live: zooney now shows this-week 220 pts / £16.00 / 14 chores / bonus 10.
  • UI: removed the no-op Simulation ON/OFF toggle (settings.simulateEow flag drives no behavior) — it was the source of "simulation on/off vs preview rollover" confusion. Card is now just "Debug: Preview payday" → "Preview payday" button. Debug card dates now render via formatDDMMYY.
  • Typecheck: frontend svelte-check still at 12 baseline errors (no new); proxy tsc unchanged pre-existing baseline.

2026-08-04 — Preview Payday Extends to Child Dashboard

  • Feature: "Preview payday" now enables a family-wide preview mode that the child dashboard reacts to. ?/previewEow action calls eowPreview then hono.admin.updateSettings({ simulateEow: true }), returning { preview, simulateEow: true }. A "Turn off preview" button (?/setEow, on=false) clears it.
  • Child notice: my-chores (proxy/src/index.ts) now returns simulateEow: !!settings.simulateEow; child +page.server.ts passes it as data.simulateEow. Child kanban renders a .preview-notice banner ("Payday preview — your parent is checking this week's payday. Nothing is paid out yet.") when the flag is set. Admin card shows a .eow-mode-on note + "Turn off preview" when active.
  • Note: simulateEow (settings.simulateEow) was previously a no-op debug flag; it now meaningfully drives preview mode across admin + child views.
  • Verified live: POST ?/previewEow sets settings.simulateEow=true (GET settings confirms); child SSR page data carries simulateEow:true; control case (flag off) renders no notice. Test data restored afterwards (zooney deviceToken + flag reset to false).

2026-08-04 — Payday-Gated Bonus Payouts

  • Feature: weekly/monthly period bonus rewards are now claimable only on payday (not the moment they're met). Rewards gain claimable: 'immediate' | 'payday' + settleDate (YYYY-MM-DD, server-side). The bonus-met notice stays exciting on the child dash — the reward line shows a locked "🔒 pays out {DDMMYY}" badge and skips the request button pre-payday.
  • Stamp logic: claimableStamp() in proxy/src/index.ts — periods weekly/monthly → { claimable: 'payday', settleDate: nextPaydayAfter(periodEnd) }; else immediate. Manual bonus triggers (/bonus-configs/:id/trigger) stamp immediate (parent-initiated, not a scheduled payout). All 3 evaluateFam create sites (individual/collaborative/competitive) use claimableStamp. nextPaydayAfter() helper added to timezone.ts.
  • Enforcement: member claim endpoint checks assertPaydayUnlocked() (throws "This bonus pays out on payday (…settleDate) — hang tight!", returned as HTTP 400); request-all skips payday-gated rewards not yet settled. Admin Issue/Issue All are parent discretion and unaffected.
  • UI: child wallet renders locked badge for pre-settle payday rewards; admin "Claims → Outstanding" shows a 🔒 {DDMMYY} hint. Both reuse formatDDMMYY(). (Later: switched both to formatShortDate() → "🔒 pays out 9 Aug"; child owedCash banner excludes payday-locked rewards so "You've earned £X — go get it!" no longer shows for rewards that aren't claimable yet.)
  • Schema: rewards.claimable (select, required) + rewards.settleDate (text) added in proxy/src/migrate.ts + proxy/scripts/seed.ts. PB's required select rejects empty on write; legacy null-claimable rewards are treated as immediate by both proxy and frontend, so no data backfill was needed.
  • Verified live: complete-week → evaluateFam recreated the weekly Pocket Money reward with claimable=payday, settleDate=2026-08-09 (Sunday payday after Sun→Sat week); member claim pre-payday → 400 with friendly message + status stays unclaimed; request-all returns {count:0}; claim succeeds after settleDate.
  • Ops note: the dev proxy's tsx watch had silently frozen (file edits at 09:49 weren't picked up by a child started 09:47). Fixed by killing the watcher tree with explicit PIDs and relaunching pnpm dev (nohup → /tmp/proxy_dev.log). pkill -f "tsx watch src/index.ts" hangs the shell — use kill <pid> instead.

2026-08-04 — Dev Servers: Always Reuse Existing 2080/3456

  • Rule: NEVER start our own dev servers. Always use the already-running ones: proxy 192.168.1.225:3456 (tsx watch, reloads on edit) and frontend localhost:2080 (vite HMR). Don't spawn nohup pnpm dev, tsx watch, or extra vite instances — it wastes time/tokens. Only kill/restart when the user explicitly asks (or a watcher is demonstrably stale, and then only after asking). Prefer short targeted curls and reuse one auth TOKEN across commands in the persistent shell.

2026-08-04 — Chores Page Accordion Quick Fixes

  • Add actions moved into sections: removed the blue round + from the member swimlane header and the Templates column header. Both replaced by a shared full-width dashed + Add a todo / + New template button (.add-inline) at the top of the Todos accordion content and the Templates list respectively.
  • Accordions default open: accordionState lookup defaults to { chores: true, todos: true } (?? true in the template + toggle), so both sections load expanded on page load; still toggleable. Redundant empty-state "+ Add a todo" button and .add-todo-btn/.empty-cta CSS removed.
  • Check: frontend svelte-check stays at 12 baseline errors.

2026-08-04 — Human Dates for Todo "Due" (Not DDMMYY)

  • Problem: the chores todo card rendered due 050826 (DDMMYY code) — ambiguous/terrible for a due date.
  • Fix: added formatShortDate() to frontend/src/lib/format.ts — renders 5 Aug (adds 26 when the year isn't the current one). Chores todo card now shows due 5 Aug. AGENTS.md date rule updated: DDMMYY for dense/range contexts, formatShortDate() for single human-readable dates like due dates.

2026-08-04 — Child-Dashboard Design Lead Applied to All Pages

  • Design principal (from [fam]/[username] child dash): gradient hero lead (linear-gradient(135deg, #6366f1, #8b5cf6 55%, #a855f7), radius 16px, glow shadow, white text), gradient stat tiles, rounded white cards.
  • ViewHeader hero variant: added hero prop to frontend/src/lib/components/ViewHeader.svelte — renders the title/subtitle/tools on the gradient hero (tabs/nav/sort get tinted-on-white styling). Off by default, so no behavior change elsewhere.
  • Applied hero to: admin dashboard (fam name), chores, bonuses, rewards, settings, preferences, platform admin /admin.
  • Admin dashboard stat tiles: added 4 gradient tiles (members / points / cash / chores done) reusing the child .tiles/.tile pattern + new .tile-members/.tile-chores colors, from a new adminTiles derived summing summary.summaries. Also fixed admin subtitle Week of {YYYY-MM-DD} → Week of {DDMMYY}.
  • Check: frontend svelte-check stays at 12 baseline errors. Note: frontend dev server on :2080 was not running when verified (proxy :3456 up).

2026-08-06 — Env Consolidation: SERVER_IP, PROXY_URL, and SvelteKit env only

  • config.ts is proxy-only. It now holds just the three ports (FRONTEND_PORT/PROXY_PORT/PB_PORT = 2080/3456/8090). SvelteKit never imports config.ts — SvelteKit env vars are declared in frontend/src/env.ts and read via $app/env/*. Deleted the stale config.js/.d.ts/.map artifacts.
  • frontend/src/env.ts declares: PROXY_URL (public, default http://127.0.0.1:3456), SERVER_IP (public, default 192.168.1.225), PB_EMAIL/PB_PASSWORD (private, defaults). PUBLIC_PB_URL removed (was the source of a startup crash when unset).
  • Deleted frontend/src/lib/server/env.ts (untracked). All server modules now import { PROXY_URL } from '$app/env/public' (hono.ts, auth.ts, +layout.server.ts, +page.server.ts, preferences, join/[code]/[member]). admin/+page.server.ts imports creds from $app/env/private.
  • frontend/src/lib/pocketbase.ts (browser) + pb-admin.ts: PB_ENDPOINT = import.meta.env.PROD ? '/pb' : \http://${SERVER_IP}:8090`. (Fixed a bug where pocketbase.tsusedimport.meta.env.SERVER_IP` → undefined.)
  • proxy/src/env.ts (new): PB_ENDPOINT = SERVER_IP ? \http://${SERVER_IP}:8090` : `http://127.0.0.1:8090`. Dev env is loaded by the proxy's dev/seedscripts viatsx --env-file-if-exists=../.env(pnpm has no--env-file; NODE_OPTIONS='--env-file=…'is rejected by Node). NoloadEnvFile` hack in code.
  • Docker: removed dead ENV PB_ENDPOINT from Dockerfile; EXPOSE 3001 (was 3005 8090); compose public port is ${PORT:-3001}:3001, creds default to the code fallback, redundant FRONTEND_PORT/PROXY_PORT passthrough dropped; entrypoint.sh simplified (PB_DATA=/app/pb_data, PORT=$FRONTEND_PORT, no :- fallbacks).
  • Frontend deps added (were missing imports): chart.js, qrcode, @hiseb/confetti.
  • Build checks: proxy + frontend pnpm build clean.

2026-08-06 — Dev PB data incident (pb-dev) — see RULES.md "Dev vs Prod PocketBase data"

  • Symptom: pnpm dev proxy migrate failed; PB superuser auth returned HTTP 500 "Something went wrong"; could not log into the PB admin UI.
  • Root cause: the permanent dev PB container pb-dev (publishes :8090, data in host ./pb_data) had a broken bind mount — it was serving an empty throwaway store, so the debug@famchamp.dev superuser didn't exist. The real data.db was on the host but the container wasn't seeing it.
  • Fix: recreated pb-dev with the mount correctly attached (-v "$PWD/pb_data:/pb_data", pocketbase serve --http=0.0.0.0:8090 --dir=/pb_data). Superuser auth then returned 200 on both 127.0.0.1:8090 and the Tailscale SERVER_IP:8090.
  • Watch-out: a careless docker run with a fresh volume (my first attempt, aborted in time) would have wiped the permanent PB data. Restore command is in RULES.md. Two containers (pb-dev :8090 and the docker app's internal PB :8091) currently share the same host ./pb_data — be careful with both.

2026-08-06 — Added root shared/ for cross-package code

  • Created shared/timezone.ts (moved from root timezone.ts). Imported by frontend/src/routes/[fam]/[username]/+page.svelte, .../settings/+page.svelte, and proxy/src/index.ts. Deleted the root timezone.ts.
  • Created shared/pb/schema.ts — single source of truth for the PocketBase schema + field builders (SCHEMA_PLAN ordered collection plan + text/select/rel/... helpers). Both proxy/src/migrate.ts (ensureSchema) and proxy/scripts/seed.ts now iterate SCHEMA_PLAN; kills the previous duplicated schema/field-helper definitions in both files.
  • Reason: timezone.ts and the PB schema are consumed by more than one package; shared/ is the root location both can reach. Rule added to RULES.md: shared code lives in shared/, never inside frontend/ or proxy/.
  • Note: proxy tsc --noEmit already errors on .ts-extension imports (allowImportingTsExtensions unset) — pre-existing, not from this change. Runtime uses esbuild (build) + tsx (dev), both of which bundle the shared/ imports correctly. Verified pnpm build clean for both packages.

2026-08-07 — @shared/* import alias (path alias, not a pnpm package)

  • Moved config.ts → shared/config.ts. All shared/ code is now imported as @shared/* instead of relative ../../shared/....
  • This is a path alias, not a pnpm workspace package (@shared alone isn't a valid npm package name; a real package would need @scope/name).
  • Proxy: tsconfig.json sets paths: { "@shared/*": ["../shared/*"] }; esbuild build adds --alias:@shared=../shared; tsx resolves via tsconfig paths. Proxy keeps .ts extensions (@shared/config.ts).
  • Frontend: uses kit.alias in vite.config.ts (NOT paths in frontend/tsconfig.json, which SvelteKit warns against). Frontend imports shared files without the .ts extension (@shared/timezone) because rewriteRelativeImportExtensions only rewrites relative paths.
  • Verified: proxy build + frontend build + svelte-check all clean for @shared/* (svelte-check still reports pre-existing qrcode types + CSS warnings).
  • Docker note: runtime image only copies frontend/build + proxy/dist (both already bundle shared/), so shared/ needn't be copied into the image.

2026-08-10 — Dev runtime cleanup (PB instances / containers)

  • Removed test container 31e74178b3f0 (famdone-service-app-1, host :3010 + :8092). It ran PB 0.39.10 and bound the same host pb_data as pb-dev → two PBs (v0.25 + v0.39) writing one SQLite DB = corruption/lock risk (likely source of dev instability/login lag).
  • Killed 7 stale host tsx watch dev-proxy processes (Jul 28–Aug 5) + my throwaway 0.39 PBs.
  • Reset pb_data: stopped pb-dev, wiped /home/threejjjs/development/famchamp/pb_data, recreated pb-dev container (fresh v0.25 store) with --automigrate=false, recreated dev superuser debug@famchamp.dev/debug123.
  • Why recreate pb-dev: v0.25 automigrate had baked stale pb_migrations into the old container's writable layer; on a fresh store they failed (Failed to apply migration ...: no rows in result set).
  • Desired end state (confirmed): 8090 = pb-dev (v0.25, single instance, automigrate off); 3001+8091 = PROD app cffd636cc772 (kept, not live); PROD pb_data at /data/coolify/.../pb_data (separate from dev).

2026-08-10 — PB 0.25 → 0.39 migration (branch feature/migrate-pocketbase)

  • Decision: keep our own migrate.ts schema-as-code (API-driven, does data migrations + rule locking), NOT PocketBase's built-in automigrate (schema-only, generates version-specific migration files, and generates conflicting snapshots on upgraded stores). Disable PB automigrate in the runtime.
  • Verified against 0.39.10 (throwaway binaries on 127.0.0.1:8098/8099, temp data dirs):
    1. Fresh store: migrate() bootstraps all 14 SCHEMA_PLAN collections + every field migration cleanly (schema field builders are 0.39-compatible).
    2. Existing 0.25 pb_data → 0.39: opens & serves with --automigrate=false and no stale pb_migrations dir. (Without this, automigrate writes a snapshot to ./pb_migrations relative to CWD that conflicts with existing collections → "Collection name must be unique".)
    3. JS SDK pocketbase@^0.27.0: authWithPassword + public reads OK; realtime SSE endpoint serves PB_CONNECT.
  • Code changes on branch:
    • docker/Dockerfile POCKETBASE_VERSION → 0.39.10.
    • docker/Dockerfile.dev → 0.39.10 + CMD ... --automigrate=false.
    • docker/entrypoint.sh → pocketbase serve ... --automigrate=false.
    • proxy/src/env.ts → added non-breaking PB_ENDPOINT env override (used to point migrate at a throwaway PB on another port; default dev/prod split unchanged).
  • ⚠️ 0.39 schema breaking change: PB 0.39 does NOT auto-add createdAt/updatedAt to API-created collections (0.25 did). The chat store filters/sorts on a custom messages.createdAt, so a fresh 0.39 store is missing it → raw PB 400. Fixed two ways:
    • shared/pb/schema.ts: messages now declares date("createdAt") explicitly (source of truth → ensureSchema).
    • proxy/src/migrate.ts: the "messages already exists" branch now adds createdAt if missing (idempotent hardening for drifted/upgraded stores).
  • Dev now on 0.39.10: pb-dev rebuilt from branch Dockerfile.dev (0.39.10 + --automigrate=false), fresh store on :8090, superuser debug@famchamp.dev re-verified, migrate() bootstraps schema + applies messages.createdAt fix cleanly.
  • Prod upgrade steps: backup pb_data → run the 0.39 image (automigrate off) → run migrate() → verify no drift (messages.createdAt, id.autogeneratePattern).

2026-08-10 — Revert PB to 0.25.8 (backtrack from 0.39)

  • Decision: backtrack off the PocketBase 0.39 bump (introduced in commit 3725c54 via ARG POCKETBASE_VERSION=0.39.10) and work from a 0.25.8 baseline in BOTH dev and prod, then migrate to 0.39 deliberately later.
  • Reverted docker/Dockerfile POCKETBASE_VERSION back to 0.25.8 (matches docker/Dockerfile.dev). Dev pb-dev and the docker app internal PB :8091 share host ./pb_data.
  • Migration schema scripts (DO NOT FORGET): the schema single source of truth is shared/pb/schema.ts (SCHEMA_PLAN), iterated by proxy/src/migrate.ts (ensureSchema) and proxy/scripts/seed.ts. The chat messages / chat_typing collections are defined there without an explicit createdAt — they rely on PB auto-adding it on first create.
    • The 0.39 prod messages drift (missing createdAt, then id "Cannot be blank" after a raw field PATCH) came from schema mismatch during the bump. When migrating 0.25→0.39, reconcile the schema scripts against 0.39's field semantics (incl. system id autogeneratePattern) instead of patching collections by hand.
  • Migration (members → users, run live + verified): deleted the members collection and repointed the 5 memberId relations (assigned_chores, completions, rewards, weekly_history, bonus_configs) → users. Added users.name + users.color, backfilled child name/color; backfilled parent role ('' → 'parent'). Scoped users rules: list/view = famId = @request.auth.famId, update/delete = famId = @request.auth.famId && @request.auth.role = 'parent'. Re-runs are idempotent.
  • PB relation quirk: PB forbids changing a relation's target collection in place (validation_field_relation_change) — you must drop the field and re-add it targeting the new collection in two separate collection updates.
  • Child (member) auth: children are users records with role='child', PB username = {famSlug}:{handle} (composite, globally unique), name = raw display name. Server derives the PB password = MEMBER_SECRET + famSlug + handle; the join gate is a 20-min OTP in user_configs, then authWithPassword. Children now hold a pb_token cookie (previously a device_token cookie with no session). SessionUser gained username (set in hooks.server.ts, stores handleOf(record.username)); the kanban child redirect compares session.username, not session.name.
  • Cookie httpOnly: pb_token is now httpOnly:true. The browser PB SDK is seeded from page.data.pbToken via initPb(token) (layout onMount) — not from document.cookie (the client can no longer read it). Logout is server-side only. Note: the JWT is still shipped to the client in SSR HTML via the pbToken prop.
  • Typecheck baselines: frontend svelte-check = 20 pre-existing canary errors (chat json(status) ResponseInit, $types Action/SubmitFunction, qrcode decl, vite.config, RewardType/Frequency casts, implicitly-any); proxy tsc --noEmit = pre-existing record-typing + implicit-any errors. No new errors in edited files.
  • crypto.randomUUID() unavailable over plain HTTP (non-secure context) → added genClientId() fallback (randomUUID if available, else Date.now().toString(36)+random) in chat.svelte.ts.
  • Shared slugify util: shared/slugify.ts (@shared/slugify alias) — used by proxy signup + fam rename, frontend signup + settings renameFam, and member-otp.createChild (child username + password gen). Removed the 3 local duplicate slugify helpers.
  • Settings UI: CardGrid/Card are now responsive (media queries + --grid-cols/--card-cols; Cards use container-type: inline-size). Members card split into two columns: add-child form | member list (space-between rows, larger 22px colour circle, "Preview" CTA → /{famSlug}/{m.username}, Remove). Family Name card gained an explainer + mono /{fam.slug} slug line.
  • Hono audit: the proxy is the live data layer — admin CRUD/reads via hono.admin.* (kanban load, bonuses ~17 calls, ledger 6, preferences 2, fam dash 4, settings complete-week/debug/generate-data), member actions via memberApi.* (toggleCompletion/claimReward/payday) + direct /api fetches (chores assigned-chores, kanban /api/members/me). Not implemented: /api/weekly-cron CRON, Stripe (create-checkout + webhook), WhatsApp — weekly settlement is manual via complete-week/simulateEow. Chat endpoints exist in the proxy but the frontend talks to PB directly.

2026-08-15 — Signup: multi-step flow restored + family-creation bug fixed

  • Bug (blocker): new family creation failed with PB {"username":{"code":"validation_required","message":"Cannot be blank."}} — the users.create in /signup omitted the auth username field (PB 0.39 requires it). Reproduced directly against PB: create WITHOUT username → validation_required; WITH username → succeeds.
  • Fix: signup/+page.server.ts now includes username on the parent users create.
  • Username convention (composite + handle): PB users.username is the composite {famSlug}:{handle} for BOTH parents and children — globally unique (PB auth-identity needs a single-column unique index) even though the URL segment is per-family. handle(name) = lowercase, strips all non-[a-z0-9] ("Jakey Boy" → jakeyboy); slugify() (hyphenated) is kept only for fam slugs. URL segment = handleOf(username) (part after the last :) → /{famSlug}/{handle}. name keeps the raw display name, read from DB via authRefresh (not plucked into the cookie). Parent's handle captured at signup step 1 (yourName) → username = famUsername(famSlug, handle(yourName)); parents authenticate email+password and land on the fam dashboard /{famSlug} (not username-routed). Children authenticate via OTP → authWithPassword(famUsername(...), derivePassword(famSlug, handle)). Redirects in login/+page.server.ts, [fam]/[username]/+page.server.ts (parent + child branches) and preferences/+page.server.ts use session.username (the handle). Member-list URLs in settings, [fam]/+page.svelte, [fam]/[username]/+page.svelte build /{famSlug}/{handleOf(m.username)}. handle/handleOf/famUsername live in shared/slugify.ts (@shared/slugify).
  • Restored intended multi-step signup (from the guide, adapted to current OTP model): /signup steps — (1) ?/signup familyName/yourName/email/password → create fam + parent (name=yourName, username=famUsername(famSlug, handle(yourName))) + settings, set pb_token; (2) ?/child optional child → issueAccess returns { code, joinUrl }; (3) show OTP join code + "Go to dashboard" link. Uses named actions + use:enhance (callback typed any to avoid the pre-existing canary $types SubmitFunction error).
  • Typecheck: frontend svelte-check stays at 20 pre-existing errors (no new in edited files).

2026-08-21 — Stripe embedded checkout live + access-code gating (fams.paymentMode)

  • Embedded Checkout fixes (frontend/src/lib/server/stripe.ts): ui_mode: 'embedded' → 'embedded_page' (Stripe deprecated 'embedded'); removed customer_creation: 'always' — only valid in payment mode; subscription mode auto-creates the customer from customer_email. Client side already used createEmbeddedCheckoutPage({ clientSecret }).
  • Secure-context gotcha: embedded Checkout requires HTTPS or localhost. Dev host is reached over Tailscale IP via plain HTTP → checkout hangs silently (the muid/guid/sid JSON from m.stripe.com is Radar device fingerprinting, not an error; Stripe CLI websocket errors are benign). Fix: SSH port-forward ssh -L 2080:localhost:2080 and use http://localhost:2080. Webhook listener is now a root script: pnpm stripe:listen (= stripe listen -e customer.subscription.updated,customer.subscription.deleted,checkout.session.completed --forward-to http://127.0.0.1:2080/account/webhook).
  • Gating model: the platform is gated. fams.paymentMode = none | code | sub | canceled; fams.active (bool) is the derived "usable now" flag, re-persisted by ensureFamAccess() when it drifts. New superuser-only accesscodes collection (all rules null like otp, so it lives in migrate.ts not SCHEMA_PLAN): value (unique, required), name, duration (months from entry date; 0=continuous), expiry (months after the code's own createdAt; 0=never), active failsafe, createdAt. Idempotently seeded with dev123 / developer / 0 / 0.
  • Core module frontend/src/lib/server/access.ts: addMonthsUTC, codeIsValid, computeFamAccess (mode → {disabled, reason}; reasons no_access|code_expired|code_disabled|subscription_inactive|canceled), ensureFamAccess(famId) (reads fam+code, persists drifted active, returns {fam, access}), applyAccessCode(famId, value) (sets mode=code + accessCodeId + accessCodeEnteredAt). Wired into [fam]/+layout.server.ts load → data.famAccess (both roles).
  • UI gating: [fam]/+layout.svelte blurs .page-content.locked behind a non-blocking overlay card + admin TopNav announcement (sidebar/chat stay usable). Member kanban gate is frontend-only by decision: [fam]/[username]/+page.svelte derives accessDisabled from page.data.famAccess?.disabled, early-returns in toggle(), and renders three empty locked columns instead of the board. Signup takes an optional code (blank → gated fam; invalid → 400); settings has an Access card (?/applyCode). Webhooks maintain paymentMode: checkout completed / subscription sync → sub; subscription deleted → canceled.
  • Bug found while verifying: the live fams collection was missing the active bool entirely (schema.ts declared it; this PB predated it) → active writes were silently dropped. Fixed by adding it to ensureFamFields() (idempotent; runs outside ensureSchema's early-return alongside ensureAccessCodes) and patching the live collection. Verified end-to-end against PB: fam with valid dev123 → active=true; fam with empty mode → active=false (gated).
  • PB curl gotcha: single-record endpoints are /api/collections/{name}/records/{id} — omitting /records/ returns PB's "File not found." 404 which masquerades as a missing record. The JS SDK always builds the correct path (an earlier "fams by-id 404" scare was a bad curl URL, not an app bug).

2026-08-22 — Routes reshuffle: [famSlug]→[fam] merge, /pricing public, signup wizard with inline checkout

  • Join route merged: [famSlug]/join/{username} → [fam]/join/{username} (same URL shape, single fam param). Fixed params.famSlug→params.fam in join page files.
  • Public pricing page: /subscriptions → /pricing (untracked dir renamed). New PricingPlans.svelte component (reusable tier cards; props: action, hideTrial, selected, error, onsubmit handler). /pricing is public: logged-out "Choose monthly" → redirect /signup?plan=monthly; logged-in → existing embedded checkout.
  • Signup wizard rewritten as state machine (fam → child → code → plan → done):
    • Step 1 (fam): family + parent creation (access code field REMOVED from here)
    • Step 2 (child): add child or skip (unchanged)
    • Step 3 (code): "Have an access code?" Apply (→ done) or Skip (→ plan)
    • Step 4 (plan): PricingPlans embedded (hideTrial=true); selecting mounts embedded checkout INLINE (user authenticated); ?plan=X from /pricing pre-highlights tier
    • Step 5 (done): "Go to dashboard" — webhook flips paymentMode=sub, overlay lifts
    • Server actions: signup (no code), child (unchanged), access (reuses applyAccessCode), choose (embedded checkout session)
  • Webhook moved to /api/webhooks/stripe (machine-to-machine endpoint belongs in /api/* namespace). pnpm stripe:listen forward URL updated.
  • Links updated: account "Change plan", settings "Plans", stripe.ts cancel_url → /pricing.
  • Docs updated: AGENTS.md routes, ARCHITECTURE.md (routes, Stripe flow, architecture diagram, project structure), MEMORY.md this entry.

2026-08-21 — Platform feature flags (platform collection) + debug-gated revoke CTA

  • fams.featureFlags deprecated (removed from SCHEMA_PLAN, Fam type, live PB; field dropped). Replaced by a global platform collection: single record label='global', json flags. Rules: list/view = "" (public read — the one rule shape col() CAN express), create/update/delete = null (superuser-only) → created in migrate.ts (ensurePlatform + idempotent seedPlatform, like otp/accesscodes).
  • Public load: new root frontend/src/routes/+layout.server.ts exposes page.data.platformFlags on every page via getPlatformFlags() (lib/server/platform.ts, 10s TTL cache; setPlatformFlag for superuser writes).
  • debug flag gates dev-only UI: settings "Revoke code" CTA (?/revokeCode) — clears an applied code (paymentMode→none, accessCodeId/EnteredAt→'', active=false, fam re-gates). Server action checks the flag itself (hidden CTA is not the boundary). Settings' old per-fam featureFlags.debugMode Debug Tools card now keys off page.data.platformFlags.debug.
  • /admin Platform Flags card replaces the per-fam Debug column: ?/togglePlatformFlag toggles any flag on the global record. Dev PB seeded with debug: true.
  • Also: [fam]/+layout.svelte exempts /settings from the paused blur overlay (admins can apply a code while gated) and disabled/accessReason are $derived so applying/revoking updates the overlay without a refresh.

2026-08-22 — Settings reorg: Accordion groups, paymentMode-only billing, notices system

  • Settings grouped into 4 Accordions (Family / App / Invites / Billing). Accordion.svelte rewritten as a styled snippet wrapper + new self-contained AccordionItem (own open state, $bindable, {@render children()}) — no items-array API.
  • Gating model simplified (user decision): fams.paymentMode alone drives the FE (none = gated/paused; code valid = active; sub follows webhooks; canceled = gated). No paused field added; the local pause toggle was removed entirely. fams.active remains an internal derived flag maintained by ensureFamAccess/webhooks only.
  • Access card: shows countdown from accessCodeEnteredAt + code duration months (days when <1 month, "never expires" when duration=0) — settings load now fetches the accesscodes record (data.accessCode). Once a code is applied the input/Apply are hidden and Revoke is always visible (debug-flag requirement dropped); revoke just sets paymentMode:'none' + clears code fields (fam-scoped only — global code management is a platform-admin concern).
  • Billing card replaces /account (route deleted): sub → Change plan (/pricing) + Open billing portal (Stripe Customer Portal; dummy mode opens returned URL); code → Switch to subscription; none/canceled → Choose plan. Portal + checkout both return to /{fam}?checkout=return.
  • Checkout-return welcome notice: [fam]/+page.svelte $effect watches ?checkout=return → fires a success notice via the new notices store (lib/stores/notices.ts: typed add/success/info/warning/error + auto-dismiss helper) rendered by global <NoticeDialog /> in the root layout; query param scrubbed via history.replaceState so refresh doesn't re-fire.
  • Gotchas fixed along the way: duplicate NoticeDialog export; Svelte 5 forbids class: directives on components unless declared (Card got selected prop instead); second <script> block in a component is invalid.

2026-08-22 — famSlug single source of truth + notices store to runes

  • famSlug convention: the URL param surfaced by [fam]/+layout.server.ts as top-level data.famSlug is canonical. Client code reads page.data.famSlug (fallback ?? page.params.fam acceptable); server loads/actions under [fam] read event.params.fam. Never copy it into local $state — settings had a frozen-snapshot bug doing exactly that (now $derived(page.data.famSlug ...)). Nested session.famSlug removed (no consumers). Only exception with no URL param: signup child action resolves via DB (fam?.slug || famId) with a comment.
  • Sweep results: removed dead/mislabeled const famId = $derived(page.params.fam) in bonuses page; zero params.famSlug references remain post [famSlug]→[fam] merge.
  • Notices store converted to Svelte 5 runes: lib/stores/notices.svelte.ts (class with $state<Notice[]> list, add/remove/clear/success/info/warning/error + addAutoDismissNotice). Consumers: notices.list in NoticeDialog.svelte; no more svelte-store writable/$notices auto-subscription.

2026-08-22 — famSlug single source of truth + docs Hono purge

  • famSlug convention: [fam]/+layout.server.ts returns top-level data.famSlug (from the URL param) — canonical. Client: page.data.famSlug; server under [fam]: event.params.fam. Never copy into local $state (settings had that frozen-snapshot bug). Nested session.famSlug removed; signup child action is the only DB-fallback case. Fixed platform-admin links pointing at nonexistent /[slug]/admin.
  • Docs: purged all stale Hono-proxy references from AGENTS.md + ARCHITECTURE.md (proxy deleted 2026-08-17); data-flow sections now describe SvelteKit services//api/* routes. Remaining "Hono" mentions are struck-through historical build phases.

2026-08-22 — Trial codes in PB, pause=cancel decision, settings reorder

  • "Pause" = cancel (decision): no separate pause concept. Pausing a plan means cancelling the card subscription via the billing portal; data is kept, resubscribing restores access (paymentMode webhook-driven). Copy lives in settings Account card.
  • Trial codes now PB-backed: accesscodes.trialDays (number). resolveTrialDays() (stripe.ts) is async — queries accesscodes for an active record with value match and trialDays > 0; static TRIAL_CODES map deleted. Seeded: FAM3MONTHS = 90 days. ensureAccessCodeFields() hardens existing installs; seeds unified in seedAccessCodes().
  • Settings accordion order: Family (name/payday/seasons, opens by default via <AccordionItem open>) → Invites → Account (Access + Subscription) → App last.
  • clearLegacyCookies(cookies) added to $lib/server/session.ts; auth/join/logout use it instead of inline device_token deletes.

2026-08-31 — All-time earnings + weekly trend chart (family root), lifetime wallet (member)

  • New server stats service frontend/src/lib/server/services/stats.ts (familyStats(pb, famId), exposed as s.stats.family(famId)): scans full-history completions + rewards once, filtered/summed on the server, returns fixed-size { allTimePts, allTimeCash, allTimeChores, series } (per-member weekly buckets {weekStart, points, cash, chores}). Server-side aggregation keeps the client payload constant-size regardless of history depth — this is the chosen scaling win for all-time totals + the retrospective graph.
  • Totals definition (matches the app's own accounting, no double count): lifetime points = points-type completions' chore values + claimed points rewards; lifetime cash = money-type completions' chore values + claimed cash rewards; chores = count of all completions. Unclaimed/requested rewards stay out of totals (they're the "to chase" list).
  • Reading weekly_history NOT needed — totals derive from live completions+rewards (both have listRule: "" in SCHEMA_PLAN = open to any authenticated session, which is why parent/child token reads already work today).
  • Member wallet ([fam]/[username]/+page.svelte): the existing allTimeCash/allTimePoints deriveds now include chore completion earnings (previously claimed-rewards only). Wallet UI restyled: big lifetime cells (cash "earned all time" + points "accrued") on top, then a "To collect from parent" divider + the existing pendingRewards list (unclaimed/requested/payday-locked) so members still see what to chase.
  • Family root ([fam]/+page.svelte + +page.server.ts): load now fetches familyStats (+ drops the removed templates). ViewHeader gained an optional default slot (children?: Snippet); the fam hero renders large all-time points | cash inside the header. Chores card shows "this week | total all-time" with justify-content: space-between. Templates card removed. Chart.js line graph "Over time" plots per-member lines with a Points/Cash/Chores tab; window starts at the current month and expands a month at a time up to 3 months based on data span (≤31d→1, ≤62d→2, else 3).
  • Typecheck/build: still 6 pre-existing canary errors only (none in edited files); pnpm build (adapter-node) clean. New code is picked up by HMR; no schema/migration change so no restart required.

2026-08-31 — Bonus progress window: completeBy + startDate implemented

  • Problem: a standalone cash bonus (core reward with a points threshold) displayed progress differently on the child dashboard (current week → "0/500") vs the admin dashboard (cumulative since records began). Both were "correct" because bonus_configs had no way to scope tracking — with no period set, progress()/evaluateFam totalled all completions, and the child dashboard forced cfg.period || 'weekly'.
  • Fix: added to bonus_configs (schema + ensureBonusFields idempotent field-add in migrate.ts, so existing installs upgrade without wiping): completeBy (select unlimited|week|custom), startDate (text), completeByDate (text). New shared helpers in shared/timezone.ts: bonusWindow(cfg, payday, tz) + completionInWindow(c, {from,to}) ('' = unbounded side).
  • Window semantics (unified across server progress()/evaluateFam() and the child thresholdGoals): recurring configs (period set) keep their period window; standalone configs use completeBy:
    • unlimited (default) → [startDate, ∞] cumulative
    • week → current week [weekStart, weekEnd]
    • custom → [startDate, completeByDate]
    • No startDate/completeBy (legacy) → unbounded both sides (== the pre-fix admin behaviour, so existing cash bonuses stay correct).
  • UI: rewards modal gained a "Progress counts" group (shown when no period / not manual): completeBy radios (Unlimited | Until a date) + optional completeByDate date input + "Progress starts on" startDate date input. Creating from a template's startMode (Today/Next week) now sets startDate (Today = fam-tz today; Next week = +7 days). openEditConfig/openCreateFromTemplate default startDate to today, so saving an existing cash bonus re-scopes it to count from today (progress since the reward begins), not since records started.
  • Server actions (rewards/+page.server.ts): createConfig/updateConfig/createFromTemplate now persist completeBy/startDate/completeByDate.
  • Typecheck/build: svelte-check still at the 6 pre-existing canary errors (chores RewardType/Frequency, qrcode decl, settings unknown→string) — none in edited files; pnpm build (adapter-node) clean.
  • Ops note: the schema fields are added to live PB by ensureBonusFields() on next migrateOnBoot (dev server restart). A restart of the dev frontend is needed to apply the migration + load the new code.

2026-08-22 — Graceful post-checkout activation (webhook-lag UX)

  • [fam]/+layout.svelte owns the ?checkout=return flow (moved out of the fam page). On landing: if unlocked → welcome notice. If still gated (webhook lag) → activating state: paused overlay swaps to a spinner card ("Activating your subscription…"), TopNav paused announcement suppressed, and invalidateAll() revalidates every 1.5s (max 12 tries). The $effect watching activating && !disabled cancels polling and fires "Subscription active!" the instant the gate lifts; exhaustion degrades to a refresh-hint warning.
  • Mechanics: pollToken guards against stale loops; history.replaceState scrubs the query cosmetically + returnHandled flag prevents double-handling. Webhook remains the sole source of truth for paymentMode/active.
  • Upgrade (same day): activation is event-driven, not polled. startActivating subscribes the browser PB client to its own fams record (pb.collection('fams').subscribe(famId) — allowed by viewRule id = @request.auth.famId). Webhook (superuser) writes → PB SSE push → single invalidateAll(); unlock $effect stops the subscription + fires success. 20s timer kept purely as a degrade-gracefully fallback. Rejected: onComplete-as-source-of-truth (untrusted); optional future hardening = server-side session verification on return.

2026-08-22 — Platform admin: access-code management + richer stats

  • /admin extended (same route/embedded login — env-var check + platform_session cookie, no PB auth or hooks involved):
    • Access Codes card: issue codes via ?/createCode — blank value auto-generates XXXX-XXXX (unambiguous charset); fields name/duration/expiry/trialDays (trialDays > 0 = Stripe trial code). Table shows type badge, active/disabled, used-by count (from fams.accessCodeId map), copy-to-clipboard. ?/toggleCode flips active; ?/deleteCode blocked while in use (must disable).
    • Overview: added chores completed (completions length), active subs (paymentMode='sub'), paused/gated (!active || none/canceled), active codes. Families table gained plan+paused badges.
    • All actions guarded by requirePlatform(cookies); data still via pbAdmin superuser facade.
  • Note: svelte-kit sync needed after changing load return shapes or $types staleness doubles the error count.
  • /admin login pattern: action sets platform_session cookie + returns {success:true}; the form's use:enhance callback flips a local authed view state — no redirect, no reliance on inline invalidation or fetch-time Set-Cookie behavior (which proved flaky in-browser despite curl proving both response paths carried it). Cookie still covers subsequent loads; load errors surface as data.loadError on the login card instead of silently masquerading as logged-out.
  • Platform-admin auth via hooks: hooks.server.ts resolves locals.platformAdmin from the platform_session cookie (=== 'authenticated') on every request; /admin load/actions consume event.locals.platformAdmin (requirePlatform(event)) instead of raw cookie reads. Same central pattern as pb_token → locals.user.
  • isDummyStripe removed: real test keys made every dummy branch dead code — and the webhook's || !signature fallback accepted UNSIGNED events (forgeable access grants). Signature is now mandatory (400 without it); settings' simulated endSubscription/portal branches deleted. Dev verification stays via stripe-cli signed events (pnpm stripe:listen).
  • Platform-admin auth hardened (supersedes the constant-cookie version): /admin login now does a real _superusers.authWithPassword via PB; the minted superuser JWT goes in platform_session (setPlatformSession in session.ts). hooks.server.ts deviates on /admin: verifies the token with _superusers.authRefresh → locals.platformAdmin (forged values fail authRefresh and get cleared); fam-user pb_token flow skipped on that route. FINAL login shape (user-amended, working): action returns {success:true} (no redirect); form's enhance callback does goto('/admin', { invalidateAll: true }) on success — forcing the load re-run with the fresh cookie; view branches on data.authenticated. Verified: real token renders dashboard, forged cookie gets login.
  • use:enhance redirect gotcha (session-wide lesson): action-thrown redirects surface as result.type === 'redirect' in the RESOLVE callback — handlers checking only 'success' silently drop them (bit /admin login, pricing choose, and settings billingPortal). Shared fix: lib/forms.ts → handleResult(handler?) factory follows redirects via goto, delegates the rest to the handler or default update(). Use it for any form whose action can throw a redirect. Also: enhance is two-stage — {result} only exists in the resolve fn, not the submit params (was mis-handled in pricing/signup handlers).

2026-09-02 — Count-type reward: Target Chore + TODO (period semantics)

  • Feature: Count-type rewards now support pinning to a single assigned chore (bonus_configs.targetChoreId). On the fam-admin Rewards modal (Step 2), when Type = "Count - (chores)" a Target Chore dropdown appears after the Target Value field — options are the selected member's assigned (non-todo) chores, default "All Chores". Evaluation (bonuses.evaluateFam) + display (bonuses.progress, dashboard thresholdGoals) now filter Count progress to only that chore's completions when set. Platform-level template form only needed the relabeled "Count - (chores)" — no target chore at template level. Pocket-money checkbox removed from the platform template form (only the auto-created per-child droplet needs it).
  • TODO (logic, not yet fixed): Count rewards with a period (e.g. weekly) reset + re-earn each period like a points/cash threshold. Once a Count reward is pinned to a target (or all chores), the intended semantics are ambiguous: should it be continuous (unlimited, measured once since startDate until reached) or limited to the period window (re-earn each week)? Currently it follows the periodic-reset behavior. Decide whether Count rewards should ignore period (behave as standalone/unlimited since startDate) and adjust bonusWindow/evaluation accordingly. Not attempted in this change.

2026-09-11 — Shared-device PIN switching (a computer shared by siblings)

  • Problem: a single pb_token cookie meant joining kid B on the family computer silently logged out kid A; every hand-off needed a fresh parent-issued OTP. Design note: shared-device.md (decisions + flows).
  • Model — the PIN is NOT a login: it selects among sessions that already exist on the device (pb_token_<childId> per child + pb_active naming the current one). PIN alone mints nothing on a fresh device; a stolen cookie alone selects nothing. Threat model = sibling mischief; devtools-level bypass explicitly accepted (data is family-scoped, toggles reversible).
  • Sessions (session.ts + hooks.server.ts): children store one httpOnly cookie per account (pb_token_<id>, 5d TTL) + pb_active. Resolution order: pb_active first, then legacy pb_token (parents + pre-feature children). Parent login/join clears pb_active → supersedes kid mode; kid switches shadow (don't delete) a parent session; /logout clears everything. Per-profile removal = picker ✕ → POST /api/device/remove (device-local cookie surgery, no session required).
  • pins collection (superuser-only rules like otp): plaintext 3-digit PIN, deliberately parent-recoverable (Q1: View). All access server-side with session-role checks — kids can never read siblings' pins via PB rules. Created on boot via ensurePins({}) in the always-run migrate path (existing installs — no DB wipe), plus settings.lockMins via SCHEMA_PLAN + ensureSettingsFields.
  • Endpoints: POST /api/switch-user {userId, pin} — cookie-presence gate, superuser PIN verify, in-memory rate limit (5 fails → 30s), self-healing: expired device tokens re-mint via the derived password (server-only) instead of forcing a re-join. POST /api/pins — set (first-time), change (needs current), ensure (set-if-missing, used by the join wizard so joining a second shared device doesn't clash with an existing PIN).
  • Picker (SharedPicker component) — one UI everywhere: top-nav colour-dot (any session), idle-lock overlay, and standalone /{fam}/switch landing (fam layout redirects there when kid cookies exist but none is active; join pages excluded from the redirect; +page.server.ts sends active sessions home). PIN required on every pick incl. "resume" (deliberate-select property). Standalone footer links: join with a code + parent login.
  • Join wizard (JoinPinFlow): after OTP redeem → "shared with siblings?" → PIN setup (skipped on "no"), forced when the device already has other kid sessions; then straight to the kid's dashboard. All three child join routes updated (root /join, /{fam}/join, /{fam}/join/{username}); parents keep the single-session flow.
  • Idle lock (client, lib/client/lock.ts): localStorage[fam_last_active] written on click/key/touch (throttled 10s) + force-written on pagehide → survives browser close/sleep/restart (next launch compares elapsed; live 15s interval mid-session). Default 10 min (user-set: default 10, not 2-3); parent-adjustable Off / 2 / 10 in Settings → Family → Shared computer. Children only; tab-switches don't lock; two tabs share the timestamp so the active tab arbitrates.
  • Parent/child PIN UI: Settings → Invites → All members → PIN per member (reveal + give-a-new-PIN modal); child Preferences → My PIN (set, or change with current PIN; "forgot? parent can read it out").
  • Typecheck/build: svelte-check still 4 pre-existing canary errors only (chores RewardType/Frequency ×3, qrcode decl — files untouched by this change); prettier run over touched files. Migration applies on next dev-server boot (migrateOnBoot) — no DB wipe; hooks changes need the dev server restart (auto).
  • Caveats: legacy single-cookie child sessions work but don't appear in the picker until re-join; multi-tab concurrency accepted (single-tab-norm on family desktops); shared-device.md records the settled open questions (view-not-reset, optional-at-join, no parent switcher, straight-to-dashboard, no cross-family).