Skip to content

Strength logger layout, workout energy, Oura workouts, basal formula, session RPE and workout banner - #34

Merged
DX23876 merged 9 commits into
mainfrom
claude/zwei-bugs-untersuchen-lu00yv
Sep 26, 2026
Merged

DX23876 merged 9 commits into
mainfrom
claude/zwei-bugs-untersuchen-lu00yv

Conversation

@DX23876

@DX23876 DX23876 commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

What this PR does

Two user reports, then the review they led into.

1. Strength logger row overflow (c7f1e0b). A unilateral weighted exercise (weight + left + right steppers) needs ~424 pt against ~329 pt on a 393 pt iPhone; the overflowing row widened the whole card and clipped title, column labels and set numbers. Header and rows now go through one ViewThatFits: one line where it fits, otherwise reps move to a second line with a matching header.

2. Workout tile ~783 kcal vs. day "Training" 215 kcal (61cfef0). The strap energy model read its workout context from its own namespace list, which lacked the native training tables (and Hevy API, retained straps), so a native lifting session was priced as unexplained heart rate (0 active). The tile, with no stored figure, fell to Keytel at the average HR. Now the model reads rawWorkoutRows, the tile takes the model's own figure for its window (.strapModel), workout changes re-price their days, the refresh window starts at midnight (the oldest day was rewritten from its afternoon), and the finished-day burn-rate memo is cleared on republish. Model version whoop-bucket-v7.

Follow-ups agreed during review

  • 1ad8ab3 Oura cloud workouts (oura-api) join the list and the energy model; Oura's own source/sport vocabulary is normalised on read.
  • e432290 Confirmed workouts priced on ACSM's %HRR ≈ %VO2R (Swain & Leutholtz 1997) with a lifting share (0.6), replacing 1 + c·HRR² (lifting at 108 bpm ~2.1 → ~3.8 MET). Also: "Hiking" was classified other; "Strength Training" missed the MET table's strength entry; strap basal removal used the local day length on DST days.
  • eeb5565 No stored lifting figure comes from Keytel: live-session save and manual rescore go through WorkoutEnergyEstimate.boutKcal.
  • db74c65 The session-RPE push is sent only where it adds something heart rate cannot (SessionRatingPolicy): always for lifting, ball/racket, combat, swimming, unknown; for steady/low-load sports when ≥ 45 min, ≥ 50 % HRR or no HR. New setting "Ask to rate sessions".
  • 351a145 One basal formula for the whole history (Mifflin–St Jeor by default, retroactive), and it now reaches every energy path — before, the Energy Plan's choice changed only the Energy Plan. Katch–McArdle uses the body fat in force per day. A strap day's active energy is the model's stored figure, so a formula change never moves active energy.
  • 15da157, 0c2fa45 Workout Live Activity: own "Workout" switch (follows "Live heart rate" until set), strap-log lines for start / end / refusal / switch / iOS setting; Powerlifting and Calisthenics open the strength logger (CrossFit deliberately stays a heart-rate session).

Analysis migration required: no for every commit — recovery, sleep and strain do not read the energy model; energy has its own model version (v7) recomputed by the existing 120-day offload refresh. Rationale for each change is in docs/fork/decisions.md (2026-09-26 rows) and docs/ANALYTICS.md.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Documentation
  • CI / tooling

How it was tested

  • Swift Packages CI green on 351a145 (packages unchanged since): new tests in WorkoutEnergyEstimateTests, WhoopEnergyModelTests, ActivityMETCatalogTests, EnergyEngineTests, BasalFormulaTests, ManualWorkoutRescoreTests, SessionRatingPolicyTests.
  • App build on 0c2fa45: Strand (macOS) build + StrandTests green, NOOPiOS build green. New app tests: NativeWorkoutEnergyContextTests (native session → confirmed workout → tile reads the strap model), WorkoutSourceTests (Oura), SessionRatingCatalogTests, ActiveSessionControllerTests.
  • Expected numeric values were re-derived independently in Python before pushing.
  • Not tested on a device: the logger layout on an iPhone, the Live Activity, and real strap energy figures. No BLE code is touched.

