Files
famdone/frontend/src/lib/server/services/bonuses.ts
T

412 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}