Tool / 001

Fatigue Analyser

Upload your TRACS CSV export for an estimated analysis, or add a harvester JSON for enhanced accuracy using your actual job detail. Everything is processed locally — your data never leaves your browser.

Drop files here

CSV required — harvester JSON optional

Error:

Standard — CSV only

In TRACS Enterprise, open your Work Plan, set your date range, then click Export to CSV at the bottom of the page. Upload that file above.

★ Enhanced — CSV + Harvester JSON (recommended)

On your TRACS work plan page, open the browser console (F12 on Windows  /  Cmd+Option+J on Mac) and paste this line:

var s=document.createElement('script');s.src='https://headcode.uk/harvester.js?bust='+Date.now();document.body.appendChild(s);

The script fetches your job details, then automatically downloads both the harvester JSON and your CSV. Drop both files above together. Learn more →

Fatigue Analyser

Driver Analysis

CUSTOM RANGE
Fatigue Index per Shift
Low <25
Moderate 25–34
High 35–44
Very High 45+
Rest day working (dot)
Forced extension (dot)
Predicted
Rest Gaps at a Glance
<12h
12–14h
14–18h
18h+
first shift
Shift Detail
Showing detail for selected period E = enhanced   ~ = estimated
DateTurnOnOffHrs RestCumCircJob FI / RISrc
Key Fatigue Drivers
// Run all checks const checks = []; // 1. Short rest gaps < 12h const shortRest = W.filter(s => s.rest !== null && s.rest !== undefined && s.rest < 12); checks.push({ id: 'rest12', label: 'Rest < 12h', rule: 'Minimum 12h rest between duties (c2c agreement)', count: shortRest.length, items: shortRest, severity: shortRest.length ? 'breach' : 'pass', detail: s => { const hrs = s.rest.toFixed(1); return '' + hrs + 'h rest before ' + (s.effOn||'?') + ' start — ' + (12 - s.rest).toFixed(1) + 'h short of minimum'; } }); // 2. Shift > 10h (T&C max for full timers, 1.2) const longShifts = W.filter(s => s.effHrs && parseFloat(s.effHrs) > 10); checks.push({ id: 'shiftlen', label: 'Shift > 10h', rule: 'Maximum turn length for full time drivers is 10h (c2c 1.2)', count: longShifts.length, items: longShifts, severity: longShifts.length ? 'breach' : 'pass', detail: s => '' + parseFloat(s.effHrs).toFixed(1) + 'h shift — ' + (parseFloat(s.effHrs)-10).toFixed(1) + 'h over the 10h maximum' }); // 3. Early start length caps (c2c 1.10) const earlyLong = W.filter(s => { if (!s.effOn || !s.effHrs) return false; const onMins = tm(s.effOn); const hrs = parseFloat(s.effHrs); if (onMins === null) return false; if (onMins < 300 && hrs > 8) return true; if (onMins >= 300 && onMins < 360 && hrs > 9) return true; return false; }); checks.push({ id: 'earlylong', label: 'Early start length cap', rule: 'Booking on before 05:00: max 8h. Between 05:00-05:59: max 9h (c2c 1.10)', count: earlyLong.length, items: earlyLong, severity: earlyLong.length ? 'breach' : 'pass', detail: s => { const onMins = tm(s.effOn); const cap = onMins < 300 ? 8 : 9; return '' + parseFloat(s.effHrs).toFixed(1) + 'h shift booking on at ' + s.effOn + ' — ' + (parseFloat(s.effHrs)-cap).toFixed(1) + 'h over the ' + cap + 'h cap for this start time'; } }); // 4. Consecutive working days > 7 const consec = []; let run = 0; W.forEach((s, i) => { if (i === 0) { run = 1; return; } const gap = (new Date(s.date) - new Date(W[i-1].date)) / 86400000; if (gap <= 1.5) { run++; if (run > 7) consec.push(s); } else run = 1; }); checks.push({ id: 'consec', label: 'Consecutive days > 7', rule: 'Maximum 7 consecutive working days (c2c agreement)', count: consec.length, items: consec, severity: consec.length ? 'breach' : 'pass', detail: s => '' + fmtDateShort(s.date) + ' is day 8+ of a consecutive working run' }); // 5. Rolling 7-day hours > 72h (legal requirement, Working Time Regulations) const weeklyOver = []; W.forEach((s, i) => { const curr = new Date(s.date); let hrs7 = parseFloat(s.effHrs) || 0; for (let j = i-1; j >= 0; j--) { if ((curr - new Date(W[j].date)) / 86400000 > 7) break; hrs7 += parseFloat(W[j].effHrs) || 0; } if (hrs7 > 72) weeklyOver.push({...s, _weekHrs: hrs7}); }); checks.push({ id: 'weekly72', label: 'Weekly hours > 72h', rule: 'Maximum 72h in any rolling 7-day period (Working Time Regulations)', count: weeklyOver.length, items: weeklyOver, severity: weeklyOver.length ? 'breach' : 'pass', detail: s => '' + (s._weekHrs||0).toFixed(1) + 'h in the 7 days ending ' + fmtDateShort(s.date) + ' — ' + ((s._weekHrs||0)-72).toFixed(1) + 'h over limit' }); // 6. Minimum 2 rest days per week (c2c 6.6) const insufficientRestWeeks = []; const allDates = allData.map(s => s.date).sort(); if (allDates.length) { const firstDate = new Date(allDates[0]); // Align to Monday of first week const dayOfWeek = firstDate.getDay(); const monday = new Date(firstDate); monday.setDate(monday.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1)); let weekStart = new Date(monday); const lastDate = new Date(allDates[allDates.length-1]); while (weekStart <= lastDate) { const weekEnd = new Date(weekStart); weekEnd.setDate(weekEnd.getDate() + 6); const weekDates = []; for (let d = new Date(weekStart); d <= weekEnd; d.setDate(d.getDate()+1)) { weekDates.push(d.toISOString().slice(0,10)); } const workingDays = weekDates.filter(d => { const shift = allData.find(s => s.date === d); return shift && shift.fri !== null; }); const restCount = 7 - workingDays.length; if (restCount < 2 && workingDays.length > 0) { // Find the last worked shift of that week to attach the breach to const lastWorked = allData.filter(s => weekDates.includes(s.date) && s.fri !== null) .sort((a,b) => b.date.localeCompare(a.date))[0]; if (lastWorked) insufficientRestWeeks.push({...lastWorked, _weekStart: weekStart.toISOString().slice(0,10), _restCount: restCount}); } weekStart.setDate(weekStart.getDate() + 7); } } checks.push({ id: 'restdays', label: 'Rest days < 2 per week', rule: 'Minimum 2 rest days per week, Sunday to Saturday inclusive (c2c 6.6)', count: insufficientRestWeeks.length, items: insufficientRestWeeks, severity: insufficientRestWeeks.length ? 'breach' : 'pass', detail: s => 'Week of ' + fmtDateShort(s._weekStart) + ': only ' + s._restCount + ' rest day' + (s._restCount !== 1 ? 's' : '') + ' — minimum is 2' }); // 7. 10-week average hours > 35h (c2c 6.1) // Split into 10-week (70-day) blocks from first shift date const blockBreaches = []; if (allDates.length) { const firstDay = new Date(allDates[0]); let blockStart = new Date(firstDay); while (blockStart <= new Date(allDates[allDates.length-1])) { const blockEnd = new Date(blockStart); blockEnd.setDate(blockEnd.getDate() + 70); const blockShifts = W.filter(s => s.date >= blockStart.toISOString().slice(0,10) && s.date < blockEnd.toISOString().slice(0,10)); if (blockShifts.length >= 10) { // only check if enough data const totalHrs = blockShifts.reduce((a, s) => a + (parseFloat(s.effHrs)||0), 0); const avgWeekly = totalHrs / 10; if (avgWeekly > 35) { const lastShift = blockShifts[blockShifts.length-1]; blockBreaches.push({...lastShift, _blockStart: blockStart.toISOString().slice(0,10), _avgHrs: avgWeekly, _totalHrs: totalHrs}); } } blockStart = blockEnd; } } checks.push({ id: 'avg10wk', label: '10-week avg > 35h/week', rule: 'Average turn length must not exceed 35h/week over a 10-week block (c2c 6.1)', count: blockBreaches.length, items: blockBreaches, severity: blockBreaches.length ? 'breach' : 'pass', detail: s => '10-week block from ' + fmtDateShort(s._blockStart) + ': average ' + (s._avgHrs||0).toFixed(1) + 'h/week (' + (s._totalHrs||0).toFixed(0) + 'h total) — ' + ((s._avgHrs||0)-35).toFixed(1) + 'h/week over the 35h average' }); // 8. Continuous driving > 4h30m (enhanced only) const contBreaches = W.filter(s => s.jobDetail && s.fri && s.fri.exceedsContinuous); checks.push({ id: 'continuous', label: 'Continuous driving > 4h30m', rule: 'Maximum continuous driving without a break (c2c / RSSB)', count: contBreaches.length, items: contBreaches, severity: contBreaches.length ? 'breach' : (dataMode === 'enhanced' ? 'pass' : 'na'), naLabel: 'CSV only', detail: s => '' + (s.jobDetail.longestContinuousMins||'?') + 'min continuous driving — ' + ((s.jobDetail.longestContinuousMins||0)-270) + 'min over the 270min (4h30m) limit' }); // 9. AR turn time shift > 2h or extension > 2h (enhanced: uses baseOn/baseHrs from CSV) const arBreaches = W.filter(s => { if (!s.baseTurn || !(s.baseTurn.startsWith('A/R') || s.baseTurn.startsWith('AR'))) return false; if (!s.baseOn || !s.bkOn) return false; const baseOnMins = tm(s.baseOn); const bkOnMins = tm(s.bkOn); if (baseOnMins === null || bkOnMins === null) return false; let shift = bkOnMins - baseOnMins; if (shift > 720) shift -= 1440; if (shift < -720) shift += 1440; if (Math.abs(shift) > 120) return true; // moved more than 2h // Check extension if (s.baseHrs && s.bkHrs) { const baseH = typeof s.baseHrs === 'number' ? s.baseHrs : parseFloat(s.baseHrs); const bkH = typeof s.bkHrs === 'number' ? s.bkHrs : parseFloat(s.bkHrs); if (!isNaN(baseH) && !isNaN(bkH) && bkH - baseH > 2) return true; } return false; }); checks.push({ id: 'arshift', label: 'A/R turn breach', rule: 'AR turns may only be moved 2h from base start and extended by max 2h (c2c 6.11.3)', count: arBreaches.length, items: arBreaches, severity: arBreaches.length ? 'breach' : 'pass', detail: s => { const baseOnMins = tm(s.baseOn); const bkOnMins = tm(s.bkOn); let shiftMins = bkOnMins - baseOnMins; if (shiftMins > 720) shiftMins -= 1440; if (shiftMins < -720) shiftMins += 1440; const parts = []; if (Math.abs(shiftMins) > 120) parts.push('start moved by ' + Math.abs(shiftMins) + 'min (max 120min)'); if (s.baseHrs && s.bkHrs) { const ext = parseFloat(s.bkHrs) - parseFloat(s.baseHrs); if (ext > 2) parts.push('extended by ' + ext.toFixed(1) + 'h (max 2h)'); } return 'A/R turn on ' + fmtDateShort(s.date) + ': ' + parts.join(' and '); } }); // 10. PNB below entitlement (enhanced only, driving turns only) const pnbBreaches = W.filter(s => { if (!s.jobDetail || s.jobDetail.pnbDerived) return false; const tt = s.jobDetail.turnType || ''; if (tt === 'cover' || tt === 'stud' || tt === 'ar' || tt === 'training') return false; if (s.status === 'training' || s.status === 'training-off') return false; const hrs = parseFloat(s.effHrs) || 0; const pnb = s.jobDetail.pnbMins || 0; if (hrs >= 8 && pnb < 45) return true; if (hrs >= 6 && pnb < 30) return true; if (hrs >= 4 && pnb < 10) return true; return false; }); checks.push({ id: 'pnb', label: 'PNB below entitlement', rule: 'Paid Natural Break: 10m for <6h, 30m for 6-8h, 45m for 8h+ (c2c 2.2-2.4)', count: pnbBreaches.length, items: pnbBreaches, severity: pnbBreaches.length ? 'warning' : (dataMode === 'enhanced' ? 'pass' : 'na'), naLabel: 'CSV only', detail: s => { const hrs = parseFloat(s.effHrs) || 0; const entitled = hrs >= 8 ? 45 : hrs >= 6 ? 30 : 10; return '' + (s.jobDetail.pnbMins||0) + 'min PNB recorded — entitlement for a ' + hrs.toFixed(1) + 'h shift is ' + entitled + 'min'; } }); // 11. PNB timing window (enhanced only - requires pnbTime from harvester v2.5+) // 6-8h shift: PNB must fall between hour 2 and hour 6 (c2c 2.3) // 8-10h shift: PNB must fall between hour 2h30m and hour 7h30m (c2c 2.4) const pnbTimingBreaches = W.filter(s => { if (!s.jobDetail || !s.jobDetail.pnbTime || !s.jobDetail.on) return false; const tt = s.jobDetail.turnType || ''; if (tt === 'cover' || tt === 'stud' || tt === 'ar') return false; const hrs = parseFloat(s.effHrs) || 0; if (hrs < 6) return false; // natural breaks only, no window requirement const onMins = tm(s.jobDetail.on); const pnbMins2 = tm(s.jobDetail.pnbTime); if (onMins === null || pnbMins2 === null) return false; let afterSignon = (pnbMins2 - onMins) / 60; if (afterSignon < 0) afterSignon += 24; if (hrs >= 8 && hrs <= 10) return afterSignon < 2.5 || afterSignon > 7.5; if (hrs >= 6 && hrs < 8) return afterSignon < 2 || afterSignon > 6; return false; }); checks.push({ id: 'pnbtiming', label: 'PNB outside window', rule: '6-8h shifts: PNB between hour 2-6. 8-10h shifts: PNB between hour 2h30m-7h30m (c2c 2.3-2.4)', count: pnbTimingBreaches.length, items: pnbTimingBreaches, severity: pnbTimingBreaches.length ? 'warning' : (dataMode === 'enhanced' ? 'pass' : 'na'), naLabel: 'CSV only', detail: s => { const onMins = tm(s.jobDetail.on); const pnbM = tm(s.jobDetail.pnbTime); let afterSignon = (pnbM - onMins) / 60; if (afterSignon < 0) afterSignon += 24; const hrs = parseFloat(s.effHrs) || 0; const window = hrs >= 8 ? '2h30m-7h30m' : '2h-6h'; return 'PNB at ' + s.jobDetail.pnbTime + ' (' + afterSignon.toFixed(1) + 'h after sign-on) — required window for a ' + hrs.toFixed(1) + 'h shift is ' + window; } }); // 12. First break within 4h30m (enhanced - c2c 2.2/2.4) // Any shift: a break must occur no later than 4h30m after signing on const firstBreakLate = W.filter(s => { if (!s.jobDetail) return false; const onTime = s.jobDetail.on; if (!onTime) return false; const onMins = tm(onTime); if (onMins === null) return false; // Find the earliest break (PNB or natural break) let earliest = null; if (s.jobDetail.pnbTime) { let t = tm(s.jobDetail.pnbTime) - onMins; if (t < 0) t += 1440; earliest = t; } (s.jobDetail.breakTimes || []).forEach(bt => { if (!bt) return; let t = tm(bt) - onMins; if (t < 0) t += 1440; if (earliest === null || t < earliest) earliest = t; }); // Also check via continuous blocks - if longest block > 270, no break within 4h30m if (earliest === null && s.jobDetail.longestContinuousMins > 270) return true; if (earliest !== null && earliest > 270) return true; // break after 4h30m return false; }); checks.push({ id: 'firstbreak', label: 'First break after 4h30m', rule: 'A break must occur no later than 4h30m after signing on (c2c 2.2/2.4)', count: firstBreakLate.length, items: firstBreakLate, severity: firstBreakLate.length ? 'warning' : (dataMode === 'enhanced' ? 'pass' : 'na'), naLabel: 'CSV only', detail: s => { const onMins = tm(s.jobDetail.on || s.effOn); let earliest = null; if (s.jobDetail.pnbTime) { let t = tm(s.jobDetail.pnbTime) - onMins; if (t < 0) t += 1440; earliest = t; } if (earliest !== null) { return 'First break at ' + s.jobDetail.pnbTime + '' + (earliest/60).toFixed(1) + 'h after sign-on (limit: 4h30m)'; } return 'No break detected within 4h30m of sign-on — longest continuous block: ' + (s.jobDetail.longestContinuousMins||'?') + 'min'; } });