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 recent activities found.
- ) : ( - - )} +{route.name}
- {route.auto_detected && ( - auto - )} -{route.name}
+ {route.auto_detected && ( + auto + )} +Merge another route into this one
+All activities from the selected route will be moved here, then the other route will be deleted.
+No other {selected.sport_type} routes to merge with.
+ )} +Course record 🏆
@@ -158,13 +266,15 @@ export default function RoutesPage() {