feat: times in the reader's own zone; the audit log shows exact moments (milestone 490)
- 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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user