Log and retime completed workouts - #189
Conversation
The strap's auto-detector reports an effort's HARD-EFFORT CORE, not its
wall-clock: a minute only counts at >= RHR + 0.45*(HRmax-RHR), a dip over
90 s breaks the span, and the survivor must hold >= 12 min. That tuning is
right for a "did you work out?" prompt — it was calibrated against a
sedentary week where a looser gate fired 30 false windows — but it means
warm-up, cool-down and inter-set rest fall out, so an hour of mixed-intensity
training routinely surfaces as ~25 minutes.
There was no way to say otherwise. The only session writer was startWorkout,
which stamps DateTime.now(), and the only edit on a saved session was its
type. Reported by a user whose hour of training logged as 25 minutes.
Two ways in now: an "Add" pill in the Workouts header, and the session's time
window on the detail hero, which now reads as a range ("Today - 4:00 PM -
4:42 PM") and taps through to the same form. A start time alone cannot show
that a window was clipped.
Scoring reuses what was already there: getWorkout enriches from the 1 Hz
substrate over [start_ts, end_ts], so a corrected window gets its HR curve,
avg/min/max, zone bands, drift and recovery curve for free. Only strain and
calories needed a producer, and they go through the published methods the
day-level pipeline already uses — Banister TRIMP -> log squash, Keytel 2005.
No kAlgoVersion bump: sessions do not feed day_result (derivation reads them
only as savedSpans and to backfill hrr_bpm), so no analytics output moved.
Honesty contract holds. Every anchor is a term in its formula, so a missing
one abstains rather than defaulting — including calories, where
estimateBoutCalories' own usedDefaultAnchors flag is honoured instead of
banking a figure built on a 60 bpm guess. A window past the ~3-day
decoded_onehz retention saves with null strain/calories and the form says so
plainly rather than showing silent blanks.
Also here:
* GPS routes now follow their session's window. routePoints had no window
filter, so a retimed session kept its original map, distance, moving time
and splits — narrow a 90-minute run to the 60 you actually ran and the card
claimed 60 minutes over 90 minutes of GPS. Clipped to the window (+/-5 s,
since GPS is ms-stamped and the window is whole seconds); under two
surviving fixes yields no map rather than an orphaned pin. The route rows
survive, so widening back restores it.
* Confirming an auto-detected suggestion now scores it. That path hand-built
a row with no strain and no calories, so every accepted suggestion landed
in the log showing blanks — the numbers were computable from the substrate
the whole time, nothing was asking for them.
* The write seam re-validates and throws ManualWindowException. The form
validates live, but its snapshot of saved spans can go stale mid-edit and
it is not the only possible caller.
* pickWorkoutType moved to workout_types.dart, next to the table it renders,
so the new form can reach it without the two screen files importing each
other.
"Log a past workout" first went in as a fourth child of the start bottom
sheet, where it was invisible and untappable: the sheet defaults to
isScrollControlled:false, capping it at 9/16 of the screen, and the nine type
tiles already wrap to three rows. It overflowed by 11 px and the row fell off
the bottom edge — in release there are no overflow stripes, so nothing
announced it. Hence the header pill, a note on startWorkoutFlow, and a test
that fails if the sheet is grown again (verified by reintroducing the bug).
Three things put a number on the 0-21 strain dial and only one of them was using the documented method: daily strain Banister TRIMP -> min(21, ln(trimp+1)/ln(1.5)) a live session strain += %HRR * 0.01, accrued per second an auto-detected nothing written at all Measured for the same hour at 150 bpm (age 30, RHR 55): the live accrual reads 25.33 where the canonical method reads 11.62. That is 2.18x, and it is off the top of its own scale — it passes 21 after about 50 minutes at that intensity, after which the gauge clamps to full while the number beside it keeps climbing. "Workout strain" and "daily strain" looked like the same quantity and were not comparable. This is pre-existing, but manual logging puts both numbers in one list where they sit next to each other, so it had to be settled rather than documented. Live strain is no longer accrued. accrueHr folds 1 Hz samples into per-minute means — the unit Banister weights — and recomputes through the shared strainFromPerMinuteHr. The in-progress minute counts, so the gauge moves inside the first 60 s instead of sitting at zero. (The auto-detected path was routed through the same function in the previous commit.) Anchors are honest now too. The old path substituted 30 y / 70 kg / 60 bpm when the profile was empty, which turned an absent input into a confident-looking score. Every one of those is a term in the formula, so a missing one abstains: LiveWorkoutState.strain is nullable, the arc sits empty and the readout is a dash. _liveRestingHr prefers the MEASURED nightly rhr over the user-typed one and never falls back to 60 — the display-only _restingHr still does, which is fine for copy but not as a term inside a formula. That nullability carries to WorkoutFinishSnapshot, so an unscored session shows a dash on the finish card instead of a confident 0.0, and cannot accidentally trip the top-workout PR check. No kAlgoVersion bump: session strain lives in `sessions`, not day_result. Live CALORIES have the same shape of problem — an inline Keytel copy that falls back to 30 y / 70 kg / male where the offline scorer abstains. Left alone deliberately; it is a separate user-visible change. One test records the retired formula's 25.33 and asserts the >2x gap, so the regression is on the record rather than only in this message.
|
Warning Review limit reached
Next review available in: 31 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (10)
📝 WalkthroughWalkthroughThis change adds manual workout entry and retiming, shared session validation and HR scoring, repository persistence, route clipping, detector suggestion retirement, personalized live strain, and related UI and integration tests. ChangesWorkout session lifecycle
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Athlete
participant ManualWorkoutScreen
participant LocalRepository
participant ManualSessionPolicy
participant LocalDatabase
Athlete->>ManualWorkoutScreen: Enter workout window and type
ManualWorkoutScreen->>LocalRepository: Save or retime workout
LocalRepository->>ManualSessionPolicy: Validate window and compute statistics
ManualSessionPolicy-->>LocalRepository: Session row and nullable metrics
LocalRepository->>LocalDatabase: Persist session
LocalRepository-->>ManualWorkoutScreen: Return save result
ManualWorkoutScreen-->>Athlete: Show success or validation error
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
PR Reviewer Guide 🔍(Review updated until commit b893f22)Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Latest suggestions up to b893f22
Previous suggestionsSuggestions up to commit a53234b
|
There was a problem hiding this comment.
Actionable comments posted: 12
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
lib/state/app_state.dart (1)
3575-3595: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy liftCapture the refreshed RHR anchor before creating
LiveWorkoutState.
_refreshNightlyRhr()assigns_nightlyRhronly after its database await. Both flows constructLiveWorkoutStatewith the pre-refresh_liveRestingHr, andrestingHris final. A nightly RHR derived after initialization cannot score the current session when no manual RHR exists, andstopWorkout()then persists null strain.Load the anchor before constructing either state. If startup becomes asynchronous, re-check
activeWorkoutafter the await and update all start callers. Add a delayed-loader regression test for both new and resumed sessions.As per coding guidelines, cover every call path when adding or changing a capability.
Also applies to: 3770-3782
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@lib/state/app_state.dart` around lines 3575 - 3595, Ensure both new and resumed session start flows await _refreshNightlyRhr() before constructing LiveWorkoutState, so restingHr uses the refreshed anchor when no manual RHR exists. Because startup becomes asynchronous, re-check activeWorkout after the await and update every start caller accordingly; add delayed-loader regression coverage for both paths.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@lib/compute/manual_session.dart`:
- Around line 300-316: Apply the worn-sample filter to the calorie calculation
in computeManualSessionStats by building timestamped worn pairs and passing
their timestamps and heart rates to ana.Calories.estimateBoutCalories instead of
the raw hrTs and hrBpm; update test/manual_session_test.dart lines 344-357 to
assert the dropped-minute window produces the same calories as the equivalent
worn-only window.
In `@lib/data/local_repository_impl.dart`:
- Around line 2038-2043: Update the type guard that constructs SessionSpan so
both start_ts and end_ts are checked as num, matching the existing numeric casts
and allowing double values to be retained. Keep the id String check and
SessionSpan conversion unchanged.
- Around line 2083-2097: Update setWorkoutWindow to reject existing sessions
whose status is live before calling _writeManualSession, preserving the existing
behavior for non-live sessions. Use the existing row’s status value and throw an
appropriate StateError at this repository write seam.
In `@lib/data/local_repository.dart`:
- Around line 118-148: Update the return-value documentation for
logManualWorkout, logDetectedWorkout, and setWorkoutWindow to include the
hr_samples key alongside workout_id and unscored, matching
LocalRepositoryImpl._writeManualSession and the existing test contract.
In `@lib/ui/workouts/manual_workout_screen.dart`:
- Around line 116-127: Update _pickDate so showDatePicker always receives an
initialDate within its firstDate/lastDate range: clamp _start to firstDate when
it predates the allowed boundary, while preserving the existing date limits and
picker behavior.
- Around line 339-351: Update _dateLabel to use dayLabelOf and todayLabel from
data/day_label.dart for the Today/Yesterday decision instead of comparing
DateTime differences with inDays. Compare local calendar-day labels, preserve
the existing Today/Yesterday and month-day display behavior, and follow the
repository’s day-label helpers without assuming fixed day lengths.
In `@lib/ui/workouts/workouts_screen.dart`:
- Around line 561-580: Update the workout logging try/catch around
logDetectedWorkout so ManualWindowException from _writeManualSession is rethrown
and never reaches the hand-built fallback row. Keep fallback behavior for other
unexpected errors, and add the compute/manual_session.dart import if needed;
preserve the successful path that dismisses the suggestion and exports the saved
session.
In `@test/core_screens_test.dart`:
- Around line 468-473: Update the positive retime-affordance test around the
tapped finder to use find.bySemanticsLabel(RegExp('Edit workout times')) instead
of find.textContaining('–'). Keep the existing tap and assertion unchanged so
both positive and negative cases target the same semantics-labeled control
without relying on tree order.
In `@test/manual_session_test.dart`:
- Around line 344-357: Add a calories assertion to the off-skin zero-sample test
for computeManualSessionStats, verifying that zero heart-rate samples do not
alter the expected calorie result alongside avgHr and hrSampleCount.
In `@test/manual_workout_repo_test.dart`:
- Around line 335-355: The tests “retiming preserves the route rows themselves
(id is stable)” and “savedSessionSpans surfaces saved windows for the overlap
check” depend on data created by earlier tests. Seed the required w-route rows
inside the first test and create a saved session/window inside the second before
calling LocalDb.routePoints or repo.savedSessionSpans, so each test passes
independently without relying on shared test state.
- Around line 364-374: Update the rejected-write assertions in the overlap,
future-window, and sub-minute tests to await the asynchronous expectation using
await expectLater before querying the database. Preserve the existing
ManualWindowException and error assertions, and ensure each “Nothing was
written” check runs only after logManualWorkout has completed and been rejected.
In `@test/workouts_header_actions_test.dart`:
- Around line 129-132: Add a unique key to the workout sheet body’s SafeArea and
update the height assertion in the relevant test to locate that widget by key
instead of using find.byType(SafeArea).last. Keep the existing size threshold
and assertion behavior unchanged.
---
Outside diff comments:
In `@lib/state/app_state.dart`:
- Around line 3575-3595: Ensure both new and resumed session start flows await
_refreshNightlyRhr() before constructing LiveWorkoutState, so restingHr uses the
refreshed anchor when no manual RHR exists. Because startup becomes
asynchronous, re-check activeWorkout after the await and update every start
caller accordingly; add delayed-loader regression coverage for both paths.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: b367c34a-5f94-4115-816e-78a5f61f66a0
📒 Files selected for processing (13)
lib/compute/manual_session.dartlib/data/local_repository.dartlib/data/local_repository_impl.dartlib/state/app_state.dartlib/ui/activity/live_session_screen.dartlib/ui/workouts/manual_workout_screen.dartlib/ui/workouts/workout_types.dartlib/ui/workouts/workouts_screen.darttest/core_screens_test.darttest/live_strain_convergence_test.darttest/manual_session_test.darttest/manual_workout_repo_test.darttest/workouts_header_actions_test.dart
| final window = find.textContaining('–'); | ||
| expect(window, findsWidgets); | ||
|
|
||
| await t.tap(window.first); | ||
| await t.pump(); | ||
| expect(tapped, 1); |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Target the retime affordance by its semantics label.
find.textContaining('–') also matches the zone rows, which render '${lo}–${hi} bpm' with the same en dash. The test then relies on tree order for window.first. The negative test below already uses find.bySemanticsLabel(RegExp('Edit workout times')); use the same finder here so the positive and negative cases assert the same thing.
♻️ Proposed change
- // A range, not just the start — "Today · 4:00 PM – 4:42 PM".
- final window = find.textContaining('–');
- expect(window, findsWidgets);
-
- await t.tap(window.first);
+ // A range, not just the start — "Today · 4:00 PM – 4:42 PM".
+ final window = find.bySemanticsLabel(RegExp('Edit workout times'));
+ expect(window, findsOneWidget);
+
+ await t.tap(window);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| final window = find.textContaining('–'); | |
| expect(window, findsWidgets); | |
| await t.tap(window.first); | |
| await t.pump(); | |
| expect(tapped, 1); | |
| final window = find.bySemanticsLabel(RegExp('Edit workout times')); | |
| expect(window, findsOneWidget); | |
| await t.tap(window); | |
| await t.pump(); | |
| expect(tapped, 1); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test/core_screens_test.dart` around lines 468 - 473, Update the positive
retime-affordance test around the tapped finder to use
find.bySemanticsLabel(RegExp('Edit workout times')) instead of
find.textContaining('–'). Keep the existing tap and assertion unchanged so both
positive and negative cases target the same semantics-labeled control without
relying on tree order.
Twelve inline findings plus one outside the diff. Verified each against the
code first; all were real, several in ways worth recording.
Correctness
* computeManualSessionStats dropped off-skin samples for avgHr, maxHr,
hrSampleCount, strain and the zone bands, but forwarded the raw list to the
calorie estimator — which bills every sample under the active threshold at
the RESTING rate, so a window with lost contact bought basal kcal that every
other field had correctly discarded. The filter now runs once and everything
downstream reads the survivors. Production could not reach this
(hrSamplesInRange filters hr > 0 in SQL) but nothing in the function said so.
* _logDetectedSession caught ManualWindowException and fell through to the
hand-built row: a refusal became a duplicate write under the same auto: id,
stripped back to the blank strain and calories this change set out to
remove, bypassing the overlap rule the write seam exists to enforce. A
refusal now retires the suggestion and stops; the fallback stays for genuine
failures.
* showDatePicker asserts initialDate is on or after firstDate
(date_picker.dart:235). firstDate was pinned to 1 January of last year while
initialDate came from the session, so retiming anything older threw instead
of opening the picker. Sessions are never pruned and an import can bring in
years of history. The floor now widens to admit whatever is being shown.
* _dateLabel decided Today/Yesterday from today.difference(that).inDays. That
is elapsed time between two local midnights, and a spring-forward day is
23 h, which truncates to 0 — yesterday would render as "Today". Now compares
dayLabelOf/todayLabel, per the repository's own day-label rule. NOTE: the
pre-existing _dayLabel and _whenLabel in workouts_screen.dart carry the same
pattern and are left alone here.
* startWorkout fired _refreshNightlyRhr() unawaited and then immediately
constructed LiveWorkoutState with the pre-refresh anchor, so the comment
claiming it picked up a measured RHR "before this session is scored" was
simply false. restingHr is no longer final and the refresh back-fills it when
the read lands — but only to fill a gap, since overwriting an anchor a
running session was already scored against would move its number mid-workout.
* setWorkoutWindow accepted a status='live' row and buildManualSessionRow
always writes 'done', so retiming a running session silently ended it. The
detail screen already guarded this, but the window is re-validated at this
seam precisely because the form is not the only caller.
* savedSessionSpans guarded on `is int` while casting `as num`; a double would
have been dropped, silently disabling the overlap check for that session.
Tests
* Two tests read rows an earlier test happened to leave behind. Confirmed by
running one alone: 0 route rows instead of >2. Both now seed their own data.
* Three rejection tests used expect() on an async callback, which returns
before the future settles, so the "nothing was written" query raced the
rejection. Now await expectLater.
* The off-skin test asserted avgHr and hrSampleCount but not calories — the
exact field that was wrong. It now pins calories, strain and zones against an
equivalent worn-only window.
* The retime-affordance test matched find.textContaining('–'), which also
matches the zone rows ("133–152 bpm"), so it relied on tree order. Both cases
now target a Key.
* The sheet-height assertion used find.byType(SafeArea).last, which depended on
how many SafeAreas the harness nested.
Not done, deliberately
The review suggested targeting the affordance by semantics label. That finder
returns nothing here: the annotation merges into an ancestor card's node, and
tester.getSemantics resolves to that ancestor (a 350x275 rect) rather than to
the label. container: true and excludeSemantics: true were tried to force a
standalone node and could not be shown to work. Excluding the child's semantics
on that evidence would risk dropping the window text from the merged
announcement while gaining nothing — the same trap as excluding semantics
without re-exposing the action. Left as a plain annotation with a note, and the
tests target a Key instead. This also means the ORIGINAL negative assertion
(bySemanticsLabel, findsNothing) was passing for the wrong reason, since that
finder could never have matched.
flutter analyze clean; 1201 tests green against the pinned sibling SHAs.
|
Persistent review updated to latest commit b893f22 |
User description
Why
A user reported an hour of training logging as ~25 minutes. Nothing was broken — the auto-detector reports an effort's hard-effort core, not its wall-clock. A minute only counts at
>= RHR + 0.45*(HRmax-RHR)(hrrFloorFraction), a dip over 90 s breaks the span (maxDipS), and the survivor must hold >= 12 min (minSustainedMin). That tuning is correct for a "did you work out?" prompt — the comments record it being calibrated against a sedentary week where a looser gate fired 30 false windows — but warm-up, cool-down and inter-set rest fall out of it.There was no way to say otherwise. The only session writer was
startWorkout, which stampsDateTime.now(); the only edit on a saved session was its type.What
Two ways in. An Add pill in the Workouts header, and the session's time window on the detail hero — now rendered as a range (
Today · 4:00 PM – 4:42 PM) that taps through to the same form. A start time alone can't show that a window was clipped.Scoring reuses what was already there.
getWorkoutalready enriches from the 1 Hz substrate over[start_ts, end_ts], so a corrected window gets its HR curve, avg/min/max, zone bands, drift and recovery curve for free. Onlystrainandcalorieslacked a producer, and they go through the published methods the day pipeline already uses.No
kAlgoVersionbump. Sessions don't feedday_result— derivation reads them only assavedSpans(auto-detect exclusion) and to backfillhrr_bpm— so no analytics output moved. Verified the pinned analytics SHAf0d1153actually contains every symbol used (banisterTrimp,estimateBoutCalories,Sex,WorkoutUserProfile) with matching signatures, per the v43 drift lesson.The strain convergence (second commit)
Three things put a number on the 0–21 dial and only one used the documented method:
min(21, ln(trimp+1)/ln(1.5))strain += %HRR * 0.01/sec2.18x apart, and the live figure is off the top of its own scale — it passes 21 after ~50 min, after which the gauge clamps to full while the number keeps climbing. Pre-existing, but manual logging puts both in one list side by side, so it needed settling rather than documenting.
Live strain is no longer accrued:
accrueHrfolds 1 Hz samples into per-minute means (the unit Banister weights) and recomputes through the sharedstrainFromPerMinuteHr. The in-progress minute counts, so the gauge moves inside the first 60 s.Anchors are honest now. The old path substituted 30 y / 70 kg / 60 bpm on an empty profile, turning an absent input into a confident-looking score. Each is a term in the formula, so a missing one abstains —
LiveWorkoutState.strainis nullable, the arc sits empty, the readout is a dash._liveRestingHrprefers the measured nightlyrhrand never falls back to 60.Also fixed
GPS routes now follow their session's window.
routePointshad no window filter, so a retimed session kept its original map, distance, moving time and splits — narrow a 90-minute run to the 60 you actually ran and the card claimed 60 minutes over 90 minutes of GPS. Clipped to the window (±5 s, since GPS is ms-stamped and the window is whole seconds); under two surviving fixes yields no map rather than an orphaned pin. Route rows survive, so widening back restores it.Confirming an auto-detected suggestion now scores it. That path hand-built a row with no
strainand nocalories, so every accepted suggestion landed showing blanks.The write seam re-validates and throws
ManualWindowException— the form validates live, but its snapshot of saved spans can go stale mid-edit and it isn't the only possible caller.A bug worth recording
"Log a past workout" first went in as a fourth child of the start bottom sheet, where it was invisible and untappable.
showModalBottomSheetdefaults toisScrollControlled: false, capping it at 9/16 of the screen; the nine type tiles already wrap to three rows. It overflowed by 11 px and the row fell off the bottom edge — in release there are no overflow stripes, so nothing announced it. Hence the header pill, a note onstartWorkoutFlow, and a test that fails if the sheet is grown again (verified by reintroducing the bug and watching it fail).Testing
flutter analyzeclean; 1200 tests green (was 1156 on this base), run against the pinned sibling SHAs withpubspec_overrides.yamlremoved so it matches CI. Both commits verified to compile and pass standalone.manual_session_test.dart— validation, per-minute folding, zone bands, the honesty contract, row buildingmanual_workout_repo_test.dart— the write path against a real DB, including that widening a clipped window actually raises the numbers, and the route clippinglive_strain_convergence_test.dart— the live accumulator; one test records the retired formula's 25.33 and asserts the >2x gapworkouts_header_actions_test.dart— header fits at 375/390 pt in both palettes; the sheet stays under its capNot in scope
Live calories have the same shape of problem — an inline Keytel copy falling back to 30 y / 70 kg / male where the offline scorer abstains. Left deliberately; separate user-visible change.
Retiming is realistically a within-3-days feature (
rawRetentionDays = 3). Older windows save with null strain/calories and the form says so plainly.🤖 Generated with Claude Code
PR Type
Enhancement, Bug fix, Tests
Description
Add "Log a past workout" and "Edit times" flows so athletes can correct auto-detected session windows
Unify all strain scoring onto one Banister TRIMP → log-squash method, retiring the uncited
%HRR × 0.01/slive accrual that exceeded 21 after ~50 minEnforce honesty contract: absent HR, RHR, age, weight, or sex yields null strain/calories, never a fabricated figure
Add 4 new test files covering pure policy, repo wiring, live strain convergence, and header action smoke tests
Diagram Walkthrough
File Walkthrough
3 files
New pure policy module for manual/retimed workout windowsAdd Add pill, retime affordance, and scored detected-session loggingNew full-screen form for logging and retiming completed workouts1 files
Replace live strain accrual with Banister TRIMP; add nightly RHRrefresh2 files
Pure-policy unit tests for validation, scoring, and honesty contractRepo-level integration tests against real sqflite for log/retime flows7 files