Files
famdone/frontend/src/lib/server/services/fam.ts
T
2026-09-10 13:23:55 +01:00

332 lines
11 KiB
TypeScript

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<string, unknown>) {
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<string, string | number> = {};
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<string, unknown>
) {
const patch: Record<string, unknown> = {};
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<string, number> = {};
const dayCompletions: Record<string, number> = {};
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
};
}