fixed payout locks & time sensitive todos & minor ui updates on chores view

This commit is contained in:
JCEEE
2026-08-04 11:38:38 +01:00
parent 7603c3eb21
commit c38ca43f0b
11 changed files with 289 additions and 164 deletions
+2
View File
@@ -286,6 +286,8 @@ async function main() {
number("value", true),
select("rewardType", ["cash", "prize", "points"], true),
select("status", ["unclaimed", "requested", "claimed"], true),
select("claimable", ["immediate", "payday"], true),
text("settleDate"),
date("claimedAt"),
date("requestedAt"),
text("date"),
+57 -2
View File
@@ -9,6 +9,7 @@ import {
todayInTz,
wallClockToUtc,
resolveTz,
nextPaydayAfter as nextPaydayAfterTz,
} from "../../timezone.ts";
const app = new Hono();
@@ -69,6 +70,35 @@ function periodEnd(period: string, start: string): string {
return start;
}
function nextPaydayAfter(dateStr: string, payday: number, tz?: string): string {
return nextPaydayAfterTz(dateStr, payday, resolveServerTz(tz));
}
// Rewards from weekly/monthly bonus configs are claimable only on payday.
// Stamp the reward with a settleDate = the next payday after the period ends.
function claimableStamp(cfg: any, payday: number, tz?: string) {
if (cfg.period !== "weekly" && cfg.period !== "monthly") {
return { claimable: "immediate", settleDate: "" };
}
const tzR = resolveServerTz(tz);
const now = todayInTz(tzR);
const start =
cfg.period === "monthly" ? `${now.slice(0, 7)}-01` : weekStart(payday, tzR);
const end = periodEnd(cfg.period, start);
return { claimable: "payday", settleDate: nextPaydayAfter(end, payday, tzR) };
}
// Payday-gated rewards can't be claimed until their settleDate (the payday).
function assertPaydayUnlocked(reward: any, tz?: string) {
if (!reward || reward.claimable !== "payday" || !reward.settleDate) return;
const today = todayInTz(resolveServerTz(tz));
if (today < reward.settleDate) {
throw new Error(
`This bonus pays out on payday (${reward.settleDate}) — hang tight!`,
);
}
}
async function getFamPayday(famId: string): Promise<number> {
try {
const fam = await pb.getList("fams", `id = '${famId}'`);
@@ -809,11 +839,11 @@ app.get("/api/admin/:famId/debug/eow-preview", requireAdmin, async (c) => {
const [pw, cw] = await Promise.all([
pb.getList(
"rewards",
`famId = '${famId}' && rewardType = 'points' && status = 'claimed'`,
`famId = '${famId}' && rewardType = 'points' && status = 'claimed' && date >= '${ws}'`,
),
pb.getList(
"rewards",
`famId = '${famId}' && rewardType = 'cash' && status = 'claimed'`,
`famId = '${famId}' && rewardType = 'cash' && status = 'claimed' && date >= '${ws}'`,
),
]);
rewardPointsList = pw.items;
@@ -1327,6 +1357,7 @@ async function evaluateFam(famId: string): Promise<void> {
status: cfg.rewardType === "points" ? "claimed" : "unclaimed",
claimedAt: cfg.rewardType === "points" ? now : null,
date: now.slice(0, 10),
...claimableStamp(cfg, paydayEval, tzEval),
});
createdReward = true;
}
@@ -1377,6 +1408,7 @@ async function evaluateFam(famId: string): Promise<void> {
status: cfg.rewardType === "points" ? "claimed" : "unclaimed",
claimedAt: cfg.rewardType === "points" ? now : null,
date: now.slice(0, 10),
...claimableStamp(cfg, paydayEval, tzEval),
});
createdReward = true;
}
@@ -1434,6 +1466,7 @@ async function evaluateFam(famId: string): Promise<void> {
status: cfg.rewardType === "points" ? "claimed" : "unclaimed",
claimedAt: cfg.rewardType === "points" ? now : null,
date: now.slice(0, 10),
...claimableStamp(cfg, paydayEval, tzEval),
});
createdReward = true;
}
@@ -1576,6 +1609,8 @@ app.post(
status: cfg.rewardType === "points" ? "claimed" : "unclaimed",
claimedAt: cfg.rewardType === "points" ? now : null,
date: now.slice(0, 10),
claimable: "immediate",
settleDate: "",
});
created.push(record);
}
@@ -1690,6 +1725,7 @@ app.post("/api/members/my-chores", requireDeviceToken, async (c) => {
const payday = await getFamPayday(famId);
const paydayTime = await getFamPaydayTime(famId);
const timezone = await getFamTimezone(famId);
const settings = await getFamSettings(famId);
const rewardsList = rewards.items;
const configsList = bonusConfigs.items;
return c.json({
@@ -1701,6 +1737,7 @@ app.post("/api/members/my-chores", requireDeviceToken, async (c) => {
payday,
paydayTime,
timezone,
simulateEow: !!settings.simulateEow,
});
} catch (err) {
return handleError(c, err);
@@ -1921,6 +1958,18 @@ app.post("/api/members/rewards/:id/claim", requireDeviceToken, async (c) => {
const famId = c.get("famId");
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 reward = found.items?.[0];
if (!reward) return c.json({ error: "Reward not found" }, 404);
try {
assertPaydayUnlocked(reward, tz);
} catch (e) {
return c.json(
{ error: e instanceof Error ? e.message : "Not claimable yet" },
400,
);
}
const record = await pb.update("rewards", id, {
status: "requested",
requestedAt: now,
@@ -1951,6 +2000,7 @@ app.post("/api/members/rewards/request-all", requireDeviceToken, async (c) => {
const famId = c.get("famId");
const memberId = c.get("memberId");
const now = new Date().toISOString();
const tz = await getFamTimezone(famId);
// Find all unclaimed rewards for this member
const rewards = await pb.getList(
"rewards",
@@ -1958,6 +2008,11 @@ app.post("/api/members/rewards/request-all", requireDeviceToken, async (c) => {
);
let count = 0;
for (const r of rewards.items) {
try {
assertPaydayUnlocked(r, tz);
} catch {
continue; // payday-gated reward not yet settled — leave for payday
}
await pb.update("rewards", r.id, {
status: "requested",
requestedAt: now,
+36
View File
@@ -284,6 +284,42 @@ export async function migrate(): Promise<void> {
console.log(` ↳ rewards collection not found (will be created by seed)`);
}
// Ensure rewards.claimable + rewards.settleDate exist (payday-gated bonuses)
const rewardsCol2 = await getCollection("rewards");
if (rewardsCol2) {
const fieldNames = rewardsCol2.fields.map((f: any) => f.name);
const missing: any[] = [];
if (!fieldNames.includes("claimable")) {
missing.push({
name: "claimable",
type: "select",
required: true,
values: ["immediate", "payday"],
maxSelect: 1,
});
}
if (!fieldNames.includes("settleDate")) {
missing.push({ name: "settleDate", type: "text", required: false });
}
if (missing.length) {
console.log("[migrate] Adding rewards.claimable/settleDate (payday gating)...");
rewardsCol2.fields.push(...missing);
await updateCollection(rewardsCol2.id, {
name: "rewards",
type: "base",
listRule: rewardsCol2.listRule,
viewRule: rewardsCol2.viewRule,
createRule: rewardsCol2.createRule,
updateRule: rewardsCol2.updateRule,
deleteRule: rewardsCol2.deleteRule,
fields: rewardsCol2.fields,
});
console.log(" ✓ rewards.claimable/settleDate added");
} else {
console.log(` ↳ rewards.claimable/settleDate already exist`);
}
}
// ── 3. Update settings collection (drop old fields) ──
const settingsCol = await getCollection("settings");
if (settingsCol) {