diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index ce7c8f1..1921421 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -176,6 +176,61 @@ async def route_activities( ] +@router.post("/{route_id}/merge/{source_id}", response_model=RouteOut) +async def merge_routes( + route_id: int, + source_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Move all activities from source route into route_id, then delete source route.""" + from sqlalchemy import update + + target = (await db.execute( + select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == current_user.id) + )).scalar_one_or_none() + source = (await db.execute( + select(NamedRoute).where(NamedRoute.id == source_id, NamedRoute.user_id == current_user.id) + )).scalar_one_or_none() + if not target or not source: + raise HTTPException(status_code=404, detail="Route not found") + if route_id == source_id: + raise HTTPException(status_code=400, detail="Cannot merge a route with itself") + + await db.execute( + update(Activity) + .where(Activity.named_route_id == source_id, Activity.user_id == current_user.id) + .values(named_route_id=route_id) + ) + await db.delete(source) + await db.commit() + await db.refresh(target) + return target + + +@router.delete("/{route_id}") +async def delete_route( + route_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + from sqlalchemy import update as sa_update + route = (await db.execute( + select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == current_user.id) + )).scalar_one_or_none() + if not route: + raise HTTPException(status_code=404, detail="Route not found") + # Unlink activities before deleting + await db.execute( + sa_update(Activity) + .where(Activity.named_route_id == route_id, Activity.user_id == current_user.id) + .values(named_route_id=None) + ) + await db.delete(route) + await db.commit() + return {"status": "ok"} + + @router.post("/{route_id}/assign-activity") async def assign_activity_to_route( route_id: int, diff --git a/backend/app/main.py b/backend/app/main.py index a487efc..4e88690 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -50,6 +50,24 @@ async def init_db(): except Exception as e: print(f"Column migration skipped: {e}") + # Replace the all-columns unique constraint on personal_records with a partial + # index (only current records must be unique per user/sport/distance). + # The old constraint also covered is_current_record=False rows, causing + # UniqueViolation crashes when multiple workers deactivate the same PR. + try: + async with engine.begin() as conn: + await conn.execute(text( + "ALTER TABLE personal_records " + "DROP CONSTRAINT IF EXISTS uq_pr_current" + )) + await conn.execute(text( + "CREATE UNIQUE INDEX IF NOT EXISTS uq_pr_current_active " + "ON personal_records (user_id, sport_type, distance_m) " + "WHERE is_current_record = true" + )) + except Exception as e: + print(f"PR constraint migration skipped: {e}") + # Seed admin user (only if password is configured) if not settings.admin_password: print("ADMIN_PASSWORD not set - skipping admin user seed") diff --git a/backend/app/services/garmin_connect_sync.py b/backend/app/services/garmin_connect_sync.py index 5f6e009..db7d7c0 100644 --- a/backend/app/services/garmin_connect_sync.py +++ b/backend/app/services/garmin_connect_sync.py @@ -46,15 +46,17 @@ def authenticate_garmin(email: str, password_enc: str, token_store: Optional[str """ import garminconnect - # Try stored OAuth token first (garth auto-refreshes access token on use) + # Try stored OAuth token first. + # Must call login(tokenstore=...) rather than garth.loads() directly so that + # garmin.display_name is populated — it's required by get_user_summary() and + # several other endpoints. Without it every stats call silently returns None. if token_store: try: garmin = garminconnect.Garmin( email=email, password=decrypt_password(password_enc) ) - garmin.garth.loads(token_store) - garmin.get_full_name() # lightweight request; triggers refresh if needed - return garmin, None # tokens still valid + garmin.login(tokenstore=token_store) + return garmin, None except Exception as exc: logger.info("Garmin token invalid (%s), re-authenticating", exc) @@ -234,6 +236,8 @@ def _parse_day(stats, sleep_data, hrv_data) -> dict: if stats: _set(row, "resting_hr", stats.get("restingHeartRate")) + _set(row, "avg_hr_day", stats.get("averageHeartRate")) + _set(row, "max_hr_day", stats.get("maxHeartRate")) _set(row, "steps", stats.get("totalSteps")) _set(row, "floors_climbed", stats.get("floorsAscended")) _set(row, "avg_stress", stats.get("averageStressLevel")) diff --git a/frontend/src/pages/RoutesPage.jsx b/frontend/src/pages/RoutesPage.jsx index 75984e1..f5e831a 100644 --- a/frontend/src/pages/RoutesPage.jsx +++ b/frontend/src/pages/RoutesPage.jsx @@ -1,12 +1,56 @@ import { useState } from 'react' +import { Link } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import api from '../utils/api' import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format' +// Decode Google encoded polyline to [[lat,lng], ...] +function decodePolyline(encoded) { + if (!encoded) return [] + const points = [] + let idx = 0, lat = 0, lng = 0 + while (idx < encoded.length) { + let shift = 0, result = 0, byte + do { byte = encoded.charCodeAt(idx++) - 63; result |= (byte & 0x1f) << shift; shift += 5 } while (byte >= 0x20) + lat += result & 1 ? ~(result >> 1) : result >> 1 + shift = 0; result = 0 + do { byte = encoded.charCodeAt(idx++) - 63; result |= (byte & 0x1f) << shift; shift += 5 } while (byte >= 0x20) + lng += result & 1 ? ~(result >> 1) : result >> 1 + points.push([lat / 1e5, lng / 1e5]) + } + return points +} + +function RouteMap({ polyline, className = '' }) { + const pts = decodePolyline(polyline) + if (pts.length < 2) return ( +
+ no track +
+ ) + const lats = pts.map(p => p[0]), lngs = pts.map(p => p[1]) + const minLat = Math.min(...lats), maxLat = Math.max(...lats) + const minLng = Math.min(...lngs), maxLng = Math.max(...lngs) + const rangeL = maxLng - minLng || 1e-5 + const rangeA = maxLat - minLat || 1e-5 + const pad = 4 + const w = 100, h = 60 + const toX = lng => pad + ((lng - minLng) / rangeL) * (w - pad * 2) + const toY = lat => pad + ((maxLat - lat) / rangeA) * (h - pad * 2) + const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${toX(p[1]).toFixed(1)},${toY(p[0]).toFixed(1)}`).join(' ') + return ( + + + + ) +} + export default function RoutesPage() { const [selected, setSelected] = useState(null) const [showCreate, setShowCreate] = useState(false) const [newRoute, setNewRoute] = useState({ name: '', activity_id: '' }) + const [merging, setMerging] = useState(false) + const [mergeTarget, setMergeTarget] = useState('') const qc = useQueryClient() const { data: routes } = useQuery({ @@ -27,8 +71,8 @@ export default function RoutesPage() { }) const createRoute = useMutation({ - mutationFn: (data) => api.post('/routes/', data).then(r => r.data), - onSuccess: (route) => { + mutationFn: data => api.post('/routes/', data).then(r => r.data), + onSuccess: route => { qc.invalidateQueries({ queryKey: ['routes'] }) setShowCreate(false) setNewRoute({ name: '', activity_id: '' }) @@ -36,7 +80,27 @@ export default function RoutesPage() { }, }) + const mergeRoute = useMutation({ + mutationFn: ({ into, from }) => api.post(`/routes/${into}/merge/${from}`).then(r => r.data), + onSuccess: updated => { + qc.invalidateQueries({ queryKey: ['routes'] }) + qc.invalidateQueries({ queryKey: ['route-activities', updated.id] }) + setMerging(false) + setMergeTarget('') + setSelected(updated) + }, + }) + + const deleteRoute = useMutation({ + mutationFn: id => api.delete(`/routes/${id}`), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['routes'] }) + setSelected(null) + }, + }) + const fastest = routeActivities?.[0] + const otherRoutes = routes?.filter(r => r.id !== selected?.id && r.sport_type === selected?.sport_type) ?? [] return (
@@ -53,7 +117,7 @@ export default function RoutesPage() {
- {/* Create route */} + {/* Create route panel */} {showCreate && (

Create named route

@@ -63,34 +127,25 @@ export default function RoutesPage() {
- setNewRoute(r => ({ ...r, name: e.target.value }))} + setNewRoute(r => ({ ...r, name: e.target.value }))} className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="e.g. Morning park loop" />
- {recentActivities?.length === 0 ? ( -

No recent activities found.

- ) : ( - - )} +
-
)} {routes?.map(route => ( - ))} @@ -137,15 +196,64 @@ export default function RoutesPage() { {selected && (
-
-

{selected.name}

- {selected.auto_detected && ( - - Auto-detected - - )} +
+
+ +
+

{selected.name}

+
+ {selected.sport_type && {selected.sport_type}} + {formatDistance(selected.distance_m)} + {selected.auto_detected && ( + Auto-detected + )} +
+
+
+
+ + +
+ {/* Merge panel */} + {merging && ( +
+

Merge another route into this one

+

All activities from the selected route will be moved here, then the other route will be deleted.

+
+ + + +
+ {otherRoutes.length === 0 && ( +

No other {selected.sport_type} routes to merge with.

+ )} +
+ )} + + {/* Course record */} {fastest && (

Course record 🏆

@@ -158,13 +266,15 @@ export default function RoutesPage() {
)} + {/* Activity list */}

All runs ({routeActivities?.length ?? 0})

-
+
{routeActivities?.map((act, i) => ( -
- {i + 1} + + {i + 1} {formatDate(act.start_time)} {formatDuration(act.duration_s)} {formatPace(act.avg_speed_ms, selected.sport_type)} @@ -174,7 +284,8 @@ export default function RoutesPage() { {i === 0 && ( CR )} -
+ + ))}