Functional Weave
Code in TypeScript

fleet.service-due

Next vehicle service by months or miles, whichever comes first, projecting the mileage date, with an overdue flag.

1.0.0 · published 2026-10-03 by charlie · Anterra

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

What it does

When a vehicle's next service is due under a "every N months or N miles, whichever comes first" schedule, how far away each limit is, and whether the service is overdue.

## Whichever comes first

For example

  • serviceDue(2025-03-15, 30,000, 12, 10,000, 2025-09-15, 35,000) → due date 2026-03-15, due mileage 40,000, days remaining 181, miles remaining 5,000, projected mileage date 2026-03-18, next due date 2026-03-15, due by date, overdue false an average driver: the 12 months come three days before the 10,000 miles
  • serviceDue(2025-01-10, 20,000, 12, 10,000, 2025-04-10, 26,000) → due date 2026-01-10, due mileage 30,000, days remaining 275, miles remaining 4,000, projected mileage date 2025-06-09, next due date 2025-06-09, due by mileage, overdue false a high-mileage driver reaches 10,000 miles long before the year is up
  • serviceDue(2024-05-01, 10,000, 12, 12,000, 2025-05-20, 18,000) → due date 2025-05-01, due mileage 22,000, days remaining -19, miles remaining 4,000, projected mileage date 2025-11-28, next due date 2025-05-01, due by date, overdue true overdue by date though the mileage is not reached

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 serviceDue(lastServiceDate: string, lastServiceMileage: number, intervalMonths: number, intervalMiles: number, onDate: string, currentMileage: number): ServiceDue
lastServiceDatedatethe date of the last service
lastServiceMileageintodometer reading at the last service
intervalMonthsintmonths between services; 0 when the schedule is by mileage only
intervalMilesintmiles between services; 0 when the schedule is by time only
onDatedatetoday, as the caller sees it
currentMileageintodometer reading on onDate
returnsServiceDue

The types it declares, generated into your project

export type ServiceTrigger = "date" | "mileage";

/** Both limits, how far away each is, and which one comes first. */
export interface ServiceDue {
  /** last service plus the interval in months, clamped to the month end; null without one */
  readonly dueDate: string | null;
  /** last service mileage plus the interval in miles; null without one */
  readonly dueMileage: number | null;
  /** onDate to dueDate; negative once past */
  readonly daysRemaining: number | null;
  /** dueMileage less currentMileage; negative once past */
  readonly milesRemaining: number | null;
  /** when dueMileage will be reached at the average daily mileage since the last service */
  readonly projectedMileageDate: string | null;
  /** the earlier of dueDate and the mileage date (onDate once the mileage is reached) */
  readonly nextDueDate: string | null;
  /** which limit gives nextDueDate; date on a tie */
  readonly dueBy: ServiceTrigger | null;
  /** past dueDate, or past dueMileage */
  readonly overdue: boolean;
}

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

import { serviceDue } from "#fune/fleet.service-due@^1";
impl/typescript.ts · 78 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 { addMonths } from "./dates_add_months.ts";  ← from dates.add-months ^1.0.0 · built alongside by fune
import { daysBetween } from "./dates_days_between.ts";  ← from dates.days-between ^1.0.0 · built alongside by fune
import { roundDiv } from "./math_round_div.ts";  ← from math.round-div ^1.0.0 · built alongside by fune
import { type ServiceDue, type ServiceTrigger } from "./fleet_service_due_types.ts";

function wholeNumber(name: string, value: number): void {
  if (!Number.isInteger(value) || value < 0) {
    throw new RangeError(`${name} must be a whole number, 0 or more, received ${value}`);
  }
}

/**
 * The next service, by time or mileage, whichever comes first.
 *
 * "Whichever comes first" needs a date for the mileage limit too, so it is
 * projected from the average daily mileage since the last service, rounded up
 * to a whole day: a service booked for the projected day is never late on
 * that estimate.
 */
