Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { addDays } from "./dates_add_days.ts"; ← from dates.add-days ^1.0.0 · built alongside by fune
import { dayOfWeek } from "./dates_day_of_week.ts"; ← from dates.day-of-week ^1.0.0 · built alongside by fune
import { DRIVING_LIMITS, DRIVING_LIMITS_HISTORY } from "./fleet_drivers_hours_data.ts"; ← this capability’s own data, compiled from data/limits.json into the same file by fune build
import { type DriverActivity, type DriversHoursCheck, type HoursBreach } from "./fleet_drivers_hours_types.ts";
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const HH_MM = /^([01]\d|2[0-3]):[0-5]\d$/;
const KINDS = ["driving", "work", "availability", "rest"];
const WEEK = 10080;
function limitOn(rule: string, onDate: string): number {
let best: { value: number; validFrom: string } | null = null;
let earliest: string | null = null;
for (const row of DRIVING_LIMITS) {
if (row.rule !== rule) continue;
if (earliest === null || row.validFrom < earliest) earliest = row.validFrom;
if (onDate < row.validFrom) continue;
if (row.validTo !== null && onDate > row.validTo) continue;
if (best === null || row.validFrom > best.validFrom) best = row;
}
if (best !== null) return best.value;
if (DRIVING_LIMITS_HISTORY !== "full" && earliest !== null && onDate < earliest) {
throw new RangeError(
`no drivers' hours limit "${rule}" on ${onDate}: this build was installed with history=${DRIVING_LIMITS_HISTORY}, ` +
`so it only carries limits from ${earliest}. Reinstall with history=full for older records.`
);
}
throw new RangeError(`no drivers' hours limit "${rule}" on ${onDate}`);
}
interface Pending {
rule: string;
atMinute: number;
actual: number;
limit: number;
}
/**
* Check a run of tachograph activities against the GB/EU drivers' hours rules
* on driving time and breaks (Regulation (EC) 561/2006, Articles 6 and 7).
*
* Activities are back to back from startDate/startTime, in local time. A rest
* of 45 minutes, or 15 then 30, ends a driving period; a rest of 9 hours or
* more is a daily rest and ends the daily driving time. Weeks are fixed weeks,
* Monday 00:00 to Sunday 24:00, so driving across Sunday midnight is split.
*/
export function checkDriversHours(startDate: string, startTime: string, activities: readonly DriverActivity[]): DriversHoursCheck {
if (!ISO_DATE.test(startDate)) {
throw new RangeError(`startDate must be an ISO date (YYYY-MM-DD), received "${startDate}"`);
}
if (!HH_MM.test(startTime)) {
throw new RangeError(`startTime must be HH:MM, 24-hour, received "${startTime}"`);
}
const drivingPeriod = limitOn("driving-period", startDate);
const fullBreak = limitOn("break", startDate);
const splitFirst = limitOn("split-break-first", startDate);
const splitSecond = limitOn("split-break-second", startDate);
const daily = limitOn("daily-driving", startDate);
const dailyExtended = limitOn("daily-driving-extended", startDate);
const extensionsAllowed = limitOn("extended-days-per-week", startDate);
const weeklyLimit = limitOn("weekly-driving", startDate);
const fortnightLimit = limitOn("fortnightly-driving", startDate);
const dailyRest = limitOn("daily-rest-minimum", startDate);
// Merge neighbours of the same kind: two rests back to back are one
// uninterrupted rest, whatever the recording split them into.
const segments: { kind: string; minutes: number }[] = [];
for (const activity of activities) {
if (!KINDS.includes(activity.kind)) {
throw new RangeError(`unknown activity kind "${activity.kind}"`);
}
if (!Number.isInteger(activity.minutes) || activity.minutes < 1) {
throw new RangeError(`minutes must be a whole number of at least 1, received ${activity.minutes}`);
}
const last = segments[segments.length - 1];
if (last !== undefined && last.kind === activity.kind) last.minutes += activity.minutes;
else segments.push({ kind: activity.kind, minutes: activity.minutes });
}
const startMinute = Number(startTime.slice(0, 2)) * 60 + Number(startTime.slice(3, 5));
const weekBase = (dayOfWeek(startDate) - 1) * 1440 + startMinute;
const weekOf = (offset: number): number => Math.floor((weekBase + offset) / WEEK);
const breaches: Pending[] = [];
let totalDriving = 0;
// driving period (Art. 7)
let accumulated = 0;
let firstPartTaken = false;
let breakBreach: Pending | null = null;
// daily driving (Art. 6(1))
let dayDriving = 0;
let dayPassedDaily: number | null = null;
let dayWeek: number | null = null;
let dayBreach: Pending | null = null;
const extensions = new Map<number, number>();
// weekly and fortnightly (Art. 6(2), 6(3))
const weekly = new Map<number, number>();
const weeklyBreaches = new Map<number, Pending>();
const fortnightBreaches = new Map<number, Pending>();
const endDrivingPeriod = (): void => {
if (breakBreach !== null) breakBreach.actual = accumulated;
accumulated = 0;
firstPartTaken = false;
breakBreach = null;
};
const closeDay = (): void => {
if (dayDriving > daily && dayWeek !== null) {
const used = (extensions.get(dayWeek) ?? 0) + 1;
extensions.set(dayWeek, used);
if (used > extensionsAllowed && dayDriving <= dailyExtended && dayPassedDaily !== null) {
breaches.push({ rule: "daily-driving", atMinute: dayPassedDaily, actual: dayDriving, limit: daily });
}
}
if (dayBreach !== null) dayBreach.actual = dayDriving;
dayDriving = 0;
dayPassedDaily = null;
dayWeek = null;
dayBreach = null;
};
let offset = 0;
for (const segment of segments) {
if (segment.kind === "driving") {
totalDriving += segment.minutes;
let start = offset;
const end = offset + segment.minutes;
while (start < end) {
const week = weekOf(start);
const chunkEnd = Math.min(end, (week + 1) * WEEK - weekBase);
const m = chunkEnd - start;
if (breakBreach === null && accumulated + m > drivingPeriod) {
breakBreach = { rule: "break", atMinute: start + (drivingPeriod - accumulated), actual: 0, limit: drivingPeriod };
breaches.push(breakBreach);
}
accumulated += m;
if (dayWeek === null) dayWeek = week;
if (dayPassedDaily === null && dayDriving + m > daily) dayPassedDaily = start + (daily - dayDriving);
if (dayBreach === null && dayDriving + m > dailyExtended) {
dayBreach = { rule: "daily-driving", atMinute: start + (dailyExtended - dayDriving), actual: 0, limit: dailyExtended };
breaches.push(dayBreach);
}
dayDriving += m;
const before = weekly.get(week) ?? 0;
if (!weeklyBreaches.has(week) && before + m > weeklyLimit) {
const breach = { rule: "weekly-driving", atMinute: start + (weeklyLimit - before), actual: 0, limit: weeklyLimit };
weeklyBreaches.set(week, breach);
breaches.push(breach);
}
const pairBefore = (weekly.get(week - 1) ?? 0) + before;
if (!fortnightBreaches.has(week) && pairBefore + m > fortnightLimit) {
const breach = { rule: "fortnightly-driving", atMinute: start + (fortnightLimit - pairBefore), actual: 0, limit: fortnightLimit };
fortnightBreaches.set(week, breach);
breaches.push(breach);
}
weekly.set(week, before + m);
start = chunkEnd;
}
} else if (segment.kind === "rest") {
const m = segment.minutes;
if (m >= fullBreak || (firstPartTaken && m >= splitSecond)) endDrivingPeriod();
else if (m >= splitFirst && accumulated > 0) firstPartTaken = true;
if (m >= dailyRest) closeDay();
}
offset += segment.minutes;
}
endDrivingPeriod();
closeDay();
for (const [week, breach] of weeklyBreaches) breach.actual = weekly.get(week) ?? 0;
for (const [week, breach] of fortnightBreaches) breach.actual = (weekly.get(week - 1) ?? 0) + (weekly.get(week) ?? 0);
const ordered = breaches
.map((b, i) => ({ b, i }))
.sort((x, y) => x.b.atMinute - y.b.atMinute || x.i - y.i)
.map(({ b }): HoursBreach => ({ rule: b.rule, at: localTime(startDate, startMinute + b.atMinute), actual: b.actual, limit: b.limit }));
return { compliant: ordered.length === 0, drivingMinutes: totalDriving, breaches: ordered };
}
function localTime(startDate: string, minute: number): string {
const day = Math.floor(minute / 1440);
const inDay = minute - day * 1440;
const hh = String(Math.floor(inDay / 60)).padStart(2, "0");
const mm = String(inDay % 60).padStart(2, "0");
return `${addDays(startDate, day)}T${hh}:${mm}`;
}