378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
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 };
|
||
}
|