merge feature/sse-reads: SSE reactivity + claim flow + platform admin
This commit is contained in:
@@ -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=
|
||||||
@@ -116,13 +116,29 @@ $effect(() => {
|
|||||||
let items = $state(famStore.initialized ? famStore.items : (data.items || []));
|
let items = $state(famStore.initialized ? famStore.items : (data.items || []));
|
||||||
```
|
```
|
||||||
|
|
||||||
**Form action callbacks must call `famStore.applyRecord()`:**
|
## Update Patterns
|
||||||
```ts
|
|
||||||
// In use:enhance callback:
|
Two patterns based on who's acting:
|
||||||
famStore.applyRecord('collection_name', record, 'create' | 'update' | 'delete');
|
|
||||||
|
| 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
|
## UI Component Conventions
|
||||||
|
|
||||||
|
|||||||
Vendored
+9
@@ -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
|
||||||
@@ -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"}
|
||||||
@@ -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
|
||||||
@@ -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"}
|
||||||
@@ -468,18 +468,7 @@
|
|||||||
{#each todays as c}
|
{#each todays as c}
|
||||||
<li>
|
<li>
|
||||||
✅ {choreNameFor(c.assignedChoreId)}
|
✅ {choreNameFor(c.assignedChoreId)}
|
||||||
<form
|
<form method="POST" action="?/revoke" use:enhance={() => { return async (args) => handleResult(args); }} class="revoke-form">
|
||||||
method="POST"
|
|
||||||
action="?/revoke"
|
|
||||||
use:enhance={() => {
|
|
||||||
return async (args) =>
|
|
||||||
handleResult(args, (d) => {
|
|
||||||
if (d.revoked)
|
|
||||||
famStore.applyRecord('completions', { id: d.id }, 'delete');
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
class="revoke-form"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="id" value={c.id} />
|
<input type="hidden" name="id" value={c.id} />
|
||||||
<button type="submit" class="revoke-btn" title="Revoke">↩</button>
|
<button type="submit" class="revoke-btn" title="Revoke">↩</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -511,18 +500,7 @@
|
|||||||
<span class="trigger-value">{val}</span>
|
<span class="trigger-value">{val}</span>
|
||||||
</div>
|
</div>
|
||||||
{#if targeted}
|
{#if targeted}
|
||||||
<form
|
<form method="POST" action="?/trigger" use:enhance={() => { return async (args: any) => handleResult(args); }}>
|
||||||
method="POST"
|
|
||||||
action="?/trigger"
|
|
||||||
use:enhance={() => {
|
|
||||||
return async (args: any) =>
|
|
||||||
handleResult(args, (d: any) => {
|
|
||||||
if (d.records) {
|
|
||||||
d.records.forEach((r: any) => famStore.applyRecord('rewards', r, 'create'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="configId" value={bc.id} />
|
<input type="hidden" name="configId" value={bc.id} />
|
||||||
<input type="hidden" name="memberId" value={targeted.id} />
|
<input type="hidden" name="memberId" value={targeted.id} />
|
||||||
<div class="trigger-row">
|
<div class="trigger-row">
|
||||||
@@ -533,18 +511,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{:else}
|
{:else}
|
||||||
<form
|
<form method="POST" action="?/trigger" use:enhance={() => { return async (args: any) => handleResult(args); }}>
|
||||||
method="POST"
|
|
||||||
action="?/trigger"
|
|
||||||
use:enhance={() => {
|
|
||||||
return async (args: any) =>
|
|
||||||
handleResult(args, (d: any) => {
|
|
||||||
if (d.records) {
|
|
||||||
d.records.forEach((r: any) => famStore.applyRecord('rewards', r, 'create'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="configId" value={bc.id} />
|
<input type="hidden" name="configId" value={bc.id} />
|
||||||
<div class="trigger-row">
|
<div class="trigger-row">
|
||||||
<select name="memberId" class="trigger-select">
|
<select name="memberId" class="trigger-select">
|
||||||
@@ -584,12 +551,7 @@
|
|||||||
method="POST"
|
method="POST"
|
||||||
action="?/claim"
|
action="?/claim"
|
||||||
use:enhance={() => {
|
use:enhance={() => {
|
||||||
return async (args: any) =>
|
return async (args: any) => handleResult(args);
|
||||||
handleResult(args, (d: any) => {
|
|
||||||
if (d.record) {
|
|
||||||
famStore.applyRecord('rewards', d.record, 'update');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={r.id} />
|
<input type="hidden" name="id" value={r.id} />
|
||||||
@@ -600,7 +562,6 @@
|
|||||||
<span class="value">{rewardLabel(r)}</span>
|
<span class="value">{rewardLabel(r)}</span>
|
||||||
<Button type="submit" size="sm" variant="primary">Issue</Button>
|
<Button type="submit" size="sm" variant="primary">Issue</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
{/each}
|
{/each}
|
||||||
{#if requested.length > 1}
|
{#if requested.length > 1}
|
||||||
|
|||||||
@@ -176,8 +176,10 @@ import type {
|
|||||||
const rec = result.data?.record;
|
const rec = result.data?.record;
|
||||||
if (rec) {
|
if (rec) {
|
||||||
configs = [rec as BonusConfig, ...configs];
|
configs = [rec as BonusConfig, ...configs];
|
||||||
|
if (famStore.fam?.featureFlags?.optimisticUpdates) {
|
||||||
famStore.applyRecord('bonus_configs', rec, 'create');
|
famStore.applyRecord('bonus_configs', rec, 'create');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
showCreateModal = false;
|
showCreateModal = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -189,7 +191,9 @@ import type {
|
|||||||
const updated = { ...editingConfig, ...editVals, rewardValue: editVals.rewardValue };
|
const updated = { ...editingConfig, ...editVals, rewardValue: editVals.rewardValue };
|
||||||
const idx = configs.findIndex((c) => c.id === editingConfig!.id);
|
const idx = configs.findIndex((c) => c.id === editingConfig!.id);
|
||||||
if (idx !== -1) configs[idx] = updated as BonusConfig;
|
if (idx !== -1) configs[idx] = updated as BonusConfig;
|
||||||
|
if (famStore.fam?.featureFlags?.optimisticUpdates) {
|
||||||
famStore.applyRecord('bonus_configs', updated, 'update');
|
famStore.applyRecord('bonus_configs', updated, 'update');
|
||||||
|
}
|
||||||
showEditModal = false;
|
showEditModal = false;
|
||||||
editingConfig = null;
|
editingConfig = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,11 +140,13 @@
|
|||||||
const rec = result.data?.record
|
const rec = result.data?.record
|
||||||
if (rec) {
|
if (rec) {
|
||||||
templates = [rec as ChoreTemplate, ...templates]
|
templates = [rec as ChoreTemplate, ...templates]
|
||||||
|
if (famStore.fam?.featureFlags?.optimisticUpdates) {
|
||||||
famStore.applyRecord('chore_templates', rec, 'create')
|
famStore.applyRecord('chore_templates', rec, 'create')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function enhanceUpdateAssigned() {
|
function enhanceUpdateAssigned() {
|
||||||
return async ({ result }: any) => {
|
return async ({ result }: any) => {
|
||||||
@@ -159,9 +161,11 @@
|
|||||||
customName: editCustomName || undefined,
|
customName: editCustomName || undefined,
|
||||||
seasonIds: editSeasonIds.length > 0 ? editSeasonIds : [],
|
seasonIds: editSeasonIds.length > 0 ? editSeasonIds : [],
|
||||||
} as AssignedChore
|
} as AssignedChore
|
||||||
|
if (famStore.fam?.featureFlags?.optimisticUpdates) {
|
||||||
famStore.applyRecord('assigned_chores', assigned[idx], 'update')
|
famStore.applyRecord('assigned_chores', assigned[idx], 'update')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
closeEdit()
|
closeEdit()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,7 +189,9 @@
|
|||||||
? { ...a, frequency: editTplFreq, type: editTplType, value: editTplValue }
|
? { ...a, frequency: editTplFreq, type: editTplType, value: editTplValue }
|
||||||
: a
|
: a
|
||||||
) as AssignedChore[]
|
) as AssignedChore[]
|
||||||
|
if (famStore.fam?.featureFlags?.optimisticUpdates) {
|
||||||
famStore.applyRecord('chore_templates', templates[idx], 'update')
|
famStore.applyRecord('chore_templates', templates[idx], 'update')
|
||||||
|
}
|
||||||
closeEditTemplate()
|
closeEditTemplate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,7 +239,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<button onclick={() => openEditTemplate(t)} class="edit-btn" title="Edit template">✎</button>
|
<button onclick={() => openEditTemplate(t)} class="edit-btn" title="Edit template">✎</button>
|
||||||
<form method="POST" action="?/deleteTemplate" use:enhance={() => { 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'); } }; }}>
|
<form method="POST" action="?/deleteTemplate" use:enhance={() => { 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'); } } }; }}>
|
||||||
<input type="hidden" name="id" value={t.id} />
|
<input type="hidden" name="id" value={t.id} />
|
||||||
<button type="submit" class="del-btn" title="Delete template">×</button>
|
<button type="submit" class="del-btn" title="Delete template">×</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -278,11 +284,10 @@
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const s = page.data.session as any;
|
const s = page.data.session as any;
|
||||||
if (!s) return;
|
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',
|
method: 'DELETE',
|
||||||
headers: { 'x-session-famid': s.famId, 'x-session-userid': s.userId },
|
headers: { 'x-session-famid': s.famId, 'x-session-userid': s.userId },
|
||||||
});
|
});
|
||||||
if (res.ok) famStore.applyRecord('assigned_chores', { id: a.id }, 'delete');
|
|
||||||
}}
|
}}
|
||||||
>×</button>
|
>×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{#if outstanding}
|
{#if outstanding}
|
||||||
<form method="POST" action="?/claim" use:enhance={() => { 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(); } }; }}>
|
<form method="POST" action="?/claim" use:enhance={() => { 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(); } }; }}>
|
||||||
<input name="id" type="hidden" value={r.id} />
|
<input name="id" type="hidden" value={r.id} />
|
||||||
<Button type="submit" size="sm">Claim</Button>
|
<Button type="submit" size="sm">Claim</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user