412 lines
14 KiB
TypeScript
412 lines
14 KiB
TypeScript
import {
|
||
todayInTz,
|
||
resolveTz,
|
||
periodStart,
|
||
periodEnd,
|
||
nextPaydayAfter,
|
||
weekStart,
|
||
bonusWindow,
|
||
completionInWindow
|
||
} from '@shared/timezone';
|
||
import { famMeta } from './fam';
|
||
|
||
function resolveServerTz(tz?: string): string {
|
||
return resolveTz(tz || 'auto');
|
||
}
|
||
|
||
function claimableStamp(cfg: any, payday: number, tz: string) {
|
||
if (cfg.period !== 'weekly' && cfg.period !== 'monthly') {
|
||
return { claimable: 'immediate', settleDate: '' };
|
||
}
|
||
const now = todayInTz(resolveServerTz(tz));
|
||
const start = cfg.period === 'monthly' ? `${now.slice(0, 7)}-01` : weekStart(payday, tz);
|
||
const end = periodEnd(cfg.period, start);
|
||
return { claimable: 'payday', settleDate: nextPaydayAfter(end, payday, tz) };
|
||
}
|
||
|
||
export async function evaluateFam(pb: any, famId: string) {
|
||
let configs: any[] = [];
|
||
try {
|
||
configs = await pb
|
||
.collection('bonus_configs')
|
||
.getFullList({ filter: `famId = '${famId}' && status = 'active' && type != 'manual'` });
|
||
} catch {
|
||
return;
|
||
}
|
||
if (!configs.length) return;
|
||
|
||
const [allMembers, allAssigned, allCompletions] = await Promise.all([
|
||
pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }),
|
||
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }),
|
||
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` })
|
||
]);
|
||
let allRewards: any[] = [];
|
||
try {
|
||
allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` });
|
||
} catch {}
|
||
const { payday: paydayEval, tz: tzEval } = await famMeta(pb, famId);
|
||
|
||
// Chores "due" this week per member (daily = 7, otherwise 1). Used by the
|
||
// `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);
|
||
}
|
||
const totalDue = Object.values(dueByMember).reduce((s, n) => s + n, 0) || 1;
|
||
|
||
for (const cfg of configs) {
|
||
// Pocket money pauses until the parent sets an amount.
|
||
if (cfg.isPocketMoney && !cfg.rewardValue) continue;
|
||
const win = bonusWindow(cfg, paydayEval, tzEval);
|
||
let periodCompletions = allCompletions.filter((c: any) => completionInWindow(c, win));
|
||
|
||
// Count-type rewards can pin against a single assigned chore (targetChoreId).
|
||
// When set, only completions of that specific chore count toward the target.
|
||
if (cfg.type === 'count' && cfg.targetChoreId) {
|
||
periodCompletions = periodCompletions.filter(
|
||
(c: any) => c.assignedChoreId === cfg.targetChoreId
|
||
);
|
||
}
|
||
|
||
const existingRewards = allRewards.filter((r: any) => r.bonusConfigId === cfg.id);
|
||
let createdReward = false;
|
||
|
||
const rewardData = (memberId: string) => {
|
||
const label =
|
||
cfg.rewardType === 'cash'
|
||
? `${cfg.name} – £${Number(cfg.rewardValue).toFixed(2)}`
|
||
: `${cfg.name} – ${cfg.rewardValue}`;
|
||
const now = new Date().toISOString();
|
||
return {
|
||
famId,
|
||
memberId,
|
||
bonusConfigId: cfg.id,
|
||
label,
|
||
value: Number(cfg.rewardValue) || 0,
|
||
rewardType: cfg.rewardType,
|
||
status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed',
|
||
claimedAt: cfg.rewardType === 'points' ? now : null,
|
||
date: now.slice(0, 10),
|
||
...claimableStamp(cfg, paydayEval, tzEval)
|
||
};
|
||
};
|
||
|
||
if (cfg.target === 'individual') {
|
||
const targetMembers = cfg.memberId ? allMembers.filter((m: any) => m.id === cfg.memberId) : allMembers;
|
||
for (const m of targetMembers) {
|
||
const memberCompletions = periodCompletions.filter((c: any) => c.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 = 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) {
|
||
if (memberReward.status !== 'claimed') {
|
||
try {
|
||
await pb.collection('rewards').delete(memberReward.id);
|
||
} catch {}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (memberReward) continue;
|
||
|
||
if (achieved) {
|
||
await pb.collection('rewards').create(rewardData(m.id));
|
||
createdReward = true;
|
||
}
|
||
}
|
||
} else if (cfg.target === 'collaborative') {
|
||
const allMemberIds = allMembers.map((m: any) => m.id);
|
||
const teamCompletions = periodCompletions.filter((c: any) => allMemberIds.includes(c.memberId));
|
||
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') {
|
||
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));
|
||
createdReward = true;
|
||
}
|
||
}
|
||
} else if (cfg.target === 'competitive') {
|
||
const scored = allMembers.map((m: any) => {
|
||
const memberCompletions = periodCompletions.filter((c: any) => c.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 = 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') {
|
||
try {
|
||
await pb.collection('rewards').delete(existing.id);
|
||
} catch {}
|
||
}
|
||
}
|
||
|
||
if (winner && existingRewards.length === 0) {
|
||
await pb.collection('rewards').create(rewardData(winner.memberId));
|
||
createdReward = true;
|
||
}
|
||
}
|
||
|
||
if (cfg.occurrence === 'once' && (existingRewards.length > 0 || createdReward)) {
|
||
try {
|
||
await pb.collection('bonus_configs').update(cfg.id, { status: 'completed' });
|
||
} catch {}
|
||
}
|
||
}
|
||
}
|
||
|
||
export async function evaluateAll(pb: any, famId: string) {
|
||
await evaluateFam(pb, famId);
|
||
return { evaluated: true };
|
||
}
|
||
|
||
export async function progress(pb: any, famId: string) {
|
||
const { payday, tz } = await famMeta(pb, famId);
|
||
let configsData: any[] = [];
|
||
try {
|
||
configsData = await pb
|
||
.collection('bonus_configs')
|
||
.getFullList({ filter: `famId = '${famId}' && status = 'active'` });
|
||
} catch {}
|
||
const [members, assigned, completions] = await Promise.all([
|
||
pb.collection('users').getFullList({ filter: `famId = '${famId}' && role = 'child'` }),
|
||
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;
|
||
let periodCompletions = completionsList.filter((c: any) => completionInWindow(c, win));
|
||
|
||
// Count-type rewards pinned to a specific chore only count that chore's
|
||
// completions toward the target.
|
||
if (cfg.type === 'count' && cfg.targetChoreId) {
|
||
periodCompletions = periodCompletions.filter(
|
||
(c: any) => c.assignedChoreId === cfg.targetChoreId
|
||
);
|
||
}
|
||
|
||
const progressRows: any[] = [];
|
||
|
||
if (cfg.target === 'collaborative') {
|
||
const teamCompletions = periodCompletions.filter((c: any) =>
|
||
members.some((m: any) => m.id === c.memberId)
|
||
);
|
||
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 memberCompletions = periodCompletions.filter((c: any) => c.memberId === m.id);
|
||
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;
|
||
}
|
||
|
||
export async function trigger(pb: any, famId: string, configId: string, memberId?: string) {
|
||
const configs = await pb
|
||
.collection('bonus_configs')
|
||
.getFullList({ filter: `famId = '${famId}' && id = '${configId}' && status = 'active'` });
|
||
const cfg = configs?.[0];
|
||
if (!cfg) throw new Error('Bonus config not found');
|
||
if (cfg.type !== 'manual') throw new Error('Only manual-type configs can be triggered');
|
||
|
||
const existingRewards = await pb.collection('rewards').getFullList({
|
||
filter: `famId = '${famId}' && bonusConfigId = '${cfg.id}'`
|
||
});
|
||
|
||
const members = await pb
|
||
.collection('users')
|
||
.getFullList({ filter: `famId = '${famId}' && role = 'child'` });
|
||
|
||
const targetMembers: any[] = [];
|
||
if (cfg.target === 'competitive' || cfg.target === 'collaborative') {
|
||
for (const m of members) targetMembers.push(m);
|
||
} else if (cfg.target === 'individual') {
|
||
const targetId = cfg.memberId || memberId;
|
||
if (!targetId) throw new Error('memberId required for individual trigger');
|
||
const member = members.find((m: any) => m.id === targetId);
|
||
if (!member) throw new Error('Member not found');
|
||
targetMembers.push(member);
|
||
}
|
||
|
||
for (const m of targetMembers) {
|
||
const memberRewards = existingRewards.filter((r: any) => r.memberId === m.id);
|
||
if (cfg.occurrence === 'once' && memberRewards.some((r: any) => r.status === 'unclaimed')) {
|
||
throw new Error(`Already issued and pending for ${m.name}`);
|
||
}
|
||
if (cfg.occurrence === 'recurring' && cfg.period) {
|
||
const { payday, tz } = await famMeta(pb, famId);
|
||
const pStart = periodStart(cfg.period, payday, tz);
|
||
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}`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
const created: any[] = [];
|
||
for (const m of targetMembers) {
|
||
const label =
|
||
cfg.rewardType === 'cash'
|
||
? `${cfg.name} – £${Number(cfg.rewardValue).toFixed(2)}`
|
||
: `${cfg.name} – ${cfg.rewardValue}`;
|
||
const now = new Date().toISOString();
|
||
const record = await pb.collection('rewards').create({
|
||
famId,
|
||
memberId: m.id,
|
||
bonusConfigId: cfg.id,
|
||
label,
|
||
value: Number(cfg.rewardValue) || 0,
|
||
rewardType: cfg.rewardType,
|
||
status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed',
|
||
claimedAt: cfg.rewardType === 'points' ? now : null,
|
||
date: now.slice(0, 10),
|
||
claimable: 'immediate',
|
||
settleDate: ''
|
||
});
|
||
created.push(record);
|
||
}
|
||
|
||
if (cfg.occurrence === 'once') {
|
||
await pb.collection('bonus_configs').update(cfg.id, { status: 'completed' });
|
||
}
|
||
|
||
return { triggered: true, created: created.length, records: created };
|
||
} |