Functional Weave
Code in Rust

fleet.drivers-hours@1.0.2

impl/typescript.ts

8,414 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 { 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}`;
}