feat: redesign GPS workout UI with Runkeeper-style map overlay
All checks were successful
CI / update (push) Successful in 2m32s
All checks were successful
CI / update (push) Successful in 2m32s
- Full-screen fixed map with controls overlaid at the bottom - Activity type selector (running/walking/cycling/hiking) with proper exercise mapping for history display - GPS starts immediately on entering workout screen for faster lock - GPS track attached to cardio exercise (like GPX upload) so history shows distance, pace, splits, and map - Add activityType field to workout state, session model, and sync - Cancel button appears when workout is paused - GPS Workout button only shown in Tauri app
This commit is contained in:
@@ -221,6 +221,22 @@ export function createGpsTracker() {
|
|||||||
bridge?.resumeTracking();
|
bridge?.resumeTracking();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Request location permissions without starting the tracking service.
|
||||||
|
* Returns true if permissions were granted. */
|
||||||
|
async function ensurePermissions(): Promise<boolean> {
|
||||||
|
if (!checkTauri()) return false;
|
||||||
|
try {
|
||||||
|
const geo = await import('@tauri-apps/plugin-geolocation');
|
||||||
|
let perms = await geo.checkPermissions();
|
||||||
|
if (perms.location !== 'granted') {
|
||||||
|
perms = await geo.requestPermissions(['location']);
|
||||||
|
}
|
||||||
|
return perms.location === 'granted';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get track() { return track; },
|
get track() { return track; },
|
||||||
get isTracking() { return isTracking; },
|
get isTracking() { return isTracking; },
|
||||||
@@ -237,7 +253,8 @@ export function createGpsTracker() {
|
|||||||
hasTtsEngine,
|
hasTtsEngine,
|
||||||
installTtsEngine,
|
installTtsEngine,
|
||||||
pauseTracking,
|
pauseTracking,
|
||||||
resumeTracking
|
resumeTracking,
|
||||||
|
ensurePermissions
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,14 @@ export interface TemplateData {
|
|||||||
|
|
||||||
const STORAGE_KEY = 'fitness-active-workout';
|
const STORAGE_KEY = 'fitness-active-workout';
|
||||||
|
|
||||||
|
export type WorkoutMode = 'manual' | 'gps';
|
||||||
|
export type GpsActivityType = 'running' | 'walking' | 'cycling' | 'hiking';
|
||||||
|
|
||||||
export interface StoredState {
|
export interface StoredState {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
paused: boolean;
|
paused: boolean;
|
||||||
|
mode: WorkoutMode;
|
||||||
|
activityType: GpsActivityType | null;
|
||||||
name: string;
|
name: string;
|
||||||
templateId: string | null;
|
templateId: string | null;
|
||||||
exercises: WorkoutExercise[];
|
exercises: WorkoutExercise[];
|
||||||
@@ -49,6 +54,8 @@ export interface StoredState {
|
|||||||
|
|
||||||
export interface RemoteState {
|
export interface RemoteState {
|
||||||
name: string;
|
name: string;
|
||||||
|
mode: WorkoutMode;
|
||||||
|
activityType: GpsActivityType | null;
|
||||||
templateId: string | null;
|
templateId: string | null;
|
||||||
exercises: WorkoutExercise[];
|
exercises: WorkoutExercise[];
|
||||||
paused: boolean;
|
paused: boolean;
|
||||||
@@ -89,6 +96,8 @@ function clearStorage() {
|
|||||||
export function createWorkout() {
|
export function createWorkout() {
|
||||||
let active = $state(false);
|
let active = $state(false);
|
||||||
let paused = $state(false);
|
let paused = $state(false);
|
||||||
|
let mode = $state<WorkoutMode>('manual');
|
||||||
|
let activityType = $state<GpsActivityType | null>(null);
|
||||||
let name = $state('');
|
let name = $state('');
|
||||||
let templateId: string | null = $state(null);
|
let templateId: string | null = $state(null);
|
||||||
let exercises = $state<WorkoutExercise[]>([]);
|
let exercises = $state<WorkoutExercise[]>([]);
|
||||||
@@ -115,6 +124,8 @@ export function createWorkout() {
|
|||||||
saveToStorage({
|
saveToStorage({
|
||||||
active,
|
active,
|
||||||
paused,
|
paused,
|
||||||
|
mode,
|
||||||
|
activityType,
|
||||||
name,
|
name,
|
||||||
templateId,
|
templateId,
|
||||||
exercises: JSON.parse(JSON.stringify(exercises)),
|
exercises: JSON.parse(JSON.stringify(exercises)),
|
||||||
@@ -182,6 +193,8 @@ export function createWorkout() {
|
|||||||
|
|
||||||
active = true;
|
active = true;
|
||||||
paused = stored.paused;
|
paused = stored.paused;
|
||||||
|
mode = stored.mode ?? 'manual';
|
||||||
|
activityType = stored.activityType ?? null;
|
||||||
name = stored.name;
|
name = stored.name;
|
||||||
templateId = stored.templateId;
|
templateId = stored.templateId;
|
||||||
exercises = stored.exercises;
|
exercises = stored.exercises;
|
||||||
@@ -220,6 +233,7 @@ export function createWorkout() {
|
|||||||
function startFromTemplate(template: TemplateData) {
|
function startFromTemplate(template: TemplateData) {
|
||||||
name = template.name;
|
name = template.name;
|
||||||
templateId = template._id;
|
templateId = template._id;
|
||||||
|
mode = 'manual';
|
||||||
exercises = template.exercises.map((e) => ({
|
exercises = template.exercises.map((e) => ({
|
||||||
exerciseId: e.exerciseId,
|
exerciseId: e.exerciseId,
|
||||||
sets: e.sets.length > 0
|
sets: e.sets.length > 0
|
||||||
@@ -246,6 +260,7 @@ export function createWorkout() {
|
|||||||
function startEmpty() {
|
function startEmpty() {
|
||||||
name = 'Quick Workout';
|
name = 'Quick Workout';
|
||||||
templateId = null;
|
templateId = null;
|
||||||
|
mode = 'manual';
|
||||||
exercises = [];
|
exercises = [];
|
||||||
startTime = new Date();
|
startTime = new Date();
|
||||||
_pausedElapsed = 0;
|
_pausedElapsed = 0;
|
||||||
@@ -256,6 +271,26 @@ export function createWorkout() {
|
|||||||
_persist();
|
_persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startGpsWorkout(activity: GpsActivityType = 'running') {
|
||||||
|
const labels: Record<GpsActivityType, string> = {
|
||||||
|
running: 'Running',
|
||||||
|
walking: 'Walking',
|
||||||
|
cycling: 'Cycling',
|
||||||
|
hiking: 'Hiking'
|
||||||
|
};
|
||||||
|
name = labels[activity];
|
||||||
|
templateId = null;
|
||||||
|
mode = 'gps';
|
||||||
|
activityType = activity;
|
||||||
|
exercises = [];
|
||||||
|
startTime = null;
|
||||||
|
_pausedElapsed = 0;
|
||||||
|
_elapsed = 0;
|
||||||
|
paused = true;
|
||||||
|
active = true;
|
||||||
|
_persist();
|
||||||
|
}
|
||||||
|
|
||||||
function pauseTimer() {
|
function pauseTimer() {
|
||||||
if (!active || paused) return;
|
if (!active || paused) return;
|
||||||
_computeElapsed();
|
_computeElapsed();
|
||||||
@@ -374,6 +409,8 @@ export function createWorkout() {
|
|||||||
templateId,
|
templateId,
|
||||||
templateName: templateId ? name : undefined,
|
templateName: templateId ? name : undefined,
|
||||||
name,
|
name,
|
||||||
|
mode,
|
||||||
|
activityType,
|
||||||
exercises: exercises
|
exercises: exercises
|
||||||
.filter((e) => e.sets.some((s) => s.completed))
|
.filter((e) => e.sets.some((s) => s.completed))
|
||||||
.map((e) => ({
|
.map((e) => ({
|
||||||
@@ -409,6 +446,8 @@ export function createWorkout() {
|
|||||||
function _reset() {
|
function _reset() {
|
||||||
active = false;
|
active = false;
|
||||||
paused = false;
|
paused = false;
|
||||||
|
mode = 'manual';
|
||||||
|
activityType = null;
|
||||||
name = '';
|
name = '';
|
||||||
templateId = null;
|
templateId = null;
|
||||||
exercises = [];
|
exercises = [];
|
||||||
@@ -427,6 +466,8 @@ export function createWorkout() {
|
|||||||
/** Apply state from another device (merge strategy: incoming wins) */
|
/** Apply state from another device (merge strategy: incoming wins) */
|
||||||
function applyRemoteState(remote: RemoteState) {
|
function applyRemoteState(remote: RemoteState) {
|
||||||
name = remote.name;
|
name = remote.name;
|
||||||
|
mode = remote.mode ?? 'manual';
|
||||||
|
activityType = remote.activityType ?? null;
|
||||||
templateId = remote.templateId;
|
templateId = remote.templateId;
|
||||||
exercises = remote.exercises;
|
exercises = remote.exercises;
|
||||||
|
|
||||||
@@ -470,6 +511,8 @@ export function createWorkout() {
|
|||||||
saveToStorage({
|
saveToStorage({
|
||||||
active: true,
|
active: true,
|
||||||
paused,
|
paused,
|
||||||
|
mode,
|
||||||
|
activityType,
|
||||||
name,
|
name,
|
||||||
templateId,
|
templateId,
|
||||||
exercises: JSON.parse(JSON.stringify(exercises)),
|
exercises: JSON.parse(JSON.stringify(exercises)),
|
||||||
@@ -496,6 +539,8 @@ export function createWorkout() {
|
|||||||
return {
|
return {
|
||||||
get active() { return active; },
|
get active() { return active; },
|
||||||
get paused() { return paused; },
|
get paused() { return paused; },
|
||||||
|
get mode() { return mode; },
|
||||||
|
get activityType() { return activityType; },
|
||||||
get name() { return name; },
|
get name() { return name; },
|
||||||
set name(v: string) { name = v; _persist(); },
|
set name(v: string) { name = v; _persist(); },
|
||||||
get templateId() { return templateId; },
|
get templateId() { return templateId; },
|
||||||
@@ -511,6 +556,7 @@ export function createWorkout() {
|
|||||||
restore,
|
restore,
|
||||||
startFromTemplate,
|
startFromTemplate,
|
||||||
startEmpty,
|
startEmpty,
|
||||||
|
startGpsWorkout,
|
||||||
pauseTimer,
|
pauseTimer,
|
||||||
resumeTimer,
|
resumeTimer,
|
||||||
addExercise,
|
addExercise,
|
||||||
|
|||||||
@@ -7,13 +7,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getWorkout } from '$lib/js/workout.svelte';
|
import { getWorkout } from '$lib/js/workout.svelte';
|
||||||
import type { WorkoutExercise } from '$lib/js/workout.svelte';
|
import type { WorkoutExercise, WorkoutMode, GpsActivityType } from '$lib/js/workout.svelte';
|
||||||
|
|
||||||
type SyncStatus = 'idle' | 'synced' | 'syncing' | 'offline' | 'conflict';
|
type SyncStatus = 'idle' | 'synced' | 'syncing' | 'offline' | 'conflict';
|
||||||
|
|
||||||
interface ServerWorkout {
|
interface ServerWorkout {
|
||||||
version: number;
|
version: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
mode: WorkoutMode;
|
||||||
|
activityType: GpsActivityType | null;
|
||||||
templateId: string | null;
|
templateId: string | null;
|
||||||
exercises: WorkoutExercise[];
|
exercises: WorkoutExercise[];
|
||||||
paused: boolean;
|
paused: boolean;
|
||||||
@@ -42,6 +44,8 @@ export function createWorkoutSync() {
|
|||||||
return {
|
return {
|
||||||
version: serverVersion,
|
version: serverVersion,
|
||||||
name: workout.name,
|
name: workout.name,
|
||||||
|
mode: workout.mode,
|
||||||
|
activityType: workout.activityType,
|
||||||
templateId: workout.templateId,
|
templateId: workout.templateId,
|
||||||
exercises: JSON.parse(JSON.stringify(workout.exercises)),
|
exercises: JSON.parse(JSON.stringify(workout.exercises)),
|
||||||
paused: workout.paused,
|
paused: workout.paused,
|
||||||
@@ -107,6 +111,8 @@ export function createWorkoutSync() {
|
|||||||
// but we keep the higher value for completed sets
|
// but we keep the higher value for completed sets
|
||||||
workout.applyRemoteState({
|
workout.applyRemoteState({
|
||||||
name: doc.name,
|
name: doc.name,
|
||||||
|
mode: doc.mode ?? 'manual',
|
||||||
|
activityType: doc.activityType ?? null,
|
||||||
templateId: doc.templateId,
|
templateId: doc.templateId,
|
||||||
exercises: doc.exercises,
|
exercises: doc.exercises,
|
||||||
paused: doc.paused,
|
paused: doc.paused,
|
||||||
@@ -225,6 +231,8 @@ export function createWorkoutSync() {
|
|||||||
serverVersion = serverDoc.version;
|
serverVersion = serverDoc.version;
|
||||||
workout.restoreFromRemote({
|
workout.restoreFromRemote({
|
||||||
name: serverDoc.name,
|
name: serverDoc.name,
|
||||||
|
mode: serverDoc.mode ?? 'manual',
|
||||||
|
activityType: serverDoc.activityType ?? null,
|
||||||
templateId: serverDoc.templateId,
|
templateId: serverDoc.templateId,
|
||||||
exercises: serverDoc.exercises,
|
exercises: serverDoc.exercises,
|
||||||
paused: serverDoc.paused,
|
paused: serverDoc.paused,
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export interface IActiveWorkout {
|
|||||||
userId: string;
|
userId: string;
|
||||||
version: number;
|
version: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
mode: 'manual' | 'gps';
|
||||||
|
activityType: 'running' | 'walking' | 'cycling' | 'hiking' | null;
|
||||||
templateId: string | null;
|
templateId: string | null;
|
||||||
exercises: IActiveWorkoutExercise[];
|
exercises: IActiveWorkoutExercise[];
|
||||||
paused: boolean;
|
paused: boolean;
|
||||||
@@ -62,6 +64,16 @@ const ActiveWorkoutSchema = new mongoose.Schema(
|
|||||||
trim: true,
|
trim: true,
|
||||||
maxlength: 100
|
maxlength: 100
|
||||||
},
|
},
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
enum: ['manual', 'gps'],
|
||||||
|
default: 'manual'
|
||||||
|
},
|
||||||
|
activityType: {
|
||||||
|
type: String,
|
||||||
|
enum: ['running', 'walking', 'cycling', 'hiking'],
|
||||||
|
default: null
|
||||||
|
},
|
||||||
templateId: {
|
templateId: {
|
||||||
type: String,
|
type: String,
|
||||||
default: null
|
default: null
|
||||||
|
|||||||
@@ -41,12 +41,16 @@ export interface IWorkoutSession {
|
|||||||
templateId?: string; // Reference to WorkoutTemplate if based on template
|
templateId?: string; // Reference to WorkoutTemplate if based on template
|
||||||
templateName?: string; // Snapshot of template name for history
|
templateName?: string; // Snapshot of template name for history
|
||||||
name: string;
|
name: string;
|
||||||
|
mode?: 'manual' | 'gps';
|
||||||
|
activityType?: 'running' | 'walking' | 'cycling' | 'hiking';
|
||||||
exercises: ICompletedExercise[];
|
exercises: ICompletedExercise[];
|
||||||
startTime: Date;
|
startTime: Date;
|
||||||
endTime?: Date;
|
endTime?: Date;
|
||||||
duration?: number; // Duration in minutes
|
duration?: number; // Duration in minutes
|
||||||
totalVolume?: number; // Total weight × reps across all exercises
|
totalVolume?: number; // Total weight × reps across all exercises
|
||||||
totalDistance?: number; // Total distance across all cardio exercises
|
totalDistance?: number; // Total distance across all cardio exercises
|
||||||
|
gpsTrack?: IGpsPoint[]; // Top-level GPS track for GPS-only workouts
|
||||||
|
gpsPreview?: number[][]; // Downsampled [[lat,lng], ...] for card preview
|
||||||
prs?: IPr[];
|
prs?: IPr[];
|
||||||
notes?: string;
|
notes?: string;
|
||||||
createdBy: string; // username/nickname of the person who performed the workout
|
createdBy: string; // username/nickname of the person who performed the workout
|
||||||
@@ -155,15 +159,18 @@ const WorkoutSessionSchema = new mongoose.Schema(
|
|||||||
trim: true,
|
trim: true,
|
||||||
maxlength: 100
|
maxlength: 100
|
||||||
},
|
},
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
enum: ['manual', 'gps'],
|
||||||
|
default: 'manual'
|
||||||
|
},
|
||||||
|
activityType: {
|
||||||
|
type: String,
|
||||||
|
enum: ['running', 'walking', 'cycling', 'hiking']
|
||||||
|
},
|
||||||
exercises: {
|
exercises: {
|
||||||
type: [CompletedExerciseSchema],
|
type: [CompletedExerciseSchema],
|
||||||
required: true,
|
default: []
|
||||||
validate: {
|
|
||||||
validator: function(exercises: ICompletedExercise[]) {
|
|
||||||
return exercises.length > 0;
|
|
||||||
},
|
|
||||||
message: 'A workout session must have at least one exercise'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
startTime: {
|
startTime: {
|
||||||
type: Date,
|
type: Date,
|
||||||
@@ -185,6 +192,14 @@ const WorkoutSessionSchema = new mongoose.Schema(
|
|||||||
type: Number,
|
type: Number,
|
||||||
min: 0
|
min: 0
|
||||||
},
|
},
|
||||||
|
gpsTrack: {
|
||||||
|
type: [GpsPointSchema],
|
||||||
|
default: undefined
|
||||||
|
},
|
||||||
|
gpsPreview: {
|
||||||
|
type: [[Number]],
|
||||||
|
default: undefined
|
||||||
|
},
|
||||||
prs: [{
|
prs: [{
|
||||||
exerciseId: { type: String, required: true },
|
exerciseId: { type: String, required: true },
|
||||||
type: { type: String, required: true },
|
type: { type: String, required: true },
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { IPr } from '$models/WorkoutSession';
|
|||||||
import { WorkoutTemplate } from '$models/WorkoutTemplate';
|
import { WorkoutTemplate } from '$models/WorkoutTemplate';
|
||||||
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
|
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
|
||||||
import { detectCardioPrs } from '$lib/data/cardioPrRanges';
|
import { detectCardioPrs } from '$lib/data/cardioPrRanges';
|
||||||
|
import { simplifyTrack } from '$lib/server/simplifyTrack';
|
||||||
|
|
||||||
function estimatedOneRepMax(weight: number, reps: number): number {
|
function estimatedOneRepMax(weight: number, reps: number): number {
|
||||||
if (reps <= 0 || weight <= 0) return 0;
|
if (reps <= 0 || weight <= 0) return 0;
|
||||||
@@ -27,7 +28,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
const offset = parseInt(url.searchParams.get('offset') || '0');
|
const offset = parseInt(url.searchParams.get('offset') || '0');
|
||||||
|
|
||||||
const sessions = await WorkoutSession.find({ createdBy: session.user.nickname })
|
const sessions = await WorkoutSession.find({ createdBy: session.user.nickname })
|
||||||
.select('-exercises.gpsTrack')
|
.select('-exercises.gpsTrack -gpsTrack')
|
||||||
.sort({ startTime: -1 })
|
.sort({ startTime: -1 })
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.skip(offset);
|
.skip(offset);
|
||||||
@@ -52,10 +53,10 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
|||||||
await dbConnect();
|
await dbConnect();
|
||||||
|
|
||||||
const data = await request.json();
|
const data = await request.json();
|
||||||
const { templateId, name, exercises, startTime, endTime, notes } = data;
|
const { templateId, name, mode, activityType, exercises, startTime, endTime, notes, gpsTrack, totalDistance: gpsDistance } = data;
|
||||||
|
|
||||||
if (!name || !exercises || !Array.isArray(exercises) || exercises.length === 0) {
|
if (!name || (!exercises?.length && !gpsTrack?.length)) {
|
||||||
return json({ error: 'Name and at least one exercise are required' }, { status: 400 });
|
return json({ error: 'Name and at least one exercise or GPS track required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let templateName;
|
let templateName;
|
||||||
@@ -68,8 +69,8 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
|||||||
|
|
||||||
// Compute totalVolume and totalDistance
|
// Compute totalVolume and totalDistance
|
||||||
let totalVolume = 0;
|
let totalVolume = 0;
|
||||||
let totalDistance = 0;
|
let totalDistance = gpsDistance ?? 0;
|
||||||
for (const ex of exercises) {
|
for (const ex of (exercises ?? [])) {
|
||||||
const exercise = getExerciseById(ex.exerciseId);
|
const exercise = getExerciseById(ex.exerciseId);
|
||||||
const metrics = getExerciseMetrics(exercise);
|
const metrics = getExerciseMetrics(exercise);
|
||||||
const isCardio = metrics.includes('distance');
|
const isCardio = metrics.includes('distance');
|
||||||
@@ -86,7 +87,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
|||||||
|
|
||||||
// Detect PRs by comparing against previous best for each exercise
|
// Detect PRs by comparing against previous best for each exercise
|
||||||
const prs: IPr[] = [];
|
const prs: IPr[] = [];
|
||||||
for (const ex of exercises) {
|
for (const ex of (exercises ?? [])) {
|
||||||
const exercise = getExerciseById(ex.exerciseId);
|
const exercise = getExerciseById(ex.exerciseId);
|
||||||
const metrics = getExerciseMetrics(exercise);
|
const metrics = getExerciseMetrics(exercise);
|
||||||
const isCardio = metrics.includes('distance');
|
const isCardio = metrics.includes('distance');
|
||||||
@@ -143,16 +144,31 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate GPS preview for top-level GPS track
|
||||||
|
const gpsPreview = gpsTrack?.length >= 2 ? simplifyTrack(gpsTrack) : undefined;
|
||||||
|
|
||||||
|
// Generate gpsPreview for exercise-level GPS tracks
|
||||||
|
const processedExercises = (exercises ?? []).map((ex: any) => {
|
||||||
|
if (ex.gpsTrack?.length >= 2 && !ex.gpsPreview) {
|
||||||
|
return { ...ex, gpsPreview: simplifyTrack(ex.gpsTrack) };
|
||||||
|
}
|
||||||
|
return ex;
|
||||||
|
});
|
||||||
|
|
||||||
const workoutSession = new WorkoutSession({
|
const workoutSession = new WorkoutSession({
|
||||||
templateId,
|
templateId,
|
||||||
templateName,
|
templateName,
|
||||||
name,
|
name,
|
||||||
exercises,
|
mode: mode ?? (gpsTrack?.length ? 'gps' : 'manual'),
|
||||||
|
activityType: activityType ?? undefined,
|
||||||
|
exercises: processedExercises,
|
||||||
startTime: startTime ? new Date(startTime) : new Date(),
|
startTime: startTime ? new Date(startTime) : new Date(),
|
||||||
endTime: endTime ? new Date(endTime) : undefined,
|
endTime: endTime ? new Date(endTime) : undefined,
|
||||||
duration: endTime && startTime ? Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / (1000 * 60)) : undefined,
|
duration: endTime && startTime ? Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / (1000 * 60)) : undefined,
|
||||||
totalVolume: totalVolume > 0 ? totalVolume : undefined,
|
totalVolume: totalVolume > 0 ? totalVolume : undefined,
|
||||||
totalDistance: totalDistance > 0 ? totalDistance : undefined,
|
totalDistance: totalDistance > 0 ? totalDistance : undefined,
|
||||||
|
gpsTrack: gpsTrack?.length ? gpsTrack : undefined,
|
||||||
|
gpsPreview,
|
||||||
prs: prs.length > 0 ? prs : undefined,
|
prs: prs.length > 0 ? prs : undefined,
|
||||||
notes,
|
notes,
|
||||||
createdBy: session.user.nickname
|
createdBy: session.user.nickname
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
|
|||||||
try {
|
try {
|
||||||
await dbConnect();
|
await dbConnect();
|
||||||
const data = await request.json();
|
const data = await request.json();
|
||||||
const { name, templateId, exercises, paused, elapsed, savedAt, expectedVersion, restStartedAt, restTotal, restExerciseIdx, restSetIdx } = data;
|
const { name, mode, activityType, templateId, exercises, paused, elapsed, savedAt, expectedVersion, restStartedAt, restTotal, restExerciseIdx, restSetIdx } = data;
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return json({ error: 'Name is required' }, { status: 400 });
|
return json({ error: 'Name is required' }, { status: 400 });
|
||||||
@@ -58,6 +58,8 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
|
|||||||
{
|
{
|
||||||
$set: {
|
$set: {
|
||||||
name,
|
name,
|
||||||
|
mode: mode ?? 'manual',
|
||||||
|
activityType: activityType ?? null,
|
||||||
templateId: templateId ?? null,
|
templateId: templateId ?? null,
|
||||||
exercises: exercises ?? [],
|
exercises: exercises ?? [],
|
||||||
paused: paused ?? false,
|
paused: paused ?? false,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { Plus, Trash2, Play, Pencil, X, Save, CalendarClock, ChevronUp, ChevronDown, ArrowRight } from 'lucide-svelte';
|
import { Plus, Trash2, Play, Pencil, X, Save, CalendarClock, ChevronUp, ChevronDown, ArrowRight, MapPin, Dumbbell } from 'lucide-svelte';
|
||||||
import { getWorkout } from '$lib/js/workout.svelte';
|
import { getWorkout } from '$lib/js/workout.svelte';
|
||||||
import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
|
import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
|
||||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||||
@@ -48,8 +48,10 @@
|
|||||||
/** @type {any} */
|
/** @type {any} */
|
||||||
let nextTemplate = $derived(nextTemplateId ? templates.find((t) => t._id === nextTemplateId) : null);
|
let nextTemplate = $derived(nextTemplateId ? templates.find((t) => t._id === nextTemplateId) : null);
|
||||||
let hasSchedule = $derived(scheduleOrder.length > 0);
|
let hasSchedule = $derived(scheduleOrder.length > 0);
|
||||||
|
let isApp = $state(false);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
isApp = '__TAURI__' in window;
|
||||||
workout.restore();
|
workout.restore();
|
||||||
|
|
||||||
// If there's an active workout, redirect to the active page
|
// If there's an active workout, redirect to the active page
|
||||||
@@ -93,6 +95,12 @@
|
|||||||
goto(`/fitness/${sl.workout}/${sl.active}`);
|
goto(`/fitness/${sl.workout}/${sl.active}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startGps() {
|
||||||
|
workout.startGpsWorkout('running');
|
||||||
|
await sync.onWorkoutStart();
|
||||||
|
goto(`/fitness/${sl.workout}/${sl.active}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function startNextScheduled() {
|
async function startNextScheduled() {
|
||||||
if (!nextTemplate) return;
|
if (!nextTemplate) return;
|
||||||
await startFromTemplate(nextTemplate);
|
await startFromTemplate(nextTemplate);
|
||||||
@@ -333,9 +341,18 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section class="quick-start">
|
<section class="quick-start">
|
||||||
<button class="start-empty-btn" onclick={startEmpty}>
|
<div class="quick-start-row">
|
||||||
{t('start_empty_workout', lang)}
|
{#if isApp}
|
||||||
|
<button class="start-choice-btn" onclick={startGps}>
|
||||||
|
<MapPin size={18} />
|
||||||
|
<span>GPS Workout</span>
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button class="start-choice-btn" onclick={startEmpty}>
|
||||||
|
{#if isApp}<Dumbbell size={18} />{/if}
|
||||||
|
<span>{t('start_empty_workout', lang)}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="templates-section">
|
<section class="templates-section">
|
||||||
@@ -638,19 +655,27 @@
|
|||||||
.quick-start {
|
.quick-start {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.start-empty-btn {
|
.quick-start-row {
|
||||||
width: 100%;
|
display: flex;
|
||||||
padding: 0.9rem;
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.start-choice-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 1rem 0.5rem;
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: var(--primary-contrast);
|
color: var(--primary-contrast);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 0.9rem;
|
font-size: 0.85rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
.start-empty-btn:hover {
|
.start-choice-btn:hover {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
.templates-header {
|
.templates-header {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { Trash2, Play, Pause, Trophy, Clock, Dumbbell, Route, RefreshCw, Check, ChevronUp, ChevronDown, Flame, MapPin, Volume2 } from 'lucide-svelte';
|
import { Trash2, Play, Pause, Trophy, Clock, Dumbbell, Route, RefreshCw, Check, ChevronUp, ChevronDown, Flame, MapPin, Volume2, X } from 'lucide-svelte';
|
||||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||||
|
|
||||||
const lang = $derived(detectFitnessLang($page.url.pathname));
|
const lang = $derived(detectFitnessLang($page.url.pathname));
|
||||||
@@ -49,6 +49,30 @@
|
|||||||
let vgLanguage = $state('en');
|
let vgLanguage = $state('en');
|
||||||
let vgShowPanel = $state(false);
|
let vgShowPanel = $state(false);
|
||||||
|
|
||||||
|
// GPS workout mode state — if we're restoring a GPS workout that was already tracking, it's started
|
||||||
|
let gpsStarted = $state(gps.isTracking && workout.mode === 'gps' && !workout.paused);
|
||||||
|
let gpsStarting = $state(false);
|
||||||
|
|
||||||
|
// Activity type for GPS workouts
|
||||||
|
/** @type {import('$lib/js/workout.svelte').GpsActivityType} */
|
||||||
|
let selectedActivity = $state(workout.activityType ?? 'running');
|
||||||
|
let showActivityPicker = $state(false);
|
||||||
|
let showAudioPanel = $state(false);
|
||||||
|
|
||||||
|
const GPS_ACTIVITIES = [
|
||||||
|
{ id: 'running', label: 'Running', icon: '🏃' },
|
||||||
|
{ id: 'walking', label: 'Walking', icon: '🚶' },
|
||||||
|
{ id: 'cycling', label: 'Cycling', icon: '🚴' },
|
||||||
|
{ id: 'hiking', label: 'Hiking', icon: '🥾' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function selectActivity(/** @type {string} */ id) {
|
||||||
|
selectedActivity = /** @type {import('$lib/js/workout.svelte').GpsActivityType} */ (id);
|
||||||
|
const labels = { running: 'Running', walking: 'Walking', cycling: 'Cycling', hiking: 'Hiking' };
|
||||||
|
workout.name = labels[selectedActivity] ?? 'GPS Workout';
|
||||||
|
showActivityPicker = false;
|
||||||
|
}
|
||||||
|
|
||||||
const availableMetrics = [
|
const availableMetrics = [
|
||||||
{ id: 'totalTime', label: 'Total Time' },
|
{ id: 'totalTime', label: 'Total Time' },
|
||||||
{ id: 'totalDistance', label: 'Total Distance' },
|
{ id: 'totalDistance', label: 'Total Distance' },
|
||||||
@@ -125,6 +149,22 @@
|
|||||||
liveMarker.setLatLng(pts[pts.length - 1]);
|
liveMarker.setLatLng(pts[pts.length - 1]);
|
||||||
liveMap.setView(pts[pts.length - 1], 16);
|
liveMap.setView(pts[pts.length - 1], 16);
|
||||||
prevTrackLen = gps.track.length;
|
prevTrackLen = gps.track.length;
|
||||||
|
} else {
|
||||||
|
// No track yet — show fallback until GPS kicks in
|
||||||
|
liveMap.setView([51.5, 10], 16);
|
||||||
|
if ('geolocation' in navigator) {
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
if (liveMap) {
|
||||||
|
const ll = [pos.coords.latitude, pos.coords.longitude];
|
||||||
|
liveMap.setView(ll, 16);
|
||||||
|
liveMarker.setLatLng(ll);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
{ enableHighAccuracy: true, timeout: 10000 }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,11 +207,14 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const len = gps.track.length;
|
const len = gps.track.length;
|
||||||
if (len > prevTrackLen && liveMap && gps.latestPoint) {
|
if (len > prevTrackLen && liveMap && gps.latestPoint) {
|
||||||
// Add all new points since last update (native polling delivers batches)
|
if (gpsStarted) {
|
||||||
|
// Only draw the trail once the workout has actually started
|
||||||
for (let i = prevTrackLen; i < len; i++) {
|
for (let i = prevTrackLen; i < len; i++) {
|
||||||
const p = gps.track[i];
|
const p = gps.track[i];
|
||||||
livePolyline.addLatLng([p.lat, p.lng]);
|
livePolyline.addLatLng([p.lat, p.lng]);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Always update the position marker
|
||||||
const pt = [gps.latestPoint.lat, gps.latestPoint.lng];
|
const pt = [gps.latestPoint.lat, gps.latestPoint.lng];
|
||||||
liveMarker.setLatLng(pt);
|
liveMarker.setLatLng(pt);
|
||||||
const zoom = liveMap.getZoom() || 16;
|
const zoom = liveMap.getZoom() || 16;
|
||||||
@@ -188,9 +231,19 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _prestartGps = false;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!workout.active && !completionData) {
|
if (!workout.active && !completionData) {
|
||||||
goto(`/fitness/${sl.workout}`);
|
goto(`/fitness/${sl.workout}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For GPS workouts in pre-start: start GPS immediately so the map
|
||||||
|
// shows the user's position while they configure activity/audio.
|
||||||
|
if (workout.mode === 'gps' && !gpsStarted && !gps.isTracking) {
|
||||||
|
_prestartGps = true;
|
||||||
|
gps.start();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -223,19 +276,71 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startGpsWorkout() {
|
||||||
|
if (gpsStarting) return;
|
||||||
|
gpsStarting = true;
|
||||||
|
try {
|
||||||
|
if (_prestartGps && gps.isTracking) {
|
||||||
|
// GPS was running for pre-start preview — stop and restart
|
||||||
|
// so the native service resets time/distance to zero
|
||||||
|
await gps.stop();
|
||||||
|
gps.reset();
|
||||||
|
}
|
||||||
|
const started = await gps.start(getVoiceGuidanceConfig());
|
||||||
|
if (started) {
|
||||||
|
gpsStarted = true;
|
||||||
|
useGps = true;
|
||||||
|
workout.resumeTimer();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
gpsStarting = false;
|
||||||
|
_prestartGps = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map GPS activity types to exercise IDs */
|
||||||
|
const ACTIVITY_EXERCISE_MAP = /** @type {Record<string, string>} */ ({
|
||||||
|
running: 'running',
|
||||||
|
walking: 'walking',
|
||||||
|
cycling: 'cycling-outdoor',
|
||||||
|
hiking: 'hiking',
|
||||||
|
});
|
||||||
|
|
||||||
async function finishWorkout() {
|
async function finishWorkout() {
|
||||||
// Stop GPS tracking and collect track data
|
// Stop GPS tracking and collect track data
|
||||||
const gpsTrack = gps.isTracking ? await gps.stop() : [];
|
const gpsTrack = gps.isTracking ? await gps.stop() : [];
|
||||||
|
const wasGpsMode = workout.mode === 'gps';
|
||||||
|
const actType = workout.activityType;
|
||||||
|
|
||||||
const sessionData = workout.finish();
|
const sessionData = workout.finish();
|
||||||
if (sessionData.exercises.length === 0) {
|
|
||||||
|
if (wasGpsMode && gpsTrack.length >= 2) {
|
||||||
|
// GPS workout: create a cardio exercise entry with the track attached,
|
||||||
|
// just like a manually-added workout with GPX upload
|
||||||
|
const filteredDistance = trackDistance(gpsTrack);
|
||||||
|
const durationMin = (gpsTrack[gpsTrack.length - 1].timestamp - gpsTrack[0].timestamp) / 60000;
|
||||||
|
const exerciseId = ACTIVITY_EXERCISE_MAP[actType ?? 'running'] ?? 'running';
|
||||||
|
const exerciseName = getExerciseById(exerciseId)?.name ?? exerciseId;
|
||||||
|
|
||||||
|
sessionData.exercises = [{
|
||||||
|
exerciseId,
|
||||||
|
name: exerciseName,
|
||||||
|
sets: [{
|
||||||
|
distance: filteredDistance,
|
||||||
|
duration: Math.round(durationMin * 100) / 100,
|
||||||
|
completed: true,
|
||||||
|
}],
|
||||||
|
gpsTrack,
|
||||||
|
totalDistance: filteredDistance,
|
||||||
|
}];
|
||||||
|
} else if (wasGpsMode && gpsTrack.length === 0) {
|
||||||
|
// GPS workout with no track data — nothing to save
|
||||||
gps.reset();
|
gps.reset();
|
||||||
await sync.onWorkoutEnd();
|
await sync.onWorkoutEnd();
|
||||||
await goto(`/fitness/${sl.workout}`);
|
await goto(`/fitness/${sl.workout}`);
|
||||||
return;
|
return;
|
||||||
}
|
} else {
|
||||||
|
// Manual workout: attach GPS to cardio exercises
|
||||||
// Only save GPS points recorded while the workout timer was running
|
|
||||||
const workoutStart = new Date(sessionData.startTime).getTime();
|
const workoutStart = new Date(sessionData.startTime).getTime();
|
||||||
const filteredTrack = gpsTrack.filter((/** @type {any} */ p) => p.timestamp >= workoutStart);
|
const filteredTrack = gpsTrack.filter((/** @type {any} */ p) => p.timestamp >= workoutStart);
|
||||||
const filteredDistance = trackDistance(filteredTrack);
|
const filteredDistance = trackDistance(filteredTrack);
|
||||||
@@ -249,6 +354,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
gps.reset();
|
gps.reset();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -286,7 +392,7 @@
|
|||||||
const durationMin = Math.round((endTime.getTime() - startTime.getTime()) / 60000);
|
const durationMin = Math.round((endTime.getTime() - startTime.getTime()) / 60000);
|
||||||
|
|
||||||
let totalTonnage = 0;
|
let totalTonnage = 0;
|
||||||
let totalDistance = 0;
|
let totalDistance = local.totalDistance ?? 0;
|
||||||
/** @type {any[]} */
|
/** @type {any[]} */
|
||||||
const prs = [];
|
const prs = [];
|
||||||
|
|
||||||
@@ -750,6 +856,155 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{:else if workout.active && workout.mode === 'gps'}
|
||||||
|
<div class="gps-workout">
|
||||||
|
<div class="gps-workout-map" use:mountMap></div>
|
||||||
|
|
||||||
|
<!-- Overlay: sits on top of the map at the bottom -->
|
||||||
|
<div class="gps-overlay">
|
||||||
|
{#if gpsStarted}
|
||||||
|
<div class="gps-workout-stats">
|
||||||
|
<div class="gps-stat">
|
||||||
|
<span class="gps-stat-value">{gps.distance.toFixed(2)}</span>
|
||||||
|
<span class="gps-stat-unit">km</span>
|
||||||
|
</div>
|
||||||
|
<div class="gps-stat">
|
||||||
|
<span class="gps-stat-value">{formatElapsed(workout.elapsedSeconds)}</span>
|
||||||
|
<span class="gps-stat-unit">time</span>
|
||||||
|
</div>
|
||||||
|
{#if gps.currentPace > 0}
|
||||||
|
<div class="gps-stat">
|
||||||
|
<span class="gps-stat-value">{Math.floor(gps.currentPace)}:{Math.round((gps.currentPace % 1) * 60).toString().padStart(2, '0')}</span>
|
||||||
|
<span class="gps-stat-unit">/km</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if vgEnabled}
|
||||||
|
<div class="vg-active-badge">
|
||||||
|
<Volume2 size={12} />
|
||||||
|
<span>Voice: every {vgTriggerValue} {vgTriggerType === 'distance' ? 'km' : 'min'}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="gps-overlay-actions">
|
||||||
|
<button class="gps-overlay-pause" onclick={() => workout.paused ? workout.resumeTimer() : workout.pauseTimer()} aria-label={workout.paused ? 'Resume' : 'Pause'}>
|
||||||
|
{#if workout.paused}<Play size={22} />{:else}<Pause size={22} />{/if}
|
||||||
|
</button>
|
||||||
|
{#if workout.paused}
|
||||||
|
<button class="gps-overlay-cancel" onclick={async () => { if (gps.isTracking) await gps.stop(); gps.reset(); workout.cancel(); await sync.onWorkoutEnd(); await goto(`/fitness/${sl.workout}`); }}>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button class="gps-overlay-finish" onclick={finishWorkout}>Finish</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="gps-options-grid">
|
||||||
|
<button class="gps-option-tile" onclick={() => { showActivityPicker = !showActivityPicker; showAudioPanel = false; }} type="button">
|
||||||
|
<span class="gps-option-icon">{GPS_ACTIVITIES.find(a => a.id === selectedActivity)?.icon ?? '🏃'}</span>
|
||||||
|
<span class="gps-option-label">Activity</span>
|
||||||
|
<span class="gps-option-value">{GPS_ACTIVITIES.find(a => a.id === selectedActivity)?.label ?? 'Running'}</span>
|
||||||
|
</button>
|
||||||
|
<button class="gps-option-tile" onclick={() => { showAudioPanel = !showAudioPanel; showActivityPicker = false; }} type="button">
|
||||||
|
<Volume2 size={20} />
|
||||||
|
<span class="gps-option-label">Audio Stats</span>
|
||||||
|
<span class="gps-option-value">{vgEnabled ? `Every ${vgTriggerValue} ${vgTriggerType === 'distance' ? 'km' : 'min'}` : 'Off'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showActivityPicker}
|
||||||
|
<div class="gps-activity-picker">
|
||||||
|
{#each GPS_ACTIVITIES as act (act.id)}
|
||||||
|
<button
|
||||||
|
class="gps-activity-choice"
|
||||||
|
class:active={selectedActivity === act.id}
|
||||||
|
onclick={() => selectActivity(act.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="gps-activity-icon">{act.icon}</span>
|
||||||
|
<span>{act.label}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showAudioPanel}
|
||||||
|
<div class="vg-panel">
|
||||||
|
{#if !gps.hasTtsEngine()}
|
||||||
|
<div class="vg-no-engine">
|
||||||
|
<span>No text-to-speech engine installed.</span>
|
||||||
|
<button class="vg-install-btn" onclick={() => gps.installTtsEngine()} type="button">
|
||||||
|
Install TTS Engine
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<label class="vg-row">
|
||||||
|
<input type="checkbox" bind:checked={vgEnabled} />
|
||||||
|
<span>Enable voice announcements</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if vgEnabled}
|
||||||
|
<div class="vg-group">
|
||||||
|
<span class="vg-label">Announce every</span>
|
||||||
|
<div class="vg-trigger-row">
|
||||||
|
<input
|
||||||
|
class="vg-number"
|
||||||
|
type="number"
|
||||||
|
min="0.1"
|
||||||
|
step="0.5"
|
||||||
|
bind:value={vgTriggerValue}
|
||||||
|
/>
|
||||||
|
<select class="vg-select" bind:value={vgTriggerType}>
|
||||||
|
<option value="distance">km</option>
|
||||||
|
<option value="time">min</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vg-group">
|
||||||
|
<span class="vg-label">Metrics</span>
|
||||||
|
<div class="vg-metrics">
|
||||||
|
{#each availableMetrics as m (m.id)}
|
||||||
|
<button
|
||||||
|
class="vg-metric-chip"
|
||||||
|
class:selected={vgMetrics.includes(m.id)}
|
||||||
|
onclick={() => toggleMetric(m.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vg-group">
|
||||||
|
<span class="vg-label">Language</span>
|
||||||
|
<select class="vg-select" bind:value={vgLanguage}>
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="de">Deutsch</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button class="gps-start-btn" onclick={startGpsWorkout} disabled={gpsStarting}>
|
||||||
|
{#if gpsStarting}
|
||||||
|
<span class="gps-spinner"></span> Initializing GPS…
|
||||||
|
{:else}
|
||||||
|
Start
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="gps-cancel-link" onclick={async () => { if (gps.isTracking) await gps.stop(); gps.reset(); workout.cancel(); await sync.onWorkoutEnd(); await goto(`/fitness/${sl.workout}`); }} type="button">
|
||||||
|
<X size={14} />
|
||||||
|
{t('cancel_workout', lang)}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{:else if workout.active}
|
{:else if workout.active}
|
||||||
<div class="active-workout">
|
<div class="active-workout">
|
||||||
<input
|
<input
|
||||||
@@ -1543,4 +1798,267 @@
|
|||||||
color: var(--nord14);
|
color: var(--nord14);
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
.gps-overlay .vg-active-badge {
|
||||||
|
color: var(--nord7);
|
||||||
|
}
|
||||||
|
.gps-overlay .vg-panel {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255,255,255,0.15);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.6rem;
|
||||||
|
border-top: none;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.gps-overlay .vg-label {
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
}
|
||||||
|
.gps-overlay .vg-row {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.gps-overlay .vg-number,
|
||||||
|
.gps-overlay .vg-select {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border-color: rgba(255,255,255,0.2);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.gps-overlay .vg-metric-chip {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border-color: rgba(255,255,255,0.2);
|
||||||
|
color: rgba(255,255,255,0.7);
|
||||||
|
}
|
||||||
|
.gps-overlay .vg-metric-chip.selected {
|
||||||
|
background: var(--nord14);
|
||||||
|
color: var(--nord0);
|
||||||
|
border-color: var(--nord14);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GPS Workout Mode — full-bleed map with overlay */
|
||||||
|
.gps-workout {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
.gps-workout-map {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
/* Dark gradient at top so status bar text stays readable */
|
||||||
|
.gps-workout::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: calc(env(safe-area-inset-top, 0px) + 3rem + 24px);
|
||||||
|
background: linear-gradient(to bottom, rgba(0,0,0,0.45), transparent);
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
:global(.gps-workout-map .leaflet-control-container) {
|
||||||
|
/* push leaflet's own controls above our overlay */
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
.gps-overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));
|
||||||
|
background: linear-gradient(to top, rgba(0,0,0,0.7) 60%, transparent);
|
||||||
|
color: #fff;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.gps-overlay > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.gps-workout-stats {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.gps-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.gps-stat-value {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
.gps-stat-unit {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: rgba(255,255,255,0.75);
|
||||||
|
}
|
||||||
|
.gps-options-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.gps-option-tile {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.65rem 0.5rem;
|
||||||
|
background: rgba(255,255,255,0.12);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
color: #fff;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.gps-option-tile:hover {
|
||||||
|
border-color: rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
.gps-option-icon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
.gps-option-label {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
}
|
||||||
|
.gps-option-value {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.gps-activity-picker {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.gps-activity-choice {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.gps-activity-choice.active {
|
||||||
|
border-color: var(--nord8);
|
||||||
|
background: rgba(136,192,208,0.25);
|
||||||
|
color: var(--nord8);
|
||||||
|
}
|
||||||
|
.gps-activity-choice:hover:not(.active) {
|
||||||
|
border-color: rgba(255,255,255,0.4);
|
||||||
|
}
|
||||||
|
.gps-activity-icon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
.gps-start-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--primary-contrast);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.gps-start-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.gps-cancel-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(255,255,255,0.5);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
.gps-cancel-link:hover {
|
||||||
|
color: var(--nord11);
|
||||||
|
}
|
||||||
|
.gps-overlay-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.gps-overlay-pause {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255,255,255,0.25);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.gps-overlay-pause:hover {
|
||||||
|
background: rgba(255,255,255,0.25);
|
||||||
|
}
|
||||||
|
.gps-overlay-cancel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
background: rgba(191,97,106,0.25);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid var(--nord11);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--nord11);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.gps-overlay-cancel:hover {
|
||||||
|
background: rgba(191,97,106,0.4);
|
||||||
|
}
|
||||||
|
.gps-overlay-finish {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.85rem;
|
||||||
|
background: var(--nord11);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user