diff --git a/MEMORY.md b/MEMORY.md index 00b6da6..4534100 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -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`). - **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. diff --git a/frontend/src/lib/components/FamDone.svelte b/frontend/src/lib/components/FamDone.svelte index 04e5901..97d803f 100644 --- a/frontend/src/lib/components/FamDone.svelte +++ b/frontend/src/lib/components/FamDone.svelte @@ -3,11 +3,11 @@ - FamDoneDone diff --git a/frontend/src/lib/server/migrate.ts b/frontend/src/lib/server/migrate.ts index 2ac4474..ee03072 100644 --- a/frontend/src/lib/server/migrate.ts +++ b/frontend/src/lib/server/migrate.ts @@ -513,6 +513,10 @@ async function ensureBonusFields(): Promise { 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. const rv = fields.find((f: any) => f.name === 'rewardValue'); if (rv && rv.required) { diff --git a/frontend/src/lib/server/services/bonuses.ts b/frontend/src/lib/server/services/bonuses.ts index 7f03670..c9eecc5 100644 --- a/frontend/src/lib/server/services/bonuses.ts +++ b/frontend/src/lib/server/services/bonuses.ts @@ -58,7 +58,15 @@ export async function evaluateFam(pb: any, famId: string) { // Pocket money pauses until the parent sets an amount. if (cfg.isPocketMoney && !cfg.rewardValue) continue; 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); let createdReward = false; @@ -241,7 +249,15 @@ export async function progress(pb: any, famId: string) { const win = bonusWindow(cfg, payday, tz); const pStart = win.from; 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[] = []; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index ce537fa..07d6dce 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -137,6 +137,7 @@ export interface BonusConfig { criteriaValue?: number; period?: BonusPeriod; status: BonusStatus; + targetChoreId?: string; isPocketMoney?: boolean; completeBy?: 'unlimited' | 'week' | 'custom'; startDate?: string; diff --git a/frontend/src/routes/[fam]/[username]/+page.svelte b/frontend/src/routes/[fam]/[username]/+page.svelte index 63f4945..ea1232b 100644 --- a/frontend/src/routes/[fam]/[username]/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/+page.svelte @@ -522,9 +522,15 @@ const isPercent = cfg.thresholdType === 'percent'; 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` // instances, a weekly chore = 1). - const totalPotential = myChores.reduce( + const totalPotential = countChores.reduce( (sum, a) => sum + (a.frequency === 'daily' ? periodDays : 1), 0 ); @@ -533,7 +539,9 @@ if (isPercent) { current = periodCompletions.length; } else if (isCount) { - current = periodCompletions.length; + current = cfg.targetChoreId + ? periodCompletions.filter((c) => c.assignedChoreId === cfg.targetChoreId).length + : periodCompletions.length; } else { current = periodCompletions.reduce((sum, c) => { const chore = assigned.find((a) => a.id === c.assignedChoreId); @@ -569,6 +577,7 @@ const unit = isPercent ? '% of chores' : isCount ? 'chores' : 'pts'; const targetLabel = `${configured} ${unit}`; const targetPct = isPercent ? Math.min(100, Math.max(0, configured)) : 100; + const targetChore = isCount ? countChores[0] : null; return { config: cfg, current, @@ -578,6 +587,11 @@ target, totalPotential, isPercent, + targetChoreName: targetChore + ? targetChore.customName || + templates.find((t) => t.id === targetChore.templateId)?.name || + '' + : '', pct, achieved, rewardStatus: existingReward?.status || null, @@ -1252,7 +1266,11 @@ {@html checkCircleIcon} earned {:else} {pct}% - {@html targetIcon} {goal.targetLabel} + {@html targetIcon} {goal.targetLabel}{#if goal.targetChoreName} + · {goal.targetChoreName} + {/if} {/if} @@ -2214,6 +2232,10 @@ width: 16px; height: 16px; } + .goal-chore { + font-weight: 600; + color: #6b7280; + } .goal-achieved .goal-bar-fill { background: linear-gradient(90deg, #10b981, #34d399); } diff --git a/frontend/src/routes/[fam]/[username]/rewards/+page.server.ts b/frontend/src/routes/[fam]/[username]/rewards/+page.server.ts index 48463bc..0de8228 100644 --- a/frontend/src/routes/[fam]/[username]/rewards/+page.server.ts +++ b/frontend/src/routes/[fam]/[username]/rewards/+page.server.ts @@ -115,6 +115,8 @@ export const actions = { const period = fd.get('period'); if (period !== null) data.period = period; if (data.occurrence === 'once') data.period = ''; + const targetChoreId = fd.get('targetChoreId'); + if (targetChoreId !== null) data.targetChoreId = targetChoreId || ''; const description = fd.get('description'); if (description) data.description = description; const memberId = fd.get('memberId'); @@ -159,6 +161,8 @@ export const actions = { const period = fd.get('period'); if (period !== null) data.period = period; if (occurrence === 'once') data.period = ''; + const targetChoreId = fd.get('targetChoreId'); + if (targetChoreId !== null) data.targetChoreId = targetChoreId || ''; const description = fd.get('description'); if (description) data.description = description; const memberId = fd.get('memberId'); @@ -212,6 +216,7 @@ export const actions = { memberId: fd.get('memberId') || '', status, isPocketMoney: !!fd.get('isPocketMoney'), + targetChoreId: fd.get('targetChoreId') || '', completeBy: fd.get('completeBy') || 'unlimited', startDate: fd.get('startDate') || '', completeByDate: fd.get('completeByDate') || '' diff --git a/frontend/src/routes/[fam]/[username]/rewards/+page.svelte b/frontend/src/routes/[fam]/[username]/rewards/+page.svelte index 07b135a..5d40337 100644 --- a/frontend/src/routes/[fam]/[username]/rewards/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/rewards/+page.svelte @@ -89,7 +89,8 @@ startMode: 'today' as string, completeBy: 'unlimited' as string, startDate: '' as string, - completeByDate: '' as string + completeByDate: '' as string, + targetChoreId: '' as string }); let configs = $derived( @@ -116,6 +117,14 @@ // Standalone (no period) rewards can scope progress by completeBy + dates. 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 allBonusConfigs = $derived( configs.filter((c: BonusConfig) => c.status !== 'completed') as BonusConfig[] @@ -194,7 +203,8 @@ startMode: 'today', completeBy: 'unlimited', startDate: todayIso(), - completeByDate: '' + completeByDate: '', + targetChoreId: '' }; step = 1; showEditModal = true; @@ -246,7 +256,8 @@ startMode: cfg.status === 'disabled' ? 'disabled' : 'today', completeBy: cfg.completeBy || 'unlimited', startDate: cfg.startDate || todayIso(), - completeByDate: cfg.completeByDate || '' + completeByDate: cfg.completeByDate || '', + targetChoreId: cfg.targetChoreId || '' }; step = 1; showEditModal = true; @@ -372,6 +383,11 @@
{/if} + {#if cfg.type === 'count'} + + {Math.min(current, criteria)} / {criteria}{cfg.targetChoreId ? ' chores' : ''} + + {/if} {#if !p || p.state === 'pending'}{:else}{p.state}{/if} @@ -577,8 +593,7 @@ {/if} - {#if step === 1} -
+ - {/if} - {#if step === 2} -
+