time.round-to-increment
Round a duration in minutes to a billing increment such as 6 or 15 minutes, up, down or to nearest.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 18 tests, run in TypeScript, Python and Rust.
What it does
Rounds a duration to whole billing units. 7 minutes in six-minute units (the tenth-of-an-hour convention common in legal billing) is 12 rounding up, 6 rounding down and 6 to nearest; 22 minutes in quarter hours is 15 to nearest and 30 rounding up.
The modes are math.round-div's, which this capability is a thin wrapper over, so rounding means the same thing here as it does for money:
For example
round_to_increment(7, 6, up)→ 12 7 minutes in six-minute units, rounding up, bills two unitsround_to_increment(7, 6, down)→ 6 7 minutes in six-minute units, rounding down, bills oneround_to_increment(7, 6, half-up)→ 6 7 minutes to the nearest six is 6
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 round_to_increment(minutes: i64, increment: i64, mode: &str) -> i64
| minutes | int | the duration, 0 or more |
| increment | int | the unit to round to, in minutes: 6 for tenths of an hour, 15 for quarter hours |
| mode | RoundingMode | up (always bill the part unit), down, half-up (nearest, ties up) or half-even (nearest, ties to even) |
| returns | int | a whole number of increments, in minutes |
Your code names it in one line, in the file that uses it
fune!(time.round-to-increment@^1); // then call round_to_increment(…)
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::math_round_div::round_div; ← from math.round-div ^1.0.0 · built alongside by fune
/// Round a duration to a whole number of billing increments. The rounding is
/// math.round-div's, so "half-up" here is the same rule as for money.
///
/// # Panics
/// Panics on negative minutes, an increment below one, or an unknown mode.
pub fn round_to_increment(minutes: i64, increment: i64, mode: &str) -> i64 {
if minutes < 0 {
panic!("minutes must be a non-negative integer, received {}", minutes);
}
if increment < 1 {
panic!("increment must be a positive integer, received {}", increment);
}
round_div(minutes, increment, mode) * increment
}
pub fn fune_vector(args: &[Value]) -> Value {
Value::Int(round_to_increment(
args[0].as_i64(),
args[1].as_i64(),
args[2].as_str(),
))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 1 dependency, 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 time.round-to-increment
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./time.round-to-increment-1.0.0-rust.fune, or fetch it from a terminal with fune pull time.round-to-increment@1.0.0:rust.
The whole function, every language, is one file too: time.round-to-increment-1.0.0.fune, 7,541 bytes, sha256 a025300b5b63a666a8089af6172507925276781f65c341183991b49c65834fc8. 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 time.round-to-increment
after — your function gets the result and the arguments, and returns the final result.
// fune: after time.round-to-increment
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 math.round-div in time.round-to-increment
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 time.round-to-increment --steps.
// fune: step time.round-to-increment 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 | |
|---|---|---|---|
| 7 minutes in six-minute units, rounding up, bills two units | 7, 6, up | → | 12 |
| 7 minutes in six-minute units, rounding down, bills one | 7, 6, down | → | 6 |
| 7 minutes to the nearest six is 6 | 7, 6, half-up | → | 6 |
| 9 minutes is exactly half way between 6 and 12: half-up goes to 12 | 9, 6, half-up | → | 12 |
| 15 minutes is half way between 12 and 18: half-up goes to 18 | 15, 6, half-up | → | 18 |
| 15 minutes, half-even goes to the even unit count, 2 units = 12 | 15, 6, half-even | → | 12 |
| 3 minutes, half-even rounds the half unit to zero units | 3, 6, half-even | → | 0 |
| an exact multiple is unchanged when rounding up | 12, 6, up | → | 12 |
| one minute in quarter hours, rounding up, is a quarter hour | 1, 15, up | → | 15 |
| 22 minutes to the nearest quarter hour is 15 | 22, 15, half-up | → | 15 |
Show the other 8 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| 23 minutes to the nearest quarter hour is 30 | 23, 15, half-up | → | 30 |
| 52 minutes rounding down in quarter hours is 45 | 52, 15, down | → | 45 |
| zero stays zero even rounding up: a minimum charge is not rounding | 0, 15, up | → | 0 |
| an increment of one minute changes nothing | 37, 1, up | → | 37 |
| a long day in six-minute units | 487, 6, up | → | 492 |
| negative minutes are an error | -5, 6, up | → | error: minutes must be a non-negative integer |
| an increment of zero is an error | 7, 0, up | → | error: increment must be a positive integer |
| "nearest" is spelled half-up or half-even, so an unknown mode is an error | 7, 6, nearest | → | error: unknown rounding mode "nearest" |
More from the author
- `up`: any part of a unit bills as a whole one. The usual rule for professional time. - `down`: only completed units count. - `half-up`: nearest unit, a tie (exactly half a unit) goes up. This is "nearest". - `half-even`: nearest unit, a tie goes to the even number of units, so ties do not bias a long timesheet upwards.
Rounding applies to the one duration you pass. Round each entry and then add, or add and then round, but know which your terms of business say: seven 3-minute calls rounded up in six-minute units bill 42 minutes one way and 24 the other.
Zero stays zero in every mode. A minimum charge ("at least one unit per call") is a business rule on top of this, not rounding. Durations are never negative; minutes below zero and an increment below one are errors.
Files
| Path | Bytes |
|---|---|
| README.md | 1,200 |
| impl/python.py | 741 |
| impl/rust.rs | 825 |
| impl/typescript.ts | 673 |
| vectors.json | 2,063 |