Checklist

  • Swift package tests pass for any package I touched (swift test in Packages/<name>) — via CI
  • The affected Apple app schemes build (Strand and/or NOOPiOS) for app-layer changes — via CI
  • No new build warnings introduced — not checked
  • UI changes use only StrandDesign tokens — no hardcoded colors, fonts, or spacing
  • No hardcoded hex frame bytes; protocol facts live in the schema / decoders
  • Follows the conventions in docs/CONTRIBUTING.md
  • I did not commit generated output (Strand.xcodeproj/) or any secrets/signing material

Related issues

Reports from the #other-forks channel (strength logger overflow; strength session calories).

A unilateral weighted exercise ("Cable One Arm Lateral Raise") logs weight,
left reps and right reps: three steppers plus set, effort and check, about
424 pt of minimum width against the ~329 pt a 393 pt iPhone leaves inside
the card. The overflowing row widened the whole exercise card past the
screen, so the title, the column labels and the set numbers were clipped at
both edges. It read as a long-name bug; the name only showed where the
overflow landed. Weight plus reps (~330 pt) is already over on 375 pt phones.

The header and the rows of one exercise now go through one ViewThatFits:
one line where it fits, otherwise load, effort and check stay on the first
line and the repetitions move to a second, with a matching two-line header.
Bilateral bodyweight, duration and distance rows are unchanged.

No new strings. App-target Swift: not compiled locally (Linux).

Analysis migration required: no
… model

Reported: a 90-min strength session from the native logger (avg 108 bpm)
read ~783 kcal on its workout tile and 215 kcal as the day's "Training".

Two causes, neither a formula error:

- The strap energy model took its workout context from a hand-kept list of
  storage namespaces (`energyWorkoutIntervals`). The native training tables
  were not in it, nor the Hevy API namespace nor retained straps beyond the
  active and canonical one; dismissed detector bouts still were. The
  session's buckets were priced as `unresolvedElevatedHR` - zero active
  energy - under a band the burn-rate chart labelled as training.
- The native projection carries `energyKcal: nil`, so the tile fell to
  Keytel at the average heart rate: gross, linear in HR, with a 30 % HRR
  gate, where the bucket model uses its resistance curve.

Changes:

- `energyWorkoutIntervals` reads `rawWorkoutRows` + `dedupCrossSource`, the
  union the Workouts list and the burn-rate bands already read. Sport ->
  curve moves to `EnergyWorkoutKind.forSport` (StrandAnalytics, tested).
- `WhoopDailyEnergyEstimate.modelVersion` -> "whoop-bucket-v7" so stored
  days are recomputed by the existing 120-day offload refresh.
- `WorkoutEnergyEstimate` gains `.strapModel` between `.recorded` and
  `.heartRate`: the gross sum of the stored buckets over the session
  window (`strapWindowKcal`), answered only at >= 80 % coverage with >= 50 %
  of it priced as `confirmedWorkout`, so buckets computed before the
  session was saved never answer for it. List, detail and coach read it
  through `Repository.strapSessionEnergy`.
- Saving, editing, merging, deleting or importing a workout, and a Hevy
  sync that changed sessions, re-price the touched days
  (`Repository.scheduleEnergyRefresh`, coalesced, capped at 120 days).
- `refreshWhoopEnergyModel` starts its window at a local midnight. It
  started at `now - days`, and `replaceWhoopEnergyWindow` replaces each day
  wholesale, so the oldest day was rewritten from its afternoon alone.
- `noteEnergyPresentationChanged` clears the finished-day burn-rate memo,
  which previously outlived every refresh.

Tile and day line still differ by the window's basal share, by design:
the tile is gross like every other estimated branch, the day line active.

