add optimistic update ff
This commit is contained in:
@@ -105,13 +105,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
|
||||
|
||||
|
||||
@@ -380,7 +380,7 @@ import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } fr
|
||||
{#each todays as c}
|
||||
<li>
|
||||
✅ {choreNameFor(c.assignedChoreId)}
|
||||
<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">
|
||||
<form method="POST" action="?/revoke" use:enhance={() => { return async (args) => handleResult(args, famStore.fam?.featureFlags?.optimisticUpdates ? (d) => { if (d.revoked) famStore.applyRecord('completions', { id: d.id }, 'delete'); } : undefined); }} class="revoke-form">
|
||||
<input type="hidden" name="id" value={c.id} />
|
||||
<button type="submit" class="revoke-btn" title="Revoke">↩</button>
|
||||
</form>
|
||||
@@ -405,7 +405,7 @@ import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } fr
|
||||
<span class="trigger-value">{val}</span>
|
||||
</div>
|
||||
{#if targeted}
|
||||
<form 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')); } }); }}>
|
||||
<form method="POST" action="?/trigger" use:enhance={() => { return async (args: any) => handleResult(args, famStore.fam?.featureFlags?.optimisticUpdates ? (d: any) => { if (d.records) d.records.forEach((r: any) => famStore.applyRecord('rewards', r, 'create')); } : undefined); }}>
|
||||
<input type="hidden" name="configId" value={bc.id} />
|
||||
<input type="hidden" name="memberId" value={targeted.id} />
|
||||
<div class="trigger-row">
|
||||
@@ -414,7 +414,7 @@ import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } fr
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<form 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')); } }); }}>
|
||||
<form method="POST" action="?/trigger" use:enhance={() => { return async (args: any) => handleResult(args, famStore.fam?.featureFlags?.optimisticUpdates ? (d: any) => { if (d.records) d.records.forEach((r: any) => famStore.applyRecord('rewards', r, 'create')); } : undefined); }}>
|
||||
<input type="hidden" name="configId" value={bc.id} />
|
||||
<div class="trigger-row">
|
||||
<select name="memberId" class="trigger-select">
|
||||
@@ -442,7 +442,7 @@ import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } fr
|
||||
<div class="member-claims">
|
||||
<h4><span class="dot" style="background:{m.color}"></span> {m.name}</h4>
|
||||
{#each mc as r}
|
||||
<form method="POST" action="?/claim" use:enhance={() => { return async (args: any) => handleResult(args, (d: any) => { if (d.record) { famStore.applyRecord('rewards', d.record, 'update'); } }); }}>
|
||||
<form method="POST" action="?/claim" use:enhance={() => { return async (args: any) => handleResult(args, famStore.fam?.featureFlags?.optimisticUpdates ? (d: any) => { if (d.record) famStore.applyRecord('rewards', d.record, 'update'); } : undefined); }}>
|
||||
<input type="hidden" name="id" value={r.id} />
|
||||
<input type="hidden" name="message" value={selectedMessage[m.id] || ''} />
|
||||
<div class="payment-row">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -81,7 +81,9 @@
|
||||
const body = await res.json()
|
||||
if (body.record) {
|
||||
assigned = [body.record as AssignedChore, ...assigned]
|
||||
famStore.applyRecord('assigned_chores', body.record, 'create')
|
||||
if (famStore.fam?.featureFlags?.optimisticUpdates) {
|
||||
famStore.applyRecord('assigned_chores', body.record, 'create')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,7 +123,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')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,7 +143,9 @@
|
||||
value: editValue,
|
||||
customName: editCustomName || undefined,
|
||||
} as AssignedChore
|
||||
famStore.applyRecord('assigned_chores', assigned[idx], 'update')
|
||||
if (famStore.fam?.featureFlags?.optimisticUpdates) {
|
||||
famStore.applyRecord('assigned_chores', assigned[idx], 'update')
|
||||
}
|
||||
}
|
||||
}
|
||||
closeEdit()
|
||||
@@ -165,7 +171,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()
|
||||
}
|
||||
}
|
||||
@@ -203,7 +211,7 @@
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<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} />
|
||||
<button type="submit" class="del-btn" title="Delete template">×</button>
|
||||
</form>
|
||||
@@ -235,7 +243,7 @@
|
||||
<span class="badge type">{a.type}</span>
|
||||
</div>
|
||||
<div class="card-value">{a.type === 'money' ? `£${Number(a.value).toFixed(2)}` : a.value}</div>
|
||||
<form method="POST" action="?/unassignChore" onclick={(e) => e.stopPropagation()} use:enhance={() => { return async ({ result }) => { if (result.type === 'success') { const id = result.data.record?.id; if (id) { assigned = assigned.filter((x) => x.id !== id) as AssignedChore[]; famStore.applyRecord('assigned_chores', { id }, 'delete'); } } }; }}>
|
||||
<form method="POST" action="?/unassignChore" onclick={(e) => e.stopPropagation()} use:enhance={() => { return async ({ result }) => { if (result.type === 'success') { const id = result.data.record?.id; if (id) { assigned = assigned.filter((x) => x.id !== id) as AssignedChore[]; if (famStore.fam?.featureFlags?.optimisticUpdates) { famStore.applyRecord('assigned_chores', { id }, 'delete'); } } } }; }}>
|
||||
<input type="hidden" name="id" value={a.id} />
|
||||
<button type="submit" class="del-btn" title="Remove assignment">×</button>
|
||||
</form>
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
</td>
|
||||
<td>
|
||||
{#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} />
|
||||
<Button type="submit" size="sm">Claim</Button>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user