diff --git a/api/coach/core/payload.js b/api/coach/core/payload.js index 3830a7cadd..c4421fa129 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 572429f25f..4469625ea3 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 25ba4fe5a5..bfa3a60e0e 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 571495a0e4..61925e63a0 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