Functional Weave
Code in Rust

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.

pub fn countdown(until: i64, now: i64) -> 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.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Countdown {
    /// true from the second until is reached onwards
    pub expired: bool,
    /// seconds left, 0 or more
    pub seconds: i64,
    /// m:ss under an hour, h:mm:ss from an hour up; 0:00 once expired
    pub text: String,
}

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

fune!(time.countdown@^1);  // then call countdown(…)
impl/rust.rs · 34 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one

/// 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), so
/// `now == until` is expired with nothing left.
pub fn countdown(until: i64, now: i64) -> Countdown {
    let seconds = if until > now { until - now } else { 0 };
    let h = seconds / 3600;
    let m = (seconds % 3600) / 60;
    let s = seconds % 60;
    let text = if h > 0 { format!("{}:{:02}:{:02}", h, m, s) } else { format!("{}:{:02}", m, s) };
    Countdown { expired: seconds == 0, seconds, text }
}

pub fn countdown_to_value(c: &Countdown) -> Value {
    Value::obj(vec![
        ("expired", Value::Bool(c.expired)),
        ("seconds", Value::Int(c.seconds)),
        ("text", Value::str(&c.text)),
    ])
}

fn whole(value: &Value, message: &str) -> i64 {
    match value {
        Value::Int(i) => *i,
        _ => panic!("{}", message),
    }
}

pub fn fune_vector(args: &[Value]) -> Value {
    let until = whole(&args[0], "until must be a whole number of seconds");
    let now = whole(&args[1], "now must be a whole number of seconds");
    countdown_to_value(&countdown(until, now))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add time.countdown
Download for Rust time.countdown-1.0.0-rust.fune · 6,907 bytes sha256 c6a045a0c227d602d26241577ae8eb804f5e7cd2d2c2cf32f5b9683877238b7f

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

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