// ── 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, };