Skip to content

Add opt-in "optimal strain reached" notification — iOS/macOS twin (#593) - #597

Merged
ryanbr merged 1 commit into
mainfrom
feat/strain-target-notification-ios-593
Jul 19, 2026
Merged

ryanbr merged 1 commit into
mainfrom
feat/strain-target-notification-ios-593

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 19, 2026

Copy link
Copy Markdown
Owner

The Swift half of #593, twinning the Android implementation in #596 — the pure policy is byte-identical on both platforms (feature-level parity). Independent of #596 (no file overlap); the two can merge in either order.

What it does

Same opt-in, default-OFF nudge as the Android PR: once per day, when the day's Effort reaches the low end of today's recovery-derived optimal strain band (#43), post an "optimal strain reached" notification.

  • StrainTargetNotifier.StrainTargetPolicy — pure gate + copy, semantics identical to Android's StrainTargetPolicy: fires only when enabled, both day strain and target are known, strain ≥ target, not already fired today; a nil target (recovery calibrating) never fires. Copy is NOOP's own wording — not WHOOP's decompiled strings (same clean-room stance as Add opt-in "optimal strain reached" notification (#593) #596).
  • onDayUpdate — the BatteryNotifier idiom: requestAuthorization() up front when the toggle is enabled, status-only check at fire time, notification id "strain-target". The persisted day marker advances only after an authorized post, matching the Android notifier (a notifications-denied day still notifies once re-enabled while the same day shows the reached target).
  • BehaviorStore.strainTargetNudge — default OFF.
  • AppModel — the existing repo.$days sink also evaluates the strain target off the resolved today-row (repo.today, the same logical-day resolution every dashboard surface uses); converts stored 0–100 Effort → 0–21 via the shipped UnitFormatter.effortValue(_, .whoop) and gates on CoupledView.optimalStrainRange(recovery:).lowerBound — the exact same formatter + band the Android call site reads.
  • AutomationsView — a "Strain target" card next to Battery alerts. AutomationsView is shared, so the toggle is reachable on macOS and iOS (unlike the macOS-only NotificationSettings screen). Enabling it also evaluates immediately (the reevaluateIllness idiom), so flipping it on when today's target is already reached posts without waiting for the next refresh.
  • Localizable.xcstrings — 6 new keys with de/es/fr translations (informal de/es, formal fr, matching the existing catalog tone; Int interpolation as %lld per catalog convention).

Platform-appropriate divergences (deliberate)

  • Toggle lives in Automations (where battery/illness/inactivity live on Apple platforms) rather than a "Daily reports" section — Android's daily-reports settings screen has no Swift twin (Add confirmation dialog when ending an active activity #517 never landed on iOS), and Automations is the established home + is shared with iOS.
  • Dedupe day persists in UserDefaults (behavior.strainTargetLastDay), the BatteryNotifier convention, vs SharedPreferences on Android. Same semantics.

Tests

StrandTests/StrainTargetPolicyTests mirrors the Android StrainTargetPolicyTest byte-for-byte — same 7 fixtures, same expectations, including the copy assertions that NOOP's wording is present and WHOOP's strings are absent.

Verification (honest)

  • On Linux: swiftc -parse clean on all six touched files; Tools/i18n_audit.py --ci main → exit 0 (no new un-extracted literals, focus locales complete); xcstrings JSON validated.
  • Needs macOS: everything here is app-target (Strand/, StrandTests/) — not covered by swift-packages CI. An xcodebuild -scheme Strand … test / app-build.yml run is required to confirm compile + the 7 policy tests, and an on-device smoke test for actual delivery. New files are picked up by XcodeGen's directory globs (no project.yml change needed).

Swift twin of the Android strain-target nudge (PR #596), keeping the pure
policy byte-identical (feature-level parity contract).

- StrainTargetNotifier.StrainTargetPolicy: pure once-per-day crossing gate +
  NOOP's-own copy (NOT WHOOP's decompiled strings), semantics identical to the
  Android StrainTargetPolicy: fires only when enabled, both day strain and
  target known, strain >= target, and not already fired today; a nil target
  (recovery calibrating) never fires.
- StrainTargetNotifier.onDayUpdate: BatteryNotifier idiom (requestAuthorization
  up front on enable, status-only check at fire time); the persisted day marker
  advances only after an authorized post, matching the Android notifier.
- BehaviorStore.strainTargetNudge: default OFF, UserDefaults-backed.
- AppModel: the repo.$days sink also evaluates the strain target; converts the
  stored 0-100 Effort to the 0-21 coupled axis via UnitFormatter.effortValue
  and gates against CoupledView.optimalStrainRange(recovery:).lowerBound —
  the same shipped formatter + #43 band the Android call site uses.
- AutomationsView: "Strain target" card (shared, so reachable on macOS AND
  iOS), with the reevaluateIllness enable-time idiom so flipping it on when
  today's target is already reached posts without waiting for the next refresh.
- Localizable.xcstrings: 6 new keys with de/es/fr (audit --ci passes).

Tests: StrandTests/StrainTargetPolicyTests mirrors the Android
StrainTargetPolicyTest fixtures byte-for-byte (7 cases).

Verified on Linux: swiftc -parse clean on all touched files; i18n audit exit 0.
App-target compile + StrandTests need an xcodebuild/app-build.yml run (macOS).
@ryanbr
ryanbr merged commit 777a726 into main Jul 19, 2026
1 check passed
@ryanbr
ryanbr deleted the feat/strain-target-notification-ios-593 branch July 19, 2026 01:13
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…nbr#593) (ryanbr#597)

Swift twin of the Android strain-target nudge (PR ryanbr#596), keeping the pure
policy byte-identical (feature-level parity contract).

- StrainTargetNotifier.StrainTargetPolicy: pure once-per-day crossing gate +
  NOOP's-own copy (NOT WHOOP's decompiled strings), semantics identical to the
  Android StrainTargetPolicy: fires only when enabled, both day strain and
  target known, strain >= target, and not already fired today; a nil target
  (recovery calibrating) never fires.
- StrainTargetNotifier.onDayUpdate: BatteryNotifier idiom (requestAuthorization
  up front on enable, status-only check at fire time); the persisted day marker
  advances only after an authorized post, matching the Android notifier.
- BehaviorStore.strainTargetNudge: default OFF, UserDefaults-backed.
- AppModel: the repo.$days sink also evaluates the strain target; converts the
  stored 0-100 Effort to the 0-21 coupled axis via UnitFormatter.effortValue
  and gates against CoupledView.optimalStrainRange(recovery:).lowerBound —
  the same shipped formatter + #43 band the Android call site uses.
- AutomationsView: "Strain target" card (shared, so reachable on macOS AND
  iOS), with the reevaluateIllness enable-time idiom so flipping it on when
  today's target is already reached posts without waiting for the next refresh.
- Localizable.xcstrings: 6 new keys with de/es/fr (audit --ci passes).

Tests: StrandTests/StrainTargetPolicyTests mirrors the Android
StrainTargetPolicyTest fixtures byte-for-byte (7 cases).

Verified on Linux: swiftc -parse clean on all touched files; i18n audit exit 0.
App-target compile + StrandTests need an xcodebuild/app-build.yml run (macOS).
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…nbr#593) (ryanbr#597)

Swift twin of the Android strain-target nudge (PR ryanbr#596), keeping the pure
policy byte-identical (feature-level parity contract).

- StrainTargetNotifier.StrainTargetPolicy: pure once-per-day crossing gate +
  NOOP's-own copy (NOT WHOOP's decompiled strings), semantics identical to the
  Android StrainTargetPolicy: fires only when enabled, both day strain and
  target known, strain >= target, and not already fired today; a nil target
  (recovery calibrating) never fires.
- StrainTargetNotifier.onDayUpdate: BatteryNotifier idiom (requestAuthorization
  up front on enable, status-only check at fire time); the persisted day marker
  advances only after an authorized post, matching the Android notifier.
- BehaviorStore.strainTargetNudge: default OFF, UserDefaults-backed.
- AppModel: the repo.$days sink also evaluates the strain target; converts the
  stored 0-100 Effort to the 0-21 coupled axis via UnitFormatter.effortValue
  and gates against CoupledView.optimalStrainRange(recovery:).lowerBound —
  the same shipped formatter + ryanbr#43 band the Android call site uses.
- AutomationsView: "Strain target" card (shared, so reachable on macOS AND
  iOS), with the reevaluateIllness enable-time idiom so flipping it on when
  today's target is already reached posts without waiting for the next refresh.
- Localizable.xcstrings: 6 new keys with de/es/fr (audit --ci passes).

Tests: StrandTests/StrainTargetPolicyTests mirrors the Android
StrainTargetPolicyTest fixtures byte-for-byte (7 cases).

Verified on Linux: swiftc -parse clean on all touched files; i18n audit exit 0.
App-target compile + StrandTests need an xcodebuild/app-build.yml run (macOS).
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