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.
def duration(parts: Sequence[str]) -> 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
@dataclass(frozen=True)
class Duration:
"""A length of time in whole minutes, with its two usual spellings."""
#: the total; 0 or more
minutes: int
#: hours and minutes: 2h15m, 2h, 45m, 0m
text: str
#: hours:minutes, hours not wrapped at 24: 02:15, 25:30
clock: str
Your code names it in one line, in the file that uses it
from fune.time.duration import duration # time.duration@^1
from typing import Optional, Sequence, Tuple
from .time_duration_types import Duration
MAX_DIGITS = 6
def _not_a_duration(text: object) -> ValueError:
return ValueError('"%s" is not a duration: write it as 1h30m, 90m or 01:30' % (text,))
def _is_digit(ch: str) -> bool:
# Not str.isdigit(): that accepts superscripts and other scripts' digits.
return "0" <= ch <= "9"
def _read_number(text: str, s: str, at: int) -> Optional[Tuple[int, int]]:
"""Read a run of digits from ``at``; returns (value, next index), or None if there is none."""
end = at
while end < len(s) and _is_digit(s[end]):
end += 1
if end == at:
return None
if end - at > MAX_DIGITS:
raise _not_a_duration(text)
return int(s[at:end]), end
def _skip_spaces(s: str, at: int) -> int:
while at < len(s) and s[at] in (" ", "\t"):
at += 1
return at
def _parse_one(text: str) -> int:
"""One written duration, in minutes."""
if not isinstance(text, str):
raise _not_a_duration(text)
s = text[_skip_spaces(text, 0):].rstrip(" \t")
colon = s.find(":")
if colon >= 0:
hours = _read_number(text, s, 0)
if hours is None or hours[1] != colon:
raise _not_a_duration(text)
minutes = _read_number(text, s, colon + 1)
if minutes is None or minutes[1] != len(s) or minutes[1] - colon - 1 != 2:
raise _not_a_duration(text)
if minutes[0] >= 60:
raise ValueError('minutes must be under 60 when hours are given, in "%s"' % (text,))
return hours[0] * 60 + minutes[0]
at = 0
hours_value: Optional[int] = None
minutes_value: Optional[int] = None
while at < len(s):
read = _read_number(text, s, at)
if read is None:
raise _not_a_duration(text)
at = _skip_spaces(s, read[1])
unit = s[at] if at < len(s) else ""
if unit in ("h", "H") and hours_value is None and minutes_value is None:
hours_value = read[0]
elif unit in ("m", "M") and minutes_value is None:
minutes_value = read[0]
else:
raise _not_a_duration(text)
at = _skip_spaces(s, at + 1)
if hours_value is None and minutes_value is None:
raise _not_a_duration(text)
if hours_value is not None and minutes_value is not None and minutes_value >= 60:
raise ValueError('minutes must be under 60 when hours are given, in "%s"' % (text,))
return (hours_value or 0) * 60 + (minutes_value or 0)
def duration(parts: Sequence[str]) -> Duration:
"""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.
"""
total = 0
for part in parts:
total += _parse_one(part)
hours, minutes = divmod(total, 60)
if hours > 0 and minutes > 0:
text = "%dh%dm" % (hours, minutes)
elif hours > 0:
text = "%dh" % (hours,)
else:
text = "%dm" % (minutes,)
return Duration(minutes=total, text=text, clock="%02d:%02d" % (hours, minutes))Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and nothing else, 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 time.duration
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./time.duration-1.0.0-python.fune, or fetch it from a terminal with fune pull time.duration@1.0.0:python.
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 |