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 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 };
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user