fitness: add inline rest timer, set removal, previous set improvements, and session editing

Redesign rest timer as inline bar with linear decay placed after completed set.
Add set removal (X button), @ separator column for RPE, and N/A for missing
previous values. Enable editing past workouts (date, duration, exercises, sets)
from the history detail page.
This commit is contained in:
2026-03-20 06:50:19 +01:00
parent de55e51301
commit aec1d54841
5 changed files with 584 additions and 167 deletions
+53 -42
View File
@@ -3,15 +3,14 @@
* @type {{
* seconds: number,
* total: number,
* onComplete?: (() => void) | null
* onComplete?: (() => void) | null,
* onAdjust?: ((delta: number) => void) | null,
* onSkip?: (() => void) | null
* }}
*/
let { seconds, total, onComplete = null } = $props();
let { seconds, total, onComplete = null, onAdjust = null, onSkip = null } = $props();
const radius = 40;
const circumference = 2 * Math.PI * radius;
const progress = $derived(total > 0 ? (total - seconds) / total : 0);
const offset = $derived(circumference * (1 - progress));
const progress = $derived(total > 0 ? seconds / total : 0);
/** @param {number} secs */
function formatTime(secs) {
@@ -27,50 +26,62 @@
});
</script>
<div class="rest-timer">
<svg viewBox="0 0 100 100" class="timer-ring">
<circle cx="50" cy="50" r={radius} class="bg-ring" />
<circle
cx="50" cy="50" r={radius}
class="progress-ring"
stroke-dasharray={circumference}
stroke-dashoffset={offset}
transform="rotate(-90 50 50)"
/>
</svg>
<span class="timer-text">{formatTime(seconds)}</span>
<div class="rest-bar">
<div class="bar-fill" style:width="{progress * 100}%"></div>
<div class="bar-controls">
<button class="adjust-btn" onclick={() => onAdjust?.(-30)}>-30s</button>
<button class="time-btn" onclick={() => onSkip?.()}>{formatTime(seconds)}</button>
<button class="adjust-btn" onclick={() => onAdjust?.(30)}>+30s</button>
</div>
</div>
<style>
.rest-timer {
.rest-bar {
border-radius: 8px;
overflow: hidden;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 100px;
height: 100px;
height: 2.2rem;
background: var(--color-bg-elevated);
}
.timer-ring {
.bar-fill {
position: absolute;
inset: 0;
width: 100%;
background: var(--color-primary);
border-radius: 8px;
transition: width 1s linear;
}
.bg-ring {
fill: none;
stroke: var(--color-border);
stroke-width: 4;
}
.progress-ring {
fill: none;
stroke: var(--color-primary);
stroke-width: 4;
stroke-linecap: round;
transition: stroke-dashoffset 1s linear;
}
.timer-text {
font-size: 1.25rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--color-primary);
.bar-controls {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
z-index: 1;
}
.time-btn {
background: none;
border: none;
font-size: 0.9rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--color-text-primary, inherit);
cursor: pointer;
padding: 0.2rem 0.5rem;
}
.adjust-btn {
background: none;
border: none;
color: var(--color-text-primary, inherit);
cursor: pointer;
font-size: 0.7rem;
font-weight: 600;
padding: 0.2rem 0.4rem;
border-radius: 4px;
opacity: 0.7;
}
.adjust-btn:hover {
opacity: 1;
}
</style>
+94 -5
View File
@@ -1,6 +1,7 @@
<script>
import { Check } from 'lucide-svelte';
import { Check, X } from 'lucide-svelte';
import { METRIC_LABELS } from '$lib/data/exercises';
import RestTimer from './RestTimer.svelte';
/**
* @type {{
@@ -8,6 +9,11 @@
* previousSets?: Array<Record<string, any>> | null,
* metrics?: Array<'weight' | 'reps' | 'rpe' | 'distance' | 'duration'>,
* editable?: boolean,
* restAfterSet?: number,
* restSeconds?: number,
* restTotal?: number,
* onRestAdjust?: ((delta: number) => void) | null,
* onRestSkip?: (() => void) | null,
* onUpdate?: ((setIndex: number, data: Record<string, number | null>) => void) | null,
* onToggleComplete?: ((setIndex: number) => void) | null,
* onRemove?: ((setIndex: number) => void) | null
@@ -18,6 +24,11 @@
previousSets = null,
metrics = ['weight', 'reps', 'rpe'],
editable = false,
restAfterSet = -1,
restSeconds = 0,
restTotal = 0,
onRestAdjust = null,
onRestSkip = null,
onUpdate = null,
onToggleComplete = null,
onRemove = null
@@ -26,6 +37,9 @@
/** Metrics to show in the main columns (not RPE, which is edit-only) */
const mainMetrics = $derived(metrics.filter((m) => m !== 'rpe'));
const hasRpe = $derived(metrics.includes('rpe'));
const totalCols = $derived(
(editable && onRemove ? 1 : 0) + 1 + (previousSets ? 1 : 0) + mainMetrics.length + (editable && hasRpe ? 2 : 0) + (editable ? 1 : 0)
);
/**
* @param {number} index
@@ -38,13 +52,15 @@
onUpdate?.(index, { [field]: val });
}
/** Format a previous set for display */
/** Format a previous set for display: "weight × reps@rpe" or "distance × duration@rpe" */
function formatPrev(/** @type {Record<string, any>} */ prev) {
const parts = [];
for (const m of mainMetrics) {
if (prev[m] != null) parts.push(`${prev[m]}`);
}
return parts.join(' × ');
let result = parts.join(' × ');
if (prev.rpe != null) result += `@${prev.rpe}`;
return result;
}
/** @param {string} metric */
@@ -56,6 +72,9 @@
<table class="set-table">
<thead>
<tr>
{#if editable && onRemove}
<th class="col-remove"></th>
{/if}
<th class="col-set">SET</th>
{#if previousSets}
<th class="col-prev">PREVIOUS</th>
@@ -64,6 +83,7 @@
<th class="col-metric">{METRIC_LABELS[metric]}</th>
{/each}
{#if editable && hasRpe}
<th class="col-at"></th>
<th class="col-rpe">RPE</th>
{/if}
{#if editable}
@@ -74,13 +94,22 @@
<tbody>
{#each sets as set, i (i)}
<tr class:completed={set.completed}>
{#if editable && onRemove}
<td class="col-remove">
{#if sets.length > 1}
<button class="set-remove-btn" onclick={() => onRemove?.(i)} aria-label="Remove set {i + 1}">
<X size={12} />
</button>
{/if}
</td>
{/if}
<td class="col-set">{i + 1}</td>
{#if previousSets}
<td class="col-prev">
{#if previousSets[i]}
{formatPrev(previousSets[i])}
{:else}
<span class="prev-na">N/A</span>
{/if}
</td>
{/if}
@@ -100,6 +129,7 @@
</td>
{/each}
{#if editable && hasRpe}
<td class="col-at">@</td>
<td class="col-rpe">
<input
type="number"
@@ -125,6 +155,19 @@
</td>
{/if}
</tr>
{#if restAfterSet === i && restTotal > 0}
<tr class="rest-row">
<td colspan={totalCols} class="rest-cell">
<RestTimer
seconds={restSeconds}
total={restTotal}
onComplete={onRestSkip}
onAdjust={onRestAdjust}
onSkip={onRestSkip}
/>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
@@ -149,6 +192,10 @@
text-align: center;
border-top: 1px solid var(--color-border);
}
.col-remove {
width: 1.5rem;
padding: 0.35rem 0 0.35rem 0.25rem;
}
.col-set {
width: 2.5rem;
font-weight: 700;
@@ -156,13 +203,27 @@
}
.col-prev {
color: var(--color-text-secondary);
font-size: 0.8rem;
font-size: 0.75rem;
text-align: left;
white-space: nowrap;
}
.col-metric {
width: 4rem;
}
.col-metric:has(+ .col-at) {
padding-right: 0;
}
.col-at {
width: 0.8rem;
padding-left: 0;
padding-right: 0;
font-size: 0.7rem;
color: var(--color-text-secondary);
opacity: 0.5;
}
.col-rpe {
width: 3rem;
padding-left: 0;
}
.col-check {
width: 2.5rem;
@@ -188,6 +249,24 @@
outline: none;
border-color: var(--color-primary);
}
.set-remove-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.4rem;
height: 1.4rem;
border-radius: 50%;
border: none;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
margin: 0 auto;
padding: 0;
}
.set-remove-btn:hover {
color: var(--nord11);
background: color-mix(in srgb, var(--nord11) 10%, transparent);
}
.check-btn {
display: flex;
align-items: center;
@@ -207,4 +286,14 @@
border-color: var(--nord14);
color: white;
}
.rest-row td {
border-top: none;
}
.rest-cell {
padding: 0.3rem 0.25rem;
}
.prev-na {
opacity: 0.4;
font-size: 0.7rem;
}
</style>