time.duration
Parse durations written as 1h30m, 90m or 01:30, add them up in whole minutes, and format the total.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 23 tests, run in TypeScript, Python and Rust.
What it does
Timesheets, job cards and billing notes write durations in a handful of ways. This reads them, adds them and writes the total back out, in whole minutes, so one call turns ["1h30m", "45m", "0:15"] into 150 minutes, "2h30m" and "02:30". A single duration is a list of one.
Accepted forms, with surrounding spaces and upper-case H/M allowed:
For example
duration(1h30m)→ minutes 90, text 1h30m, clock 01:30 hours and minutesduration(90m)→ minutes 90, text 1h30m, clock 01:30 minutes alone may be 60 or moreduration(01:30)→ minutes 90, text 1h30m, clock 01:30 clock form
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 duration(parts: &[String]) -> Duration
| parts | string[] | durations as typed: "1h30m", "1h 30m", "90m", "2h", "01:30"; an empty list is zero |
| returns | Duration | the total, in minutes and in both written forms |
The type it declares, generated into your project
/// A length of time in whole minutes, with its two usual spellings.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Duration {
/// the total; 0 or more
pub minutes: i64,
/// hours and minutes: 2h15m, 2h, 45m, 0m
pub text: String,
/// hours:minutes, hours not wrapped at 24: 02:15, 25:30
pub clock: String,
}
Your code names it in one line, in the file that uses it
fune!(time.duration@^1); // then call duration(…)
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
const MAX_DIGITS: usize = 6;
fn not_a_duration(text: &str) -> ! {
panic!("\"{}\" is not a duration: write it as 1h30m, 90m or 01:30", text)
}
/// Read a run of digits from `at`; returns (value, next index), or None if there is none.
fn read_number(text: &str, s: &[u8], at: usize) -> Option<(i64, usize)> {
let mut end = at;
while end < s.len() && s[end].is_ascii_digit() {
end += 1;
}
if end == at {
return None;
}
if end - at > MAX_DIGITS {
not_a_duration(text);
}
let mut value = 0i64;
for b in &s[at..end] {
value = value * 10 + i64::from(b - b'0');
}
Some((value, end))
}
fn skip_spaces(s: &[u8], mut at: usize) -> usize {
while at < s.len() && (s[at] == b' ' || s[at] == b'\t') {
at += 1;
}
at
}
/// One written duration, in minutes.
fn parse_one(text: &str) -> i64 {
let s = text.trim_matches(|c| c == ' ' || c == '\t').as_bytes();
if let Some(colon) = s.iter().position(|&b| b == b':') {
let hours = match read_number(text, s, 0) {
Some(h) if h.1 == colon => h,
_ => not_a_duration(text),
};
let minutes = match read_number(text, s, colon + 1) {
Some(m) if m.1 == s.len() && m.1 - colon - 1 == 2 => m,
_ => not_a_duration(text),
};
if minutes.0 >= 60 {
panic!("minutes must be under 60 when hours are given, in \"{}\"", text);
}
return hours.0 * 60 + minutes.0;
}
let mut at = 0usize;
let mut hours: Option<i64> = None;
let mut minutes: Option<i64> = None;
while at < s.len() {
let read = match read_number(text, s, at) {
Some(r) => r,
None => not_a_duration(text),
};
at = skip_spaces(s, read.1);
let unit = if at < s.len() { s[at] } else { 0 };
if (unit == b'h' || unit == b'H') && hours.is_none() && minutes.is_none() {
hours = Some(read.0);
} else if (unit == b'm' || unit == b'M') && minutes.is_none() {
minutes = Some(read.0);
} else {
not_a_duration(text);
}
at = skip_spaces(s, at + 1);
}
if hours.is_none() && minutes.is_none() {
not_a_duration(text);
}
if let (Some(_), Some(m)) = (hours, minutes) {
if m >= 60 {
panic!("minutes must be under 60 when hours are given, in \"{}\"", text);
}
}
hours.unwrap_or(0) * 60 + minutes.unwrap_or(0)
}
/// Parse each written duration, add them, and give the total in minutes and in
/// both written forms. The clock form does not wrap at 24 hours, because a total
/// of work is not a time of day.
///
/// # Panics
/// Panics on any part that is not a duration in one of the accepted forms.
pub fn duration(parts: &[String]) -> Duration {
let total: i64 = parts.iter().map(|p| parse_one(p)).sum();
let hours = total / 60;
let minutes = total % 60;
let text = if hours > 0 && minutes > 0 {
format!("{}h{}m", hours, minutes)
} else if hours > 0 {
format!("{}h", hours)
} else {
format!("{}m", minutes)
};
Duration {
minutes: total,
text,
clock: format!("{:02}:{:02}", hours, minutes),
}
}
pub fn duration_to_value(d: &Duration) -> Value {
Value::obj(vec![
("minutes", Value::Int(d.minutes)),
("text", Value::str(&d.text)),
("clock", Value::str(&d.clock)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
let parts: Vec<String> = args[0].as_arr().iter().map(|v| v.as_str().to_string()).collect();
duration_to_value(&duration(&parts))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, 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.duration
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./time.duration-1.0.0-rust.fune, or fetch it from a terminal with fune pull time.duration@1.0.0:rust.
The whole function, every language, is one file too: time.duration-1.0.0.fune, 16,803 bytes, sha256 9ed1e9761812ec0a6b703bd222ff255b2ae24103893c1176ce38d9a2f9ef215a. 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.duration
after — your function gets the result and the arguments, and returns the final result.
// fune: after time.duration
replace — it requires no other capability, so there is no dependency to replace.
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.duration --steps.
// fune: step time.duration 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 | |
|---|---|---|---|
| hours and minutes | 1h30m | → | minutes 90, text 1h30m, clock 01:30 |
| minutes alone may be 60 or more | 90m | → | minutes 90, text 1h30m, clock 01:30 |
| clock form | 01:30 | → | minutes 90, text 1h30m, clock 01:30 |
| clock form without a leading zero | 1:30 | → | minutes 90, text 1h30m, clock 01:30 |
| a space between hours and minutes | 1h 30m | → | minutes 90, text 1h30m, clock 01:30 |
| whole hours format without minutes | 2h | → | minutes 120, text 2h, clock 02:00 |
| under an hour | 45m | → | minutes 45, text 45m, clock 00:45 |
| a timesheet in mixed forms adds up | 1h30m, 45m, 0:15 | → | minutes 150, text 2h30m, clock 02:30 |
| an empty list is zero | → | minutes 0, text 0m, clock 00:00 | |
| zero minutes | 0m | → | minutes 0, text 0m, clock 00:00 |
Show the other 13 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| more than a day does not wrap at 24 hours | 25:30 | → | minutes 1,530, text 25h30m, clock 25:30 |
| upper case units and surrounding spaces are accepted | 1H30M | → | minutes 90, text 1h30m, clock 01:30 |
| a three-digit hour clock | 100:00 | → | minutes 6,000, text 100h, clock 100:00 |
| minutes that sum past the hour carry into hours | 40m, 40m | → | minutes 80, text 1h20m, clock 01:20 |
| a bare number is ambiguous and rejected | 90 | → | error: "90" is not a duration |
| decimal hours are rejected | 1.5h | → | error: "1.5h" is not a duration |
| minutes of 60 or more beside hours is an error | 1h90m | → | error: minutes must be under 60 when hours are given |
| a clock minute of 75 is an error | 1:75 | → | error: minutes must be under 60 when hours are given |
| a single clock minute digit is rejected | 1:5 | → | error: "1:5" is not a duration |
| units in the wrong order are rejected | 30m1h | → | error: "30m1h" is not a duration |
| an empty string is not a duration | → | error: "" is not a duration | |
| a negative duration is rejected | -15m | → | error: "-15m" is not a duration |
| one bad part fails the whole total | 1h, soon | → | error: "soon" is not a duration |
More from the author
- hours and minutes: `1h30m`, `1h 30m`, `2h`, `45m`, `90m`. Minutes alone may be 60 or more; next to hours they must be under 60, so `1h90m` is an error (a typo for 1h30m or 1h09m, it cannot be told which). - clock form: `01:30`, `1:30`, `100:00`, with exactly two minute digits under 60.
Rejected, loudly: a bare number (`90`: minutes or hours?), decimals (`1.5h`: use 1h30m), negatives, seconds, units in the wrong order (`30m1h`), and any number over six digits. A duration is a length of time, so it is never negative.
It deals only in lengths of time. It never reads the clock and knows nothing about dates, time zones or daylight saving; minutes between two wall-clock times is time.minutes-between, and billing increments are time.round-to-increment.
The clock form does not wrap at 24 hours: 25 and a half hours is "25:30", because a total of work is not a time of day.
Files
| Path | Bytes |
|---|---|
| README.md | 1,244 |
| impl/python.py | 3,161 |
| impl/rust.rs | 3,688 |
| impl/typescript.ts | 2,991 |
| vectors.json | 2,870 |