// 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 | 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 = {}; 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; }