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
service_due(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 milesservice_due(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 upservice_due(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.
def service_due(last_service_date: str, last_service_mileage: int, interval_months: int, interval_miles: int, on_date: str, current_mileage: int) -> ServiceDue
| last_service_date | date | the date of the last service |
| last_service_mileage | int | odometer reading at the last service |
| interval_months | int | months between services; 0 when the schedule is by mileage only |
| interval_miles | int | miles between services; 0 when the schedule is by time only |
| on_date | date | today, as the caller sees it |
| current_mileage | int | odometer reading on onDate |
| returns | ServiceDue |
The types it declares, generated into your project
ServiceTrigger = Literal["date", "mileage"]
@dataclass(frozen=True)
class ServiceDue:
"""Both limits, how far away each is, and which one comes first."""
#: last service plus the interval in months, clamped to the month end; null without one
due_date: Optional[str]
#: last service mileage plus the interval in miles; null without one
due_mileage: Optional[int]
#: onDate to dueDate; negative once past
days_remaining: Optional[int]
#: dueMileage less currentMileage; negative once past
miles_remaining: Optional[int]
#: when dueMileage will be reached at the average daily mileage since the last service
projected_mileage_date: Optional[str]
#: the earlier of dueDate and the mileage date (onDate once the mileage is reached)
next_due_date: Optional[str]
#: which limit gives nextDueDate; date on a tie
due_by: Optional[ServiceTrigger]
#: past dueDate, or past dueMileage
overdue: bool
Your code names it in one line, in the file that uses it
from fune.fleet.service_due import service_due # fleet.service-due@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from typing import Optional
from .dates_add_days import add_days ← from dates.add-days ^1.0.0 · built alongside by fune
from .dates_add_months import add_months ← from dates.add-months ^1.0.0 · built alongside by fune
from .dates_days_between import days_between ← from dates.days-between ^1.0.0 · built alongside by fune
from .fleet_service_due_types import ServiceDue, ServiceTrigger
from .math_round_div import round_div ← from math.round-div ^1.0.0 · built alongside by fune
def _whole_number(name: str, value: int) -> None:
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise ValueError("%s must be a whole number, 0 or more, received %r" % (name, value))
def service_due(
last_service_date: str,
last_service_mileage: int,
interval_months: int,
interval_miles: int,
on_date: str,
current_mileage: int,
) -> ServiceDue:
"""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.
"""
_whole_number("lastServiceMileage", last_service_mileage)
_whole_number("intervalMonths", interval_months)
_whole_number("intervalMiles", interval_miles)
_whole_number("currentMileage", current_mileage)
if interval_months == 0 and interval_miles == 0:
raise ValueError("at least one of intervalMonths and intervalMiles must be above zero")
days_since = days_between(last_service_date, on_date)
if days_since < 0:
raise ValueError("onDate %s is before the last service on %s" % (on_date, last_service_date))
miles_since = current_mileage - last_service_mileage
if miles_since < 0:
raise ValueError(
"currentMileage %d is below the mileage at the last service, %d" % (current_mileage, last_service_mileage)
)
due_date = add_months(last_service_date, interval_months) if interval_months > 0 else None
days_remaining = None if due_date is None else days_between(on_date, due_date)
due_mileage = last_service_mileage + interval_miles if interval_miles > 0 else None
miles_remaining = None if due_mileage is None else due_mileage - current_mileage
projected: Optional[str] = None
mileage_date: Optional[str] = None
if miles_remaining is not None:
if miles_remaining <= 0:
mileage_date = on_date
elif days_since > 0 and miles_since > 0:
projected = add_days(on_date, round_div(miles_remaining * days_since, miles_since, "up"))
mileage_date = projected
next_due: Optional[str] = due_date
due_by: Optional[ServiceTrigger] = None if due_date is None else "date"
if mileage_date is not None and (next_due is None or mileage_date < next_due):
next_due = mileage_date
due_by = "mileage"
return ServiceDue(
due_date=due_date,
due_mileage=due_mileage,
days_remaining=days_remaining,
miles_remaining=miles_remaining,
projected_mileage_date=projected,
next_due_date=next_due,
due_by=due_by,
overdue=(days_remaining is not None and days_remaining < 0)
or (miles_remaining is not None and miles_remaining < 0),
)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the Python 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
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./fleet.service-due-1.0.0-python.fune, or fetch it from a terminal with fune pull fleet.service-due@1.0.0:python.
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 1,590 |
| impl/python.py | 3,160 |
| impl/rust.rs | 4,525 |
| impl/typescript.ts | 2,992 |
| vectors.json | 5,011 |