Skip to content

feat(today): draw the daily scores as bars, not as a line - #2011

Merged
ryanbr merged 1 commit into
mainfrom
feat/vital-detail-bars
Sep 9, 2026
Merged

ryanbr merged 1 commit into
mainfrom
feat/vital-detail-bars

Conversation

@ryanbr

@ryanbr ryanbr commented Sep 8, 2026

Copy link
Copy Markdown
Owner

feat(today): draw the daily scores as bars, not as a line

The Effort trend still read as noise after anchoring, spacing and a continuous
line, and the chart type was the reason. A line asserts continuity between
readings: it says the value travelled from one to the next. For a daily score that
is false, and on the reported series it is most of what looks chaotic. That Effort
swings 19.8 points a day on a 0..42 range, so the line spends the whole chart
climbing to and diving from values that never existed.

Bars claim nothing between slots. A zero day is a short bar beside a tall one
rather than a plunge and a climb.

One slot per DAY rather than per reading, which positions the bars by date with no
spacing machinery at all: a day with no reading is an empty slot of the right
width. It carries NaN, which BarChart already treats as nothing to draw, and a
real zero stays a real zero.

Only the daily SCORES: Effort, Rest and Charge. Resting HR, HRV, skin temperature,
respiratory rate and blood oxygen are LEVELS that do vary continuously between
measurements, so a line is the honest shape for them, and drawing them as bars
would be the mirror of the mistake this corrects. Fitness Age and Vitality are too
sparse to fill a bar chart.

BarChart is the existing primitive, already used by the Sleep metric cards and
Trends. Nothing new was built.

Why this rather than more line tweaks

Anchoring, date spacing and removing the breaks each helped, and none of them addressed the cause. The
reporter said "up and down" of the same series three times, and they were right every time: the line was
drawing travel that never happened. Changing the primitive is the fix; restyling the line was not.

Worth noting the earlier passes were not wasted, they were just aimed at the wrong thing. Date spacing in
particular is subsumed here, since one slot per day IS date positioning for a bar chart.

Tests

+7. The allow-list is pinned in both directions, including that levels stay lines. The densifier is pinned
on the property that matters: every day in the window gets a slot, a missing day is NaN rather than a zero,
a real zero survives as a zero beside it, a complete stretch is unchanged, and an unparseable day refuses
rather than shuffling readings into the wrong slots.

676 classes / 5719 tests, 0 failures. Android-only; the iOS detail chart is Swift Charts and separate.

@ryanbr
ryanbr force-pushed the feat/vital-detail-bars branch from e3617ae to a5cfb18 Compare September 9, 2026 00:16
The Effort trend still read as noise after anchoring, spacing and a continuous
line, and the chart type was the reason. A line asserts continuity between
readings: it says the value travelled from one to the next. For a daily score that
is false, and on the reported series it is most of what looks chaotic. That Effort
swings 19.8 points a day on a 0..42 range, so the line spends the whole chart
climbing to and diving from values that never existed.

Bars claim nothing between slots. A zero day is a short bar beside a tall one
rather than a plunge and a climb.

One slot per DAY rather than per reading, which positions the bars by date with no
spacing machinery at all: a day with no reading is an empty slot of the right
width. It carries NaN, which BarChart already treats as nothing to draw, and a
real zero stays a real zero.

Only the daily SCORES: Effort, Rest and Charge. Resting HR, HRV, skin temperature,
respiratory rate and blood oxygen are LEVELS that do vary continuously between
measurements, so a line is the honest shape for them, and drawing them as bars
would be the mirror of the mistake this corrects. Fitness Age and Vitality are too
sparse to fill a bar chart.

Giving missing days their own slots made one thing reachable that was not before:
BarChart flattens a non-finite value to 0.0 so it draws nothing, which is right
for the geometry and wrong for the read-out, so an empty slot would have answered
"0.0 %" on tap for a day that was never measured. A slot is now labelled only when
its raw value is finite. The number itself still comes from the cleaned value, so
no existing caller's label changes: cleaning also flattens NEGATIVES, and sleep
debt may pass them.

The densified slots and their labels are remembered, like the line path's derived
lists beside them: densifying rebuilds a slot per day and formats a label for each,
and on the ALL range that is hundreds of both on every recomposition.

BarChart is the existing primitive, already used by the Sleep metric cards and
Trends. Nothing new was built.
@ryanbr
ryanbr force-pushed the feat/vital-detail-bars branch from a5cfb18 to b6ba7ac Compare September 9, 2026 00:28
@ryanbr
ryanbr merged commit afb4616 into main Sep 9, 2026
3 checks passed
@ryanbr
ryanbr deleted the feat/vital-detail-bars branch September 9, 2026 01:03
ryanbr added a commit that referenced this pull request Sep 9, 2026
…h platforms

