-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathUom engine.ts
More file actions
139 lines (125 loc) · 4.64 KB
/
Copy pathUom engine.ts
File metadata and controls
139 lines (125 loc) · 4.64 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
/**
* UoM Engine — computes progress score for a single goal.
* Returns a number 0..100. Used for TRACKING only, never for ratings (per BRD).
*/
export type UoMType = 'numeric' | 'percentage' | 'timeline' | 'zero_based';
export type UoMDirection = 'min' | 'max'; // min = higher is better, max = lower is better
export interface GoalScoreInput {
uomType: UoMType;
uomDirection?: UoMDirection;
target?: number | null; // numeric/percentage target
targetDate?: string | null; // ISO date for timeline
actualValue?: number | null; // numeric/percentage actual, or 0/1 for zero-based
actualDate?: string | null; // ISO completion date for timeline
}
export interface GoalScoreResult {
score: number; // 0..100
display: string; // user-friendly label
reachedTarget: boolean;
}
const clamp = (n: number, lo = 0, hi = 100) => Math.max(lo, Math.min(hi, n));
export function computeGoalScore(input: GoalScoreInput): GoalScoreResult {
const { uomType, uomDirection = 'min', target, targetDate, actualValue, actualDate } = input;
// No actual yet
if (actualValue == null && !actualDate) {
return { score: 0, display: 'No update', reachedTarget: false };
}
switch (uomType) {
case 'numeric':
case 'percentage': {
if (target == null || target === 0 || actualValue == null) {
// Special case: target = 0 should usually be zero_based, but guard anyway
return { score: 0, display: 'Invalid target', reachedTarget: false };
}
let raw: number;
if (uomDirection === 'min') {
// Higher is better — e.g., Revenue: Achievement / Target
raw = (actualValue / target) * 100;
} else {
// Lower is better — e.g., TAT, Cost: Target / Achievement
if (actualValue === 0) {
// Beat target perfectly (zero cost / zero TAT)
return { score: 100, display: '100% (beat target)', reachedTarget: true };
}
raw = (target / actualValue) * 100;
}
const score = clamp(Math.round(raw));
return {
score,
display: `${score}%`,
reachedTarget: raw >= 100,
};
}
case 'timeline': {
if (!targetDate) {
return { score: 0, display: 'No deadline set', reachedTarget: false };
}
if (!actualDate) {
return { score: 0, display: 'Not completed', reachedTarget: false };
}
const deadline = new Date(targetDate).getTime();
const completed = new Date(actualDate).getTime();
if (completed <= deadline) {
return { score: 100, display: 'On/before deadline', reachedTarget: true };
}
// Missed deadline — partial credit based on how late
const daysLate = Math.ceil((completed - deadline) / 86_400_000);
const score = clamp(100 - daysLate * 5); // -5% per day late
return {
score,
display: `${daysLate} day(s) late`,
reachedTarget: false,
};
}
case 'zero_based': {
// Zero = success (e.g., zero safety incidents)
const val = actualValue ?? 0;
if (val === 0) {
return { score: 100, display: 'Zero incidents — success', reachedTarget: true };
}
return { score: 0, display: `${val} incident(s) — target missed`, reachedTarget: false };
}
default:
return { score: 0, display: 'Unknown UoM', reachedTarget: false };
}
}
/**
* Weighted score across all goals on a sheet. Weightages must sum to 100.
*/
export function computeWeightedScore(
rows: Array<{ score: number; weightage: number }>
): number {
const total = rows.reduce((sum, r) => sum + r.score * (r.weightage / 100), 0);
return Math.round(total);
}
/**
* Validates a goal sheet against the BRD rules.
*/
export interface ValidationIssue {
code: string;
message: string;
}
export function validateGoalSheet(
goals: Array<{ weightage: number }>
): { ok: boolean; totalWeightage: number; issues: ValidationIssue[] } {
const issues: ValidationIssue[] = [];
const total = goals.reduce((s, g) => s + (g.weightage || 0), 0);
if (goals.length === 0) {
issues.push({ code: 'NO_GOALS', message: 'At least one goal required.' });
}
if (goals.length > 8) {
issues.push({ code: 'TOO_MANY', message: `Maximum 8 goals allowed (you have ${goals.length}).` });
}
for (const [i, g] of goals.entries()) {
if ((g.weightage ?? 0) < 10) {
issues.push({ code: 'MIN_WEIGHT', message: `Goal #${i + 1}: minimum weightage is 10%.` });
}
}
if (Math.abs(total - 100) > 0.001) {
issues.push({
code: 'WEIGHT_SUM',
message: `Total weightage must be exactly 100% (currently ${total}%).`,
});
}
return { ok: issues.length === 0, totalWeightage: total, issues };
}