Functional Weave
Code in TypeScript

fleet.tachograph-rest@1.0.1

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}`;
}