Skip to content

Add a daily movement score and heart-rate training load - #125

Open
ak710 wants to merge 1 commit into
saksham2001:mainfrom
ak710:feat/activity-score-training-load
Open

Add a daily movement score and heart-rate training load#125
ak710 wants to merge 1 commit into
saksham2001:mainfrom
ak710:feat/activity-score-training-load

Conversation

@ak710

@ak710 ak710 commented Aug 3, 2026

Copy link
Copy Markdown

Closes #116

Merge order: 6 of 9. Branches off main, independent of everything before it.

Movement score

Four contributors scored against the user's own goals, not a population target: steps (35), active minutes (30), active energy (20), movement spread through the day (15). The goals already exist and are editable; a fixed 10,000 steps would tell a marathoner and someone recovering from surgery the same thing.

Exceeding a goal is never penalised — overreaching is what training load is for.

The regularity contributor separates a day that hit its step goal in one gym session from one that moved throughout: 250 steps/hour (the stand-hour convention) over 08:00–22:00, judged against the hours the ring actually reported buckets for, so a ring taken off at lunch isn't scored for the afternoon it never saw.

Missing contributors leave the denominator: a ring-history day has no trustworthy calorie figure (buildTodaySummary already blanks it) so it's scored out of 80; a ring with no intraday buckets, out of 85.

Training load

Edwards' summated heart-rate zones, not Banister's TRIMP. Banister needs a reliable average HR over a bounded session; all-day ring data is sparse, irregularly spaced and has no session boundaries, so Edwards — which only needs time in each zone — degrades far better. Zone floors match the ones the workout summary already draws.

Each reading is credited the gap to the next, capped at ~2× the median spacing and never over five minutes. Without that, two all-day readings 12 h apart become "12 hours of zone-1 work" — testAnOvernightGapIsCappedNotCredited.

Acute (7-day) vs chronic (28-day) mean daily load, with the usual bands.

Days with no readings are excluded from both means, never counted as rest. A week the ring wasn't worn is not a week of recovery, and averaging in zeros would manufacture a "detraining" reading out of a charging cable — testUnwornDaysAreExcludedNotCountedAsRest. The ratio is withheld entirely below 14 of 28 covered days.

Placement

Activity tab, under the rings they summarise — not Today, which is already a dense tile grid. Breakdown disclosed on tap. Absent entirely on a day with no activity row, since an empty dial reads as a zero.

Testing

ActivityScoreTests (14) + TrainingLoadTests (13). Full suite green (898). Docs: docs/project/activity-score.md.

Two SwiftLint statement_position violations my if/else chains introduced are fixed as switches — and converting the band chain changed the boundary semantics, so testBandBoundariesAreInclusiveAtTheTop now pins that 1.3 stays "steady".

PulseLoop had goals, rings and a readiness score but no answer to "how well did
I move today?" — the thing Oura calls an Activity Score and Ultrahuman a
Movement Index. And nothing weighed this week's effort against recent normal.

The movement score is four contributors scored against the user's own goals
rather than a population target: steps (35), active minutes (30), active energy
(20), and movement spread through the day (15). Goals already exist and are
already editable, and a score built on a fixed 10,000 steps would tell a
marathoner and someone recovering from surgery the same thing.

Exceeding a goal is never penalised. Overreaching is what training load is for;
a movement score that docked a long hike would be actively misleading.

The regularity contributor is what separates a day that hit its step goal in one
gym session from one that moved throughout. An hour counts as active at 250+
steps (the stand-hour convention, not a threshold invented here), judged only
over 08:00-22:00, and against the hours the ring actually reported buckets for —
so a ring taken off at lunchtime isn't scored for the afternoon it never saw.

Missing contributors leave the denominator rather than scoring zero, as sleep
and readiness do: a ring-history day has no trustworthy calorie figure so it is
scored out of 80, and a ring with no intraday buckets out of 85.

Training load uses Edwards' summated heart-rate zones rather than Banister's
TRIMP. Banister needs a reliable average HR over a bounded session; all-day ring
data is sparse, irregularly spaced and has no session boundaries, so Edwards —
which only needs time in each zone — degrades far better. Zone floors match the
boundaries the workout summary already draws, so the two can't disagree. Each
reading is credited the gap to the next, capped at ~2x the median spacing and
never over five minutes, so an overnight sampling gap can't become the biggest
session of the week.

Acute (7-day) against chronic (28-day) mean daily load, with the usual bands.
Days with no readings are excluded from both means, never counted as rest — a
week the ring wasn't worn is not a week of recovery, and averaging in zeros
would manufacture a "detraining" reading out of a charging cable. The ratio is
withheld entirely until 14 of the 28 chronic days carry data.

Both live on the Activity tab under the rings they summarise, not on Today,
which is already a dense tile grid. The contributor breakdown is disclosed on
tap. Full tables and rationale in docs/project/activity-score.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ak710
ak710 requested a review from saksham2001 as a code owner August 3, 2026 06:25
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.

[Feature]: No daily movement score, and no training load

1 participant