Tests: StrandAnalytics (strap window, precedence, sport kinds, v7 context
end to end at the model boundary) and StrandTests
(NativeWorkoutEnergyContextTests: native session -> confirmedWorkout ->
tile provenance .strapModel). Not run locally: no Swift toolchain here.
New strings translated into all nine locales.

Analysis migration required: no - recovery, sleep and strain do not read
this model; the energy rows carry their own version (v7) and refresh.
…rgy model

`OuraSyncWriter` banks ring workouts under "oura-api", and nothing read that
namespace: the sessions were in neither the Workouts list nor the strap
energy model's workout context. The rows also carry Oura's vocabulary, not
this app's: `source` holds Oura's origin enum, so "manual" classified as a
NOOP manual row (editable, and deletable through strap namespaces that do
not hold it) and "autodetected" fell through to Apple; `sport` holds tokens
like "strength_training" that the cross-source dedup cannot match against
the same session from Apple Health or the native logger.

- `WorkoutSource.oura` + `ouraSource`; `Repository.workoutNamespaces` reads
  "oura-api" (not deletable - imported history).
- `WorkoutSource.normalizedOuraRow`, applied on read: source -> "oura-api",
  sport -> "Strength Training" / "Walking". Rows already stored are
  corrected too and the store's natural key is left as written.
- Heart rate for an Oura session: strap first, then the ring's namespace.
- `ActivityContribution.Source.oura` counts Oura's workout calories as
  active energy (inferred: it is the figure Oura writes into Apple Health,
  whose workout energy is active by definition).
- Badges, source filter, detail and Training Load name "Oura"; one new
  accessibility string, translated into all nine locales.
- An Oura sync that brought workouts, or a disconnect, re-prices the energy
  model (`OuraConnectModel` is compiled only with OURA_CLOUD_IMPORT, so
  that call is not covered by the default CI build).

Tests: WorkoutSourceTests (classification, normalisation, namespaces,
Oura/native twin collapses to one), EnergyLoggedSessionTests (lane).

Analysis migration required: no
…ixes

Review of the calorie chain (basal, activity, workouts) after the reported
strength session. Findings fixed here:

- Workout curve. A confirmed workout was priced at `1 + c * HRR^2` (c = 11
  endurance / 9.5 other / 8 resistance), a curve with no source that sat
  far below every reference at moderate intensity: lifting at 108 bpm
  ~2.1 MET (Compendium 3.5-6), running at 155 bpm ~7 MET (the running
  itself ~10). Now `WhoopEnergyModel.exerciseMET`: ACSM's %HRR ~ %VO2R
  (Swain & Leutholtz 1997), `1 + share * HRR * (peakMET - 1)`, peak from
  Uth-Sorensen bounded 7-16 MET, share 1.0 endurance / 0.8 other / 0.6
  resistance (heart rate runs ahead of VO2 while lifting, Hurley 1984).
  The reported session: ~3.8 MET. The locomotion HR nudge keeps the old
  curve on purpose - it touches every walking minute of the day.
- Tile fallback for lifting. Without strap coverage a resistance session's
  average HR went to Keytel, which reads the pressor response as oxygen
  uptake (~783 kcal for the reported session). It is now priced on the same
  resistance curve plus basal (~474 kcal at 80 kg); Keytel stays for
  endurance, where it is validated.
- `EnergyWorkoutKind.forSport`: "Hiking" does not contain "hike" and was
  priced as `other`; elliptical, stair and rucking were `other` too. Found
  by the new classifier test, which failed CI on the previous commit.
- `ActivityMETCatalog`: the lookup is exact, so "Strength Training" (native
  logger, Hevy, Oura) and Apple's two strength types missed the "strength"
  entry and were priced at the unknown-sport default.
- `EnergyEngine` strap branch removed basal at `bmr / dayLength` from a
  total the bucket model priced at `bmr / 86 400`: on the 23-hour day ~an
  hour of basal too much came out of active energy, on the 25-hour day too
  little.

