feat(fitness/active): rail + focus card layout

Redesign the active-workout page around a left-rail timeline and a
focus card on the right. The rail owns the workout title, pause,
elapsed time, sync indicator, progress bar, and a reorderable chip
per exercise (drag to reorder, × to delete, starting-weight hint so
you know what to rack, green checkmark when complete). Main stage
holds a hero focus card for the active exercise plus its SetTable.

- New WorkoutRail.svelte and WorkoutFocusCard.svelte
- Active exercise pinned to top of the scrollable rail (mobile only)
- Desktop: rail grows freely; mobile: compact vertical stack
- Finish + cancel share one row; cancel is a ghost action
- Drop the old sticky bottombar; its controls moved into the rail
- ExerciseName gains `plain` prop to opt out of the detail link
- Active workout route joins the 1400px max-width whitelist
This commit is contained in:
2026-04-23 22:35:05 +02:00
parent 86ff4c5953
commit e7293ac496
6 changed files with 1151 additions and 162 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.47.4",
"version": "1.48.0",
"private": true,
"type": "module",
"scripts": {
+10 -2
View File
@@ -3,7 +3,7 @@
import { getEnrichedExerciseById } from '$lib/data/exercisedb';
import { detectFitnessLang, fitnessSlugs } from '$lib/js/fitnessI18n';
let { exerciseId } = $props();
let { exerciseId, plain = false } = $props();
const lang = $derived(detectFitnessLang($page.url.pathname));
const exercise = $derived(getEnrichedExerciseById(exerciseId, lang));
@@ -11,7 +11,11 @@
</script>
{#if exercise}
<a href="/fitness/{sl.exercises}/{exerciseId}" class="exercise-link">{exercise.localName}</a>
{#if plain}
<span class="exercise-plain">{exercise.localName}</span>
{:else}
<a href="/fitness/{sl.exercises}/{exerciseId}" class="exercise-link">{exercise.localName}</a>
{/if}
{:else}
<span class="exercise-unknown">Unknown Exercise</span>
{/if}
@@ -25,6 +29,10 @@
.exercise-link:hover {
text-decoration: underline;
}
.exercise-plain {
color: inherit;
font: inherit;
}
.exercise-unknown {
color: var(--nord11);
font-style: italic;
@@ -0,0 +1,194 @@
<script>
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
/** @type {{
* exerciseId: string,
* bodyPart?: string | null,
* equipment?: string | null,
* detailsHref?: string | null,
* detailsLabel?: string,
* exerciseIndex: number,
* totalExercises: number,
* sets: Array<{ completed?: boolean }>,
* activeSetIdx: number,
* labels: { exerciseOf: (i: number, n: number) => string, setOf: (i: number, n: number) => string, done: (n: number) => string },
* }} */
let {
exerciseId,
bodyPart = null,
equipment = null,
detailsHref = null,
detailsLabel = 'Exercise details',
exerciseIndex,
totalExercises,
sets,
activeSetIdx,
labels
} = $props();
const totalSets = $derived(sets.length);
const doneSets = $derived(sets.filter((s) => s.completed).length);
const allDone = $derived(totalSets > 0 && doneSets === totalSets);
</script>
<section class="focus-card" aria-label="Current exercise">
<header class="focus-eyebrow">
<span class="focus-step">{labels.exerciseOf(exerciseIndex + 1, totalExercises)}</span>
{#if bodyPart}
<span class="focus-dot-sep" aria-hidden="true">·</span>
<span class="focus-meta">{bodyPart}</span>
{/if}
{#if equipment}
<span class="focus-dot-sep" aria-hidden="true">·</span>
<span class="focus-meta">{equipment}</span>
{/if}
</header>
<div class="focus-name-row">
<h2 class="focus-name"><ExerciseName {exerciseId} plain /></h2>
{#if detailsHref}
<a class="focus-details" href={detailsHref} aria-label={detailsLabel} title={detailsLabel}>
<ChevronRight size={18} strokeWidth={2.2} />
</a>
{/if}
</div>
<div class="focus-progress">
<span class="focus-set-label" class:complete={allDone}>
{allDone ? labels.done(totalSets) : labels.setOf(activeSetIdx + 1, totalSets)}
</span>
<span class="focus-dots" aria-hidden="true">
{#each sets as s, si (si)}
<span
class="focus-dot"
class:filled={s.completed}
class:current={si === activeSetIdx && !s.completed}
></span>
{/each}
</span>
</div>
</section>
<style>
.focus-card {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.1rem 1.25rem 1rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
}
/* Eyebrow row: step counter + bodypart + equipment, controls on the right */
.focus-eyebrow {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.35rem;
font-size: 0.64rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-text-tertiary);
min-height: 1.5rem;
}
.focus-step {
color: var(--color-primary);
}
.focus-meta {
color: var(--color-text-secondary);
}
.focus-dot-sep {
color: var(--color-text-tertiary);
}
/* Big display name */
.focus-name-row {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.focus-name {
flex: 1;
margin: 0;
font-size: 1.55rem;
font-weight: 700;
letter-spacing: -0.01em;
line-height: 1.15;
color: var(--color-text-primary);
min-width: 0;
}
.focus-details {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 100px;
color: var(--color-text-tertiary);
text-decoration: none;
transition: background 140ms, color 140ms, transform 140ms;
}
.focus-details:hover {
background: var(--color-bg-elevated);
color: var(--color-primary);
transform: translateX(2px);
}
/* Set progress line */
.focus-progress {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.focus-set-label {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--color-text-secondary);
}
.focus-set-label.complete {
color: var(--nord14);
}
.focus-dots {
display: inline-flex;
gap: 6px;
}
.focus-dot {
width: 9px;
height: 9px;
border-radius: 100px;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
transition: background 180ms, transform 180ms, border-color 180ms;
}
.focus-dot.filled {
background: var(--color-primary);
border-color: var(--color-primary);
}
.focus-dot.current {
background: color-mix(in srgb, var(--color-primary), transparent 55%);
border-color: var(--color-primary);
transform: scale(1.25);
animation: focus-dot-pulse 1.4s ease-in-out infinite;
}
@keyframes focus-dot-pulse {
0%, 100% { transform: scale(1.25); }
50% { transform: scale(1.05); }
}
@media (max-width: 560px) {
.focus-card {
padding: 0.9rem 1rem 0.85rem;
}
.focus-name {
font-size: 1.3rem;
}
}
</style>
@@ -0,0 +1,687 @@
<script>
import Plus from '@lucide/svelte/icons/plus';
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import X from '@lucide/svelte/icons/x';
import Check from '@lucide/svelte/icons/check';
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
import SyncIndicator from '$lib/components/fitness/SyncIndicator.svelte';
/**
* @typedef {{ exerciseId: string, sets: Array<{ completed?: boolean }> }} RailExercise
*/
/** @type {{
* exercises: RailExercise[],
* activeIdx: number,
* activeSetIdx: number,
* elapsedLabel: string,
* paused?: boolean,
* syncStatus?: string,
* setsDone: number,
* setsTotal: number,
* addLabel: string,
* pauseLabel?: string,
* resumeLabel?: string,
* removeLabel?: string,
* previousData?: Record<string, Array<{ weight?: number | null, reps?: number | null }>>,
* weightUnit?: string,
* onPauseToggle?: () => void,
* onFocus: (idx: number) => void,
* onAddExercise: () => void,
* onRemove?: (idx: number) => void,
* onReorder?: (fromIdx: number, toIdx: number) => void,
* }} */
let {
exercises,
activeIdx,
activeSetIdx,
elapsedLabel,
paused = false,
syncStatus = 'idle',
setsDone,
setsTotal,
addLabel,
pauseLabel = 'Pause',
resumeLabel = 'Resume',
removeLabel = 'Remove exercise',
previousData = {},
weightUnit = 'kg',
title,
onPauseToggle,
onFocus,
onAddExercise,
onRemove,
onReorder
} = $props();
/** Drag-and-drop state */
/** @type {number | null} */
let draggedIdx = $state(null);
/** @type {number | null} */
let dragOverIdx = $state(null);
/** @type {HTMLOListElement | null} */
let listEl = $state(null);
// Keep the active chip at the top of the scrollable list so the user sees current + the next two
$effect(() => {
if (!listEl) return;
const idx = activeIdx;
if (idx < 0) return;
const items = listEl.querySelectorAll('.rail-item');
const target = /** @type {HTMLElement | undefined} */ (items[idx]);
if (!target) return;
// Use scrollTop directly to keep the scroll local to the list (avoid page scroll)
const listTop = listEl.getBoundingClientRect().top;
const itemTop = target.getBoundingClientRect().top;
listEl.scrollTo({ top: listEl.scrollTop + (itemTop - listTop), behavior: 'smooth' });
});
/** @param {DragEvent} e @param {number} idx */
function onDragStart(e, idx) {
draggedIdx = idx;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
// Firefox requires data to be set to initiate a drag
e.dataTransfer.setData('text/plain', String(idx));
}
}
/** @param {DragEvent} e @param {number} idx */
function onDragOver(e, idx) {
if (draggedIdx == null || draggedIdx === idx) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
dragOverIdx = idx;
}
/** @param {DragEvent} e @param {number} idx */
function onDrop(e, idx) {
e.preventDefault();
if (draggedIdx != null && draggedIdx !== idx && onReorder) {
onReorder(draggedIdx, idx);
}
draggedIdx = null;
dragOverIdx = null;
}
function onDragEnd() {
draggedIdx = null;
dragOverIdx = null;
}
const progressPct = $derived(setsTotal > 0 ? (setsDone / setsTotal) * 100 : 0);
/**
* What to rack: starting weight × reps for the first set.
* Falls back to the previous session's first set if the current plan is blank.
* @param {RailExercise} ex
* @returns {string | null}
*/
function startingLoadLabel(ex) {
const first = ex.sets[0];
const prev = previousData[ex.exerciseId]?.[0];
/** @type {number | null | undefined} */
const w = (first && typeof first === 'object' && 'weight' in first ? /** @type {any} */(first).weight : null) ?? prev?.weight;
/** @type {number | null | undefined} */
const r = (first && typeof first === 'object' && 'reps' in first ? /** @type {any} */(first).reps : null) ?? prev?.reps;
if (w != null && w > 0 && r != null && r > 0) return `${w} ${weightUnit} × ${r}`;
if (w != null && w > 0) return `${w} ${weightUnit}`;
if (r != null && r > 0) return `× ${r}`;
return null;
}
</script>
<aside class="workout-rail" aria-label="Workout overview">
<header class="rail-header">
{#if title}
<div class="rail-title">{@render title()}</div>
{/if}
<div class="rail-timer-row">
<button
class="rail-pause"
onclick={() => onPauseToggle?.()}
aria-label={paused ? resumeLabel : pauseLabel}
type="button"
>
{#if paused}<Play size={14} strokeWidth={2.4} />{:else}<Pause size={14} strokeWidth={2.4} />{/if}
</button>
<span class="rail-elapsed" class:paused>{elapsedLabel}</span>
<span class="rail-sync"><SyncIndicator status={syncStatus} /></span>
</div>
<div class="rail-progress">
<div class="rail-progress-bar">
<div class="rail-progress-fill" style:width="{progressPct}%"></div>
</div>
<span class="rail-progress-label">{setsDone}<span class="rail-progress-sep">/</span>{setsTotal}</span>
</div>
</header>
<ol class="rail-list" bind:this={listEl}>
{#each exercises as ex, i (i)}
{@const isActive = i === activeIdx}
{@const done = ex.sets.filter((s) => s.completed).length}
{@const complete = done === ex.sets.length && ex.sets.length > 0}
{@const load = startingLoadLabel(ex)}
{@const isDragging = draggedIdx === i}
{@const isDragOver = dragOverIdx === i && draggedIdx !== i}
{@const dropAbove = isDragOver && (draggedIdx ?? i) > i}
{@const dropBelow = isDragOver && (draggedIdx ?? i) < i}
<li
class="rail-item"
class:dragging={isDragging}
class:drop-above={dropAbove}
class:drop-below={dropBelow}
class:active={isActive}
class:complete
draggable={onReorder ? 'true' : undefined}
ondragstart={(e) => onDragStart(e, i)}
ondragover={(e) => onDragOver(e, i)}
ondrop={(e) => onDrop(e, i)}
ondragend={onDragEnd}
>
<button
class="rail-chip"
class:active={isActive}
class:complete
onclick={() => onFocus(i)}
aria-current={isActive ? 'true' : undefined}
type="button"
>
<span class="rail-chip-index">{i + 1}</span>
<span class="rail-chip-body">
<span class="rail-chip-name"><ExerciseName exerciseId={ex.exerciseId} plain /></span>
{#if load}
<span class="rail-chip-load" aria-label="Starting load">{load}</span>
{/if}
<span class="rail-chip-dots" aria-hidden="true">
{#each ex.sets as s, si (si)}
<span
class="rail-dot"
class:filled={s.completed}
class:current={isActive && si === activeSetIdx && !s.completed}
></span>
{/each}
</span>
</span>
{#if complete}
<span class="rail-chip-count done" aria-label="Exercise complete">
<Check size={14} strokeWidth={2.8} />
</span>
{:else}
<span class="rail-chip-count">{done}/{ex.sets.length}</span>
{/if}
</button>
{#if onRemove}
<button
class="rail-chip-remove"
onclick={(e) => { e.stopPropagation(); onRemove?.(i); }}
aria-label={removeLabel}
title={removeLabel}
type="button"
draggable="false"
ondragstart={(e) => e.stopPropagation()}
>
<X size={14} strokeWidth={2.6} />
</button>
{/if}
</li>
{/each}
</ol>
<button class="rail-add" onclick={onAddExercise} type="button">
<Plus size={14} strokeWidth={2.4} />
<span>{addLabel}</span>
</button>
</aside>
<style>
.workout-rail {
display: flex;
flex-direction: column;
gap: 0.75rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
padding: 1rem 0.85rem 0.85rem;
}
/* Header: elapsed + progress */
.rail-header {
display: flex;
flex-direction: column;
gap: 0.55rem;
padding: 0 0.25rem 0.6rem;
border-bottom: 1px solid var(--color-border);
}
.rail-title {
min-width: 0;
}
.rail-title :global(input) {
width: 100%;
background: transparent;
border: none;
padding: 0.1rem 0;
color: var(--color-text-primary);
font-size: 1rem;
font-weight: 700;
letter-spacing: -0.01em;
outline: none;
}
.rail-title :global(input::placeholder) {
color: var(--color-text-tertiary);
font-weight: 600;
}
.rail-title :global(input:focus) {
color: var(--color-primary);
}
.rail-timer-row {
display: flex;
align-items: center;
gap: 0.55rem;
}
.rail-pause {
all: unset;
-webkit-tap-highlight-color: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.9rem;
height: 1.9rem;
border-radius: 100px;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
color: var(--color-text-primary);
cursor: pointer;
flex-shrink: 0;
transition: background 140ms, color 140ms, border-color 140ms, transform 120ms;
}
.rail-pause:hover {
background: var(--color-bg-elevated);
color: var(--color-primary);
border-color: var(--color-primary);
}
.rail-pause:active {
transform: scale(0.94);
}
.rail-elapsed {
flex: 1;
font-size: 1.25rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
color: var(--color-text-primary);
line-height: 1.1;
min-width: 0;
}
.rail-elapsed.paused {
color: var(--nord13);
}
.rail-sync {
display: inline-flex;
align-items: center;
color: var(--color-text-tertiary);
flex-shrink: 0;
}
.rail-progress {
display: flex;
align-items: center;
gap: 0.55rem;
}
.rail-progress-bar {
flex: 1;
height: 6px;
border-radius: 100px;
background: var(--color-bg-tertiary);
overflow: hidden;
}
.rail-progress-fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, color-mix(in srgb, var(--color-primary), transparent 40%), var(--color-primary));
transition: width 350ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rail-progress-label {
font-size: 0.72rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
letter-spacing: 0.03em;
}
.rail-progress-sep {
color: var(--color-text-tertiary);
margin-inline: 0.1rem;
}
/* Chip list */
.rail-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
.rail-list::-webkit-scrollbar {
width: 4px;
}
.rail-list::-webkit-scrollbar-track {
background: transparent;
}
.rail-list::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 100px;
}
.rail-list::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--color-text-tertiary), transparent 50%);
}
/* Row wrapper holds chip + remove button, carries drag state */
.rail-item {
position: relative;
display: flex;
align-items: stretch;
border-radius: var(--radius-md, 0.5rem);
transition: opacity 140ms;
}
.rail-item[draggable='true'] {
cursor: grab;
}
.rail-item[draggable='true']:active {
cursor: grabbing;
}
.rail-item.dragging {
opacity: 0.35;
}
.rail-item.drop-above::before,
.rail-item.drop-below::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 2px;
border-radius: 100px;
background: var(--color-primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary), transparent 70%);
}
.rail-item.drop-above::before {
top: -3px;
}
.rail-item.drop-below::after {
bottom: -3px;
}
.rail-chip {
all: unset;
-webkit-tap-highlight-color: transparent;
box-sizing: border-box;
flex: 1;
min-width: 0;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.55rem;
padding: 0.55rem 0.55rem 0.55rem 0.45rem;
border-radius: var(--radius-md, 0.5rem);
background: transparent;
border: 1px solid transparent;
cursor: pointer;
transition: background 140ms, border-color 140ms, transform 120ms;
}
.rail-chip:hover {
background: var(--color-bg-elevated);
}
.rail-chip:active {
transform: scale(0.98);
}
.rail-chip.active {
background: color-mix(in srgb, var(--color-primary), transparent 82%);
border-color: transparent;
}
.rail-chip.active:hover {
background: color-mix(in srgb, var(--color-primary), transparent 76%);
}
.rail-chip.complete {
opacity: 0.72;
}
.rail-chip.complete.active {
opacity: 1;
}
/* × remove — overlays the set counter on hover (same spot) */
.rail-chip-remove {
all: unset;
-webkit-tap-highlight-color: transparent;
position: absolute;
top: 50%;
right: 0.4rem;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.55rem;
height: 1.55rem;
border-radius: 100px;
color: var(--nord11);
opacity: 0;
pointer-events: none;
cursor: pointer;
transition: opacity 140ms, background 140ms, transform 120ms;
z-index: 1;
}
.rail-item:hover .rail-chip-remove,
.rail-chip-remove:focus-visible {
opacity: 1;
pointer-events: auto;
}
.rail-chip-remove:hover {
background: color-mix(in srgb, var(--nord11), transparent 82%);
transform: translateY(-50%) scale(1.08);
}
.rail-chip-remove:active {
transform: translateY(-50%) scale(0.94);
}
.rail-chip-index {
font-size: 0.65rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--color-text-tertiary);
width: 1.25rem;
text-align: right;
letter-spacing: 0.04em;
}
.rail-chip.active .rail-chip-index {
color: var(--color-primary);
}
.rail-chip-body {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.rail-chip-name {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rail-chip.active .rail-chip-name {
font-weight: 700;
}
/* Starting weight hint — "what to rack" */
.rail-chip-load {
font-size: 0.7rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: 0.01em;
color: var(--color-text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rail-chip.active .rail-chip-load {
color: var(--color-primary);
}
.rail-chip.complete .rail-chip-load {
text-decoration: line-through;
text-decoration-color: color-mix(in srgb, currentColor, transparent 60%);
}
.rail-chip-dots {
display: inline-flex;
gap: 3px;
flex-wrap: wrap;
}
.rail-dot {
width: 6px;
height: 6px;
border-radius: 100px;
background: var(--color-border);
transition: background 180ms, transform 180ms;
}
.rail-dot.filled {
background: color-mix(in srgb, var(--color-primary), transparent 15%);
}
.rail-dot.current {
background: var(--color-primary);
transform: scale(1.3);
animation: dot-pulse 1.4s ease-in-out infinite;
}
@keyframes dot-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
/* Set counter — visible by default, fades out when × takes over on hover */
.rail-chip-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.55rem;
font-size: 0.7rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--color-text-tertiary);
flex-shrink: 0;
transition: opacity 140ms;
}
.rail-chip.active .rail-chip-count {
color: var(--color-text-secondary);
}
/* Completed exercise: matches the set-complete check button from SetTable */
.rail-chip-count.done {
width: 1.55rem;
height: 1.55rem;
border-radius: 50%;
border: 2px solid var(--nord14);
background: var(--nord14);
color: white;
}
.rail-item:hover .rail-chip-count {
opacity: 0;
}
/* Add exercise button */
.rail-add {
all: unset;
-webkit-tap-highlight-color: transparent;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.55rem 0.75rem;
border: 1px dashed var(--color-border);
border-radius: var(--radius-md, 0.5rem);
color: var(--color-text-secondary);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.03em;
cursor: pointer;
transition: border-color 140ms, color 140ms;
}
.rail-add:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
/* Narrow viewports: vertical list, compact chip, dots inline next to name */
@media (max-width: 899px) {
.workout-rail {
gap: 0.6rem;
padding: 0.75rem 0.75rem 0.6rem;
}
/* Title full-width row, timer + progress share a second row */
.rail-header {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-areas:
"title title"
"timer progress";
column-gap: 0.75rem;
row-gap: 0.5rem;
padding: 0 0.15rem 0.5rem;
align-items: center;
}
.rail-title { grid-area: title; }
.rail-timer-row {
grid-area: timer;
gap: 0.45rem;
}
.rail-elapsed {
flex: initial;
font-size: 1.05rem;
}
.rail-progress {
grid-area: progress;
min-width: 0;
}
/* Dots jump up next to the name; load sits below on its own row */
.rail-chip-body {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas:
"name dots"
"load load";
column-gap: 0.5rem;
row-gap: 0.1rem;
align-items: center;
}
.rail-chip-name { grid-area: name; }
.rail-chip-dots { grid-area: dots; flex-wrap: nowrap; }
.rail-chip-load { grid-area: load; }
.rail-chip {
padding: 0.5rem 0.55rem 0.5rem 0.4rem;
}
/* Scrollable only on mobile — desktop lets the rail grow */
.rail-list {
max-height: 10.5rem;
overflow-y: auto;
}
/* Smaller completion checkmark */
.rail-chip-count {
min-width: 1.25rem;
font-size: 0.65rem;
}
.rail-chip-count.done {
width: 1.25rem;
height: 1.25rem;
border-width: 2px;
}
.rail-chip-count.done :global(svg) {
width: 11px;
height: 11px;
}
.rail-add {
padding: 0.5rem 0.6rem;
font-size: 0.72rem;
}
}
</style>
+1 -1
View File
@@ -111,7 +111,7 @@
<UserHeader {user} />
{/snippet}
<div class="fitness-content" style:--fitness-max-width={isNutritionPage || isMeasureIndex || isExercisesIndex ? '1400px' : null}>
<div class="fitness-content" style:--fitness-max-width={isNutritionPage || isMeasureIndex || isExercisesIndex || isOnActivePage ? '1400px' : null}>
{@render children()}
</div>
</Header>
@@ -25,6 +25,7 @@
import { toast } from '$lib/js/toast.svelte';
const lang = $derived(detectFitnessLang($page.url.pathname));
const isEn = $derived(lang === 'en');
const sl = $derived(fitnessSlugs(lang));
import { getWorkout } from '$lib/js/workout.svelte';
import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
@@ -35,7 +36,8 @@
import { queueSession } from '$lib/offline/fitnessQueue';
import SetTable from '$lib/components/fitness/SetTable.svelte';
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
import SyncIndicator from '$lib/components/fitness/SyncIndicator.svelte';
import WorkoutRail from '$lib/components/fitness/WorkoutRail.svelte';
import WorkoutFocusCard from '$lib/components/fitness/WorkoutFocusCard.svelte';
import Toggle from '$lib/components/Toggle.svelte';
import { onMount } from 'svelte';
@@ -50,6 +52,82 @@
/** @type {Record<string, Array<Record<string, any>>>} */
let previousData = $state({});
/** User-pinned exercise index for the focus pane (null = auto-follow first incomplete) */
/** @type {number | null} */
let focusedIdx = $state(null);
const autoCurrentIdx = $derived.by(() => {
const exs = workout.exercises;
for (let i = 0; i < exs.length; i++) {
if (exs[i].sets.some((/** @type {any} */ s) => !s.completed)) return i;
}
return Math.max(0, exs.length - 1);
});
const activeIdx = $derived.by(() => {
const exs = workout.exercises;
if (exs.length === 0) return -1;
if (focusedIdx != null && focusedIdx >= 0 && focusedIdx < exs.length) return focusedIdx;
return autoCurrentIdx;
});
const activeExercise = $derived(activeIdx >= 0 ? workout.exercises[activeIdx] : null);
const activeExerciseMeta = $derived(activeExercise ? getExerciseById(activeExercise.exerciseId, lang) : null);
const activeSetIdx = $derived.by(() => {
const ex = activeExercise;
if (!ex || !ex.sets.length) return 0;
for (let i = 0; i < ex.sets.length; i++) {
if (!ex.sets[i].completed) return i;
}
return ex.sets.length;
});
const activeExDoneCount = $derived(activeExercise ? activeExercise.sets.filter((/** @type {any} */ s) => s.completed).length : 0);
const workoutSetsDone = $derived(
workout.exercises.reduce(
(/** @type {number} */ n, /** @type {any} */ ex) => n + ex.sets.filter((/** @type {any} */ s) => s.completed).length,
0
)
);
const workoutSetsTotal = $derived(
workout.exercises.reduce((/** @type {number} */ n, /** @type {any} */ ex) => n + ex.sets.length, 0)
);
/** @param {number} idx */
function setFocus(idx) { focusedIdx = idx; }
/**
* Reorder an exercise from one index to another, adjusting focus to follow.
* @param {number} fromIdx
* @param {number} toIdx
*/
function reorderExercise(fromIdx, toIdx) {
if (fromIdx === toIdx) return;
const dir = fromIdx < toIdx ? 1 : -1;
let i = fromIdx;
while (i !== toIdx) {
workout.moveExercise(i, dir);
i += dir;
}
// Track the moved exercise if it was focused
if (focusedIdx === fromIdx) {
focusedIdx = toIdx;
} else if (focusedIdx != null) {
if (fromIdx < focusedIdx && toIdx >= focusedIdx) focusedIdx = focusedIdx - 1;
else if (fromIdx > focusedIdx && toIdx <= focusedIdx) focusedIdx = focusedIdx + 1;
}
}
/** @param {number} idx */
function removeExerciseFromRail(idx) {
workout.removeExercise(idx);
// Unpin focus so auto-current takes over (handles removing the focused one)
if (focusedIdx === idx) focusedIdx = null;
else if (focusedIdx != null && idx < focusedIdx) focusedIdx = focusedIdx - 1;
}
/** @type {any} */
let completionData = $state(null);
@@ -1436,15 +1514,17 @@
{:else if workout.active}
<div class="active-workout">
<input
class="workout-name-input"
type="text"
bind:value={nameInput}
onfocus={() => { nameEditing = true; }}
onblur={() => { nameEditing = false; workout.name = nameInput; }}
onkeydown={(e) => { if (e.key === 'Enter' && e.target instanceof HTMLElement) e.target.blur(); }}
placeholder={t('workout_name_placeholder', lang)}
/>
{#snippet workoutTitle()}
<input
class="workout-name-input"
type="text"
bind:value={nameInput}
onfocus={() => { nameEditing = true; }}
onblur={() => { nameEditing = false; workout.name = nameInput; }}
onkeydown={(e) => { if (e.key === 'Enter' && e.target instanceof HTMLElement) e.target.blur(); }}
placeholder={t('workout_name_placeholder', lang)}
/>
{/snippet}
{#if gps.available && hasCardioExercise()}
<div class="gps-section">
@@ -1555,89 +1635,105 @@
</div>
{/if}
{#each workout.exercises as ex, exIdx (exIdx)}
{@const exMetrics = getExerciseMetrics(getExerciseById(ex.exerciseId))}
{@const isDurationOnly = exMetrics.includes('duration') && !exMetrics.includes('weight') && !exMetrics.includes('reps')}
<div class="exercise-block">
<div class="exercise-header">
<ExerciseName exerciseId={ex.exerciseId} />
<div class="exercise-header-actions">
<button class="move-exercise" disabled={exIdx === 0} onclick={() => workout.moveExercise(exIdx, -1)} aria-label="Move up">
<ChevronUp size={16} />
</button>
<button class="move-exercise" disabled={exIdx === workout.exercises.length - 1} onclick={() => workout.moveExercise(exIdx, 1)} aria-label="Move down">
<ChevronDown size={16} />
</button>
<button
class="remove-exercise"
onclick={() => workout.removeExercise(exIdx)}
aria-label="Remove exercise"
>
<Trash2 size={16} />
<div class="workout-grid">
<WorkoutRail
title={workoutTitle}
exercises={workout.exercises}
activeIdx={activeIdx}
activeSetIdx={activeSetIdx}
elapsedLabel={formatElapsed(workout.elapsedSeconds)}
paused={workout.paused}
syncStatus={sync.status}
setsDone={workoutSetsDone}
setsTotal={workoutSetsTotal}
addLabel={t('add_exercise', lang)}
pauseLabel={isEn ? 'Pause' : 'Pause'}
resumeLabel={isEn ? 'Resume' : 'Fortsetzen'}
removeLabel={isEn ? 'Remove exercise' : 'Übung entfernen'}
previousData={previousData}
onPauseToggle={() => workout.paused ? workout.resumeTimer() : workout.pauseTimer()}
onFocus={setFocus}
onAddExercise={() => showPicker = true}
onRemove={removeExerciseFromRail}
onReorder={reorderExercise}
/>
<main class="workout-stage">
{#if activeExercise}
{@const exMetrics = getExerciseMetrics(getExerciseById(activeExercise.exerciseId))}
{@const isDurationOnly = exMetrics.includes('duration') && !exMetrics.includes('weight') && !exMetrics.includes('reps')}
<WorkoutFocusCard
exerciseId={activeExercise.exerciseId}
bodyPart={activeExerciseMeta?.localBodyPart ?? null}
equipment={activeExerciseMeta?.localEquipment ?? null}
detailsHref={`/fitness/${sl.exercises}/${activeExercise.exerciseId}`}
detailsLabel={isEn ? 'Exercise details' : 'Übungsdetails'}
exerciseIndex={activeIdx}
totalExercises={workout.exercises.length}
sets={activeExercise.sets}
activeSetIdx={activeSetIdx}
labels={{
exerciseOf: (i, n) => isEn ? `Exercise ${i} of ${n}` : `Übung ${i} von ${n}`,
setOf: (i, n) => isEn ? `Set ${i} of ${n}` : `Satz ${i} von ${n}`,
done: (n) => isEn ? `${n}/${n} complete` : `${n}/${n} erledigt`,
}}
/>
<div class="exercise-block focused">
<SetTable
sets={activeExercise.sets}
previousSets={previousData[activeExercise.exerciseId] ?? []}
metrics={exMetrics}
editable={true}
timedHold={isDurationOnly}
restAfterSet={workout.restTimerActive && workout.restExerciseIdx === activeIdx ? workout.restSetIdx : -1}
restSeconds={workout.restTimerSeconds}
restTotal={workout.restTimerTotal}
holdAfterSet={workout.holdTimerActive && workout.holdExerciseIdx === activeIdx ? workout.holdSetIdx : -1}
holdSeconds={workout.holdTimerSeconds}
holdTotal={workout.holdTimerTotal}
onRestAdjust={(delta) => workout.adjustRestTimer(delta)}
onRestSkip={cancelRest}
onHoldSkip={() => workout.cancelHoldTimer()}
onUpdate={(setIdx, d) => workout.updateSet(activeIdx, setIdx, d)}
onToggleComplete={(setIdx) => {
const ex = activeExercise;
if (!ex) return;
const set = ex.sets[setIdx];
if (workout.holdTimerActive && workout.holdExerciseIdx === activeIdx && workout.holdSetIdx === setIdx) {
workout.cancelHoldTimer();
return;
}
if (isDurationOnly && set?.duration && !set.completed) {
workout.startHoldTimer(Math.round(set.duration * 60), activeIdx, setIdx);
} else {
workout.toggleSetComplete(activeIdx, setIdx);
if (ex.sets[setIdx]?.completed) {
workout.startRestTimer(ex.restTime, activeIdx, setIdx);
}
}
}}
onRemove={(setIdx) => workout.removeSet(activeIdx, setIdx)}
/>
<button class="add-set-btn" onclick={() => workout.addSet(activeIdx)}>
{t('add_set', lang)}
</button>
</div>
{:else}
<div class="empty-stage">
<p>{isEn ? 'Add an exercise to get started.' : 'Füge eine Übung hinzu, um zu starten.'}</p>
</div>
{/if}
<div class="workout-actions">
<button class="cancel-btn" onclick={async () => { if (gps.isTracking) await gps.stop(); gps.reset(); workout.cancel(); await sync.onWorkoutEnd(); await goto(`/fitness/${sl.workout}`); }}>
{t('cancel_workout', lang)}
</button>
<button class="finish-btn" onclick={finishWorkout}>{t('finish', lang)}</button>
</div>
<SetTable
sets={ex.sets}
previousSets={previousData[ex.exerciseId] ?? []}
metrics={exMetrics}
editable={true}
timedHold={isDurationOnly}
restAfterSet={workout.restTimerActive && workout.restExerciseIdx === exIdx ? workout.restSetIdx : -1}
restSeconds={workout.restTimerSeconds}
restTotal={workout.restTimerTotal}
holdAfterSet={workout.holdTimerActive && workout.holdExerciseIdx === exIdx ? workout.holdSetIdx : -1}
holdSeconds={workout.holdTimerSeconds}
holdTotal={workout.holdTimerTotal}
onRestAdjust={(delta) => workout.adjustRestTimer(delta)}
onRestSkip={cancelRest}
onHoldSkip={() => workout.cancelHoldTimer()}
onUpdate={(setIdx, d) => workout.updateSet(exIdx, setIdx, d)}
onToggleComplete={(setIdx) => {
const set = ex.sets[setIdx];
// If hold timer is running for this set, cancel it
if (workout.holdTimerActive && workout.holdExerciseIdx === exIdx && workout.holdSetIdx === setIdx) {
workout.cancelHoldTimer();
return;
}
if (isDurationOnly && set?.duration && !set.completed) {
// Start hold countdown — completion happens automatically
workout.startHoldTimer(Math.round(set.duration * 60), exIdx, setIdx);
} else {
workout.toggleSetComplete(exIdx, setIdx);
if (ex.sets[setIdx]?.completed) {
workout.startRestTimer(ex.restTime, exIdx, setIdx);
}
}
}}
onRemove={(setIdx) => workout.removeSet(exIdx, setIdx)}
/>
<button class="add-set-btn" onclick={() => workout.addSet(exIdx)}>
{t('add_set', lang)}
</button>
</div>
{/each}
<div class="workout-actions">
<button class="add-exercise-btn" onclick={() => showPicker = true}>
{t('add_exercise', lang)}
</button>
<button class="cancel-btn" onclick={async () => { if (gps.isTracking) await gps.stop(); gps.reset(); workout.cancel(); await sync.onWorkoutEnd(); await goto(`/fitness/${sl.workout}`); }}>
{t('cancel_workout', lang)}
</button>
</div>
<div class="workout-bottombar">
<div class="topbar-left">
<button class="pause-btn" onclick={() => workout.paused ? workout.resumeTimer() : workout.pauseTimer()} aria-label={workout.paused ? 'Resume' : 'Pause'}>
{#if workout.paused}<Play size={16} />{:else}<Pause size={16} />{/if}
</button>
<span class="elapsed" class:paused={workout.paused}>{formatElapsed(workout.elapsedSeconds)}</span>
<SyncIndicator status={sync.status} />
</div>
<button class="finish-btn" onclick={finishWorkout}>{t('finish', lang)}</button>
</main>
</div>
</div>
{/if}
@@ -1883,69 +1979,44 @@
flex-direction: column;
gap: 1rem;
}
.workout-bottombar {
/* Active workout: sidebar rail + focus stage */
.workout-grid {
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
bottom: 0;
background: var(--color-bg-primary);
z-index: 10;
padding: 0.75rem 0;
border-top: 1px solid var(--color-border);
flex-direction: column;
gap: 1rem;
}
.topbar-left {
.workout-stage {
display: flex;
align-items: center;
gap: 0.5rem;
flex-direction: column;
gap: 1rem;
min-width: 0;
}
.pause-btn {
background: none;
border: 1px solid var(--color-border);
border-radius: 6px;
@media (min-width: 900px) {
.workout-grid {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 1.5rem;
align-items: start;
}
.workout-grid :global(.workout-rail) {
position: sticky;
top: calc(8.5rem + env(safe-area-inset-top, 0px));
}
}
@media (min-width: 1180px) {
.workout-grid {
grid-template-columns: 360px minmax(0, 1fr);
gap: 2rem;
}
}
.empty-stage {
padding: 2rem 1rem;
text-align: center;
background: var(--color-surface);
border: 1px dashed var(--color-border);
border-radius: var(--radius-card);
color: var(--color-text-secondary);
cursor: pointer;
padding: 0.3rem;
display: flex;
align-items: center;
}
.pause-btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.elapsed {
font-variant-numeric: tabular-nums;
font-weight: 600;
font-size: 1.1rem;
color: var(--color-text-secondary);
}
.elapsed.paused {
color: var(--nord13);
}
.finish-btn {
background: var(--color-primary);
color: var(--primary-contrast);
border: none;
border-radius: 8px;
padding: 0.5rem 1.25rem;
font-weight: 700;
font-size: 0.85rem;
cursor: pointer;
letter-spacing: 0.03em;
}
.workout-name-input {
background: transparent;
border: none;
border-bottom: 1px solid var(--color-border);
color: inherit;
font-size: 1.2rem;
font-weight: 700;
padding: 0.5rem 0;
width: 100%;
outline: none;
}
.workout-name-input:focus {
border-bottom-color: var(--color-primary);
font-size: 0.9rem;
}
.exercise-block {
@@ -1954,6 +2025,11 @@
box-shadow: var(--shadow-sm);
padding: 1rem;
}
.exercise-block.focused {
background: transparent;
box-shadow: none;
padding: 0.25rem 0 0;
}
.exercise-header {
display: flex;
justify-content: space-between;
@@ -2012,9 +2088,9 @@
.workout-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1rem 0;
padding: 1rem 0 0;
}
.add-exercise-btn {
display: flex;
@@ -2032,20 +2108,44 @@
cursor: pointer;
letter-spacing: 0.03em;
}
/* Cancel: ghost style (less prominent) — matches body-parts .ghost pattern */
.cancel-btn {
width: 100%;
padding: 0.75rem;
background: transparent;
border: 1px solid var(--nord11);
border-radius: 10px;
color: var(--nord11);
font-weight: 700;
border: none;
color: var(--color-text-tertiary);
font-size: 0.85rem;
cursor: pointer;
font-weight: 600;
letter-spacing: 0.03em;
padding: 0.6rem 0.9rem;
border-radius: var(--radius-md, 0.5rem);
cursor: pointer;
transition: color 150ms, background 150ms;
flex-shrink: 0;
}
.cancel-btn:hover {
background: rgba(191, 97, 106, 0.1);
color: var(--nord11);
background: color-mix(in srgb, var(--nord11), transparent 92%);
}
/* Finish: primary, dominant */
.finish-btn {
flex: 1;
padding: 0.75rem 1.25rem;
background: var(--color-primary);
color: var(--primary-contrast);
border: none;
border-radius: 10px;
font-weight: 700;
font-size: 0.9rem;
letter-spacing: 0.03em;
cursor: pointer;
transition: background 140ms, transform 120ms, box-shadow 140ms;
}
.finish-btn:hover {
background: var(--color-primary-hover, var(--color-primary));
box-shadow: var(--shadow-md);
}
.finish-btn:active {
transform: scale(0.98);
}
/* GPS section */