Skip to content

Liquid Today: remount the illness banner, the auto-workout prompt, and the hydration value - #980

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
vishk23:upstream-pr/liquid-today-remount-audit
Aug 9, 2026
Merged

Liquid Today: remount the illness banner, the auto-workout prompt, and the hydration value#980
ryanbr merged 1 commit into
ryanbr:mainfrom
vishk23:upstream-pr/liquid-today-remount-audit

Conversation

@vishk23

@vishk23 vishk23 commented Jul 30, 2026

Copy link
Copy Markdown

Liquid is the default Today on both platforms, so anything mounted only in classic TodayView is effectively unmounted. Three things were in that state. This restores them at the same positions classic and Android use.

I found these auditing NOOP for things it computes or captures that never reach a user, checking each against a real device DB rather than the schema. Same class as #105 (workout-in-progress card) and the #992 / B1 / #543 restorations already in LiquidTodayView.

Defaults, for reference: StrandiOS/App/RootTabView.swift:46 and Strand/App/RootView.swift:201 both default liquidTodayEnabled = true.

1. HealthAlertBanner — a raised health warning had no home-screen surface

Mounted at TodayView.swift:1270 (iOS) and :1272 (macOS), never in Liquid. Android pins it on Today too (TodayScreen.kt:1306).

While unmounted, a .raised illness/strain alert survived only as one push at the moment it fired (IllnessNotifier.post) and as HeadsUpCard two taps deep in More → Health. The push is transient and the Health route is not somewhere you land by accident, so in practice the warning was invisible.

Pinned above the reorderable block, matching classic, so it can't be dragged below the fold. Renders nothing when model.healthAlert is nil.

2. AutoWorkoutCard — a Settings toggle with no visible effect

AutoWorkoutCard() had exactly one instantiation repo-wide, TodayView.swift:1352. So on the default screen PuffinExperiment.autoDetectWorkoutsKey (SettingsView.swift:122) did nothing observable: switch auto-detect on, never get shown a suggestion. Android keeps the card (TodayScreen.kt:1554).

Restored to the same slot classic uses (after the cards block, before Data Sources). Self-gates on the toggle and on the detector actually finding an unsaved, un-dismissed window, so it renders nothing by default.

3. Hydration card value was the literal "–"

LiquidTodayView.swift:698-699 hardcoded value: "–". HydrationGoal.cardValueString is unit-tested and byte-identical to the Android twin (String.format(Locale.US, "%.1f / %.1f L")), but classic TodayView.swift:2237 was its only caller — so a logged drink never appeared on the default screen.

Now reads the same total + goal and shows the goal fraction on the ring. repo.hydrationSeq joins the .task id, the same trigger set reloadHydration() uses, so logging a drink refreshes immediately.

Also adds the enablement filter Liquid never had. Classic filters via enabledDashboardCards (TodayView.swift:235-241) and Android via it != HYDRATION || hydrationEnabled; Liquid's ForEach(DashboardCardPrefs.decodeEnabled(...)) filtered on neither, so anyone who added the card and later switched the feature off kept a permanently-blank row.

Verification

xcodebuild -project Strand.xcodeproj -scheme Strand -destination 'platform=macOS' -configuration Debug buildBUILD SUCCEEDED, both on this branch and on my fork's main.

All three are self-contained views or existing store reads — no new types, no migration, no protocol change. HealthAlertBanner and AutoWorkoutCard take their dependencies from the environment, so the mounts are bare.

Not included, but found in the same pass

