diff --git a/CHANGELOG.md b/CHANGELOG.md index f78ca073..67a16c4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +## Unreleased + +### New + +* Elapsed durations follow a per-user **Duration format** preference, replacing + six ad-hoc format strings that had drifted apart across the session list, game + pages, the navbar, and statistics. Four profiles: decimal hours (`1.2 h`, the + default and what most surfaces already showed), hours and minutes + (`1 h 12 m`), whole hours (`1 hour`), and adaptive units, which climbs from + minutes through years (`3 d 11 h`). Every profile rounds rather than + truncates, so 59 minutes no longer reads as "0 hours". Grouping and the + decimal separator follow the formatting locale. +* Every duration offers the same value under the other profiles on hover, so a + rounded rendering is never a dead end. Screen readers get the value in words + ("1 hour 12 minutes") instead of the abbreviation. + +### Changed + +* Finishing or resetting a session posts and reloads the page instead of + patching the row in place. The client-side row rebuild is gone, and with it a + TypeScript re-implementation of the server's session time-range rules. +* Resetting a session's start time confirms on its own page rather than in an + inline modal. + ## 1.8.1 / 2026-07-28 ### Fixed diff --git a/common/components/__init__.py b/common/components/__init__.py index eaa939a4..88f2b430 100644 --- a/common/components/__init__.py +++ b/common/components/__init__.py @@ -64,6 +64,9 @@ ) from common.components.domain import ( BrowserTimeZoneInput, + Duration, + DurationAlternates, + DurationText, GameLink, GameStatus, GameStatusSelector, @@ -273,6 +276,9 @@ "DropdownMenuPanel", "DropdownPostItem", "DropdownSubmenu", + "Duration", + "DurationAlternates", + "DurationText", "Element", "ExternalScript", "FilterBuilder", diff --git a/common/components/domain.py b/common/components/domain.py index 983230e2..57d8f047 100644 --- a/common/components/domain.py +++ b/common/components/domain.py @@ -23,6 +23,7 @@ from games.models import Game, Purchase, Session if TYPE_CHECKING: + from common.duration_presentation import DurationPresentation from common.returns import OriginUrl @@ -338,6 +339,100 @@ def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node: ) +def DurationText( + duration, + presentation: DurationPresentation, + *, + manual: bool = False, +) -> Node: + """The value itself: visible text plus its ``sr-only`` spoken form. + + Split out of :func:`Duration` so a surface that already owns a popover can + show a duration without nesting one popover inside another. + """ + visible = presentation.format(duration) + return Fragment( + Span(aria_hidden="true")[f"{visible}*" if manual else visible], + Span(class_="sr-only")[presentation.spoken(duration, manual=manual)], + ) + + +def DurationAlternates(duration, presentation: DurationPresentation) -> Node: + """The same value under the other profiles. + + Rendered with the shared informative-tooltip treatment rather than a local + one: profile name and value are a term/description pair, which is what a + definition list is for, and the colors come from the design system instead + of being chosen here. + """ + return TooltipDefinitionList( + [ + TooltipDefinition(label, rendering, [("class", "tabular-nums")]) + for label, rendering in presentation.alternates(duration) + ] + ) + + +def Duration( + duration, + presentation: DurationPresentation, + *, + id_scope: str, + manual: bool = False, + link: str | None = None, + link_class: str = "hover:underline tabular-nums", +) -> Node: + """One elapsed duration, with the same value under the other profiles on hover. + + ``id_scope`` is required and must be unique on the page. ``Popover`` derives + its DOM id by hashing its own content, so two rows showing the same duration + would collide — and ``Game.playtime`` defaults to zero, which makes that the + common case on a game list rather than an edge case. + + The visible text is ``aria-hidden`` and a sibling ``sr-only`` span carries + the value in words: screen readers read "1.2 h" as "one point two h". For + the same reason the panel drops ``aria-describedby`` — it restates what the + ``sr-only`` text already said. + + ``manual`` appends the "*" mark that flags a hand-entered session. It sits + inside the trigger with the value and is spoken as ", manual"; it qualifies + the value, not its formatting, so it never appears among the alternates. + + ``link`` makes the value a link to ``link`` and moves the popover onto a + separate info glyph beside it. A popover trigger is a ``