Reported: pick Line in Settings, tap a ring on Today, and the detail chart draws
bars anyway.

The cause is that this screen had never read the setting. It is honoured in
exactly one place, the Trends screen, and the detail chart simply always drew a
line, which happened to match Line and silently ignored Bar. #2011 then chose
the shape per METRIC here, forcing bars for the daily scores, and that turned a
dormant gap into a visible contradiction: the picker says Line and the chart
draws bars.

The inverse was broken the whole time and nobody reported it, because it fails
quietly in the direction people do not notice. A reader who picked Bar got lines
on this screen for every metric outside those three, while the trend chart for
the same metric drew bars.

So the setting decides, and the metric does not. Trends already applies the
preference to every metric, so a detail chart reached from a ring now agrees
with the trend chart for that metric rather than contradicting it.

iOS had the same gap and no equivalent of the override, since the earlier change
was Android only: MetricExplorerView never consulted the preference, so a chosen
Bar drew a line there too. TrendChart already takes showsBars, so this is one
rule expressed once per platform rather than two behaviours.

That earlier reasoning is not discarded, it moves to where it can be acted on. If a
line really does misrepresent a daily score, and it does assert travel between
readings that never happened, then the honest response is to change the DEFAULT
to bars, visibly, in the picker, where the setting and the chart agree.
Overriding a reader who chose otherwise is a different claim and should not be
made on their behalf. That default is left alone here, deliberately, because it
is global and would move Trends too.

