fix week in week out evaluateFam

This commit is contained in:
JCEEE
2026-09-23 20:49:32 +01:00
parent adcb400f41
commit 6288a3d3f2
3 changed files with 127 additions and 116 deletions
+98 -102
View File
@@ -106,6 +106,15 @@ export async function evaluateFam(pb: any, famId: string) {
const periodCompletions = allCompletions.filter((c: any) => completionInWindow(c, win)); const periodCompletions = allCompletions.filter((c: any) => completionInWindow(c, win));
const existingRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id); 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; let createdReward = false;
const rewardData = (memberId: string) => { const rewardData = (memberId: string) => {
@@ -128,135 +137,122 @@ export async function evaluateFam(pb: any, famId: string) {
}; };
}; };
if (cfg.target === 'individual') { // Completions counting toward one member (target-chore scoped when set).
const targetMembers = cfg.memberId const completionsFor = (memberId: string) => {
? allMembers.filter((m: any) => m.id === cfg.memberId) const memberTarget = targetChoreFor(cfg, memberId);
: allMembers; let list = periodCompletions.filter((c: any) => c.memberId === memberId);
for (const m of targetMembers) { if (memberTarget) list = list.filter((c: any) => c.assignedChoreId === memberTarget);
const memberTarget = targetChoreFor(cfg, m.id); // Collaborative configs only honour the shared targetChoreId.
let memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id); if (cfg.target === 'collaborative' && cfg.targetChoreId)
if (memberTarget) list = list.filter((c: any) => c.assignedChoreId === cfg.targetChoreId);
memberCompletions = memberCompletions.filter( return list;
(c: any) => c.assignedChoreId === memberTarget };
); // Score for one member: percent of due chores, points value, or raw count.
let current = 0; const scoreFor = (memberId: string): number => {
const list = completionsFor(memberId);
if (cfg.type === 'threshold') { if (cfg.type === 'threshold') {
if (cfg.thresholdType === 'percent') { if (cfg.thresholdType === 'percent') {
const due = dueByMember[m.id] || 0; const due = dueByMember[memberId] || 0;
current = due > 0 ? Math.round((memberCompletions.length / due) * 100) : 0; return due > 0 ? Math.round((list.length / due) * 100) : 0;
} else { }
current = memberCompletions.reduce((sum: number, c: any) => { return list.reduce((sum: number, c: any) => {
const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId); const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'points' ? Number(chore.value) : 0); return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
}, 0); }, 0);
} }
} else if (cfg.type === 'count') { if (cfg.type === 'count') return list.length;
current = memberCompletions.length; return 0;
} };
const achieved = cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue); const met = (current: number) => cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue);
// Reconcile one member's window reward against their achieved state.
const memberReward = existingRewards.find((r: any) => r.memberId === m.id); // Returns true when a live window reward exists afterwards.
const reconcile = async (memberId: string, achieved: boolean): Promise<boolean> => {
if (memberReward && !achieved) { const live = windowRewards.filter((r: any) => r.memberId === memberId);
// Payday-gated (e.g. pocket money) and member-requested rewards if (!achieved) {
// persist across week rollover until the parent issues them — for (const r of live) {
// deleting them here gave parents only a payday-day window. if (!removable(r)) continue;
if (memberReward.status !== 'claimed' && memberReward.status !== 'requested' && memberReward.claimable !== 'payday') {
try { try {
await pb.collection('rewards').delete(memberReward.id); await pb.collection('rewards').delete(r.id);
} catch {} } catch {}
windowRewards.splice(windowRewards.indexOf(r), 1);
} }
continue; return false;
} }
if (live.length > 0) return true;
if (memberReward) continue; await pb.collection('rewards').create(rewardData(memberId));
if (achieved) {
await pb.collection('rewards').create(rewardData(m.id));
createdReward = true; createdReward = true;
} return true;
} };
} else if (cfg.target === 'collaborative') { // Drop stale prior-window placeholders (unclaimed, non-gated) so they
const allMemberIds = allMembers.map((m: any) => m.id); // can't pile up unseen; gated/requested/claimed rows persist by design.
let teamCompletions = periodCompletions.filter((c: any) => allMemberIds.includes(c.memberId)); const purgeStale = async (memberId: string) => {
if (cfg.targetChoreId) const stale = existingRewards.filter(
teamCompletions = teamCompletions.filter( (r: any) => r.memberId === memberId && !completionInWindow(r, win) && removable(r)
(c: any) => c.assignedChoreId === cfg.targetChoreId
); );
let total = 0; for (const r of stale) {
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);
}
} else if (cfg.type === 'count') {
total = teamCompletions.length;
}
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;
try { try {
await pb.collection('rewards').delete(r.id); await pb.collection('rewards').delete(r.id);
} catch {} } catch {}
} }
continue; };
}
if (achieved && existingRewards.length === 0) { if (cfg.target === 'competitive') {
for (const m of allMembers) { // Winner-takes-all: a single window reward held by the top scorer.
await pb.collection('rewards').create(rewardData(m.id)); 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];
const holder = windowRewards[0];
if (!winner) {
if (holder && removable(holder)) {
try {
await pb.collection('rewards').delete(holder.id);
} catch {}
}
} 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; createdReward = true;
} }
// A locked (claimed/requested/gated) holder keeps the crown until issued.
} }
} 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 { } else {
current = memberCompletions.reduce((sum: number, c: any) => { // 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); const chore = allAssigned.find((a: any) => a.id === c.assignedChoreId);
return sum + (chore?.type === 'points' ? Number(chore.value) : 0); return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
}, 0); }, 0);
}
} else if (cfg.type === 'count') { } else if (cfg.type === 'count') {
current = memberCompletions.length; teamTotal = team.length;
}
return { memberId: m.id, name: m.name, current };
});
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];
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') {
try {
await pb.collection('rewards').delete(existing.id);
} catch {}
} }
} }
const members =
if (winner && existingRewards.length === 0) { cfg.target === 'individual' && cfg.memberId
await pb.collection('rewards').create(rewardData(winner.memberId)); ? allMembers.filter((m: any) => m.id === cfg.memberId)
createdReward = true; : 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);
} }
} }
@@ -101,7 +101,14 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
if (isClaimable && existing[0].memberId === memberId) { if (isClaimable && existing[0].memberId === memberId) {
await claimChore(assignedChoreId, famId, ''); 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 }; return { completed: false };
} }
let rewardId: string | undefined; let rewardId: string | undefined;
@@ -131,7 +138,11 @@ export async function toggle(pb: any, famId: string, memberId: string, body: { a
if (isClaimable) { if (isClaimable) {
await claimChore(assignedChoreId, famId, memberId); 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 }; return { completed: true, record };
} }
@@ -149,6 +160,10 @@ export async function revoke(pb: any, famId: string, completionId: string) {
if (chore?.shared === true) { if (chore?.shared === true) {
await claimChore(chore.id, famId, ''); 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 }; return { revoked: true };
} }
+1 -1
View File
@@ -7,7 +7,7 @@ function resolveServerTz(tz?: string): string {
} }
// A `once` goal is finished the moment its reward is approved — complete the // 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. // the child dashboard stops offering it even before the next toggle.
// Config write via superuser: bonus_configs updates are parent-only. // Config write via superuser: bonus_configs updates are parent-only.
// Outstanding (unclaimed/requested) rewards are untouched — they stay visible // Outstanding (unclaimed/requested) rewards are untouched — they stay visible