diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a8548f3 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +SERVER_IP=0.0.0.0 +PROXY_PORT=3456 +FRONTEND_PORT=2080 +PB_PORT=8090 +PB_EMAIL= +PB_PASSWORD= +DEBUG_RECORD_ID= +PUBLIC_PB_URL= diff --git a/AGENTS.md b/AGENTS.md index 30720e7..7ac1b85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,13 +116,29 @@ $effect(() => { let items = $state(famStore.initialized ? famStore.items : (data.items || [])); ``` -**Form action callbacks must call `famStore.applyRecord()`:** -```ts -// In use:enhance callback: -famStore.applyRecord('collection_name', record, 'create' | 'update' | 'delete'); +## 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: +```svelte +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 */ } +} ``` -The `famStore` has reactive `$state` properties and an `applyRecord()` method designed for instant UI feedback from form actions. Calling `applyRecord()` mutates the store directly — the UI updates immediately without waiting for PB SSE. The SSE subscription is a backup for multi-user sync only. Do NOT add `$effect` watchers to bridge the gap between form actions and reactive state. +**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 diff --git a/config.d.ts b/config.d.ts new file mode 100644 index 0000000..de2e0af --- /dev/null +++ b/config.d.ts @@ -0,0 +1,9 @@ +export declare const FRONTEND_PORT = "2080"; +export declare const PROXY_PORT = "3456"; +export declare const SERVER_IP = "192.168.1.225"; +export declare const PB_PORT = "8090"; +export declare const PB_EMAIL = "debug@famchamp.dev"; +export declare const PB_PASSWORD = "debug123"; +export declare const DEBUG_RECORD_ID = "0747qjl16m6o529"; +export declare const PUBLIC_PB_URL = "http://192.168.1.225:8090"; +//# sourceMappingURL=config.d.ts.map \ No newline at end of file diff --git a/config.d.ts.map b/config.d.ts.map new file mode 100644 index 0000000..5f4b6bd --- /dev/null +++ b/config.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["config.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,aAAa,SAAS,CAAC;AACpC,eAAO,MAAM,UAAU,SAAS,CAAC;AACjC,eAAO,MAAM,SAAS,kBAAkB,CAAC;AACzC,eAAO,MAAM,OAAO,SAAS,CAAC;AAC9B,eAAO,MAAM,QAAQ,uBAAuB,CAAC;AAC7C,eAAO,MAAM,WAAW,aAAa,CAAC;AACtC,eAAO,MAAM,eAAe,oBAAoB,CAAC;AACjD,eAAO,MAAM,aAAa,8BAAmC,CAAC"} \ No newline at end of file diff --git a/config.js b/config.js new file mode 100644 index 0000000..4a2514d --- /dev/null +++ b/config.js @@ -0,0 +1,9 @@ +export const FRONTEND_PORT = "2080"; +export const PROXY_PORT = "3456"; +export const SERVER_IP = "192.168.1.225"; +export const PB_PORT = "8090"; +export const PB_EMAIL = "debug@famchamp.dev"; +export const PB_PASSWORD = "debug123"; +export const DEBUG_RECORD_ID = "0747qjl16m6o529"; +export const PUBLIC_PB_URL = `http://${SERVER_IP}:${PB_PORT}`; +//# sourceMappingURL=config.js.map \ No newline at end of file diff --git a/config.js.map b/config.js.map new file mode 100644 index 0000000..d3e247a --- /dev/null +++ b/config.js.map @@ -0,0 +1 @@ +{"version":3,"file":"config.js","sourceRoot":"","sources":["config.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,aAAa,GAAG,MAAM,CAAC;AACpC,MAAM,CAAC,MAAM,UAAU,GAAG,MAAM,CAAC;AACjC,MAAM,CAAC,MAAM,SAAS,GAAG,eAAe,CAAC;AACzC,MAAM,CAAC,MAAM,OAAO,GAAG,MAAM,CAAC;AAC9B,MAAM,CAAC,MAAM,QAAQ,GAAG,oBAAoB,CAAC;AAC7C,MAAM,CAAC,MAAM,WAAW,GAAG,UAAU,CAAC;AACtC,MAAM,CAAC,MAAM,eAAe,GAAG,iBAAiB,CAAC;AACjD,MAAM,CAAC,MAAM,aAAa,GAAG,UAAU,SAAS,IAAI,OAAO,EAAE,CAAC"} \ No newline at end of file diff --git a/frontend/src/routes/[fam]/[username]/+page.svelte b/frontend/src/routes/[fam]/[username]/+page.svelte index fbe1957..65ae877 100644 --- a/frontend/src/routes/[fam]/[username]/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/+page.svelte @@ -468,18 +468,7 @@ {#each todays as c}
  • ✅ {choreNameFor(c.assignedChoreId)} -
    { - return async (args) => - handleResult(args, (d) => { - if (d.revoked) - famStore.applyRecord('completions', { id: d.id }, 'delete'); - }); - }} - class="revoke-form" - > + { return async (args) => handleResult(args); }} class="revoke-form">
    @@ -511,18 +500,7 @@ {val} {#if targeted} -
    { - return async (args: any) => - handleResult(args, (d: any) => { - if (d.records) { - d.records.forEach((r: any) => famStore.applyRecord('rewards', r, 'create')); - } - }); - }} - > + { return async (args: any) => handleResult(args); }}>
    @@ -533,18 +511,7 @@
    {:else} -
    { - return async (args: any) => - handleResult(args, (d: any) => { - if (d.records) { - d.records.forEach((r: any) => famStore.applyRecord('rewards', r, 'create')); - } - }); - }} - > + { return async (args: any) => handleResult(args); }}>
    @@ -599,7 +561,6 @@
    {rewardLabel(r)} -
    {/each} diff --git a/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte b/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte index f39c74b..2febabf 100644 --- a/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/bonuses/+page.svelte @@ -176,7 +176,9 @@ import type { const rec = result.data?.record; if (rec) { configs = [rec as BonusConfig, ...configs]; - famStore.applyRecord('bonus_configs', rec, 'create'); + if (famStore.fam?.featureFlags?.optimisticUpdates) { + famStore.applyRecord('bonus_configs', rec, 'create'); + } } showCreateModal = false; } @@ -189,7 +191,9 @@ import type { const updated = { ...editingConfig, ...editVals, rewardValue: editVals.rewardValue }; const idx = configs.findIndex((c) => c.id === editingConfig!.id); if (idx !== -1) configs[idx] = updated as BonusConfig; - famStore.applyRecord('bonus_configs', updated, 'update'); + if (famStore.fam?.featureFlags?.optimisticUpdates) { + famStore.applyRecord('bonus_configs', updated, 'update'); + } showEditModal = false; editingConfig = null; } diff --git a/frontend/src/routes/[fam]/[username]/chores/+page.svelte b/frontend/src/routes/[fam]/[username]/chores/+page.svelte index 02bba05..f285423 100644 --- a/frontend/src/routes/[fam]/[username]/chores/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/chores/+page.svelte @@ -140,7 +140,9 @@ const rec = result.data?.record if (rec) { templates = [rec as ChoreTemplate, ...templates] - famStore.applyRecord('chore_templates', rec, 'create') + if (famStore.fam?.featureFlags?.optimisticUpdates) { + famStore.applyRecord('chore_templates', rec, 'create') + } } } } @@ -159,7 +161,9 @@ customName: editCustomName || undefined, seasonIds: editSeasonIds.length > 0 ? editSeasonIds : [], } as AssignedChore - famStore.applyRecord('assigned_chores', assigned[idx], 'update') + if (famStore.fam?.featureFlags?.optimisticUpdates) { + famStore.applyRecord('assigned_chores', assigned[idx], 'update') + } } } closeEdit() @@ -185,7 +189,9 @@ ? { ...a, frequency: editTplFreq, type: editTplType, value: editTplValue } : a ) as AssignedChore[] - famStore.applyRecord('chore_templates', templates[idx], 'update') + if (famStore.fam?.featureFlags?.optimisticUpdates) { + famStore.applyRecord('chore_templates', templates[idx], 'update') + } closeEditTemplate() } } @@ -233,7 +239,7 @@
    -
    { return async ({ result, formData }) => { if (result.type === 'success') { const id = formData.get('id'); templates = templates.filter((t) => t.id !== id) as ChoreTemplate[]; const rec = result.data.record; if (rec) famStore.applyRecord('chore_templates', { ...rec, id }, 'delete'); } }; }}> + { return async ({ result, formData }) => { if (result.type === 'success') { const id = formData.get('id'); templates = templates.filter((t) => t.id !== id) as ChoreTemplate[]; if (famStore.fam?.featureFlags?.optimisticUpdates) { const rec = result.data.record; if (rec) famStore.applyRecord('chore_templates', { ...rec, id }, 'delete'); } } }; }}>
    @@ -278,11 +284,10 @@ e.stopPropagation(); const s = page.data.session as any; if (!s) return; - const res = await fetch(`/api/admin/${s.famId}/assigned-chores/${a.id}`, { + await fetch(`/api/admin/${s.famId}/assigned-chores/${a.id}`, { method: 'DELETE', headers: { 'x-session-famid': s.famId, 'x-session-userid': s.userId }, }); - if (res.ok) famStore.applyRecord('assigned_chores', { id: a.id }, 'delete'); }} >×
    diff --git a/frontend/src/routes/[fam]/[username]/rewards/+page.svelte b/frontend/src/routes/[fam]/[username]/rewards/+page.svelte index e8fdb8c..6f6d3c0 100644 --- a/frontend/src/routes/[fam]/[username]/rewards/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/rewards/+page.svelte @@ -98,7 +98,7 @@ {#if outstanding} -
    { return async (args: any) => { const d = args.result.data || {}; if (d.error) showToast(d.error); else if (args.result.type === 'success') { if (d.record) famStore.applyRecord('rewards', d.record, 'update'); fire(); } }; }}> + { return async (args: any) => { const d = args.result.data || {}; if (d.error) showToast(d.error); else if (args.result.type === 'success') { if (d.record && famStore.fam?.featureFlags?.optimisticUpdates) famStore.applyRecord('rewards', d.record, 'update'); fire(); } }; }}>