Still classic-only, listed so it's on the record rather than as scope creep — happy to send follow-ups:

  • Updates inbox + unread bell. UpdatesInboxView is instantiated at exactly one site repo-wide (TodayView.swift:1432). seedWhatsNewIfNeeded() (StrandiOSApp.swift:339) still writes a "What's new in NOOP x.y.z" item into UpdateStore on every release, and "N new days of history landed" is still posted (TodayView.swift:4106-4112). On the default screen nothing reads that store, so every release note is written and discarded.
  • Charge breakdown sheet. Liquid's Charge cell opens the generic ScoringGuideView (LiquidTodayView.swift:539) rather than chargeBreakdownSheet (TodayView.swift:1441), so the per-driver breakdown of the user's own Charge — and the Readiness read S4 deliberately folded behind that tap (TodayView.swift:1342-1345) — has no door on the home screen.
  • First-run "Live now. Your scores are building." note (TodayView.swift:1291-1294), including the actionable "import your WHOOP export and it backfills in about a minute". Android keeps it (TodayScreen.kt:1281-1287). This is the worst moment to lose an explanation, since every tile is blank.
  • FullDayChartView.swift:66, @State private var reloadTick = 0 — its own comment says it is "bumped on every settled zoom/metric change so the re-read task re-runs at the new resolution". Nothing bumps it and nothing observes it, so zooming the full-day chart never re-reads at finer resolution. HydrationView.swift:25/73/364 implements the same idiom correctly, so it looks like a copy that was left unwired.
  • WeeklyDigestCard / WeeklyDigestView (WeeklyDigestView.swift:65, :84) have zero call sites repo-wide, and no .digest sidebar case exists. The engine itself is fine — WeeklyDigestContent ships inside Trends (TrendsView.swift:353) and Android has its own card. It's the two wrappers plus three translated strings that only they reference.

Liquid is the DEFAULT Today on both platforms (RootTabView.swift:46 and
RootView.swift:201 both default `liquidTodayEnabled = true`), so anything
mounted only in classic TodayView is invisible to almost every user. Three
things were in that state. Found by auditing what NOOP computes or captures
that never reaches a user, then checking each against a real device DB.

1. HealthAlertBanner — the strain/illness early-warning card. While unmounted,
   a RAISED health alert had no home-screen surface at all: it survived only as
   a single push at the moment it fired (IllnessNotifier.post) and as HeadsUpCard
   two taps deep in More -> Health. Pinned ABOVE the reorderable block, matching
   classic on both platforms and Android's TodayScreen, so a warning cannot be
   dragged below the fold.

2. AutoWorkoutCard — the opt-in "looks like a workout?" suggestion. Its only
   mount was classic, so PuffinExperiment.autoDetectWorkoutsKey was a Settings
   toggle with NO visible effect on the default screen: switch it on, never see
   a suggestion. Restored to the same slot classic uses.

3. Hydration card value — was the literal "-". HydrationGoal.cardValueString is
   unit-tested and byte-identical to the Android twin, but classic TodayView was
   its only caller, so a logged drink never appeared on the default screen. Now
   wired to the same total/goal read, with the goal fraction on the ring.
   repo.hydrationSeq joins the .task id so logging a drink refreshes the card.

   Also adds the hydration enablement filter Liquid never had. Classic
   (enabledDashboardCards) and Android (`it != HYDRATION || hydrationEnabled`)
   both drop the card when the feature is off; Liquid kept rendering a
   permanently-blank row for anyone who added it and later switched off.

Verified with a macOS build (xcodebuild -scheme Strand): BUILD SUCCEEDED.
@ryanbr

ryanbr commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Reviewed. All three are real — a Settings toggle with no observable effect and a raised health warning with no home-screen surface are the kind of thing that only ever gets found by someone checking against a device DB rather than the schema. Thanks also for compiling it; that is more than CI does for this file, since app-build.yml is disabled and nothing else touches app-target Swift.

