Per-user duration display format preference - #588
Merged
Conversation
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>
|
Staging deployment: https://timetracker-staging-claude-issue-486-duration-form.fly.dev |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 rendered3.2,3 hours,3 h 12 m, and3h 12mdepending on where you looked. All of it now goes through one preference.Four profiles, selectable in
/settings:decimal_hours(default)0.8 h1.2 h83.2 h1,234.0 hhours_minutes45 m1 h 12 m83 h 12 m1,234 h 00 mwhole_hours1 hour1 hour83 hours1,234 hoursadaptive45 m1 h 12 m3 d 11 h7 w 2 dThe 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 makesadaptivere-pick its unit when rounding carries: 6 d 23 h 40 m is1 w 0 d, never6 d 24 h.adaptiveandhours_minutesare not interchangeable below a day. At 23 h 59 m 45 s they render1 d 00 hand24 h 00 m. That is why popover dedup compares rendered strings and never profile identity.Locale owns grouping.
USE_THOUSAND_SEPARATORis unset project-wide and turning it on globally would retroactively regroup every price, soforce_groupingis passed per call andoverride()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-hiddenand a siblingsr-onlyspan carries the value in words ("1 hour 12 minutes") — screen readers read1.2 has "one point two h". The panel dropsaria-describedbyfor the same reason: it restates what thesr-onlytext 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>:Durationsplits intoDurationTextandDurationAlternates, and the existing stat popover renders the alternates in its own panel. One popover, full information.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.DurationText. The standalone headline stats carry the full popover.Layering
Formatting leaves the model layer:
Session.duration_formatted(),duration_formatted_with_mark(), andGame.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()andcalculated_duration_formatted()had no callers at all and are deleted rather than ported.compute_stats()stays pure computation and now returnstimedelta;stats_contentformats.total_hoursbecomes atimedeltaand the two per-game stats becometimedelta | None— the old integer0fallback is gone.Two places were computation wearing a formatter’s clothes and are now plain arithmetic on the raw value:
format_duration()and the%H/%mmini-language are deleted outright.Untouched
Stored values,
GeneratedFieldcomputation, the API, filtering, sorting, theduration_manualform input, and theplaytime_hours/duration_total_hoursfilter facets. The preference is display only.Verification
make checkgreen: lint, format-check, mypy, ts-check, vitest, and 2683 pytest tests including the full e2e suite.git diff --checkclean.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
csNBSP 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.mdgains 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 intests/test_duration_presentation.py, which the file names as the specification.🤖 Generated with Claude Code