From d962a2d58298e8bbaf21422559a76ff5fd451801 Mon Sep 17 00:00:00 2001 From: Boris 'B' Kurktchiev Date: Tue, 29 Sep 2026 10:18:44 -0400 Subject: [PATCH] fix(progression): an imported session is graded on all of its sets After a Hevy or CSV import, the first weight the app suggests for a lift can come from the light early sets. A session logged 40/50/60/70/80 x 5 reads as a 60 kg session when the routine asks for 3 sets today, and the next prescription is 62.5. #233 grades only the sets the plan asked for, and readSession takes that count from `target`: `(entry && entry.target) || fallback`. The importers write no target, and neither did anything logged before v1.2.2, so for those the count is the exercise's current config, which sessionsFor passes as the fallback. The slice then keeps the first three rows of a session nobody planned as three sets. readSession now slices only when the entry carries its own target. An entry without one is graded on every work set it has, as it was before #233. api/coach/core/payload.js keeps its own copy of readSession and gets the same rule, so the Coach's stall count agrees with the app. progression.extra-sets.test.js: the ramp reads 80 (was 60) and the next prescription is 82.5 (was 62.5); both fail on the old progression.js. payload-parity.test.js: an imported session with a short fourth set is a miss on both sides; it fails with either copy reverted. Co-Authored-By: Claude Opus 5.5 (1M context) --- api/coach/core/payload.js | 5 +++- api/test/payload-parity.test.js | 13 ++++++++++ .../src/lib/progression.extra-sets.test.js | 26 ++++++++++++++++++- frontend/src/lib/progression.js | 7 ++++- 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/api/coach/core/payload.js b/api/coach/core/payload.js index 3830a7cad..c4421fa12 100644 --- a/api/coach/core/payload.js +++ b/api/coach/core/payload.js @@ -157,7 +157,10 @@ function readSession(entry, fallback) { // app and a miss here — and stallCount, reading only this copy, reported a stall the athlete // never had. `count` below stays the real total: extra sets are exactly how bodyweight work // is meant to grow (#33), they just do not decide whether the prescription was met. - const sets = logged.slice(0, Math.max(1, planned)); + // Only a plan the session itself carried may decide where that line falls, as on the + // frontend: a Hevy or CSV import has no target of its own, and slicing it to the `fallback` + // config's set count would grade the warm-up end of a session nobody planned that way. + const sets = entry && entry.target ? logged.slice(0, Math.max(1, planned)) : logged; if (mode === 'time') { const goal = target.sec || 0; const held = sets.map(s => (s.done ? (s.sec || 0) : 0)); diff --git a/api/test/payload-parity.test.js b/api/test/payload-parity.test.js index 572429f25..4469625ea 100644 --- a/api/test/payload-parity.test.js +++ b/api/test/payload-parity.test.js @@ -85,3 +85,16 @@ test('readSession agrees with the frontend on a bonus set logged beyond the plan assert.equal(frontendReadSession(tooFew, PLAN).ok, false); assert.equal(exOf(reviewFor([passing, passing, tooFew])).lastOk, false); }); + +// An entry with no target of its own (a Hevy or CSV import, anything logged before v1.2.2) is +// never sliced on either side: grading it against the routine's set count TODAY would read the +// warm-up end of a session nobody planned that way. Here the fourth set falls short, and with +// no plan to say it was extra, it counts. +test('readSession agrees with the frontend on an imported session with no plan of its own', () => { + const passing = entry([set(10), set(10), set(10)]); + const imported = { id: EX_ID, sets: [set(10), set(10), set(10), set(5)] }; + + assert.equal(frontendReadSession(imported, PLAN).ok, false, 'the frontend reads all four sets'); + assert.equal(exOf(reviewFor([passing, passing, imported])).lastOk, false, 'and so does the payload'); + assert.equal(exOf(reviewFor([passing, passing, imported])).stalls, 1); +}); diff --git a/frontend/src/lib/progression.extra-sets.test.js b/frontend/src/lib/progression.extra-sets.test.js index 25ba4fe5a..bfa3a60e0 100644 --- a/frontend/src/lib/progression.extra-sets.test.js +++ b/frontend/src/lib/progression.extra-sets.test.js @@ -2,7 +2,7 @@ // Before this, one heavier bonus set pulled the next session's weight up with it, and a bonus // set taken short of the target reps reported the whole session as missed. import { describe, it, expect } from 'vitest' -import { readSession } from './progression.js' +import { nextPrescription, readSession } from './progression.js' const set = (w, r, done = true) => ({ w, r, done }) const entry = (target, sets) => ({ id: '0025', target, sets }) @@ -51,3 +51,27 @@ describe('extra sets stay out of the progression read', () => { expect(held.best).toBe(30) }) }) + +// The slice has to see a plan the session itself carried. A Hevy or CSV import, and anything +// logged before v1.2.2, carries no target, and readSession then falls back to the exercise's +// config as it stands TODAY: a session worked up to 80 was graded on its first three rows, +// 40/50/60, and the next prescription started again from the ramp. +describe('a session with no plan of its own is read whole', () => { + const ramp = () => ({ id: '0025', sets: [set(40, 5), set(50, 5), set(60, 5), set(70, 5), set(80, 5)] }) + + it("grades an imported session on all of its sets, not on today's set count", () => { + const s = readSession(ramp(), plan) // the 3-set config the routine asks for now + expect(s.weight).toBe(80) + expect(s.count).toBe(5) + // and a session that DID carry its own plan is still sliced by it + expect(readSession({ ...ramp(), target: { ...plan, reps: 5 } }, plan).weight).toBe(60) + }) + + it('so the first prescription after an import starts from the top set, not the third', () => { + const cfg = { id: '0025', mode: 'reps', sets: 3, reps: 5, weight: 60, prog: 'linear' } + const S = { unit: 'kg', workouts: [{ id: 'hevy-1', d: '2026-09-01', entries: [ramp()] }] } + const p = nextPrescription(S, cfg) + expect(p.kind).toBe('up') + expect(p.weight).toBe(82.5) + }) +}) diff --git a/frontend/src/lib/progression.js b/frontend/src/lib/progression.js index 571495a0e..61925e63a 100644 --- a/frontend/src/lib/progression.js +++ b/frontend/src/lib/progression.js @@ -270,7 +270,12 @@ export function readSession(entry, fallback) { // whole session as missed. Extra sets still count everywhere else — volume, PRs, history — // they just do not move the plan. The plan's own sets are the ones it laid out first, so the // read stops at that many; a session with fewer than planned is short either way (`enough`). - const sets = logged.slice(0, Math.max(1, planned)) + // Only a plan the session itself carried may decide where that line falls. `target` above + // falls back to the exercise's CURRENT config (sessionsFor hands it in), and a Hevy or CSV + // import, or anything logged before v1.2.2, has no target of its own: slicing those to + // today's set count reads the working weight off the warm-up end of a session nobody planned + // that way, and the next prescription then starts again from there. + const sets = entry && entry.target ? logged.slice(0, Math.max(1, planned)) : logged if (mode === 'time') { const goal = target.sec || 0