feat: add hold timer for timed exercises with full sync support

- Play/Stop button replaces checkmark for duration-only exercises
- Green countdown bar with auto-completion and rest timer chaining
- Display duration in seconds (SEC) instead of minutes for holds
- ActiveWorkout model now preserves distance/duration fields on sync
- Hold timer state syncs across devices via SSE
- Workout summary shows per-set hold times for duration exercises
- Template diff compares and displays duration changes correctly
This commit is contained in:
2026-04-11 17:40:49 +02:00
parent c80827b41e
commit db1cd76bb6
7 changed files with 351 additions and 41 deletions
@@ -34,7 +34,7 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
try {
await dbConnect();
const data = await request.json();
const { name, mode, activityType, templateId, exercises, paused, elapsed, savedAt, expectedVersion, restStartedAt, restTotal, restExerciseIdx, restSetIdx } = data;
const { name, mode, activityType, templateId, exercises, paused, elapsed, savedAt, expectedVersion, restStartedAt, restTotal, restExerciseIdx, restSetIdx, holdStartedAt, holdTotal, holdExerciseIdx, holdSetIdx } = data;
if (!name) {
return json({ error: 'Name is required' }, { status: 400 });
@@ -69,6 +69,10 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
restTotal: restTotal ?? 0,
restExerciseIdx: restExerciseIdx ?? -1,
restSetIdx: restSetIdx ?? -1,
holdStartedAt: holdStartedAt ?? null,
holdTotal: holdTotal ?? 0,
holdExerciseIdx: holdExerciseIdx ?? -1,
holdSetIdx: holdSetIdx ?? -1,
version: newVersion
},
$setOnInsert: { userId }
@@ -583,6 +583,7 @@
const exercise = getExerciseById(ex.exerciseId, lang);
const metrics = getExerciseMetrics(exercise);
const isCardio = metrics.includes('distance');
const isDurationOnly = metrics.includes('duration') && !metrics.includes('weight') && !metrics.includes('reps');
const isBilateral = exercise?.bilateral ?? false;
const weightMul = isBilateral ? 2 : 1;
@@ -599,6 +600,8 @@
if (isCardio) {
exDistance += s.distance ?? 0;
exDuration += s.duration ?? 0;
} else if (isDurationOnly) {
exDuration += s.duration ?? 0;
} else {
const w = (s.weight ?? 0) * weightMul;
const r = s.reps ?? 0;
@@ -618,6 +621,7 @@
exerciseId: ex.exerciseId,
sets,
isCardio,
isDurationOnly,
tonnage: exTonnage,
distance: exDistance,
duration: exDuration,
@@ -667,19 +671,21 @@
const metrics = getExerciseMetrics(exercise);
if (metrics.includes('distance')) continue; // skip cardio
const isDurOnly = metrics.includes('duration') && !metrics.includes('weight') && !metrics.includes('reps');
const completedSets = actual.sets.filter((/** @type {any} */ s) => s.completed);
if (completedSets.length === 0) continue;
// Check if sets differ in count, reps, or weight
// Check if sets differ
const tmplSets = tmplEx.sets ?? [];
let changed = completedSets.length !== tmplSets.length;
if (!changed) {
for (let i = 0; i < completedSets.length; i++) {
const a = completedSets[i];
const t = tmplSets[i];
if ((a.reps ?? 0) !== (t.reps ?? 0) || (a.weight ?? 0) !== (t.weight ?? 0)) {
changed = true;
break;
const tp = tmplSets[i];
if (isDurOnly) {
if ((a.duration ?? 0) !== (tp.duration ?? 0)) { changed = true; break; }
} else {
if ((a.reps ?? 0) !== (tp.reps ?? 0) || (a.weight ?? 0) !== (tp.weight ?? 0)) { changed = true; break; }
}
}
}
@@ -688,12 +694,12 @@
diffs.push({
exerciseId: actual.exerciseId,
name: exercise?.localName ?? actual.exerciseId,
isDurationOnly: isDurOnly,
oldSets: tmplSets,
newSets: completedSets.map((/** @type {any} */ s) => ({
reps: s.reps ?? undefined,
weight: s.weight ?? undefined,
rpe: s.rpe ?? undefined
}))
newSets: completedSets.map((/** @type {any} */ s) => isDurOnly
? { duration: s.duration ?? undefined }
: { reps: s.reps ?? undefined, weight: s.weight ?? undefined, rpe: s.rpe ?? undefined }
)
});
}
}
@@ -844,7 +850,9 @@
<span class="ex-summary-sets">{ex.sets} {ex.sets !== 1 ? t('sets', lang) : t('set', lang)}</span>
</div>
<div class="ex-summary-stats">
{#if ex.isCardio}
{#if ex.isDurationOnly}
<span>{ex.sets > 0 ? Math.round((ex.duration / ex.sets) * 60) : 0}s × {ex.sets}</span>
{:else if ex.isCardio}
{#if ex.distance > 0}
<span>{ex.distance.toFixed(1)} km</span>
{/if}
@@ -888,11 +896,19 @@
{#each diff.newSets as set, i}
{@const old = diff.oldSets[i]}
<div class="diff-set-row">
{#if old}
<span class="diff-old">{old.weight ?? '—'} kg × {old.reps ?? '—'}</span>
<span class="diff-arrow"></span>
{#if diff.isDurationOnly}
{#if old}
<span class="diff-old">{old.duration != null ? Math.round(old.duration * 60) : '—'}s</span>
<span class="diff-arrow"></span>
{/if}
<span class="diff-new">{set.duration != null ? Math.round(set.duration * 60) : '—'}s</span>
{:else}
{#if old}
<span class="diff-old">{old.weight ?? '—'} kg × {old.reps ?? '—'}</span>
<span class="diff-arrow"></span>
{/if}
<span class="diff-new">{set.weight ?? '—'} kg × {set.reps ?? '—'}</span>
{/if}
<span class="diff-new">{set.weight ?? '—'} kg × {set.reps ?? '—'}</span>
</div>
{/each}
{#if diff.newSets.length > diff.oldSets.length}
@@ -1346,6 +1362,8 @@
{/if}
{#each workout.exercises as ex, exIdx (exIdx)}
{@const exMetrics = getExerciseMetrics(getExerciseById(ex.exerciseId))}
{@const isDurationOnly = exMetrics.includes('duration') && !exMetrics.includes('weight') && !exMetrics.includes('reps')}
<div class="exercise-block">
<div class="exercise-header">
<ExerciseName exerciseId={ex.exerciseId} />
@@ -1369,18 +1387,34 @@
<SetTable
sets={ex.sets}
previousSets={previousData[ex.exerciseId] ?? []}
metrics={getExerciseMetrics(getExerciseById(ex.exerciseId))}
metrics={exMetrics}
editable={true}
timedHold={isDurationOnly}
restAfterSet={workout.restTimerActive && workout.restExerciseIdx === exIdx ? workout.restSetIdx : -1}
restSeconds={workout.restTimerSeconds}
restTotal={workout.restTimerTotal}
holdAfterSet={workout.holdTimerActive && workout.holdExerciseIdx === exIdx ? workout.holdSetIdx : -1}
holdSeconds={workout.holdTimerSeconds}
holdTotal={workout.holdTimerTotal}
onRestAdjust={(delta) => workout.adjustRestTimer(delta)}
onRestSkip={cancelRest}
onHoldSkip={() => workout.cancelHoldTimer()}
onUpdate={(setIdx, d) => workout.updateSet(exIdx, setIdx, d)}
onToggleComplete={(setIdx) => {
workout.toggleSetComplete(exIdx, setIdx);
if (ex.sets[setIdx]?.completed) {
workout.startRestTimer(ex.restTime, exIdx, setIdx);
const set = ex.sets[setIdx];
// If hold timer is running for this set, cancel it
if (workout.holdTimerActive && workout.holdExerciseIdx === exIdx && workout.holdSetIdx === setIdx) {
workout.cancelHoldTimer();
return;
}
if (isDurationOnly && set?.duration && !set.completed) {
// Start hold countdown — completion happens automatically
workout.startHoldTimer(Math.round(set.duration * 60), exIdx, setIdx);
} else {
workout.toggleSetComplete(exIdx, setIdx);
if (ex.sets[setIdx]?.completed) {
workout.startRestTimer(ex.restTime, exIdx, setIdx);
}
}
}}
onRemove={(setIdx) => workout.removeSet(exIdx, setIdx)}