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
+41 -109
View File
@@ -8,12 +8,21 @@ import {
bonusWindow,
completionInWindow
} from '@shared/timezone';
import { computeBonusProgress } from '@shared/bonus-progress';
import { famMeta } from './fam';
function resolveServerTz(tz?: string): string {
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) {
if (cfg.period !== 'weekly' && cfg.period !== 'monthly') {
return { claimable: 'immediate', settleDate: '' };
@@ -25,7 +34,11 @@ function claimableStamp(cfg: any, payday: number, tz: string) {
}
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.targetChoreId || undefined;
@@ -57,7 +70,8 @@ export async function evaluateFam(pb: any, famId: string) {
// `percent` threshold type to compute % of chores completed.
const dueByMember: Record<string, number> = {};
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;
@@ -81,7 +95,7 @@ export async function evaluateFam(pb: any, famId: string) {
memberId,
bonusConfigId: cfg.id,
label,
value: Number(cfg.rewardValue) || 0,
value: getRewardValue(cfg),
rewardType: cfg.rewardType,
status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed',
claimedAt: cfg.rewardType === 'points' ? now : null,
@@ -91,11 +105,16 @@ 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;
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);
if (memberTarget)
memberCompletions = memberCompletions.filter(
(c: any) => c.assignedChoreId === memberTarget
);
let current = 0;
if (cfg.type === 'threshold') {
if (cfg.thresholdType === 'percent') {
@@ -133,7 +152,10 @@ export async function evaluateFam(pb: any, famId: string) {
} 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);
if (cfg.targetChoreId)
teamCompletions = teamCompletions.filter(
(c: any) => c.assignedChoreId === cfg.targetChoreId
);
let total = 0;
if (cfg.type === 'threshold') {
if (cfg.thresholdType === 'percent') {
@@ -170,7 +192,10 @@ export async function evaluateFam(pb: any, famId: string) {
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);
if (memberTarget)
memberCompletions = memberCompletions.filter(
(c: any) => c.assignedChoreId === memberTarget
);
let current = 0;
if (cfg.type === 'threshold') {
if (cfg.thresholdType === 'percent') {
@@ -187,7 +212,9 @@ export async function evaluateFam(pb: any, famId: string) {
}
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 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('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[] = [];
try {
allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` });
} catch {}
const result: any[] = [];
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;
// Shared with the Rewards page client-side realtime recompute (famStore + SSE),
// so the admin kanban and member dashboard always show identical progress.
return computeBonusProgress(configsData, members, assigned, completions, allRewards, payday, tz);
}
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 periodRewards = memberRewards.filter((r: any) => r.date >= pStart && r.date <= pEnd);
if (periodRewards.length > 0) {
throw new Error(
`Already issued ${periodRewards.length}x this ${cfg.period} to ${m.name}`
);
throw new Error(`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,
bonusConfigId: cfg.id,
label,
value: Number(cfg.rewardValue) || 0,
value: getRewardValue(cfg),
rewardType: cfg.rewardType,
status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed',
claimedAt: cfg.rewardType === 'points' ? now : null,
@@ -408,4 +340,4 @@ export async function trigger(pb: any, famId: string, configId: string, memberId
}
return { triggered: true, created: created.length, records: created };
}
}