feat: add hold timer for timed exercises with full sync support
All checks were successful
CI / update (push) Successful in 3m32s
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:
@@ -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": {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
Reference in New Issue
Block a user