impl/typescript.ts
7,888 bytes · the TypeScript implementation · view raw
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 { type DriverActivity, type HoursBreach } from "./fleet_drivers_hours.ts"; ← from fleet.drivers-hours ^1.0.0 · built alongside by fune
import { REST_LIMITS, REST_LIMITS_HISTORY } from "./fleet_tachograph_rest_data.ts"; ← this capability’s own data, compiled from data/rest-limits.json into the same file by fune build
import { type RestCheck } from "./fleet_tachograph_rest_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"];
function limitOn(rule: string, onDate: string): number {
let best: { value: number; validFrom: string } | null = null;
let earliest: string | null = null;
for (const row of REST_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 (REST_LIMITS_HISTORY !== "full" && earliest !== null && onDate < earliest) {
throw new RangeError(
`no rest limit "${rule}" on ${onDate}: this build was installed with history=${REST_LIMITS_HISTORY}, ` +
`so it only carries limits from ${earliest}. Reinstall with history=full for older records.`
);
}
throw new RangeError(`no rest limit "${rule}" on ${onDate}`);
}
interface Pending {
rule: string;
atMinute: number;
actual: number;
limit: number;
}
interface Rest {
start: number;
end: number;
}
/**
* Check daily and weekly rest against Regulation (EC) 561/2006, Article 8,
* as it applies in Great Britain.
*
* The records must start straight after a weekly rest, so the first 24-hour
* and six-day windows open at startDate/startTime. A daily rest is judged on
* the part of it inside the 24-hour window (Art. 8(2)): 11 hours there is
* regular, 9 to 11 is reduced, and 3 hours then 9 is a split regular rest.
*/
export function checkTachographRest(startDate: string, startTime: string, activities: readonly DriverActivity[]): RestCheck {
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 window = limitOn("daily-rest-window", startDate);
const regularDaily = limitOn("regular-daily-rest", startDate);
const reducedDaily = limitOn("reduced-daily-rest", startDate);
const splitFirst = limitOn("split-rest-first", startDate);
const splitSecond = limitOn("split-rest-second", startDate);
const reducedAllowed = limitOn("reduced-daily-rests-allowed", startDate);
const regularWeekly = limitOn("regular-weekly-rest", startDate);
const reducedWeekly = limitOn("reduced-weekly-rest", startDate);
const weeklyWindow = limitOn("weekly-rest-window", startDate);
// Rests back to back are one uninterrupted rest.
const rests: Rest[] = [];
let total = 0;
let lastKind: string | null = null;
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}`);
}
if (activity.kind === "rest") {
if (lastKind === "rest") rests[rests.length - 1].end += activity.minutes;
else rests.push({ start: total, end: total + activity.minutes });
}
lastKind = activity.kind;
total += activity.minutes;
}
const breaches: Pending[] = [];
const events: { start: number; end: number; weekly: boolean; reduced: boolean }[] = [];
// Daily rest: one within each 24 hours after the previous rest ended.
let windowStart = 0;
for (;;) {
const windowEnd = windowStart + window;
let found: { rest: Rest; regular: boolean } | null = null;
let threeHoursTaken = false;
let longest = 0;
for (const rest of rests) {
if (rest.start < windowStart || rest.start >= windowEnd) continue;
const portion = Math.min(rest.end, windowEnd) - rest.start;
if (portion > longest) longest = portion;
if (portion >= reducedDaily) {
found = { rest, regular: portion >= regularDaily || (threeHoursTaken && portion >= splitSecond) };
break;
}
if (portion >= splitFirst) threeHoursTaken = true;
}
if (found !== null) {
if (found.rest.end - found.rest.start < reducedWeekly) {
events.push({ start: found.rest.start, end: found.rest.end, weekly: false, reduced: !found.regular });
}
windowStart = found.rest.end;
continue;
}
if (windowEnd > total) break; // the records stop before the window closes
breaches.push({ rule: "daily-rest", atMinute: windowEnd, actual: longest, limit: reducedDaily });
// Pick the count up again after the next rest long enough to be one.
const next = rests.find((r) => r.start >= windowStart && r.end - r.start >= reducedDaily);
if (next === undefined) break;
windowStart = next.end;
}
for (const rest of rests) {
const length = rest.end - rest.start;
if (length >= reducedWeekly) events.push({ start: rest.start, end: rest.end, weekly: true, reduced: length < regularWeekly });
}
events.sort((a, b) => a.start - b.start);
let dailyRests = 0;
let reducedDailyRests = 0;
let weeklyRests = 0;
let reducedWeeklyRests = 0;
let previousWeeklyEnd = 0;
let previousReduced = false;
let reducedSinceWeekly = 0;
for (const event of events) {
if (!event.weekly) {
dailyRests += 1;
if (event.reduced) {
reducedDailyRests += 1;
reducedSinceWeekly += 1;
if (reducedSinceWeekly > reducedAllowed) {
breaches.push({ rule: "reduced-daily-rest", atMinute: event.start, actual: reducedSinceWeekly, limit: reducedAllowed });
}
}
continue;
}
weeklyRests += 1;
const deadline = previousWeeklyEnd + weeklyWindow;
if (event.start > deadline) {
breaches.push({ rule: "weekly-rest-late", atMinute: deadline, actual: event.start - previousWeeklyEnd, limit: weeklyWindow });
}
if (event.reduced) {
reducedWeeklyRests += 1;
if (previousReduced) {
breaches.push({ rule: "reduced-weekly-rest", atMinute: event.start, actual: event.end - event.start, limit: regularWeekly });
}
}
previousWeeklyEnd = event.end;
previousReduced = event.reduced;
reducedSinceWeekly = 0;
}
// No weekly rest yet, and the records run past the deadline without ending
// in a rest that began in time (and may yet become one).
const deadline = previousWeeklyEnd + weeklyWindow;
const lastRest = rests[rests.length - 1];
const endsInRest = lastRest !== undefined && lastRest.end === total && lastRest.start <= deadline;
if (total > deadline && !endsInRest) {
breaches.push({ rule: "weekly-rest-late", atMinute: deadline, actual: total - previousWeeklyEnd, limit: weeklyWindow });
}
const startMinute = Number(startTime.slice(0, 2)) * 60 + Number(startTime.slice(3, 5));
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, dailyRests, reducedDailyRests, weeklyRests, reducedWeeklyRests, 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}`;
}