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
+52 -36
View File
@@ -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 (
<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>
);
if (items.length === 0) return (
const schedule = data?.schedule || [];
if (schedule.length === 0) return (
<div style={s.wrapper}>
<div style={s.sectionHd}>Point Expiration Timeline</div>
<div style={s.empty}>No active violations nothing to expire.</div>
<div style={s.sectionHd}>Point Roll-Off Timeline</div>
<div style={s.empty}>No active points nothing to roll off.</div>
</div>
);
// 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 (
<div style={s.wrapper}>
<div style={s.sectionHd}>Point Expiration Timeline</div>
<div style={s.sectionHd}>Point Roll-Off Timeline</div>
{projected.map((item) => {
const color = urgencyColor(item.days_remaining);
const pct = (item.days_remaining / 90) * 100;
<div style={{ ...s.empty, fontStyle: 'normal', marginBottom: '10px', color: '#9ca0b8' }}>
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 <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 (
<div key={item.id} style={s.row}>
{/* Violation name */}
<div style={s.name} title={item.violation_name}>{item.violation_name}</div>
<div key={ev.date} style={s.row}>
{/* Roll-off event label */}
<div style={s.name} title={`Roll-off #${i + 1}`}>Roll-off #{i + 1}</div>
{/* Points badge */}
<div style={s.pts}>{item.points}</div>
{/* Points retired */}
<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.barFill(pct, color)} />
</div>
{/* Days remaining pill */}
<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>
{/* Expiry date */}
<div style={s.date}>{item.expires_on}</div>
{/* Roll-off date */}
<div style={s.date}>{ev.date}</div>
{/* Tier drop indicator */}
{item.tierDropped && (
{ev.tierDropped && (
<div style={{ fontSize: '10px', color: '#69f0ae', whiteSpace: 'nowrap' }}>
{item.tierAfter.label}
{ev.tierAfter.label}
</div>
)}
</div>
@@ -138,16 +154,16 @@ export default function ExpirationTimeline({ employeeId, currentPoints }) {
{/* Projection summary */}
<div style={s.projBox}>
<div style={{ fontWeight: 700, color: '#f8f9fa', marginBottom: '8px', fontSize: '12px' }}>
Projected score after each expiration
Projected score after each roll-off
</div>
{projected.map((item, i) => (
<div key={item.id} style={s.projRow}>
<span style={{ color: '#9ca0b8' }}>{item.expires_on} {item.violation_name}</span>
{projected.map((ev) => (
<div key={ev.date} style={s.projRow}>
<span style={{ color: '#9ca0b8' }}>{ev.date} {ev.points_off} pts</span>
<span>
<span style={{ color: '#f8f9fa', fontWeight: 700 }}>{item.pointsAfter} pts</span>
{item.tierDropped && (
<span style={{ marginLeft: '8px', color: item.tierAfter.color, fontWeight: 700 }}>
{item.tierAfter.label}
<span style={{ color: '#f8f9fa', fontWeight: 700 }}>{ev.points_after} pts</span>
{ev.tierDropped && (
<span style={{ marginLeft: '8px', color: ev.tierAfter.color, fontWeight: 700 }}>
{ev.tierAfter.label}
</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>
<CpasBadge points={intel.score.active_points} />
<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>
</div>
)}