fitness: fix GPS preview aspect ratio, theme-reactive colors, and UI polish
All checks were successful
CI / update (push) Successful in 2m17s
All checks were successful
CI / update (push) Successful in 2m17s
- SessionCard SVG: cosine-corrected coordinates with proper aspect ratio (xMidYMid meet) - SessionCard: use --color-primary for track/distance/pace, add Gauge icon for pace - History detail: theme-reactive pace chart colors via MutationObserver + matchMedia - History detail: add Gauge icon, accent color for distance/pace stats, remove "avg" label - Move GPS remove button from info view to edit screen - Add Leaflet map preview to edit screen - Remove data points count from GPS indicators
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
|
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
|
||||||
import { Clock, Weight, Trophy } from 'lucide-svelte';
|
import { Clock, Weight, Trophy, Route, Gauge } from 'lucide-svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {{
|
* @type {{
|
||||||
@@ -10,9 +10,12 @@
|
|||||||
* startTime: string,
|
* startTime: string,
|
||||||
* duration?: number,
|
* duration?: number,
|
||||||
* totalVolume?: number,
|
* totalVolume?: number,
|
||||||
|
* totalDistance?: number,
|
||||||
* prs?: Array<any>,
|
* prs?: Array<any>,
|
||||||
* exercises: Array<{
|
* exercises: Array<{
|
||||||
* exerciseId: string,
|
* exerciseId: string,
|
||||||
|
* totalDistance?: number,
|
||||||
|
* gpsPreview?: number[][],
|
||||||
* sets: Array<{ reps?: number, weight?: number, rpe?: number, distance?: number, duration?: number }>
|
* sets: Array<{ reps?: number, weight?: number, rpe?: number, distance?: number, duration?: number }>
|
||||||
* }>
|
* }>
|
||||||
* }
|
* }
|
||||||
@@ -40,6 +43,13 @@
|
|||||||
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param {number} minPerKm */
|
||||||
|
function formatPace(minPerKm) {
|
||||||
|
const m = Math.floor(minPerKm);
|
||||||
|
const s = Math.round((minPerKm - m) * 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')} /km`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Array<Record<string, any>>} sets
|
* @param {Array<Record<string, any>>} sets
|
||||||
* @param {string} exerciseId
|
* @param {string} exerciseId
|
||||||
@@ -57,7 +67,6 @@
|
|||||||
const parts = [];
|
const parts = [];
|
||||||
if (best.distance) parts.push(`${best.distance} km`);
|
if (best.distance) parts.push(`${best.distance} km`);
|
||||||
if (best.duration) parts.push(`${best.duration} min`);
|
if (best.duration) parts.push(`${best.duration} min`);
|
||||||
if (best.rpe) parts.push(`@ ${best.rpe}`);
|
|
||||||
return parts.join(' · ') || null;
|
return parts.join(' · ') || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +80,62 @@
|
|||||||
if (best.rpe) label += ` @ ${best.rpe}`;
|
if (best.rpe) label += ` @ ${best.rpe}`;
|
||||||
return label;
|
return label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Find first GPS preview from cardio exercises */
|
||||||
|
const gpsPreview = $derived.by(() => {
|
||||||
|
for (const ex of session.exercises) {
|
||||||
|
if (ex.gpsPreview && ex.gpsPreview.length >= 2) return ex.gpsPreview;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Build SVG polyline with cosine-corrected coordinates */
|
||||||
|
const svgData = $derived.by(() => {
|
||||||
|
if (!gpsPreview) return null;
|
||||||
|
const lats = gpsPreview.map(p => p[0]);
|
||||||
|
const lngs = gpsPreview.map(p => p[1]);
|
||||||
|
const minLat = Math.min(...lats), maxLat = Math.max(...lats);
|
||||||
|
const minLng = Math.min(...lngs), maxLng = Math.max(...lngs);
|
||||||
|
const cosLat = Math.cos(((minLat + maxLat) / 2) * Math.PI / 180);
|
||||||
|
const geoW = (maxLng - minLng) * cosLat || 0.001;
|
||||||
|
const geoH = maxLat - minLat || 0.001;
|
||||||
|
const pad = Math.max(geoW, geoH) * 0.1;
|
||||||
|
const vbW = geoW + pad * 2;
|
||||||
|
const vbH = geoH + pad * 2;
|
||||||
|
const points = gpsPreview.map(p => {
|
||||||
|
const x = (p[1] - minLng) * cosLat + pad;
|
||||||
|
const y = (maxLat - p[0]) + pad;
|
||||||
|
return `${x},${y}`;
|
||||||
|
}).join(' ');
|
||||||
|
return { points, viewBox: `0 0 ${vbW} ${vbH}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Check if this session has any cardio exercise with GPS data */
|
||||||
|
const hasGpsCardio = $derived(session.exercises.some(ex => {
|
||||||
|
const exercise = getExerciseById(ex.exerciseId);
|
||||||
|
return exercise?.bodyPart === 'cardio' && ex.totalDistance;
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Get cardio summary: total distance and average pace */
|
||||||
|
const cardioSummary = $derived.by(() => {
|
||||||
|
let dist = 0;
|
||||||
|
let dur = 0;
|
||||||
|
for (const ex of session.exercises) {
|
||||||
|
const exercise = getExerciseById(ex.exerciseId);
|
||||||
|
if (exercise?.bodyPart !== 'cardio') continue;
|
||||||
|
if (ex.totalDistance) {
|
||||||
|
dist += ex.totalDistance;
|
||||||
|
}
|
||||||
|
for (const s of ex.sets) {
|
||||||
|
if (s.distance) dist += 0; // already counted via totalDistance
|
||||||
|
if (s.duration) dur += s.duration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Use session-level totalDistance if available
|
||||||
|
if (session.totalDistance) dist = session.totalDistance;
|
||||||
|
const pace = dist > 0 && dur > 0 ? dur / dist : 0;
|
||||||
|
return { distance: dist, duration: dur, pace };
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a href="/fitness/history/{session._id}" class="session-card">
|
<a href="/fitness/history/{session._id}" class="session-card">
|
||||||
@@ -79,8 +144,24 @@
|
|||||||
<span class="session-date">{formatDate(session.startTime)} · {formatTime(session.startTime)}</span>
|
<span class="session-date">{formatDate(session.startTime)} · {formatTime(session.startTime)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if svgData}
|
||||||
|
<div class="map-preview">
|
||||||
|
<svg viewBox={svgData.viewBox} preserveAspectRatio="xMidYMid meet">
|
||||||
|
<polyline
|
||||||
|
points={svgData.points}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--color-primary)"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-linecap="round"
|
||||||
|
vector-effect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="exercise-list">
|
<div class="exercise-list">
|
||||||
{#each session.exercises.slice(0, 4) as ex (ex.exerciseId)}
|
{#each session.exercises as ex (ex.exerciseId)}
|
||||||
{@const exercise = getExerciseById(ex.exerciseId)}
|
{@const exercise = getExerciseById(ex.exerciseId)}
|
||||||
{@const label = bestSetLabel(ex.sets, ex.exerciseId)}
|
{@const label = bestSetLabel(ex.sets, ex.exerciseId)}
|
||||||
<div class="exercise-row">
|
<div class="exercise-row">
|
||||||
@@ -90,17 +171,19 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if session.exercises.length > 4}
|
|
||||||
<div class="exercise-row more">+{session.exercises.length - 4} more exercises</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
{#if session.duration}
|
{#if session.duration}
|
||||||
<span class="stat"><Clock size={14} /> {formatDuration(session.duration)}</span>
|
<span class="stat"><Clock size={14} /> {formatDuration(session.duration)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if session.totalVolume}
|
{#if hasGpsCardio && cardioSummary.distance > 0}
|
||||||
<span class="stat"><Weight size={14} /> {Math.round(session.totalVolume).toLocaleString()} kg</span>
|
<span class="stat accent"><Route size={14} /> {cardioSummary.distance.toFixed(1)} km</span>
|
||||||
|
{#if cardioSummary.pace > 0}
|
||||||
|
<span class="stat accent"><Gauge size={14} /> {formatPace(cardioSummary.pace)}</span>
|
||||||
|
{/if}
|
||||||
|
{:else if session.totalVolume}
|
||||||
|
<span class="stat"><Weight size={14} /> {session.totalVolume >= 1000 ? `${(session.totalVolume / 1000).toFixed(1)}t` : `${Math.round(session.totalVolume).toLocaleString()} kg`}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if session.prs && session.prs.length > 0}
|
{#if session.prs && session.prs.length > 0}
|
||||||
<span class="stat pr"><Trophy size={14} /> {session.prs.length} PR{session.prs.length > 1 ? 's' : ''}</span>
|
<span class="stat pr"><Trophy size={14} /> {session.prs.length} PR{session.prs.length > 1 ? 's' : ''}</span>
|
||||||
@@ -139,6 +222,17 @@
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
.map-preview {
|
||||||
|
height: 80px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 6%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.map-preview svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
.exercise-list {
|
.exercise-list {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
margin-bottom: 0.6rem;
|
margin-bottom: 0.6rem;
|
||||||
@@ -155,10 +249,6 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
.more {
|
|
||||||
color: var(--color-primary);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.card-footer {
|
.card-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -166,12 +256,17 @@
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
padding-top: 0.5rem;
|
padding-top: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.stat {
|
.stat {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
.stat.accent {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
.stat.pr {
|
.stat.pr {
|
||||||
color: var(--nord13);
|
color: var(--nord13);
|
||||||
}
|
}
|
||||||
|
|||||||
103
src/lib/server/simplifyTrack.ts
Normal file
103
src/lib/server/simplifyTrack.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Ramer-Douglas-Peucker line simplification.
|
||||||
|
* Reduces a GPS track to a visually faithful preview with far fewer points.
|
||||||
|
* Preserves curves and corners, drops redundant points on straights.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Point {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Perpendicular distance from point P to line segment A-B (in degrees, good enough for simplification) */
|
||||||
|
function perpendicularDistance(p: Point, a: Point, b: Point): number {
|
||||||
|
const dx = b.lng - a.lng;
|
||||||
|
const dy = b.lat - a.lat;
|
||||||
|
const lenSq = dx * dx + dy * dy;
|
||||||
|
if (lenSq === 0) {
|
||||||
|
// A and B are the same point
|
||||||
|
const ex = p.lng - a.lng;
|
||||||
|
const ey = p.lat - a.lat;
|
||||||
|
return Math.sqrt(ex * ex + ey * ey);
|
||||||
|
}
|
||||||
|
const t = Math.max(0, Math.min(1, ((p.lng - a.lng) * dx + (p.lat - a.lat) * dy) / lenSq));
|
||||||
|
const projLng = a.lng + t * dx;
|
||||||
|
const projLat = a.lat + t * dy;
|
||||||
|
const ex = p.lng - projLng;
|
||||||
|
const ey = p.lat - projLat;
|
||||||
|
return Math.sqrt(ex * ex + ey * ey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rdpSimplify(points: Point[], epsilon: number): Point[] {
|
||||||
|
if (points.length <= 2) return points;
|
||||||
|
|
||||||
|
let maxDist = 0;
|
||||||
|
let maxIdx = 0;
|
||||||
|
const first = points[0];
|
||||||
|
const last = points[points.length - 1];
|
||||||
|
|
||||||
|
for (let i = 1; i < points.length - 1; i++) {
|
||||||
|
const d = perpendicularDistance(points[i], first, last);
|
||||||
|
if (d > maxDist) {
|
||||||
|
maxDist = d;
|
||||||
|
maxIdx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxDist > epsilon) {
|
||||||
|
const left = rdpSimplify(points.slice(0, maxIdx + 1), epsilon);
|
||||||
|
const right = rdpSimplify(points.slice(maxIdx), epsilon);
|
||||||
|
return [...left.slice(0, -1), ...right];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [first, last];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simplify a GPS track to a preview polyline.
|
||||||
|
* @param track - Full track with lat/lng fields
|
||||||
|
* @param maxPoints - Target maximum number of points (default 30)
|
||||||
|
* @returns Array of [lat, lng] pairs
|
||||||
|
*/
|
||||||
|
export function simplifyTrack(track: Array<{ lat: number; lng: number }>, maxPoints = 30): number[][] {
|
||||||
|
if (track.length <= maxPoints) {
|
||||||
|
return track.map(p => [p.lat, p.lng]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start with a generous epsilon, tighten until we're under maxPoints
|
||||||
|
// Estimate initial epsilon from bounding box
|
||||||
|
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
|
||||||
|
for (const p of track) {
|
||||||
|
if (p.lat < minLat) minLat = p.lat;
|
||||||
|
if (p.lat > maxLat) maxLat = p.lat;
|
||||||
|
if (p.lng < minLng) minLng = p.lng;
|
||||||
|
if (p.lng > maxLng) maxLng = p.lng;
|
||||||
|
}
|
||||||
|
const extent = Math.max(maxLat - minLat, maxLng - minLng);
|
||||||
|
|
||||||
|
// Binary search for the right epsilon
|
||||||
|
let lo = 0;
|
||||||
|
let hi = extent * 0.1;
|
||||||
|
let result = rdpSimplify(track, hi);
|
||||||
|
|
||||||
|
// If even max epsilon gives too many points, increase hi
|
||||||
|
while (result.length > maxPoints && hi < extent) {
|
||||||
|
hi *= 2;
|
||||||
|
result = rdpSimplify(track, hi);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binary search between lo and hi
|
||||||
|
for (let iter = 0; iter < 20; iter++) {
|
||||||
|
const mid = (lo + hi) / 2;
|
||||||
|
result = rdpSimplify(track, mid);
|
||||||
|
if (result.length > maxPoints) {
|
||||||
|
lo = mid;
|
||||||
|
} else {
|
||||||
|
hi = mid;
|
||||||
|
}
|
||||||
|
// Close enough
|
||||||
|
if (result.length >= maxPoints - 5 && result.length <= maxPoints) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.map(p => [p.lat, p.lng]);
|
||||||
|
}
|
||||||
@@ -25,9 +25,17 @@ export interface ICompletedExercise {
|
|||||||
restTime?: number;
|
restTime?: number;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
gpsTrack?: IGpsPoint[];
|
gpsTrack?: IGpsPoint[];
|
||||||
|
gpsPreview?: number[][]; // downsampled [[lat,lng], ...] for card preview
|
||||||
totalDistance?: number; // km
|
totalDistance?: number; // km
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IPr {
|
||||||
|
exerciseId: string;
|
||||||
|
type: string; // 'est1rm' | 'maxWeight' | 'repMax'
|
||||||
|
value: number;
|
||||||
|
reps?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IWorkoutSession {
|
export interface IWorkoutSession {
|
||||||
_id?: string;
|
_id?: string;
|
||||||
templateId?: string; // Reference to WorkoutTemplate if based on template
|
templateId?: string; // Reference to WorkoutTemplate if based on template
|
||||||
@@ -37,6 +45,9 @@ export interface IWorkoutSession {
|
|||||||
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
|
||||||
|
totalDistance?: number; // Total distance across all cardio exercises
|
||||||
|
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
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
@@ -118,6 +129,10 @@ const CompletedExerciseSchema = new mongoose.Schema({
|
|||||||
type: [GpsPointSchema],
|
type: [GpsPointSchema],
|
||||||
default: undefined
|
default: undefined
|
||||||
},
|
},
|
||||||
|
gpsPreview: {
|
||||||
|
type: [[Number]],
|
||||||
|
default: undefined
|
||||||
|
},
|
||||||
totalDistance: {
|
totalDistance: {
|
||||||
type: Number,
|
type: Number,
|
||||||
min: 0
|
min: 0
|
||||||
@@ -162,6 +177,21 @@ const WorkoutSessionSchema = new mongoose.Schema(
|
|||||||
type: Number, // in minutes
|
type: Number, // in minutes
|
||||||
min: 0
|
min: 0
|
||||||
},
|
},
|
||||||
|
totalVolume: {
|
||||||
|
type: Number,
|
||||||
|
min: 0
|
||||||
|
},
|
||||||
|
totalDistance: {
|
||||||
|
type: Number,
|
||||||
|
min: 0
|
||||||
|
},
|
||||||
|
prs: [{
|
||||||
|
exerciseId: { type: String, required: true },
|
||||||
|
type: { type: String, required: true },
|
||||||
|
value: { type: Number, required: true },
|
||||||
|
reps: Number,
|
||||||
|
_id: false
|
||||||
|
}],
|
||||||
notes: {
|
notes: {
|
||||||
type: String,
|
type: String,
|
||||||
trim: true,
|
trim: true,
|
||||||
|
|||||||
@@ -2,7 +2,15 @@ import { json } from '@sveltejs/kit';
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { dbConnect } from '$utils/db';
|
import { dbConnect } from '$utils/db';
|
||||||
import { WorkoutSession } from '$models/WorkoutSession';
|
import { WorkoutSession } from '$models/WorkoutSession';
|
||||||
|
import type { IPr } from '$models/WorkoutSession';
|
||||||
import { WorkoutTemplate } from '$models/WorkoutTemplate';
|
import { WorkoutTemplate } from '$models/WorkoutTemplate';
|
||||||
|
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
|
||||||
|
|
||||||
|
function estimatedOneRepMax(weight: number, reps: number): number {
|
||||||
|
if (reps <= 0 || weight <= 0) return 0;
|
||||||
|
if (reps === 1) return weight;
|
||||||
|
return Math.round(weight * (1 + reps / 30));
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/fitness/sessions - Get all workout sessions for the user
|
// GET /api/fitness/sessions - Get all workout sessions for the user
|
||||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||||
@@ -18,6 +26,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')
|
||||||
.sort({ startTime: -1 })
|
.sort({ startTime: -1 })
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.skip(offset);
|
.skip(offset);
|
||||||
@@ -56,6 +65,69 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compute totalVolume and totalDistance
|
||||||
|
let totalVolume = 0;
|
||||||
|
let totalDistance = 0;
|
||||||
|
for (const ex of exercises) {
|
||||||
|
const exercise = getExerciseById(ex.exerciseId);
|
||||||
|
const metrics = getExerciseMetrics(exercise);
|
||||||
|
const isCardio = metrics.includes('distance');
|
||||||
|
const isBilateral = exercise?.bilateral ?? false;
|
||||||
|
for (const s of (ex.sets ?? [])) {
|
||||||
|
if (!s.completed) continue;
|
||||||
|
if (isCardio) {
|
||||||
|
totalDistance += s.distance ?? 0;
|
||||||
|
} else {
|
||||||
|
totalVolume += (s.weight ?? 0) * (s.reps ?? 0) * (isBilateral ? 2 : 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect PRs by comparing against previous best for each exercise
|
||||||
|
const prs: IPr[] = [];
|
||||||
|
for (const ex of exercises) {
|
||||||
|
const exercise = getExerciseById(ex.exerciseId);
|
||||||
|
const metrics = getExerciseMetrics(exercise);
|
||||||
|
const isCardio = metrics.includes('distance');
|
||||||
|
if (isCardio) continue;
|
||||||
|
|
||||||
|
const completedSets = (ex.sets ?? []).filter((s: { completed: boolean }) => s.completed);
|
||||||
|
if (completedSets.length === 0) continue;
|
||||||
|
|
||||||
|
// Find previous best for this exercise
|
||||||
|
const prevSessions = await WorkoutSession.find({
|
||||||
|
createdBy: session.user!.nickname,
|
||||||
|
'exercises.exerciseId': ex.exerciseId,
|
||||||
|
}).sort({ startTime: -1 }).limit(50).lean();
|
||||||
|
|
||||||
|
let prevBestWeight = 0;
|
||||||
|
let prevBestEst1rm = 0;
|
||||||
|
for (const ps of prevSessions) {
|
||||||
|
const pe = ps.exercises.find((e) => e.exerciseId === ex.exerciseId);
|
||||||
|
if (!pe) continue;
|
||||||
|
for (const s of pe.sets) {
|
||||||
|
if (!s.completed || !s.weight || !s.reps) continue;
|
||||||
|
prevBestWeight = Math.max(prevBestWeight, s.weight);
|
||||||
|
prevBestEst1rm = Math.max(prevBestEst1rm, estimatedOneRepMax(s.weight, s.reps));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let bestWeight = 0;
|
||||||
|
let bestEst1rm = 0;
|
||||||
|
for (const s of completedSets) {
|
||||||
|
if (!s.weight || !s.reps) continue;
|
||||||
|
bestWeight = Math.max(bestWeight, s.weight);
|
||||||
|
bestEst1rm = Math.max(bestEst1rm, estimatedOneRepMax(s.weight, s.reps));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestWeight > prevBestWeight && prevBestWeight > 0) {
|
||||||
|
prs.push({ exerciseId: ex.exerciseId, type: 'maxWeight', value: bestWeight });
|
||||||
|
}
|
||||||
|
if (bestEst1rm > prevBestEst1rm && prevBestEst1rm > 0) {
|
||||||
|
prs.push({ exerciseId: ex.exerciseId, type: 'est1rm', value: bestEst1rm });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const workoutSession = new WorkoutSession({
|
const workoutSession = new WorkoutSession({
|
||||||
templateId,
|
templateId,
|
||||||
templateName,
|
templateName,
|
||||||
@@ -64,6 +136,9 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
|||||||
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,
|
||||||
|
totalDistance: totalDistance > 0 ? totalDistance : undefined,
|
||||||
|
prs: prs.length > 0 ? prs : undefined,
|
||||||
notes,
|
notes,
|
||||||
createdBy: session.user.nickname
|
createdBy: session.user.nickname
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { dbConnect } from '$utils/db';
|
import { dbConnect } from '$utils/db';
|
||||||
import { WorkoutSession } from '$models/WorkoutSession';
|
import { WorkoutSession } from '$models/WorkoutSession';
|
||||||
|
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
|
||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
|
|
||||||
// GET /api/fitness/sessions/[id] - Get a specific workout session
|
// GET /api/fitness/sessions/[id] - Get a specific workout session
|
||||||
@@ -57,7 +58,28 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
|||||||
|
|
||||||
const updateData: Record<string, unknown> = {};
|
const updateData: Record<string, unknown> = {};
|
||||||
if (name) updateData.name = name;
|
if (name) updateData.name = name;
|
||||||
if (exercises) updateData.exercises = exercises;
|
if (exercises) {
|
||||||
|
updateData.exercises = exercises;
|
||||||
|
// Recompute totalVolume
|
||||||
|
let totalVolume = 0;
|
||||||
|
let totalDistance = 0;
|
||||||
|
for (const ex of exercises) {
|
||||||
|
const exercise = getExerciseById(ex.exerciseId);
|
||||||
|
const metrics = getExerciseMetrics(exercise);
|
||||||
|
const isCardio = metrics.includes('distance');
|
||||||
|
const isBilateral = exercise?.bilateral ?? false;
|
||||||
|
for (const s of (ex.sets ?? [])) {
|
||||||
|
if (!s.completed) continue;
|
||||||
|
if (isCardio) {
|
||||||
|
totalDistance += s.distance ?? 0;
|
||||||
|
} else {
|
||||||
|
totalVolume += (s.weight ?? 0) * (s.reps ?? 0) * (isBilateral ? 2 : 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateData.totalVolume = totalVolume > 0 ? totalVolume : undefined;
|
||||||
|
updateData.totalDistance = totalDistance > 0 ? totalDistance : undefined;
|
||||||
|
}
|
||||||
if (startTime) updateData.startTime = new Date(startTime);
|
if (startTime) updateData.startTime = new Date(startTime);
|
||||||
if (endTime) updateData.endTime = new Date(endTime);
|
if (endTime) updateData.endTime = new Date(endTime);
|
||||||
if (duration !== undefined) updateData.duration = duration;
|
if (duration !== undefined) updateData.duration = duration;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
|
|||||||
import { dbConnect } from '$utils/db';
|
import { dbConnect } from '$utils/db';
|
||||||
import { WorkoutSession } from '$models/WorkoutSession';
|
import { WorkoutSession } from '$models/WorkoutSession';
|
||||||
import type { IGpsPoint } from '$models/WorkoutSession';
|
import type { IGpsPoint } from '$models/WorkoutSession';
|
||||||
|
import { simplifyTrack } from '$lib/server/simplifyTrack';
|
||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
|
|
||||||
/** Haversine distance in km between two points */
|
/** Haversine distance in km between two points */
|
||||||
@@ -103,6 +104,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
|||||||
const durationMin = Math.round((track[track.length - 1].timestamp - track[0].timestamp) / 60000);
|
const durationMin = Math.round((track[track.length - 1].timestamp - track[0].timestamp) / 60000);
|
||||||
|
|
||||||
workoutSession.exercises[exerciseIdx].gpsTrack = track;
|
workoutSession.exercises[exerciseIdx].gpsTrack = track;
|
||||||
|
workoutSession.exercises[exerciseIdx].gpsPreview = simplifyTrack(track);
|
||||||
workoutSession.exercises[exerciseIdx].totalDistance = Math.round(distance * 1000) / 1000;
|
workoutSession.exercises[exerciseIdx].totalDistance = Math.round(distance * 1000) / 1000;
|
||||||
|
|
||||||
// Auto-fill distance and duration on a single set
|
// Auto-fill distance and duration on a single set
|
||||||
@@ -154,6 +156,7 @@ export const DELETE: RequestHandler = async ({ params, request, locals }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
workoutSession.exercises[exerciseIdx].gpsTrack = undefined;
|
workoutSession.exercises[exerciseIdx].gpsTrack = undefined;
|
||||||
|
workoutSession.exercises[exerciseIdx].gpsPreview = undefined;
|
||||||
workoutSession.exercises[exerciseIdx].totalDistance = undefined;
|
workoutSession.exercises[exerciseIdx].totalDistance = undefined;
|
||||||
await workoutSession.save();
|
await workoutSession.save();
|
||||||
|
|
||||||
|
|||||||
121
src/routes/api/fitness/sessions/[id]/recalculate/+server.ts
Normal file
121
src/routes/api/fitness/sessions/[id]/recalculate/+server.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { dbConnect } from '$utils/db';
|
||||||
|
import { WorkoutSession } from '$models/WorkoutSession';
|
||||||
|
import type { IPr } from '$models/WorkoutSession';
|
||||||
|
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
|
||||||
|
import { simplifyTrack } from '$lib/server/simplifyTrack';
|
||||||
|
import mongoose from 'mongoose';
|
||||||
|
|
||||||
|
function estimatedOneRepMax(weight: number, reps: number): number {
|
||||||
|
if (reps <= 0 || weight <= 0) return 0;
|
||||||
|
if (reps === 1) return weight;
|
||||||
|
return Math.round(weight * (1 + reps / 30));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/fitness/sessions/[id]/recalculate — recompute derived fields
|
||||||
|
export const POST: RequestHandler = async ({ params, locals }) => {
|
||||||
|
const session = await locals.auth();
|
||||||
|
if (!session || !session.user?.nickname) {
|
||||||
|
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mongoose.Types.ObjectId.isValid(params.id)) {
|
||||||
|
return json({ error: 'Invalid session ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dbConnect();
|
||||||
|
|
||||||
|
const workoutSession = await WorkoutSession.findOne({
|
||||||
|
_id: params.id,
|
||||||
|
createdBy: session.user.nickname
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workoutSession) {
|
||||||
|
return json({ error: 'Session not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recompute totalVolume and totalDistance
|
||||||
|
let totalVolume = 0;
|
||||||
|
let totalDistance = 0;
|
||||||
|
for (const ex of workoutSession.exercises) {
|
||||||
|
const exercise = getExerciseById(ex.exerciseId);
|
||||||
|
const metrics = getExerciseMetrics(exercise);
|
||||||
|
const isCardio = metrics.includes('distance');
|
||||||
|
const isBilateral = exercise?.bilateral ?? false;
|
||||||
|
for (const s of ex.sets) {
|
||||||
|
if (!s.completed) continue;
|
||||||
|
if (isCardio) {
|
||||||
|
totalDistance += s.distance ?? 0;
|
||||||
|
} else {
|
||||||
|
totalVolume += (s.weight ?? 0) * (s.reps ?? 0) * (isBilateral ? 2 : 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regenerate gpsPreview from gpsTrack if present
|
||||||
|
if (ex.gpsTrack && ex.gpsTrack.length >= 2) {
|
||||||
|
ex.gpsPreview = simplifyTrack(ex.gpsTrack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect PRs
|
||||||
|
const prs: IPr[] = [];
|
||||||
|
for (const ex of workoutSession.exercises) {
|
||||||
|
const exercise = getExerciseById(ex.exerciseId);
|
||||||
|
const metrics = getExerciseMetrics(exercise);
|
||||||
|
if (metrics.includes('distance')) continue;
|
||||||
|
|
||||||
|
const completedSets = ex.sets.filter(s => s.completed);
|
||||||
|
if (completedSets.length === 0) continue;
|
||||||
|
|
||||||
|
// Find previous best (sessions before this one)
|
||||||
|
const prevSessions = await WorkoutSession.find({
|
||||||
|
createdBy: session.user!.nickname,
|
||||||
|
'exercises.exerciseId': ex.exerciseId,
|
||||||
|
startTime: { $lt: workoutSession.startTime }
|
||||||
|
}).sort({ startTime: -1 }).limit(50).lean();
|
||||||
|
|
||||||
|
let prevBestWeight = 0;
|
||||||
|
let prevBestEst1rm = 0;
|
||||||
|
for (const ps of prevSessions) {
|
||||||
|
const pe = ps.exercises.find(e => e.exerciseId === ex.exerciseId);
|
||||||
|
if (!pe) continue;
|
||||||
|
for (const s of pe.sets) {
|
||||||
|
if (!s.completed || !s.weight || !s.reps) continue;
|
||||||
|
prevBestWeight = Math.max(prevBestWeight, s.weight);
|
||||||
|
prevBestEst1rm = Math.max(prevBestEst1rm, estimatedOneRepMax(s.weight, s.reps));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let bestWeight = 0;
|
||||||
|
let bestEst1rm = 0;
|
||||||
|
for (const s of completedSets) {
|
||||||
|
if (!s.weight || !s.reps) continue;
|
||||||
|
bestWeight = Math.max(bestWeight, s.weight);
|
||||||
|
bestEst1rm = Math.max(bestEst1rm, estimatedOneRepMax(s.weight, s.reps));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestWeight > prevBestWeight && prevBestWeight > 0) {
|
||||||
|
prs.push({ exerciseId: ex.exerciseId, type: 'maxWeight', value: bestWeight });
|
||||||
|
}
|
||||||
|
if (bestEst1rm > prevBestEst1rm && prevBestEst1rm > 0) {
|
||||||
|
prs.push({ exerciseId: ex.exerciseId, type: 'est1rm', value: bestEst1rm });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
workoutSession.totalVolume = totalVolume > 0 ? totalVolume : undefined;
|
||||||
|
workoutSession.totalDistance = totalDistance > 0 ? totalDistance : undefined;
|
||||||
|
workoutSession.prs = prs.length > 0 ? prs : undefined;
|
||||||
|
await workoutSession.save();
|
||||||
|
|
||||||
|
return json({
|
||||||
|
totalVolume: workoutSession.totalVolume,
|
||||||
|
totalDistance: workoutSession.totalDistance,
|
||||||
|
prs: workoutSession.prs?.length ?? 0
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error recalculating session:', error);
|
||||||
|
return json({ error: 'Failed to recalculate' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { goto, invalidateAll } from '$app/navigation';
|
import { goto, invalidateAll } from '$app/navigation';
|
||||||
import { Clock, Weight, Trophy, Trash2, Pencil, Plus, Upload, Route, X } from 'lucide-svelte';
|
import { Clock, Weight, Trophy, Trash2, Pencil, Plus, Upload, Route, X, RefreshCw, Gauge } from 'lucide-svelte';
|
||||||
import { getExerciseById, getExerciseMetrics, METRIC_LABELS } from '$lib/data/exercises';
|
import { getExerciseById, getExerciseMetrics, METRIC_LABELS } from '$lib/data/exercises';
|
||||||
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
|
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
|
||||||
import SetTable from '$lib/components/fitness/SetTable.svelte';
|
import SetTable from '$lib/components/fitness/SetTable.svelte';
|
||||||
@@ -11,9 +11,29 @@
|
|||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
const session = $derived(data.session);
|
const session = $derived(data.session);
|
||||||
|
|
||||||
|
function checkDark() {
|
||||||
|
if (typeof document === 'undefined') return false;
|
||||||
|
const t = document.documentElement.dataset.theme;
|
||||||
|
if (t === 'dark') return true;
|
||||||
|
if (t === 'light') return false;
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dark = $state(checkDark());
|
||||||
|
onMount(() => {
|
||||||
|
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const onMql = () => { dark = checkDark(); };
|
||||||
|
mql.addEventListener('change', onMql);
|
||||||
|
const obs = new MutationObserver(() => { dark = checkDark(); });
|
||||||
|
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||||
|
return () => { mql.removeEventListener('change', onMql); obs.disconnect(); };
|
||||||
|
});
|
||||||
|
|
||||||
let deleting = $state(false);
|
let deleting = $state(false);
|
||||||
let editing = $state(false);
|
let editing = $state(false);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
let recalculating = $state(false);
|
||||||
let showPicker = $state(false);
|
let showPicker = $state(false);
|
||||||
|
|
||||||
/** @type {any} */
|
/** @type {any} */
|
||||||
@@ -157,6 +177,17 @@
|
|||||||
return Math.round(weight * (1 + reps / 30));
|
return Math.round(weight * (1 + reps / 30));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function recalculate() {
|
||||||
|
recalculating = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/fitness/sessions/${session._id}/recalculate`, { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
await invalidateAll();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
recalculating = false;
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteSession() {
|
async function deleteSession() {
|
||||||
if (!confirm('Delete this workout session?')) return;
|
if (!confirm('Delete this workout session?')) return;
|
||||||
deleting = true;
|
deleting = true;
|
||||||
@@ -238,7 +269,7 @@
|
|||||||
const map = L.map(node, { attributionControl: false, zoomControl: false });
|
const map = L.map(node, { attributionControl: false, zoomControl: false });
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
|
||||||
const pts = track.map((/** @type {any} */ p) => /** @type {[number, number]} */ ([p.lat, p.lng]));
|
const pts = track.map((/** @type {any} */ p) => /** @type {[number, number]} */ ([p.lat, p.lng]));
|
||||||
const polyline = L.polyline(pts, { color: '#88c0d0', weight: 3 }).addTo(map);
|
const polyline = L.polyline(pts, { color: '#5e81ac', weight: 3 }).addTo(map);
|
||||||
// Start/end markers
|
// Start/end markers
|
||||||
L.circleMarker(pts[0], { radius: 5, fillColor: '#a3be8c', fillOpacity: 1, color: '#fff', weight: 2 }).addTo(map);
|
L.circleMarker(pts[0], { radius: 5, fillColor: '#a3be8c', fillOpacity: 1, color: '#fff', weight: 2 }).addTo(map);
|
||||||
L.circleMarker(pts[pts.length - 1], { radius: 5, fillColor: '#bf616a', fillOpacity: 1, color: '#fff', weight: 2 }).addTo(map);
|
L.circleMarker(pts[pts.length - 1], { radius: 5, fillColor: '#bf616a', fillOpacity: 1, color: '#fff', weight: 2 }).addTo(map);
|
||||||
@@ -337,13 +368,15 @@
|
|||||||
// Downsample to ~50 points for readability
|
// Downsample to ~50 points for readability
|
||||||
const step = Math.max(1, Math.floor(samples.length / 50));
|
const step = Math.max(1, Math.floor(samples.length / 50));
|
||||||
const filtered = samples.filter((_, i) => i % step === 0 || i === samples.length - 1);
|
const filtered = samples.filter((_, i) => i % step === 0 || i === samples.length - 1);
|
||||||
|
const primary = dark ? '#88C0D0' : '#5E81AC';
|
||||||
|
const fill = dark ? 'rgba(136, 192, 208, 0.12)' : 'rgba(94, 129, 172, 0.12)';
|
||||||
return {
|
return {
|
||||||
labels: filtered.map(s => s.dist.toFixed(2)),
|
labels: filtered.map(s => s.dist.toFixed(2)),
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: 'Pace',
|
label: 'Pace',
|
||||||
data: filtered.map(s => s.pace),
|
data: filtered.map(s => s.pace),
|
||||||
borderColor: '#88C0D0',
|
borderColor: primary,
|
||||||
backgroundColor: 'rgba(136, 192, 208, 0.12)',
|
backgroundColor: fill,
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
@@ -406,6 +439,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
{#if editing}
|
{#if editing}
|
||||||
|
<button class="recalc-btn" onclick={recalculate} disabled={recalculating} title="Recalculate volume, PRs, and GPS previews">
|
||||||
|
<RefreshCw size={14} class={recalculating ? 'spinning' : ''} />
|
||||||
|
</button>
|
||||||
<button class="save-btn" onclick={saveEdit} disabled={saving}>
|
<button class="save-btn" onclick={saveEdit} disabled={saving}>
|
||||||
{saving ? 'SAVING...' : 'SAVE'}
|
{saving ? 'SAVING...' : 'SAVE'}
|
||||||
</button>
|
</button>
|
||||||
@@ -477,6 +513,20 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if session.exercises[exIdx]?.gpsTrack?.length || session.exercises[exIdx]?.gpsPreview?.length}
|
||||||
|
{@const exData = session.exercises[exIdx]}
|
||||||
|
<div class="gps-indicator">
|
||||||
|
<Route size={14} />
|
||||||
|
<span>GPS track stored{exData.totalDistance ? ` · ${exData.totalDistance.toFixed(2)} km` : ''}</span>
|
||||||
|
<button class="gpx-remove-btn" onclick={() => removeGpx(exIdx)} aria-label="Remove GPS track">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if exData.gpsTrack?.length >= 2}
|
||||||
|
<div class="track-map" use:renderMap={{ track: exData.gpsTrack, idx: exIdx }}></div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<SetTable
|
<SetTable
|
||||||
sets={ex.sets}
|
sets={ex.sets}
|
||||||
metrics={getExerciseMetrics(getExerciseById(ex.exerciseId))}
|
metrics={getExerciseMetrics(getExerciseById(ex.exerciseId))}
|
||||||
@@ -542,14 +592,10 @@
|
|||||||
{@const pace = dist > 0 && elapsed > 0 ? elapsed / dist : 0}
|
{@const pace = dist > 0 && elapsed > 0 ? elapsed / dist : 0}
|
||||||
<div class="gps-track-section">
|
<div class="gps-track-section">
|
||||||
<div class="gps-stats">
|
<div class="gps-stats">
|
||||||
<span class="gps-stat"><Route size={14} /> {dist.toFixed(2)} km</span>
|
<span class="gps-stat accent"><Route size={14} /> {dist.toFixed(2)} km</span>
|
||||||
{#if pace > 0}
|
{#if pace > 0}
|
||||||
<span class="gps-stat">{formatPace(pace)} avg</span>
|
<span class="gps-stat accent"><Gauge size={14} /> {formatPace(pace)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="gps-stat">{ex.gpsTrack.length} pts</span>
|
|
||||||
<button class="gpx-remove-btn" onclick={() => removeGpx(exIdx)} aria-label="Remove GPS track">
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="track-map" use:renderMap={{ track: ex.gpsTrack, idx: exIdx }}></div>
|
<div class="track-map" use:renderMap={{ track: ex.gpsTrack, idx: exIdx }}></div>
|
||||||
|
|
||||||
@@ -684,6 +730,30 @@
|
|||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
.recalc-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.4rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.recalc-btn:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
.recalc-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
:global(.spinning) {
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
.delete-btn {
|
.delete-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--nord11);
|
border: 1px solid var(--nord11);
|
||||||
@@ -835,6 +905,17 @@
|
|||||||
.remove-exercise:hover {
|
.remove-exercise:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
.gps-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
.add-set-btn {
|
.add-set-btn {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -974,9 +1055,9 @@
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
.gps-stat:first-child {
|
.gps-stat.accent {
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
color: var(--nord8);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
.gpx-remove-btn {
|
.gpx-remove-btn {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
|||||||
Reference in New Issue
Block a user