fix some issues - including add ledger page and fix reoccuring completions
This commit is contained in:
@@ -345,6 +345,7 @@ async function main() {
|
||||
rel("memberId", ids.members!, true),
|
||||
rel("assignedChoreId", ids.assigned_chores!, true),
|
||||
date("date"),
|
||||
date("completedAt"),
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
+80
-30
@@ -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),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user