Functional Weave
Code in Python

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 minutes
  • duration(90m) → minutes 90, text 1h30m, clock 01:30 minutes alone may be 60 or more
  • duration(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
partsstring[]durations as typed: "1h30m", "1h 30m", "90m", "2h", "01:30"; an empty list is zero
returnsDurationthe 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
impl/python.py · 89 lines · open · raw
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
Download for Python time.duration-1.0.0-python.fune · 9,831 bytes sha256 c9c26d99de7660503b574ae49da555f58754682e92ffd374d656cce46fa17ba4

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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

PathBytes
README.md1,244
impl/python.py3,161
impl/rust.rs3,688
impl/typescript.ts2,991
vectors.json2,870