feat: times in the reader's own zone; the audit log shows exact moments (milestone 490)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m0s
CI / unit (push) Failing after 1m23s
CI / publish (push) Skipped

- users.time_zone (0034). An account without one takes the browser's zone
  on its first page (POST /account/time-zone/adopt, never over a choice made
  by hand); the session follows the account on every request.
- /account: who you are and the zone every page writes its times in,
  reached from your name in the sidebar.
- formatting.when: 'Today 06:58', 'Yesterday 23:41', 'Tue 6 Oct 14:12',
  '6 Oct 2025 14:12', 24-hour, in the reader's zone; |ago's hover is the
  exact local moment instead of UTC; tz_label() for column headers.
- Audit log: When (EDT) with exact times, one detail fact a line, on classes.
- tzdata in the image, and the airgapped smoke checks a zone resolves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 07:56:27 -04:00
co-authored by Claude Opus 5.5
parent 955450d33f
commit 6a81cf4aac
19 changed files with 425 additions and 25 deletions
+68 -7
View File
@@ -2,13 +2,17 @@
How Steward writes a size, a rate, a span of time and a moment, and how it
grades a reading against Settings → Thresholds — once, for every page and
plugin (#5093). Registered as Jinja filters (|bytes, |rate, |duration, |ago)
and the status_class global in app.py; Python callers import them from here.
plugin (#5093). Registered as Jinja filters (|bytes, |rate, |duration, |ago,
|when) and the status_class global in app.py; Python callers import them from
here. A moment is written in the reader's own zone, on a 24-hour clock
(milestone 490); app.py hands the filters the zone saved on their account.
"""
from __future__ import annotations
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone, tzinfo
from functools import lru_cache
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones
from markupsafe import Markup, escape
@@ -65,9 +69,43 @@ def seconds_since(when: datetime | None, now: datetime | None = None) -> float |
return ((now or datetime.now(timezone.utc)) - _utc(when)).total_seconds()
def ago(when: datetime | None, now: datetime | None = None) -> Markup:
def zone(name: str | None) -> tzinfo:
"""The zone a reader asked for by its IANA name, or UTC when none was
given or it is not one this machine knows."""
if not name:
return timezone.utc
try:
return ZoneInfo(name)
except (ZoneInfoNotFoundError, ValueError):
return timezone.utc
@lru_cache(maxsize=1)
def zone_names() -> tuple[str, ...]:
"""Every IANA zone this machine knows, sorted: the Account page's choices,
and what a saved zone is checked against."""
return tuple(sorted(n for n in available_timezones() if "/" in n or n == "UTC"))
def tz_label(tz: tzinfo | None = None, now: datetime | None = None) -> str:
"""The zone's short name as it stands now ('EDT', 'CET', 'UTC'), for a
column header that says which zone its times are in."""
return (now or datetime.now(timezone.utc)).astimezone(tz or timezone.utc).tzname() or "UTC"
def _exact(local: datetime) -> str:
"""'Thu 8 Oct 2026 06:58:12 EDT': the full moment, for a hover."""
return f"{local:%a} {local.day} {local:%b %Y %H:%M:%S} {local.tzname()}"
def _time(when: datetime, local: datetime, text: str) -> Markup:
return Markup('<time datetime="{}" title="{}">{}</time>').format(
when.strftime("%Y-%m-%dT%H:%M:%SZ"), _exact(local), escape(text))
def ago(when: datetime | None, now: datetime | None = None, tz: tzinfo | None = None) -> Markup:
"""'3m ago' (or 'in 3m' for a future moment) in a <time> element whose
title is the absolute UTC time, so a hover gives the exact moment."""
title is the exact moment in the reader's zone, so a hover gives it."""
if when is None:
return Markup(NONE)
when = _utc(when)
@@ -78,8 +116,31 @@ def ago(when: datetime | None, now: datetime | None = None) -> Markup:
text = f"{human_duration(secs)} ago"
else:
text = f"in {human_duration(-secs)}"
return Markup('<time datetime="{}" title="{}">{}</time>').format(
when.strftime("%Y-%m-%dT%H:%M:%SZ"), when.strftime("%Y-%m-%d %H:%M:%S UTC"), escape(text))
return _time(when, when.astimezone(tz or timezone.utc), text)
def when(moment: datetime | None, tz: tzinfo | None = None, now: datetime | None = None) -> Markup:
"""The exact moment in the reader's zone on a 24-hour clock, as short as
it can be read: 'Today 06:58', 'Yesterday 23:41', 'Tomorrow 02:00',
'Tue 6 Oct 14:12' this year, '6 Oct 2025 14:12' before it."""
if moment is None:
return Markup(NONE)
tz = tz or timezone.utc
moment = _utc(moment)
local = moment.astimezone(tz)
today = (now or datetime.now(timezone.utc)).astimezone(tz).date()
day, hm = local.date(), f"{local:%H:%M}"
if day == today:
text = f"Today {hm}"
elif day == today - timedelta(days=1):
text = f"Yesterday {hm}"
elif day == today + timedelta(days=1):
text = f"Tomorrow {hm}"
elif day.year == today.year:
text = f"{local:%a} {day.day} {local:%b} {hm}"
else:
text = f"{day.day} {local:%b} {day.year} {hm}"
return _time(moment, local, text)
def status_class(value: Any, kind: str, thresholds: dict) -> str: