Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 6 additions & 0 deletions common/components/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@
)
from common.components.domain import (
BrowserTimeZoneInput,
Duration,
DurationAlternates,
DurationText,
GameLink,
GameStatus,
GameStatusSelector,
Expand Down Expand Up @@ -273,6 +276,9 @@
"DropdownMenuPanel",
"DropdownPostItem",
"DropdownSubmenu",
"Duration",
"DurationAlternates",
"DurationText",
"Element",
"ExternalScript",
"FilterBuilder",
Expand Down
95 changes: 95 additions & 0 deletions common/components/domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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 ``<button>`` and may
not nest inside an ``<a>``, so this is the only way a duration can both
navigate and offer its alternates — at the cost of one extra glyph.
"""
from common.components.primitives import Popover

text = DurationText(duration, presentation, manual=manual)
if link is None:
return Popover(
popover_content=DurationAlternates(duration, presentation),
children=[text],
wrapped_classes="tabular-nums underline decoration-dotted",
id=f"duration-{id_scope}",
describedby=False,
selectable_text=True,
)
return Popover(
popover_content=DurationAlternates(duration, presentation),
preface=A(href=link, class_=link_class)[text],
children=[Icon("info", size="size-[1.1em]")],
# Same treatment as TruncatedText's reveal button: the glyph exists for
# devices that cannot hover, where the host's own hover-to-open is
# unavailable. On a pointer device it stays out of the way and hovering
# the total opens the panel.
wrapped_classes=(
"hidden [@media(hover:none)]:inline-flex items-center "
"text-subtle hover:text-heading hover:cursor-pointer"
),
trigger_label="Other duration formats",
id=f"duration-{id_scope}",
describedby=False,
)


BROWSER_TIME_ZONE_FIELD = "browser_time_zone"


Expand Down
34 changes: 23 additions & 11 deletions common/components/primitives.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,9 +259,14 @@ def custom_element_builder(tag_name: str):
_PopOver = custom_element_builder("pop-over")
_TruncatedText = custom_element_builder("truncated-text")

# font-sans is deliberate, not redundant: the panel is a DOM descendant of
# whatever it annotates, so a tooltip mounted inside a data table or a
# TruncatedText host inherits font-condensed and renders in a different
# typeface from every other tooltip on the site. Stating the family here keeps
# a tooltip looking like a tooltip wherever it is mounted.
_TOOLTIP_PANEL_CLASS = (
f"z-10 inline-block text-type-body text-heading bg-brand-soft border "
f"border-brand/30 rounded-base shadow-xs {CONTENT_MAX_WIDTH_CLASS}"
f"z-10 inline-block font-sans text-type-body text-heading bg-brand-soft "
f"border border-brand/30 rounded-base shadow-xs {CONTENT_MAX_WIDTH_CLASS}"
)


Expand All @@ -274,7 +279,12 @@ def TooltipDefinitionList(
list_class = f"flex flex-col gap-2 {class_}".strip()
items = [
Div(definition.attributes)[
Dt(class_="text-type-micro text-body")[definition.term],
# font-medium on the term as well as the value: the term set no
# weight, so it inherited one from wherever the tooltip was
# mounted — 500 on an ordinary page but 400 inside a data table,
# which made the same tooltip look different per surface. Term and
# value are separated by size and color, not weight.
Dt(class_="text-type-micro text-body font-medium")[definition.term],
Dd(class_="font-medium")[definition.description],
]
for definition in definitions
Expand Down Expand Up @@ -325,6 +335,7 @@ def _popover_html(
trigger_disabled: bool = False,
preface: Node | str = "",
selectable_text: bool = False,
describedby: bool = True,
) -> Node:
"""Generate popover HTML. Single source of truth for popover structure.

Expand Down Expand Up @@ -391,19 +402,18 @@ def _popover_html(
Button(control_attributes)[*trigger_children]
]
else:
control_attributes.append(("aria-describedby", id))
if describedby:
control_attributes.append(("aria-describedby", id))
if trigger_label:
control_attributes.append(("aria-label", trigger_label))
control_attributes.append(("data-pop-over-trigger", ""))
trigger = Button(control_attributes)[*trigger_children]
else:
trigger = Span(
[
("data-pop-over-trigger", ""),
("aria-describedby", id),
("class", wrapped_classes),
]
)[*trigger_children]
span_attributes: list[HTMLAttribute] = [("data-pop-over-trigger", "")]
if describedby:
span_attributes.append(("aria-describedby", id))
span_attributes.append(("class", wrapped_classes))
trigger = Span(span_attributes)[*trigger_children]

# No positioning class — the element sets `position: fixed` + coords on show
# and clears them on hide; the `hidden` attribute owns the closed state.
Expand Down Expand Up @@ -443,6 +453,7 @@ def Popover(
trigger_disabled: bool = False,
preface: Node | str = "",
selectable_text: bool = False,
describedby: bool = True,
) -> Node:
children = as_children(children)
if not wrapped_content and not children:
Expand All @@ -462,6 +473,7 @@ def Popover(
trigger_disabled=trigger_disabled,
preface=preface,
selectable_text=selectable_text,
describedby=describedby,
)


Expand Down
Loading
Loading