Add FamDone Brand component and add rewards count system

This commit is contained in:
JCEEE
2026-09-02 13:32:11 +01:00
parent eaa5104817
commit 1360799b60
11 changed files with 123 additions and 54 deletions
+5
View File
@@ -362,3 +362,8 @@
- **`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`). - **`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. - **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). - **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.
+4 -5
View File
@@ -3,11 +3,11 @@
</script> </script>
<svelte:element this={tag} class="fam-done"> <svelte:element this={tag} class="fam-done">
FamDone<svg Fam<span class="text-green-500">Done</span><svg
class="fam-done-check" class="fam-done-check"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="14" width="1em"
height="14" height="1em"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="#10b981" stroke="#10b981"
@@ -24,10 +24,9 @@
color: inherit; color: inherit;
} }
.fam-done-check { .fam-done-check {
font-size: x-small;
vertical-align: super; vertical-align: super;
position: relative; position: relative;
top: -0.3em;
margin-left: 1px;
display: inline-block; display: inline-block;
} }
</style> </style>
+4
View File
@@ -513,6 +513,10 @@ async function ensureBonusFields(): Promise<void> {
changed = true; changed = true;
} }
} }
if (name === 'bonus_configs' && !has('targetChoreId')) {
fields.push({ name: 'targetChoreId', type: 'text', required: false });
changed = true;
}
// rewardValue must be optional so an unset pocket-money droplet can exist. // rewardValue must be optional so an unset pocket-money droplet can exist.
const rv = fields.find((f: any) => f.name === 'rewardValue'); const rv = fields.find((f: any) => f.name === 'rewardValue');
if (rv && rv.required) { if (rv && rv.required) {
+18 -2
View File
@@ -58,7 +58,15 @@ export async function evaluateFam(pb: any, famId: string) {
// Pocket money pauses until the parent sets an amount. // Pocket money pauses until the parent sets an amount.
if (cfg.isPocketMoney && !cfg.rewardValue) continue; if (cfg.isPocketMoney && !cfg.rewardValue) continue;
const win = bonusWindow(cfg, paydayEval, tzEval); const win = bonusWindow(cfg, paydayEval, tzEval);
const periodCompletions = allCompletions.filter((c: any) => completionInWindow(c, win)); let periodCompletions = allCompletions.filter((c: any) => completionInWindow(c, win));
// Count-type rewards can pin against a single assigned chore (targetChoreId).
// When set, only completions of that specific chore count toward the target.
if (cfg.type === 'count' && cfg.targetChoreId) {
periodCompletions = periodCompletions.filter(
(c: any) => c.assignedChoreId === cfg.targetChoreId
);
}
const existingRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id); const existingRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id);
let createdReward = false; let createdReward = false;
@@ -241,7 +249,15 @@ export async function progress(pb: any, famId: string) {
const win = bonusWindow(cfg, payday, tz); const win = bonusWindow(cfg, payday, tz);
const pStart = win.from; const pStart = win.from;
const pEnd = win.to; const pEnd = win.to;
const periodCompletions = completionsList.filter((c: any) => completionInWindow(c, win)); let periodCompletions = completionsList.filter((c: any) => completionInWindow(c, win));
// Count-type rewards pinned to a specific chore only count that chore's
// completions toward the target.
if (cfg.type === 'count' && cfg.targetChoreId) {
periodCompletions = periodCompletions.filter(
(c: any) => c.assignedChoreId === cfg.targetChoreId
);
}
const progressRows: any[] = []; const progressRows: any[] = [];
+1
View File
@@ -137,6 +137,7 @@ export interface BonusConfig {
criteriaValue?: number; criteriaValue?: number;
period?: BonusPeriod; period?: BonusPeriod;
status: BonusStatus; status: BonusStatus;
targetChoreId?: string;
isPocketMoney?: boolean; isPocketMoney?: boolean;
completeBy?: 'unlimited' | 'week' | 'custom'; completeBy?: 'unlimited' | 'week' | 'custom';
startDate?: string; startDate?: string;
@@ -522,9 +522,15 @@
const isPercent = cfg.thresholdType === 'percent'; const isPercent = cfg.thresholdType === 'percent';
const isCount = cfg.type === 'count'; const isCount = cfg.type === 'count';
// Count-type rewards pinned to a single chore scoped their count
// (and the period's reclaimable potential) to that chore only.
const countChores = isCount && cfg.targetChoreId
? myChores.filter((a) => a.id === cfg.targetChoreId)
: myChores;
// Frequency-aware total potential (a daily chore = `periodDays` // Frequency-aware total potential (a daily chore = `periodDays`
// instances, a weekly chore = 1). // instances, a weekly chore = 1).
const totalPotential = myChores.reduce( const totalPotential = countChores.reduce(
(sum, a) => sum + (a.frequency === 'daily' ? periodDays : 1), (sum, a) => sum + (a.frequency === 'daily' ? periodDays : 1),
0 0
); );
@@ -533,7 +539,9 @@
if (isPercent) { if (isPercent) {
current = periodCompletions.length; current = periodCompletions.length;
} else if (isCount) { } else if (isCount) {
current = periodCompletions.length; current = cfg.targetChoreId
? periodCompletions.filter((c) => c.assignedChoreId === cfg.targetChoreId).length
: periodCompletions.length;
} else { } else {
current = periodCompletions.reduce((sum, c) => { current = periodCompletions.reduce((sum, c) => {
const chore = assigned.find((a) => a.id === c.assignedChoreId); const chore = assigned.find((a) => a.id === c.assignedChoreId);
@@ -569,6 +577,7 @@
const unit = isPercent ? '% of chores' : isCount ? 'chores' : 'pts'; const unit = isPercent ? '% of chores' : isCount ? 'chores' : 'pts';
const targetLabel = `${configured} ${unit}`; const targetLabel = `${configured} ${unit}`;
const targetPct = isPercent ? Math.min(100, Math.max(0, configured)) : 100; const targetPct = isPercent ? Math.min(100, Math.max(0, configured)) : 100;
const targetChore = isCount ? countChores[0] : null;
return { return {
config: cfg, config: cfg,
current, current,
@@ -578,6 +587,11 @@
target, target,
totalPotential, totalPotential,
isPercent, isPercent,
targetChoreName: targetChore
? targetChore.customName ||
templates.find((t) => t.id === targetChore.templateId)?.name ||
''
: '',
pct, pct,
achieved, achieved,
rewardStatus: existingReward?.status || null, rewardStatus: existingReward?.status || null,
@@ -1252,7 +1266,11 @@
<span class="goal-badge">{@html checkCircleIcon} earned</span> <span class="goal-badge">{@html checkCircleIcon} earned</span>
{:else} {:else}
<span class="goal-pct">{pct}%</span> <span class="goal-pct">{pct}%</span>
<span class="goal-target-pill">{@html targetIcon} {goal.targetLabel}</span> <span class="goal-target-pill"
>{@html targetIcon} {goal.targetLabel}{#if goal.targetChoreName}
<span class="goal-chore"> · {goal.targetChoreName}</span>
{/if}</span
>
{/if} {/if}
</div> </div>
</div> </div>
@@ -2214,6 +2232,10 @@
width: 16px; width: 16px;
height: 16px; height: 16px;
} }
.goal-chore {
font-weight: 600;
color: #6b7280;
}
.goal-achieved .goal-bar-fill { .goal-achieved .goal-bar-fill {
background: linear-gradient(90deg, #10b981, #34d399); background: linear-gradient(90deg, #10b981, #34d399);
} }
@@ -115,6 +115,8 @@ export const actions = {
const period = fd.get('period'); const period = fd.get('period');
if (period !== null) data.period = period; if (period !== null) data.period = period;
if (data.occurrence === 'once') data.period = ''; if (data.occurrence === 'once') data.period = '';
const targetChoreId = fd.get('targetChoreId');
if (targetChoreId !== null) data.targetChoreId = targetChoreId || '';
const description = fd.get('description'); const description = fd.get('description');
if (description) data.description = description; if (description) data.description = description;
const memberId = fd.get('memberId'); const memberId = fd.get('memberId');
@@ -159,6 +161,8 @@ export const actions = {
const period = fd.get('period'); const period = fd.get('period');
if (period !== null) data.period = period; if (period !== null) data.period = period;
if (occurrence === 'once') data.period = ''; if (occurrence === 'once') data.period = '';
const targetChoreId = fd.get('targetChoreId');
if (targetChoreId !== null) data.targetChoreId = targetChoreId || '';
const description = fd.get('description'); const description = fd.get('description');
if (description) data.description = description; if (description) data.description = description;
const memberId = fd.get('memberId'); const memberId = fd.get('memberId');
@@ -212,6 +216,7 @@ export const actions = {
memberId: fd.get('memberId') || '', memberId: fd.get('memberId') || '',
status, status,
isPocketMoney: !!fd.get('isPocketMoney'), isPocketMoney: !!fd.get('isPocketMoney'),
targetChoreId: fd.get('targetChoreId') || '',
completeBy: fd.get('completeBy') || 'unlimited', completeBy: fd.get('completeBy') || 'unlimited',
startDate: fd.get('startDate') || '', startDate: fd.get('startDate') || '',
completeByDate: fd.get('completeByDate') || '' completeByDate: fd.get('completeByDate') || ''
@@ -89,7 +89,8 @@
startMode: 'today' as string, startMode: 'today' as string,
completeBy: 'unlimited' as string, completeBy: 'unlimited' as string,
startDate: '' as string, startDate: '' as string,
completeByDate: '' as string completeByDate: '' as string,
targetChoreId: '' as string
}); });
let configs = $derived( let configs = $derived(
@@ -116,6 +117,14 @@
// Standalone (no period) rewards can scope progress by completeBy + dates. // Standalone (no period) rewards can scope progress by completeBy + dates.
let showProgressWindow = $derived(editVals.type !== 'manual' && !editVals.period); let showProgressWindow = $derived(editVals.type !== 'manual' && !editVals.period);
// Target Chore options: per the selected member, their assigned (non-todo)
// chores. When no member is selected (All), list every family chore.
let targetChoreOptions = $derived(
famStore.assigned.filter(
(a) => (editVals.memberId ? a.memberId === editVals.memberId : true) && !a.isTodo
)
);
let templateConfigs = $derived((templates as BonusTemplate[]).filter((t) => t.global)); let templateConfigs = $derived((templates as BonusTemplate[]).filter((t) => t.global));
let allBonusConfigs = $derived( let allBonusConfigs = $derived(
configs.filter((c: BonusConfig) => c.status !== 'completed') as BonusConfig[] configs.filter((c: BonusConfig) => c.status !== 'completed') as BonusConfig[]
@@ -194,7 +203,8 @@
startMode: 'today', startMode: 'today',
completeBy: 'unlimited', completeBy: 'unlimited',
startDate: todayIso(), startDate: todayIso(),
completeByDate: '' completeByDate: '',
targetChoreId: ''
}; };
step = 1; step = 1;
showEditModal = true; showEditModal = true;
@@ -246,7 +256,8 @@
startMode: cfg.status === 'disabled' ? 'disabled' : 'today', startMode: cfg.status === 'disabled' ? 'disabled' : 'today',
completeBy: cfg.completeBy || 'unlimited', completeBy: cfg.completeBy || 'unlimited',
startDate: cfg.startDate || todayIso(), startDate: cfg.startDate || todayIso(),
completeByDate: cfg.completeByDate || '' completeByDate: cfg.completeByDate || '',
targetChoreId: cfg.targetChoreId || ''
}; };
step = 1; step = 1;
showEditModal = true; showEditModal = true;
@@ -372,6 +383,11 @@
<div class="bar-fill" style="width:{pct}%" class:met={achieved}></div> <div class="bar-fill" style="width:{pct}%" class:met={achieved}></div>
</div> </div>
{/if} {/if}
{#if cfg.type === 'count'}
<span class="count-progress">
{Math.min(current, criteria)} / {criteria}{cfg.targetChoreId ? ' chores' : ''}
</span>
{/if}
<span class="stat {p?.state ?? 'pending'}"> <span class="stat {p?.state ?? 'pending'}">
{#if !p || p.state === 'pending'}<Clock size={13} />{:else}{p.state}{/if} {#if !p || p.state === 'pending'}<Clock size={13} />{:else}{p.state}{/if}
</span> </span>
@@ -577,8 +593,7 @@
{/if} {/if}
<!-- STEP 1 — Who (basics) --> <!-- STEP 1 — Who (basics) -->
{#if step === 1} <div class="step-body" hidden={step !== 1}>
<div class="step-body">
<label> <label>
<span class="field-label">{@html stepIcon(1)} Name</span> <span class="field-label">{@html stepIcon(1)} Name</span>
<input name="name" bind:value={editVals.name} required /> <input name="name" bind:value={editVals.name} required />
@@ -607,16 +622,14 @@
</label> </label>
{/if} {/if}
</div> </div>
{/if}
<!-- STEP 2 — How (rules + progress) --> <!-- STEP 2 — How (rules + progress) -->
{#if step === 2} <div class="step-body" hidden={step !== 2}>
<div class="step-body">
<label> <label>
<span class="field-label"><Settings2 size={14} /> Type</span> <span class="field-label"><Settings2 size={14} /> Type</span>
<select name="type" bind:value={editVals.type}> <select name="type" bind:value={editVals.type}>
<option value="threshold">Threshold</option> <option value="threshold">Threshold</option>
<option value="count">Count</option> <option value="count">Count - (chores)</option>
<option value="manual">Manual</option> <option value="manual">Manual</option>
</select> </select>
</label> </label>
@@ -661,6 +674,23 @@
</label> </label>
{/if} {/if}
{/if} {/if}
{#if editVals.type === 'count'}
<label>
<span class="field-label"><Target size={14} /> Target Chore</span>
<select name="targetChoreId" bind:value={editVals.targetChoreId}>
<option value="">All Chores</option>
{#each targetChoreOptions as ac}
<option value={ac.id}>
{ac.customName || famStore.templateMap().get(ac.templateId || '')?.name || 'Chore'}
</option>
{/each}
</select>
<span class="hint"
>Count completions of a specific chore only. Leave as "All Chores" to count
every assigned chore.</span
>
</label>
{/if}
<label> <label>
<span class="field-label"><Settings2 size={14} /> Occurrence</span> <span class="field-label"><Settings2 size={14} /> Occurrence</span>
<select name="occurrence" bind:value={editVals.occurrence}> <select name="occurrence" bind:value={editVals.occurrence}>
@@ -724,11 +754,9 @@
</div> </div>
{/if} {/if}
</div> </div>
{/if}
<!-- STEP 3 — What (reward) --> <!-- STEP 3 — What (reward) -->
{#if step === 3} <div class="step-body" hidden={step !== 3}>
<div class="step-body">
<label> <label>
<span class="field-label"> <span class="field-label">
{#if editVals.rewardType === 'cash'} {#if editVals.rewardType === 'cash'}
@@ -805,7 +833,6 @@
</div> </div>
{/if} {/if}
</div> </div>
{/if}
<!-- Footer actions --> <!-- Footer actions -->
<div class="modal-actions"> <div class="modal-actions">
@@ -1135,6 +1162,12 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.count-progress {
font-size: 0.72rem;
color: #6b7280;
font-weight: 600;
white-space: nowrap;
}
.bar-wrap { .bar-wrap {
flex: 1; flex: 1;
height: 8px; height: 8px;
+2 -4
View File
@@ -277,8 +277,7 @@ export const actions: Actions = {
rewardType: (fd.get('rewardType') as string) || 'points', rewardType: (fd.get('rewardType') as string) || 'points',
rewardValue: (fd.get('rewardValue') as string) || '', rewardValue: (fd.get('rewardValue') as string) || '',
criteriaValue: Number(fd.get('criteriaValue') || 0), criteriaValue: Number(fd.get('criteriaValue') || 0),
period: (fd.get('period') as string) || 'weekly', period: (fd.get('period') as string) || 'weekly'
isPocketMoney: fd.get('isPocketMoney') === 'true'
}); });
} }
return { success: true }; return { success: true };
@@ -322,8 +321,7 @@ export const actions: Actions = {
rewardType: (fd.get('rewardType') as string) || 'points', rewardType: (fd.get('rewardType') as string) || 'points',
rewardValue: (fd.get('rewardValue') as string) || '', rewardValue: (fd.get('rewardValue') as string) || '',
criteriaValue: Number(fd.get('criteriaValue') || 0), criteriaValue: Number(fd.get('criteriaValue') || 0),
period: (fd.get('period') as string) || 'weekly', period: (fd.get('period') as string) || 'weekly'
isPocketMoney: fd.get('isPocketMoney') === 'true'
}); });
} }
return { success: true }; return { success: true };
+4 -19
View File
@@ -45,8 +45,7 @@
rewardType: 'points', rewardType: 'points',
rewardValue: '', rewardValue: '',
criteriaValue: 0, criteriaValue: 0,
period: 'weekly', period: 'weekly'
isPocketMoney: false
}; };
let tplVals = $state({ ...blankTpl }); let tplVals = $state({ ...blankTpl });
@@ -74,8 +73,7 @@
rewardType: rec.rewardType || 'points', rewardType: rec.rewardType || 'points',
rewardValue: rec.rewardValue ?? '', rewardValue: rec.rewardValue ?? '',
criteriaValue: rec.criteriaValue ?? 0, criteriaValue: rec.criteriaValue ?? 0,
period: rec.period || 'weekly', period: rec.period || 'weekly'
isPocketMoney: !!rec.isPocketMoney
}; };
showTplModal = true; showTplModal = true;
} }
@@ -524,7 +522,7 @@
Type Type
<select name="bonusType" bind:value={tplVals.bonusType}> <select name="bonusType" bind:value={tplVals.bonusType}>
<option value="threshold">Threshold</option> <option value="threshold">Threshold</option>
<option value="count">Count</option> <option value="count">Count - (chores)</option>
<option value="manual">Manual</option> <option value="manual">Manual</option>
</select> </select>
</label> </label>
@@ -568,15 +566,7 @@
<option value="monthly">Monthly</option> <option value="monthly">Monthly</option>
</select> </select>
</label> </label>
<label class="check">
<input
type="checkbox"
name="isPocketMoney"
value="true"
bind:checked={tplVals.isPocketMoney}
/>
Pocket Money
</label>
{/if} {/if}
<div class="modal-actions"> <div class="modal-actions">
@@ -1118,11 +1108,6 @@
background: #dcfce7; background: #dcfce7;
color: #166534; color: #166534;
} }
.check {
display: flex;
align-items: center;
gap: 0.4rem;
}
/* ── Modal (shared) ── */ /* ── Modal (shared) ── */
.overlay { .overlay {
+1
View File
@@ -228,6 +228,7 @@ export const SCHEMA_PLAN: CollectionPlanEntry[] = [
rel("memberId", ids.users), rel("memberId", ids.users),
select("period", ["schedule", "daily", "weekly", "monthly"]), select("period", ["schedule", "daily", "weekly", "monthly"]),
select("status", ["active", "completed"], true), select("status", ["active", "completed"], true),
text("targetChoreId"),
bool("isPocketMoney"), bool("isPocketMoney"),
// Standalone/one-off reward progress window. `period` stays empty for // Standalone/one-off reward progress window. `period` stays empty for
// these (they're measured once, not per-cycle). `completeBy` bounds how // these (they're measured once, not per-cycle). `completeBy` bounds how