Skip to content

Commit 7d7cbb8

Browse files
committed
Workout: the header ⋮ has Don't count for progression for the whole workout, so an injury day stays out of the next session's numbers; every exercise shows the marker, a second tap counts them again, an exercise or routine added afterwards stays out too, and it works in the saved-workout editor (Discord asierlama, #203)
It stamps every entry's noProg, the flag the exercise ⋯ menu sets for one exercise, so the finished workout saves exactly like one whose exercises were each kept out by hand: noProg on every entry and excludeFromProgression on the workout (finish-workout.js), which the history and the next prescription already read past. The running session remembers the choice as active.noProg (never saved), which is what the menu shows as on and what later additions join. Switching it off leaves a deload or rehab routine's own exercises out, and one card's Undo ends the whole-workout choice. A saved workout kept out as a whole opens in the editor with it on.
1 parent 81b9f77 commit 7d7cbb8

24 files changed

Lines changed: 431 additions & 14 deletions

‎frontend/src/lib/pt-br-locale.test.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ describe('Brazilian Portuguese locale', () => {
4141
// has to be taught is a number nobody reads. What the numbers stood for is asserted above.
4242
// If the hash fails, review the changed keys and wording before accepting a new one. From
4343
// frontend/: node scripts/pt-br-inheritance-fingerprint.mjs --list
44-
expect(fingerprint, 'pt-PT inheritance changed; review the inherited pt-BR wording').toBe('1a6ecca227bbbead13b5be3e4e410f1e1c9ac36bd32d31199bfd1581d792f4ef')
44+
expect(fingerprint, 'pt-PT inheritance changed; review the inherited pt-BR wording').toBe('de3c7a1850341bf238bd4f9544a30e5a2e043e232f8acbce0582cd527d96b429')
4545
})
4646

4747
test('does not leak European Portuguese UI terms', () => {

‎frontend/src/lib/session-edit.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,12 @@ export function editCompletedSession(state, ref) {
7575
// A workout saved before exclusion moved onto the entries (ENG-11) carries only the whole-workout
7676
// flag. The editor rebuilds that flag from the entries (buildCompletedWorkout), so it is written
7777
// onto each of them here, the way a session starts since: the edited workout stays out of
78-
// progression, and a swap in the editor keeps its replacement out too.
78+
// progression, and a swap in the editor keeps its replacement out too. A workout out as a whole
79+
// opens with the header's "Don't count for progression" on (lib/session-noprog.js), so it can
80+
// be switched off there, and an exercise added in the editor stays out with the rest.
7981
if (original.excludeFromProgression === true) {
8082
for (const entry of list(active.entries)) if (entry && entry.noProg !== true) entry.noProg = true
83+
active.noProg = true
8184
}
8285
// Worked out again on Save, from the edited sets.
8386
for (const k of ['vol', 'prs', '_ts']) delete active[k]

‎frontend/src/lib/session-edit.test.js‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,20 @@ describe('saved workout editing', () => {
187187
expect(lastEntryFor(state, '0025').sets[0].w).toBe(80)
188188
})
189189

190+
// The header ⋮'s "Don't count for progression" for the whole workout (lib/session-noprog.js)
191+
// opens on for a workout saved out as a whole, and off for one that counts.
192+
it('opens a workout kept out as a whole with the whole-workout switch on, and a counting one with it off', () => {
193+
const state = fixture()
194+
state.workouts.push({ id: 'rehab', d: '2026-09-02', start: 3000, end: 4000, excludeFromProgression: true, entries: [{ ...entry(10), noProg: true }], prs: [] })
195+
editCompletedSession(state, 'rehab')
196+
expect(state.active.noProg).toBe(true)
197+
const saved = saveWorkoutEdit(state)
198+
expect(saved).not.toHaveProperty('noProg')
199+
200+
editCompletedSession(state, 'workout')
201+
expect(state.active).not.toHaveProperty('noProg')
202+
})
203+
190204
// A stamp outranks what another device wrote since. Opening the editor and saving without a
191205
// change must not give the record one, or it beats sets added on the phone that have not synced.
192206
it('leaves the record and its stamp alone when Save changed nothing', () => {

‎frontend/src/lib/session-noprog.js‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// "Don't count for progression" for a whole session, from the workout header ⋮ (Discord,
2+
// asierlama: "exclude the current workout" on an injury day). An exercise's ⋯ menu does it for
3+
// one exercise by stamping that entry's `noProg`; this stamps every entry. The entries are what
4+
// everything downstream reads: the saved workout keeps each entry's flag and gets the whole-workout
5+
// `excludeFromProgression` when every entry carries it (finish-workout.js), and the history and the
6+
// next prescription read past them (history.js entryExcluded). So a session kept out as a whole
7+
// saves exactly like one whose exercises were each kept out by hand.
8+
//
9+
// `active.noProg` records that the choice was made for the session as a whole, which the entries
10+
// cannot tell on their own: a one-exercise session with that exercise kept out by hand looks the
11+
// same. It is what the header shows as on, and what an exercise or a routine added afterwards
12+
// joins, so the session stays out as a whole. It lives on the running session only; the saved
13+
// workout is built from the entries (buildCompletedWorkout lists its fields) and never carries it.
14+
15+
const list = v => (Array.isArray(v) ? v : [])
16+
17+
export const sessionNoProg = active => active?.noProg === true
18+
19+
/**
20+
* Keeps the whole session out of progression, or puts it back. Off counts every entry again
21+
* except the ones a deload or rehab routine keeps out (`routineOwns(entry)`): that is the
22+
* routine's setting, which its entries show with no switch of their own, the same rule as the
23+
* exercise menu. Mutates `active`, for use inside a store update.
24+
*/
25+
export function setSessionNoProg(active, on, routineOwns = () => false) {
26+
if (!active) return
27+
if (on) {
28+
active.noProg = true
29+
for (const entry of list(active.entries)) if (entry) entry.noProg = true
30+
return
31+
}
32+
delete active.noProg
33+
for (const entry of list(active.entries)) if (entry && !routineOwns(entry)) delete entry.noProg
34+
}
35+
36+
/**
37+
* One exercise kept out or counted again from its own menu or its marker's Undo. Counting one
38+
* again means the session is no longer out as a whole, so the header stops saying it is and an
39+
* exercise added later counts too.
40+
*/
41+
export function setEntryNoProg(active, index, on) {
42+
const entry = list(active?.entries)[index]
43+
if (!entry) return
44+
if (on) {
45+
entry.noProg = true
46+
return
47+
}
48+
delete entry.noProg
49+
delete active.noProg
50+
}
51+
52+
/** An entry joining the session (added, or brought in with a routine): out too while the whole session is. */
53+
export const joinSessionNoProg = (active, entry) => (sessionNoProg(active) ? { ...entry, noProg: true } : entry)
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { sessionNoProg, setSessionNoProg, setEntryNoProg, joinSessionNoProg } from './session-noprog.js'
3+
import { buildCompletedWorkout } from './finish-workout.js'
4+
import { entryExcluded, lastEntryFor } from './history.js'
5+
6+
// "Don't count for progression" for the whole workout (Discord, asierlama: an injury day).
7+
const entry = (id, extra = {}) => ({ id, target: { sets: 1, reps: 5, weight: 60 }, sets: [{ w: 60, r: 5, done: true }], ...extra })
8+
const session = entries => ({ id: 'a', d: '2026-09-24', start: 0, routineIds: ['main'], name: 'Main', entries })
9+
const deloadOwns = e => e.rid === 'deload'
10+
11+
describe('setSessionNoProg', () => {
12+
it('on: every entry is kept out and the session says it is out as a whole', () => {
13+
const active = session([entry('0025'), entry('0027', { noProg: true })])
14+
setSessionNoProg(active, true)
15+
expect(sessionNoProg(active)).toBe(true)
16+
expect(active.entries.map(e => e.noProg)).toEqual([true, true])
17+
})
18+
19+
it('off: every entry counts again, apart from the ones a deload routine keeps out', () => {
20+
const active = session([entry('0025', { rid: 'main' }), entry('0027', { rid: 'deload', noProg: true })])
21+
setSessionNoProg(active, true, deloadOwns)
22+
setSessionNoProg(active, false, deloadOwns)
23+
expect(sessionNoProg(active)).toBe(false)
24+
expect('noProg' in active).toBe(false)
25+
expect(active.entries.map(e => e.noProg)).toEqual([undefined, true])
26+
})
27+
28+
it('does nothing without a session', () => {
29+
expect(() => setSessionNoProg(null, true)).not.toThrow()
30+
expect(sessionNoProg(null)).toBe(false)
31+
})
32+
})
33+
34+
describe('setEntryNoProg', () => {
35+
it('keeps one exercise out without making it the whole session', () => {
36+
const active = session([entry('0025')])
37+
setEntryNoProg(active, 0, true)
38+
expect(active.entries[0].noProg).toBe(true)
39+
expect(sessionNoProg(active)).toBe(false)
40+
})
41+
42+
it('counting one exercise again ends the whole-session choice, the others stay out', () => {
43+
const active = session([entry('0025'), entry('0027')])
44+
setSessionNoProg(active, true)
45+
setEntryNoProg(active, 1, false)
46+
expect(sessionNoProg(active)).toBe(false)
47+
expect(active.entries.map(e => e.noProg)).toEqual([true, undefined])
48+
})
49+
50+
it('ignores an index with no entry', () => {
51+
const active = session([entry('0025')])
52+
setSessionNoProg(active, true)
53+
setEntryNoProg(active, 5, false)
54+
expect(sessionNoProg(active)).toBe(true)
55+
})
56+
})
57+
58+
describe('joinSessionNoProg', () => {
59+
it('an entry added to a session kept out as a whole is kept out too', () => {
60+
const active = session([entry('0025')])
61+
setSessionNoProg(active, true)
62+
expect(joinSessionNoProg(active, entry('0043')).noProg).toBe(true)
63+
})
64+
65+
it('an exercise kept out by hand is not passed on to one added after it', () => {
66+
const active = session([entry('0025')])
67+
setEntryNoProg(active, 0, true)
68+
const added = entry('0043')
69+
expect(joinSessionNoProg(active, added)).toBe(added)
70+
expect(added.noProg).toBeUndefined()
71+
})
72+
})
73+
74+
// The saved workout is built from the entries, so it reads exactly like one whose exercises were
75+
// each kept out by hand: the whole-workout mirror, every entry excluded, no session-only field.
76+
describe('a session kept out as a whole, saved', () => {
77+
it('writes excludeFromProgression and noProg on every entry, and the history reads past it', () => {
78+
const active = session([entry('0025', { rid: 'main' }), entry('0027', { rid: 'main' })])
79+
setSessionNoProg(active, true)
80+
active.entries.push(joinSessionNoProg(active, entry('0043', { rid: 'main' })))
81+
const saved = buildCompletedWorkout(active, { end: 1 })
82+
expect(saved.excludeFromProgression).toBe(true)
83+
expect(saved.entries.every(e => e.noProg === true && entryExcluded(saved, e))).toBe(true)
84+
expect(saved).not.toHaveProperty('noProg')
85+
86+
const earlier = { id: 'w0', d: '2026-09-20', routineIds: ['main'], entries: [entry('0025', { rid: 'main', sets: [{ w: 100, r: 5, done: true }] })] }
87+
const S = { workouts: [earlier, saved], routines: [], exWeights: {} }
88+
expect(lastEntryFor(S, '0025', 'main').d).toBe('2026-09-20')
89+
})
90+
91+
it('switched off again before the finish, it saves as an ordinary counting workout', () => {
92+
const active = session([entry('0025'), entry('0027')])
93+
setSessionNoProg(active, true)
94+
setSessionNoProg(active, false)
95+
const saved = buildCompletedWorkout(active, { end: 1 })
96+
expect(saved).not.toHaveProperty('excludeFromProgression')
97+
expect(saved.entries.some(e => 'noProg' in e)).toBe(false)
98+
})
99+
})

‎frontend/src/locales/ar.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1597,4 +1597,5 @@ export default {
15971597
'You get a code that lets your phone or another computer create a passkey for this profile. First confirm that it is you.': 'ستحصل على رمز يتيح لهاتفك أو لحاسوب آخر إنشاء مفتاح مرور لهذا الملف الشخصي. أكّد أولًا أنك أنت.',
15981598
'or with your password': 'أو بكلمة المرور',
15991599
'{0} passkeys': 'مفاتيح المرور: {0}',
1600+
'Every exercise in this workout': 'كل التمارين في هذه الجلسة',
16001601
}

‎frontend/src/locales/de.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1564,4 +1564,5 @@ export default {
15641564
'Log your body weight to score this lift.': 'Trag dein Körpergewicht ein, damit diese Übung bewertet werden kann.',
15651565
'Log the anchor lift to score this one.': 'Trag die Anker-Übung ein, damit diese bewertet werden kann.',
15661566
'Not scored': 'Nicht bewertet',
1567+
'Every exercise in this workout': 'Alle Übungen dieses Trainings',
15671568
}

‎frontend/src/locales/es.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1544,4 +1544,5 @@ export default {
15441544
'Log your body weight to score this lift.': 'Registra tu peso corporal para evaluar este ejercicio.',
15451545
'Log the anchor lift to score this one.': 'Registra el ejercicio ancla para evaluar este.',
15461546
'Not scored': 'Sin evaluar',
1547+
'Every exercise in this workout': 'Todos los ejercicios de este entrenamiento',
15471548
}

‎frontend/src/locales/fr.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1544,4 +1544,5 @@ export default {
15441544
'Log your body weight to score this lift.': 'Note ton poids corporel pour évaluer cet exercice.',
15451545
'Log the anchor lift to score this one.': 'Note l’exercice de référence pour évaluer celui-ci.',
15461546
'Not scored': 'Non évalué',
1547+
'Every exercise in this workout': 'Tous les exercices de cette séance',
15471548
}

‎frontend/src/locales/hi.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1544,4 +1544,5 @@ export default {
15441544
'Log your body weight to score this lift.': 'इस व्यायाम का आकलन करने के लिए अपने शरीर का वज़न दर्ज करें।',
15451545
'Log the anchor lift to score this one.': 'इसका आकलन करने के लिए आधार व्यायाम दर्ज करें।',
15461546
'Not scored': 'आकलन नहीं हुआ',
1547+
'Every exercise in this workout': 'इस वर्कआउट के सभी व्यायाम',
15471548
}

0 commit comments

Comments
 (0)