Skip to content

Per-user duration display format preference - #588

Merged
KucharczykL merged 15 commits into
mainfrom
claude/issue-486-duration-format
Jul 29, 2026
Merged

Per-user duration display format preference#588
KucharczykL merged 15 commits into
mainfrom
claude/issue-486-duration-format

Conversation

@KucharczykL

Copy link
Copy Markdown
Owner

Closes #486. Builds on #583.

What changed

Six ad-hoc format_duration() pattern strings had drifted apart across the session list, game pages, the navbar, and statistics — the same value rendered 3.2, 3 hours, 3 h 12 m, and 3h 12m depending on where you looked. All of it now goes through one preference.

Four profiles, selectable in /settings:

Value 45 minutes 1 h 12 m 83 h 12 m 1234 h
decimal_hours (default) 0.8 h 1.2 h 83.2 h 1,234.0 h
hours_minutes 45 m 1 h 12 m 83 h 12 m 1,234 h 00 m
whole_hours 1 hour 1 hour 83 hours 1,234 hours
adaptive 45 m 1 h 12 m 3 d 11 h 7 w 2 d

The default matches what the session list and game playtime already showed, so an untouched install looks unchanged.

Rules worth knowing

Round, never truncate, at each profile’s own resolution — truncation renders 59 minutes as "0 hours", which is a lie the popover should not have to repair.

Round the total once, then decompose. Rounding a component after the split renders 1 h 59 m 45 s as 2 h 60 m. The same rule makes adaptive re-pick its unit when rounding carries: 6 d 23 h 40 m is 1 w 0 d, never 6 d 24 h.

adaptive and hours_minutes are not interchangeable below a day. At 23 h 59 m 45 s they render 1 d 00 h and 24 h 00 m. That is why popover dedup compares rendered strings and never profile identity.

Locale owns grouping. USE_THOUSAND_SEPARATOR is unset project-wide and turning it on globally would retroactively regroup every price, so force_grouping is passed per call and override() is scoped to that call — the formatting locale is deliberately never activated request-wide.

The popover

Every duration offers the same value under the other profiles on hover, deduplicated so no line repeats another. The visible text is aria-hidden and a sibling sr-only span carries the value in words ("1 hour 12 minutes") — screen readers read 1.2 h as "one point two h". The panel drops aria-describedby for the same reason: it restates what the sr-only text already said.

Three surfaces could not take the popover in its plain form, all for the same reason — a popover trigger is a <button> and may not nest inside an <a>:

  • Game detail stats row — solved. Duration splits into DurationText and DurationAlternates, and the existing stat popover renders the alternates in its own panel. One popover, full information.
  • Navbar totals — solved via Popover(preface=…), in its own commit (d528381) so it can be reverted alone. The value stays a link and the popover moves to a sibling info glyph. Visual call is yours; see Navbar playtime totals show no alternate duration formats #587 for the alternatives.
  • Stats playtime cells that are themselves links — render as DurationText. The standalone headline stats carry the full popover.

Layering

Formatting leaves the model layer: Session.duration_formatted(), duration_formatted_with_mark(), and Game.playtime_formatted() are gone, because a model method has no request and cannot resolve a per-user preference. Session.__str__ keeps a fixed decimal-hours rendering — it is a debug string. SessionQuerySet.total_duration_formatted() and calculated_duration_formatted() had no callers at all and are deleted rather than ported.

compute_stats() stays pure computation and now returns timedelta; stats_content formats. total_hours becomes a timedelta and the two per-game stats become timedelta | None — the old integer 0 fallback is gone.

Two places were computation wearing a formatter’s clothes and are now plain arithmetic on the raw value:

  • the game-detail session average parsed a formatted string back into a float
  • the play-event note default seeds text the user then saves, so it gets a fixed rendering — a display preference must not leak into stored data

format_duration() and the %H/%m mini-language are deleted outright.

Untouched

Stored values, GeneratedField computation, the API, filtering, sorting, the duration_manual form input, and the playtime_hours / duration_total_hours filter facets. The preference is display only.

Verification

make check green: lint, format-check, mypy, ts-check, vitest, and 2683 pytest tests including the full e2e suite. git diff --check clean.

New coverage includes a table test driven from the design’s rendering rules (every profile against zero, sub-minute, sub-hour, hour-plus, 24-hour-plus, week-plus, year-plus, plus the carry boundaries), locale tests asserting the cs NBSP separator explicitly, popover-id uniqueness on a list with two equal-duration rows, and four e2e tests covering the preference change, the popover contents, and the screen-reader text.

Still worth doing by hand: an Orca pass over a session list, to confirm each duration is announced once and in words.