The tests that pinned the per-metric rule are repinned rather than deleted: a
chosen Line draws lines including for the daily scores, a chosen Bar draws bars,
and the metric key no longer changes the answer for either.
ryanbr added a commit that referenced this pull request Sep 9, 2026
…rms (#2027)

Reported: pick Line in Settings, tap a ring on Today, and the detail chart draws
bars anyway.

The cause is that this screen had never read the setting. It is honoured in
exactly one place, the Trends screen, and the detail chart simply always drew a
line, which happened to match Line and silently ignored Bar. #2011 then chose
the shape per METRIC here, forcing bars for the daily scores, and that turned a
dormant gap into a visible contradiction: the picker says Line and the chart
draws bars.

The inverse was broken the whole time and nobody reported it, because it fails
quietly in the direction people do not notice. A reader who picked Bar got lines
on this screen for every metric outside those three, while the trend chart for
the same metric drew bars.

So the setting decides, and the metric does not. Trends already applies the
preference to every metric, so a detail chart reached from a ring now agrees
with the trend chart for that metric rather than contradicting it.

iOS had the same gap and no equivalent of the override, since the earlier change
was Android only: MetricExplorerView never consulted the preference, so a chosen
Bar drew a line there too. TrendChart already takes showsBars, so this is one
rule expressed once per platform rather than two behaviours.

That earlier reasoning is not discarded, it moves to where it can be acted on. If a
line really does misrepresent a daily score, and it does assert travel between
readings that never happened, then the honest response is to change the DEFAULT
to bars, visibly, in the picker, where the setting and the chart agree.
Overriding a reader who chose otherwise is a different claim and should not be
made on their behalf. That default is left alone here, deliberately, because it
is global and would move Trends too.

The tests that pinned the per-metric rule are repinned rather than deleted: a
chosen Line draws lines including for the daily scores, a chosen Bar draws bars,
and the metric key no longer changes the answer for either.
DX23876 added a commit to DX23876/noop that referenced this pull request Sep 16, 2026
Thirty real conflicts. The Android tree is dropped again per FORK_GUIDE; every
other hunk was resolved by asking which mechanism this fork actually runs, not
by preferring a side.

Taken from upstream:
  • Protocol: ryanbr#2017/ryanbr#2018/ryanbr#2020 — the 5/MG historical optical and raw-IMU
    layouts, and the v26 window's ABSOLUTE base code beside its deltas. Its
    migration lands as `v57-ppg-waveform-base-code` (upstream's v44), since this
    fork's sequence is nine ahead of theirs.
  • BLE diagnostics ryanbr#1998-ryanbr#2003: a bond end that says WHY, named BLE integers, a
    session-budgeted hex dump, and "we hold the link" only when the OS says so.
  • Battery: ryanbr#1951 stop asking a 5/MG for a reading it refuses; ryanbr#1959 poll
    0x2A19 for an unbonded one.
  • Scoring: ryanbr#1978/ryanbr#1982/ryanbr#1990 the RHR/HRV baseline gates, ryanbr#1977 the strain
    denominator's domain, RMSSD withheld on an over-counted night, ryanbr#1943 the
    resting-HR bin gate, the closed final HR/HRV window, HR-recovery requiring
    one CONTINUOUS effort, ryanbr#1984 nights bridged under minSleepMin, ryanbr#2015 motion
    re-folded only when that day's gravity moved, ryanbr#2016 a provisional Rest.
  • Charts ryanbr#2004/ryanbr#2005/ryanbr#2007/ryanbr#2009/ryanbr#2011 and ryanbr#1995's hero-ring detail route,
    ryanbr#1164's "Pending sync" caption under Rest.
  • Widgets: the heart-rate widget with its trace (ryanbr#1957/ryanbr#1964) and the widget
    cost counters.
  • Health: ryanbr#1503 — the Apple Health external UUID drops the strap id, so a
    re-pair no longer strands every prior record, plus the one-off sweep that
    reclaims the stranded ones; ryanbr#1205 GPS routes read back from Apple Health.
  • Storage: rolling retention for ppgWaveformSample, the skin-temp absolute
    backfill outside the 21-night window, per-table byte estimates.
  • Units split (body vs exercise distance), per-day wake overrides, the
    wind-down nudge pinned to its own day, and the German Sie→du conversion
    (29 entries, merged per LOCALE so the fork's own de edits stayed).

Kept on the fork's side, with the reason:
  • ryanbr#1005's in-memory `dayScanCache` stays out, again: the persisted
    `DayScanFingerprint` survives relaunch and its key folds the per-day weight
    and max HR upstream's does not.
  • The steps-motion cache stays PERSISTED for the same reason. Upstream's
    failed-read-vs-empty-day distinction IS adopted into it — on a persisted
    cache a stale zero from a read error would outlive the process that made it.
  • Every stream read in the day loop keeps `limit: Int.max`. All five are
    uncapped together; taking StreamReadCap for three of them would leave the
    loop half-capped, which is worse than either choice.
  • The coach engine. Upstream's K1-K15 layer is not adopted: its single 40-row
    `coachMessage` table, its tool-less pre-baked context and its own brief are
    behind what this fork already ships. Its ADDITIVE half is kept — the
    on-device voice input, the Siri "Ask Coach" intent, the brief scheduler and
    brief widget, and streaming for the OpenAI-compatible providers this fork's
    Anthropic-only StreamingToolClient does not cover. They are wired up in the
    next commit; `CoachBriefWidget` is deliberately not registered until then.
  • Liquid's `DomainTheme` labels, value-sampled Charge tint, `LiquidScoreGauge`
    and `TodayCardSurface`; classic Today's ring order and `LiveEffort.today`.
  • RootTabView's four-tab shell and `MoreCatalog`: Coach hangs on content here,
    not on a tab.

Two duplicate mechanisms collapsed rather than carried:
  • `ChargeBreakdownWiring` (upstream) and `ChargeBreakdownFormat` (fork) are the
    same extraction done twice. The fork's survives, Coupled now reads it, and
    upstream's TEST is kept — it is coverage this path never had.
  • Upstream's `CoachMessageStore` is dropped; `CoachConversationStore` already
    autosaves the whole multi-conversation history.

One defect found while resolving, fixed here: `LiveEffort.today` read HR with no
explicit limit, so `hrSamples`' 8000-row default truncated it — at ~18k rows a
day that covered about ten hours and the live Effort silently stopped climbing.
It failed safe (`effectiveEffort` takes the max), which is why it went unseen.

`Live5RestFrozenTests` is removed by upstream, which replaces it with
`Live5NoGravityRestChainTests`. `local_day_windows_oracle.json` moves out of
upstream's android path into `Tests/StrandAnalyticsTests/Oracles/` — NOT
`Fixtures/`, which this repo's .gitignore excludes everywhere.

Verified: StrandAnalytics 2233, WhoopStore 627, WhoopProtocol 700, StrandImport
293, StrandDesign 120, NoopLocalAccess 14, SemanticMemory 19, OuraProtocol 209
and PolarProtocol 25 all green; macOS and iOS both build; doc_comment_lint clean;
`i18n_audit --ci` byte-identical to its pre-merge output (it is red on main for
reasons that predate this, and the seven new widget literals were given all nine
locales so the gate did not get worse).

Six StrandTests were ALREADY failing on main before this merge and still are —
`CoachMyLogsAndZonesTests` (the tool census pin was not bumped when the Hevy
tools landed) and five in `RawPhysiologyUnionTests`. Every input to them —
CoachTools.swift, ToolConsent.swift, Repository.swift and both test files — is
byte-identical to main, so this merge neither caused nor fixed them. They went
unnoticed because app-build.yml last ran on 2026-08-25.

Analysis migration required: YES. The scoring changes above make stored derived
values stale; the recipe bump and its resumable migration are the next commit.
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.

1 participant