Part of the unreleased whoop-bucket-v7, so one recompute covers it.
Endurance and resistance sessions read higher than under v6 - by design.

Tests: WhoopEnergyModelTests (Compendium range at 108 bpm, linearity and
kind order, classifier spellings), WorkoutEnergyEstimateTests (resistance
HR fallback vs Keytel), ActivityMETCatalogTests (strength labels),
EnergyEngineTests (strap basal removal on 23/24/25-hour days). Expected
values checked against a Python re-derivation; not run locally.

Analysis migration required: no - energy model v7, recovery/sleep/strain
do not read it.
The previous change moved only the average-HR tile fallback off Keytel.
The two places that PERSIST a figure NOOP computed itself still called
Keytel directly, and a stored figure is shown as the session's own, ahead
of every estimate:

- a live session at save (`AppModel.endWorkout`), and
- the post-sync rescore of an under-scored manual row
  (`ManualWorkoutRescore.scored`).

Keytel was fitted on steady endurance exercise and reads lifting's pressor
response as oxygen uptake. Both now go through
`WorkoutEnergyEstimate.boutKcal`: a resistance sport is integrated sample
by sample on `WhoopEnergyModel.exerciseMET` (resistance share) plus basal,
with the same time weighting and 150 s gap cap as the Keytel integration;
every other sport returns exactly the Keytel figure as before. A 60-minute
lifting trace (sets at 125 bpm, rests at 100) now stores ~318 kcal instead
of ~461. The rescore takes the row's sport; without one it keeps Keytel.

Existing rows are not rewritten: the manual sheet accepts typed kcal, so a
stored value cannot be attributed to NOOP, and the rescore only ever raises
an under-scored figure.

Tests: ManualWorkoutRescoreTests (lifting on the resistance curve, other
sports and no sport keep Keytel; a steady trace integrates to the
average-HR price). Expected values checked against a Python
re-derivation; not run locally.

Analysis migration required: no - no stored value changes; new ones are
computed on the resistance curve.
The delayed "how did the session feel?" push fired after every session,
a stroll included: the answer is almost always 1-2 and heart rate already
measures that load, which teaches people to dismiss the prompt after the
lifting session it matters for.

`SessionRatingPolicy` (StrandAnalytics) groups every catalog sport and the
import spellings (Apple, WHOOP, Oura, German free text) by keyword:

- always asked: resistance, stop-and-go ball/racket sports, combat,
  swimming (wrist HR unreliable under water), and sessions NOOP cannot
  place ("Other", "Workout", free text);
- steady endurance and low-load activities (walking, hiking, cycling,
  yoga, golf, ...): asked when the session lasted >= 45 min, averaged
  >= 50 % of the heart-rate reserve, or has no heart rate. A long
  mountain hike is asked about; a 20-minute walk is not.

