Repository navigation
Strength logger layout, workout energy, Oura workouts, basal formula, session RPE and workout banner - #34
Merged
Conversation
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
Owner
Author
|
The Generated by Claude Code |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 oneViewThatFits: 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 readsrawWorkoutRows, 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 versionwhoop-bucket-v7.Follow-ups agreed during review
1ad8ab3Oura cloud workouts (oura-api) join the list and the energy model; Oura's ownsource/sportvocabulary is normalised on read.e432290Confirmed workouts priced on ACSM's %HRR ≈ %VO2R (Swain & Leutholtz 1997) with a lifting share (0.6), replacing1 + c·HRR²(lifting at 108 bpm ~2.1 → ~3.8 MET). Also: "Hiking" was classifiedother; "Strength Training" missed the MET table's strength entry; strap basal removal used the local day length on DST days.eeb5565No stored lifting figure comes from Keytel: live-session save and manual rescore go throughWorkoutEnergyEstimate.boutKcal.db74c65The 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".351a145One 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,0c2fa45Workout 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) anddocs/ANALYTICS.md.Type of change
How it was tested
351a145(packages unchanged since): new tests inWorkoutEnergyEstimateTests,WhoopEnergyModelTests,ActivityMETCatalogTests,EnergyEngineTests,BasalFormulaTests,ManualWorkoutRescoreTests,SessionRatingPolicyTests.0c2fa45:Strand(macOS) build +StrandTestsgreen,NOOPiOSbuild green. New app tests:NativeWorkoutEnergyContextTests(native session → confirmed workout → tile reads the strap model),WorkoutSourceTests(Oura),SessionRatingCatalogTests,ActiveSessionControllerTests.Checklist
swift testinPackages/<name>) — via CIStrandand/orNOOPiOS) for app-layer changes — via CIStrandDesigntokens — no hardcoded colors, fonts, or spacingdocs/CONTRIBUTING.mdStrand.xcodeproj/) or any secrets/signing materialRelated issues
Reports from the #other-forks channel (strength logger overflow; strength session calories).