clean pass update with rewards and completions
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user