fix(today): don't flash a stale readiness before today's settles#121
Merged
Conversation
The readiness ring briefly showed a wrong score (e.g. 100) on load/refresh and then snapped to the true value. While today's overnight is still building, getToday surfaces the last settled night's readiness (showing_prior_overnight) so the rest of the screen has data; the orbit hero rendered that held-over value as the big number, so it flashed the prior night's score until today's real one landed. Add TodayData.settledReadinessScore, which withholds the number until today's overnight has actually settled (overnight_state == 'ready'), and route both the hero and the once-a-morning recovery story through it. Until then the hero shows its honest empty/loading centre instead of a bogus figure. Status-less synthetic payloads keep their existing behaviour, so the current Today widget tests are unaffected; new unit tests pin the gate.
📝 WalkthroughWalkthroughReadiness headlines and recovery content now use a settled score that excludes pending or missing overnight data and saturated composite values. The Today screen and compute pipeline include regression coverage for transient flashes, saturation, valid scores, and absent metrics. ChangesReadiness stabilization
Estimated code review effort: 3 (Moderate) | ~25 minutes Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
…bounce to 100 The READINESS ring could keep jumping to ~100 and back while today's overnight was already `ready`. A day stays recomputable ~48h and re-derives on every BLE drain, so today's readiness can change value between renders with the state `ready` throughout — the settled-state gate can't catch it. The composite maps its weighted robust-z to a score via a logistic, score = 100 / (1 + exp(-z)). robustZ nulls only on exact-zero MAD (fully quantised → honest blank). A near-degenerate baseline (duplicate-day pollution collapsing the window toward one value) has a tiny NON-zero MAD, so robustZ returns a huge z, the logistic saturates to ~100, and that bogus value is persisted and headlined until a cleaner re-derive snaps it back. Add headlineReadinessScalar / kReadinessZCap: abstain from the headline scalar when |composite z| exceeds a physiological cap (5 → score 99.3, unreachable by a real reading; a flawless +2z-all-round morning is only z=2/score 88). This hides zero legitimate scores and stops the bogus 100 on every surface (ring, widget, briefing). Removing the degenerate baselines at source is the sibling fix/readiness-baseline-pollution work; this is the belt-and-braces guard.
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.
Problem
On the Today screen the big READINESS ring shows a wrong score on load/refresh: it flashes 100 ("Primed", green) and then snaps to the true value (e.g. 73, orange) — and, when today is still settling, it can keep jumping to ~100 and back. The settled score is correct; the transient 100 is the bug.
Root cause
Two distinct paths both let a not-yet-final readiness reach the headline ring:
Held-over prior night (building window). While today's overnight is still building,
getTodaydeliberately surfaces the last settled night's readiness (showing_prior_overnight) so the rest of the screen has data. The orbit hero renderedt.readiness.valuedirectly, so it headlined the prior night's number until today's settled, then snapped.Ready→ready saturation bounce. A day stays recomputable for ~48h and re-derives on every BLE drain, so today's readiness can change value between renders while
overnight_statestaysreadythe whole time — a gate on the ready state alone can't catch it. The composite maps its weighted robust-z to a score via a logistic,score = 100 / (1 + exp(-z)).robustZonly returns null on exact-zero MAD (fully-quantised baseline → the honest blank—). A near-degenerate baseline — duplicate-day pollution collapsing the trailing window toward one repeated value — has a tiny non-zero MAD, sorobustZdoes not null: it returns a huge z, the logistic saturates to ~100, and that bogus value is persisted and headlined until a cleaner re-derive snaps it back. That is the observed "keeps jumping to 100 and back".Fix
Two complementary guards, both on the compute→render seam:
Building-window guard (UI model).
TodayData.settledReadinessScoreonly returns a score once today's overnight has settled (overnight_state == 'ready'); while building/missing, any readiness present is a held-over prior night and is withheld. The orbit hero and the once-a-morning recovery story route through it. Status-less synthetic payloads keep their previous behaviour, so existing Today widget tests are unaffected.Saturation guard (compute seam).
headlineReadinessScalar/kReadinessZCap(onehz_pipeline.dart) abstain from a saturated readiness: if the composite is present but|composite z|exceeds the cap it persists absent (null) instead of the ~100. The cap is5, chosen physiologically — a real composite z is a weighted mean of per-input robust-z's, so even a flawless morning (every input at +2 z) lands at z=2 → score 88, and +3 z all-round is z=3 → 95; z=5 is score 99.3, unreachable by a genuine reading. So the cap suppresses only the degenerate rail and hides zero legitimate scores, on every surface (ring, home-screen widget, AI briefing).Tests
test/readiness_flash_test.dart— the building-window gate: settled → surfaced; building/missing → withheld; absent → null; status-less → unchanged.test/readiness_saturation_test.dart— the saturation gate: a near-degenerate (tiny-non-zero-MAD) baseline saturates the composite to >99 and is abstained; a clean baseline surfaces its real score untouched; a ready→ready sequence (saturated derive then clean) never headlines the saturated value; exact-zero MAD stays the honest blank path.Notes
flutter analyze/flutter testwere not run here and the runtime flash was not reproduced on a device — verified by inspection and the unit tests above. CI is the gate.—flicker is still possible if degenerate and clean derives alternate (the number briefly disappears rather than showing a wrong value). Fully removing that requires the siblingfix/readiness-baseline-pollutionbranch, which eliminates the degenerate baselines at source (de-duplicates the rolling window so MAD never collapses). The two are complementary.Closes #117
Summary by CodeRabbit