clean pass update with rewards and completions

This commit is contained in:
JCEEE
2026-09-09 19:02:29 +01:00
parent 9d7915eb60
commit 3f95d50d2e
6 changed files with 1126 additions and 590 deletions
+211
View File
@@ -0,0 +1,211 @@
// Bonus progress computation — single source of truth shared by the server
// rewards loader (`services/bonuses.ts` → `progress()`) and the Rewards page
// client-side realtime recompute (famStore + PB SSE). Both views must show the
// same counts, so the algorithm lives here rather than in two places.
import { bonusWindow, completionInWindow } from "./timezone";
export interface BonusProgressConfig {
id: string;
target: string;
memberId?: string;
type: string;
thresholdType?: string;
criteriaValue?: number;
period?: string;
targetChoreId?: string;
targetChoreIds?: Record<string, string> | null;
}
export interface BonusProgressMember {
id: string;
name: string;
color: string;
}
export interface BonusProgressAssigned {
id: string;
memberId?: string | null;
frequency?: string;
type?: string;
value?: number | string;
}
export interface BonusProgressCompletion {
memberId: string;
assignedChoreId: string;
date?: string;
}
export interface BonusProgressReward {
id: string;
memberId: string;
bonusConfigId?: string;
status: string;
}
export interface BonusProgressRow {
memberId: string;
memberName: string;
memberColor: string;
current: number;
criteriaValue: number;
reward: { id: string; status: string } | null;
state: string;
achieved: boolean;
}
export interface BonusConfigWithProgress {
config: BonusProgressConfig;
progress: BonusProgressRow[];
periodStart: string;
periodEnd: string;
}
// Per-member target-chore pick wins over the legacy scalar targetChoreId.
function targetChoreFor(
cfg: BonusProgressConfig,
memberId: string,
): string | undefined {
if (
cfg.targetChoreIds &&
typeof cfg.targetChoreIds === "object" &&
cfg.targetChoreIds[memberId]
) {
return cfg.targetChoreIds[memberId];
}
return cfg.targetChoreId || undefined;
}
// Compute per-config progress rows for every member (or the team/winner for
// collaborative/competitive targets). Callers pass the configs they want
// scored (the server uses active-only; the Rewards page mirrors that).
export function computeBonusProgress(
configs: BonusProgressConfig[],
members: BonusProgressMember[],
assigned: BonusProgressAssigned[],
completions: BonusProgressCompletion[],
rewards: BonusProgressReward[],
payday: number,
tz: string,
): BonusConfigWithProgress[] {
// Chores "due" per week per member (daily = 7, otherwise 1). Used by the
// `percent` threshold type to compute % of chores completed.
const dueByMember: Record<string, number> = {};
for (const a of assigned) {
if (a.memberId) {
dueByMember[a.memberId] =
(dueByMember[a.memberId] || 0) + (a.frequency === "daily" ? 7 : 1);
}
}
const totalDue = Object.values(dueByMember).reduce((s, n) => s + n, 0) || 1;
const result: BonusConfigWithProgress[] = [];
for (const cfg of configs) {
const cfgRewards = rewards.filter((r) => r.bonusConfigId === cfg.id);
const win = bonusWindow(cfg, payday, tz);
const pStart = win.from;
const pEnd = win.to;
const periodCompletions = completions.filter((c) =>
completionInWindow(c, win),
);
const progressRows: BonusProgressRow[] = [];
if (cfg.target === "collaborative") {
let teamCompletions = periodCompletions.filter((c) =>
members.some((m) => m.id === c.memberId),
);
if (cfg.targetChoreId) {
teamCompletions = teamCompletions.filter(
(c) => c.assignedChoreId === cfg.targetChoreId,
);
}
let teamCurrent = 0;
if (cfg.type === "threshold") {
if (cfg.thresholdType === "percent") {
teamCurrent = Math.round((teamCompletions.length / totalDue) * 100);
} else {
teamCurrent = teamCompletions.reduce((sum, c) => {
const chore = assigned.find((a) => a.id === c.assignedChoreId);
return sum + (chore?.type === "points" ? Number(chore.value) : 0);
}, 0);
}
} else if (cfg.type === "count") {
teamCurrent = teamCompletions.length;
}
const teamReward = cfgRewards[0];
progressRows.push({
memberId: "__team__",
memberName: "Team Total",
memberColor: "#8b5cf6",
current: teamCurrent,
criteriaValue: cfg.criteriaValue || 0,
reward: teamReward
? { id: teamReward.id, status: teamReward.status }
: null,
state: teamReward ? teamReward.status : "pending",
achieved: teamReward ? true : false,
});
}
if (cfg.target !== "collaborative") {
const progressMembers =
cfg.target === "individual" && cfg.memberId
? members.filter((m) => m.id === cfg.memberId)
: members;
for (const m of progressMembers) {
const memberTarget = targetChoreFor(cfg, m.id);
let memberCompletions = periodCompletions.filter(
(c) => c.memberId === m.id,
);
if (memberTarget) {
memberCompletions = memberCompletions.filter(
(c) => c.assignedChoreId === memberTarget,
);
}
const memberReward = cfgRewards.find((r) => r.memberId === m.id);
let current = 0;
if (cfg.type === "threshold") {
if (cfg.thresholdType === "percent") {
const due = dueByMember[m.id] || 0;
current =
due > 0 ? Math.round((memberCompletions.length / due) * 100) : 0;
} else {
current = memberCompletions.reduce((sum, c) => {
const chore = assigned.find((a) => a.id === c.assignedChoreId);
return sum + (chore?.type === "points" ? Number(chore.value) : 0);
}, 0);
}
} else if (cfg.type === "count") {
current = memberCompletions.length;
} else if (cfg.type === "manual") {
current = 0;
}
progressRows.push({
memberId: m.id,
memberName: m.name,
memberColor: m.color,
current,
criteriaValue: cfg.criteriaValue || 0,
reward: memberReward
? { id: memberReward.id, status: memberReward.status }
: null,
state: memberReward ? memberReward.status : "pending",
achieved: memberReward ? true : false,
});
}
}
result.push({
config: cfg,
progress: progressRows,
periodStart: pStart,
periodEnd: pEnd,
});
}
return result;
}