Liquid Today: remount the illness banner, the auto-workout prompt, and the hydration value - #980
Conversation
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.
|
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 Verified rather than assumed, given nothing here type-checks in CI:
One bonus you get for free: One change I would rework before landing.task(id: "\(repo.refreshSeq)-\(selectedDayOffset)-\(repo.hydrationSeq)-\(hydrationEnabled)") { await load() }This wires hydration into the heavy loader. Classic deliberately does the opposite, and says so at
The PR comment says it uses "the same trigger set classic .task(id: repo.hydrationSeq) { await reloadHydration() }
.onChangeCompat(of: hydrationEnabled) { _ in Task { await reloadHydration() } }with the two reads you already wrote lifted out of Worth knowing for scale: until earlier today 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. |
…#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.
…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.
|
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 The Updates inbox and Charge-breakdown-sheet items are real but lower priority — send them whenever; no rush. |
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.
…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.
…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.
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.
Liquid is the default Today on both platforms, so anything mounted only in classic
TodayViewis 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:46andStrand/App/RootView.swift:201both defaultliquidTodayEnabled = true.1.
HealthAlertBanner— a raised health warning had no home-screen surfaceMounted at
TodayView.swift:1270(iOS) and:1272(macOS), never in Liquid. Android pins it on Today too (TodayScreen.kt:1306).While unmounted, a
.raisedillness/strain alert survived only as one push at the moment it fired (IllnessNotifier.post) and asHeadsUpCardtwo 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.healthAlertis nil.2.
AutoWorkoutCard— a Settings toggle with no visible effectAutoWorkoutCard()had exactly one instantiation repo-wide,TodayView.swift:1352. So on the default screenPuffinExperiment.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-699hardcodedvalue: "–".HydrationGoal.cardValueStringis unit-tested and byte-identical to the Android twin (String.format(Locale.US, "%.1f / %.1f L")), but classicTodayView.swift:2237was 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.hydrationSeqjoins the.taskid, the same trigger setreloadHydration()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 viait != HYDRATION || hydrationEnabled; Liquid'sForEach(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 build→ BUILD 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.
HealthAlertBannerandAutoWorkoutCardtake 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:
UpdatesInboxViewis 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 intoUpdateStoreon 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.ScoringGuideView(LiquidTodayView.swift:539) rather thanchargeBreakdownSheet(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.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/364implements 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.digestsidebar case exists. The engine itself is fine —WeeklyDigestContentships inside Trends (TrendsView.swift:353) and Android has its own card. It's the two wrappers plus three translated strings that only they reference.