Functional Weave
Code in TypeScript

fleet.drivers-hours

Check a driver's activities against GB/EU drivers' hours: breaks after 4.5h, daily, weekly and fortnightly driving.

1.0.1 (not the latest) · published 2026-10-03 by charlie · Anterra

Pinned by 26 tests, run in TypeScript, Python and Rust.

Not professional advice. This capability calculates drivers’ hours figures from published rules. It is a software component for developers, not legal advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed in its README, and have a transport compliance specialist (DVSA rules) review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.

What it does

Status: needs review by a drivers' hours / transport compliance expert before it is published.

Checks a driver's recorded activities against the driving-time and break rules of Regulation (EC) No 561/2006, as it applies in Great Britain (assimilated law, formerly "retained EU law"). It returns every breach it finds, in time order, with the local date and time the limit was passed.

For example

  • checkDriversHours(2026-09-21, 06:00, activities ×4) → compliant true, driving minutes 540, breaches a legal day: 4h30, 45 minute break, 4h30, then daily rest
  • checkDriversHours(2026-09-21, 06:00, activities ×2) → compliant false, driving minutes 300, breaches ×1 five hours without a break passes the 4h30 limit at 10:30
  • checkDriversHours(2026-09-21, 06:00, activities ×5) → compliant true, driving minutes 510, breaches a split break of 15 then 30 minutes is legal

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

export function checkDriversHours(startDate: string, startTime: string, activities: readonly DriverActivity[]): DriversHoursCheck
startDatedatelocal date the first activity starts
startTimestringHH:MM, 24-hour, local time the first activity starts
activitiesDriverActivity[]back to back and in order: each starts when the one before ends
returnsDriversHoursCheck

The types it declares, generated into your project

export type ActivityKind = "driving" | "work" | "availability" | "rest";

/** One period of a driver's time, as a tachograph records it. */
export interface DriverActivity {
  /** rest covers breaks and rest periods alike */
  readonly kind: ActivityKind;
  /** length in whole minutes, at least 1 */
  readonly minutes: number;
}

/** One rule broken, and when. */
export interface HoursBreach {
  /** the rule's name, e.g. break or weekly-driving */
  readonly rule: string;
  /** local date and time the limit was passed, YYYY-MM-DDTHH:MM */
  readonly at: string;
  /** what was recorded, in the rule's unit (minutes, or a count) */
  readonly actual: number;
  /** the limit in the same unit */
  readonly limit: number;
}

/** The breaches found, in time order. */
export interface DriversHoursCheck {
  /** true when there are no breaches */
  readonly compliant: boolean;
  /** all driving in the activities */
  readonly drivingMinutes: number;
  readonly breaches: readonly HoursBreach[];
}

Your code names it in one line, in the file that uses it

