332 lines
11 KiB
TypeScript
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
|
|
};
|
|
}
|