Skip to content

HRV: default Overnight only ON for fresh installs (#1008, minimum half) - #1019

Merged
ryanbr merged 4 commits into
mainfrom
feat/hrv-overnight-default
Aug 1, 2026
Merged

HRV: default Overnight only ON for fresh installs (#1008, minimum half)#1019
ryanbr merged 4 commits into
mainfrom
feat/hrv-overnight-default

Conversation

@ryanbr

@ryanbr ryanbr commented Aug 1, 2026

Copy link
Copy Markdown
Owner

The safe half of #1008. No existing user's behaviour changes.

What

WHOOP publishes no daytime HRV figure — its reading is an overnight one. So a 24/7 stream has no official-app analogue, and the setting's own copy says overnight-only "roughly halv[es] the battery cost".

Today, turning on Continuous HRV capture gives you the expensive, non-WHOOP-like behaviour unless you separately find and enable Overnight only. That is the wrong way round: the cheaper, comparable option should be what you get, and the expensive one should be the deliberate choice.

Existing users are unchanged, and that is the whole design

A one-time launch migration pins the old default for anyone already running it:

  • has used Continuous HRV, never chose an overnight setting → pinned to OFF (always-on preserved)
  • everything else → left alone, so the read takes the new ON default
  • an explicit choice is never overwritten, in either direction

It runs before the user can reach either toggle, and it is idempotent, so it is a no-op on every launch after the first and on every fresh install.

Why a migration and not a read-time default

I tried the read-time version first and it was wrong. It keyed the default on "has ever enabled Continuous HRV" — a fact the user's own opt-in creates. So a fresh install read overnight-only ON, and then flipped to OFF the moment they enabled Continuous HRV: the exact opposite of the intent.

State-by-state tests could not see it. Every individual state was correct; only the sequence was wrong. There is now a test for that sequence specifically.

A @AppStorage onChange hook cannot substitute on iOS either — @AppStorage writes the value before the handler runs, so by then a first-ever toggle is indistinguishable from any other. That asymmetry is why this is a launch migration rather than something wired to the toggle.

What is deliberately NOT here

The other half of #1008 — defaulting HrvWindow to DEEP_SLEEP to match WHOOP's reading. Two reasons, both in the issue:

Parity

Both platforms, same rule, same five test cases in the same order. Android's UI NoopPrefs and iOS's PuffinExperiment each keep their own storage; only the decision is shared in shape.

UserDefaults.bool(forKey:) cannot express the new default on its own — it returns false for a missing key, which is indistinguishable from an explicit off — so the read is object(forKey:) as? Bool ?? true, and the migration checks object(forKey:) for presence, mirroring Android's contains().

The migration is wired into StrandiOSApp.init() and StrandApp.init() (macOS). Those are app-target Swift, which nothing compiles here — parse-checked only, same limitation as the rest of the app target while app-build.yml is disabled.

Verification

  • The rule is lifted into continuousHrvOvernightDefault on both platforms so it is testable without a Context or UserDefaults.
  • Kotlin: 5 tests green, run locally against the rule extracted verbatim from source. They run in CI under testFullDebugUnitTest.
  • Kotlin compile against a main worktree: 2517 errors both sides, 41 in MainActivity.kt both sides, error sets byte-identical. No new errors.
  • swiftc -parse clean on PuffinExperiment.swift and the new test.
  • Tools/doc_comment_lint.py clean.
  • The Swift tests live in StrandTests, which only runs under xcodebuild on macOS — and app-build.yml is disabled, so they are not executed by CI today. Stated in the test file itself so nobody reads them as covered.

Note for the release

No stored value changes and no score moves, so this does not belong under "scores that change". It is worth one line in the notes for new users: enabling Continuous HRV now captures overnight by default, and the all-day behaviour is one toggle away.

Re-review: the iOS toggle and the behaviour disagreed

Changing the read default left SettingsView's @AppStorage on false. Same key, two routes — so a fresh install would show "Overnight only" OFF while capture was actually overnight-only.

The failure mode is worse than a wrong label: a user "correcting" the toggle by flipping it on and off would write an explicit false and end up with exactly the 24/7 behaviour they were trying to avoid.

Android was never affected — its toggle reads through NoopPrefs.continuousHrvOvernight, so it cannot disagree with what the BLE client acts on. That is the better shape, and it is why the bug was iOS-only.

I then checked the rest of SettingsView for the same class. journalReminderEnabled and experimentalSleepV2Enabled are the only other true-default toggles, and both already pair with an accessor that handles the unset case — the first with the exact object(forKey:) as? Bool ?? true spelling used here. So the fix matches how the codebase already solves this twice, and there is no other instance.

ryanbr added 4 commits August 1, 2026 00:58
WHOOP publishes no daytime HRV figure - its reading is an overnight one - so
a 24/7 stream has no official-app analogue, and the setting's own copy says
overnight-only roughly halves the battery cost. Turning on "Continuous HRV
capture" currently gives you the expensive, non-WHOOP-like behaviour unless
you separately find "Overnight only", which is the wrong way round.

EXISTING USERS ARE UNCHANGED. The unset case resolves from whether the base
Continuous HRV key exists: present means the user has been through this
screen and experienced always-on, so they keep it; absent means a fresh
install, which gets overnight-only. That preserves the deliberate choice the
#927 comment records ("Default OFF, so existing Continuous HRV users keep the
always-on behaviour with no migration") while changing what new users get.

Resolved at read time rather than by writing a migration, because the only
thing that must not happen is silently narrowing capture for someone relying
on it for daytime Stress - and a migration that runs at the wrong moment does
exactly that.

An explicit choice always wins in both directions, including an explicit OFF
on a fresh install.

This is the SAFE half of #1008. The other half - defaulting the HRV window to
DEEP_SLEEP to match WHOOP's reading - is deliberately not here: it moves every
existing HRV number and its baselines, and it depends on deep-sleep staging
quality that is still unsettled on 5/MG. That needs SleepPSG evidence first.

Rule lifted into continuousHrvOvernightDefault on both platforms so it is
testable without a Context or UserDefaults. Four twin tests each; the Kotlin
ones run in CI, the Swift ones live in StrandTests which app-build.yml would
run if it were enabled.
The first version of this resolved the default at READ time, keyed on
whether Continuous HRV had ever been enabled. That fact is created by the
user's own opt-in, so a fresh install read overnight-only ON and then flipped
to OFF the moment they enabled Continuous HRV - the exact opposite of the
intent.

State-by-state tests could not see it: every individual state was correct,
and only the sequence was wrong.

Replaced with a one-time launch migration on both platforms: an install that
has used Continuous HRV and never chose an overnight setting is pinned to the
old OFF; everything else is left alone and takes the new ON default. Taken
before the user can reach either toggle, so the inputs cannot move under it.

A @AppStorage onChange hook cannot substitute on iOS - @AppStorage writes the
value BEFORE the handler runs, so a first-ever toggle is indistinguishable
from any other by then. That asymmetry is why this is a launch migration
rather than something wired to the toggle.

The extracted rule now describes the MIGRATION decision rather than the read,
because the read is simply getBoolean(key, true) - the previous extraction had
become dead code describing the broken design, and its tests were exercising
that dead code rather than production.

Five twin tests each, including the sequence that broke it and idempotence.
The replacement added shouldPinLegacyOvernightDefault but left
continuousHrvOvernightDefault behind on the Swift side - dead code whose doc
describes the design this PR abandoned, and points at a Kotlin twin that no
longer exists.

Worse than ordinary dead code: it documents the read-time approach as though
it were current, which is the specific mistake the migration exists to
prevent someone repeating.

Found by grepping for stale references after the rewrite; nothing referenced
it, so removal is inert.
Changing the read default left SettingsView's @AppStorage on false, so a
fresh install would show "Overnight only" OFF while capture was actually
overnight-only. They read the same key by different routes.

The failure mode is worse than a wrong label: a user "correcting" the toggle
by flipping it on and off would write an explicit false and end up with the
24/7 behaviour they were trying to avoid.

Android was never affected - its toggle reads through
NoopPrefs.continuousHrvOvernight, so it cannot disagree with what the BLE
client acts on. Its stale "Default OFF" comment is corrected too.

Checked the rest of SettingsView for the same class: journalReminderEnabled
and experimentalSleepV2Enabled are the only other true-default toggles, and
both already pair with an accessor that handles the unset case - the first
with the exact `object(forKey:) as? Bool ?? true` spelling used here. So this
now matches how the codebase already solves it, and no other instance exists.
@ryanbr
ryanbr merged commit 978a98b into main Aug 1, 2026
3 checks passed
@ryanbr
ryanbr deleted the feat/hrv-overnight-default branch August 1, 2026 17:57
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