feat: add hold timer for timed exercises with full sync support
All checks were successful
CI / update (push) Successful in 3m32s

- 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 a5daae3fc9
commit b4da24b572
7 changed files with 351 additions and 41 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.25.3", "version": "1.26.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -1,5 +1,5 @@
<script> <script>
import { Check, X } from '@lucide/svelte'; import { Check, X, Play, Square } from '@lucide/svelte';
import { METRIC_LABELS } from '$lib/data/exercises'; import { METRIC_LABELS } from '$lib/data/exercises';
import RestTimer from './RestTimer.svelte'; import RestTimer from './RestTimer.svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
@@ -16,8 +16,13 @@
* restAfterSet?: number, * restAfterSet?: number,
* restSeconds?: number, * restSeconds?: number,
* restTotal?: number, * restTotal?: number,
* holdAfterSet?: number,
* holdSeconds?: number,
* holdTotal?: number,
* onRestAdjust?: ((delta: number) => void) | null, * onRestAdjust?: ((delta: number) => void) | null,
* onRestSkip?: (() => void) | null, * onRestSkip?: (() => void) | null,
* timedHold?: boolean,
* onHoldSkip?: (() => void) | null,
* onUpdate?: ((setIndex: number, data: Record<string, number | null>) => void) | null, * onUpdate?: ((setIndex: number, data: Record<string, number | null>) => void) | null,
* onToggleComplete?: ((setIndex: number) => void) | null, * onToggleComplete?: ((setIndex: number) => void) | null,
* onRemove?: ((setIndex: number) => void) | null * onRemove?: ((setIndex: number) => void) | null
@@ -31,8 +36,13 @@
restAfterSet = -1, restAfterSet = -1,
restSeconds = 0, restSeconds = 0,
restTotal = 0, restTotal = 0,
timedHold = false,
holdAfterSet = -1,
holdSeconds = 0,
holdTotal = 0,
onRestAdjust = null, onRestAdjust = null,
onRestSkip = null, onRestSkip = null,
onHoldSkip = null,
onUpdate = null, onUpdate = null,
onToggleComplete = null, onToggleComplete = null,
onRemove = null onRemove = null
@@ -52,7 +62,9 @@
*/ */
function handleInput(index, field, e) { function handleInput(index, field, e) {
const target = /** @type {HTMLInputElement} */ (e.target); const target = /** @type {HTMLInputElement} */ (e.target);
const val = target.value === '' ? null : Number(target.value); const raw = target.value === '' ? null : Number(target.value);
// For timedHold exercises, duration input is in seconds — convert to minutes for storage
const val = (timedHold && field === 'duration' && raw != null) ? raw / 60 : raw;
onUpdate?.(index, { [field]: val }); onUpdate?.(index, { [field]: val });
} }
@@ -60,7 +72,10 @@
function formatPrev(/** @type {Record<string, any>} */ prev) { function formatPrev(/** @type {Record<string, any>} */ prev) {
const parts = []; const parts = [];
for (const m of mainMetrics) { for (const m of mainMetrics) {
if (prev[m] != null) parts.push(`${prev[m]}`); if (prev[m] != null) {
const v = (timedHold && m === 'duration') ? Math.round(prev[m] * 60) : prev[m];
parts.push(`${v}`);
}
} }
let result = parts.join(' × '); let result = parts.join(' × ');
if (prev.rpe != null) result += `@${prev.rpe}`; if (prev.rpe != null) result += `@${prev.rpe}`;
@@ -84,7 +99,7 @@
<th class="col-prev">{t('prev_header', lang)}</th> <th class="col-prev">{t('prev_header', lang)}</th>
{/if} {/if}
{#each mainMetrics as metric (metric)} {#each mainMetrics as metric (metric)}
<th class="col-metric">{METRIC_LABELS[metric]}</th> <th class="col-metric">{timedHold && metric === 'duration' ? 'SEC' : METRIC_LABELS[metric]}</th>
{/each} {/each}
{#if editable && hasRpe} {#if editable && hasRpe}
<th class="col-at"></th> <th class="col-at"></th>
@@ -118,17 +133,20 @@
</td> </td>
{/if} {/if}
{#each mainMetrics as metric (metric)} {#each mainMetrics as metric (metric)}
{@const displayVal = (timedHold && metric === 'duration' && set[metric] != null)
? Math.round(set[metric] * 60)
: set[metric]}
<td class="col-metric" class:col-weight={metric === 'weight'}> <td class="col-metric" class:col-weight={metric === 'weight'}>
{#if editable} {#if editable}
<input <input
type="number" type="number"
inputmode={inputMode(metric)} inputmode={timedHold && metric === 'duration' ? 'numeric' : inputMode(metric)}
value={set[metric] ?? ''} value={displayVal ?? ''}
placeholder="0" placeholder="0"
oninput={(e) => handleInput(i, metric, e)} oninput={(e) => handleInput(i, metric, e)}
/> />
{:else} {:else}
{set[metric] ?? '—'} {displayVal ?? '—'}
{/if} {/if}
</td> </td>
{/each} {/each}
@@ -148,17 +166,51 @@
{/if} {/if}
{#if editable} {#if editable}
<td class="col-check"> <td class="col-check">
<button {#if timedHold && !set.completed}
class="check-btn" {#if holdAfterSet === i}
class:checked={set.completed} <button
onclick={() => onToggleComplete?.(i)} class="check-btn hold-stop"
aria-label="Mark set complete" onclick={() => onToggleComplete?.(i)}
> aria-label="Stop timer"
<Check size={16} /> >
</button> <Square size={14} />
</button>
{:else}
<button
class="check-btn hold-play"
onclick={() => onToggleComplete?.(i)}
aria-label="Start hold timer"
>
<Play size={16} />
</button>
{/if}
{:else}
<button
class="check-btn"
class:checked={set.completed}
onclick={() => onToggleComplete?.(i)}
aria-label="Mark set complete"
>
<Check size={16} />
</button>
{/if}
</td> </td>
{/if} {/if}
</tr> </tr>
{#if holdAfterSet === i && holdTotal > 0}
<tr class="rest-row">
<td colspan={totalCols} class="rest-cell">
<div class="hold-bar">
<div class="hold-fill" style:width="{holdTotal > 0 ? (holdSeconds / holdTotal) * 100 : 0}%"></div>
<div class="hold-controls">
<button class="hold-skip-btn" onclick={() => onHoldSkip?.()}>
{Math.floor(holdSeconds / 60)}:{(holdSeconds % 60).toString().padStart(2, '0')}
</button>
</div>
</div>
</td>
</tr>
{/if}
{#if restAfterSet === i && restTotal > 0} {#if restAfterSet === i && restTotal > 0}
<tr class="rest-row"> <tr class="rest-row">
<td colspan={totalCols} class="rest-cell"> <td colspan={totalCols} class="rest-cell">
@@ -303,12 +355,58 @@
border-color: var(--nord14); border-color: var(--nord14);
color: white; color: white;
} }
.check-btn.hold-play {
border-color: var(--nord14);
color: var(--nord14);
}
.check-btn.hold-play:hover {
background: color-mix(in srgb, var(--nord14) 15%, transparent);
}
.check-btn.hold-stop {
border-color: var(--nord11);
color: var(--nord11);
}
.check-btn.hold-stop:hover {
background: color-mix(in srgb, var(--nord11) 15%, transparent);
}
.rest-row td { .rest-row td {
border-top: none; border-top: none;
} }
.rest-cell { .rest-cell {
padding: 0.3rem 0.25rem; padding: 0.3rem 0.25rem;
} }
.hold-bar {
border-radius: 8px;
overflow: hidden;
position: relative;
height: 2.2rem;
background: color-mix(in srgb, var(--nord14) 20%, var(--nord0));
}
.hold-fill {
position: absolute;
inset: 0;
background: var(--nord14);
border-radius: 8px;
transition: width 1s linear;
}
.hold-controls {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.hold-skip-btn {
background: none;
border: none;
font-size: 0.9rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--nord0);
cursor: pointer;
padding: 0.2rem 0.5rem;
}
.prev-na { .prev-na {
opacity: 0.4; opacity: 0.4;
font-size: 0.7rem; font-size: 0.7rem;

View File

@@ -51,6 +51,10 @@ export interface StoredState {
restTotal: number; // total rest duration in seconds restTotal: number; // total rest duration in seconds
restExerciseIdx: number; // which exercise the rest timer belongs to restExerciseIdx: number; // which exercise the rest timer belongs to
restSetIdx: number; // which set the rest timer belongs to restSetIdx: number; // which set the rest timer belongs to
holdStartedAt?: number | null; // Date.now() when hold timer started
holdTotal?: number; // total hold duration in seconds
holdExerciseIdx?: number;
holdSetIdx?: number;
} }
export interface RemoteState { export interface RemoteState {
@@ -67,6 +71,10 @@ export interface RemoteState {
restTotal: number; restTotal: number;
restExerciseIdx: number; restExerciseIdx: number;
restSetIdx: number; restSetIdx: number;
holdStartedAt?: number | null;
holdTotal?: number;
holdExerciseIdx?: number;
holdSetIdx?: number;
} }
function createEmptySet(): WorkoutSet { function createEmptySet(): WorkoutSet {
@@ -114,6 +122,15 @@ export function createWorkout() {
let _restExerciseIdx = $state(-1); let _restExerciseIdx = $state(-1);
let _restSetIdx = $state(-1); let _restSetIdx = $state(-1);
// Hold timer (countdown for timed/duration sets)
let _holdSeconds = $state(0);
let _holdTotal = $state(0);
let _holdActive = $state(false);
let _holdStartedAt: number | null = null;
let _holdExerciseIdx = $state(-1);
let _holdSetIdx = $state(-1);
let _holdInterval: ReturnType<typeof setInterval> | null = null;
let _timerInterval: ReturnType<typeof setInterval> | null = null; let _timerInterval: ReturnType<typeof setInterval> | null = null;
let _restInterval: ReturnType<typeof setInterval> | null = null; let _restInterval: ReturnType<typeof setInterval> | null = null;
let _onChangeCallback: (() => void) | null = null; let _onChangeCallback: (() => void) | null = null;
@@ -138,7 +155,11 @@ export function createWorkout() {
restStartedAt: _restActive ? _restStartedAt : null, restStartedAt: _restActive ? _restStartedAt : null,
restTotal: _restTotal, restTotal: _restTotal,
restExerciseIdx: _restActive ? _restExerciseIdx : -1, restExerciseIdx: _restActive ? _restExerciseIdx : -1,
restSetIdx: _restActive ? _restSetIdx : -1 restSetIdx: _restActive ? _restSetIdx : -1,
holdStartedAt: _holdActive ? _holdStartedAt : null,
holdTotal: _holdTotal,
holdExerciseIdx: _holdActive ? _holdExerciseIdx : -1,
holdSetIdx: _holdActive ? _holdSetIdx : -1
}); });
_onChangeCallback?.(); _onChangeCallback?.();
} }
@@ -211,6 +232,62 @@ export function createWorkout() {
_restSetIdx = -1; _restSetIdx = -1;
} }
// --- Hold timer (timed exercise countdown) ---
function _computeHoldSeconds() {
if (!_holdActive || !_holdStartedAt) return;
const elapsed = Math.floor((Date.now() - _holdStartedAt) / 1000);
_holdSeconds = Math.max(0, _holdTotal - elapsed);
if (_holdSeconds <= 0) {
_onHoldComplete();
}
}
function _onHoldComplete() {
const exIdx = _holdExerciseIdx;
const setIdx = _holdSetIdx;
_stopHoldTimer();
_playRestDoneSound();
// Auto-complete the set
const ex = exercises[exIdx];
if (ex?.sets[setIdx] && !ex.sets[setIdx].completed) {
ex.sets[setIdx].completed = true;
// Start rest timer
startRestTimer(ex.restTime, exIdx, setIdx);
}
_persist();
}
function startHoldTimer(seconds: number, exerciseIdx: number, setIdx: number) {
_stopHoldTimer();
_holdStartedAt = Date.now();
_holdSeconds = seconds;
_holdTotal = seconds;
_holdActive = true;
_holdExerciseIdx = exerciseIdx;
_holdSetIdx = setIdx;
_holdInterval = setInterval(() => _computeHoldSeconds(), 1000);
_persist();
}
function cancelHoldTimer() {
_stopHoldTimer();
_persist();
}
function _stopHoldTimer() {
if (_holdInterval) {
clearInterval(_holdInterval);
_holdInterval = null;
}
_holdActive = false;
_holdSeconds = 0;
_holdTotal = 0;
_holdStartedAt = null;
_holdExerciseIdx = -1;
_holdSetIdx = -1;
}
// Restore from localStorage on creation // Restore from localStorage on creation
function restore() { function restore() {
const stored = loadFromStorage(); const stored = loadFromStorage();
@@ -254,6 +331,26 @@ export function createWorkout() {
_startRestInterval(); _startRestInterval();
} }
} }
// Restore hold timer if it was active
if (stored.holdStartedAt && stored.holdTotal && stored.holdTotal > 0) {
const elapsed = Math.floor((Date.now() - stored.holdStartedAt) / 1000);
const remaining = stored.holdTotal - elapsed;
if (remaining > 0) {
_holdStartedAt = stored.holdStartedAt;
_holdTotal = stored.holdTotal;
_holdSeconds = remaining;
_holdActive = true;
_holdExerciseIdx = stored.holdExerciseIdx ?? -1;
_holdSetIdx = stored.holdSetIdx ?? -1;
_holdInterval = setInterval(() => _computeHoldSeconds(), 1000);
} else {
// Timer expired while away — complete it
_holdExerciseIdx = stored.holdExerciseIdx ?? -1;
_holdSetIdx = stored.holdSetIdx ?? -1;
_onHoldComplete();
}
}
} }
function startFromTemplate(template: TemplateData) { function startFromTemplate(template: TemplateData) {
@@ -445,6 +542,7 @@ export function createWorkout() {
function finish() { function finish() {
_stopTimer(); _stopTimer();
_stopRestTimer(); _stopRestTimer();
_stopHoldTimer();
const endTime = new Date(); const endTime = new Date();
_computeElapsed(); _computeElapsed();
@@ -505,6 +603,7 @@ export function createWorkout() {
function cancel() { function cancel() {
_stopTimer(); _stopTimer();
_stopRestTimer(); _stopRestTimer();
_stopHoldTimer();
_reset(); _reset();
} }
@@ -553,6 +652,29 @@ export function createWorkout() {
} }
} }
// Apply hold timer state from remote
const holdChanged = remote.holdStartedAt !== _holdStartedAt || remote.holdTotal !== _holdTotal;
if (holdChanged) {
_stopHoldTimer();
if (remote.holdStartedAt && remote.holdTotal && remote.holdTotal > 0) {
const elapsed = Math.floor((Date.now() - remote.holdStartedAt) / 1000);
const remaining = remote.holdTotal - elapsed;
if (remaining > 0) {
_holdStartedAt = remote.holdStartedAt;
_holdTotal = remote.holdTotal;
_holdSeconds = remaining;
_holdActive = true;
_holdExerciseIdx = remote.holdExerciseIdx ?? -1;
_holdSetIdx = remote.holdSetIdx ?? -1;
_holdInterval = setInterval(() => _computeHoldSeconds(), 1000);
} else {
_holdExerciseIdx = remote.holdExerciseIdx ?? -1;
_holdSetIdx = remote.holdSetIdx ?? -1;
_onHoldComplete();
}
}
}
// Persist locally but don't trigger onChange (to avoid re-push loop) // Persist locally but don't trigger onChange (to avoid re-push loop)
saveToStorage({ saveToStorage({
active: true, active: true,
@@ -568,7 +690,11 @@ export function createWorkout() {
restStartedAt: _restActive ? _restStartedAt : null, restStartedAt: _restActive ? _restStartedAt : null,
restTotal: _restTotal, restTotal: _restTotal,
restExerciseIdx: _restActive ? _restExerciseIdx : -1, restExerciseIdx: _restActive ? _restExerciseIdx : -1,
restSetIdx: _restActive ? _restSetIdx : -1 restSetIdx: _restActive ? _restSetIdx : -1,
holdStartedAt: _holdActive ? _holdStartedAt : null,
holdTotal: _holdTotal,
holdExerciseIdx: _holdActive ? _holdExerciseIdx : -1,
holdSetIdx: _holdActive ? _holdSetIdx : -1
}); });
} }
@@ -601,6 +727,12 @@ export function createWorkout() {
get restStartedAt() { return _restStartedAt; }, get restStartedAt() { return _restStartedAt; },
get restExerciseIdx() { return _restExerciseIdx; }, get restExerciseIdx() { return _restExerciseIdx; },
get restSetIdx() { return _restSetIdx; }, get restSetIdx() { return _restSetIdx; },
get holdTimerSeconds() { return _holdSeconds; },
get holdTimerTotal() { return _holdTotal; },
get holdTimerActive() { return _holdActive; },
get holdStartedAt() { return _holdStartedAt; },
get holdExerciseIdx() { return _holdExerciseIdx; },
get holdSetIdx() { return _holdSetIdx; },
restore, restore,
startFromTemplate, startFromTemplate,
startEmpty, startEmpty,
@@ -618,6 +750,8 @@ export function createWorkout() {
startRestTimer, startRestTimer,
cancelRestTimer, cancelRestTimer,
adjustRestTimer, adjustRestTimer,
startHoldTimer,
cancelHoldTimer,
finish, finish,
cancel, cancel,
applyRemoteState, applyRemoteState,

View File

@@ -26,6 +26,10 @@ interface ServerWorkout {
restTotal: number; restTotal: number;
restExerciseIdx: number; restExerciseIdx: number;
restSetIdx: number; restSetIdx: number;
holdStartedAt: number | null;
holdTotal: number;
holdExerciseIdx: number;
holdSetIdx: number;
} }
export function createWorkoutSync() { export function createWorkoutSync() {
@@ -56,7 +60,11 @@ export function createWorkoutSync() {
restStartedAt: workout.restStartedAt, restStartedAt: workout.restStartedAt,
restTotal: workout.restTimerTotal, restTotal: workout.restTimerTotal,
restExerciseIdx: workout.restExerciseIdx, restExerciseIdx: workout.restExerciseIdx,
restSetIdx: workout.restSetIdx restSetIdx: workout.restSetIdx,
holdStartedAt: workout.holdStartedAt,
holdTotal: workout.holdTimerTotal,
holdExerciseIdx: workout.holdExerciseIdx,
holdSetIdx: workout.holdSetIdx
}; };
} }
@@ -124,7 +132,11 @@ export function createWorkoutSync() {
restStartedAt: doc.restStartedAt ?? null, restStartedAt: doc.restStartedAt ?? null,
restTotal: doc.restTotal ?? 0, restTotal: doc.restTotal ?? 0,
restExerciseIdx: doc.restExerciseIdx ?? -1, restExerciseIdx: doc.restExerciseIdx ?? -1,
restSetIdx: doc.restSetIdx ?? -1 restSetIdx: doc.restSetIdx ?? -1,
holdStartedAt: doc.holdStartedAt ?? null,
holdTotal: doc.holdTotal ?? 0,
holdExerciseIdx: doc.holdExerciseIdx ?? -1,
holdSetIdx: doc.holdSetIdx ?? -1
}); });
status = 'synced'; status = 'synced';
@@ -245,7 +257,11 @@ export function createWorkoutSync() {
restStartedAt: serverDoc.restStartedAt ?? null, restStartedAt: serverDoc.restStartedAt ?? null,
restTotal: serverDoc.restTotal ?? 0, restTotal: serverDoc.restTotal ?? 0,
restExerciseIdx: serverDoc.restExerciseIdx ?? -1, restExerciseIdx: serverDoc.restExerciseIdx ?? -1,
restSetIdx: serverDoc.restSetIdx ?? -1 restSetIdx: serverDoc.restSetIdx ?? -1,
holdStartedAt: serverDoc.holdStartedAt ?? null,
holdTotal: serverDoc.holdTotal ?? 0,
holdExerciseIdx: serverDoc.holdExerciseIdx ?? -1,
holdSetIdx: serverDoc.holdSetIdx ?? -1
}); });
} }
connectSSE(); connectSSE();

