90 day rolloff fix
Build and Push Docker Image / build (push) Successful in 16s

This commit is contained in:
2026-05-27 21:41:57 -05:00
parent 08401afd28
commit 6ce9788a6b
7 changed files with 249 additions and 116 deletions
+108
View File
@@ -0,0 +1,108 @@
// ── 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,
};