Functional Weave
Code in TypeScript

time.countdown

Seconds left until a Unix time, whether it has passed, and a timer text like 4:05, for tokens or retry waits.

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

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

What it does

How long is left until a moment, as a number and as the text a timer shows:

countdown(1790427600, 1790424000)   # {expired: false, seconds: 3600, text: "1:00:00"}
countdown(1000754, 1000000)         # {expired: false, seconds: 754,  text: "12:34"}
countdown(1790427600, 1790427600)   # {expired: true,  seconds: 0,    text: "0:00"}

For example

  • countdown(1,790,427,600, 1,790,424,000) → expired false, seconds 3,600, text 1:00:00 a fresh one-hour token reads 1:00:00
  • countdown(1,790,427,599, 1,790,424,000) → expired false, seconds 3,599, text 59:59 one second under an hour drops to minutes and seconds
  • countdown(1,000,754, 1,000,000) → expired false, seconds 754, text 12:34 a Retry-After of 754 seconds

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 countdown(until: number, now: number): Countdown
untilintthe moment counted down to, in Unix seconds (a token's exp, a lockout's end)
nowintthe current time in Unix seconds, read by the caller
returnsCountdownthe seconds left (0 once reached), whether it has been reached, and the text to show

The type it declares, generated into your project

/** What is left of a wait, for a timer on screen. */
export interface Countdown {
  /** true from the second until is reached onwards */
  readonly expired: boolean;
  /** seconds left, 0 or more */
  readonly seconds: number;
  /** m:ss under an hour, h:mm:ss from an hour up; 0:00 once expired */
  readonly text: string;
}

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

import { countdown } from "#fune/time.countdown@^1";
impl/typescript.ts · 21 lines · open · raw
import { type Countdown } from "./time_countdown_types.ts";

function pad2(n: number): string {
  return n < 10 ? "0" + n : String(n);
}

/**
 * The time left until `until`, as a number and as a timer's text. A moment is
 * reached at `until` itself, as a JWT's exp is (RFC 7519 4.1.4: "on or
 * after"), so `now === until` is expired with nothing left.
 */
export function countdown(until: number, now: number): Countdown {
  if (typeof until !== "number" || !Number.isSafeInteger(until)) throw new RangeError("until must be a whole number of seconds");
  if (typeof now !== "number" || !Number.isSafeInteger(now)) throw new RangeError("now must be a whole number of seconds");
  const seconds = until > now ? until - now : 0;
  const h = Math.floor(seconds / 3600);
  const m = Math.floor((seconds % 3600) / 60);
  const s = seconds % 60;
  const text = h > 0 ? `${h}:${pad2(m)}:${pad2(s)}` : `${m}:${pad2(s)}`;
  return { expired: seconds === 0, seconds, text };
}

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.countdown
Download for TypeScript time.countdown-1.0.0-typescript.fune · 6,689 bytes sha256 1bc6ee087f2155db4c340a5b1602cbe097839fe795bc63fd771e677db6b185d5

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

The whole function, every language, is one file too: time.countdown-1.0.0.fune, 8,884 bytes, sha256 5f79273496584521bbb68fa26b0ddacef830b3504a91209b74ff3a0c133ebbac. 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.countdown

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

// fune: after time.countdown

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.countdown --steps.

// fune: step time.countdown 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
a fresh one-hour token reads 1:00:00 1,790,427,600, 1,790,424,000 → expired false, seconds 3,600, text 1:00:00
one second under an hour drops to minutes and seconds 1,790,427,599, 1,790,424,000 → expired false, seconds 3,599, text 59:59
a Retry-After of 754 seconds 1,000,754, 1,000,000 → expired false, seconds 754, text 12:34
exactly one minute 60, 0 → expired false, seconds 60, text 1:00
under a minute keeps a leading 0: 59, 0 → expired false, seconds 59, text 0:59
the last second 1,790,427,600, 1,790,427,599 → expired false, seconds 1, text 0:01
reached at until itself, as a JWT exp is 1,790,427,600, 1,790,427,600 → expired true, seconds 0, text 0:00
long past is 0, never negative 100, 5,000 → expired true, seconds 0, text 0:00
hours are not wrapped at a day: 25h 1m 1s 90,061, 0 → expired false, seconds 90,061, text 25:01:01
minutes and seconds under ten are padded after an hour 3,661, 0 → expired false, seconds 3,661, text 1:01:01
Show the other 3 tests
CaseArgumentsExpected
times before 1970 count the same way -10, -70 → expired false, seconds 60, text 1:00
a fractional until is refused 1.5, 0 → error: until must be a whole number of seconds
a fractional now is refused (Date.now()/1000 not floored) 100, 1,790,424,000.25 → error: now must be a whole number of seconds

More from the author

Use it for an access token's remaining life (`until` is the token's `exp`), for how long a locked-out login must wait (`until` is now plus Retry-After), or for a cookie's `Max-Age` (`seconds`). The caller reads the clock and passes `now`, so the function stays pure and a page can call it once a second to tick.

**Reached means expired.** The moment is reached at `until` itself, not one second later: RFC 7519 section 4.1.4 says a token MUST NOT be accepted "on or after" its `exp`. So `now == until` is `expired: true` with `seconds: 0`, and `expired` is exactly `seconds == 0`. A moment already past is also 0, never negative, so a timer never shows "-0:05".

**Text.** `m:ss` under an hour (`0:59`, `12:34`), `h:mm:ss` from an hour up (`1:00:00`). Hours are not wrapped at 24, because a wait is a length of time and not a time of day: 25 hours and a bit is `25:01:01`.

**Whole seconds.** Both arguments are whole Unix seconds, as JWT `exp` and `time.iso-to-unix` give them. A fraction (JavaScript's `Date.now() / 1000` not floored) is refused: "until must be a whole number of seconds" or "now must be a whole number of seconds".

Files

PathBytes
README.md1,496
impl/python.py905
impl/rust.rs1,183
impl/typescript.ts968
vectors.json1,809