fitness: GPS workout templates with interval pre-selection
All checks were successful
CI / update (push) Successful in 2m17s

Enable creating templates for GPS-tracked workouts with activity type
and optional interval training. GPS templates show activity/interval
info instead of exercise lists in cards, modals, and schedule. Starting
a GPS template pre-selects the interval and jumps to the map screen.
This commit is contained in:
2026-03-30 13:24:52 +02:00
parent 27a29b6f69
commit 29763ffaa9
8 changed files with 382 additions and 52 deletions

View File

@@ -1,6 +1,6 @@
<script>
import { getExerciseById } from '$lib/data/exercises';
import { EllipsisVertical } from 'lucide-svelte';
import { EllipsisVertical, MapPin } from 'lucide-svelte';
import { page } from '$app/stores';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
@@ -8,7 +8,7 @@
/**
* @type {{
* template: { _id: string, name: string, exercises: Array<{ exerciseId: string, sets: any[] }> },
* template: { _id: string, name: string, mode?: string, activityType?: string, exercises: Array<{ exerciseId: string, sets: any[] }> },
* lastUsed?: string | null,
* onStart?: (() => void) | null,
* onMenu?: ((e: MouseEvent) => void) | null
@@ -42,15 +42,19 @@
</button>
{/if}
</div>
<ul class="exercise-preview">
{#each template.exercises.slice(0, 4) as ex}
{@const exercise = getExerciseById(ex.exerciseId, lang)}
<li>{ex.sets.length} &times; {exercise?.localName ?? ex.exerciseId}</li>
{/each}
{#if template.exercises.length > 4}
<li class="more">+{template.exercises.length - 4} {t('more', lang)}</li>
{/if}
</ul>
{#if template.mode === 'gps'}
<div class="gps-badge"><MapPin size={12} /> {template.activityType?.[0]?.toUpperCase()}{template.activityType?.slice(1) ?? 'Running'}</div>
{:else}
<ul class="exercise-preview">
{#each template.exercises.slice(0, 4) as ex}
{@const exercise = getExerciseById(ex.exerciseId, lang)}
<li>{ex.sets.length} &times; {exercise?.localName ?? ex.exerciseId}</li>
{/each}
{#if template.exercises.length > 4}
<li class="more">+{template.exercises.length - 4} {t('more', lang)}</li>
{/if}
</ul>
{/if}
{#if lastUsed}
<p class="last-used">{t('last_performed', lang)} {formatDate(lastUsed)}</p>
{/if}
@@ -115,6 +119,14 @@
color: var(--color-primary);
font-style: italic;
}
.gps-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8rem;
color: var(--nord10);
font-weight: 600;
}
.last-used {
margin: 0.5rem 0 0;
font-size: 0.75rem;

View File

@@ -43,6 +43,7 @@ export interface StoredState {
activityType: GpsActivityType | null;
name: string;
templateId: string | null;
intervalTemplateId: string | null;
exercises: WorkoutExercise[];
elapsed: number; // total elapsed seconds at time of save
savedAt: number; // Date.now() at time of save
@@ -57,6 +58,7 @@ export interface RemoteState {
mode: WorkoutMode;
activityType: GpsActivityType | null;
templateId: string | null;
intervalTemplateId: string | null;
exercises: WorkoutExercise[];
paused: boolean;
elapsed: number;
@@ -100,6 +102,7 @@ export function createWorkout() {
let activityType = $state<GpsActivityType | null>(null);
let name = $state('');
let templateId: string | null = $state(null);
let intervalTemplateId: string | null = $state(null);
let exercises = $state<WorkoutExercise[]>([]);
let startTime: Date | null = $state(null);
let _pausedElapsed = $state(0); // seconds accumulated before current run
@@ -128,6 +131,7 @@ export function createWorkout() {
activityType,
name,
templateId,
intervalTemplateId,
exercises: JSON.parse(JSON.stringify(exercises)),
elapsed: _elapsed,
savedAt: Date.now(),
@@ -197,6 +201,7 @@ export function createWorkout() {
activityType = stored.activityType ?? null;
name = stored.name;
templateId = stored.templateId;
intervalTemplateId = stored.intervalTemplateId ?? null;
exercises = stored.exercises;
if (stored.paused) {
@@ -233,6 +238,7 @@ export function createWorkout() {
function startFromTemplate(template: TemplateData) {
name = template.name;
templateId = template._id;
intervalTemplateId = null;
mode = 'manual';
exercises = template.exercises.map((e) => ({
exerciseId: e.exerciseId,
@@ -260,6 +266,7 @@ export function createWorkout() {
function startEmpty() {
name = 'Quick Workout';
templateId = null;
intervalTemplateId = null;
mode = 'manual';
exercises = [];
startTime = new Date();
@@ -280,6 +287,7 @@ export function createWorkout() {
};
name = labels[activity];
templateId = null;
intervalTemplateId = null;
mode = 'gps';
activityType = activity;
exercises = [];
@@ -291,6 +299,21 @@ export function createWorkout() {
_persist();
}
function startFromGpsTemplate(template: { _id: string; name: string; activityType?: string; intervalTemplateId?: string }) {
name = template.name;
templateId = template._id;
intervalTemplateId = template.intervalTemplateId ?? null;
mode = 'gps';
activityType = (template.activityType as GpsActivityType) ?? 'running';
exercises = [];
startTime = null;
_pausedElapsed = 0;
_elapsed = 0;
paused = true;
active = true;
_persist();
}
function pauseTimer() {
if (!active || paused) return;
_computeElapsed();
@@ -450,6 +473,7 @@ export function createWorkout() {
activityType = null;
name = '';
templateId = null;
intervalTemplateId = null;
exercises = [];
startTime = null;
_pausedElapsed = 0;
@@ -469,6 +493,7 @@ export function createWorkout() {
mode = remote.mode ?? 'manual';
activityType = remote.activityType ?? null;
templateId = remote.templateId;
intervalTemplateId = remote.intervalTemplateId ?? null;
exercises = remote.exercises;
if (remote.paused) {
@@ -515,6 +540,7 @@ export function createWorkout() {
activityType,
name,
templateId,
intervalTemplateId,
exercises: JSON.parse(JSON.stringify(exercises)),
elapsed: _elapsed,
savedAt: Date.now(),
@@ -544,6 +570,7 @@ export function createWorkout() {
get name() { return name; },
set name(v: string) { name = v; _persist(); },
get templateId() { return templateId; },
get intervalTemplateId() { return intervalTemplateId; },
get exercises() { return exercises; },
get startTime() { return startTime; },
get elapsedSeconds() { return _elapsed; },
@@ -557,6 +584,7 @@ export function createWorkout() {
startFromTemplate,
startEmpty,
startGpsWorkout,
startFromGpsTemplate,
pauseTimer,
resumeTimer,
addExercise,

View File

@@ -17,6 +17,7 @@ interface ServerWorkout {
mode: WorkoutMode;
activityType: GpsActivityType | null;
templateId: string | null;
intervalTemplateId: string | null;
exercises: WorkoutExercise[];
paused: boolean;
elapsed: number;
@@ -47,6 +48,7 @@ export function createWorkoutSync() {
mode: workout.mode,
activityType: workout.activityType,
templateId: workout.templateId,
intervalTemplateId: workout.intervalTemplateId,
exercises: JSON.parse(JSON.stringify(workout.exercises)),
paused: workout.paused,
elapsed,
@@ -114,6 +116,7 @@ export function createWorkoutSync() {
mode: doc.mode ?? 'manual',
activityType: doc.activityType ?? null,
templateId: doc.templateId,
intervalTemplateId: doc.intervalTemplateId ?? null,
exercises: doc.exercises,
paused: doc.paused,
elapsed: doc.elapsed,
@@ -234,6 +237,7 @@ export function createWorkoutSync() {
mode: serverDoc.mode ?? 'manual',
activityType: serverDoc.activityType ?? null,
templateId: serverDoc.templateId,
intervalTemplateId: serverDoc.intervalTemplateId ?? null,
exercises: serverDoc.exercises,
paused: serverDoc.paused,
elapsed: serverDoc.elapsed,

View File

@@ -19,6 +19,9 @@ export interface IWorkoutTemplate {
_id?: string;
name: string;
description?: string;
mode?: 'manual' | 'gps';
activityType?: 'running' | 'walking' | 'cycling' | 'hiking';
intervalTemplateId?: string; // reference to an IntervalTemplate for GPS workouts
exercises: IExercise[];
createdBy: string; // username/nickname of the person who created the template
isPublic?: boolean; // whether other users can see/use this template
@@ -89,11 +92,27 @@ const WorkoutTemplateSchema = new mongoose.Schema(
trim: true,
maxlength: 500
},
mode: {
type: String,
enum: ['manual', 'gps'],
default: 'manual'
},
activityType: {
type: String,
enum: ['running', 'walking', 'cycling', 'hiking'],
default: undefined
},
intervalTemplateId: {
type: String,
default: undefined
},
exercises: {
type: [ExerciseSchema],
required: true,
validate: {
validator: function(exercises: IExercise[]) {
validator: function(this: any, exercises: IExercise[]) {
// GPS templates don't need exercises
if (this.mode === 'gps') return true;
return exercises.length > 0;
},
message: 'A workout template must have at least one exercise'

View File

@@ -45,23 +45,33 @@ export const POST: RequestHandler = async ({ request, locals }) => {
await dbConnect();
const data = await request.json();
const { name, description, exercises, isPublic = false } = data;
const { name, description, exercises, isPublic = false, mode = 'manual', activityType, intervalTemplateId } = data;
if (!name || !exercises || !Array.isArray(exercises) || exercises.length === 0) {
return json({ error: 'Name and at least one exercise are required' }, { status: 400 });
const isGps = mode === 'gps';
if (!name) {
return json({ error: 'Name is required' }, { status: 400 });
}
if (!isGps && (!exercises || !Array.isArray(exercises) || exercises.length === 0)) {
return json({ error: 'At least one exercise is required' }, { status: 400 });
}
// Validate exercises structure
for (const exercise of exercises) {
if (!exercise.exerciseId) {
return json({ error: 'Each exercise must have an exerciseId' }, { status: 400 });
if (exercises && Array.isArray(exercises)) {
for (const exercise of exercises) {
if (!exercise.exerciseId) {
return json({ error: 'Each exercise must have an exerciseId' }, { status: 400 });
}
}
}
const template = new WorkoutTemplate({
name,
description,
exercises,
mode,
...(isGps ? { activityType, intervalTemplateId } : {}),
exercises: exercises ?? [],
isPublic,
createdBy: session.user.nickname
});

View File

@@ -52,28 +52,39 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
}
const data = await request.json();
const { name, description, exercises, isPublic } = data;
const { name, description, exercises, isPublic, mode = 'manual', activityType, intervalTemplateId } = data;
if (!name || !exercises || !Array.isArray(exercises) || exercises.length === 0) {
return json({ error: 'Name and at least one exercise are required' }, { status: 400 });
const isGps = mode === 'gps';
if (!name) {
return json({ error: 'Name is required' }, { status: 400 });
}
if (!isGps && (!exercises || !Array.isArray(exercises) || exercises.length === 0)) {
return json({ error: 'At least one exercise is required' }, { status: 400 });
}
// Validate exercises structure
for (const exercise of exercises) {
if (!exercise.exerciseId) {
return json({ error: 'Each exercise must have an exerciseId' }, { status: 400 });
if (exercises && Array.isArray(exercises)) {
for (const exercise of exercises) {
if (!exercise.exerciseId) {
return json({ error: 'Each exercise must have an exerciseId' }, { status: 400 });
}
}
}
const template = await WorkoutTemplate.findOneAndUpdate(
{
_id: params.id,
createdBy: session.user.nickname // Only allow users to edit their own templates
createdBy: session.user.nickname
},
{
name,
description,
exercises,
mode,
activityType: isGps ? activityType : undefined,
intervalTemplateId: isGps ? intervalTemplateId : undefined,
exercises: exercises ?? [],
isPublic
},
{ new: true }

View File

@@ -2,7 +2,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { Plus, Trash2, Play, Pencil, X, Save, CalendarClock, ChevronUp, ChevronDown, ArrowRight, MapPin, Dumbbell } from 'lucide-svelte';
import { Plus, Trash2, Play, Pencil, X, Save, CalendarClock, ChevronUp, ChevronDown, ArrowRight, MapPin, Dumbbell, Timer } from 'lucide-svelte';
import { getWorkout } from '$lib/js/workout.svelte';
import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
@@ -45,14 +45,35 @@
let editorPicker = $state(false);
let editorSaving = $state(false);
// GPS template editor state
/** @type {'manual' | 'gps'} */
let editorMode = $state('manual');
/** @type {'running' | 'walking' | 'cycling' | 'hiking'} */
let editorActivityType = $state('running');
/** @type {string | null} */
let editorIntervalId = $state(null);
/** @type {any[]} */
let intervalTemplates = $state([]);
/** @type {any} */
let nextTemplate = $derived(nextTemplateId ? templates.find((t) => t._id === nextTemplateId) : null);
let hasSchedule = $derived(scheduleOrder.length > 0);
let isApp = $state(false);
async function fetchIntervalTemplates() {
try {
const res = await fetch('/api/fitness/intervals');
if (res.ok) {
const d = await res.json();
intervalTemplates = d.templates ?? [];
}
} catch {}
}
onMount(() => {
isApp = '__TAURI__' in window;
workout.restore();
fetchIntervalTemplates();
// If there's an active workout, redirect to the active page
if (workout.active) {
@@ -84,7 +105,12 @@
/** @param {any} template */
async function startFromTemplate(template) {
selectedTemplate = null;
workout.startFromTemplate(template);
if (template.mode === 'gps') {
if (!isApp) return; // can't start GPS on desktop
workout.startFromGpsTemplate(template);
} else {
workout.startFromTemplate(template);
}
await sync.onWorkoutStart();
goto(`/fitness/${sl.workout}/${sl.active}`);
}
@@ -110,6 +136,9 @@
editingTemplate = null;
editorName = '';
editorExercises = [];
editorMode = 'manual';
editorActivityType = 'running';
editorIntervalId = null;
showTemplateEditor = true;
}
@@ -118,7 +147,10 @@
selectedTemplate = null;
editingTemplate = template;
editorName = template.name;
editorExercises = template.exercises.map((/** @type {any} */ ex) => ({
editorMode = template.mode ?? 'manual';
editorActivityType = template.activityType ?? 'running';
editorIntervalId = template.intervalTemplateId ?? null;
editorExercises = (template.exercises ?? []).map((/** @type {any} */ ex) => ({
exerciseId: ex.exerciseId,
sets: ex.sets.map((/** @type {any} */ s) => ({ ...s })),
restTime: ex.restTime ?? 120
@@ -175,12 +207,19 @@
}
async function saveTemplate() {
if (!editorName.trim() || editorExercises.length === 0) return;
const isGps = editorMode === 'gps';
if (!editorName.trim() || (!isGps && editorExercises.length === 0)) return;
editorSaving = true;
const body = {
name: editorName.trim(),
exercises: editorExercises.map((ex) => {
mode: editorMode,
...(isGps ? {
activityType: editorActivityType,
intervalTemplateId: editorIntervalId || undefined,
exercises: []
} : {}),
...(!isGps ? { exercises: editorExercises.map((ex) => {
const metrics = getExerciseMetrics(getExerciseById(ex.exerciseId));
return {
exerciseId: ex.exerciseId,
@@ -194,7 +233,7 @@
}),
restTime: ex.restTime
};
})
}) } : {})
};
try {
@@ -323,7 +362,20 @@
<button class="next-workout-btn" onclick={startNextScheduled}>
<div class="next-info">
<span class="next-name">{nextTemplate.name}</span>
<span class="next-exercises">{nextTemplate.exercises.length} {nextTemplate.exercises.length !== 1 ? t('exercises_word', lang) : t('exercise', lang)}</span>
{#if nextTemplate.mode === 'gps'}
<span class="next-exercises next-gps-info">
<MapPin size={12} />
{nextTemplate.activityType?.[0]?.toUpperCase()}{nextTemplate.activityType?.slice(1) ?? 'Running'}
{#if nextTemplate.intervalTemplateId}
{@const interval = intervalTemplates.find((/** @type {any} */ t) => t._id === nextTemplate.intervalTemplateId)}
{#if interval}
· <Timer size={12} /> {interval.name}
{/if}
{/if}
</span>
{:else}
<span class="next-exercises">{nextTemplate.exercises.length} {nextTemplate.exercises.length !== 1 ? t('exercises_word', lang) : t('exercise', lang)}</span>
{/if}
</div>
<div class="next-go">
<Play size={18} />
@@ -397,22 +449,44 @@
<button class="close-btn" onclick={closeTemplateDetail} aria-label="Close"><X size={20} /></button>
</div>
<div class="modal-body">
<ul class="template-exercises">
{#each selectedTemplate.exercises as ex (ex.exerciseId)}
{@const exercise = getExerciseById(ex.exerciseId, lang)}
<li>
<span class="tex-name">{exercise?.localName ?? ex.exerciseId}</span>
<span class="tex-sets">{ex.sets.length} {ex.sets.length !== 1 ? t('sets', lang) : t('set', lang)}</span>
</li>
{/each}
</ul>
{#if selectedTemplate.mode === 'gps'}
<div class="modal-gps-info">
<span class="modal-gps-badge"><MapPin size={14} /> GPS — {selectedTemplate.activityType?.[0]?.toUpperCase()}{selectedTemplate.activityType?.slice(1) ?? 'Running'}</span>
{#if selectedTemplate.intervalTemplateId}
{@const interval = intervalTemplates.find((/** @type {any} */ t) => t._id === selectedTemplate.intervalTemplateId)}
{#if interval}
<span class="modal-interval-badge"><Timer size={14} /> {interval.name}</span>
{/if}
{/if}
</div>
{#if !isApp}
<div class="gps-desktop-notice">
<MapPin size={16} />
<p>GPS workouts require the app. <a href="https://bocken.org/static/Bocken.apk" target="_blank" rel="noopener">Download APK</a></p>
</div>
{/if}
{:else}
<ul class="template-exercises">
{#each selectedTemplate.exercises as ex (ex.exerciseId)}
{@const exercise = getExerciseById(ex.exerciseId, lang)}
<li>
<span class="tex-name">{exercise?.localName ?? ex.exerciseId}</span>
<span class="tex-sets">{ex.sets.length} {ex.sets.length !== 1 ? t('sets', lang) : t('set', lang)}</span>
</li>
{/each}
</ul>
{/if}
{#if selectedTemplate.lastUsed}
<p class="modal-meta">Last performed: {new Date(selectedTemplate.lastUsed).toLocaleDateString()}</p>
{/if}
</div>
<div class="modal-actions">
<button class="modal-start" onclick={() => startFromTemplate(selectedTemplate)}>
<Play size={16} /> {t('start_workout', lang)}
<button class="modal-start" onclick={() => startFromTemplate(selectedTemplate)} disabled={selectedTemplate.mode === 'gps' && !isApp}>
{#if selectedTemplate.mode === 'gps'}
<MapPin size={16} /> Start GPS Workout
{:else}
<Play size={16} /> {t('start_workout', lang)}
{/if}
</button>
<button class="modal-edit" onclick={() => openEditTemplate(selectedTemplate)}>
<Pencil size={16} /> {t('edit_template', lang)}
@@ -444,6 +518,56 @@
bind:value={editorName}
/>
<div class="editor-mode-toggle">
<button class="mode-btn" class:active={editorMode === 'manual'} onclick={() => editorMode = 'manual'} type="button">
<Dumbbell size={14} /> Strength
</button>
<button class="mode-btn" class:active={editorMode === 'gps'} onclick={() => editorMode = 'gps'} type="button">
<MapPin size={14} /> GPS
</button>
</div>
{#if editorMode === 'gps'}
{#if !isApp}
<div class="gps-desktop-notice">
<MapPin size={16} />
<p>GPS workouts require the app. <a href="https://bocken.org/static/Bocken.apk" target="_blank" rel="noopener">Download APK</a></p>
</div>
{/if}
<div class="editor-gps-options">
<label class="editor-label">Activity</label>
<div class="activity-chips">
{#each /** @type {const} */ (['running', 'walking', 'cycling', 'hiking']) as act}
<button
class="activity-chip"
class:selected={editorActivityType === act}
onclick={() => editorActivityType = /** @type {typeof editorActivityType} */ (act)}
type="button"
>{act[0].toUpperCase() + act.slice(1)}</button>
{/each}
</div>
<label class="editor-label">Interval Training</label>
<div class="interval-select">
<button
class="interval-option"
class:selected={!editorIntervalId}
onclick={() => editorIntervalId = null}
type="button"
>None</button>
{#each intervalTemplates as tmpl (tmpl._id)}
<button
class="interval-option"
class:selected={editorIntervalId === tmpl._id}
onclick={() => editorIntervalId = editorIntervalId === tmpl._id ? null : tmpl._id}
type="button"
>{tmpl.name} ({tmpl.steps.length} steps)</button>
{/each}
</div>
</div>
{/if}
{#if editorMode === 'manual'}
{#each editorExercises as ex, exIdx (exIdx)}
{@const exercise = getExerciseById(ex.exerciseId, lang)}
{@const exMetrics = getExerciseMetrics(exercise).filter((/** @type {string} */ m) => m !== 'rpe')}
@@ -488,9 +612,10 @@
<button class="editor-add-exercise" onclick={() => editorPicker = true}>
<Plus size={16} /> {t('add_exercise_btn', lang)}
</button>
{/if}
</div>
<div class="modal-actions">
<button class="modal-start" onclick={saveTemplate} disabled={editorSaving || !editorName.trim() || editorExercises.length === 0}>
<button class="modal-start" onclick={saveTemplate} disabled={editorSaving || !editorName.trim() || (editorMode === 'manual' && editorExercises.length === 0)}>
<Save size={16} /> {editorSaving ? t('saving', lang) : t('save_template', lang)}
</button>
</div>
@@ -623,6 +748,11 @@
font-size: 0.8rem;
opacity: 0.85;
}
.next-gps-info {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.next-go {
display: flex;
align-items: center;
@@ -891,6 +1021,118 @@
outline: none;
border-color: var(--color-primary);
}
.editor-mode-toggle {
display: flex;
gap: 0.4rem;
margin-bottom: 0.75rem;
}
.mode-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.5rem;
border-radius: 8px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-secondary);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.mode-btn.active {
background: var(--nord10);
color: #fff;
border-color: var(--nord10);
}
.editor-gps-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.editor-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
margin-top: 0.25rem;
}
.activity-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.activity-chip {
padding: 0.3rem 0.7rem;
border-radius: 20px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-primary);
font-size: 0.8rem;
cursor: pointer;
}
.activity-chip.selected {
background: var(--nord10);
color: #fff;
border-color: var(--nord10);
}
.interval-select {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.interval-option {
text-align: left;
padding: 0.5rem 0.75rem;
border-radius: 8px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-primary);
font-size: 0.85rem;
cursor: pointer;
}
.interval-option.selected {
background: var(--nord10);
color: #fff;
border-color: var(--nord10);
}
.gps-desktop-notice {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: var(--color-bg-elevated);
border-radius: 8px;
color: var(--color-text-secondary);
font-size: 0.85rem;
}
.gps-desktop-notice a {
color: var(--nord10);
}
.modal-gps-info {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.modal-gps-badge, .modal-interval-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.6rem;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 600;
}
.modal-gps-badge {
background: rgba(94, 129, 172, 0.15);
color: var(--nord10);
}
.modal-interval-badge {
background: rgba(163, 190, 140, 0.15);
color: var(--nord14);
}
.editor-exercise {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);

View File

@@ -254,7 +254,7 @@
}).addTo(liveMap);
livePolyline = leafletLib.polyline([], { color: '#88c0d0', weight: 3 }).addTo(liveMap);
liveMarker = leafletLib.circleMarker([0, 0], {
radius: 6, fillColor: '#a3be8c', fillOpacity: 1, color: '#fff', weight: 2, opacity: 0, fillOpacity: 0
radius: 6, fillColor: '#a3be8c', color: '#fff', weight: 2, opacity: 0, fillOpacity: 0
}).addTo(liveMap);
if (gps.track.length > 0) {
@@ -379,11 +379,15 @@
} catch {}
vgLoaded = true;
// Restore selected interval from localStorage
try {
const savedId = localStorage.getItem('selected_interval_id');
if (savedId) selectedIntervalId = savedId;
} catch {}
// Pre-select interval from GPS template, or restore from localStorage
if (workout.intervalTemplateId) {
selectedIntervalId = workout.intervalTemplateId;
} else {
try {
const savedId = localStorage.getItem('selected_interval_id');
if (savedId) selectedIntervalId = savedId;
} catch {}
}
// Fetch interval templates
fetchIntervalTemplates();