diff --git a/frontend/src/lib/server/services/bonuses.ts b/frontend/src/lib/server/services/bonuses.ts index 479930f..4db1755 100644 --- a/frontend/src/lib/server/services/bonuses.ts +++ b/frontend/src/lib/server/services/bonuses.ts @@ -106,6 +106,15 @@ export async function evaluateFam(pb: any, famId: string) { const periodCompletions = allCompletions.filter((c: any) => completionInWindow(c, win)); const existingRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id); + // Uniform rule, one reward row per config per member per window: + // achieved + no window reward → create; unachieved → delete the window + // reward unless it is claimed, requested, or payday-gated (those persist + // by design until the parent issues them). Only rewards dated inside the + // CURRENT window count as "already rewarded" — last week's row must never + // block this week's payout. + const windowRewards = existingRewards.filter((r: any) => completionInWindow(r, win)); + const removable = (r: any) => + r.status !== 'claimed' && r.status !== 'requested' && r.claimable !== 'payday'; let createdReward = false; const rewardData = (memberId: string) => { @@ -128,135 +137,122 @@ 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; - 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 - ); - 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 = allAssigned.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; - } - const achieved = cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue); - - const memberReward = existingRewards.find((r: any) => r.memberId === m.id); - - if (memberReward && !achieved) { - // Payday-gated (e.g. pocket money) and member-requested rewards - // persist across week rollover until the parent issues them — - // deleting them here gave parents only a payday-day window. - if (memberReward.status !== 'claimed' && memberReward.status !== 'requested' && memberReward.claimable !== 'payday') { - try { - await pb.collection('rewards').delete(memberReward.id); - } catch {} - } - continue; - } - - if (memberReward) continue; - - if (achieved) { - await pb.collection('rewards').create(rewardData(m.id)); - createdReward = true; - } - } - } 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 - ); - let total = 0; + // Completions counting toward one member (target-chore scoped when set). + const completionsFor = (memberId: string) => { + const memberTarget = targetChoreFor(cfg, memberId); + let list = periodCompletions.filter((c: any) => c.memberId === memberId); + if (memberTarget) list = list.filter((c: any) => c.assignedChoreId === memberTarget); + // Collaborative configs only honour the shared targetChoreId. + if (cfg.target === 'collaborative' && cfg.targetChoreId) + list = list.filter((c: any) => c.assignedChoreId === cfg.targetChoreId); + return list; + }; + // Score for one member: percent of due chores, points value, or raw count. + const scoreFor = (memberId: string): number => { + const list = completionsFor(memberId); if (cfg.type === 'threshold') { if (cfg.thresholdType === 'percent') { - total = Math.round((teamCompletions.length / totalDue) * 100); - } else { - total = teamCompletions.reduce((sum: number, c: any) => { - const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId); - return sum + (chore?.type === 'points' ? Number(chore.value) : 0); - }, 0); + const due = dueByMember[memberId] || 0; + return due > 0 ? Math.round((list.length / due) * 100) : 0; } - } else if (cfg.type === 'count') { - total = teamCompletions.length; + return list.reduce((sum: number, c: any) => { + const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId); + return sum + (chore?.type === 'points' ? Number(chore.value) : 0); + }, 0); } - const achieved = cfg.criteriaValue > 0 && total >= Number(cfg.criteriaValue); - - if (!achieved && existingRewards.length > 0) { - for (const r of existingRewards) { - if (r.status === 'claimed' || r.status === 'requested' || r.claimable === 'payday') continue; + if (cfg.type === 'count') return list.length; + return 0; + }; + const met = (current: number) => cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue); + // Reconcile one member's window reward against their achieved state. + // Returns true when a live window reward exists afterwards. + const reconcile = async (memberId: string, achieved: boolean): Promise => { + const live = windowRewards.filter((r: any) => r.memberId === memberId); + if (!achieved) { + for (const r of live) { + if (!removable(r)) continue; try { await pb.collection('rewards').delete(r.id); } catch {} + windowRewards.splice(windowRewards.indexOf(r), 1); } - continue; + return false; } - - if (achieved && existingRewards.length === 0) { - for (const m of allMembers) { - await pb.collection('rewards').create(rewardData(m.id)); - createdReward = true; - } - } - } else if (cfg.target === 'competitive') { - 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 - ); - 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 = allAssigned.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; - } - return { memberId: m.id, name: m.name, current }; - }); - const qualified = scored.filter( - (s: any) => cfg.criteriaValue > 0 && s.current >= Number(cfg.criteriaValue) + if (live.length > 0) return true; + await pb.collection('rewards').create(rewardData(memberId)); + createdReward = true; + return true; + }; + // Drop stale prior-window placeholders (unclaimed, non-gated) so they + // can't pile up unseen; gated/requested/claimed rows persist by design. + const purgeStale = async (memberId: string) => { + const stale = existingRewards.filter( + (r: any) => r.memberId === memberId && !completionInWindow(r, win) && removable(r) ); + for (const r of stale) { + try { + await pb.collection('rewards').delete(r.id); + } catch {} + } + }; + + if (cfg.target === 'competitive') { + // Winner-takes-all: a single window reward held by the top scorer. + const scored = allMembers.map((m: any) => ({ memberId: m.id, current: scoreFor(m.id) })); + const qualified = scored.filter((s: any) => met(s.current)); 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]; - - if (existingRewards.length > 0) { - const existing = existingRewards[0]; - const stillValid = winner && existing.memberId === winner.memberId && winner.current > 0; - if (!stillValid && existing.status !== 'claimed' && existing.status !== 'requested' && existing.claimable !== 'payday') { + const holder = windowRewards[0]; + if (!winner) { + if (holder && removable(holder)) { try { - await pb.collection('rewards').delete(existing.id); + await pb.collection('rewards').delete(holder.id); } catch {} } - } - - if (winner && existingRewards.length === 0) { + } else if (!holder) { + await purgeStale(winner.memberId); await pb.collection('rewards').create(rewardData(winner.memberId)); createdReward = true; + } else if (holder.memberId !== winner.memberId) { + if (removable(holder)) { + try { + await pb.collection('rewards').delete(holder.id); + } catch {} + await purgeStale(winner.memberId); + await pb.collection('rewards').create(rewardData(winner.memberId)); + createdReward = true; + } + // A locked (claimed/requested/gated) holder keeps the crown until issued. + } + } else { + // Individual: each member against their own score. Collaborative: the + // whole team against the team total, rewarded per member. + let teamTotal = 0; + if (cfg.target === 'collaborative') { + const ids = new Set(allMembers.map((m: any) => m.id)); + let team = periodCompletions.filter((c: any) => ids.has(c.memberId)); + if (cfg.targetChoreId) + team = team.filter((c: any) => c.assignedChoreId === cfg.targetChoreId); + if (cfg.type === 'threshold' && cfg.thresholdType === 'percent') { + teamTotal = Math.round((team.length / totalDue) * 100); + } else if (cfg.type === 'threshold') { + teamTotal = team.reduce((sum: number, c: any) => { + const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId); + return sum + (chore?.type === 'points' ? Number(chore.value) : 0); + }, 0); + } else if (cfg.type === 'count') { + teamTotal = team.length; + } + } + const members = + cfg.target === 'individual' && cfg.memberId + ? allMembers.filter((m: any) => m.id === cfg.memberId) + : allMembers; + for (const m of members) { + const achieved = + cfg.target === 'collaborative' ? met(teamTotal) : met(scoreFor(m.id)); + await reconcile(m.id, achieved); + await purgeStale(m.id); } } diff --git a/frontend/src/lib/server/services/completions.ts b/frontend/src/lib/server/services/completions.ts index 4664f25..41c38cc 100644 --- a/frontend/src/lib/server/services/completions.ts +++ b/frontend/src/lib/server/services/completions.ts @@ -101,7 +101,14 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a if (isClaimable && existing[0].memberId === memberId) { await claimChore(assignedChoreId, famId, ''); } - evaluateFam(pb, famId).catch(() => {}); + // Bonus evaluation is awaited (not fire-and-forget) so failures are + // visible in the server log instead of silently swallowing missed payouts. + // The client already updates optimistically, so this doesn't block the UI. + try { + await evaluateFam(pb, famId); + } catch (e) { + console.error('[evaluateFam] after un-complete:', e); + } return { completed: false }; } let rewardId: string | undefined; @@ -131,7 +138,11 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a if (isClaimable) { await claimChore(assignedChoreId, famId, memberId); } - evaluateFam(pb, famId).catch(() => {}); + try { + await evaluateFam(pb, famId); + } catch (e) { + console.error('[evaluateFam] after toggle:', e); + } return { completed: true, record }; } @@ -149,6 +160,10 @@ export async function revoke(pb: any, famId: string, completionId: string) { if (chore?.shared === true) { await claimChore(chore.id, famId, ''); } - evaluateFam(pb, famId).catch(() => {}); + try { + await evaluateFam(pb, famId); + } catch (e) { + console.error('[evaluateFam] after revoke:', e); + } return { revoked: true }; } \ No newline at end of file diff --git a/frontend/src/lib/server/services/rewards.ts b/frontend/src/lib/server/services/rewards.ts index 3c5f2ce..da5b691 100644 --- a/frontend/src/lib/server/services/rewards.ts +++ b/frontend/src/lib/server/services/rewards.ts @@ -7,7 +7,7 @@ function resolveServerTz(tz?: string): string { } // A `once` goal is finished the moment its reward is approved — complete the -// config here (not just in evaluateFam, which only runs on chore toggles) so +// config here (not just in evaluateFam, which runs on toggles/revoke/payday) so // the child dashboard stops offering it even before the next toggle. // Config write via superuser: bonus_configs updates are parent-only. // Outstanding (unclaimed/requested) rewards are untouched — they stay visible