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))
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")))
The rules, written down¶
- "Now" window: under 30 seconds either way — zero included — is
"now"/"agora". Never "0 seconds ago". - 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.
- Rounding: half up, like JavaScript's
Math.round. So 89 s is "1 minute" and 90 s is "2 minutes". - 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".
- 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))
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)takestarget - nowin seconds and answers in the language of the app'sLocale.- Under 30 s is "now"; rounding is half up; rounded up to the limit, it moves up a unit.
translationsadds a language or changes a phrase; whatever is missing falls back to English.nan/infinity raiseValueError.