212 lines
6.3 KiB
TypeScript
212 lines
6.3 KiB
TypeScript
// 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;
|
|
}
|