Skip to content

5. Language and relative time

Every list screen shows when something happened: "3 minutes ago", "yesterday", "in 2 days". Hand-rolled, that text always breaks in the same places — "1 minutes ago", "0 seconds ago", "60 minutes ago" — and each screen breaks it its own way.

The core solves it with relative_time, which speaks the same language as the rest of the app: the same Locale you already hand to translate. 🌍

Locale and translate

Locale is input context, like Theme: the view reads it, it never becomes a node in the tree. translate (aliased t) looks the key up in the language's table and interpolates with str.format:

from tempest_core import Locale, translate

texts = {
    "pt": {"hello": "Olá, {name}"},
    "en": {"hello": "Hello, {name}"},
}

print(translate("hello", Locale(language="pt"), texts, name="Ana"))
print(translate("hello", Locale(language="en"), texts, name="Ana"))
print(translate("bye", Locale(language="en"), texts))
Olá, Ana
Hello, Ana
bye

A missing key returns the key itself — the screen degrades to the developer's text instead of breaking.

relative_time: one delta, one phrase

relative_time takes a delta in seconds (target - now): negative is the past, positive the future.

from tempest_core import Locale, relative_time

pt = Locale(language="pt", region="BR")
en = Locale(language="en")

for delta in (-1, -45, -90, -3_600, -86_400, 172_800):
    print(f"{delta:>8} | {relative_time(delta, pt):<14} | {relative_time(delta, en)}")
      -1 | agora          | now
     -45 | há 45 segundos | 45 seconds ago
     -90 | há 2 minutos   | 2 minutes ago
   -3600 | há 1 hora      | 1 hour ago
  -86400 | ontem          | yesterday
  172800 | em 2 dias      | in 2 days

Without locale, the default Locale() applies, which is Portuguese: relative_time(-45) returns "há 45 segundos".

Why seconds, not datetime?

The core is pure and deterministic: it never reads a clock. "Now" is the app's decision — in the browser it is the client's clock, on desktop another one. Taking the delta keeps the function testable without freezing time.

With datetime, the delta is a subtraction:

from datetime import datetime, timezone

from tempest_core import Locale, relative_time

sent_at = datetime(2026, 9, 27, 14, 0, tzinfo=timezone.utc)
now = datetime(2026, 9, 27, 14, 3, 10, tzinfo=timezone.utc)

print(relative_time((sent_at - now).total_seconds(), Locale(language="en")))
3 minutes ago

The rules, written down

  1. "Now" window: under 30 seconds either way — zero included — is "now" / "agora". Never "0 seconds ago".
  2. Unit: seconds below 1 minute, minutes below 1 hour, hours below 1 day, days below 1 week, weeks below 30 days, months below 365 days, and years from there on.
  3. Rounding: half up, like JavaScript's Math.round. So 89 s is "1 minute" and 90 s is "2 minutes".
  4. Rounded up to the limit, move up a unit: 59.5 s becomes "1 minute", never "60 seconds"; 23.5 h becomes "yesterday", never "24 hours".
  5. Singular only when the amount is exactly 1.

A month is 30 days, a year 365

The math knows no calendar, on purpose: the result depends only on the delta, and comes out identical on tempestweb and tempestroid. For the same reason, "yesterday" means "one 24-hour day ago", not "the previous calendar day".

nan and infinity are rejected

relative_time(float("nan")) raises ValueError. A delta that is not a number is bad data arriving from outside, and "inf years ago" on screen would only hide it.

Another language, or other wording

The phrases come from RELATIVE_TIME_TRANSLATIONS and go through the same translate. To add a language or change a phrase, pass translations with the keys you want:

from tempest_core import Locale, relative_time

es = {"es": {"relative_time.past.minute.other": "hace {n} minutos"}}

print(relative_time(-180, Locale(language="es"), es))
print(relative_time(-3_600, Locale(language="es"), es))
hace 3 minutos
1 hour ago

A key your table leaves out falls back to English (RELATIVE_TIME_FALLBACK_LANGUAGE), one by one — the raw key never leaks. A language with no table at all also falls back to English.

The keys

relative_time.now, and relative_time.<past|future>.<unit>.<one|other>, with <unit> in second, minute, hour, day, week, month, year. {n} is the already-rounded amount.

Recap

  • relative_time(delta, locale) takes target - now in seconds and answers in the language of the app's Locale.
  • Under 30 s is "now"; rounding is half up; rounded up to the limit, it moves up a unit.
  • translations adds a language or changes a phrase; whatever is missing falls back to English.
  • nan/infinity raise ValueError.