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
+3 -1
View File
@@ -14,7 +14,9 @@
"Bash(npm install *)", "Bash(npm install *)",
"Bash(node -e \"require\\('./auth.js'\\); console.log\\('auth.js OK'\\)\")", "Bash(node -e \"require\\('./auth.js'\\); console.log\\('auth.js OK'\\)\")",
"Bash(node --check auth.js)", "Bash(node --check auth.js)",
"Bash(node --check db/database.js)" "Bash(node --check db/database.js)",
"Bash(node -e ' *)",
"Bash(node --check lib/rolloff.js)"
] ]
} }
} }
+52 -36
View File
@@ -62,73 +62,89 @@ const s = {
}; };
export default function ExpirationTimeline({ employeeId, currentPoints }) { export default function ExpirationTimeline({ employeeId, currentPoints }) {
const [items, setItems] = useState([]); const [data, setData] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
axios.get(`/api/employees/${employeeId}/expiration`) axios.get(`/api/employees/${employeeId}/expiration`)
.then(r => setItems(r.data)) .then(r => setData(r.data))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [employeeId]); }, [employeeId]);
if (loading) return ( if (loading) return (
<div style={s.wrapper}> <div style={s.wrapper}>
<div style={s.sectionHd}>Point Expiration Timeline</div> <div style={s.sectionHd}>Point Roll-Off Timeline</div>
<div style={{ ...s.empty }}>Loading</div> <div style={{ ...s.empty }}>Loading</div>
</div> </div>
); );
if (items.length === 0) return ( const schedule = data?.schedule || [];
if (schedule.length === 0) return (
<div style={s.wrapper}> <div style={s.wrapper}>
<div style={s.sectionHd}>Point Expiration Timeline</div> <div style={s.sectionHd}>Point Roll-Off Timeline</div>
<div style={s.empty}>No active violations nothing to expire.</div> <div style={s.empty}>No active points nothing to roll off.</div>
</div> </div>
); );
// Build running totals: after each violation expires, what's the remaining score? // Points roll off 5 at a time (oldest first) after each 90 consecutive clean
let running = currentPoints || 0; // days. Walk the schedule to project the score and tier after each event.
const projected = items.map(item => { let running = data.active_points ?? currentPoints ?? 0;
const before = running; const projected = schedule.map(ev => {
running = Math.max(0, running - item.points); const before = running;
running = ev.points_after;
const tierBefore = getTier(before); const tierBefore = getTier(before);
const tierAfter = getTier(running); const tierAfter = getTier(running);
const dropped = tierAfter.min < tierBefore.min; return {
return { ...item, pointsBefore: before, pointsAfter: running, tierBefore, tierAfter, tierDropped: dropped }; ...ev,
pointsBefore: before,
tierBefore,
tierAfter,
tierDropped: tierAfter.min < tierBefore.min,
};
}); });
return ( return (
<div style={s.wrapper}> <div style={s.wrapper}>
<div style={s.sectionHd}>Point Expiration Timeline</div> <div style={s.sectionHd}>Point Roll-Off Timeline</div>
{projected.map((item) => { <div style={{ ...s.empty, fontStyle: 'normal', marginBottom: '10px', color: '#9ca0b8' }}>
const color = urgencyColor(item.days_remaining); 5 points roll off after every 90 consecutive days with no new violation.
const pct = (item.days_remaining / 90) * 100; Any new violation resets the countdown.
{data.last_violation_date && (
<> Clean since <strong style={{ color: '#f8f9fa' }}>{data.last_violation_date}</strong>.</>
)}
</div>
{projected.map((ev, i) => {
const color = urgencyColor(ev.days_remaining);
const pct = (ev.days_remaining / 90) * 100;
return ( return (
<div key={item.id} style={s.row}> <div key={ev.date} style={s.row}>
{/* Violation name */} {/* Roll-off event label */}
<div style={s.name} title={item.violation_name}>{item.violation_name}</div> <div style={s.name} title={`Roll-off #${i + 1}`}>Roll-off #{i + 1}</div>
{/* Points badge */} {/* Points retired */}
<div style={s.pts}>{item.points}</div> <div style={s.pts}>{ev.points_off}</div>
{/* Progress bar: how much of the 90 days has elapsed */} {/* Progress bar: how much of the 90-day cycle has elapsed */}
<div style={s.bar(pct, color)}> <div style={s.bar(pct, color)}>
<div style={s.barFill(pct, color)} /> <div style={s.barFill(pct, color)} />
</div> </div>
{/* Days remaining pill */} {/* Days remaining pill */}
<div style={s.pill(color)}> <div style={s.pill(color)}>
{item.days_remaining <= 0 ? 'Expiring today' : `${item.days_remaining}d`} {ev.days_remaining <= 0 ? 'Today' : `${ev.days_remaining}d`}
</div> </div>
{/* Expiry date */} {/* Roll-off date */}
<div style={s.date}>{item.expires_on}</div> <div style={s.date}>{ev.date}</div>
{/* Tier drop indicator */} {/* Tier drop indicator */}
{item.tierDropped && ( {ev.tierDropped && (
<div style={{ fontSize: '10px', color: '#69f0ae', whiteSpace: 'nowrap' }}> <div style={{ fontSize: '10px', color: '#69f0ae', whiteSpace: 'nowrap' }}>
{item.tierAfter.label} {ev.tierAfter.label}
</div> </div>
)} )}
</div> </div>
@@ -138,16 +154,16 @@ export default function ExpirationTimeline({ employeeId, currentPoints }) {
{/* Projection summary */} {/* Projection summary */}
<div style={s.projBox}> <div style={s.projBox}>
<div style={{ fontWeight: 700, color: '#f8f9fa', marginBottom: '8px', fontSize: '12px' }}> <div style={{ fontWeight: 700, color: '#f8f9fa', marginBottom: '8px', fontSize: '12px' }}>
Projected score after each expiration Projected score after each roll-off
</div> </div>
{projected.map((item, i) => ( {projected.map((ev) => (
<div key={item.id} style={s.projRow}> <div key={ev.date} style={s.projRow}>
<span style={{ color: '#9ca0b8' }}>{item.expires_on} {item.violation_name}</span> <span style={{ color: '#9ca0b8' }}>{ev.date} {ev.points_off} pts</span>
<span> <span>
<span style={{ color: '#f8f9fa', fontWeight: 700 }}>{item.pointsAfter} pts</span> <span style={{ color: '#f8f9fa', fontWeight: 700 }}>{ev.points_after} pts</span>
{item.tierDropped && ( {ev.tierDropped && (
<span style={{ marginLeft: '8px', color: item.tierAfter.color, fontWeight: 700 }}> <span style={{ marginLeft: '8px', color: ev.tierAfter.color, fontWeight: 700 }}>
{item.tierAfter.label} {ev.tierAfter.label}
</span> </span>
)} )}
</span> </span>
+1 -1
View File
@@ -215,7 +215,7 @@ export default function ViolationForm() {
<span style={{ fontSize: '13px', color: '#d1d3e0', fontWeight: 600 }}>Current Standing:</span> <span style={{ fontSize: '13px', color: '#d1d3e0', fontWeight: 600 }}>Current Standing:</span>
<CpasBadge points={intel.score.active_points} /> <CpasBadge points={intel.score.active_points} />
<span style={{ fontSize: '12px', color: '#9ca0b8' }}> <span style={{ fontSize: '12px', color: '#9ca0b8' }}>
{intel.score.violation_count} violation{intel.score.violation_count !== 1 ? 's' : ''} in last 90 days {intel.score.violation_count} active violation{intel.score.violation_count !== 1 ? 's' : ''}
</span> </span>
</div> </div>
)} )}
+5 -11
View File
@@ -98,17 +98,11 @@ db.exec(`CREATE TABLE IF NOT EXISTS sessions (
expires_at DATETIME NOT NULL expires_at DATETIME NOT NULL
)`); )`);
// Recreate view so it always filters negated rows // The old `active_cpas_scores` view implemented a naive per-violation 90-day
db.exec(`DROP VIEW IF EXISTS active_cpas_scores; // window. CPAS standing now follows the clean-cycle roll-off model (see
CREATE VIEW active_cpas_scores AS // lib/rolloff.js), which is order-dependent and computed in JS, so the view is
SELECT // retired here to keep a single source of truth.
employee_id, db.exec('DROP VIEW IF EXISTS active_cpas_scores;');
SUM(points) AS active_points,
COUNT(*) AS violation_count
FROM violations
WHERE negated = 0
AND incident_date >= DATE('now', '-90 days')
GROUP BY employee_id;`);
console.log('[DB] Connected:', dbPath); console.log('[DB] Connected:', dbPath);
module.exports = db; module.exports = db;
+3 -10
View File
@@ -38,13 +38,6 @@ CREATE TABLE IF NOT EXISTS violation_resolutions (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
-- Active score: only non-negated violations in rolling 90 days -- CPAS standing (active points + roll-off schedule) is computed in JS from the
CREATE VIEW IF NOT EXISTS active_cpas_scores AS -- clean-cycle model in lib/rolloff.js; see db/database.js for why the old
SELECT -- active_cpas_scores view was retired.
employee_id,
SUM(points) AS active_points,
COUNT(*) AS violation_count
FROM violations
WHERE negated = 0
AND incident_date >= DATE('now', '-90 days')
GROUP BY employee_id;
+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,
};
+77 -57
View File
@@ -4,6 +4,7 @@ const path = require('path');
const db = require('./db/database'); const db = require('./db/database');
const auth = require('./auth'); const auth = require('./auth');
const generatePdf = require('./pdf/generator'); const generatePdf = require('./pdf/generator');
const { computeStanding } = require('./lib/rolloff');
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
@@ -33,6 +34,20 @@ function audit(action, entityType, entityId, performedBy, details) {
} }
} }
// ── CPAS standing helpers ────────────────────────────────────────────────────
// All point/roll-off math lives in lib/rolloff.js. These helpers just feed it
// the employee's non-negated violations and return the computed standing.
function loadViolations(employeeId) {
return db.prepare(
`SELECT id, points, incident_date, negated, violation_name, violation_type, category
FROM violations
WHERE employee_id = ? AND negated = 0`
).all(employeeId);
}
function standingFor(employeeId, asOf) {
return computeStanding(loadViolations(employeeId), asOf);
}
// ── Version info (written by Dockerfile at build time) ─────────────────────── // ── Version info (written by Dockerfile at build time) ───────────────────────
// Falls back to { sha: 'dev' } when running outside a Docker build (local dev). // Falls back to { sha: 'dev' } when running outside a Docker build (local dev).
let BUILD_VERSION = { sha: 'dev', shortSha: 'dev', buildTime: null }; let BUILD_VERSION = { sha: 'dev', shortSha: 'dev', buildTime: null };
@@ -224,8 +239,8 @@ app.patch('/api/employees/:id/notes', (req, res) => {
app.get('/api/employees/:id/score', (req, res) => { app.get('/api/employees/:id/score', (req, res) => {
const empId = req.params.id; const empId = req.params.id;
// Active points from the 90-day rolling view // Active points from the clean-cycle roll-off model
const active = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?').get(empId); const standing = standingFor(empId);
// Total violations (all time) and negated count // Total violations (all time) and negated count
const totals = db.prepare(` const totals = db.prepare(`
@@ -238,50 +253,57 @@ app.get('/api/employees/:id/score', (req, res) => {
res.json({ res.json({
employee_id: empId, employee_id: empId,
active_points: active ? active.active_points : 0, active_points: standing.activePoints,
violation_count: active ? active.violation_count : 0, violation_count: standing.violationCount,
total_violations: totals ? totals.total_violations : 0, total_violations: totals ? totals.total_violations : 0,
negated_count: totals ? totals.negated_count : 0, negated_count: totals ? totals.negated_count : 0,
}); });
}); });
// ── Expiration Timeline ────────────────────────────────────────────────────── // ── Roll-off Timeline ────────────────────────────────────────────────────────
// GET /api/employees/:id/expiration — active violations sorted by roll-off date // GET /api/employees/:id/expiration — projected clean-cycle roll-off schedule.
// Returns each active violation with days_remaining until it exits the 90-day window. // Points only retire after 90 consecutive days with no new violation; any new
// violation resets the clock. Returns the active total plus each future 5-point
// roll-off event (oldest points first) until the balance reaches zero.
app.get('/api/employees/:id/expiration', (req, res) => { app.get('/api/employees/:id/expiration', (req, res) => {
const rows = db.prepare(` const standing = standingFor(req.params.id);
SELECT res.json({
v.id, active_points: standing.activePoints,
v.violation_name, last_violation_date: standing.lastViolationDate,
v.violation_type, next_rolloff_date: standing.nextRolloffDate,
v.category, schedule: standing.schedule,
v.points, });
v.incident_date,
DATE(v.incident_date, '+90 days') AS expires_on,
CAST(
JULIANDAY(DATE(v.incident_date, '+90 days')) -
JULIANDAY(DATE('now'))
AS INTEGER
) AS days_remaining
FROM violations v
WHERE v.employee_id = ?
AND v.negated = 0
AND v.incident_date >= DATE('now', '-90 days')
ORDER BY v.incident_date ASC
`).all(req.params.id);
res.json(rows);
}); });
// Dashboard // Dashboard
app.get('/api/dashboard', (req, res) => { app.get('/api/dashboard', (req, res) => {
const rows = db.prepare(` const employees = db.prepare(
SELECT e.id, e.name, e.department, e.supervisor, 'SELECT id, name, department, supervisor FROM employees'
COALESCE(s.active_points, 0) AS active_points, ).all();
COALESCE(s.violation_count,0) AS violation_count
FROM employees e // Pull every non-negated violation once, bucket by employee, then run the
LEFT JOIN active_cpas_scores s ON s.employee_id = e.id // roll-off model per employee rather than issuing a query each.
ORDER BY active_points DESC, e.name ASC const buckets = new Map();
`).all(); for (const v of db.prepare(
`SELECT id, employee_id, points, incident_date, negated
FROM violations WHERE negated = 0`
).all()) {
if (!buckets.has(v.employee_id)) buckets.set(v.employee_id, []);
buckets.get(v.employee_id).push(v);
}
const rows = employees.map(e => {
const standing = computeStanding(buckets.get(e.id) || []);
return {
...e,
active_points: standing.activePoints,
violation_count: standing.violationCount,
};
});
rows.sort((a, b) =>
b.active_points - a.active_points || a.name.localeCompare(b.name)
);
res.json(rows); res.json(rows);
}); });
@@ -309,33 +331,32 @@ app.get('/api/violations/:id/amendments', (req, res) => {
res.json(rows); res.json(rows);
}); });
// Helper: compute prior_active_points at time of insert // Helper: compute prior_active_points at time of insert — the employee's active
// CPAS standing the instant BEFORE this incident, under the clean-cycle model.
// Uses only violations strictly earlier than this incident, evaluated as of the
// incident date.
function getPriorActivePoints(employeeId, incidentDate) { function getPriorActivePoints(employeeId, incidentDate) {
const row = db.prepare( const earlier = db.prepare(
`SELECT COALESCE(SUM(points),0) AS pts `SELECT id, points, incident_date, negated
FROM violations FROM violations
WHERE employee_id = ? WHERE employee_id = ? AND negated = 0 AND incident_date < ?`
AND negated = 0 ).all(employeeId, incidentDate);
AND incident_date >= DATE(?, '-90 days') return computeStanding(earlier, incidentDate).activePoints;
AND incident_date < ?`
).get(employeeId, incidentDate, incidentDate);
return row ? row.pts : 0;
} }
// Helper: after a back-dated insert, refresh snapshots on any existing // Helper: after a back-dated insert, refresh snapshots on every later
// violations whose 90-day prior-window now includes the new (earlier) // violation. Under the clean-cycle roll-off model an earlier incident raises
// incident_date. Without this, their PDFs would still show the pre-backdate // the running total and shifts the roll-off clock for ALL subsequent
// "Prior Active Points" and miss the inserted earlier violation. // violations, so their "Prior Active Points" snapshots can all change — not
// Snapshots are still immutable w.r.t. negate/restore — only timeline- // just those within 90 days. Snapshots remain immutable w.r.t. negate/restore;
// rewriting events (back-dated inserts) trigger a refresh. // only timeline-rewriting events (back-dated inserts) trigger a refresh.
function recomputeSnapshotsAfter(employeeId, incidentDate) { function recomputeSnapshotsAfter(employeeId, incidentDate) {
const affected = db.prepare(` const affected = db.prepare(`
SELECT id, incident_date, prior_active_points SELECT id, incident_date, prior_active_points
FROM violations FROM violations
WHERE employee_id = ? WHERE employee_id = ?
AND incident_date > ? AND incident_date > ?
AND incident_date <= DATE(?, '+90 days') `).all(employeeId, incidentDate);
`).all(employeeId, incidentDate, incidentDate);
const updateStmt = db.prepare('UPDATE violations SET prior_active_points = ? WHERE id = ?'); const updateStmt = db.prepare('UPDATE violations SET prior_active_points = ? WHERE id = ?');
const changes = []; const changes = [];
@@ -732,13 +753,12 @@ app.get('/api/violations/:id/pdf', async (req, res) => {
if (!violation) return res.status(404).json({ error: 'Violation not found' }); if (!violation) return res.status(404).json({ error: 'Violation not found' });
const active = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?') const standing = standingFor(violation.employee_id);
.get(violation.employee_id) || { active_points: 0, violation_count: 0 };
const scoreForPdf = { const scoreForPdf = {
employee_id: violation.employee_id, employee_id: violation.employee_id,
active_points: violation.prior_active_points != null ? violation.prior_active_points : active.active_points, active_points: violation.prior_active_points != null ? violation.prior_active_points : standing.activePoints,
violation_count: active.violation_count, violation_count: standing.violationCount,
}; };
const pdfBuffer = await generatePdf(violation, scoreForPdf); const pdfBuffer = await generatePdf(violation, scoreForPdf);