fix some issues - including add ledger page and fix reoccuring completions

This commit is contained in:
JCEEE
2026-08-05 09:03:08 +01:00
parent 6e817be13f
commit d162ea762e
14 changed files with 993 additions and 546 deletions
+1
View File
@@ -345,6 +345,7 @@ async function main() {
rel("memberId", ids.members!, true),
rel("assignedChoreId", ids.assigned_chores!, true),
date("date"),
date("completedAt"),
],
});
+80 -30
View File
@@ -10,6 +10,7 @@ import {
wallClockToUtc,
resolveTz,
nextPaydayAfter as nextPaydayAfterTz,
periodWindow,
} from "../../timezone.ts";
const app = new Hono();
@@ -613,7 +614,11 @@ app.patch("/api/admin/:famId/fam", requireAdmin, async (c) => {
payday: record.payday,
});
}
if (body.payday !== undefined || body.paydayTime !== undefined || body.timezone !== undefined) {
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);
@@ -629,8 +634,14 @@ app.patch("/api/admin/:famId/fam", requireAdmin, async (c) => {
}
if (body.timezone !== undefined) {
const timezone = String(body.timezone);
if (timezone !== "auto" && !/^[A-Za-z_+-]+\/[A-Za-z_+-]+$/.test(timezone))
return c.json({ error: "timezone must be an IANA name or 'auto'" }, 400);
if (
timezone !== "auto" &&
!/^[A-Za-z_+-]+\/[A-Za-z_+-]+$/.test(timezone)
)
return c.json(
{ error: "timezone must be an IANA name or 'auto'" },
400,
);
patch.timezone = timezone;
}
const record = await pb.update("fams", famId, patch);
@@ -782,9 +793,7 @@ app.get("/api/admin/:famId/weekly-summary", requireAdmin, async (c) => {
async function getFamSettings(famId: string): Promise<any> {
try {
return (
(
await pb.getList("settings", `famId = '${famId}'`)
).items?.[0] || {}
(await pb.getList("settings", `famId = '${famId}'`)).items?.[0] || {}
);
} catch {
return {};
@@ -818,7 +827,10 @@ app.patch("/api/admin/:famId/settings", requireAdmin, async (c) => {
} else if (Object.keys(patch).length) {
s = await pb.update("settings", s.id, patch);
}
return c.json({ simulateEow: !!s.simulateEow, webhookUrl: s.webhookUrl || "" });
return c.json({
simulateEow: !!s.simulateEow,
webhookUrl: s.webhookUrl || "",
});
} catch (err) {
return handleError(c, err);
}
@@ -842,7 +854,9 @@ app.get("/api/admin/:famId/debug/eow-preview", requireAdmin, async (c) => {
pb
.getList("bonus_configs", `famId = '${famId}' && status = 'active'`)
.catch(() => ({ items: [] })),
pb.getList("rewards", `famId = '${famId}'`).catch(() => ({ items: [] })),
pb
.getList("rewards", `famId = '${famId}'`)
.catch(() => ({ items: [] })),
]);
let rewardPointsList: any[] = [];
@@ -914,7 +928,9 @@ app.get("/api/admin/:famId/debug/eow-preview", requireAdmin, async (c) => {
let current = 0;
if (cfg.type === "threshold")
current = sourceComps.reduce((sum: number, c: any) => {
const ch = assignedList.find((a: any) => a.id === c.assignedChoreId);
const ch = assignedList.find(
(a: any) => a.id === c.assignedChoreId,
);
return sum + (ch?.type === "points" ? Number(ch.value) : 0);
}, 0);
else if (cfg.type === "count") current = sourceComps.length;
@@ -927,7 +943,10 @@ app.get("/api/admin/:famId/debug/eow-preview", requireAdmin, async (c) => {
: members.items;
for (const m of targets) {
if (cfgRewards.some((r: any) => r.memberId === m.id)) continue;
const current = tryEval(m, periodCompletions.filter((c: any) => c.memberId === m.id));
const current = tryEval(
m,
periodCompletions.filter((c: any) => c.memberId === m.id),
);
if (cfg.criteriaValue > 0 && current >= Number(cfg.criteriaValue))
predictedRewards.push({
config: cfg.name,
@@ -969,9 +988,7 @@ app.get("/api/admin/:famId/debug/eow-preview", requireAdmin, async (c) => {
const eligible = qualified.length
? qualified
: scored.filter((st) => st.current > 0);
const winner = eligible.sort(
(aa, bb) => bb.current - aa.current,
)[0];
const winner = eligible.sort((aa, bb) => bb.current - aa.current)[0];
if (winner)
predictedRewards.push({
config: cfg.name,
@@ -1299,7 +1316,9 @@ async function evaluateFam(famId: string): Promise<void> {
const tzEval = await getFamTimezone(famId);
for (const cfg of configs) {
const pStart2 = cfg.period ? periodStart(cfg.period, paydayEval, tzEval) : "";
const pStart2 = cfg.period
? periodStart(cfg.period, paydayEval, tzEval)
: "";
const pEnd = cfg.period ? periodEnd(cfg.period, pStart2) : "";
const periodCompletions = cfg.period
? allCompletions.items.filter(
@@ -1397,7 +1416,9 @@ async function evaluateFam(famId: string): Promise<void> {
if (!achieved && existingRewards.length > 0) {
for (const r of existingRewards) {
if (r.status !== "claimed") {
try { await pb.delete("rewards", r.id); } catch {}
try {
await pb.delete("rewards", r.id);
} catch {}
}
}
continue;
@@ -1454,11 +1475,11 @@ async function evaluateFam(famId: string): Promise<void> {
if (existingRewards.length > 0) {
const existing = existingRewards[0];
const stillValid =
winner &&
existing.memberId === winner.memberId &&
winner.current > 0;
winner && existing.memberId === winner.memberId && winner.current > 0;
if (!stillValid && existing.status !== "claimed") {
try { await pb.delete("rewards", existing.id); } catch {}
try {
await pb.delete("rewards", existing.id);
} catch {}
}
}
@@ -1653,13 +1674,26 @@ app.post("/api/completions/toggle", requireDeviceToken, async (c) => {
if (!assignedChoreId || !date) {
return c.json({ error: "assignedChoreId and date required" }, 400);
}
const nextDay = new Date(new Date(date + "T00:00:00Z").getTime() + 86400000)
.toISOString()
.slice(0, 10);
const existing = await pb.getList(
"completions",
`assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}' && date >= '${date}' && date < '${nextDay}'`,
);
// Todos are one-off: any existing completion means it's done, regardless of date.
const chore = await pb
.getList(
"assigned_chores",
`famId = '${famId}' && id = '${assignedChoreId}'`,
)
.then((r) => r.items?.[0]);
const isTodo = chore?.isTodo;
let filter: string;
if (isTodo) {
filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}'`;
} else {
// Scope to the chore's period: daily = today, weekly = the current week.
// Otherwise a weekly chore completed yesterday would be toggleable again today.
const payday = await getFamPayday(famId);
const tz = await getFamTimezone(famId);
const { from, to } = periodWindow(chore?.frequency, payday, tz);
filter = `assignedChoreId = '${assignedChoreId}' && memberId = '${memberId}' && date >= '${from}' && date < '${to}'`;
}
const existing = await pb.getList("completions", filter);
if (existing.items?.length > 0) {
await pb.delete("completions", existing.items[0].id);
evaluateFam(famId).catch(() => {});
@@ -1670,6 +1704,7 @@ app.post("/api/completions/toggle", requireDeviceToken, async (c) => {
memberId,
assignedChoreId,
date,
completedAt: new Date().toISOString(),
});
evaluateFam(famId).catch(() => {});
return c.json({ completed: true, record });
@@ -1971,7 +2006,10 @@ app.post("/api/members/rewards/:id/claim", requireDeviceToken, async (c) => {
const memberId = c.get("memberId");
const now = new Date().toISOString();
const tz = await getFamTimezone(famId);
const found = await pb.getList("rewards", `famId = '${famId}' && id = '${id}'`);
const found = await pb.getList(
"rewards",
`famId = '${famId}' && id = '${id}'`,
);
const reward = found.items?.[0];
if (!reward) return c.json({ error: "Reward not found" }, 404);
try {
@@ -2097,7 +2135,12 @@ async function releaseWeek(famId: string) {
const paydayTime = fam.paydayTime || "18:00";
const target = new Date(wallClockToUtc(wsToday, paydayTime, tz));
if (Date.now() < target.getTime()) {
return { settled: false, notYet: true, weekStart: wsToday, target: target.toISOString() };
return {
settled: false,
notYet: true,
weekStart: wsToday,
target: target.toISOString(),
};
}
if (fam.lastIssued === wsToday) return { settled: false, weekStart: wsToday };
@@ -2119,7 +2162,10 @@ async function releaseWeek(famId: string) {
const unpaid = (cashRewards.items || []).filter(
(r: any) => r.memberId === m.id && r.status !== "claimed",
);
const total = unpaid.reduce((sum: number, r: any) => sum + Number(r.value), 0);
const total = unpaid.reduce(
(sum: number, r: any) => sum + Number(r.value),
0,
);
if (total > 0) {
// Auto-claim: flip every unpaid cash reward to 'requested' so they land on
// the parent's Issue list, but keep them as individual rows (Issue All totals).
@@ -2145,7 +2191,11 @@ async function releaseWeek(famId: string) {
memberId: m.id,
name: m.name,
total,
rewards: unpaid.map((r: any) => ({ id: r.id, label: r.label, value: Number(r.value) })),
rewards: unpaid.map((r: any) => ({
id: r.id,
label: r.label,
value: Number(r.value),
})),
});
}
}
+30
View File
@@ -1377,5 +1377,35 @@ export async function migrate(): Promise<void> {
console.log(` ↳ assigned_chores collection not found (will be created by seed)`);
}
// ── 8. Add completedAt timestamp to completions ──
const complCol = await getCollection("completions");
if (complCol) {
const hasCompletedAt = complCol.fields.some((f: any) => f.name === "completedAt");
if (!hasCompletedAt) {
console.log("[migrate] Adding completions.completedAt...");
complCol.fields.push({
name: "completedAt",
type: "date",
required: false,
hidden: false,
});
await updateCollection(complCol.id, {
name: "completions",
type: "base",
listRule: complCol.listRule,
viewRule: complCol.viewRule,
createRule: complCol.createRule,
updateRule: complCol.updateRule,
deleteRule: complCol.deleteRule,
fields: complCol.fields,
});
console.log(" ✓ completions.completedAt added");
} else {
console.log(` ↳ completions.completedAt already exists`);
}
} else {
console.log(` ↳ completions collection not found (will be created by seed)`);
}
console.log("[migrate] Done");
}