import { checkDriversHours } from "#fune/fleet.drivers-hours@^1";
impl/typescript.ts · 185 lines · open · 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}`;
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add fleet.drivers-hours
Download for TypeScript fleet.drivers-hours-1.0.1-typescript.fune · 30,776 bytes sha256 292a2acd0032d703661007917066bab0c518cff64ec09cf9fbe75c60fafae8c6

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./fleet.drivers-hours-1.0.1-typescript.fune, or fetch it from a terminal with fune pull fleet.drivers-hours@1.0.1:typescript.

The whole function, every language, is one file too: fleet.drivers-hours-1.0.1.fune, 52,953 bytes, sha256 382070925b09f173e31ff7c9f0e3b74a49f5ae30a02fe2b70b5db2234858199d. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before fleet.drivers-hours

after — your function gets the result and the arguments, and returns the final result.

// fune: after fleet.drivers-hours

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace dates.add-days in fleet.drivers-hours
// fune: replace dates.day-of-week in fleet.drivers-hours

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show fleet.drivers-hours --steps.

// fune: step fleet.drivers-hours after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
a legal day: 4h30, 45 minute break, 4h30, then daily rest 2026-09-21, 06:00, activities ×4 → compliant true, driving minutes 540, breaches
five hours without a break passes the 4h30 limit at 10:30 2026-09-21, 06:00, activities ×2 → compliant false, driving minutes 300, breaches ×1
a split break of 15 then 30 minutes is legal 2026-09-21, 06:00, activities ×5 → compliant true, driving minutes 510, breaches
30 then 15 is not a split break: the second part must be at least 30 2026-09-21, 06:00, activities ×5 → compliant false, driving minutes 330, breaches ×1
an hour of other work is not a break 2026-09-21, 06:00, activities ×3 → compliant false, driving minutes 300, breaches ×1
a period of availability is not a break 2026-09-21, 06:00, activities ×3 → compliant false, driving minutes 280, breaches ×1
two rests recorded back to back are one 45 minute break 2026-09-21, 06:00, activities ×4 → compliant true, driving minutes 540, breaches
a 15 minute rest before any driving is not the first part of a split break 2026-09-21, 06:00, activities ×4 → compliant false, driving minutes 300, breaches ×1
10h30 of driving in a day passes the 10 hour ceiling at 16:30 2026-09-21, 05:00, activities ×5 → compliant false, driving minutes 630, breaches ×1
two 10 hour days in a week are allowed 2026-09-21, 06:00, activities ×11 → compliant true, driving minutes 1,200, breaches
Show the other 16 tests
CaseArgumentsExpected
a third 10 hour day in the same fixed week breaks the 9 hour limit 2026-09-21, 06:00, activities ×17 → compliant false, driving minutes 1,800, breaches ×1
six 9 hour days and 3 hours on Sunday pass 56 hours at 08:00 on Sunday 2026-09-21, 06:00, activities ×25 → compliant false, driving minutes 3,420, breaches ×1
driving across Sunday midnight is split between the two fixed weeks 2026-09-21, 06:00, activities ×25 → compliant true, driving minutes 3,480, breaches
54 hours then 36 hours is exactly 90; half an hour more passes the fortnightly limit 2026-09-21, 06:00, activities ×41 → compliant false, driving minutes 5,430, breaches ×1
breaches come back in time order 2026-09-21, 06:00, activities ×3 → compliant false, driving minutes 610, breaches ×3
no activities is compliant 2026-09-21, 06:00, → compliant true, driving minutes 0, breaches
a start time of 24:00 is refused 2026-09-21, 24:00, activities ×1 → error: startTime must be HH:MM, 24-hour
a date that is not ISO is refused 21/09/2026, 06:00, activities ×1 → error: startDate must be an ISO date (YYYY-MM-DD)
an unknown activity kind is refused 2026-09-21, 06:00, activities ×1 → error: unknown activity kind "sleeping"
a zero-length activity is refused 2026-09-21, 06:00, activities ×1 → error: minutes must be a whole number of at least 1
a fractional minute is refused 2026-09-21, 06:00, activities ×1 → error: minutes must be a whole number of at least 1
dates before Regulation 561/2006 applied (11 April 2007) are refused 2007-04-10, 06:00, activities ×1 → error: no drivers' hours limit
a start date with a trailing newline is refused 2026-09-21 , 06:00, activities ×1 → error: startDate must be an ISO date (YYYY-MM-DD), received "2026-09-21 "
a start date in Arabic-Indic digits is refused ٢٠٢٦-٠٩-٢١, 06:00, activities ×1 → error: startDate must be an ISO date (YYYY-MM-DD), received "٢٠٢٦-٠٩-٢١"
a start time with a trailing newline is refused 2026-09-21, 06:00 , activities ×1 → error: startTime must be HH:MM, 24-hour, received "06:00 "
a start time in Arabic-Indic digits is refused 2026-09-21, 0٦:0٠, activities ×1 → error: startTime must be HH:MM, 24-hour, received "0٦:0٠"

More from the author

checkDriversHours("2026-09-21", "06:00", [
  { kind: "driving", minutes: 300 }, { kind: "rest", minutes: 45 }
])
// { compliant: false, drivingMinutes: 300,
//   breaches: [{ rule: "break", at: "2026-09-21T10:30", actual: 300, limit: 270 }] }

## Input

The activities are back to back from `startDate` and `startTime`: each one starts when the previous one ends, as on a tachograph printout. `kind` is `driving`, `work` (other work), `availability` (a period of availability) or `rest` (any break or rest). Neighbouring activities of the same kind are merged, so a rest recorded as 20 minutes then 25 minutes is one uninterrupted 45-minute break.

Times are local wall-clock time with no time zone or clock change handling.

## Rules checked

| rule | limit | meaning | |------|-------|---------| | `break` | 270 minutes | Art. 7: more than 4h30 of driving without a qualifying break. `actual` is the driving in that driving period, up to the break that ended it | | `daily-driving` | 600 minutes | Art. 6(1): daily driving time over 10 hours | | `daily-driving` | 540 minutes | Art. 6(1): a third (or later) day over 9 hours in one fixed week; `at` is when that day passed 9 hours | | `weekly-driving` | 3360 minutes | Art. 6(2): over 56 hours in one fixed week | | `fortnightly-driving` | 5400 minutes | Art. 6(3): over 90 hours in two consecutive fixed weeks; `at` falls in the second week |

How the activities are read:

- **A break** is a rest of at least 45 minutes, or a rest of at least 15 minutes followed later by one of at least 30 (in that order: 30 then 15 does not count). Other work and periods of availability are not breaks. A 15-minute rest before any driving in the period is not the first part of a split break, since it is not taken during the driving period. - **A daily rest** is a rest of 9 hours or more. It ends the daily driving time (Art. 4(k)); a shorter rest, including the 3-hour first part of a split daily rest, does not. - **Weeks** are fixed weeks, 00:00 Monday to 24:00 Sunday (Art. 4(i)). Driving that crosses Sunday midnight counts in both weeks, minute by minute. A day's 10-hour extension counts in the week its first driving falls in. - **The end of the records** closes the last driving period and day, so records that stop mid-day are judged on what they show.

Whether daily and weekly *rest* was taken is a separate check: `fleet.tachograph-rest`.

## What it does not do

- GB domestic drivers' hours rules (Transport Act 1968), AETR, or mixed domestic/EU days. - Multi-manning, ferry or train crossings (Art. 9), the Art. 12 emergency departure, and the exemptions and national derogations (Art. 3 and 13). - The working-time limits (Road Transport (Working Time) Regulations 2005), including the 6-hour work break rule. - Weeks outside the records: the fortnightly check only adds up weeks it has data for, so pass the previous week's activities too if they matter.

## Data

The limits are dated rows in `data/limits.json`, effective from 11 April 2007 (Art. 29) and still in force. A date before that is an error.

## Sources

- Regulation (EC) No 561/2006, as it applies in the UK, legislation.gov.uk: Article 4 (definitions of break, rest, daily rest, week, daily driving time, driving period), Article 6 (9h/10h twice a week, 56h, 90h), Article 7 (45 minutes after 4h30; 15 then 30), Article 29 (entry into force 11 April 2007). https://www.legislation.gov.uk/eur/2006/561/contents - DVSA, "Drivers' hours: Assimilated rules": https://www.gov.uk/drivers-hours/eu-rules - DVSA, "Drivers' hours and tachographs: goods vehicles", section 1 (split break order 15 then 30, other work and availability are not breaks, fixed week): https://www.gov.uk/guidance/drivers-hours-goods-vehicles/1-eu-and-aetr-rules-on-drivers-hours

1.0.1 fixes Python accepting a trailing newline or non-ASCII digits in startDate and startTime; adds tests.

Files

PathBytes
README.md4,340
data/limits.json1,581
impl/python.py8,954
impl/rust.rs12,435
impl/typescript.ts8,414
vectors.json10,305