feat(ios): add Target Strain Reached notification - #600
Conversation
WHOOP's official app fires a "Target Strain Reached" celebratory notification; NOOP had wind-down, battery, illness, inactivity, and scheduled report notifications but nothing for this. HONEST about scope: the official copy implies a per-activity target from an undocumented proprietary model NOOP has no visibility into. This instead fires against the day's recovery-based optimal-Strain floor (CoupledView.optimalStrainRange(recovery:).lowerBound) -- the one target NOOP can actually compute -- and the notification copy says "today", not "for this activity", so it never overclaims precision NOOP doesn't have. - StrainGoalNotifier (new): pure once-per-day crossing policy (mirrors BatteryAlertPolicy) + a point-in-time post gated on live authorization status, same pattern as BatteryNotifier/IllnessNotifier. - AppModel: evaluateStrainGoal(_:) hooked into the same repo.$days sink as evaluateIllness; uses Repository.widgetAnchor to find the row Today displays. - BehaviorStore: strainGoalAlerts toggle, default OFF (opt-in like every automation here). - AutomationsView: new "Strain goal" card, mirrors the Illness card's request-authorization-on-enable pattern. iOS only for now -- Android is a tracked fast-follow (ryanbr#593).
|
Thank you for this — the architecture instinct here is spot on (pure once-per-day crossing policy mirroring Closing as superseded, though — this landed in parallel with #596/#597, which already shipped the same feature on iOS/macOS ( Two substantive reasons the merged version is the one to keep, in case they're useful for future work:
None of this is a knock on the work — the two above are subtle and easy to miss. If you'd like to keep going on this feature, the Android side is still an open fast-follow (noted on #593): the merged iOS |
|
Thanks for the catch — I lifted "Target Strain Reached" / "You've hit your target Strain of X" straight from the decompiled strings without registering that's expression, not an interop fact. That was a mistake for a clean-room project; noted for next time. Good call closing this in favor of #596/#597 — properly scoped (day-band target choice, per-pass gate, NOOP's own copy) and already shipped on both platforms. |
…ep boundary (ryanbr#277) Sleep V2 (the transparent per-night cardiorespiratory recipe from PR ryanbr#600) shipped opt-in/experimental because it had only n=1 validation. A 44-subject cross-subject benchmark (AAUWSS 13 + Walch sleep-accel 31, leave-one-subject-out, run through the real shipped stager) shows V2 strictly dominates the default V1 on both datasets: kappa 0.356 vs 0.034 (AAUWSS) / 0.321 vs 0.072 (sleep-accel), deep recall 53% vs 1% / 57% vs 9%, with 4-class and sleep/wake also higher. So promote V2 to the default and keep V1 available behind the flag. Also tune V2's deep boundary on the same benchmark (parity-safe constant edits, all a-priori, none fit to labels): - deepGateThresh 0.20 -> 0.25 (recover the deep recall the tighter edits shed) - deep emission -1.4*zhvv -0.2*zhrv -0.3*zmvv -> -1.1*zhvv -0.5*zmvv (drop the mean-HR term; HR-variability + movement carry deep) - deep transition row deep .90 -> .86 (renormalises to rem .007 / light .126 / awake .007) Isolated impact (untuned -> tuned V2, same detection): kappa +0.018 / +0.027, 4-class +1.9 / +2.6, sleep/wake flat, deep precision up, nothing slid. Promote is the PuffinExperiment default flip (Android getBoolean default true; macOS resolves a nil key to true, matching) plus the two @AppStorage defaults. Pure analysis switch: detection, scoring and the V1 path are untouched; the same detected window is simply staged by V2. Byte-identical Kotlin + Swift.
The upstream NoopApp/noop repo has been deleted (gh can't resolve it; its
raw.githubusercontent.com stats JSON returns 404), so a pile of references now
point at a dead repo:
- README stats badges (raw.githubusercontent.com/NoopApp/noop/.../docs/stats/*.json → 404)
- the bug-report issue template + its config (FAQ / Troubleshooting / WHOOP-5.0 /
Discussions links) — the fork's wiki DOES have those pages, so they resolve on ryanbr
- SUPPORT.md issue/discussion links
- Tools/ dev scripts that hit the GitHub API (refresh-stats-badges.py, release.sh,
update-altstore-source.sh)
Case-sensitive sweep of `NoopApp/noop` and the URL-encoded `NoopApp%2Fnoop` → ryanbr,
which deliberately leaves the Homebrew tap references (lowercase `noopapp/noop`,
`NoopApp/homebrew-noop`) untouched — `ryanbr/homebrew-noop` doesn't exist yet, so
repointing the brew instruction would just swap one dead tap for another. Homebrew is a
separate follow-up (create the tap first).
Not touched: historical release notes / CHANGELOG, the in-app "What's New" changelog
cards, and code provenance comments ("recipe in NoopApp/noop PR ryanbr#600") — those are a
dated record, not live links. The app's actual update-check + Settings links already
point at ryanbr/noop.
Curated from the good parts of ryanbr#54 (excludes its unrelated Android capture files); also
covers the issue templates, which ryanbr#54 missed.
The active repo is the fork now, so repoint the user-facing/functional github links (the update check already pointed at ryanbr/noop): Code (compiles; testcentre tests 108 pass): - Settings → About "Project home & source" link (Android; iOS was already ryanbr). - Bug-report "issues/new" URL + its tests/comments (Android + iOS). - Donation link (Android + iOS) → ryanbr/noop/wiki/Donations. Docs: README badges/links (releases, stars, wiki/FAQ, issues, clone, star-history), docs/IOS.md + BUILD.md + CONTRIBUTING.md release links, docs/SAFEGUARDS.md operational gh command, and altstore-source.json owner (host/path only — asset filenames untouched). Deliberately LEFT as-is: the `NoopApplication` class name; provenance/credit comments (PR ryanbr#600, ryanbr#569, ryanbr#554, ryanbr#1029, "reimplemented under NoopApp"); historical changelog entries (AppChangelog, CHANGELOG.md, docs/releases/); and the `Copyright NoopApp` / PolyForm license attribution.
The upstream NoopApp/noop repo has been deleted (gh can't resolve it; its
raw.githubusercontent.com stats JSON returns 404), so a pile of references now
point at a dead repo:
- README stats badges (raw.githubusercontent.com/NoopApp/noop/.../docs/stats/*.json → 404)
- the bug-report issue template + its config (FAQ / Troubleshooting / WHOOP-5.0 /
Discussions links) — the fork's wiki DOES have those pages, so they resolve on ryanbr
- SUPPORT.md issue/discussion links
- Tools/ dev scripts that hit the GitHub API (refresh-stats-badges.py, release.sh,
update-altstore-source.sh)
Case-sensitive sweep of `NoopApp/noop` and the URL-encoded `NoopApp%2Fnoop` → ryanbr,
which deliberately leaves the Homebrew tap references (lowercase `noopapp/noop`,
`NoopApp/homebrew-noop`) untouched — `ryanbr/homebrew-noop` doesn't exist yet, so
repointing the brew instruction would just swap one dead tap for another. Homebrew is a
separate follow-up (create the tap first).
Not touched: historical release notes / CHANGELOG, the in-app "What's New" changelog
cards, and code provenance comments ("recipe in NoopApp/noop PR ryanbr#600") — those are a
dated record, not live links. The app's actual update-check + Settings links already
point at ryanbr/noop.
Curated from the good parts of ryanbr#54 (excludes its unrelated Android capture files); also
covers the issue templates, which ryanbr#54 missed.
…ep boundary (ryanbr#277) Sleep V2 (the transparent per-night cardiorespiratory recipe from PR ryanbr#600) shipped opt-in/experimental because it had only n=1 validation. A 44-subject cross-subject benchmark (AAUWSS 13 + Walch sleep-accel 31, leave-one-subject-out, run through the real shipped stager) shows V2 strictly dominates the default V1 on both datasets: kappa 0.356 vs 0.034 (AAUWSS) / 0.321 vs 0.072 (sleep-accel), deep recall 53% vs 1% / 57% vs 9%, with 4-class and sleep/wake also higher. So promote V2 to the default and keep V1 available behind the flag. Also tune V2's deep boundary on the same benchmark (parity-safe constant edits, all a-priori, none fit to labels): - deepGateThresh 0.20 -> 0.25 (recover the deep recall the tighter edits shed) - deep emission -1.4*zhvv -0.2*zhrv -0.3*zmvv -> -1.1*zhvv -0.5*zmvv (drop the mean-HR term; HR-variability + movement carry deep) - deep transition row deep .90 -> .86 (renormalises to rem .007 / light .126 / awake .007) Isolated impact (untuned -> tuned V2, same detection): kappa +0.018 / +0.027, 4-class +1.9 / +2.6, sleep/wake flat, deep precision up, nothing slid. Promote is the PuffinExperiment default flip (Android getBoolean default true; macOS resolves a nil key to true, matching) plus the two @AppStorage defaults. Pure analysis switch: detection, scoring and the V1 path are untouched; the same detected window is simply staged by V2. Byte-identical Kotlin + Swift.
Two comment inaccuracies, no behaviour. The sleep files carried #600 and #690 from the project's pre-fork repo. Neither resolves here, and worse, both collide: this repo's #600 is an iOS Target Strain notification and its #690 is the body-location probe, which BodyLocationProbe cites correctly. So the same number meant two things depending on the file. Rewritten to references that resolve in THIS repo: - the V2 recipe keeps its credit as @sunny-noop, matching how the same contributor is credited in HealthExportPlan and SpotHrvReading, and drops the dead repo path - the V2-flag sites now cite 7.0.0, the release that introduced SleepStagerV2 here (CHANGELOG.md:240; the file arrived in commit 4da3cfb), plus #277/#351 for the default-ON claim, which those files already cite a line above - HrBroadcaster's credit keeps ryanbr, loses the dead path github.com/NoopApp/noop 404s and this repo is a fork whose parent no longer resolves, so every one of those links was dead. Nothing live pointed there: UpdateChecker and UpdateCheck both read api.github.com/repos/ryanbr/noop, and both About links go to github.com/ryanbr/noop. The two AppChangelog entries that mention NoopApp are left alone deliberately — they are the shipped release notes for the version that made that change, and rewriting them would falsify the record rather than correct it. Also: ReportReviewGate described the Oura Tier-B sidecars as capped at 20 MB. Both dumps enforce 25 MB (OuraActivityDump.maxBytes / OuraMotionDump.maxBytes).
…#939) Two comment inaccuracies, no behaviour. The sleep files carried #600 and #690 from the project's pre-fork repo. Neither resolves here, and worse, both collide: this repo's #600 is an iOS Target Strain notification and its #690 is the body-location probe, which BodyLocationProbe cites correctly. So the same number meant two things depending on the file. Rewritten to references that resolve in THIS repo: - the V2 recipe keeps its credit as @sunny-noop, matching how the same contributor is credited in HealthExportPlan and SpotHrvReading, and drops the dead repo path - the V2-flag sites now cite 7.0.0, the release that introduced SleepStagerV2 here (CHANGELOG.md:240; the file arrived in commit 4da3cfb), plus #277/#351 for the default-ON claim, which those files already cite a line above - HrBroadcaster's credit keeps ryanbr, loses the dead path github.com/NoopApp/noop 404s and this repo is a fork whose parent no longer resolves, so every one of those links was dead. Nothing live pointed there: UpdateChecker and UpdateCheck both read api.github.com/repos/ryanbr/noop, and both About links go to github.com/ryanbr/noop. The two AppChangelog entries that mention NoopApp are left alone deliberately — they are the shipped release notes for the version that made that change, and rewriting them would falsify the record rather than correct it. Also: ReportReviewGate described the Oura Tier-B sidecars as capped at 20 MB. Both dumps enforce 25 MB (OuraActivityDump.maxBytes / OuraMotionDump.maxBytes).
Fixes #593.
Problem
WHOOP's official app fires a "Target Strain Reached" celebratory notification the moment you hit your day's target Strain. NOOP has wind-down, battery, illness, inactivity, and scheduled morning/workout report notifications, but nothing for this.
Honest scope note
The official copy ("You've hit your target Strain of X for this activity!") implies a per-activity target from an undocumented proprietary model — NOOP has no visibility into that algorithm. This instead fires against the day's recovery-based optimal-Strain floor (`CoupledView.optimalStrainRange(recovery:).lowerBound`) — the one target NOOP can actually compute honestly — and the notification copy says "today", not "for this activity", so it never overclaims precision NOOP doesn't have.
What's added
Scope
iOS/macOS only, 4 files (1 new). No BLE/protocol/schema change — reads the existing `DailyMetric.strain`/`.recovery` fields and the existing `optimalStrainRange` helper. Android is a tracked fast-follow once this shape is confirmed (noted in #593).
Testing
macOS `Strand` scheme builds clean (0 errors) — `xcodebuild -scheme Strand -destination 'platform=macOS'`. Full `NOOPiOS` scheme not run locally (missing watchOS SDK in my environment, same constraint as prior PRs).