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:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user