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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -49,7 +49,7 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
||||
}
|
||||
|
||||
const data = await request.json();
|
||||
const { name, exercises, startTime, endTime, notes } = data;
|
||||
const { name, exercises, startTime, endTime, duration, notes } = data;
|
||||
|
||||
if (exercises && (!Array.isArray(exercises) || exercises.length === 0)) {
|
||||
return json({ error: 'At least one exercise is required' }, { status: 400 });
|
||||
@@ -60,10 +60,11 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
||||
if (exercises) updateData.exercises = exercises;
|
||||
if (startTime) updateData.startTime = new Date(startTime);
|
||||
if (endTime) updateData.endTime = new Date(endTime);
|
||||
if (duration !== undefined) updateData.duration = duration;
|
||||
if (notes !== undefined) updateData.notes = notes;
|
||||
|
||||
// Calculate duration if both times are provided
|
||||
if (updateData.startTime && updateData.endTime) {
|
||||
// Calculate duration from times if both provided but duration wasn't explicit
|
||||
if (updateData.startTime && updateData.endTime && duration === undefined) {
|
||||
updateData.duration = Math.round(((updateData.endTime as Date).getTime() - (updateData.startTime as Date).getTime()) / (1000 * 60));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,120 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { Clock, Weight, Trophy, Trash2 } from 'lucide-svelte';
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { Clock, Weight, Trophy, Trash2, Pencil, Plus } from 'lucide-svelte';
|
||||
import { getExerciseById, getExerciseMetrics, METRIC_LABELS } from '$lib/data/exercises';
|
||||
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
|
||||
import SetTable from '$lib/components/fitness/SetTable.svelte';
|
||||
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const session = $derived(data.session);
|
||||
let deleting = $state(false);
|
||||
let editing = $state(false);
|
||||
let saving = $state(false);
|
||||
let showPicker = $state(false);
|
||||
|
||||
/** @type {any} */
|
||||
let editData = $state(null);
|
||||
|
||||
function startEdit() {
|
||||
editData = {
|
||||
name: session.name,
|
||||
date: toLocalDate(session.startTime),
|
||||
time: toLocalTime(session.startTime),
|
||||
duration: session.duration ?? 0,
|
||||
notes: session.notes ?? '',
|
||||
exercises: session.exercises.map((/** @type {any} */ ex) => ({
|
||||
exerciseId: ex.exerciseId,
|
||||
name: ex.name,
|
||||
restTime: ex.restTime,
|
||||
sets: ex.sets.map((/** @type {any} */ s) => ({ ...s }))
|
||||
}))
|
||||
};
|
||||
editing = true;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editing = false;
|
||||
editData = null;
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editData) return;
|
||||
saving = true;
|
||||
const startTime = new Date(`${editData.date}T${editData.time}`);
|
||||
const body = {
|
||||
name: editData.name,
|
||||
startTime: startTime.toISOString(),
|
||||
duration: editData.duration,
|
||||
notes: editData.notes,
|
||||
exercises: editData.exercises.map((/** @type {any} */ ex) => ({
|
||||
exerciseId: ex.exerciseId,
|
||||
name: ex.name,
|
||||
restTime: ex.restTime,
|
||||
sets: ex.sets
|
||||
}))
|
||||
};
|
||||
try {
|
||||
const res = await fetch(`/api/fitness/sessions/${session._id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (res.ok) {
|
||||
editing = false;
|
||||
editData = null;
|
||||
await invalidateAll();
|
||||
}
|
||||
} catch {}
|
||||
saving = false;
|
||||
}
|
||||
|
||||
/** @param {string} exerciseId */
|
||||
function addExerciseToEdit(exerciseId) {
|
||||
const exercise = getExerciseById(exerciseId);
|
||||
editData.exercises = [
|
||||
...editData.exercises,
|
||||
{
|
||||
exerciseId,
|
||||
name: exercise?.name ?? exerciseId,
|
||||
restTime: 120,
|
||||
sets: [{ completed: true }]
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/** @param {number} exIdx */
|
||||
function removeExerciseFromEdit(exIdx) {
|
||||
editData.exercises = editData.exercises.filter((/** @type {any} */ _e, /** @type {number} */ i) => i !== exIdx);
|
||||
}
|
||||
|
||||
/** @param {number} exIdx */
|
||||
function addSetToEdit(exIdx) {
|
||||
editData.exercises[exIdx].sets = [...editData.exercises[exIdx].sets, { completed: true }];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} exIdx
|
||||
* @param {number} setIdx
|
||||
*/
|
||||
function removeSetFromEdit(exIdx, setIdx) {
|
||||
editData.exercises[exIdx].sets = editData.exercises[exIdx].sets.filter(
|
||||
(/** @type {any} */ _s, /** @type {number} */ i) => i !== setIdx
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} exIdx
|
||||
* @param {number} setIdx
|
||||
* @param {Record<string, number | null>} data
|
||||
*/
|
||||
function updateSetInEdit(exIdx, setIdx, data) {
|
||||
editData.exercises[exIdx].sets[setIdx] = {
|
||||
...editData.exercises[exIdx].sets[setIdx],
|
||||
...data
|
||||
};
|
||||
}
|
||||
|
||||
/** @param {number} mins */
|
||||
function formatDuration(mins) {
|
||||
@@ -29,6 +136,18 @@
|
||||
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/** @param {string} dateStr */
|
||||
function toLocalDate(dateStr) {
|
||||
const d = new Date(dateStr);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** @param {string} dateStr */
|
||||
function toLocalTime(dateStr) {
|
||||
const d = new Date(dateStr);
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** @param {number} weight @param {number} reps */
|
||||
function epley1rm(weight, reps) {
|
||||
if (reps <= 0 || weight <= 0) return 0;
|
||||
@@ -42,7 +161,7 @@
|
||||
try {
|
||||
const res = await fetch(`/api/fitness/sessions/${session._id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
goto('/fitness/history');
|
||||
await goto('/fitness/history');
|
||||
}
|
||||
} catch {}
|
||||
deleting = false;
|
||||
@@ -59,76 +178,149 @@
|
||||
<div class="session-detail">
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<h1>{session.name}</h1>
|
||||
{#if editing}
|
||||
<input class="edit-name-input" type="text" bind:value={editData.name} />
|
||||
{:else}
|
||||
<h1>{session.name}</h1>
|
||||
{/if}
|
||||
<p class="session-date">{formatDate(session.startTime)} · {formatTime(session.startTime)}</p>
|
||||
</div>
|
||||
<button class="delete-btn" onclick={deleteSession} disabled={deleting} aria-label="Delete session">
|
||||
<Trash2 size={18} />
|
||||
<div class="header-actions">
|
||||
{#if editing}
|
||||
<button class="save-btn" onclick={saveEdit} disabled={saving}>
|
||||
{saving ? 'SAVING...' : 'SAVE'}
|
||||
</button>
|
||||
<button class="cancel-edit-btn" onclick={cancelEdit}>CANCEL</button>
|
||||
{:else}
|
||||
<button class="edit-btn" onclick={startEdit} aria-label="Edit session">
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button class="delete-btn" onclick={deleteSession} disabled={deleting} aria-label="Delete session">
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if editing}
|
||||
<div class="edit-meta">
|
||||
<div class="meta-row">
|
||||
<label for="edit-date">Date</label>
|
||||
<input id="edit-date" type="date" bind:value={editData.date} />
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<label for="edit-time">Time</label>
|
||||
<input id="edit-time" type="time" bind:value={editData.time} />
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<label for="edit-duration">Duration (min)</label>
|
||||
<input id="edit-duration" type="number" min="0" bind:value={editData.duration} />
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<label for="edit-notes">Notes</label>
|
||||
<textarea id="edit-notes" bind:value={editData.notes} rows="2" placeholder="Workout notes..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="stats-row">
|
||||
{#if session.duration}
|
||||
<div class="stat-pill">
|
||||
<Clock size={14} />
|
||||
<span>{formatDuration(session.duration)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if session.totalVolume}
|
||||
<div class="stat-pill">
|
||||
<Weight size={14} />
|
||||
<span>{Math.round(session.totalVolume).toLocaleString()} kg</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if session.prs?.length > 0}
|
||||
<div class="stat-pill pr">
|
||||
<Trophy size={14} />
|
||||
<span>{session.prs.length} PR{session.prs.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if editing}
|
||||
{#each editData.exercises as ex, exIdx (exIdx)}
|
||||
<div class="exercise-block">
|
||||
<div class="exercise-header">
|
||||
<ExerciseName exerciseId={ex.exerciseId} />
|
||||
<button
|
||||
class="remove-exercise"
|
||||
onclick={() => removeExerciseFromEdit(exIdx)}
|
||||
aria-label="Remove exercise"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SetTable
|
||||
sets={ex.sets}
|
||||
metrics={getExerciseMetrics(getExerciseById(ex.exerciseId))}
|
||||
editable={true}
|
||||
onUpdate={(setIdx, d) => updateSetInEdit(exIdx, setIdx, d)}
|
||||
onToggleComplete={(setIdx) => {
|
||||
ex.sets[setIdx].completed = !ex.sets[setIdx].completed;
|
||||
}}
|
||||
onRemove={(setIdx) => removeSetFromEdit(exIdx, setIdx)}
|
||||
/>
|
||||
|
||||
<button class="add-set-btn" onclick={() => addSetToEdit(exIdx)}>
|
||||
+ ADD SET
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button class="add-exercise-btn" onclick={() => showPicker = true}>
|
||||
<Plus size={18} /> ADD EXERCISE
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="stats-row">
|
||||
{#if session.duration}
|
||||
<div class="stat-pill">
|
||||
<Clock size={14} />
|
||||
<span>{formatDuration(session.duration)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if session.totalVolume}
|
||||
<div class="stat-pill">
|
||||
<Weight size={14} />
|
||||
<span>{Math.round(session.totalVolume).toLocaleString()} kg</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if session.prs?.length > 0}
|
||||
<div class="stat-pill pr">
|
||||
<Trophy size={14} />
|
||||
<span>{session.prs.length} PR{session.prs.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#each session.exercises as ex, exIdx (ex.exerciseId + '-' + exIdx)}
|
||||
{@const exercise = getExerciseById(ex.exerciseId)}
|
||||
{@const metrics = getExerciseMetrics(exercise)}
|
||||
{@const mainMetrics = metrics.filter((/** @type {string} */ m) => m !== 'rpe')}
|
||||
{@const showEst1rm = isStrength(ex.exerciseId)}
|
||||
<div class="exercise-block">
|
||||
<h3 class="exercise-title">
|
||||
<ExerciseName exerciseId={ex.exerciseId} />
|
||||
</h3>
|
||||
<table class="sets-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SET</th>
|
||||
{#each mainMetrics as metric (metric)}
|
||||
<th>{METRIC_LABELS[metric]}</th>
|
||||
{/each}
|
||||
<th>RPE</th>
|
||||
{#if showEst1rm}
|
||||
<th>EST. 1RM</th>
|
||||
{/if}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each ex.sets as set, i (i)}
|
||||
{:else}
|
||||
{#each session.exercises as ex, exIdx (ex.exerciseId + '-' + exIdx)}
|
||||
{@const exercise = getExerciseById(ex.exerciseId)}
|
||||
{@const metrics = getExerciseMetrics(exercise)}
|
||||
{@const mainMetrics = metrics.filter((/** @type {string} */ m) => m !== 'rpe')}
|
||||
{@const showEst1rm = isStrength(ex.exerciseId)}
|
||||
<div class="exercise-block">
|
||||
<h3 class="exercise-title">
|
||||
<ExerciseName exerciseId={ex.exerciseId} />
|
||||
</h3>
|
||||
<table class="sets-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td class="set-num">{i + 1}</td>
|
||||
<th>SET</th>
|
||||
{#each mainMetrics as metric (metric)}
|
||||
<td>{set[metric] ?? '—'}</td>
|
||||
<th>{METRIC_LABELS[metric]}</th>
|
||||
{/each}
|
||||
<td class="rpe">{set.rpe ?? '—'}</td>
|
||||
<th>RPE</th>
|
||||
{#if showEst1rm}
|
||||
<td class="est1rm">{set.weight && set.reps ? epley1rm(set.weight, set.reps) : '—'}</td>
|
||||
<th>EST. 1RM</th>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/each}
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each ex.sets as set, i (i)}
|
||||
<tr>
|
||||
<td class="set-num">{i + 1}</td>
|
||||
{#each mainMetrics as metric (metric)}
|
||||
<td>{set[metric] ?? '—'}</td>
|
||||
{/each}
|
||||
<td class="rpe">{set.rpe ?? '—'}</td>
|
||||
{#if showEst1rm}
|
||||
<td class="est1rm">{set.weight && set.reps ? epley1rm(set.weight, set.reps) : '—'}</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if session.prs?.length > 0}
|
||||
{#if !editing && session.prs?.length > 0}
|
||||
<div class="prs-section">
|
||||
<h2>Personal Records</h2>
|
||||
<div class="pr-list">
|
||||
@@ -150,7 +342,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if session.notes}
|
||||
{#if !editing && session.notes}
|
||||
<div class="notes-section">
|
||||
<h2>Notes</h2>
|
||||
<p>{session.notes}</p>
|
||||
@@ -158,6 +350,13 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showPicker}
|
||||
<ExercisePicker
|
||||
onSelect={(id) => { addExerciseToEdit(id); showPicker = false; }}
|
||||
onClose={() => showPicker = false}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.session-detail {
|
||||
display: flex;
|
||||
@@ -178,6 +377,24 @@
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
.edit-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0.4rem;
|
||||
display: flex;
|
||||
}
|
||||
.edit-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.delete-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--nord11);
|
||||
@@ -195,6 +412,90 @@
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.save-btn {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 1rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.save-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.cancel-edit-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0.4rem 1rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.cancel-edit-btn:hover {
|
||||
border-color: var(--color-text-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.edit-name-input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: inherit;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
padding: 0.2rem 0;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
.edit-name-input:focus {
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
.edit-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
background: var(--color-surface);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 1rem;
|
||||
}
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.meta-row label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
min-width: 7rem;
|
||||
}
|
||||
.meta-row input,
|
||||
.meta-row textarea {
|
||||
flex: 1;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: inherit;
|
||||
}
|
||||
.meta-row textarea {
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
}
|
||||
.meta-row input:focus,
|
||||
.meta-row textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
@@ -224,10 +525,61 @@
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 1rem;
|
||||
}
|
||||
.exercise-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.exercise-title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.remove-exercise {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--nord11);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.remove-exercise:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.add-set-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.4rem;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 8px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.add-set-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.add-exercise-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.sets-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
|
||||
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
|
||||
import SetTable from '$lib/components/fitness/SetTable.svelte';
|
||||
import RestTimer from '$lib/components/fitness/RestTimer.svelte';
|
||||
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
|
||||
import SyncIndicator from '$lib/components/fitness/SyncIndicator.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
@@ -14,6 +13,8 @@
|
||||
const workout = getWorkout();
|
||||
const sync = getWorkoutSync();
|
||||
let showPicker = $state(false);
|
||||
let restExerciseIdx = $state(-1);
|
||||
let restSetIdx = $state(-1);
|
||||
|
||||
/** @type {Record<string, Array<Record<string, any>>>} */
|
||||
let previousData = $state({});
|
||||
@@ -79,6 +80,12 @@
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function cancelRest() {
|
||||
workout.cancelRestTimer();
|
||||
restExerciseIdx = -1;
|
||||
restSetIdx = -1;
|
||||
}
|
||||
|
||||
// Fetch previous data for existing exercises on mount
|
||||
onMount(() => {
|
||||
if (workout.active && workout.exercises.length > 0) {
|
||||
@@ -107,21 +114,6 @@
|
||||
placeholder="Workout name"
|
||||
/>
|
||||
|
||||
{#if workout.restTimerActive}
|
||||
<div class="rest-timer-section">
|
||||
<RestTimer
|
||||
seconds={workout.restTimerSeconds}
|
||||
total={workout.restTimerTotal}
|
||||
onComplete={() => workout.cancelRestTimer()}
|
||||
/>
|
||||
<div class="rest-controls">
|
||||
<button class="rest-adjust" onclick={() => workout.adjustRestTimer(-30)}>-30s</button>
|
||||
<button class="skip-rest" onclick={() => workout.cancelRestTimer()}>Skip</button>
|
||||
<button class="rest-adjust" onclick={() => workout.adjustRestTimer(30)}>+30s</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each workout.exercises as ex, exIdx (exIdx)}
|
||||
<div class="exercise-block">
|
||||
<div class="exercise-header">
|
||||
@@ -137,16 +129,24 @@
|
||||
|
||||
<SetTable
|
||||
sets={ex.sets}
|
||||
previousSets={previousData[ex.exerciseId] ?? null}
|
||||
previousSets={previousData[ex.exerciseId] ?? []}
|
||||
metrics={getExerciseMetrics(getExerciseById(ex.exerciseId))}
|
||||
editable={true}
|
||||
restAfterSet={workout.restTimerActive && restExerciseIdx === exIdx ? restSetIdx : -1}
|
||||
restSeconds={workout.restTimerSeconds}
|
||||
restTotal={workout.restTimerTotal}
|
||||
onRestAdjust={(delta) => workout.adjustRestTimer(delta)}
|
||||
onRestSkip={cancelRest}
|
||||
onUpdate={(setIdx, d) => workout.updateSet(exIdx, setIdx, d)}
|
||||
onToggleComplete={(setIdx) => {
|
||||
workout.toggleSetComplete(exIdx, setIdx);
|
||||
if (ex.sets[setIdx]?.completed && !workout.restTimerActive) {
|
||||
restExerciseIdx = exIdx;
|
||||
restSetIdx = setIdx;
|
||||
workout.startRestTimer(ex.restTime);
|
||||
}
|
||||
}}
|
||||
onRemove={(setIdx) => workout.removeSet(exIdx, setIdx)}
|
||||
/>
|
||||
|
||||
<button class="add-set-btn" onclick={() => workout.addSet(exIdx)}>
|
||||
@@ -244,41 +244,6 @@
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.rest-timer-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.rest-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.rest-adjust {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
.rest-adjust:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.skip-rest {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.exercise-block {
|
||||
background: var(--color-surface);
|
||||
border-radius: 8px;
|
||||
@@ -357,5 +322,4 @@
|
||||
.cancel-btn:hover {
|
||||
background: rgba(191, 97, 106, 0.1);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user