109 lines
4.3 KiB
JavaScript
109 lines
4.3 KiB
JavaScript
|
|
// ── CPAS point roll-off model ────────────────────────────────────────────────
|
||
|
|
// Handbook rule: points roll off only after the employee goes a full
|
||
|
|
// ROLLOFF_DAYS (90) with NO new violations. The clock is shared across all of an
|
||
|
|
// employee's active points and is RESET by any new non-negated violation. Each
|
||
|
|
// completed clean cycle removes ROLLOFF_POINTS (5), oldest violation first, and
|
||
|
|
// every successive roll-off requires another fresh clean cycle.
|
||
|
|
//
|
||
|
|
// This is intentionally NOT a per-violation "expires 90 days after its own
|
||
|
|
// incident date" window — a new violation pushes back roll-off for everything.
|
||
|
|
|
||
|
|
const ROLLOFF_POINTS = 5;
|
||
|
|
const ROLLOFF_DAYS = 90;
|
||
|
|
const MS_PER_DAY = 86400000;
|
||
|
|
|
||
|
|
// All dates are treated as UTC calendar days to match SQLite's DATE('now').
|
||
|
|
function dayValue(dateStr) {
|
||
|
|
const [y, m, d] = String(dateStr).slice(0, 10).split('-').map(Number);
|
||
|
|
return Date.UTC(y, m - 1, d);
|
||
|
|
}
|
||
|
|
function daysBetween(fromStr, toStr) {
|
||
|
|
return Math.round((dayValue(toStr) - dayValue(fromStr)) / MS_PER_DAY);
|
||
|
|
}
|
||
|
|
function addDays(dateStr, n) {
|
||
|
|
return new Date(dayValue(dateStr) + n * MS_PER_DAY).toISOString().slice(0, 10);
|
||
|
|
}
|
||
|
|
function todayStr() {
|
||
|
|
return new Date().toISOString().slice(0, 10);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Compute an employee's CPAS standing as of `asOf` (default today).
|
||
|
|
//
|
||
|
|
// `violations` is an array of rows with at least { id, points, incident_date,
|
||
|
|
// negated }. Negated rows and rows dated after `asOf` are ignored.
|
||
|
|
//
|
||
|
|
// Returns:
|
||
|
|
// activePoints — points still counting against the employee
|
||
|
|
// totalPoints — sum of all (non-negated, on-or-before asOf) points
|
||
|
|
// rolledOffPoints — points already retired by completed clean cycles
|
||
|
|
// cycles — completed 90-day clean cycles since the last violation
|
||
|
|
// violationCount — non-negated violations still retaining points
|
||
|
|
// lastViolationDate — most recent non-negated incident date (anchors the clock)
|
||
|
|
// nextRolloffDate — date the next 5 points retire (null if nothing left)
|
||
|
|
// perViolation — oldest-first breakdown: each row + { rolledOff, activePoints }
|
||
|
|
// schedule — future roll-off events: { date, points_off, points_after, days_remaining }
|
||
|
|
function computeStanding(violations, asOf = todayStr()) {
|
||
|
|
const asOfVal = dayValue(asOf);
|
||
|
|
const active = violations.filter(
|
||
|
|
v => !v.negated && dayValue(v.incident_date) <= asOfVal
|
||
|
|
);
|
||
|
|
|
||
|
|
if (active.length === 0) {
|
||
|
|
return {
|
||
|
|
activePoints: 0, totalPoints: 0, rolledOffPoints: 0, cycles: 0,
|
||
|
|
violationCount: 0, lastViolationDate: null, nextRolloffDate: null,
|
||
|
|
perViolation: [], schedule: [],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const sorted = [...active].sort((a, b) => {
|
||
|
|
const d = dayValue(a.incident_date) - dayValue(b.incident_date);
|
||
|
|
return d !== 0 ? d : (a.id - b.id);
|
||
|
|
});
|
||
|
|
|
||
|
|
const totalPoints = sorted.reduce((s, v) => s + v.points, 0);
|
||
|
|
const lastViolationDate = String(sorted[sorted.length - 1].incident_date).slice(0, 10);
|
||
|
|
const daysSince = daysBetween(lastViolationDate, asOf);
|
||
|
|
const cycles = daysSince > 0 ? Math.floor(daysSince / ROLLOFF_DAYS) : 0;
|
||
|
|
const rolledOffPoints = Math.min(totalPoints, cycles * ROLLOFF_POINTS);
|
||
|
|
const activePoints = totalPoints - rolledOffPoints;
|
||
|
|
|
||
|
|
// Retire points oldest-first.
|
||
|
|
let toRoll = rolledOffPoints;
|
||
|
|
const perViolation = sorted.map(v => {
|
||
|
|
const off = Math.min(v.points, toRoll);
|
||
|
|
toRoll -= off;
|
||
|
|
return { ...v, rolledOff: off, activePoints: v.points - off };
|
||
|
|
});
|
||
|
|
const violationCount = perViolation.filter(v => v.activePoints > 0).length;
|
||
|
|
|
||
|
|
// Project remaining roll-offs forward. Cycle N retires at lastViolation + N*90.
|
||
|
|
const schedule = [];
|
||
|
|
let remaining = activePoints;
|
||
|
|
let c = cycles;
|
||
|
|
while (remaining > 0) {
|
||
|
|
c += 1;
|
||
|
|
const date = addDays(lastViolationDate, c * ROLLOFF_DAYS);
|
||
|
|
const off = Math.min(ROLLOFF_POINTS, remaining);
|
||
|
|
remaining -= off;
|
||
|
|
schedule.push({
|
||
|
|
date,
|
||
|
|
points_off: off,
|
||
|
|
points_after: remaining,
|
||
|
|
days_remaining: daysBetween(asOf, date),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
activePoints, totalPoints, rolledOffPoints, cycles, violationCount,
|
||
|
|
lastViolationDate,
|
||
|
|
nextRolloffDate: schedule.length ? schedule[0].date : null,
|
||
|
|
perViolation, schedule,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
ROLLOFF_POINTS, ROLLOFF_DAYS,
|
||
|
|
computeStanding, daysBetween, addDays, todayStr,
|
||
|
|
};
|