View File

@@ -4,6 +4,8 @@ export interface IActiveWorkoutSet {
reps: number | null; reps: number | null;
weight: number | null; weight: number | null;
rpe: number | null; rpe: number | null;
distance: number | null;
duration: number | null;
completed: boolean; completed: boolean;
} }
@@ -29,6 +31,10 @@ export interface IActiveWorkout {
restTotal: number; restTotal: number;
restExerciseIdx: number; restExerciseIdx: number;
restSetIdx: number; restSetIdx: number;
holdStartedAt: number | null;
holdTotal: number;
holdExerciseIdx: number;
holdSetIdx: number;
updatedAt?: Date; updatedAt?: Date;
} }
@@ -36,6 +42,8 @@ const ActiveWorkoutSetSchema = new mongoose.Schema({
reps: { type: Number, default: null }, reps: { type: Number, default: null },
weight: { type: Number, default: null }, weight: { type: Number, default: null },
rpe: { type: Number, default: null }, rpe: { type: Number, default: null },
distance: { type: Number, default: null },
duration: { type: Number, default: null },
completed: { type: Boolean, default: false } completed: { type: Boolean, default: false }
}, { _id: false }); }, { _id: false });
@@ -109,6 +117,22 @@ const ActiveWorkoutSchema = new mongoose.Schema(
restSetIdx: { restSetIdx: {
type: Number, type: Number,
default: -1 default: -1
},
holdStartedAt: {
type: Number,
default: null
},
holdTotal: {
type: Number,
default: 0
},
holdExerciseIdx: {
type: Number,
default: -1
},
holdSetIdx: {
type: Number,
default: -1
} }
}, },
{ {

View File

@@ -34,7 +34,7 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
try { try {
await dbConnect(); await dbConnect();
const data = await request.json(); 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) { if (!name) {
return json({ error: 'Name is required' }, { status: 400 }); return json({ error: 'Name is required' }, { status: 400 });
@@ -69,6 +69,10 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
restTotal: restTotal ?? 0, restTotal: restTotal ?? 0,
restExerciseIdx: restExerciseIdx ?? -1, restExerciseIdx: restExerciseIdx ?? -1,
restSetIdx: restSetIdx ?? -1, restSetIdx: restSetIdx ?? -1,
holdStartedAt: holdStartedAt ?? null,
holdTotal: holdTotal ?? 0,
holdExerciseIdx: holdExerciseIdx ?? -1,
holdSetIdx: holdSetIdx ?? -1,
version: newVersion version: newVersion
}, },
$setOnInsert: { userId } $setOnInsert: { userId }

View File

@@ -583,6 +583,7 @@
const exercise = getExerciseById(ex.exerciseId, lang); const exercise = getExerciseById(ex.exerciseId, lang);
const metrics = getExerciseMetrics(exercise); const metrics = getExerciseMetrics(exercise);
const isCardio = metrics.includes('distance'); const isCardio = metrics.includes('distance');
const isDurationOnly = metrics.includes('duration') && !metrics.includes('weight') && !metrics.includes('reps');
const isBilateral = exercise?.bilateral ?? false; const isBilateral = exercise?.bilateral ?? false;
const weightMul = isBilateral ? 2 : 1; const weightMul = isBilateral ? 2 : 1;
@@ -599,6 +600,8 @@
if (isCardio) { if (isCardio) {
exDistance += s.distance ?? 0; exDistance += s.distance ?? 0;
exDuration += s.duration ?? 0; exDuration += s.duration ?? 0;
} else if (isDurationOnly) {
exDuration += s.duration ?? 0;
} else { } else {
const w = (s.weight ?? 0) * weightMul; const w = (s.weight ?? 0) * weightMul;
const r = s.reps ?? 0; const r = s.reps ?? 0;
@@ -618,6 +621,7 @@
exerciseId: ex.exerciseId, exerciseId: ex.exerciseId,
sets, sets,
isCardio, isCardio,
isDurationOnly,
tonnage: exTonnage, tonnage: exTonnage,
distance: exDistance, distance: exDistance,
duration: exDuration, duration: exDuration,
@@ -667,19 +671,21 @@
const metrics = getExerciseMetrics(exercise); const metrics = getExerciseMetrics(exercise);
if (metrics.includes('distance')) continue; // skip cardio 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); const completedSets = actual.sets.filter((/** @type {any} */ s) => s.completed);
if (completedSets.length === 0) continue; if (completedSets.length === 0) continue;
// Check if sets differ in count, reps, or weight // Check if sets differ
const tmplSets = tmplEx.sets ?? []; const tmplSets = tmplEx.sets ?? [];
let changed = completedSets.length !== tmplSets.length; let changed = completedSets.length !== tmplSets.length;
if (!changed) { if (!changed) {
for (let i = 0; i < completedSets.length; i++) { for (let i = 0; i < completedSets.length; i++) {
const a = completedSets[i]; const a = completedSets[i];
const t = tmplSets[i]; const tp = tmplSets[i];
if ((a.reps ?? 0) !== (t.reps ?? 0) || (a.weight ?? 0) !== (t.weight ?? 0)) { if (isDurOnly) {
changed = true; if ((a.duration ?? 0) !== (tp.duration ?? 0)) { changed = true; break; }
break; } else {
if ((a.reps ?? 0) !== (tp.reps ?? 0) || (a.weight ?? 0) !== (tp.weight ?? 0)) { changed = true; break; }
} }
} }
} }
@@ -688,12 +694,12 @@
diffs.push({ diffs.push({
exerciseId: actual.exerciseId, exerciseId: actual.exerciseId,
name: exercise?.localName ?? actual.exerciseId, name: exercise?.localName ?? actual.exerciseId,
isDurationOnly: isDurOnly,
oldSets: tmplSets, oldSets: tmplSets,
newSets: completedSets.map((/** @type {any} */ s) => ({ newSets: completedSets.map((/** @type {any} */ s) => isDurOnly
reps: s.reps ?? undefined, ? { duration: s.duration ?? undefined }
weight: s.weight ?? undefined, : { reps: s.reps ?? undefined, weight: s.weight ?? undefined, rpe: s.rpe ?? 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> <span class="ex-summary-sets">{ex.sets} {ex.sets !== 1 ? t('sets', lang) : t('set', lang)}</span>
</div> </div>
<div class="ex-summary-stats"> <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} {#if ex.distance > 0}
<span>{ex.distance.toFixed(1)} km</span> <span>{ex.distance.toFixed(1)} km</span>
{/if} {/if}
@@ -888,11 +896,19 @@
{#each diff.newSets as set, i} {#each diff.newSets as set, i}
{@const old = diff.oldSets[i]} {@const old = diff.oldSets[i]}
<div class="diff-set-row"> <div class="diff-set-row">
{#if old} {#if diff.isDurationOnly}
<span class="diff-old">{old.weight ?? '—'} kg × {old.reps ?? '—'}</span> {#if old}
<span class="diff-arrow"></span> <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} {/if}
<span class="diff-new">{set.weight ?? '—'} kg × {set.reps ?? '—'}</span>
</div> </div>
{/each} {/each}
{#if diff.newSets.length > diff.oldSets.length} {#if diff.newSets.length > diff.oldSets.length}
@@ -1346,6 +1362,8 @@
{/if} {/if}
{#each workout.exercises as ex, exIdx (exIdx)} {#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-block">
<div class="exercise-header"> <div class="exercise-header">
<ExerciseName exerciseId={ex.exerciseId} /> <ExerciseName exerciseId={ex.exerciseId} />
@@ -1369,18 +1387,34 @@
<SetTable <SetTable
sets={ex.sets} sets={ex.sets}
previousSets={previousData[ex.exerciseId] ?? []} previousSets={previousData[ex.exerciseId] ?? []}
metrics={getExerciseMetrics(getExerciseById(ex.exerciseId))} metrics={exMetrics}
editable={true} editable={true}
timedHold={isDurationOnly}
restAfterSet={workout.restTimerActive && workout.restExerciseIdx === exIdx ? workout.restSetIdx : -1} restAfterSet={workout.restTimerActive && workout.restExerciseIdx === exIdx ? workout.restSetIdx : -1}
restSeconds={workout.restTimerSeconds} restSeconds={workout.restTimerSeconds}
restTotal={workout.restTimerTotal} restTotal={workout.restTimerTotal}
holdAfterSet={workout.holdTimerActive && workout.holdExerciseIdx === exIdx ? workout.holdSetIdx : -1}
holdSeconds={workout.holdTimerSeconds}
holdTotal={workout.holdTimerTotal}
onRestAdjust={(delta) => workout.adjustRestTimer(delta)} onRestAdjust={(delta) => workout.adjustRestTimer(delta)}
onRestSkip={cancelRest} onRestSkip={cancelRest}
onHoldSkip={() => workout.cancelHoldTimer()}
onUpdate={(setIdx, d) => workout.updateSet(exIdx, setIdx, d)} onUpdate={(setIdx, d) => workout.updateSet(exIdx, setIdx, d)}
onToggleComplete={(setIdx) => { onToggleComplete={(setIdx) => {
workout.toggleSetComplete(exIdx, setIdx); const set = ex.sets[setIdx];
if (ex.sets[setIdx]?.completed) { // If hold timer is running for this set, cancel it
workout.startRestTimer(ex.restTime, exIdx, setIdx); 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)} onRemove={(setIdx) => workout.removeSet(exIdx, setIdx)}