Docs

docs/configuration.md gains a Duration format entry; the changelog gains an Unreleased section. The spec and plan for this work are deleted here — the rendering rules now live in tests/test_duration_presentation.py, which the file names as the specification.

🤖 Generated with Claude Code

KucharczykL and others added 11 commits July 29, 2026 13:15
Decimal hours, hours and minutes, whole hours, and an adaptive ladder
from minutes through years. Every profile rounds the total once and then
decomposes, which is what keeps 1 h 59 m 45 s from rendering as
2 h 60 m and 6 d 23 h 40 m from rendering as 6 d 24 h.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
force_grouping is passed per call because USE_THOUSAND_SEPARATOR is
unset project-wide, and turning it on globally would retroactively
regroup every price on the site. The locale override is scoped to the
number call so the formatting locale never becomes the application's
language.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dedup compares rendered strings rather than profile identity: adaptive
and hours_minutes agree below a day but diverge at the carry boundary,
so 23 h 59 m 45 s legitimately has three distinct alternates.

The spoken form is profile-independent and uses hours and minutes only.
Screen readers read '1.2 h' as 'one point two h', and no listener is
helped by '375 days'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DURATION_FORMAT is a user-scoped SELECT resolving through the same chain
as DATETIME_FORMAT, defaulting to decimal_hours so an untouched install
renders exactly as it did. No migration: unmapped keys fall through
UserPreferences.extra_preferences.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The id_scope parameter is required: 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Duration splits into DurationText (the value plus its spoken form) and
DurationAlternates (the other profiles), so a surface that already owns
a popover can show both without nesting one popover inside another. The
game-detail stats row does exactly that.

NavbarPlaytime is a node tree rather than an HTML f-string, which is
what lets a duration node's Media survive there at all. The navbar
totals still render as text: each already links to its filtered session
list, and a popover trigger is a <button>, which may not nest inside a
link.

The game-detail session average now divides the raw timedelta instead of
parsing a formatted string back into a float — that was computation
wearing a formatter's clothes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
compute_stats() is pure computation with no HTTP, so it now returns
timedelta values and stats_content formats them. total_hours becomes a
timedelta and the two per-game stats become timedelta | None — the old
integer 0 fallback is gone.

Playtime cells that are themselves links render as DurationText: the
popover trigger is a <button> and may not nest inside an <a>. The
standalone headline stats carry the full popover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Duration gains an opt-in link parameter that puts the value in a link
and the popover on a sibling info glyph, using Popover's preface slot.
That is the only shape where a duration can both navigate and offer its
alternates, since a popover trigger is a <button> and may not nest
inside an <a>.

Revert this commit alone to return the navbar to plain DurationText.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Formatting leaves the model layer entirely: a model method has no
request and so cannot resolve a per-user preference. Session.__str__
keeps a fixed decimal-hours rendering because it is a debug string, and
the two SessionQuerySet formatters had no callers at all.

The play-event note default also gets a fixed rendering rather than the
viewer's: it seeds text the user then saves, so a display preference
must not leak into stored data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The spec and plan go with the work that consumed them: the rendering
rules now live in tests/test_duration_presentation.py, the rationale in
the code that needed it, and the user-facing half in the configuration
reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

KucharczykL and others added 4 commits July 29, 2026 14:10
The hand-rolled label/value rows picked their own muted color and busted
contrast. TooltipDefinitionList is the canonical term/description
treatment for informative tooltips — the same one the settings source
badges use — and a definition list is what a profile-name/value pair
actually is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A tooltip panel is a DOM descendant of whatever it annotates, so one
mounted inside a data table or a TruncatedText host inherited
font-condensed and rendered in a different typeface from every other
tooltip on the site. Pre-existing for the name-cell tooltips; the
duration popovers made it visible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

Measured on a running server before and after: table terms were
IBM Plex Sans 400/12px against the navbar's 500/12px, and now match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Duration's link mode now follows TruncatedText's reveal pattern: the
info glyph is hidden on pointer devices, where hovering the total opens
the panel, and appears only under [@media(hover:none)].

That makes the link mode good enough to use everywhere a duration is
also a link, so the stats per-month and per-platform cells stop falling
back to plain text and offer their alternates like every other duration.
DurationText is now only used where a duration is neither standalone nor
linked.

Verified on a running server: 27 duration popovers on the stats page,
all ids unique, hover opens and closes the panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@KucharczykL
KucharczykL merged commit b80a6b4 into main Jul 29, 2026
4 of 5 checks passed
@KucharczykL
KucharczykL deleted the claude/issue-486-duration-format branch July 29, 2026 12:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a per-user duration display format preference

1 participant