fitness: GPS workout templates with interval pre-selection
All checks were successful
CI / update (push) Successful in 2m17s
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:
@@ -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} × {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} × {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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user