Functional Weave
Code in TypeScript

monitor.format-duration

A number of seconds as a short human duration for dashboards and alerts: "3h 12m", "2d 4h", "45s".

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 18 tests, run in TypeScript, Python and Rust.

What it does

A number of seconds as the short duration a dashboard or alert shows: `"3h 12m"`, `"2d 4h"`, `"45s"`, `"0s"`. Units are days, hours, minutes and seconds (`d h m s`); days never roll up into weeks or years, because months and years have no fixed length (400 days is `"400d"`).

## The rule

For example

  • formatDuration(0, 2) → 0s zero is 0s, not an empty string
  • formatDuration(45, 2) → 45s under a minute is seconds only
  • formatDuration(11,520, 2) → 3h 12m three hours twelve minutes

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 formatDuration(seconds: number, maxUnits: number): string
secondsint0 or more
maxUnitsinthow many consecutive units to consider from the largest non-zero one, 1 to 4 (d h m s)
returnsstring

Your code names it in one line, in the file that uses it

import { formatDuration } from "#fune/monitor.format-duration@^1";
impl/typescript.ts · 28 lines · open · raw
const UNITS: readonly (readonly [string, number])[] = [["d", 86400], ["h", 3600], ["m", 60], ["s", 1]];

/**
 * Seconds as "3h 12m". Starts at the largest non-zero unit and looks at
 * `maxUnits` consecutive units from there, leaving out the zero ones, so
 * 3605 s with two units is "1h", not "1h 0m" and not "1h 5s": the seconds
 * are beyond the precision asked for. Truncates, never rounds up, so a
 * duration is never shown longer than it was (86399 s is "23h", not "1d").
 */
export function formatDuration(seconds: number, maxUnits: number): string {
  if (!Number.isSafeInteger(seconds)) throw new RangeError(`seconds must be a whole number, received ${seconds}`);
  if (seconds < 0) throw new RangeError(`seconds must not be negative, received ${seconds}`);
  if (!Number.isSafeInteger(maxUnits) || maxUnits < 1 || maxUnits > 4) {
    throw new RangeError(`maxUnits must be 1 to 4, received ${maxUnits}`);
  }
  if (seconds === 0) return "0s";
  let first = 0;
  while (Math.floor(seconds / UNITS[first][1]) === 0) first += 1;
  const parts: string[] = [];
  let rest = seconds;
  for (let i = 0; i < UNITS.length; i++) {
    const [label, size] = UNITS[i];
    const n = Math.floor(rest / size);
    rest -= n * size;
    if (i >= first && i < first + maxUnits && n > 0) parts.push(`${n}${label}`);
  }
  return parts.join(" ");
}

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 monitor.format-duration
Download for TypeScript monitor.format-duration-1.0.0-typescript.fune · 6,012 bytes sha256 62be196ee2ed832b8593bdf8d7f318b01f7eb33c1669a95c04c4bac3deca19ab

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./monitor.format-duration-1.0.0-typescript.fune, or fetch it from a terminal with fune pull monitor.format-duration@1.0.0:typescript.

The whole function, every language, is one file too: monitor.format-duration-1.0.0.fune, 9,390 bytes, sha256 44f871f699d6fea3ca40422a12abc585030b3392315c13e5dd4c90297fe0841f. 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 monitor.format-duration

after — your function gets the result and the arguments, and returns the final result.

// fune: after monitor.format-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 monitor.format-duration --steps.

// fune: step monitor.format-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
zero is 0s, not an empty string 0, 2 → 0s
under a minute is seconds only 45, 2 → 45s
three hours twelve minutes 11,520, 2 → 3h 12m
two days four hours 187,200, 2 → 2d 4h
zero minutes are dropped and the seconds are beyond two units, so 1h 3,605, 2 → 1h
with three units the seconds come back and the zero minutes stay out 3,605, 3 → 1h 5s
every unit 90,061, 4 → 1d 1h 1m 1s
one unit only 90,061, 1 → 1d
truncates: one second short of a day is 23h, never 1d 86,399, 1 → 23h
one second short of a day in full 86,399, 4 → 23h 59m 59s
Show the other 8 tests
CaseArgumentsExpected
exactly a minute 60, 4 → 1m
a million seconds to three units 1,000,000, 3 → 11d 13h 46m
days never roll up into years 34,560,000, 2 → 400d
negative seconds are an error -1, 2 → error: seconds must not be negative, received -1
fractional seconds are an error 1.5, 2 → error: seconds must be a whole number, received 1.5
zero units is an error 60, 0 → error: maxUnits must be 1 to 4, received 0
five units is an error: there are only four 60, 5 → error: maxUnits must be 1 to 4, received 5
fractional units are an error 60, 2.5 → error: maxUnits must be 1 to 4, received 2.5

More from the author

1. Find the largest unit that is not zero. 2. Look at `maxUnits` consecutive units from there, down to seconds at most. 3. Show the non-zero ones among them, separated by a space.

So zeros in between are dropped (`3605` with 3 units is `"1h 5s"`, not `"1h 0m 5s"`), and a unit beyond the window is never shown however large (`3605` with 2 units is `"1h"`: minutes are zero and seconds are beyond the precision asked for). The result is always truncated, never rounded up: a duration is never displayed longer than it was, so `86399` with one unit is `"23h"`, not `"1d"`.

Zero is `"0s"`, never an empty string.

`time.duration` also formats durations, but in whole minutes with no spaces (`"2h15m"`); this one is the second-resolution format monitoring tools use.

## Errors

- `seconds must be a whole number, received X` - `seconds must not be negative, received X` - `maxUnits must be 1 to 4, received X`

Files

PathBytes
README.md1,226
impl/python.py1,344
impl/rust.rs1,855
impl/typescript.ts1,342
vectors.json1,751