diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4448ccf..7215d34 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -14,7 +14,9 @@ "Bash(npm install *)", "Bash(node -e \"require\\('./auth.js'\\); console.log\\('auth.js OK'\\)\")", "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)" ] } } diff --git a/client/src/components/ExpirationTimeline.jsx b/client/src/components/ExpirationTimeline.jsx index ebe7147..71984c8 100644 --- a/client/src/components/ExpirationTimeline.jsx +++ b/client/src/components/ExpirationTimeline.jsx @@ -62,73 +62,89 @@ const s = { }; export default function ExpirationTimeline({ employeeId, currentPoints }) { - const [items, setItems] = useState([]); + const [data, setData] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { setLoading(true); axios.get(`/api/employees/${employeeId}/expiration`) - .then(r => setItems(r.data)) + .then(r => setData(r.data)) .finally(() => setLoading(false)); }, [employeeId]); if (loading) return (
-
Point Expiration Timeline
+
Point Roll-Off Timeline
Loading…
); - if (items.length === 0) return ( + const schedule = data?.schedule || []; + + if (schedule.length === 0) return (
-
Point Expiration Timeline
-
No active violations — nothing to expire.
+
Point Roll-Off Timeline
+
No active points — nothing to roll off.
); - // Build running totals: after each violation expires, what's the remaining score? - let running = currentPoints || 0; - const projected = items.map(item => { - const before = running; - running = Math.max(0, running - item.points); + // Points roll off 5 at a time (oldest first) after each 90 consecutive clean + // days. Walk the schedule to project the score and tier after each event. + let running = data.active_points ?? currentPoints ?? 0; + const projected = schedule.map(ev => { + const before = running; + running = ev.points_after; const tierBefore = getTier(before); const tierAfter = getTier(running); - const dropped = tierAfter.min < tierBefore.min; - return { ...item, pointsBefore: before, pointsAfter: running, tierBefore, tierAfter, tierDropped: dropped }; + return { + ...ev, + pointsBefore: before, + tierBefore, + tierAfter, + tierDropped: tierAfter.min < tierBefore.min, + }; }); return (
-
Point Expiration Timeline
+
Point Roll-Off Timeline
- {projected.map((item) => { - const color = urgencyColor(item.days_remaining); - const pct = (item.days_remaining / 90) * 100; +
+ 5 points roll off after every 90 consecutive days with no new violation. + Any new violation resets the countdown. + {data.last_violation_date && ( + <> Clean since {data.last_violation_date}. + )} +
+ + {projected.map((ev, i) => { + const color = urgencyColor(ev.days_remaining); + const pct = (ev.days_remaining / 90) * 100; return ( -
- {/* Violation name */} -
{item.violation_name}
+
+ {/* Roll-off event label */} +
Roll-off #{i + 1}
- {/* Points badge */} -
−{item.points}
+ {/* Points retired */} +
−{ev.points_off}
- {/* Progress bar: how much of the 90 days has elapsed */} + {/* Progress bar: how much of the 90-day cycle has elapsed */}
{/* Days remaining pill */}
- {item.days_remaining <= 0 ? 'Expiring today' : `${item.days_remaining}d`} + {ev.days_remaining <= 0 ? 'Today' : `${ev.days_remaining}d`}
- {/* Expiry date */} -
{item.expires_on}
+ {/* Roll-off date */} +
{ev.date}
{/* Tier drop indicator */} - {item.tierDropped && ( + {ev.tierDropped && (
- ↓ {item.tierAfter.label} + ↓ {ev.tierAfter.label}
)}
@@ -138,16 +154,16 @@ export default function ExpirationTimeline({ employeeId, currentPoints }) { {/* Projection summary */}
- Projected score after each expiration + Projected score after each roll-off
- {projected.map((item, i) => ( -
- {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);