Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion api/coach/core/payload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
13 changes: 13 additions & 0 deletions api/test/payload-parity.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
26 changes: 25 additions & 1 deletion frontend/src/lib/progression.extra-sets.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -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)
})
})
7 changes: 6 additions & 1 deletion frontend/src/lib/progression.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading