Skip to content

feat(ios): add Target Strain Reached notification - #600

Closed
tigercraft4 wants to merge 1 commit into
ryanbr:mainfrom
tigercraft4:feat/target-strain-notification
Closed

feat(ios): add Target Strain Reached notification#600
tigercraft4 wants to merge 1 commit into
ryanbr:mainfrom
tigercraft4:feat/target-strain-notification

Conversation

@tigercraft4

Copy link
Copy Markdown

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

  • `StrainGoalNotifier` (new file): pure once-per-day crossing policy (`StrainGoalPolicy`, mirrors `BatteryAlertPolicy`'s structure) + a point-in-time notification post gated on live `UNUserNotificationCenter` authorization status — same safe pattern as `BatteryNotifier`/`IllnessNotifier` (not the old unconditional-schedule bug `WindDownNudge` had, fixed in fix(ios): request notification permission and surface denial for wind-down nudge #251).
  • `AppModel`: `evaluateStrainGoal(_:)` hooked into the same `repo.$days` sink as `evaluateIllness`; uses `Repository.widgetAnchor` to resolve which row is "Today" (same source Today/widget/Live Activity already agree on).
  • `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.

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).

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).
@ryanbr

ryanbr commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Thank you for this — the architecture instinct here is spot on (pure once-per-day crossing policy mirroring BatteryAlertPolicy, opt-in default-OFF, request-authorization-on-enable like IllnessNotifier), and the honesty note about not claiming a per-activity target NOOP can't compute is exactly the right call.

Closing as superseded, though — this landed in parallel with #596/#597, which already shipped the same feature on iOS/macOS (StrainTargetNotifier + the "Strain target" Automations card, merged). That's the coordination's fault, not yours — you called your shot on #593 and delivered. Because #597 touched the same three files, this PR is now conflicting anyway.

Two substantive reasons the merged version is the one to keep, in case they're useful for future work:

  1. Strain scale. DailyMetric.strain is stored 0–100 (Effort), but optimalStrainRange(...).lowerBound is on the 0–21 WHOOP axis. strain >= targetStrain here compares 0–100 against 0–21, so it fires at ~14% effort (almost immediately) and the body prints a 0–100 number labelled as WHOOP Strain. Add opt-in "optimal strain reached" notification — iOS/macOS twin (#593) #597 converts first via UnitFormatter.effortValue(strain, scale: .whoop) and compares like-for-like.
  2. "Today" resolution. Repository.widgetAnchor carries back to yesterday's row when today has no recovery yet (its purpose is the widget's recovery-carry display), so on an unscored morning it would evaluate yesterday's strain/target as "today". Add opt-in "optimal strain reached" notification — iOS/macOS twin (#593) #597 uses repo.today + optimalStrainRange(nil) → nil → no fire, which gates until today is actually scored.
  3. Minor: Add opt-in "optimal strain reached" notification — iOS/macOS twin (#593) #597 uses NOOP's own copy ("Optimal strain reached") rather than WHOOP's decompiled strings, per the clean-room point raised on Add "Target Strain Reached" notification (WHOOP-native feature, currently missing) #593.

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 StrainTargetPolicy is pure and easy to twin in Kotlin, and that PR would be very welcome. Thanks again.

@ryanbr ryanbr closed this Jul 19, 2026
@tigercraft4

Copy link
Copy Markdown
Author

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.

mohdarsal9-sudo pushed a commit to mohdarsal9-sudo/noop that referenced this pull request Jul 19, 2026
…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.
JNyyyyy pushed a commit to JNyyyyy/noop that referenced this pull request Jul 20, 2026
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.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
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.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
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.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…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.
ryanbr added a commit that referenced this pull request Jul 29, 2026
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).
ryanbr added a commit that referenced this pull request Jul 29, 2026
…#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).
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.

Add "Target Strain Reached" notification (WHOOP-native feature, currently missing)

2 participants