- Projected score after each expiration
+ Projected score after each roll-off
-
{item.expires_on} — {item.violation_name}
+ {projected.map((ev) => (
+
+ {ev.date} — −{ev.points_off} pts
- {item.pointsAfter} pts
- {item.tierDropped && (
-
- → {item.tierAfter.label}
+ {ev.points_after} pts
+ {ev.tierDropped && (
+
+ → {ev.tierAfter.label}
)}
diff --git a/client/src/components/ViolationForm.jsx b/client/src/components/ViolationForm.jsx
index c7edf45..59e33ad 100755
--- a/client/src/components/ViolationForm.jsx
+++ b/client/src/components/ViolationForm.jsx
@@ -215,7 +215,7 @@ export default function ViolationForm() {
Current Standing:
- {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' : ''}
)}
diff --git a/db/database.js b/db/database.js
index 48868ea..18cd32a 100755
--- a/db/database.js
+++ b/db/database.js
@@ -98,17 +98,11 @@ db.exec(`CREATE TABLE IF NOT EXISTS sessions (
expires_at DATETIME NOT NULL
)`);
-// Recreate view so it always filters negated rows
-db.exec(`DROP VIEW IF EXISTS active_cpas_scores;
-CREATE VIEW active_cpas_scores AS
-SELECT
- 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;`);
+// The old `active_cpas_scores` view implemented a naive per-violation 90-day
+// window. CPAS standing now follows the clean-cycle roll-off model (see
+// lib/rolloff.js), which is order-dependent and computed in JS, so the view is
+// retired here to keep a single source of truth.
+db.exec('DROP VIEW IF EXISTS active_cpas_scores;');
console.log('[DB] Connected:', dbPath);
module.exports = db;
diff --git a/db/schema.sql b/db/schema.sql
index 5ee5c9a..1f6888a 100755
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -38,13 +38,6 @@ CREATE TABLE IF NOT EXISTS violation_resolutions (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
--- Active score: only non-negated violations in rolling 90 days
-CREATE VIEW IF NOT EXISTS active_cpas_scores AS
-SELECT
- 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;
+-- CPAS standing (active points + roll-off schedule) is computed in JS from the
+-- clean-cycle model in lib/rolloff.js; see db/database.js for why the old
+-- active_cpas_scores view was retired.
diff --git a/lib/rolloff.js b/lib/rolloff.js
new file mode 100644
index 0000000..79bd1ac
--- /dev/null
+++ b/lib/rolloff.js
@@ -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,
+};
diff --git a/server.js b/server.js
index 277a2fb..5722d4c 100755
--- a/server.js
+++ b/server.js
@@ -4,6 +4,7 @@ const path = require('path');
const db = require('./db/database');
const auth = require('./auth');
const generatePdf = require('./pdf/generator');
+const { computeStanding } = require('./lib/rolloff');
const app = express();
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) ───────────────────────
// Falls back to { sha: 'dev' } when running outside a Docker build (local dev).
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) => {
const empId = req.params.id;
- // Active points from the 90-day rolling view
- const active = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?').get(empId);
+ // Active points from the clean-cycle roll-off model
+ const standing = standingFor(empId);
// Total violations (all time) and negated count
const totals = db.prepare(`
@@ -238,50 +253,57 @@ app.get('/api/employees/:id/score', (req, res) => {
res.json({
employee_id: empId,
- active_points: active ? active.active_points : 0,
- violation_count: active ? active.violation_count : 0,
+ active_points: standing.activePoints,
+ violation_count: standing.violationCount,
total_violations: totals ? totals.total_violations : 0,
negated_count: totals ? totals.negated_count : 0,
});
});
-// ── Expiration Timeline ──────────────────────────────────────────────────────
-// GET /api/employees/:id/expiration — active violations sorted by roll-off date
-// Returns each active violation with days_remaining until it exits the 90-day window.
+// ── Roll-off Timeline ────────────────────────────────────────────────────────
+// GET /api/employees/:id/expiration — projected clean-cycle roll-off schedule.
+// 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) => {
- const rows = db.prepare(`
- SELECT
- v.id,
- v.violation_name,
- v.violation_type,
- v.category,
- 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);
+ const standing = standingFor(req.params.id);
+ res.json({
+ active_points: standing.activePoints,
+ last_violation_date: standing.lastViolationDate,
+ next_rolloff_date: standing.nextRolloffDate,
+ schedule: standing.schedule,
+ });
});
// Dashboard
app.get('/api/dashboard', (req, res) => {
- const rows = db.prepare(`
- SELECT e.id, e.name, e.department, e.supervisor,
- COALESCE(s.active_points, 0) AS active_points,
- COALESCE(s.violation_count,0) AS violation_count
- FROM employees e
- LEFT JOIN active_cpas_scores s ON s.employee_id = e.id
- ORDER BY active_points DESC, e.name ASC
- `).all();
+ const employees = db.prepare(
+ 'SELECT id, name, department, supervisor FROM employees'
+ ).all();
+
+ // Pull every non-negated violation once, bucket by employee, then run the
+ // roll-off model per employee rather than issuing a query each.
+ const buckets = new Map();
+ 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);
});
@@ -309,33 +331,32 @@ app.get('/api/violations/:id/amendments', (req, res) => {
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) {
- const row = db.prepare(
- `SELECT COALESCE(SUM(points),0) AS pts
+ const earlier = db.prepare(
+ `SELECT id, points, incident_date, negated
FROM violations
- WHERE employee_id = ?
- AND negated = 0
- AND incident_date >= DATE(?, '-90 days')
- AND incident_date < ?`
- ).get(employeeId, incidentDate, incidentDate);
- return row ? row.pts : 0;
+ WHERE employee_id = ? AND negated = 0 AND incident_date < ?`
+ ).all(employeeId, incidentDate);
+ return computeStanding(earlier, incidentDate).activePoints;
}
-// Helper: after a back-dated insert, refresh snapshots on any existing
-// violations whose 90-day prior-window now includes the new (earlier)
-// incident_date. Without this, their PDFs would still show the pre-backdate
-// "Prior Active Points" and miss the inserted earlier violation.
-// Snapshots are still immutable w.r.t. negate/restore — only timeline-
-// rewriting events (back-dated inserts) trigger a refresh.
+// Helper: after a back-dated insert, refresh snapshots on every later
+// violation. Under the clean-cycle roll-off model an earlier incident raises
+// the running total and shifts the roll-off clock for ALL subsequent
+// violations, so their "Prior Active Points" snapshots can all change — not
+// just those within 90 days. Snapshots remain immutable w.r.t. negate/restore;
+// only timeline-rewriting events (back-dated inserts) trigger a refresh.
function recomputeSnapshotsAfter(employeeId, incidentDate) {
const affected = db.prepare(`
SELECT id, incident_date, prior_active_points
FROM violations
WHERE employee_id = ?
AND incident_date > ?
- AND incident_date <= DATE(?, '+90 days')
- `).all(employeeId, incidentDate, incidentDate);
+ `).all(employeeId, incidentDate);
const updateStmt = db.prepare('UPDATE violations SET prior_active_points = ? WHERE id = ?');
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' });
- const active = db.prepare('SELECT * FROM active_cpas_scores WHERE employee_id = ?')
- .get(violation.employee_id) || { active_points: 0, violation_count: 0 };
+ const standing = standingFor(violation.employee_id);
const scoreForPdf = {
employee_id: violation.employee_id,
- active_points: violation.prior_active_points != null ? violation.prior_active_points : active.active_points,
- violation_count: active.violation_count,
+ active_points: violation.prior_active_points != null ? violation.prior_active_points : standing.activePoints,
+ violation_count: standing.violationCount,
};
const pdfBuffer = await generatePdf(violation, scoreForPdf);