fix week in week out evaluateFam
This commit is contained in:
@@ -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;
|
||||
// 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') {
|
||||
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 due = dueByMember[memberId] || 0;
|
||||
return due > 0 ? Math.round((list.length / due) * 100) : 0;
|
||||
}
|
||||
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);
|
||||
}
|
||||
} 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') {
|
||||
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<boolean> => {
|
||||
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(memberReward.id);
|
||||
await pb.collection('rewards').delete(r.id);
|
||||
} catch {}
|
||||
windowRewards.splice(windowRewards.indexOf(r), 1);
|
||||
}
|
||||
continue;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (memberReward) continue;
|
||||
|
||||
if (achieved) {
|
||||
await pb.collection('rewards').create(rewardData(m.id));
|
||||
if (live.length > 0) return true;
|
||||
await pb.collection('rewards').create(rewardData(memberId));
|
||||
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
|
||||
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)
|
||||
);
|
||||
let total = 0;
|
||||
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;
|
||||
for (const r of stale) {
|
||||
try {
|
||||
await pb.collection('rewards').delete(r.id);
|
||||
} catch {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if (achieved && existingRewards.length === 0) {
|
||||
for (const m of allMembers) {
|
||||
await pb.collection('rewards').create(rewardData(m.id));
|
||||
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];
|
||||
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;
|
||||
}
|
||||
// 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 {
|
||||
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);
|
||||
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)
|
||||
);
|
||||
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 {}
|
||||
teamTotal = team.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (winner && existingRewards.length === 0) {
|
||||
await pb.collection('rewards').create(rewardData(winner.memberId));
|
||||
createdReward = true;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user