export function serviceDue(
  lastServiceDate: string,
  lastServiceMileage: number,
  intervalMonths: number,
  intervalMiles: number,
  onDate: string,
  currentMileage: number
): ServiceDue {
  wholeNumber("lastServiceMileage", lastServiceMileage);
  wholeNumber("intervalMonths", intervalMonths);
  wholeNumber("intervalMiles", intervalMiles);
  wholeNumber("currentMileage", currentMileage);
  if (intervalMonths === 0 && intervalMiles === 0) {
    throw new RangeError("at least one of intervalMonths and intervalMiles must be above zero");
  }
  const daysSince = daysBetween(lastServiceDate, onDate);
  if (daysSince < 0) {
    throw new RangeError(`onDate ${onDate} is before the last service on ${lastServiceDate}`);
  }
  const milesSince = currentMileage - lastServiceMileage;
  if (milesSince < 0) {
    throw new RangeError(`currentMileage ${currentMileage} is below the mileage at the last service, ${lastServiceMileage}`);
  }

  const dueDate = intervalMonths > 0 ? addMonths(lastServiceDate, intervalMonths) : null;
  const daysRemaining = dueDate === null ? null : daysBetween(onDate, dueDate);
  const dueMileage = intervalMiles > 0 ? lastServiceMileage + intervalMiles : null;
  const milesRemaining = dueMileage === null ? null : dueMileage - currentMileage;

  let projectedMileageDate: string | null = null;
  let mileageDate: string | null = null;
  if (milesRemaining !== null) {
    if (milesRemaining <= 0) {
      mileageDate = onDate;
    } else if (daysSince > 0 && milesSince > 0) {
      projectedMileageDate = addDays(onDate, roundDiv(milesRemaining * daysSince, milesSince, "up"));
      mileageDate = projectedMileageDate;
    }
  }

  let nextDueDate: string | null = dueDate;
  let dueBy: ServiceTrigger | null = dueDate === null ? null : "date";
  if (mileageDate !== null && (nextDueDate === null || mileageDate < nextDueDate)) {
    nextDueDate = mileageDate;
    dueBy = "mileage";
  }

  return {
    dueDate,
    dueMileage,
    daysRemaining,
    milesRemaining,
    projectedMileageDate,
    nextDueDate,
    dueBy,
    overdue: (daysRemaining !== null && daysRemaining < 0) || (milesRemaining !== null && milesRemaining < 0),
  };
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 4 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.service-due
Download for TypeScript fleet.service-due-1.0.0-typescript.fune · 14,000 bytes sha256 1cba5e3f08afcc9fd2a37e52993ef5cfacd25ad84b1c8302a37a23b8ca5b0da4

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

The whole function, every language, is one file too: fleet.service-due-1.0.0.fune, 21,991 bytes, sha256 121547871626eacd7a5f48e89ec365542c118d12124d29b4defe9360ad0a5566. 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.service-due

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

// fune: after fleet.service-due

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.service-due
// fune: replace dates.add-months in fleet.service-due
// fune: replace dates.days-between in fleet.service-due
// fune: replace math.round-div in fleet.service-due

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.service-due --steps.

// fune: step fleet.service-due 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
an average driver: the 12 months come three days before the 10,000 miles 2025-03-15, 30,000, 12, 10,000, 2025-09-15, 35,000 → due date 2026-03-15, due mileage 40,000, days remaining 181, miles remaining 5,000, projected mileage date 2026-03-18, next due date 2026-03-15, due by date, overdue false
a high-mileage driver reaches 10,000 miles long before the year is up 2025-01-10, 20,000, 12, 10,000, 2025-04-10, 26,000 → due date 2026-01-10, due mileage 30,000, days remaining 275, miles remaining 4,000, projected mileage date 2025-06-09, next due date 2025-06-09, due by mileage, overdue false
overdue by date though the mileage is not reached 2024-05-01, 10,000, 12, 12,000, 2025-05-20, 18,000 → due date 2025-05-01, due mileage 22,000, days remaining -19, miles remaining 4,000, projected mileage date 2025-11-28, next due date 2025-05-01, due by date, overdue true
overdue by mileage: due from today, nothing to project 2025-01-01, 50,000, 24, 20,000, 2025-06-01, 70,500 → due date 2027-01-01, due mileage 70,000, days remaining 579, miles remaining -500, projected mileage date —, next due date 2025-06-01, due by mileage, overdue true
exactly at the due mileage is due, not overdue 2025-01-01, 0, 12, 10,000, 2025-07-01, 10,000 → due date 2026-01-01, due mileage 10,000, days remaining 184, miles remaining 0, projected mileage date —, next due date 2025-07-01, due by mileage, overdue false
time only: a service on 29 February is next due on 28 February 2024-02-29, 1,000, 12, 0, 2024-06-01, 3,000 → due date 2025-02-28, due mileage —, days remaining 272, miles remaining —, projected mileage date —, next due date 2025-02-28, due by date, overdue false
mileage only: projected from 100 miles a day 2025-01-01, 0, 0, 5,000, 2025-01-11, 1,000 → due date —, due mileage 5,000, days remaining —, miles remaining 4,000, projected mileage date 2025-02-20, next due date 2025-02-20, due by mileage, overdue false
serviced today: no average yet, so no projection 2025-03-01, 40,000, 12, 10,000, 2025-03-01, 40,000 → due date 2026-03-01, due mileage 50,000, days remaining 365, miles remaining 10,000, projected mileage date —, next due date 2026-03-01, due by date, overdue false
a vehicle that has not moved has no projection 2025-03-01, 40,000, 12, 10,000, 2025-06-01, 40,000 → due date 2026-03-01, due mileage 50,000, days remaining 273, miles remaining 10,000, projected mileage date —, next due date 2026-03-01, due by date, overdue false
the projection lands on the due date: a tie is by date 2025-01-01, 0, 1, 310, 2025-01-11, 100 → due date 2025-02-01, due mileage 310, days remaining 21, miles remaining 210, projected mileage date 2025-02-01, next due date 2025-02-01, due by date, overdue false
Show the other 8 tests
CaseArgumentsExpected
a fractional day of driving rounds the projection up: 56.6 days is 57 2025-01-01, 0, 12, 10,000, 2025-01-08, 1,100 → due date 2026-01-01, due mileage 10,000, days remaining 358, miles remaining 8,900, projected mileage date 2025-03-06, next due date 2025-03-06, due by mileage, overdue false
mileage only and not moving: nothing is due yet 2025-01-01, 0, 0, 5,000, 2025-02-01, 0 → due date —, due mileage 5,000, days remaining —, miles remaining 5,000, projected mileage date —, next due date —, due by —, overdue false
no interval at all is an error 2025-01-01, 0, 0, 0, 2025-02-01, 100 → error: at least one of intervalMonths and intervalMiles must be above zero
a negative interval is an error 2025-01-01, 0, -12, 10,000, 2025-02-01, 100 → error: intervalMonths must be a whole number, 0 or more
a fractional mileage is an error 2025-01-01, 0, 12, 10,000, 2025-02-01, 100.5 → error: currentMileage must be a whole number, 0 or more
a date before the last service is an error 2025-01-01, 0, 12, 10,000, 2024-12-31, 0 → error: is before the last service
a mileage below the last service is an error (clocked, or the wrong vehicle) 2025-01-01, 5,000, 12, 10,000, 2025-02-01, 4,000 → error: is below the mileage at the last service
a malformed date is an error 2025-1-1, 0, 12, 10,000, 2025-02-01, 100 → error: is not an ISO date

More from the author

The time limit is a date: the last service plus `intervalMonths`, clamped to the month end by `dates.add-months` (a service on 29 February 2024 with a 12-month interval is due on 28 February 2025). The mileage limit is a mileage, so to compare the two it is turned into a date: the average miles a day since the last service, projected forward from `onDate`, rounded **up** to a whole day. `nextDueDate` is the earlier of the two and `dueBy` says which; a tie counts as `date`.

There is no projection (and `projectedMileageDate` is null) when the service was today or the vehicle has not moved, since there is no average to project; `nextDueDate` then falls back to the date limit alone, or is null for a mileage-only schedule. Once the due mileage is reached the service is due from `onDate`.

## Overdue

`overdue` is true once `onDate` is after `dueDate` or `currentMileage` is above `dueMileage`. On the due date, or at exactly the due mileage, the service is due but not yet overdue; `daysRemaining` or `milesRemaining` is then 0, and both go negative once past.

## Inputs

`intervalMonths` or `intervalMiles` may be 0 to schedule by the other alone, but not both. `onDate` is passed in, never read from a clock. An `onDate` before the last service, or a mileage below the last service's (a clocked odometer, or the wrong vehicle), is an error rather than a guess.

Files

PathBytes
README.md1,590
impl/python.py3,160
impl/rust.rs4,525
impl/typescript.ts2,992
vectors.json5,011