Skip to content

Commit 4d8147a

Browse files
authored
Merge pull request #2843 from DUC750/fix/mobile-exercise-time-and-duration
fix(workouts): give each exercise its own time and duration in live sessions
2 parents 9a8b037 + 855a3c9 commit 4d8147a

4 files changed

Lines changed: 208 additions & 24 deletions

File tree

‎SparkyFitnessMobile/__tests__/utils/workoutSession.test.ts‎

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2346,7 +2346,7 @@ describe('workoutSession', () => {
23462346
const startedAt = Date.UTC(2026, 2, 20, 10, 0, 0);
23472347
const min = (n: number) => startedAt + n * 60_000;
23482348

2349-
it('splits start→last-completion across exercises by completed-set count', () => {
2349+
it('gives each exercise the time up to its own completed sets', () => {
23502350
const session = makePreset({
23512351
exercises: [
23522352
makeExercise({
@@ -2368,8 +2368,83 @@ describe('workoutSession', () => {
23682368
{},
23692369
startedAt
23702370
);
2371-
expect(payload[0].duration_minutes).toBe(20); // 30 × 2/3
2372-
expect(payload[1].duration_minutes).toBe(10); // 30 × 1/3
2371+
expect(payload[0].duration_minutes).toBe(12); // start → 12 min
2372+
expect(payload[1].duration_minutes).toBe(18); // 12 → 30 min
2373+
});
2374+
2375+
it('splits alternating superset sets by their own gaps', () => {
2376+
const session = makePreset({
2377+
exercises: [
2378+
makeExercise({
2379+
superset_group: 1,
2380+
sets: [makeSet({ id: 101 }), makeSet({ id: 102, set_number: 2 })],
2381+
}),
2382+
makeExercise({
2383+
id: ENTRY_B,
2384+
exercise_id: EX_2,
2385+
superset_group: 1,
2386+
sets: [makeSet({ id: 201 }), makeSet({ id: 202, set_number: 2 })],
2387+
}),
2388+
],
2389+
});
2390+
2391+
const completed = {
2392+
'101': min(3),
2393+
'201': min(5),
2394+
'102': min(8),
2395+
'202': min(11),
2396+
};
2397+
const payload = buildSessionExercisesPayload(
2398+
session,
2399+
completed,
2400+
{},
2401+
startedAt
2402+
);
2403+
expect(payload[0].duration_minutes).toBe(6); // 0→3 + 5→8
2404+
expect(payload[1].duration_minutes).toBe(5); // 3→5 + 8→11
2405+
});
2406+
2407+
it('stamps entry_time with the local start of each exercise', () => {
2408+
const localStart = new Date(2026, 2, 20, 10, 0, 0).getTime();
2409+
const at = (n: number) => localStart + n * 60_000;
2410+
const session = makePreset({
2411+
exercises: [
2412+
makeExercise({
2413+
sets: [makeSet({ id: 101 }), makeSet({ id: 102, set_number: 2 })],
2414+
}),
2415+
makeExercise({
2416+
id: ENTRY_B,
2417+
exercise_id: EX_2,
2418+
sets: [makeSet({ id: 201 })],
2419+
}),
2420+
makeExercise({
2421+
id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
2422+
sets: [makeSet({ id: 301 })],
2423+
}),
2424+
],
2425+
});
2426+
const payload = buildSessionExercisesPayload(
2427+
session,
2428+
{ '101': at(4), '102': at(12), '201': at(19) },
2429+
{},
2430+
localStart
2431+
);
2432+
expect(payload[0].entry_time).toBe('10:00');
2433+
expect(payload[1].entry_time).toBe('10:12');
2434+
// Nothing completed yet: omitted so the server keeps its value.
2435+
expect('entry_time' in payload[2]).toBe(false);
2436+
});
2437+
2438+
it('omits entry_time without a workout start', () => {
2439+
const session = makePreset({
2440+
exercises: [makeExercise({ sets: [makeSet({ id: 101 })] })],
2441+
});
2442+
const payload = buildSessionExercisesPayload(
2443+
session,
2444+
{ '101': min(10) },
2445+
{}
2446+
);
2447+
expect('entry_time' in payload[0]).toBe(false);
23732448
});
23742449

23752450
it('gives an exercise with no completed sets zero duration', () => {

‎SparkyFitnessMobile/src/utils/workoutSession.ts‎

Lines changed: 65 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1594,7 +1594,7 @@ export function buildSessionExercisesPayload(
15941594
prSetIds: PrSetMap,
15951595
startedAtMs?: number | null
15961596
): PresetSessionExerciseRequest[] {
1597-
const durationByEntryId = buildSessionDurationMinutes(
1597+
const timing = buildSessionExerciseTiming(
15981598
session,
15991599
completedSetIds,
16001600
startedAtMs
@@ -1608,7 +1608,11 @@ export function buildSessionExercisesPayload(
16081608
resolveSnapshotModality(exercise.exercise_snapshot)
16091609
)
16101610
? setsDurationMinutes(exercise.sets)
1611-
: (durationByEntryId?.get(exercise.id) ?? exercise.duration_minutes ?? 0),
1611+
: (timing?.durations.get(exercise.id) ?? exercise.duration_minutes ?? 0),
1612+
// Omitted when nothing was completed yet, so the server keeps what it has.
1613+
...(timing?.startTimes.has(exercise.id)
1614+
? { entry_time: timing.startTimes.get(exercise.id) }
1615+
: {}),
16121616
notes: exercise.notes ?? null,
16131617
superset_group: exercise.superset_group ?? null,
16141618
sets: exercise.sets.map((set, setIndex) => {
@@ -1638,12 +1642,47 @@ export function buildSessionDurationMinutes(
16381642
completedSetIds: CompletedSetMap,
16391643
startedAtMs?: number | null
16401644
): Map<string, number> | null {
1645+
return (
1646+
buildSessionExerciseTiming(session, completedSetIds, startedAtMs)
1647+
?.durations ?? null
1648+
);
1649+
}
1650+
1651+
export interface SessionExerciseTiming {
1652+
/** Minutes per non-cardio entry; entries absent here keep their stored value. */
1653+
durations: Map<string, number>;
1654+
/** Local wall-clock 'HH:MM' at which each entry's first timed span began. */
1655+
startTimes: Map<string, string>;
1656+
}
1657+
1658+
/** Local wall-clock 'HH:MM' for an instant, the format entry_time expects. */
1659+
export function toEntryTimeString(ms: number): string {
1660+
const date = new Date(ms);
1661+
const pad = (n: number) => n.toString().padStart(2, '0');
1662+
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
1663+
}
1664+
1665+
/**
1666+
* Walks the completed strength sets in the order they were ticked off and
1667+
* gives each gap (previous completion, or the workout start, up to this
1668+
* completion) to the exercise the set belongs to. That is the time spent on
1669+
* that exercise's set plus the rest before it, so supersets and exercises
1670+
* done out of order each get their own share. The per-entry durations still
1671+
* add up to start→last completion.
1672+
*
1673+
* An entry's start time is the beginning of its first gap. Cardio entries
1674+
* are left out: their duration is the sum of their set durations.
1675+
*/
1676+
export function buildSessionExerciseTiming(
1677+
session: PresetSessionResponse,
1678+
completedSetIds: CompletedSetMap,
1679+
startedAtMs?: number | null
1680+
): SessionExerciseTiming | null {
16411681
if (startedAtMs == null) return null;
16421682

1643-
let lastCompletedMs = 0;
1644-
let totalCompleted = 0;
16451683
let anyCompletedAfterStart = false;
16461684
const completedCountByEntryId = new Map<string, number>();
1685+
const timeline: { ms: number; entryId: string }[] = [];
16471686
for (const exercise of session.exercises) {
16481687
const cardio = isCardioModality(
16491688
resolveSnapshotModality(exercise.exercise_snapshot)
@@ -1655,28 +1694,34 @@ export function buildSessionDurationMinutes(
16551694
if (ms > startedAtMs) anyCompletedAfterStart = true;
16561695
if (cardio) continue;
16571696
count++;
1658-
totalCompleted++;
1659-
if (ms > lastCompletedMs) lastCompletedMs = ms;
1697+
// Completions before the start (a resumed session) can't be placed on
1698+
// this clock; their entry keeps its stored duration.
1699+
if (ms > startedAtMs) timeline.push({ ms, entryId: exercise.id });
16601700
}
16611701
if (!cardio) completedCountByEntryId.set(exercise.id, count);
16621702
}
1663-
if (totalCompleted === 0 || lastCompletedMs <= startedAtMs) {
1664-
if (!anyCompletedAfterStart) return null;
1665-
const zeroed = new Map<string, number>();
1666-
for (const [entryId, count] of completedCountByEntryId) {
1667-
if (count === 0) zeroed.set(entryId, 0);
1668-
}
1669-
return zeroed;
1703+
if (!anyCompletedAfterStart) return null;
1704+
1705+
const durations = new Map<string, number>();
1706+
const startTimes = new Map<string, string>();
1707+
for (const [entryId, count] of completedCountByEntryId) {
1708+
if (count === 0) durations.set(entryId, 0);
16701709
}
16711710

1672-
const totalMinutes = (lastCompletedMs - startedAtMs) / 60_000;
1673-
const byEntryId = new Map<string, number>();
1674-
for (const exercise of session.exercises) {
1675-
const count = completedCountByEntryId.get(exercise.id) ?? 0;
1676-
const share = (totalMinutes * count) / totalCompleted;
1677-
byEntryId.set(exercise.id, Math.round(share * 10) / 10);
1711+
timeline.sort((a, b) => a.ms - b.ms);
1712+
const spanMs = new Map<string, number>();
1713+
let previousMs = startedAtMs;
1714+
for (const { ms, entryId } of timeline) {
1715+
spanMs.set(entryId, (spanMs.get(entryId) ?? 0) + (ms - previousMs));
1716+
if (!startTimes.has(entryId)) {
1717+
startTimes.set(entryId, toEntryTimeString(previousMs));
1718+
}
1719+
previousMs = ms;
1720+
}
1721+
for (const [entryId, ms] of spanMs) {
1722+
durations.set(entryId, Math.round((ms / 60_000) * 10) / 10);
16781723
}
1679-
return byEntryId;
1724+
return { durations, startTimes };
16801725
}
16811726

16821727
export const WORKOUT_LONG_GAP_MINUTES = 30;

‎SparkyFitnessServer/services/exerciseService.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2305,7 +2305,8 @@ async function updateGroupedWorkoutSession(
23052305
preparedEntry.calories_burned
23062306
),
23072307
entry_date: targetEntryDate,
2308-
entry_time: ex.entry_time ?? null,
2308+
// Preserve when omitted; explicit null clears the time of day.
2309+
entry_time: ex.entry_time,
23092310
},
23102311
actingUserId,
23112312
existingById.get(ex.id)?.source ?? existingSession.source

‎SparkyFitnessServer/tests/exerciseService.groupedWorkouts.test.ts‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1349,6 +1349,69 @@ describe('exerciseService grouped workouts', () => {
13491349
expect(updateCalls[1][3]).toMatchObject({ superset_group: null });
13501350
});
13511351

1352+
it('passes entry_time through the reconcile path', async () => {
1353+
setupExistingSession();
1354+
1355+
await exerciseService.updateGroupedWorkoutSession(
1356+
'user-1',
1357+
'actor-1',
1358+
'preset-entry-1',
1359+
{
1360+
exercises: [
1361+
{
1362+
id: 'entry-a',
1363+
exercise_id: exerciseAId,
1364+
sort_order: 0,
1365+
duration_minutes: 12,
1366+
entry_time: '15:59',
1367+
sets: [],
1368+
},
1369+
{
1370+
id: 'entry-b',
1371+
exercise_id: exerciseBId,
1372+
sort_order: 1,
1373+
duration_minutes: 0,
1374+
entry_time: null,
1375+
sets: [],
1376+
},
1377+
],
1378+
}
1379+
);
1380+
1381+
const updateCalls = vi.mocked(
1382+
exerciseEntryDb._updateExerciseEntryWithClient
1383+
).mock.calls;
1384+
expect(updateCalls[0][3]).toMatchObject({ entry_time: '15:59' });
1385+
expect(updateCalls[1][3]).toMatchObject({ entry_time: null });
1386+
});
1387+
1388+
it('leaves entry_time undefined when the reconcile payload omits it', async () => {
1389+
setupExistingSession();
1390+
1391+
await exerciseService.updateGroupedWorkoutSession(
1392+
'user-1',
1393+
'actor-1',
1394+
'preset-entry-1',
1395+
{
1396+
exercises: [
1397+
{
1398+
id: 'entry-a',
1399+
exercise_id: exerciseAId,
1400+
sort_order: 0,
1401+
duration_minutes: 0,
1402+
sets: [],
1403+
},
1404+
],
1405+
}
1406+
);
1407+
1408+
// undefined makes _updateExerciseEntryWithClient keep the stored time.
1409+
const updateCalls = vi.mocked(
1410+
exerciseEntryDb._updateExerciseEntryWithClient
1411+
).mock.calls;
1412+
expect(updateCalls[0][3].entry_time).toBeUndefined();
1413+
});
1414+
13521415
it('carries superset_group through the delete-and-recreate path', async () => {
13531416
setupExistingSession();
13541417
// @ts-expect-error TS(2339): mockResolvedValue on mocked fn

0 commit comments

Comments
 (0)