import { weekStart, addDaysStr, resolveTz, periodStart, periodEnd, wallClockToUtc } from '@shared/timezone'; import { slugify } from '@shared/slugify'; import { evaluateFam } from './bonuses'; import { cleanupExpiredTodos } from './chores'; function resolveServerTz(tz?: string): string { return resolveTz(tz || 'auto'); } export async function famMeta(pb: any, famId: string) { const fam = await pb.collection('fams').getOne(famId); return { payday: fam.payday !== undefined && fam.payday !== null ? Number(fam.payday) : 1, paydayTime: fam.paydayTime || '18:00', tz: resolveServerTz(fam.timezone) }; } export async function getFam(pb: any, famId: string) { const fam = await pb.collection('fams').getOne(famId); return { name: fam.name, slug: fam.slug, payday: fam.payday, paydayTime: fam.paydayTime || '18:00', timezone: fam.timezone || 'auto' }; } export async function patchFam(pb: any, famId: string, body: Record) { if (body.name !== undefined) { const name = body.name as string; if (!name) throw new Error('name required'); const slug = slugify(name); const record = await pb.collection('fams').update(famId, { name, slug }); return { name: record.name, slug: record.slug, payday: record.payday }; } if (body.payday !== undefined || body.paydayTime !== undefined || body.timezone !== undefined) { const patch: Record = {}; if (body.payday !== undefined) { const payday = Number(body.payday); if (payday < 0 || payday > 6 || !Number.isInteger(payday)) throw new Error('payday must be 0-6'); patch.payday = payday; } if (body.paydayTime !== undefined) { const paydayTime = String(body.paydayTime); if (!/^\d{2}:\d{2}$/.test(paydayTime)) throw new Error('paydayTime must be HH:MM'); patch.paydayTime = paydayTime; } if (body.timezone !== undefined) { const timezone = String(body.timezone); if (timezone !== 'auto' && !/^[A-Za-z_+-]+\/[A-Za-z_+-]+$/.test(timezone)) throw new Error("timezone must be an IANA name or 'auto'"); patch.timezone = timezone; } const record = await pb.collection('fams').update(famId, patch); return { payday: record.payday, paydayTime: record.paydayTime, timezone: record.timezone }; } throw new Error('no valid fields'); } export async function getProfile(pb: any, famId: string, userId: string) { const rec = await pb.collection('users').getOne(userId); return { id: rec.id, name: rec.name || '', color: rec.color || '#6366f1', pattern: rec.pattern || '', themeSize: rec.themeSize || '', themeOpacity: rec.themeOpacity || '', email: rec.email || '' }; } export async function updateProfile( pb: any, famId: string, userId: string, data: Record ) { const patch: Record = {}; if (data.name) patch.name = data.name; if (data.color) patch.color = data.color; if (data.pattern !== undefined) patch.pattern = data.pattern; if (data.themeSize !== undefined) patch.themeSize = data.themeSize; if (data.themeOpacity !== undefined) patch.themeOpacity = data.themeOpacity; if (data.email !== undefined) patch.email = data.email; const rec = await pb.collection('users').update(userId, patch); return { id: rec.id, name: rec.name || '', color: rec.color || '#6366f1', pattern: rec.pattern || '', themeSize: rec.themeSize || '', themeOpacity: rec.themeOpacity || '', email: rec.email || '' }; } export async function weeklySummary(pb: any, famId: string) { const { payday, tz } = await famMeta(pb, famId); const ws = weekStart(payday, tz); 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}' && date >= '${ws}'` }) ]); let rewardPointsList: any[] = []; try { rewardPointsList = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'` }); } catch {} const assignedList = assigned; const completionsList = completions; const daysInWeek: string[] = []; { const d = new Date(ws + 'T00:00:00Z'); for (let i = 0; i < 7; i++) { daysInWeek.push(d.toISOString().slice(0, 10)); d.setDate(d.getDate() + 1); } } const summaries = await Promise.all( members.map(async (m: any) => { const memberAssignments = assignedList.filter((a: any) => a.memberId === m.id); const memberCompletions = completionsList.filter((c: any) => c.memberId === m.id); const weekPoints = 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); const dayPoints: Record = {}; const dayCompletions: Record = {}; for (const day of daysInWeek) { dayPoints[day] = 0; dayCompletions[day] = 0; } for (const c of memberCompletions) { const day = (c.date || '').slice(0, 10); if (dayPoints[day] !== undefined) { const chore = assignedList.find((a: any) => a.id === c.assignedChoreId); dayPoints[day] += chore?.type === 'points' ? Number(chore.value) : 0; dayCompletions[day]++; } } const bonusPoints = rewardPointsList .filter((r: any) => r.memberId === m.id) .reduce((sum: number, r: any) => sum + Number(r.value), 0); const weekMoney = memberCompletions.reduce((sum: number, c: any) => { const chore = assignedList.find((a: any) => a.id === c.assignedChoreId); return sum + (chore?.type === 'money' ? Number(chore.value) : 0); }, 0); let bonusMoney = 0; try { const cashRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && memberId = '${m.id}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'` }); bonusMoney = cashRewards.reduce((sum: number, r: any) => sum + Number(r.value), 0); } catch {} return { memberId: m.id, memberName: m.name, memberColor: m.color, pointsEarned: weekPoints + bonusPoints, moneyEarned: weekMoney + bonusMoney, choresCompleted: memberCompletions.length, totalChores: memberAssignments.length, dayPoints, dayCompletions }; }) ); return { weekStart: ws, daysInWeek, summaries }; } export async function releaseWeek(pb: any, famId: string) { const { payday, paydayTime, tz } = await famMeta(pb, famId); const fams = await pb.collection('fams').getFullList({ filter: `id = '${famId}'` }); const fam = fams?.[0]; if (!fam) throw new Error('Fam not found'); const wsToday = weekStart(payday, tz); const target = new Date(wallClockToUtc(wsToday, paydayTime || '18:00', tz)); if (Date.now() < target.getTime()) { return { settled: false, notYet: true, weekStart: wsToday, target: target.toISOString() }; } if (fam.lastIssued === wsToday) return { settled: false, weekStart: wsToday }; const members = await pb .collection('users') .getFullList({ filter: `famId = '${famId}' && role = 'child'` }); let cashRewards: any[] = []; try { cashRewards = await pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && rewardType = 'cash' && (status = 'unclaimed' || status = 'requested')` }); } catch {} const breakdown: any[] = []; const now = new Date().toISOString(); const today = now.slice(0, 10); for (const m of members) { const unpaid = cashRewards.filter((r: any) => r.memberId === m.id && r.status !== 'claimed'); const total = unpaid.reduce((sum: number, r: any) => sum + Number(r.value), 0); if (total > 0) { for (const r of unpaid) { if (r.status !== 'requested') { await pb.collection('rewards').update(r.id, { status: 'requested', claimedAt: null, date: (r.date || '').slice(0, 10) || today }); } } breakdown.push({ memberId: m.id, name: m.name, total, rewards: unpaid.map((r: any) => ({ id: r.id, label: r.label, value: Number(r.value) })) }); } } await pb.collection('fams').update(famId, { lastIssued: wsToday }); return { settled: true, weekStart: wsToday, breakdown }; } export async function completeWeek(pb: any, famId: string) { const { payday, tz } = await famMeta(pb, famId); const ws = weekStart(payday, tz); await evaluateFam(pb, famId); // Start of a new week → purge todos whose deadline was in a prior week. await cleanupExpiredTodos(pb, famId).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}' && date >= '${ws}'` }) ]); let rewardPointsList: any[] = []; let rewardCashList: any[] = []; try { [rewardPointsList, rewardCashList] = await Promise.all([ pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'` }), pb.collection('rewards').getFullList({ filter: `famId = '${famId}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'` }) ]); } catch {} const assignedList = assigned; const historyRecords: any[] = []; for (const m of members) { const memberCompletions = completions.filter((c: any) => c.memberId === m.id); const weekPoints = 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); const weekMoney = memberCompletions.reduce((sum: number, c: any) => { const chore = assignedList.find((a: any) => a.id === c.assignedChoreId); return sum + (chore?.type === 'money' ? Number(chore.value) : 0); }, 0); const bonusPoints = rewardPointsList .filter((r: any) => r.memberId === m.id) .reduce((sum: number, r: any) => sum + Number(r.value), 0); const bonusMoney = rewardCashList .filter((r: any) => r.memberId === m.id) .reduce((sum: number, r: any) => sum + Number(r.value), 0); const existing = await pb .collection('weekly_history') .getFullList({ filter: `famId = '${famId}' && memberId = '${m.id}' && weekStart = '${ws}'` }); const recordData = { famId, memberId: m.id, weekStart: ws, pointsEarned: weekPoints + bonusPoints, moneyEarned: weekMoney + bonusMoney, choresCompleted: memberCompletions.length, bonusEarned: bonusPoints }; if (existing.length > 0) { await pb.collection('weekly_history').update(existing[0].id, recordData); } else { const record = await pb.collection('weekly_history').create(recordData); historyRecords.push(record); } } return { weekStart: ws, historyRecords, memberCount: members.length }; }