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:00countdown(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 secondscountdown(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
| until | int | the moment counted down to, in Unix seconds (a token's exp, a lockout's end) |
| now | int | the current time in Unix seconds, read by the caller |
| returns | Countdown | the 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";
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
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 1,496 |
| impl/python.py | 905 |
| impl/rust.rs | 1,183 |
| impl/typescript.ts | 968 |
| vectors.json | 1,809 |