Files
famdone/frontend/src/lib/server/services/bonuses.ts
T
2026-09-23 20:49:32 +01:00

378 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,
weekdayInTz,
bonusWindow,
completionInWindow
} from '@shared/timezone';
import { computeBonusProgress } from '@shared/bonus-progress';
import { famMeta } from './fam';
import { pbAdmin } from '$lib/server/pocketbase';
// `fams` reads via superuser: the acting token may predate RULE_OWN_FAM
// (migrate only bootstraps fresh stores), which otherwise logs a 404 and
// aborts evaluation. Fam-scoped by record id.
async function famMetaSU(famId: string) {
try {
const fam: any = await pbAdmin.getOne('fams', famId);
return {
payday: fam.payday !== undefined && fam.payday !== null ? Number(fam.payday) : 1,
paydayTime: fam.paydayTime || '18:00',
tz: resolveTz(fam.timezone || 'auto')
};
} catch {
return { payday: 1, paydayTime: '18:00', tz: resolveTz('auto') };
}
}
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: '' };
}
const now = todayInTz(resolveServerTz(tz));
const start = cfg.period === 'monthly' ? `${now.slice(0, 7)}-01` : weekStart(payday, tz);
const end = periodEnd(cfg.period, start);
// Weeks close on payday, so a weekly window's end IS the settle date —
// nextPaydayAfter(end) would skip a whole extra week.
const settle =
weekdayInTz(new Date(end + 'T12:00:00Z'), tz) === payday
? end
: nextPaydayAfter(end, payday, tz);
return { claimable: 'payday', settleDate: settle };
}
function targetChoreFor(cfg: any, memberId: string): string | undefined {
if (
cfg.targetChoreIds &&
typeof cfg.targetChoreIds === 'object' &&
cfg.targetChoreIds[memberId]
) {
return cfg.targetChoreIds[memberId];
}
return cfg.targetChoreId || undefined;
}
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 famMetaSU(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);
const periodCompletions = allCompletions.filter((c: any) => completionInWindow(c, win));
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;
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: getRewardValue(cfg),
rewardType: cfg.rewardType,
status: cfg.rewardType === 'points' ? 'claimed' : 'unclaimed',
claimedAt: cfg.rewardType === 'points' ? now : null,
date: now.slice(0, 10),
...claimableStamp(cfg, paydayEval, tzEval)
};
};
// Completions counting toward one member (target-chore scoped when set).
const completionsFor = (memberId: string) => {
const memberTarget = targetChoreFor(cfg, memberId);
let list = periodCompletions.filter((c: any) => c.memberId === memberId);
if (memberTarget) list = list.filter((c: any) => c.assignedChoreId === memberTarget);
// Collaborative configs only honour the shared targetChoreId.
if (cfg.target === 'collaborative' && cfg.targetChoreId)
list = list.filter((c: any) => c.assignedChoreId === cfg.targetChoreId);
return list;
};
// Score for one member: percent of due chores, points value, or raw count.
const scoreFor = (memberId: string): number => {
const list = completionsFor(memberId);
if (cfg.type === 'threshold') {
if (cfg.thresholdType === 'percent') {
const due = dueByMember[memberId] || 0;
return due > 0 ? Math.round((list.length / due) * 100) : 0;
}
return list.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);
}
if (cfg.type === 'count') return list.length;
return 0;
};
const met = (current: number) => cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue);
// Reconcile one member's window reward against their achieved state.
// Returns true when a live window reward exists afterwards.
const reconcile = async (memberId: string, achieved: boolean): Promise<boolean> => {
const live = windowRewards.filter((r: any) => r.memberId === memberId);
if (!achieved) {
for (const r of live) {
if (!removable(r)) continue;
try {
await pb.collection('rewards').delete(r.id);
} catch {}
windowRewards.splice(windowRewards.indexOf(r), 1);
}
return false;
}
if (live.length > 0) return true;
await pb.collection('rewards').create(rewardData(memberId));
createdReward = true;
return true;
};
// Drop stale prior-window placeholders (unclaimed, non-gated) so they
// can't pile up unseen; gated/requested/claimed rows persist by design.
const purgeStale = async (memberId: string) => {
const stale = existingRewards.filter(
(r: any) => r.memberId === memberId && !completionInWindow(r, win) && removable(r)
);
for (const r of stale) {
try {
await pb.collection('rewards').delete(r.id);
} catch {}
}
};
if (cfg.target === 'competitive') {
// Winner-takes-all: a single window reward held by the top scorer.
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;
}
// A locked (claimed/requested/gated) holder keeps the crown until issued.
}
} else {
// 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);
return sum + (chore?.type === 'points' ? Number(chore.value) : 0);
}, 0);
} else if (cfg.type === 'count') {
teamTotal = team.length;
}
}
const members =
cfg.target === 'individual' && cfg.memberId
? allMembers.filter((m: any) => m.id === cfg.memberId)
: 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);
}
}
if (cfg.occurrence === 'once' && (existingRewards.length > 0 || createdReward)) {
// Status write via superuser: evaluate often runs as a child token
// (fire-and-forget after a chore toggle) and bonus_configs updates
// are parent-only — the user-token write 403s and the config stays
// `active` forever. Stamp who earned it + when.
const earner =
existingRewards.find((r: any) => r.memberId)?.memberId ||
allMembers[0]?.id ||
'auto';
try {
await pbAdmin.update('bonus_configs', cfg.id, {
status: 'completed',
completedAt: new Date().toISOString().slice(0, 10),
completedBy: earner
});
} 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}'` })
]);
let allRewards: any[] = [];
try {
allRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` });
} catch {}
// 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) {
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: getRewardValue(cfg),
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 };
}