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.
pub fn service_due(last_service_date: &str, last_service_mileage: i64, interval_months: i64, interval_miles: i64, on_date: &str, current_mileage: i64) -> 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 is a string in Rust, one of: "date", "mileage".
// Parameters take it as &str and results hold it as String.
/// Both limits, how far away each is, and which one comes first.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServiceDue {
/// last service plus the interval in months, clamped to the month end; null without one
pub due_date: Option<String>,
/// last service mileage plus the interval in miles; null without one
pub due_mileage: Option<i64>,
/// onDate to dueDate; negative once past
pub days_remaining: Option<i64>,
/// dueMileage less currentMileage; negative once past
pub miles_remaining: Option<i64>,
/// when dueMileage will be reached at the average daily mileage since the last service
pub projected_mileage_date: Option<String>,
/// the earlier of dueDate and the mileage date (onDate once the mileage is reached)
pub next_due_date: Option<String>,
/// which limit gives nextDueDate; date on a tie
pub due_by: Option<String>,
/// past dueDate, or past dueMileage
pub overdue: bool,
}
Your code names it in one line, in the file that uses it
fune!(fleet.service-due@^1); // then call service_due(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::dates_add_days::add_days; ← from dates.add-days ^1.0.0 · built alongside by fune
use super::dates_add_months::add_months; ← from dates.add-months ^1.0.0 · built alongside by fune
use super::dates_days_between::days_between; ← from dates.days-between ^1.0.0 · built alongside by fune
use super::math_round_div::round_div; ← from math.round-div ^1.0.0 · built alongside by fune
fn whole_number(name: &str, value: i64) {
if value < 0 {
panic!("{} must be a whole number, 0 or more, received {}", name, 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.
///
/// # Panics
/// Panics on a negative number, no interval at all, a malformed date, or an
/// onDate or mileage before the last service.
pub fn service_due(
last_service_date: &str,
last_service_mileage: i64,
interval_months: i64,
interval_miles: i64,
on_date: &str,
current_mileage: i64,
) -> ServiceDue {
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 && interval_miles == 0 {
panic!("at least one of intervalMonths and intervalMiles must be above zero");
}
let days_since = days_between(last_service_date, on_date);
if days_since < 0 {
panic!("onDate {} is before the last service on {}", on_date, last_service_date);
}
let miles_since = current_mileage - last_service_mileage;
if miles_since < 0 {
panic!(
"currentMileage {} is below the mileage at the last service, {}",
current_mileage, last_service_mileage
);
}
let due_date = if interval_months > 0 { Some(add_months(last_service_date, interval_months)) } else { None };
let days_remaining = due_date.as_ref().map(|d| days_between(on_date, d));
let due_mileage = if interval_miles > 0 { Some(last_service_mileage + interval_miles) } else { None };
let miles_remaining = due_mileage.map(|m| m - current_mileage);
let mut projected: Option<String> = None;
let mut mileage_date: Option<String> = None;
if let Some(remaining) = miles_remaining {
if remaining <= 0 {
mileage_date = Some(on_date.to_string());
} else if days_since > 0 && miles_since > 0 {
let date = add_days(on_date, round_div(remaining * days_since, miles_since, "up"));
projected = Some(date.clone());
mileage_date = Some(date);
}
}
let mut next_due = due_date.clone();
let mut due_by: Option<String> = due_date.as_ref().map(|_| "date".to_string());
if let Some(md) = mileage_date {
if next_due.as_ref().map_or(true, |n| md < *n) {
next_due = Some(md);
due_by = Some("mileage".to_string());
}
}
ServiceDue {
overdue: days_remaining.map_or(false, |d| d < 0) || miles_remaining.map_or(false, |m| m < 0),
due_date,
due_mileage,
days_remaining,
miles_remaining,
projected_mileage_date: projected,
next_due_date: next_due,
due_by,
}
}
fn opt_str(v: &Option<String>) -> Value {
match v {
Some(s) => Value::str(s),
None => Value::Null,
}
}
fn opt_int(v: Option<i64>) -> Value {
match v {
Some(i) => Value::Int(i),
None => Value::Null,
}
}
pub fn service_due_to_value(s: &ServiceDue) -> Value {
Value::obj(vec![
("dueDate", opt_str(&s.due_date)),
("dueMileage", opt_int(s.due_mileage)),
("daysRemaining", opt_int(s.days_remaining)),
("milesRemaining", opt_int(s.miles_remaining)),
("projectedMileageDate", opt_str(&s.projected_mileage_date)),
("nextDueDate", opt_str(&s.next_due_date)),
("dueBy", opt_str(&s.due_by)),
("overdue", Value::Bool(s.overdue)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
for (i, name) in [(1usize, "lastServiceMileage"), (2, "intervalMonths"), (3, "intervalMiles"), (5, "currentMileage")] {
if let Value::Float(f) = &args[i] {
panic!("{} must be a whole number, 0 or more, received {}", name, f);
}
}
service_due_to_value(&service_due(
args[0].as_str(),
args[1].as_i64(),
args[2].as_i64(),
args[3].as_i64(),
args[4].as_str(),
args[5].as_i64(),
))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 4 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. 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 Rust implementation. Install it without the registry with fune add ./fleet.service-due-1.0.0-rust.fune, or fetch it from a terminal with fune pull fleet.service-due@1.0.0:rust.
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 |