Verified rather than assumed, given nothing here type-checks in CI:

  • HydrationGoal.cardValueString(totalML:goalML:) and fraction(totalML:goalML:) exist with exactly those signatures; HealthAlertBanner / AutoWorkoutCard are bare no-arg views; profile is an @EnvironmentObject already on this view.
  • The enablement filter matches classic's enabledDashboardCards and Android's it != HYDRATION || hydrationEnabled.
  • Both "not included" findings I spot-checked hold: FullDayChartView.swift:66 reloadTick is declared and never bumped or observed, and UpdatesInboxView( has exactly one call site repo-wide, in classic TodayView.

One bonus you get for free: repo.hydrationTotal now returns hand-logged plus water imported from Apple Health / Health Connect (#974, merged earlier today), so the Liquid card picks that up with no extra work.

One change I would rework before landing

.task(id: "\(repo.refreshSeq)-\(selectedDayOffset)-\(repo.hydrationSeq)-\(hydrationEnabled)") { await load() }

This wires hydration into the heavy loader. load() has 13 awaits and does the repo.days scan (up to 599 rows) that the function's own comment calls out as "the stutter" it was restructured to avoid. So tapping Sip / Cup / Bottle now re-runs the entire home-screen load per tap.

Classic deliberately does the opposite, and says so at TodayView.swift:1360:

#989: … re-read just the two hydration fields. Cheap (one metricSeries row), **never re-runs the heavy loads.**

The PR comment says it uses "the same trigger set classic reloadHydration() uses" — the triggers match, but they are attached to a different handler. Mirroring classic keeps the main task untouched and adds:

.task(id: repo.hydrationSeq) { await reloadHydration() }
.onChangeCompat(of: hydrationEnabled) { _ in Task { await reloadHydration() } }

with the two reads you already wrote lifted out of load() into reloadHydration(). onChangeCompat is the same helper classic uses, so nothing new is needed.

Worth knowing for scale: until earlier today hydrationSeq was bumped on every Apple Health sync, i.e. on each of six hourly observer wakes — so as written this would also have fired a full load() in the background all day. #986 narrowed the bump to real changes, so that half is already gone. The drink-log path is the part still worth fixing.

Everything else looks right to me, and the two mounts are exactly the leaves classic and Android use. Happy to re-review once the trigger is split.

ryanbr added a commit that referenced this pull request Jul 30, 2026
…#984) (#989)

Two symptoms in the report, one shared cause plus a second bug underneath.

The sheet could not scroll. ModalBottomSheet hands its content a plain
ColumnScope and does not scroll it, and the inbox root was a bare Column — so
everything past the bottom of the screen was clipped and unreachable, the
Clear all / Mark all read footer included.

That is also why a tapped row looked deleted. Tapping marks the row read, which
moves it out of "New" and down into "Earlier" — rendered below, i.e. into the
part that could not be reached. The row was never removed; it relocated
somewhere the user could not get to.

Underneath that, the What's New row was posted with no deepLink at all while
its own message reads "tap to read what's new". handleTap bails on a null link,
so the tap could never have opened anything: every release since the inbox
shipped has posted an entry that cannot be read. The destination already
existed — WhatsNewSheet, the same one Settings > About opens — it was simply
never wired to the row.

Fixes all three: verticalScroll on the root, a deepLink on the seeded row, and
a "whatsNew" case in AppRoot's onDeepLink that presents the changelog. It is
handled beside the route table rather than in it because What's New is a sheet,
not a nav destination.

The tap rule moves into UpdateStore.deepLinkTarget so it can be unit-tested —
the store's own persistence is SharedPreferences + org.json and is not reachable
from a plain JVM test, the same constraint NapStoreTest documents. It also falls
back by kind, which matters: What's New rows already sitting in people's inboxes
carry no deepLink and would otherwise stay inert until a later release replaced
them. That includes the reporter's.

iOS is deliberately not matched. Its inbox is a ScrollView, so the scroll bug
does not exist there. It has the same missing deepLink, but per #980 the inbox
is only mounted in classic TodayView and not in Liquid, which is the default —
so there is no reachable surface to fix until that lands.

Tests: 6 JVM tests over the tap rule, including the already-posted-row case.
ryanbr added a commit that referenced this pull request Aug 1, 2026
…und entry (#1021) (#1024)

The only automatic write-back ran on scenePhase == .active - the same block that kicks the strap offload, so it raced the data it was meant to publish and last night's sleep reached Health an app-open late.

Hooks the write to refreshAfterCompletedBackfill, the real "new data landed" signal that #980 already publishes the widget from, and resumes Health auth on that path so a backfill completing in a backgrounded or BLE-relaunched process is not silently dropped. Write-only, matching the Android twin (WhoopBleClient calls HealthConnectWriter.write after backfill, since #660).

Not compile-verified: app-build.yml is disabled, so no CI builds app-target Swift.
@ryanbr
ryanbr merged commit 372b742 into ryanbr:main Aug 9, 2026
5 checks passed
@ryanbr

ryanbr commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Merged — thanks, all three were real and exactly the kind of thing only a device-DB audit surfaces.

On the "not included" list, two notes so the follow-ups land clean:

The first-run "Live now. Your scores are building." note is the one worth doing next — greenlit. Losing it on the default screen at first run is the worst case (every tile is blank and there's no explanation, plus the actionable "import your WHOOP export and it backfills in about a minute"). Android keeps it, so it's a straight Liquid restoration in the same vein as this PR. Happy to review it as a standalone.

One correction on the FullDayChartView.reloadTick item — it isn't actually a bug. Zoom does re-read at the new resolution: taskKey (FullDayChartView.swift:118-121) keys on visibleWindow's bounds (lo/hi), and visibleWindow = zoomDomain ?? dayBounds, so a settled zoom changes zoomDomainvisibleWindowtaskKey.task(id: taskKey) re-runs reload(). reloadTick is just vestigial @State (declared at :66, never bumped, never observed) left over from an earlier approach that the taskKey path superseded — its comment is stale. So there's nothing to wire up; at most it's a dead-state deletion + comment fix, not a behavior fix. Didn't want you to spend time "restoring" a path that already works.

The Updates inbox and Charge-breakdown-sheet items are real but lower priority — send them whenever; no rush.

DX23876 added a commit to DX23876/noop that referenced this pull request Aug 12, 2026
85 commits from ryanbr/noop (a26bd3a..d94d568, 6–9 Aug): Oura decode fixes
(0x5D bucket order and pairing, RSA gating), a batch of BLE work (5/MG bond
watchdog, empty-offload backoff, MTU settle, battery poll throttle, DIS-based
4.0/5.0 correction), sleep-card reordering, the stress motion gate, the ryanbr#103
SpO₂ @82 candidate, language settings, and the ryanbr#1068 visual-system pass.

24 files conflicted. Resolved so that upstream wins on behaviour and this fork
wins on its own visual identity:

- Liquid Today, the Sleep hero, ScreenScaffold, StrandCard/Typography/Components
  keep the fork's design. Upstream's ryanbr#1068 rewrite (NoopPanelSurface everywhere,
  SF-Rounded roles, native tab bar) is deliberately not adopted — it targets a
  screen layout this fork replaced.
- Features upstream re-mounted in ryanbr#980 are taken: HealthAlertBanner and
  AutoWorkoutCard now render on Liquid Today, which is the default screen here,
  so a raised health alert finally has a home surface. `dataSourcesSection` is
  NOT re-mounted — the reorderable card block already renders it.
- Also taken: the ryanbr#1112 Sleep card reordering (all six of this fork's cards are
  covered by upstream's section enum), ryanbr#989 hydration, ryanbr#804 Fix A's provided
  hypnogram, the accent-colour storage, SyncChipState, and ryanbr#103's SpO₂ candidate
  — the last one rerouted through `windowedSpark` so the tile still honours the
  Detailed-tiles window picker.
- The string catalog was merged structurally, per key, rather than textually:
  4352 keys, 35 new from upstream, every focus locale complete.

Two silent regressions from upstream's rewrite were caught and restored:
ScreenScaffold's `overSky` title contrast (fork-only, deleted by ryanbr#1068) and the
`icon:` argument that ktile gained. Both heroes now share `LiquidScoreGauge`
instead of each inlining a vessel.

Verified: Strand (macOS) and NOOPiOS build; StrandTests 1864 pass; WhoopProtocol
558, WhoopStore 367, StrandAnalytics 1302 pass; i18n audit clean. One upstream
test (AppLanguageTests, new in ryanbr#1181) asserted through NSArgumentDomain, which
this fork's test scheme pins to `en` — rewritten to read the suite's own domain,
matching what the test already does for its second assertion.

NOT verified: Android does not compile here (no SDK on this machine), and none
of the BLE changes have been near a strap.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…ryanbr#984) (ryanbr#989)

Two symptoms in the report, one shared cause plus a second bug underneath.

The sheet could not scroll. ModalBottomSheet hands its content a plain
ColumnScope and does not scroll it, and the inbox root was a bare Column — so
everything past the bottom of the screen was clipped and unreachable, the
Clear all / Mark all read footer included.

That is also why a tapped row looked deleted. Tapping marks the row read, which
moves it out of "New" and down into "Earlier" — rendered below, i.e. into the
part that could not be reached. The row was never removed; it relocated
somewhere the user could not get to.

Underneath that, the What's New row was posted with no deepLink at all while
its own message reads "tap to read what's new". handleTap bails on a null link,
so the tap could never have opened anything: every release since the inbox
shipped has posted an entry that cannot be read. The destination already
existed — WhatsNewSheet, the same one Settings > About opens — it was simply
never wired to the row.

Fixes all three: verticalScroll on the root, a deepLink on the seeded row, and
a "whatsNew" case in AppRoot's onDeepLink that presents the changelog. It is
handled beside the route table rather than in it because What's New is a sheet,
not a nav destination.

The tap rule moves into UpdateStore.deepLinkTarget so it can be unit-tested —
the store's own persistence is SharedPreferences + org.json and is not reachable
from a plain JVM test, the same constraint NapStoreTest documents. It also falls
back by kind, which matters: What's New rows already sitting in people's inboxes
carry no deepLink and would otherwise stay inert until a later release replaced
them. That includes the reporter's.

iOS is deliberately not matched. Its inbox is a ScrollView, so the scroll bug
does not exist there. It has the same missing deepLink, but per ryanbr#980 the inbox
is only mounted in classic TodayView and not in Liquid, which is the default —
so there is no reachable surface to fix until that lands.

Tests: 6 JVM tests over the tap rule, including the already-posted-row case.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…und entry (ryanbr#1021) (ryanbr#1024)

The only automatic write-back ran on scenePhase == .active - the same block that kicks the strap offload, so it raced the data it was meant to publish and last night's sleep reached Health an app-open late.

Hooks the write to refreshAfterCompletedBackfill, the real "new data landed" signal that ryanbr#980 already publishes the widget from, and resumes Health auth on that path so a backfill completing in a backgrounded or BLE-relaunched process is not silently dropped. Write-only, matching the Android twin (WhoopBleClient calls HealthConnectWriter.write after backfill, since ryanbr#660).

Not compile-verified: app-build.yml is disabled, so no CI builds app-target Swift.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
Liquid is the DEFAULT Today on both platforms (RootTabView.swift:46 and
RootView.swift:201 both default `liquidTodayEnabled = true`), so anything
mounted only in classic TodayView is invisible to almost every user. Three
things were in that state. Found by auditing what NOOP computes or captures
that never reaches a user, then checking each against a real device DB.

1. HealthAlertBanner — the strain/illness early-warning card. While unmounted,
   a RAISED health alert had no home-screen surface at all: it survived only as
   a single push at the moment it fired (IllnessNotifier.post) and as HeadsUpCard
   two taps deep in More -> Health. Pinned ABOVE the reorderable block, matching
   classic on both platforms and Android's TodayScreen, so a warning cannot be
   dragged below the fold.

2. AutoWorkoutCard — the opt-in "looks like a workout?" suggestion. Its only
   mount was classic, so PuffinExperiment.autoDetectWorkoutsKey was a Settings
   toggle with NO visible effect on the default screen: switch it on, never see
   a suggestion. Restored to the same slot classic uses.

3. Hydration card value — was the literal "-". HydrationGoal.cardValueString is
   unit-tested and byte-identical to the Android twin, but classic TodayView was
   its only caller, so a logged drink never appeared on the default screen. Now
   wired to the same total/goal read, with the goal fraction on the ring.
   repo.hydrationSeq joins the .task id so logging a drink refreshes the card.

   Also adds the hydration enablement filter Liquid never had. Classic
   (enabledDashboardCards) and Android (`it != HYDRATION || hydrationEnabled`)
   both drop the card when the feature is off; Liquid kept rendering a
   permanently-blank row for anyone who added it and later switched off.

Verified with a macOS build (xcodebuild -scheme Strand): BUILD SUCCEEDED.
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.

2 participants