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.
export function duration(parts: readonly 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. */
export interface Duration {
/** the total; 0 or more */
readonly minutes: number;
/** hours and minutes: 2h15m, 2h, 45m, 0m */
readonly text: string;
/** hours:minutes, hours not wrapped at 24: 02:15, 25:30 */
readonly clock: string;
}
Your code names it in one line, in the file that uses it
import { duration } from "#fune/time.duration@^1";
import { type Duration } from "./time_duration_types.ts";
const MAX_DIGITS = 6;
function notADuration(text: string): RangeError {
return new RangeError(`"${text}" is not a duration: write it as 1h30m, 90m or 01:30`);
}
function isDigit(ch: string): boolean {
return ch >= "0" && ch <= "9";
}
/** Read a run of digits from `at`; returns [value, next index], or null if there is none. */
function readNumber(text: string, s: string, at: number): [number, number] | null {
let end = at;
while (end < s.length && isDigit(s[end])) end++;
if (end === at) return null;
if (end - at > MAX_DIGITS) throw notADuration(text);
return [Number(s.slice(at, end)), end];
}
function skipSpaces(s: string, at: number): number {
while (at < s.length && (s[at] === " " || s[at] === "\t")) at++;
return at;
}
/** One written duration, in minutes. */
function parseOne(text: string): number {
if (typeof text !== "string") throw notADuration(String(text));
const s = text.slice(skipSpaces(text, 0)).replace(/[ \t]+$/, "");
const colon = s.indexOf(":");
if (colon >= 0) {
const hours = readNumber(text, s, 0);
if (hours === null || hours[1] !== colon) throw notADuration(text);
const minutes = readNumber(text, s, colon + 1);
if (minutes === null || minutes[1] !== s.length || minutes[1] - colon - 1 !== 2) throw notADuration(text);
if (minutes[0] >= 60) throw new RangeError(`minutes must be under 60 when hours are given, in "${text}"`);
return hours[0] * 60 + minutes[0];
}
let at = 0;
let hours: number | null = null;
let minutes: number | null = null;
while (at < s.length) {
const read = readNumber(text, s, at);
if (read === null) throw notADuration(text);
at = skipSpaces(s, read[1]);
const unit = s[at];
if ((unit === "h" || unit === "H") && hours === null && minutes === null) hours = read[0];
else if ((unit === "m" || unit === "M") && minutes === null) minutes = read[0];
else throw notADuration(text);
at = skipSpaces(s, at + 1);
}
if (hours === null && minutes === null) throw notADuration(text);
if (hours !== null && minutes !== null && minutes >= 60) {
throw new RangeError(`minutes must be under 60 when hours are given, in "${text}"`);
}
return (hours ?? 0) * 60 + (minutes ?? 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.
*/
export function duration(parts: readonly string[]): Duration {
let total = 0;
for (const part of parts) total += parseOne(part);
const hours = Math.floor(total / 60);
const minutes = total - hours * 60;
let text: string;
if (hours > 0 && minutes > 0) text = `${hours}h${minutes}m`;
else if (hours > 0) text = `${hours}h`;
else text = `${minutes}m`;
const clock = `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
return { minutes: total, text, clock };
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and nothing else, 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 time.duration
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./time.duration-1.0.0-typescript.fune, or fetch it from a terminal with fune pull time.duration@1.0.0:typescript.
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 |