clean pass update with rewards and completions
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user