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 = {}; 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 => { 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 }; }