migrate from hono
This commit is contained in:
@@ -0,0 +1,452 @@
|
||||
import {
|
||||
weekStart,
|
||||
addDaysStr,
|
||||
resolveTz,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
wallClockToUtc
|
||||
} from '@shared/timezone';
|
||||
import { slugify } from '@shared/slugify';
|
||||
import { evaluateFam } from './bonuses';
|
||||
|
||||
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', 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.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', 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 eowPreview(pb: any, famId: string) {
|
||||
const { payday, tz } = await famMeta(pb, famId);
|
||||
const ws = weekStart(payday, tz);
|
||||
const we = periodEnd('weekly', ws);
|
||||
|
||||
const [members, assigned, completions, configs, rewards] = 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}'` }),
|
||||
pb
|
||||
.collection('bonus_configs')
|
||||
.getFullList({ filter: `famId = '${famId}' && status = 'active'` })
|
||||
.catch(() => []),
|
||||
pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }).catch(() => [])
|
||||
]);
|
||||
|
||||
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 completionsList = completions;
|
||||
|
||||
const summaries = members.map((m: any) => {
|
||||
const mc = completionsList.filter((c: any) => c.memberId === m.id);
|
||||
const weekPoints = mc.reduce((sum: number, c: any) => {
|
||||
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
||||
return sum + (ch?.type === 'points' ? Number(ch.value) : 0);
|
||||
}, 0);
|
||||
const weekMoney = mc.reduce((sum: number, c: any) => {
|
||||
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
||||
return sum + (ch?.type === 'money' ? Number(ch.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);
|
||||
return {
|
||||
memberId: m.id,
|
||||
memberName: m.name || m.username || m.id.slice(0, 6),
|
||||
memberColor: m.color,
|
||||
pointsEarned: weekPoints + bonusPoints,
|
||||
moneyEarned: weekMoney + bonusMoney,
|
||||
choresCompleted: mc.length,
|
||||
bonusEarned: bonusPoints
|
||||
};
|
||||
});
|
||||
|
||||
const predictedRewards: any[] = [];
|
||||
const existingRewards = rewards;
|
||||
for (const cfg of configs) {
|
||||
if (cfg.type === 'manual') continue;
|
||||
const pStart = cfg.period ? periodStart(cfg.period, payday, tz) : '';
|
||||
const pEnd = cfg.period ? periodEnd(cfg.period, pStart) : '';
|
||||
const periodCompletions = cfg.period
|
||||
? completionsList.filter(
|
||||
(c: any) => (c.date || '').slice(0, 10) >= pStart && (c.date || '').slice(0, 10) <= pEnd
|
||||
)
|
||||
: completionsList;
|
||||
const cfgRewards = existingRewards.filter((r: any) => r.bonusConfigId === cfg.id);
|
||||
|
||||
const tryEval = (sourceComps: any[]) => {
|
||||
if (cfg.type === 'threshold')
|
||||
return sourceComps.reduce((sum: number, c: any) => {
|
||||
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
|
||||
return sum + (ch?.type === 'points' ? Number(ch.value) : 0);
|
||||
}, 0);
|
||||
if (cfg.type === 'count') return sourceComps.length;
|
||||
return 0;
|
||||
};
|
||||
|
||||
if (cfg.target === 'individual') {
|
||||
const targets = cfg.memberId ? members.filter((m: any) => m.id === cfg.memberId) : members;
|
||||
for (const m of targets) {
|
||||
if (cfgRewards.some((r: any) => r.memberId === m.id)) continue;
|
||||
const current = tryEval(periodCompletions.filter((c: any) => c.memberId === m.id));
|
||||
if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue))
|
||||
predictedRewards.push({
|
||||
config: cfg.name,
|
||||
memberName: m.name || m.id.slice(0, 6),
|
||||
type: cfg.rewardType,
|
||||
value: Number(cfg.rewardValue) || 0,
|
||||
detail: `${cfg.type} ${current}/${cfg.criteriaValue}`
|
||||
});
|
||||
}
|
||||
} else if (cfg.target === 'collaborative') {
|
||||
if (cfgRewards.length) continue;
|
||||
const allIds = members.map((m: any) => m.id);
|
||||
const teamComps = periodCompletions.filter((c: any) => allIds.includes(c.memberId));
|
||||
const current = tryEval(teamComps);
|
||||
if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue))
|
||||
predictedRewards.push({
|
||||
config: cfg.name,
|
||||
memberName: 'Everyone',
|
||||
type: cfg.rewardType,
|
||||
value: Number(cfg.rewardValue) || 0,
|
||||
detail: `${cfg.type} ${current}/${cfg.criteriaValue}`
|
||||
});
|
||||
} else if (cfg.target === 'competitive') {
|
||||
if (cfgRewards.length) continue;
|
||||
const scored = members.map((m: any) => ({
|
||||
memberId: m.id,
|
||||
name: m.name,
|
||||
current: tryEval(periodCompletions.filter((c: any) => c.memberId === m.id))
|
||||
}));
|
||||
const qualified = scored.filter((st: any) => st.current >= Number(cfg.criteriaValue));
|
||||
const eligible = qualified.length ? qualified : scored.filter((st: any) => st.current > 0);
|
||||
const winner = eligible.sort((aa: any, bb: any) => bb.current - aa.current)[0];
|
||||
if (winner)
|
||||
predictedRewards.push({
|
||||
config: cfg.name,
|
||||
memberName: winner.name,
|
||||
type: cfg.rewardType,
|
||||
value: Number(cfg.rewardValue) || 0,
|
||||
detail: `winner ${winner.current} pts`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const nextWeekStart = addDaysStr(ws, 7);
|
||||
|
||||
return {
|
||||
simulateEow: true,
|
||||
weekStart: ws,
|
||||
weekEnd: we,
|
||||
nextWeekStart,
|
||||
summaries,
|
||||
predictedRewards,
|
||||
completionsThisWeek: completionsList.length
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user