The boundaries are deliberately not at 60 seconds and 60 minutes. Look at what happens if they are.
# Thresholds at the exact units
# 89 seconds -> '1 minutes ago' wrong number and wrong grammar
# 59 minutes -> '59 minutes ago' nobody says that
# Thresholds where the phrasing changes
elapsed = now - then
if elapsed < 45: return 'just now'
if elapsed < 90: return 'a minute ago'
if elapsed < 45 * 60: return f'{round(elapsed / 60)} minutes ago'
round(89 / 60) # -> 1 which is why 90 is where the ladder turns
- The ladder is ordered, so each rung only needs an upper bound
Every branch is reached only when all the ones above it have failed, so 'under 45 minutes' already means 'and at least 90 seconds'. Writing both bounds on each rung would be twice the code saying the same thing, and twice the opportunity for two adjacent rungs to disagree about who owns the boundary.
- Singular phrases exist to avoid rounding to one
'a minute ago' and 'an hour ago' are not decoration — they cover exactly the span where the rounded number would be 1, so the plural branches never have to produce '1 minutes'. That is why the boundaries sit at 90 seconds and 90 minutes rather than at 60 of each.
- round(), not int()
int() truncates, so 119 seconds becomes '1 minutes' and 3.9 hours becomes '3 hours ago' — always early, always by up to a whole unit. Rounding puts the error either side and halves it. Note Python rounds halves to even, so round(2.5) is 2 — irrelevant at this precision and worth knowing before it surprises you somewhere it matters.
Work out the elapsed seconds once, then walk down a ladder of early returns. Each rung only needs to know its own upper bound, because everything above it has already been ruled out.
The order matters: seconds, then the singular minute, then plural minutes, then the singular hour, then plural hours, then yesterday, then days. Use round() rather than int() for every number you print.
Name the units — MINUTE = 60, HOUR = 60 * MINUTE, DAY = 24 * HOUR — so the thresholds read as 45 * MINUTE rather than 2700. The singular rungs are what stop the plural ones ever rounding to 1, so they cannot be dropped as duplicates.
MINUTE = 60 HOUR = 60 * MINUTE DAY = 24 * HOUR def relative_time(then, now): elapsed = now - then if elapsed < 0: raise ____('then is in the future') if elapsed < 45: return 'just now' if elapsed < 90: return 'a minute ago' if elapsed < 45 * MINUTE: return f'{____(elapsed / MINUTE)} minutes ago' if elapsed < 90 * MINUTE: return '____ hour ago' if elapsed < 22 * HOUR: return f'{round(elapsed / ____)} hours ago' if elapsed < 36 * HOUR: return '____' return f'{round(elapsed / DAY)} days ago'
MINUTE = 60
HOUR = 60 * MINUTE
DAY = 24 * HOUR
def relative_time(then, now):
elapsed = now - then
if elapsed < 0:
raise ValueError(
f"then is {-elapsed} seconds in the future"
)
if elapsed < 45:
return "just now"
if elapsed < 90:
return "a minute ago"
if elapsed < 45 * MINUTE:
return f"{round(elapsed / MINUTE)} minutes ago"
if elapsed < 90 * MINUTE:
return "an hour ago"
if elapsed < 22 * HOUR:
return f"{round(elapsed / HOUR)} hours ago"
if elapsed < 36 * HOUR:
return "yesterday"
return f"{round(elapsed / DAY)} days ago"
Each rung ends the function, so the next one starts from a clean assumption and the whole thing reads top to bottom as a table of thresholds. Written with elif it would be identical; written with nested ifs it would be six levels deep and the boundaries would stop being visible — which is the only thing anybody ever comes to this function to check.
45 * MINUTE says forty-five minutes; 2700 says nothing and has to be divided by 60 in the reader’s head. The constants also make the relationship between the rungs visible — that hours run to 22 and yesterday to 36 — which is the sort of thing somebody will want to tune after seeing it in a real interface.
Every case here is impossible against a function that calls time.time() itself — the same argument as fake-clock, and the same one-parameter fix. It also means the caller can format a whole page of timestamps against one consistent 'now', so two comments posted a second apart do not disagree about which is older.
Do not build this for a real product in more than one language: the plural rules alone defeat f-strings — Polish has three plural forms, Arabic six — and the phrasing is a translation job, not a formatting one. Babel’s format_timedelta does it properly, and on the web the <time> element with Intl.RelativeTimeFormat does it in the browser, which also solves the harder problem: a server-rendered 'just now' is a lie by the time anybody reads it. And for anything where the calendar matters — 'yesterday' when it is 00:30 — you need real dates and a timezone, not a difference in seconds.
- Add the future direction — 'in 3 minutes' — and see how much of the ladder can be shared between the two.
- Take datetimes instead of epoch seconds and work out what 'yesterday' should mean when the two moments are 40 minutes apart across midnight.
- Add a threshold beyond which it gives up and returns an absolute date, which is what every real implementation does eventually.