clean pass update with rewards and completions

This commit is contained in:
JCEEE
2026-09-09 19:02:29 +01:00
parent 9d7915eb60
commit 3f95d50d2e
6 changed files with 1126 additions and 590 deletions
+40 -108
View File
@@ -8,12 +8,21 @@ import {
bonusWindow, bonusWindow,
completionInWindow completionInWindow
} from '@shared/timezone'; } from '@shared/timezone';
import { computeBonusProgress } from '@shared/bonus-progress';
import { famMeta } from './fam'; import { famMeta } from './fam';
function resolveServerTz(tz?: string): string { function resolveServerTz(tz?: string): string {
return resolveTz(tz || 'auto'); 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) { function claimableStamp(cfg: any, payday: number, tz: string) {
if (cfg.period !== 'weekly' && cfg.period !== 'monthly') { if (cfg.period !== 'weekly' && cfg.period !== 'monthly') {
return { claimable: 'immediate', settleDate: '' }; return { claimable: 'immediate', settleDate: '' };
@@ -25,7 +34,11 @@ function claimableStamp(cfg: any, payday: number, tz: string) {
} }
function targetChoreFor(cfg: any, memberId: string): string | undefined { 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.targetChoreIds[memberId];
} }
return cfg.targetChoreId || undefined; return cfg.targetChoreId || undefined;
@@ -57,7 +70,8 @@ export async function evaluateFam(pb: any, famId: string) {
// `percent` threshold type to compute % of chores completed. // `percent` threshold type to compute % of chores completed.
const dueByMember: Record<string, number> = {}; const dueByMember: Record<string, number> = {};
for (const a of allAssigned) { 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; 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, memberId,
bonusConfigId: cfg.id, bonusConfigId: cfg.id,
label, label,
value: Number(cfg.rewardValue) || 0, value: getRewardValue(cfg),
rewardType: cfg.rewardType, rewardType: cfg.rewardType,
status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed', status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed',
claimedAt: cfg.rewardType === 'points' ? now : null, claimedAt: cfg.rewardType === 'points' ? now : null,
@@ -91,11 +105,16 @@ export async function evaluateFam(pb: any, famId: string) {
}; };
if (cfg.target === 'individual') { 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) { for (const m of targetMembers) {
const memberTarget = targetChoreFor(cfg, m.id); const memberTarget = targetChoreFor(cfg, m.id);
let memberCompletions = periodCompletions.filter((c: any) => c.memberId === 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; let current = 0;
if (cfg.type === 'threshold') { if (cfg.type === 'threshold') {
if (cfg.thresholdType === 'percent') { if (cfg.thresholdType === 'percent') {
@@ -133,7 +152,10 @@ export async function evaluateFam(pb: any, famId: string) {
} else if (cfg.target === 'collaborative') { } else if (cfg.target === 'collaborative') {
const allMemberIds = allMembers.map((m: any) => m.id); const allMemberIds = allMembers.map((m: any) => m.id);
let teamCompletions = periodCompletions.filter((c: any) => allMemberIds.includes(c.memberId)); 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; let total = 0;
if (cfg.type === 'threshold') { if (cfg.type === 'threshold') {
if (cfg.thresholdType === 'percent') { if (cfg.thresholdType === 'percent') {
@@ -170,7 +192,10 @@ export async function evaluateFam(pb: any, famId: string) {
const scored = allMembers.map((m: any) => { const scored = allMembers.map((m: any) => {
const memberTarget = targetChoreFor(cfg, m.id); const memberTarget = targetChoreFor(cfg, m.id);
let memberCompletions = periodCompletions.filter((c: any) => c.memberId === 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; let current = 0;
if (cfg.type === 'threshold') { if (cfg.type === 'threshold') {
if (cfg.thresholdType === 'percent') { if (cfg.thresholdType === 'percent') {
@@ -187,7 +212,9 @@ export async function evaluateFam(pb: any, famId: string) {
} }
return { memberId: m.id, name: m.name, current }; 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 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]; 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('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` }) pb.collection('completions').getFullList({ filter: `famId = '${famId}'` })
]); ]);
const assignedList = assigned;
const completionsList = completions;
const dueByMember: Record<string, number> = {};
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[] = []; let allRewards: any[] = [];
try { try {
allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }); allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` });
} catch {} } catch {}
// Shared with the Rewards page client-side realtime recompute (famStore + SSE),
const result: any[] = []; // so the admin kanban and member dashboard always show identical progress.
return computeBonusProgress(configsData, members, assigned, completions, allRewards, payday, tz);
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;
} }
export async function trigger(pb: any, famId: string, configId: string, memberId?: string) { 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 pEnd = periodEnd(cfg.period, pStart);
const periodRewards = memberRewards.filter((r: any) => r.date >= pStart && r.date <= pEnd); const periodRewards = memberRewards.filter((r: any) => r.date >= pStart && r.date <= pEnd);
if (periodRewards.length > 0) { if (periodRewards.length > 0) {
throw new Error( throw new Error(`Already issued ${periodRewards.length}x this ${cfg.period} to ${m.name}`);
`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, memberId: m.id,
bonusConfigId: cfg.id, bonusConfigId: cfg.id,
label, label,
value: Number(cfg.rewardValue) || 0, value: getRewardValue(cfg),
rewardType: cfg.rewardType, rewardType: cfg.rewardType,
status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed', status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed',
claimedAt: cfg.rewardType === 'points' ? now : null, claimedAt: cfg.rewardType === 'points' ? now : null,
+5 -3
View File
@@ -9,14 +9,16 @@ export async function load(event) {
const s = createServices(pbUser(event), session); const s = createServices(pbUser(event), session);
try { 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('members', famId),
s.crud.list('assigned-chores', famId), s.crud.list('assigned-chores', famId),
s.fam.weeklySummary(famId), s.fam.weeklySummary(famId),
s.fam.get(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 { } catch {
return {}; return {};
} }
+33
View File
@@ -4,6 +4,7 @@
import { Chart, registerables } from 'chart.js'; import { Chart, registerables } from 'chart.js';
import { ViewHeader, CardGrid, Card } from '$lib/components'; import { ViewHeader, CardGrid, Card } from '$lib/components';
import { formatDDMMYY } from '$lib/format'; import { formatDDMMYY } from '$lib/format';
import { famStore } from '$lib/stores/fam.svelte';
import { handleOf } from '@shared/slugify'; import { handleOf } from '@shared/slugify';
import { addDaysStr, resolveTz, todayInTz } from '@shared/timezone'; import { addDaysStr, resolveTz, todayInTz } from '@shared/timezone';
@@ -54,6 +55,28 @@
let allTimeCash = $derived(Number(stats.allTimeCash) || 0); let allTimeCash = $derived(Number(stats.allTimeCash) || 0);
let allTimeChores = $derived(Number(stats.allTimeChores) || 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<string, { count: number; names: string[] }>();
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. ─── // ─── Trend chart window: show current month, expand a month at a time up to 3. ───
let trend = $derived.by(() => { let trend = $derived.by(() => {
const series = Array.isArray(stats.series) ? stats.series : []; const series = Array.isArray(stats.series) ? stats.series : [];
@@ -340,6 +363,7 @@
{@const scPot = dailyPointsPotential(s.memberId)} {@const scPot = dailyPointsPotential(s.memberId)}
{@const scVal = avgDailyPointsFor(s)} {@const scVal = avgDailyPointsFor(s)}
{@const scPct = scPot > 0 ? Math.min(100, Math.round((scVal / scPot) * 100)) : 0} {@const scPct = scPot > 0 ? Math.min(100, Math.round((scVal / scPot) * 100)) : 0}
{@const goals = goalsByMember.get(s.memberId)}
<div class="column col-member"> <div class="column col-member">
<div class="member-head"> <div class="member-head">
<span class="dot" style="background:{s.memberColor}"></span> <span class="dot" style="background:{s.memberColor}"></span>
@@ -378,6 +402,12 @@
<span class="stat-lbl">Done</span> <span class="stat-lbl">Done</span>
<span class="stat-val">{wkDone}/{wkTotal}</span> <span class="stat-val">{wkDone}/{wkTotal}</span>
</div> </div>
<div class="stat-row">
<span class="stat-lbl">Goals 🎉</span>
<span class="stat-val goals" title={goals?.names.join(' · ')}
>{goals?.count ?? 0}</span
>
</div>
</div> </div>
</div> </div>
{/each} {/each}
@@ -656,6 +686,9 @@
.stat-val.cash { .stat-val.cash {
color: #059669; color: #059669;
} }
.stat-val.goals {
color: #059669;
}
/* ── Chart ── */ /* ── Chart ── */
.chart-wrap { .chart-wrap {
+306 -44
View File
@@ -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) ─── // ─── Child View (kanban) ───
let memberId = $state(data.memberId || ''); let memberId = $state(data.memberId || '');
let pbToken = $state(data.token || ''); let pbToken = $state(data.token || '');
@@ -179,10 +207,9 @@
: (data.templates as ChoreTemplate[]) : (data.templates as ChoreTemplate[])
); );
let assigned = $derived( let assigned = $derived(
( (famStore.initialized
famStore.initialized ? (famStore.assigned as AssignedChore[])
? (famStore.assigned as AssignedChore[]) : (data.assigned as AssignedChore[])
: (data.assigned as AssignedChore[])
).map((a) => (a.memberId === '' ? { ...a, memberId: null } : a)) ).map((a) => (a.memberId === '' ? { ...a, memberId: null } : a))
); );
let completions = $derived( let completions = $derived(
@@ -489,8 +516,11 @@
const name = bonusConfigs.find((b: BonusConfig) => b.id === r.bonusConfigId)?.name || r.label; const name = bonusConfigs.find((b: BonusConfig) => b.id === r.bonusConfigId)?.name || r.label;
map.set(name, (map.get(name) || 0) + 1); 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) ─── // ─── Threshold Goals (bonus configs the member is chasing) ───
let thresholdGoals = $derived.by(() => { 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<Record<string, string>>(
typeof localStorage !== 'undefined'
? (JSON.parse(localStorage.getItem(goalCelebratedKey) || '{}') as Record<string, string>)
: {}
);
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<string, string> = { ...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 { function shortDate(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00'); const d = new Date(dateStr + 'T00:00:00');
const day = String(d.getDate()).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0');
@@ -802,6 +897,31 @@
</div> </div>
</div> </div>
<!-- GOALS COMPLETED (mini cards, above the member/trigger/claims grid) -->
<div class="goals-completed">
{#if parentGoalsCompleted.length === 0}
<p class="empty">
No goals completed yet — rewards appear here the moment a member hits a target.
</p>
{:else}
<div class="goal-mini-grid">
{#each parentGoalsCompleted as g}
<div class="goal-mini">
<div class="gm-top">
<span class="dot" style="background:{g.color}"></span>
<span class="gm-name">🎉 {g.goalName}</span>
<span class="gm-member">{g.memberName}</span>
</div>
<div class="gm-bottom">
<span class="gm-reward">{g.rewardText}</span>
<span class="gm-date">{formatDDMMYY(g.date)}</span>
</div>
</div>
{/each}
</div>
{/if}
</div>
<div class="kanban-scroll"> <div class="kanban-scroll">
<div class="kanban-inner"> <div class="kanban-inner">
<CardGrid stackTablet> <CardGrid stackTablet>
@@ -1205,12 +1325,14 @@
</div> </div>
<div class="hero-meta"> <div class="hero-meta">
{#if weekBonusTallies.length > 0} {#if anyWeekBonuses}
<div class="hero-pills"> {#if weekBonusTallies.length > 0}
{#each weekBonusTallies as t} <div class="hero-pills">
<span class="hero-pill">🎉 {t.name} ×{t.count}</span> {#each weekBonusTallies as t}
{/each} <span class="hero-pill">🎉 {t.name} ×{t.count}</span>
</div> {/each}
</div>
{/if}
{:else} {:else}
<span class="hero-null">no bonuses earned yet</span> <span class="hero-null">no bonuses earned yet</span>
{/if} {/if}
@@ -1260,35 +1382,42 @@
{/if} {/if}
</span> </span>
</div> </div>
<div class="goal-bar-wrap"> {#if goal.achieved}
<div class="goal-bar-fill" style="width:{pct}%"></div> <div class="goal-done">
{#if goal.targetPct < 100} <span class="goal-tick">{@html checkCircleIcon}</span>
<div class="goal-target" style="left:{goal.targetPct}%"></div> <span class="goal-done-text">
{/if} <b>Reward earned</b>
</div> <span>{goalRewardLabel(goal)}</span>
<div class="goal-foot"> </span>
{#if goal.isPercent && goal.totalPotential === 0} </div>
<span class="goal-progress muted">no chores set</span> {:else}
{:else if goal.isPercent} <div class="goal-bar-wrap">
<span class="goal-progress" <div class="goal-bar-fill" style="width:{pct}%"></div>
>{Math.min(goal.current, goal.totalPotential)} / {goal.totalPotential}</span {#if goal.targetPct < 100}
> <div class="goal-target" style="left:{goal.targetPct}%"></div>
{:else} {/if}
<span class="goal-progress" </div>
>{Math.min(goal.current, goal.target)} / {goal.target}</span <div class="goal-foot">
> {#if goal.isPercent && goal.totalPotential === 0}
{/if} <span class="goal-progress muted">no chores set</span>
{#if goal.achieved} {:else if goal.isPercent}
<span class="goal-badge">{@html checkCircleIcon} earned</span> <span class="goal-progress"
{:else} >{Math.min(goal.current, goal.totalPotential)} / {goal.totalPotential}</span
>
{:else}
<span class="goal-progress"
>{Math.min(goal.current, goal.target)} / {goal.target}</span
>
{/if}
<span class="goal-pct">{pct}%</span> <span class="goal-pct">{pct}%</span>
<span class="goal-target-pill" <span class="goal-target-pill"
>{@html targetIcon} {goal.targetLabel}{#if goal.targetChoreName} >{@html targetIcon}
{goal.targetLabel}{#if goal.targetChoreName}
<span class="goal-chore"> · {goal.targetChoreName}</span> <span class="goal-chore"> · {goal.targetChoreName}</span>
{/if}</span {/if}</span
> >
{/if} </div>
</div> {/if}
</div> </div>
{/each} {/each}
</div> </div>
@@ -1311,7 +1440,7 @@
</div> </div>
</div> </div>
{:else} {:else}
{#if celebrations.length > 0} {#if celebrations.length > 0 || goalCelebrations.length > 0}
<div class="celebrations"> <div class="celebrations">
{#each celebrations as cel} {#each celebrations as cel}
<div class="celebrate-card"> <div class="celebrate-card">
@@ -1322,6 +1451,16 @@
<button class="celebrate-close" onclick={() => dismissCelebrate(cel.id)}>✕</button> <button class="celebrate-close" onclick={() => dismissCelebrate(cel.id)}>✕</button>
</div> </div>
{/each} {/each}
{#each goalCelebrations as cel}
<div class="celebrate-card goal">
<span class="celebrate-emoji">🎉</span>
<span class="celebrate-text"
><strong>{cel.name}</strong> complete — you earned <b>{cel.reward}</b>!</span
>
<button class="celebrate-close" onclick={() => dismissGoalCelebrate(cel.id)}>✕</button
>
</div>
{/each}
</div> </div>
{/if} {/if}
<div class="kanban"> <div class="kanban">
@@ -1451,12 +1590,8 @@
<span class="wallet-lbl">{@html walletIcon} Wallet</span> <span class="wallet-lbl">{@html walletIcon} Wallet</span>
<!-- Lifetime earnings (everything earned + claimed), one-shot totals --> <!-- Lifetime earnings (everything earned + claimed), one-shot totals -->
<div class="life-badges"> <div class="life-badges">
<span class="life-badge life-cash" <span class="life-badge life-cash">£{allTimeCash.toFixed(2)} <i>all time</i></span>
>£{allTimeCash.toFixed(2)} <i>all time</i></span <span class="life-badge life-pts">{allTimePoints.toLocaleString()} <i>pts</i></span>
>
<span class="life-badge life-pts"
>{allTimePoints.toLocaleString()} <i>pts</i></span
>
</div> </div>
</div> </div>
@@ -1545,7 +1680,7 @@
border-left: 4px solid #6366f1; border-left: 4px solid #6366f1;
border-radius: 10px; border-radius: 10px;
padding: 0.65rem 0.85rem; padding: 0.65rem 0.85rem;
background: white; background: #f6fbff;
margin-bottom: 0.6rem; margin-bottom: 0.6rem;
transition: transition:
transform 0.15s, transform 0.15s,
@@ -1647,7 +1782,7 @@
color: #059669; color: #059669;
} }
.done-item.odd { .done-item.odd {
background: #f3f4f6; background: #ffffff;
} }
.done-icon { .done-icon {
display: inline-flex; display: inline-flex;
@@ -2283,6 +2418,133 @@
color: #6366f1; 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 ── */ /* ── Kanban ── */
.celebrations { .celebrations {
display: flex; display: flex;
File diff suppressed because it is too large Load Diff
+211
View File
@@ -0,0 +1,211 @@
// Bonus progress computation — single source of truth shared by the server
// rewards loader (`services/bonuses.ts` → `progress()`) and the Rewards page
// client-side realtime recompute (famStore + PB SSE). Both views must show the
// same counts, so the algorithm lives here rather than in two places.
import { bonusWindow, completionInWindow } from "./timezone";
export interface BonusProgressConfig {
id: string;
target: string;
memberId?: string;
type: string;
thresholdType?: string;
criteriaValue?: number;
period?: string;
targetChoreId?: string;
targetChoreIds?: Record<string, string> | null;
}
export interface BonusProgressMember {
id: string;
name: string;
color: string;
}
export interface BonusProgressAssigned {
id: string;
memberId?: string | null;
frequency?: string;
type?: string;
value?: number | string;
}
export interface BonusProgressCompletion {
memberId: string;
assignedChoreId: string;
date?: string;
}
export interface BonusProgressReward {
id: string;
memberId: string;
bonusConfigId?: string;
status: string;
}
export interface BonusProgressRow {
memberId: string;
memberName: string;
memberColor: string;
current: number;
criteriaValue: number;
reward: { id: string; status: string } | null;
state: string;
achieved: boolean;
}
export interface BonusConfigWithProgress {
config: BonusProgressConfig;
progress: BonusProgressRow[];
periodStart: string;
periodEnd: string;
}
// Per-member target-chore pick wins over the legacy scalar targetChoreId.
function targetChoreFor(
cfg: BonusProgressConfig,
memberId: string,
): string | undefined {
if (
cfg.targetChoreIds &&
typeof cfg.targetChoreIds === "object" &&
cfg.targetChoreIds[memberId]
) {
return cfg.targetChoreIds[memberId];
}
return cfg.targetChoreId || undefined;
}
// Compute per-config progress rows for every member (or the team/winner for
// collaborative/competitive targets). Callers pass the configs they want
// scored (the server uses active-only; the Rewards page mirrors that).
export function computeBonusProgress(
configs: BonusProgressConfig[],
members: BonusProgressMember[],
assigned: BonusProgressAssigned[],
completions: BonusProgressCompletion[],
rewards: BonusProgressReward[],
payday: number,
tz: string,
): BonusConfigWithProgress[] {
// Chores "due" per week per member (daily = 7, otherwise 1). Used by the
// `percent` threshold type to compute % of chores completed.
const dueByMember: Record<string, number> = {};
for (const a of assigned) {
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;
const result: BonusConfigWithProgress[] = [];
for (const cfg of configs) {
const cfgRewards = rewards.filter((r) => r.bonusConfigId === cfg.id);
const win = bonusWindow(cfg, payday, tz);
const pStart = win.from;
const pEnd = win.to;
const periodCompletions = completions.filter((c) =>
completionInWindow(c, win),
);
const progressRows: BonusProgressRow[] = [];
if (cfg.target === "collaborative") {
let teamCompletions = periodCompletions.filter((c) =>
members.some((m) => m.id === c.memberId),
);
if (cfg.targetChoreId) {
teamCompletions = teamCompletions.filter(
(c) => 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, c) => {
const chore = assigned.find((a) => 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) => m.id === cfg.memberId)
: members;
for (const m of progressMembers) {
const memberTarget = targetChoreFor(cfg, m.id);
let memberCompletions = periodCompletions.filter(
(c) => c.memberId === m.id,
);
if (memberTarget) {
memberCompletions = memberCompletions.filter(
(c) => c.assignedChoreId === memberTarget,
);
}
const memberReward = cfgRewards.find((r) => 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, c) => {
const chore = assigned.find((a) => 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;
}