From 3f95d50d2ecb4397151b2eb4dfdc8ad8de75ed29 Mon Sep 17 00:00:00 2001 From: JCEEE <0xjceee@proton.me> Date: Wed, 9 Sep 2026 19:02:29 +0100 Subject: [PATCH] clean pass update with rewards and completions --- frontend/src/lib/server/services/bonuses.ts | 150 +-- frontend/src/routes/[fam]/+page.server.ts | 8 +- frontend/src/routes/[fam]/+page.svelte | 33 + .../src/routes/[fam]/[username]/+page.svelte | 350 ++++++- .../[fam]/[username]/rewards/+page.svelte | 964 ++++++++++-------- shared/bonus-progress.ts | 211 ++++ 6 files changed, 1126 insertions(+), 590 deletions(-) create mode 100644 shared/bonus-progress.ts diff --git a/frontend/src/lib/server/services/bonuses.ts b/frontend/src/lib/server/services/bonuses.ts index 027365f..fecfbe1 100644 --- a/frontend/src/lib/server/services/bonuses.ts +++ b/frontend/src/lib/server/services/bonuses.ts @@ -8,12 +8,21 @@ import { bonusWindow, completionInWindow } from '@shared/timezone'; +import { computeBonusProgress } from '@shared/bonus-progress'; import { famMeta } from './fam'; function resolveServerTz(tz?: string): string { return resolveTz(tz || 'auto'); } +const getRewardValue = (cfg: any): number => { + const n = Number(cfg.rewardValue); + // Prize rewards carry the payout in `label`/rewardValue text ("fluffy toy"), + // not a number — but PB rejects value 0 on the required number field, so + // fall back to 1 and let the label carry the meaning. + return Number.isFinite(n) && n > 0 ? n : 1; +}; + function claimableStamp(cfg: any, payday: number, tz: string) { if (cfg.period !== 'weekly' && cfg.period !== 'monthly') { return { claimable: 'immediate', settleDate: '' }; @@ -25,7 +34,11 @@ function claimableStamp(cfg: any, payday: number, tz: string) { } function targetChoreFor(cfg: any, memberId: string): string | undefined { - if (cfg.targetChoreIds && typeof cfg.targetChoreIds === 'object' && cfg.targetChoreIds[memberId]) { + if ( + cfg.targetChoreIds && + typeof cfg.targetChoreIds === 'object' && + cfg.targetChoreIds[memberId] + ) { return cfg.targetChoreIds[memberId]; } return cfg.targetChoreId || undefined; @@ -57,7 +70,8 @@ export async function evaluateFam(pb: any, famId: string) { // `percent` threshold type to compute % of chores completed. const dueByMember: Record = {}; for (const a of allAssigned) { - if (a.memberId) dueByMember[a.memberId] = (dueByMember[a.memberId] || 0) + (a.frequency === 'daily' ? 7 : 1); + if (a.memberId) + dueByMember[a.memberId] = (dueByMember[a.memberId] || 0) + (a.frequency === 'daily' ? 7 : 1); } const totalDue = Object.values(dueByMember).reduce((s, n) => s + n, 0) || 1; @@ -81,7 +95,7 @@ export async function evaluateFam(pb: any, famId: string) { memberId, bonusConfigId: cfg.id, label, - value: Number(cfg.rewardValue) || 0, + value: getRewardValue(cfg), rewardType: cfg.rewardType, status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed', claimedAt: cfg.rewardType === 'points' ? now : null, @@ -91,11 +105,16 @@ export async function evaluateFam(pb: any, famId: string) { }; if (cfg.target === 'individual') { - const targetMembers = cfg.memberId ? allMembers.filter((m: any) => m.id === cfg.memberId) : allMembers; + const targetMembers = cfg.memberId + ? allMembers.filter((m: any) => m.id === cfg.memberId) + : allMembers; for (const m of targetMembers) { const memberTarget = targetChoreFor(cfg, m.id); let memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id); - if (memberTarget) memberCompletions = memberCompletions.filter((c: any) => c.assignedChoreId === memberTarget); + if (memberTarget) + memberCompletions = memberCompletions.filter( + (c: any) => c.assignedChoreId === memberTarget + ); let current = 0; if (cfg.type === 'threshold') { if (cfg.thresholdType === 'percent') { @@ -133,7 +152,10 @@ export async function evaluateFam(pb: any, famId: string) { } else if (cfg.target === 'collaborative') { const allMemberIds = allMembers.map((m: any) => m.id); let teamCompletions = periodCompletions.filter((c: any) => allMemberIds.includes(c.memberId)); - if (cfg.targetChoreId) teamCompletions = teamCompletions.filter((c: any) => c.assignedChoreId === cfg.targetChoreId); + if (cfg.targetChoreId) + teamCompletions = teamCompletions.filter( + (c: any) => c.assignedChoreId === cfg.targetChoreId + ); let total = 0; if (cfg.type === 'threshold') { if (cfg.thresholdType === 'percent') { @@ -170,7 +192,10 @@ export async function evaluateFam(pb: any, famId: string) { const scored = allMembers.map((m: any) => { const memberTarget = targetChoreFor(cfg, m.id); let memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id); - if (memberTarget) memberCompletions = memberCompletions.filter((c: any) => c.assignedChoreId === memberTarget); + if (memberTarget) + memberCompletions = memberCompletions.filter( + (c: any) => c.assignedChoreId === memberTarget + ); let current = 0; if (cfg.type === 'threshold') { if (cfg.thresholdType === 'percent') { @@ -187,7 +212,9 @@ export async function evaluateFam(pb: any, famId: string) { } return { memberId: m.id, name: m.name, current }; }); - const qualified = scored.filter((s: any) => cfg.criteriaValue > 0 && s.current >= Number(cfg.criteriaValue)); + const qualified = scored.filter( + (s: any) => cfg.criteriaValue > 0 && s.current >= Number(cfg.criteriaValue) + ); const eligible = qualified.length > 0 ? qualified : scored.filter((s: any) => s.current > 0); const winner = eligible.sort((a: any, b: any) => b.current - a.current)[0]; @@ -233,106 +260,13 @@ export async function progress(pb: any, famId: string) { pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }), pb.collection('completions').getFullList({ filter: `famId = '${famId}'` }) ]); - - const assignedList = assigned; - const completionsList = completions; - const dueByMember: Record = {}; - for (const a of assignedList) { - if (a.memberId) dueByMember[a.memberId] = (dueByMember[a.memberId] || 0) + (a.frequency === 'daily' ? 7 : 1); - } - const totalDue = Object.values(dueByMember).reduce((s, n) => s + n, 0) || 1; let allRewards: any[] = []; try { allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }); } catch {} - - const result: any[] = []; - - for (const cfg of configsData) { - const cfgRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id); - const win = bonusWindow(cfg, payday, tz); - const pStart = win.from; - const pEnd = win.to; - const periodCompletions = completionsList.filter((c: any) => completionInWindow(c, win)); - - const progressRows: any[] = []; - - if (cfg.target === 'collaborative') { - let teamCompletions = periodCompletions.filter((c: any) => - members.some((m: any) => m.id === c.memberId) - ); - if (cfg.targetChoreId) teamCompletions = teamCompletions.filter((c: any) => c.assignedChoreId === cfg.targetChoreId); - let teamCurrent = 0; - if (cfg.type === 'threshold') { - if (cfg.thresholdType === 'percent') { - teamCurrent = Math.round((teamCompletions.length / totalDue) * 100); - } else { - teamCurrent = teamCompletions.reduce((sum: number, c: any) => { - const chore = assignedList.find((a: any) => a.id === c.assignedChoreId); - return sum + (chore?.type === 'points' ? Number(chore.value) : 0); - }, 0); - } - } else if (cfg.type === 'count') { - teamCurrent = teamCompletions.length; - } - const teamReward = cfgRewards[0]; - progressRows.push({ - memberId: '__team__', - memberName: 'Team Total', - memberColor: '#8b5cf6', - current: teamCurrent, - criteriaValue: cfg.criteriaValue || 0, - reward: teamReward ? { id: teamReward.id, status: teamReward.status } : null, - state: teamReward ? teamReward.status : 'pending', - achieved: teamReward ? true : false - }); - } - - if (cfg.target !== 'collaborative') { - const progressMembers = - cfg.target === 'individual' && cfg.memberId - ? members.filter((m: any) => m.id === cfg.memberId) - : members; - for (const m of progressMembers) { - const memberTarget = targetChoreFor(cfg, m.id); - let memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id); - if (memberTarget) memberCompletions = memberCompletions.filter((c: any) => c.assignedChoreId === memberTarget); - const memberReward = cfgRewards.find((r: any) => r.memberId === m.id); - - let current = 0; - if (cfg.type === 'threshold') { - if (cfg.thresholdType === 'percent') { - const due = dueByMember[m.id] || 0; - current = due > 0 ? Math.round((memberCompletions.length / due) * 100) : 0; - } else { - current = memberCompletions.reduce((sum: number, c: any) => { - const chore = assignedList.find((a: any) => a.id === c.assignedChoreId); - return sum + (chore?.type === 'points' ? Number(chore.value) : 0); - }, 0); - } - } else if (cfg.type === 'count') { - current = memberCompletions.length; - } else if (cfg.type === 'manual') { - current = 0; - } - - progressRows.push({ - memberId: m.id, - memberName: m.name, - memberColor: m.color, - current, - criteriaValue: cfg.criteriaValue || 0, - reward: memberReward ? { id: memberReward.id, status: memberReward.status } : null, - state: memberReward ? memberReward.status : 'pending', - achieved: memberReward ? true : false - }); - } - } - - result.push({ config: cfg, progress: progressRows, periodStart: pStart, periodEnd: pEnd }); - } - - return result; + // Shared with the Rewards page client-side realtime recompute (famStore + SSE), + // so the admin kanban and member dashboard always show identical progress. + return computeBonusProgress(configsData, members, assigned, completions, allRewards, payday, tz); } export async function trigger(pb: any, famId: string, configId: string, memberId?: string) { @@ -373,9 +307,7 @@ export async function trigger(pb: any, famId: string, configId: string, memberId const pEnd = periodEnd(cfg.period, pStart); const periodRewards = memberRewards.filter((r: any) => r.date >= pStart && r.date <= pEnd); if (periodRewards.length > 0) { - throw new Error( - `Already issued ${periodRewards.length}x this ${cfg.period} to ${m.name}` - ); + throw new Error(`Already issued ${periodRewards.length}x this ${cfg.period} to ${m.name}`); } } } @@ -392,7 +324,7 @@ export async function trigger(pb: any, famId: string, configId: string, memberId memberId: m.id, bonusConfigId: cfg.id, label, - value: Number(cfg.rewardValue) || 0, + value: getRewardValue(cfg), rewardType: cfg.rewardType, status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed', claimedAt: cfg.rewardType === 'points' ? now : null, @@ -408,4 +340,4 @@ export async function trigger(pb: any, famId: string, configId: string, memberId } return { triggered: true, created: created.length, records: created }; -} \ No newline at end of file +} diff --git a/frontend/src/routes/[fam]/+page.server.ts b/frontend/src/routes/[fam]/+page.server.ts index 045a0af..70d1aea 100644 --- a/frontend/src/routes/[fam]/+page.server.ts +++ b/frontend/src/routes/[fam]/+page.server.ts @@ -9,14 +9,16 @@ export async function load(event) { const s = createServices(pbUser(event), session); try { - const [members, assigned, summary, fam, stats] = await Promise.all([ + const [members, assigned, summary, fam, stats, rewards, bonusConfigs] = await Promise.all([ s.crud.list('members', famId), s.crud.list('assigned-chores', famId), s.fam.weeklySummary(famId), s.fam.get(famId), - s.stats.family(famId) + s.stats.family(famId), + s.crud.list('rewards', famId), + s.crud.list('bonus-configs', famId) ]); - return { members, assigned, summary, fam, stats }; + return { members, assigned, summary, fam, stats, rewards, bonusConfigs }; } catch { return {}; } diff --git a/frontend/src/routes/[fam]/+page.svelte b/frontend/src/routes/[fam]/+page.svelte index 58c0188..da97f1b 100644 --- a/frontend/src/routes/[fam]/+page.svelte +++ b/frontend/src/routes/[fam]/+page.svelte @@ -4,6 +4,7 @@ import { Chart, registerables } from 'chart.js'; import { ViewHeader, CardGrid, Card } from '$lib/components'; import { formatDDMMYY } from '$lib/format'; + import { famStore } from '$lib/stores/fam.svelte'; import { handleOf } from '@shared/slugify'; import { addDaysStr, resolveTz, todayInTz } from '@shared/timezone'; @@ -54,6 +55,28 @@ let allTimeCash = $derived(Number(stats.allTimeCash) || 0); let allTimeChores = $derived(Number(stats.allTimeChores) || 0); + // ─── Goals completed (rewards from bonus configs, pocket money excluded) ─── + // Derived from famStore so realtime updates (a goal met / revoked) flow in. + let rewards = $derived(famStore.initialized ? famStore.rewards : data.rewards || []); + let bonusConfigs = $derived( + famStore.initialized ? famStore.bonusConfigs : data.bonusConfigs || [] + ); + let goalsByMember = $derived.by(() => { + const ws = (summary?.weekStart as string) || todayInTz(famTz); + const pmIds = new Set(bonusConfigs.filter((c: any) => c.isPocketMoney).map((c: any) => c.id)); + const map = new Map(); + for (const r of rewards as any[]) { + if (!r.bonusConfigId || pmIds.has(r.bonusConfigId)) continue; + if ((r.date || '').slice(0, 10) < ws) continue; + const cfg = bonusConfigs.find((c: any) => c.id === r.bonusConfigId); + const entry = map.get(r.memberId) || { count: 0, names: [] }; + entry.count++; + if (cfg?.name && !entry.names.includes(cfg.name)) entry.names.push(cfg.name); + map.set(r.memberId, entry); + } + return map; + }); + // ─── Trend chart window: show current month, expand a month at a time up to 3. ─── let trend = $derived.by(() => { const series = Array.isArray(stats.series) ? stats.series : []; @@ -340,6 +363,7 @@ {@const scPot = dailyPointsPotential(s.memberId)} {@const scVal = avgDailyPointsFor(s)} {@const scPct = scPot > 0 ? Math.min(100, Math.round((scVal / scPot) * 100)) : 0} + {@const goals = goalsByMember.get(s.memberId)}
@@ -378,6 +402,12 @@ Done {wkDone}/{wkTotal}
+
+ Goals 🎉 + {goals?.count ?? 0} +
{/each} @@ -656,6 +686,9 @@ .stat-val.cash { color: #059669; } + .stat-val.goals { + color: #059669; + } /* ── Chart ── */ .chart-wrap { diff --git a/frontend/src/routes/[fam]/[username]/+page.svelte b/frontend/src/routes/[fam]/[username]/+page.svelte index 3ea7956..8e2501c 100644 --- a/frontend/src/routes/[fam]/[username]/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/+page.svelte @@ -163,6 +163,34 @@ }); } + // Goals completed feed for the admin overview — every materialized bonus + // reward (prize / points / cash), newest first. Undoing the underlying + // chore/points deletes the unclaimed reward server-side, so this list + // shrinks again in realtime via famStore. + let parentGoalsCompleted = $derived.by(() => + parentRewards + .filter((r: any) => r.bonusConfigId) + .map((r: any) => { + const cfg = parentBonusConfigs.find((c: any) => c.id === r.bonusConfigId); + const m = parentMembers.find((x: any) => x.id === r.memberId); + return { + id: r.id, + goalName: cfg?.name || r.label, + memberName: m?.name || 'Unknown', + color: m?.color || '#6366f1', + rewardText: + r.rewardType === 'cash' + ? `£${Number(r.value).toFixed(2)}` + : r.rewardType === 'points' + ? `${r.value} pts` + : cfg?.rewardValue || r.label, + date: (r.date || '').slice(0, 10) + }; + }) + .sort((a: any, b: any) => (b.date || '').localeCompare(a.date || '')) + .slice(0, 10) + ); + // ─── Child View (kanban) ─── let memberId = $state(data.memberId || ''); let pbToken = $state(data.token || ''); @@ -179,10 +207,9 @@ : (data.templates as ChoreTemplate[]) ); let assigned = $derived( - ( - famStore.initialized - ? (famStore.assigned as AssignedChore[]) - : (data.assigned as AssignedChore[]) + (famStore.initialized + ? (famStore.assigned as AssignedChore[]) + : (data.assigned as AssignedChore[]) ).map((a) => (a.memberId === '' ? { ...a, memberId: null } : a)) ); let completions = $derived( @@ -489,8 +516,11 @@ const name = bonusConfigs.find((b: BonusConfig) => b.id === r.bonusConfigId)?.name || r.label; map.set(name, (map.get(name) || 0) + 1); } - return [...map.entries()].map(([name, count]) => ({ name, count })); + // Only badge recurring/multiple bonuses — a single occurrence (×1) is + // already obvious from the wallet, so it just adds noise here. + return [...map.entries()].map(([name, count]) => ({ name, count })).filter((t) => t.count > 1); }); + let anyWeekBonuses = $derived(weekRewards.length > 0); // ─── Threshold Goals (bonus configs the member is chasing) ─── let thresholdGoals = $derived.by(() => { @@ -608,6 +638,71 @@ }); }); + // ─── Goal celebrations (bonus rewards achieved) ─── + // A goal flips to achieved the moment its completions cross the threshold. + // We celebrate once per goal per day (localStorage, so the notice survives + // reloads but expires at the next 00:00), re-arm when the achievement is + // revoked (chore/points undone), and always show the reward value/prize. + const goalCelebratedKey = `goalCelebrated:${famId}`; + let goalCelebrated = $state>( + typeof localStorage !== 'undefined' + ? (JSON.parse(localStorage.getItem(goalCelebratedKey) || '{}') as Record) + : {} + ); + let goalCelebrations = $state<{ id: string; name: string; reward: string }[]>([]); + + function goalRewardLabel(g: { config: any }): string { + const cfg = g.config; + if (cfg.rewardType === 'cash') return `£${Number(cfg.rewardValue).toFixed(2)}`; + if (cfg.rewardType === 'prize') return cfg.rewardValue || 'prize'; + return `${cfg.rewardValue} pts`; + } + + function dismissGoalCelebrate(id: string) { + goalCelebrations = goalCelebrations.filter((c) => c.id !== id); + goalCelebrated = { ...goalCelebrated, [id]: todayChild }; + try { + localStorage.setItem(goalCelebratedKey, JSON.stringify(goalCelebrated)); + } catch {} + } + + $effect(() => { + if (role !== 'child') return; + const today = todayChild; + let next: Record = { ...goalCelebrated }; + let changed = false; + // Yesterday's celebrations expire — a notice lives for its day only. + for (const [id, day] of Object.entries(next)) { + if (day !== today) { + delete next[id]; + changed = true; + } + } + for (const g of thresholdGoals) { + if (g.achieved && next[g.config.id] !== today) { + next[g.config.id] = today; + changed = true; + goalCelebrations = [ + { id: g.config.id, name: g.config.name, reward: goalRewardLabel(g) }, + ...goalCelebrations.filter((c) => c.id !== g.config.id) + ]; + fire(); + } else if (!g.achieved && next[g.config.id]) { + // Achievement revoked (chore/points undone) — drop the notice and + // re-arm so completing it again celebrates again. + delete next[g.config.id]; + changed = true; + goalCelebrations = goalCelebrations.filter((c) => c.id !== g.config.id); + } + } + if (changed) { + goalCelebrated = next; + try { + localStorage.setItem(goalCelebratedKey, JSON.stringify(next)); + } catch {} + } + }); + function shortDate(dateStr: string): string { const d = new Date(dateStr + 'T00:00:00'); const day = String(d.getDate()).padStart(2, '0'); @@ -802,6 +897,31 @@ + +
+ {#if parentGoalsCompleted.length === 0} +

+ No goals completed yet — rewards appear here the moment a member hits a target. +

+ {:else} +
+ {#each parentGoalsCompleted as g} +
+
+ + 🎉 {g.goalName} + {g.memberName} +
+
+ {g.rewardText} + {formatDDMMYY(g.date)} +
+
+ {/each} +
+ {/if} +
+
@@ -1205,12 +1325,14 @@
- {#if weekBonusTallies.length > 0} -
- {#each weekBonusTallies as t} - 🎉 {t.name} ×{t.count} - {/each} -
+ {#if anyWeekBonuses} + {#if weekBonusTallies.length > 0} +
+ {#each weekBonusTallies as t} + 🎉 {t.name} ×{t.count} + {/each} +
+ {/if} {:else} no bonuses earned yet {/if} @@ -1260,35 +1382,42 @@ {/if}
-
-
- {#if goal.targetPct < 100} -
- {/if} -
-
- {#if goal.isPercent && goal.totalPotential === 0} - no chores set - {:else if goal.isPercent} - {Math.min(goal.current, goal.totalPotential)} / {goal.totalPotential} - {:else} - {Math.min(goal.current, goal.target)} / {goal.target} - {/if} - {#if goal.achieved} - {@html checkCircleIcon} earned - {:else} + {#if goal.achieved} +
+ {@html checkCircleIcon} + + Reward earned + {goalRewardLabel(goal)} + +
+ {:else} +
+
+ {#if goal.targetPct < 100} +
+ {/if} +
+
+ {#if goal.isPercent && goal.totalPotential === 0} + no chores set + {:else if goal.isPercent} + {Math.min(goal.current, goal.totalPotential)} / {goal.totalPotential} + {:else} + {Math.min(goal.current, goal.target)} / {goal.target} + {/if} {pct}% {@html targetIcon} {goal.targetLabel}{#if goal.targetChoreName} + >{@html targetIcon} + {goal.targetLabel}{#if goal.targetChoreName} · {goal.targetChoreName} {/if} - {/if} -
+
+ {/if}
{/each} @@ -1311,7 +1440,7 @@ {:else} - {#if celebrations.length > 0} + {#if celebrations.length > 0 || goalCelebrations.length > 0}
{#each celebrations as cel}
@@ -1322,6 +1451,16 @@
{/each} + {#each goalCelebrations as cel} +
+ 🎉 + {cel.name} complete — you earned {cel.reward}! + +
+ {/each}
{/if}
@@ -1451,12 +1590,8 @@ {@html walletIcon} Wallet
- £{allTimeCash.toFixed(2)} all time - {allTimePoints.toLocaleString()} pts + £{allTimeCash.toFixed(2)} all time + {allTimePoints.toLocaleString()} pts
@@ -1545,7 +1680,7 @@ border-left: 4px solid #6366f1; border-radius: 10px; padding: 0.65rem 0.85rem; - background: white; + background: #f6fbff; margin-bottom: 0.6rem; transition: transform 0.15s, @@ -1647,7 +1782,7 @@ color: #059669; } .done-item.odd { - background: #f3f4f6; + background: #ffffff; } .done-icon { display: inline-flex; @@ -2283,6 +2418,133 @@ color: #6366f1; } + /* Achieved goal — big tick + reward, progress hidden */ + .goal-done { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 0 0.15rem; + } + .goal-tick { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 50%; + background: #10b981; + color: #fff; + flex: none; + } + .goal-tick :global(svg) { + width: 20px; + height: 20px; + } + .goal-done-text { + display: flex; + flex-direction: column; + gap: 0.1rem; + font-size: 0.85rem; + color: #047857; + } + .goal-done-text b { + font-size: 0.9rem; + } + .goal-done-text span { + font-weight: 800; + color: #059669; + font-size: 1rem; + } + .goal-achieved .goal-done { + animation: goal-pop 0.35s ease; + } + @keyframes goal-pop { + 0% { + transform: scale(0.9); + opacity: 0; + } + 100% { + transform: scale(1); + opacity: 1; + } + } + + /* Admin overview — "Goals completed" mini cards */ + .goals-completed { + margin-bottom: 1rem; + } + .goal-mini-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.6rem; + } + @media (max-width: 1023px) { + .goal-mini-grid { + grid-template-columns: repeat(2, 1fr); + } + } + @media (max-width: 639px) { + .goal-mini-grid { + grid-template-columns: 1fr; + } + } + .goal-mini { + background: linear-gradient(135deg, #d1d3fa, #ccfbf1); + border: 1px solid #a7f3d0; + border-radius: 12px; + padding: 0.55rem 0.7rem; + display: flex; + flex-direction: column; + gap: 0.4rem; + min-width: 0; + } + .gm-top { + display: flex; + align-items: center; + gap: 0.4rem; + min-width: 0; + } + .gm-top .dot { + flex: none; + } + .gm-name { + font-weight: 700; + font-size: 0.82rem; + color: #7a43bf; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + } + .gm-member { + font-size: 0.72rem; + font-weight: 600; + color: #6b7280; + flex: none; + } + .gm-bottom { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.4rem; + } + .gm-reward { + font-weight: 800; + color: #047857; + font-size: 0.85rem; + } + .gm-date { + color: #9ca3af; + font-size: 0.72rem; + } + + /* Goal celebration notice (child view) */ + .celebrate-card.goal { + border-color: #86efac; + background: #ecfdf5; + } + /* ── Kanban ── */ .celebrations { display: flex; diff --git a/frontend/src/routes/[fam]/[username]/rewards/+page.svelte b/frontend/src/routes/[fam]/[username]/rewards/+page.svelte index 7a7add8..a0551d4 100644 --- a/frontend/src/routes/[fam]/[username]/rewards/+page.svelte +++ b/frontend/src/routes/[fam]/[username]/rewards/+page.svelte @@ -6,8 +6,13 @@ import { ViewHeader, CardGrid, Card, Button } from '$lib/components'; import TemplateIcon from '$lib/components/TemplateIcon.svelte'; import { resolveTz, todayInTz, addDaysStr } from '@shared/timezone'; + import { + computeBonusProgress, + type BonusConfigWithProgress as BonusProgressResult + } from '@shared/bonus-progress'; import { chevronRight } from '$lib/components/icons'; import Clock from '@lucide/svelte/icons/clock'; + import Check from '@lucide/svelte/icons/check'; import Tag from '@lucide/svelte/icons/tag'; import UsersRound from '@lucide/svelte/icons/users-round'; import Target from '@lucide/svelte/icons/target'; @@ -27,8 +32,7 @@ BonusTemplate, Member, AssignedChore, - BonusConfigWithProgress, - BonusProgress, + Completion, Reward } from '$lib/types'; @@ -116,11 +120,33 @@ let allRewards = $derived( famStore.initialized ? (famStore.rewards as Reward[]) : (data.rewards as Reward[]) || [] ); - let progressData = $state((data.progress as BonusConfigWithProgress[]) || []); + + // Progress is recomputed in realtime from famStore (PB SSE) using the same + // shared algorithm as the server loader — otherwise the kanban (counts, + // bars, achieved states) goes stale until a page refresh. + let famPayday = $derived(Number(page.data.fam?.payday ?? (famStore.fam as any)?.payday ?? 1)); + let famTz = $derived( + resolveTz(page.data.fam?.timezone || (famStore.fam as any)?.timezone || 'auto') + ); + let progressData = $derived.by((): BonusProgressResult[] => { + if (!famStore.initialized) return (data.progress as BonusProgressResult[]) || []; + const active = (famStore.bonusConfigs as BonusConfig[]).filter((c) => c.status === 'active'); + return computeBonusProgress( + active, + famStore.members as Member[], + famStore.assigned as AssignedChore[], + famStore.completions as Completion[], + famStore.rewards as Reward[], + famPayday, + famTz + ); + }); let showPeriod = $derived(editVals.occurrence !== 'once'); let showCriteria = $derived(editVals.type !== 'manual'); - let editIsPercent = $derived(editVals.type === 'threshold' && editVals.thresholdType === 'percent'); + let editIsPercent = $derived( + editVals.type === 'threshold' && editVals.thresholdType === 'percent' + ); // Standalone (no period) rewards can scope progress by completeBy + dates. let showProgressWindow = $derived(editVals.type !== 'manual' && !editVals.period); @@ -151,7 +177,9 @@ editVals.targetChoreIds = { ...editVals.targetChoreIds, [memberId]: value }; } - let templateConfigs = $derived((templates as BonusTemplate[]).filter((t) => t.global && !t.isPocketMoney)); + let templateConfigs = $derived( + (templates as BonusTemplate[]).filter((t) => t.global && !t.isPocketMoney) + ); let allBonusConfigs = $derived( configs.filter((c: BonusConfig) => c.status !== 'completed') as BonusConfig[] ); @@ -162,7 +190,7 @@ ); let progressByConfigId = $derived.by(() => { - const map = new Map(); + const map = new Map(); for (const p of progressData) map.set(p.config.id, p); return map; }); @@ -271,7 +299,12 @@ const targetIds = (cfg.targetChoreIds as Record | undefined) || {}; // Legacy individual rewards pinned a chore via the scalar targetChoreId — // surface that in the per-member dropdown too. - if (cfg.target === 'individual' && cfg.memberId && cfg.targetChoreId && !targetIds[cfg.memberId]) { + if ( + cfg.target === 'individual' && + cfg.memberId && + cfg.targetChoreId && + !targetIds[cfg.memberId] + ) { targetIds[cfg.memberId] = cfg.targetChoreId; } editVals = { @@ -318,12 +351,15 @@ Pocket Money {#if hasUnsetPocket}

- Every child has a Pocket Money droplet. Set how it's awarded — pick the - amount (£) and the % of weekly chores they must complete to earn it — then save. Until - you set the amount, pocket money is paused for that child. + Every child has a Pocket Money droplet. Set how it's awarded — pick the amount (£) + and the % of weekly chores they must complete to earn it — then save. Until you set the amount, + pocket money is paused for that child.

{:else} -

All Pocket Money droplets are set. Children will earn pocket money as they complete chores.

+

+ All Pocket Money droplets are set. Children will earn pocket money as they complete + chores. +

{/if} @@ -335,28 +371,33 @@

Templates — Drag to assign

- {#each templateConfigs as cfg} -
handleDragStart(e, cfg)} style="border-left-color:{cfg.color}"> -
- - {cfg.name} + {#each templateConfigs as cfg} +
handleDragStart(e, cfg)} + style="border-left-color:{cfg.color}" + > +
+ + {cfg.name} +
+
+ {cfg.target} + {cfg.type} + {cfg.occurrence} + {#if cfg.period} + {cfg.period} + {/if} + {#if cfg.isPocketMoney} + Pocket Money + {/if} +
+
+ {formatReward(cfg.rewardType, cfg.rewardValue)} +
-
- {cfg.target} - {cfg.type} - {cfg.occurrence} - {#if cfg.period} - {cfg.period} - {/if} - {#if cfg.isPocketMoney} - Pocket Money - {/if} -
-
- {formatReward(cfg.rewardType, cfg.rewardValue)} -
-
- {/each} + {/each} {#if templateConfigs.length === 0}

No templates yet

{/if} @@ -366,81 +407,94 @@
-
+
- {#each allBonusConfigs.filter((c) => c.target === 'individual' && !doneConfigs.includes(c)) as cfg} -
openEditConfig(cfg)} - onkeydown={(e) => e.key === 'Enter' && openEditConfig(cfg)} - > -
- {cfg.name} -
- {cfg.target} - {cfg.type} - {cfg.occurrence} - {#if cfg.isPocketMoney} - Pocket Money - {/if} + {#each allBonusConfigs.filter((c) => c.target === 'individual' && !doneConfigs.includes(c)) as cfg} +
openEditConfig(cfg)} + onkeydown={(e) => e.key === 'Enter' && openEditConfig(cfg)} + > +
+ {cfg.name} +
+ {cfg.target} + {cfg.type} + {cfg.occurrence} + {#if cfg.isPocketMoney} + Pocket Money + {/if} +
+ + {formatReward(cfg.rewardType, cfg.rewardValue)} +
- - {cfg.rewardType === 'cash' ? `£${Number(cfg.rewardValue).toFixed(2)}` : `${cfg.rewardValue} pts`} - -
- {#each members as m} - {#if !cfg.memberId || cfg.memberId === m.id} - {@const p = progressByConfigId - .get(cfg.id) - ?.progress.find((pr: BonusProgress) => pr.memberId === m.id)} - {#if cfg.type === 'manual'} -
- {m.name} -
- {:else} - {@const criteria = p?.criteriaValue ?? cfg.criteriaValue ?? 0} - {@const current = p?.current ?? 0} - {@const achieved = p?.achieved ?? false} - {@const isPct = cfg.thresholdType === 'percent'} - {@const pct = isPct - ? Math.min(100, current) - : criteria > 0 - ? Math.min(100, (current / criteria) * 100) - : 0} -
- {m.name} - {#if criteria > 0} -
-
-
- {/if} - {#if cfg.type === 'count'} - - {Math.min(current, criteria)} / {criteria}{hasTargetChore(cfg) ? ' chores' : ''} - - {/if} - - {#if !p || p.state === 'pending'}{:else}{p.state}{/if} - -
+ {#each members as m} + {#if !cfg.memberId || cfg.memberId === m.id} + {@const p = progressByConfigId + .get(cfg.id) + ?.progress.find((pr) => pr.memberId === m.id)} + {#if cfg.type === 'manual'} +
+ {m.name} +
+ {:else} + {@const criteria = p?.criteriaValue ?? cfg.criteriaValue ?? 0} + {@const current = p?.current ?? 0} + {@const achieved = p?.achieved ?? false} + {@const isPct = cfg.thresholdType === 'percent'} + {@const pct = isPct + ? Math.min(100, current) + : criteria > 0 + ? Math.min(100, (current / criteria) * 100) + : 0} +
+ {m.name} + {#if achieved} + + Done · {formatReward(cfg.rewardType, cfg.rewardValue)} + + {:else} + {#if criteria > 0} +
+
+
+ {/if} + {#if cfg.type === 'count'} + + {Math.min(current, criteria)} / {criteria}{hasTargetChore(cfg) + ? ' chores' + : ''} + + {/if} + + {#if !p || p.state === 'pending'}{:else}{p.state}{/if} + + {/if} +
+ {/if} {/if} - {/if} - {/each} -
- Click to edit + {/each} +
+ Click to edit +
-
- {/each} - {#if allBonusConfigs.filter((c) => c.target === 'individual' && !doneConfigs.includes(c)).length === 0} -

No bonuses

- {/if} + {/each} + {#if allBonusConfigs.filter((c) => c.target === 'individual' && !doneConfigs.includes(c)).length === 0} +

No bonuses

+ {/if}
@@ -457,65 +511,67 @@ {#if !targetsEnabled} Coming soon {/if} - {@html chevronRight} + {@html chevronRight}
- {#each allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)) as cfg} - {@const prog = progressByConfigId.get(cfg.id)} -
openEditConfig(cfg)} - onkeydown={(e) => e.key === 'Enter' && openEditConfig(cfg)} - > -
- {cfg.name} -
- {cfg.target} - {cfg.type} - {cfg.occurrence} + {#each allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)) as cfg} + {@const prog = progressByConfigId.get(cfg.id)} +
openEditConfig(cfg)} + onkeydown={(e) => e.key === 'Enter' && openEditConfig(cfg)} + > +
+ {cfg.name} +
+ {cfg.target} + {cfg.type} + {cfg.occurrence} +
+ + {formatReward(cfg.rewardType, cfg.rewardValue)} +
- - {cfg.rewardType === 'cash' ? `£${Number(cfg.rewardValue).toFixed(2)}` : `${cfg.rewardValue} pts`} - -
- {#if prog} -
- {#each [...prog.progress].sort((a, b) => b.current - a.current) as p, i} -
0}> - #{i + 1} - {p.memberName} - {#if p.criteriaValue > 0} -
-
-
- {/if} - - {#if p.state === 'pending'}{:else}{p.state}{/if} - -
- {/each} + {#if prog} +
+ {#each [...prog.progress].sort((a, b) => b.current - a.current) as p, i} +
0}> + #{i + 1} + {p.memberName} + {#if p.criteriaValue > 0} +
+
+
+ {/if} + + {#if p.state === 'pending'}{:else}{p.state}{/if} + +
+ {/each} +
+ {/if} +
+ Click to edit
- {/if} -
- Click to edit
-
- {/each} - {#if allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)).length === 0} -

No bonuses

- {/if} + {/each} + {#if allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)).length === 0} +

No bonuses

+ {/if}
@@ -532,67 +588,69 @@ {#if !targetsEnabled} Coming soon {/if} - {@html chevronRight} + {@html chevronRight}
- {#each allBonusConfigs.filter((c) => c.target === 'collaborative') as cfg} - {@const prog = progressByConfigId.get(cfg.id)} -
openEditConfig(cfg)} - onkeydown={(e) => e.key === 'Enter' && openEditConfig(cfg)} - > -
- {cfg.name} -
- {cfg.target} - {cfg.type} - {cfg.occurrence} -
- - {cfg.rewardType === 'cash' ? `£${Number(cfg.rewardValue).toFixed(2)}` : `${cfg.rewardValue} pts`} - -
- {#if prog} - {@const team = prog.progress[0]} - {#if team} -
- {team.memberName} - {#if team.criteriaValue > 0} -
-
-
- {/if} - - {team.current}/{team.criteriaValue} - {cfg.type === 'threshold' ? 'pts' : 'chores'} - - {#if team.state === 'pending'}{:else}{team.state}{/if} - + {#each allBonusConfigs.filter((c) => c.target === 'collaborative') as cfg} + {@const prog = progressByConfigId.get(cfg.id)} +
openEditConfig(cfg)} + onkeydown={(e) => e.key === 'Enter' && openEditConfig(cfg)} + > +
+ {cfg.name} +
+ {cfg.target} + {cfg.type} + {cfg.occurrence}
+ + {formatReward(cfg.rewardType, cfg.rewardValue)} + +
+ {#if prog} + {@const team = prog.progress[0]} + {#if team} +
+ {team.memberName} + {#if team.criteriaValue > 0} +
+
+
+ {/if} + + {team.current}/{team.criteriaValue} + {cfg.type === 'threshold' ? 'pts' : 'chores'} + + {#if team.state === 'pending'}{:else}{team.state}{/if} + +
+ {/if} {/if} - {/if} -
- Click to edit +
+ Click to edit +
-
- {/each} - {#if allBonusConfigs.filter((c) => c.target === 'collaborative' && !doneConfigs.includes(c)).length === 0} -

No bonuses

- {/if} + {/each} + {#if allBonusConfigs.filter((c) => c.target === 'collaborative' && !doneConfigs.includes(c)).length === 0} +

No bonuses

+ {/if}
@@ -620,20 +678,20 @@ type="button" class="step-tab" class:active={step === 1} - onclick={() => (step = 1)} - >{@html stepIcon(1)}Who + onclick={() => (step = 1)}>{@html stepIcon(1)}Who + onclick={() => (step = 2)}>{@html stepIcon(2)}How + onclick={() => (step = 3)}>{@html stepIcon(3)}What
{/if} {#if creatingFromTemplate} - + {/if} + + {:else} + +

Rewards are always created for individual members.

+ {/if} + {#if (editingConfig || creatingFromTemplate) && editVals.target === 'individual'} + + {/if} +