`Repository.shouldPromptSessionRating` applies it (reading the window's
average HR when the row has none) at all three places that schedule the
push: finishing a native workout (judged on its projected sport, not the
wearer's title), the Training Load refresh for imported sessions, and the
Session Load card. New setting "Ask to rate sessions": Off / When useful
(default) / Always, translated into all nine locales. The card stays on
every session's detail, so any session can still be rated.

Tests: SessionRatingPolicyTests (families for catalog and import names,
always-asked families, the walk/hike/run thresholds) and StrandTests
SessionRatingCatalogTests (every catalog sport but "Other" has a family).
Classification of all 101 catalog sports checked with a Python mirror.

Analysis migration required: no
The Energy Plan offers Harris-Benedict, Mifflin-St Jeor and Katch-McArdle,
but the choice reached only the Energy Plan and the coach. The day total,
the strap bucket model, the day's timeline and the session tiles all
called Harris-Benedict directly, so after a switch the plan and the Today
card disagreed about the same body.

- `UserProfile` carries `basalFormula` (default Harris-Benedict, so any
  profile built without one is unchanged) and `bodyFatPercent`;
  `Calories.bmrKcalPerDay(profile:)` evaluates it through `BasalRate`.
  Katch-McArdle without a reading in force falls back to Mifflin.
- `BmrFormulaLog.current`: the formula for EVERY day. The choice is a
  method, not a change in the body; switching it per day from a date put
  a step into trends, 30-day means and the intake/weight comparison.
  Weight and body fat stay per day. An untouched log answers Mifflin-St
  Jeor (the better height/weight regression) - retroactively, as agreed.
- `Repository.analyticsProfile` passes the formula; energy summaries, the
  bucket refresh, the timeline and the reference curves resolve body fat
  per day for Katch-McArdle (`bodyFatResolver`, no read otherwise).
- `EnergyEngine.DayInputs.strapActiveKcal`: a strap day's active energy is
  the model's stored figure (sum of its hourly rows), not total minus
  basal, so a formula change moves basal and total only - also on days
  older than the 120-day recompute.
- A switch re-prices the model's stored basal (`scheduleEnergyRefresh`).
- Energy Plan and switch sheet no longer promise "never rewrites
  history"; they say the formula applies to the whole history and, for
  Katch-McArdle, where the curve steps. Four new strings, nine locales.

Tests: BasalFormulaTests (untouched log -> Mifflin, explicit choice kept,
profile formula incl. Katch fallback), EnergyEngineTests (stored active
energy is not re-derived from basal). Not run locally.

Analysis migration required: no - recovery, sleep and strain do not read
basal; energy summaries are recomputed on read and the strap model is
re-priced by its own refresh.
1. The Lock Screen / Dynamic Island workout banner was gated on the
   "Live heart rate" switch, whose label only speaks of the connected
   strap, so turning off the everyday heart-rate banner silently removed
   the workout banner too. It now has its own switch, "Workout", in
   Settings > Live notifications (`UnitPrefs.workoutLiveActivityKey`).
   Unset, it follows the live-HR switch, so nobody who had turned that off
   gets a banner back unasked. Turning it off mid-session ends the banner;
   turning it on shows it (the controller re-publishes on the change).
2. The workout path wrote nothing to the strap log: a refusal from
   ActivityKit was swallowed and the switch skipped silently, so a log
   could not say why a Lock Screen stayed empty. It now logs, as
   "Workout banner: ...", started, ended, iOS Live Activities off, its
   switch off, waiting for the foreground and iOS's refusal with its
   reason - each skip reason once per session, not on every publish. A
   failed request also clears its state so the next publish asks again.
3. Powerlifting, CrossFit and Calisthenics picked from the "+" sport list
   open the strength logger instead of a plain heart-rate recording.

One new string (the switch's detail), nine locales. Tests:
ActiveSessionControllerTests covers the three added sports. The banner
itself needs a device; not tested here.

Analysis migration required: no
A WOD runs for time or as an AMRAP; nobody logs it set by set while the
clock runs, so routing CrossFit into the set logger (previous commit) was
wrong. Powerlifting and Calisthenics stay routed there. The test now
pins CrossFit on the heart-rate side.

Analysis migration required: no
Copilot AI balanced review requested due to automatic review settings September 26, 2026 20:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

DX23876 commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner Author

The github-advanced-security check (Code scanning AI findings) fails before it has looked at any code. The scanning agent's model request is rejected by the service: CAPIError: 400 The requested model is not supported (job log). This PR did not cause it. The same check failed the same way on #23, #30, #31, #32 and #33. Nothing in the repository fixes it. It is a code-scanning agent setting on GitHub's side, under the repository's Code security settings. A re-run was refused (403 This workflow run cannot be retried). All other checks are unaffected.


Generated by Claude Code

@DX23876
DX23876 merged commit 3acdf60 into main Sep 26, 2026
24 of 25 checks passed
@DX23876
DX23876 deleted the claude/zwei-bugs-untersuchen-lu00yv branch September 28, 2026 08:24
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.

2 participants