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