From 02eccad57895d7267f3cabe753bd6cf925bccfe4 Mon Sep 17 00:00:00 2001 From: owain Date: Sun, 7 Jun 2026 12:01:25 +0100 Subject: [PATCH] Add segments, YTD stats, route matching fixes, body battery layout, pace fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Segments page: new /segments route with auto-generate (1km splits, turn detection, hill detection), manual segment creation, per-segment performance times across matched activities; fixed auth on existing segment endpoints - YTD distance: new /activities/stats/ytd endpoint; Dashboard replaces 'Total distance' with 'Running this year' + 'Cycling this year'; Activities page shows YTD stats row - Weekly chart click: clicking a Dashboard bar navigates to Activities filtered to that week; Activities reads from/to query params with dismissable chip - Route matching: add ±2.5% distance gate + 3% relative DTW threshold (was flat 80m); tighten candidate pre-filter from 80/120% to 95/105% - Body battery layout: HR chart and body battery now side-by-side at same height on large screens instead of stacked full-width - Pace display fix: MetricTimeline clamps GPS speed outliers before computing Y-axis domain; tick formatter guards against v<=0 or v>25 m/s Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/activities.py | 24 ++ backend/app/api/routes.py | 180 ++++++++++ backend/app/main.py | 9 + backend/app/models/user.py | 1 + backend/app/services/route_matcher.py | 158 +++++++++ backend/app/workers/tasks.py | 6 +- frontend/src/App.jsx | 2 + .../components/activity/MetricTimeline.jsx | 12 +- frontend/src/components/ui/Layout.jsx | 1 + frontend/src/pages/ActivitiesPage.jsx | 43 ++- frontend/src/pages/DashboardPage.jsx | 39 ++- frontend/src/pages/HealthPage.jsx | 32 +- frontend/src/pages/SegmentsPage.jsx | 322 ++++++++++++++++++ 13 files changed, 797 insertions(+), 32 deletions(-) create mode 100644 frontend/src/pages/SegmentsPage.jsx diff --git a/backend/app/api/activities.py b/backend/app/api/activities.py index 2b08ef9..45559ae 100644 --- a/backend/app/api/activities.py +++ b/backend/app/api/activities.py @@ -75,6 +75,30 @@ class LapOut(BaseModel): from_attributes = True +@router.get("/stats/ytd") +async def ytd_stats( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Return year-to-date distance totals grouped by sport type.""" + from datetime import date, timezone + year_start = datetime(date.today().year, 1, 1, tzinfo=timezone.utc) + result = await db.execute( + select(Activity.sport_type, func.sum(Activity.distance_m).label("total_m")) + .where(Activity.user_id == current_user.id, Activity.start_time >= year_start) + .group_by(Activity.sport_type) + ) + rows = result.all() + totals = {r.sport_type: (r.total_m or 0) / 1000 for r in rows} + return { + "running_km": round(totals.get("running", 0), 2), + "cycling_km": round(totals.get("cycling", 0), 2), + "hiking_km": round(totals.get("hiking", 0), 2), + "walking_km": round(totals.get("walking", 0), 2), + "total_km": round(sum(totals.values()), 2), + } + + @router.get("/", response_model=List[ActivitySummary]) async def list_activities( page: int = Query(1, ge=1), diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 1921421..c448cdd 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -47,11 +47,25 @@ class SegmentOut(BaseModel): start_distance_m: float end_distance_m: float description: Optional[str] + auto_generated: Optional[bool] = False class Config: from_attributes = True +class AutoGenerateRequest(BaseModel): + type: str # "1km" | "turns" | "hills" + gradient_pct: float = 5.0 + turn_angle_deg: float = 45.0 + + +class SegmentTimeEntry(BaseModel): + activity_id: int + date: datetime + name: str + duration_s: float + + @router.get("/", response_model=List[RouteOut]) async def list_routes( db: AsyncSession = Depends(get_db), @@ -253,12 +267,23 @@ async def assign_activity_to_route( return {"status": "ok"} +async def _get_owned_route(route_id: int, user_id: int, db: AsyncSession) -> NamedRoute: + result = await db.execute( + select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == user_id) + ) + route = result.scalar_one_or_none() + if not route: + raise HTTPException(status_code=404, detail="Route not found") + return route + + @router.get("/{route_id}/segments", response_model=List[SegmentOut]) async def list_segments( route_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): + await _get_owned_route(route_id, current_user.id, db) result = await db.execute( select(RouteSegment) .where(RouteSegment.route_id == route_id) @@ -274,14 +299,169 @@ async def create_segment( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): + await _get_owned_route(route_id, current_user.id, db) segment = RouteSegment( route_id=route_id, name=body.name, start_distance_m=body.start_distance_m, end_distance_m=body.end_distance_m, description=body.description, + auto_generated=False, ) db.add(segment) await db.commit() await db.refresh(segment) return segment + + +@router.delete("/{route_id}/segments/{segment_id}", status_code=204) +async def delete_segment( + route_id: int, + segment_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + await _get_owned_route(route_id, current_user.id, db) + result = await db.execute( + select(RouteSegment).where( + RouteSegment.id == segment_id, RouteSegment.route_id == route_id + ) + ) + seg = result.scalar_one_or_none() + if not seg: + raise HTTPException(status_code=404, detail="Segment not found") + await db.delete(seg) + await db.commit() + + +@router.post("/{route_id}/segments/auto", response_model=List[SegmentOut]) +async def auto_generate_segments( + route_id: int, + body: AutoGenerateRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Auto-generate segments: 1km splits, turns, or hills.""" + from app.services.route_matcher import ( + generate_1km_segments, generate_turn_segments, generate_hill_segments, + ) + from sqlalchemy import delete as sql_delete + + route = await _get_owned_route(route_id, current_user.id, db) + + if body.type not in ("1km", "turns", "hills"): + raise HTTPException(status_code=400, detail="type must be '1km', 'turns', or 'hills'") + + # Clear existing auto-generated segments of this type + await db.execute( + sql_delete(RouteSegment).where( + RouteSegment.route_id == route_id, + RouteSegment.auto_generated == True, + ) + ) + + raw_segments: list[tuple[str, float, float]] = [] + + if body.type == "1km": + if not route.distance_m: + raise HTTPException(status_code=400, detail="Route has no distance recorded") + raw_segments = generate_1km_segments(route.reference_polyline or "", route.distance_m) + + elif body.type == "turns": + if not route.reference_polyline: + raise HTTPException(status_code=400, detail="Route has no polyline") + raw_segments = generate_turn_segments(route.reference_polyline, body.turn_angle_deg) + + elif body.type == "hills": + if not route.reference_polyline: + raise HTTPException(status_code=400, detail="Route has no polyline") + # Find most recent matched activity for elevation data + act_result = await db.execute( + select(Activity) + .where(Activity.named_route_id == route_id, Activity.user_id == current_user.id) + .order_by(desc(Activity.start_time)) + .limit(1) + ) + act = act_result.scalar_one_or_none() + if not act: + raise HTTPException(status_code=400, detail="No matched activities found for elevation data") + from app.models.user import ActivityDataPoint + dp_result = await db.execute( + select(ActivityDataPoint) + .where(ActivityDataPoint.activity_id == act.id) + .order_by(ActivityDataPoint.timestamp) + ) + dps = dp_result.scalars().all() + dp_list = [{"distance_m": p.distance_m, "altitude_m": p.altitude_m} for p in dps] + raw_segments = generate_hill_segments(dp_list, body.gradient_pct) + + new_segments = [] + for name, start_m, end_m in raw_segments: + seg = RouteSegment( + route_id=route_id, + name=name, + start_distance_m=start_m, + end_distance_m=end_m, + auto_generated=True, + ) + db.add(seg) + new_segments.append(seg) + + await db.commit() + for seg in new_segments: + await db.refresh(seg) + return new_segments + + +@router.get("/{route_id}/segments/{segment_id}/times", response_model=List[SegmentTimeEntry]) +async def get_segment_times( + route_id: int, + segment_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Return the last 10 times this segment was traversed across matched activities.""" + from app.services.route_matcher import find_segment_times + from app.models.user import ActivityDataPoint + + await _get_owned_route(route_id, current_user.id, db) + + seg_result = await db.execute( + select(RouteSegment).where( + RouteSegment.id == segment_id, RouteSegment.route_id == route_id + ) + ) + seg = seg_result.scalar_one_or_none() + if not seg: + raise HTTPException(status_code=404, detail="Segment not found") + + acts_result = await db.execute( + select(Activity) + .where(Activity.named_route_id == route_id, Activity.user_id == current_user.id) + .order_by(desc(Activity.start_time)) + .limit(10) + ) + activities = acts_result.scalars().all() + + times = [] + for act in activities: + dp_result = await db.execute( + select(ActivityDataPoint) + .where(ActivityDataPoint.activity_id == act.id) + .order_by(ActivityDataPoint.timestamp) + ) + dps = dp_result.scalars().all() + dp_list = [ + {"distance_m": p.distance_m, "timestamp": p.timestamp} + for p in dps + if p.distance_m is not None + ] + duration = find_segment_times(dp_list, seg.start_distance_m, seg.end_distance_m) + if duration: + times.append(SegmentTimeEntry( + activity_id=act.id, + date=act.start_time, + name=act.name, + duration_s=duration, + )) + return times diff --git a/backend/app/main.py b/backend/app/main.py index f565d43..cd4dbcd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -63,6 +63,15 @@ async def init_db(): except Exception as e: print(f"health_metrics column migration skipped: {e}") + # route_segments auto_generated column added after initial creation + try: + async with engine.begin() as conn: + await conn.execute(text( + "ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated BOOLEAN DEFAULT FALSE" + )) + except Exception as e: + print(f"route_segments 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 diff --git a/backend/app/models/user.py b/backend/app/models/user.py index f14c08c..63fe126 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -181,6 +181,7 @@ class RouteSegment(Base): start_distance_m = Column(Float, nullable=False) end_distance_m = Column(Float, nullable=False) description = Column(Text, nullable=True) + auto_generated = Column(Boolean, default=False) route = relationship("NamedRoute", back_populates="segments") diff --git a/backend/app/services/route_matcher.py b/backend/app/services/route_matcher.py index 99a46af..92dd307 100644 --- a/backend/app/services/route_matcher.py +++ b/backend/app/services/route_matcher.py @@ -63,11 +63,21 @@ def routes_are_similar( bb1: Optional[dict], bb2: Optional[dict], dtw_threshold_m: float = 80.0, + dist1: Optional[float] = None, + dist2: Optional[float] = None, ) -> bool: """ Returns True if two activities are on sufficiently similar routes. First does a cheap bounding box check, then DTW on downsampled tracks. + When dist1/dist2 are provided: + - Rejects if distance differs by more than 2.5% + - Uses 3% of route distance as the DTW threshold (capped at 300m) """ + if dist1 and dist2 and dist1 > 0 and dist2 > 0: + if abs(dist1 - dist2) / max(dist1, dist2) > 0.025: + return False + dtw_threshold_m = min(max(dist1, dist2) * 0.03, 300.0) + if bb1 and bb2: if not bounding_boxes_overlap(bb1, bb2): return False @@ -164,6 +174,154 @@ def find_best_split_time( return best +def _bearing(p1: tuple, p2: tuple) -> float: + """Compass bearing in degrees (0-360) from p1 to p2.""" + lat1, lon1 = math.radians(p1[0]), math.radians(p1[1]) + lat2, lon2 = math.radians(p2[0]), math.radians(p2[1]) + dlon = lon2 - lon1 + x = math.sin(dlon) * math.cos(lat2) + y = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon) + return math.degrees(math.atan2(x, y)) % 360 + + +def generate_1km_segments(encoded_polyline: str, total_dist_m: float) -> list[tuple[str, float, float]]: + """Generate 1-km splits along a route. Returns list of (name, start_m, end_m).""" + if not encoded_polyline: + return [] + km_count = int(total_dist_m / 1000) + segments = [] + for i in range(km_count): + segments.append((f"km {i + 1}", float(i * 1000), float((i + 1) * 1000))) + remainder = total_dist_m - km_count * 1000 + if remainder >= 200: + segments.append((f"km {km_count + 1}", float(km_count * 1000), total_dist_m)) + return segments + + +def generate_turn_segments( + encoded_polyline: str, + turn_angle_deg: float = 45.0, +) -> list[tuple[str, float, float]]: + """Detect sharp turns in a route polyline. Returns list of (name, start_m, end_m).""" + coords = decode_polyline_to_coords(encoded_polyline) + if len(coords) < 3: + return [] + + cum_dists = [0.0] + for i in range(1, len(coords)): + cum_dists.append(cum_dists[-1] + haversine_m(coords[i - 1], coords[i])) + total = cum_dists[-1] + + HALF_WINDOW = 100.0 # metres either side of candidate turn point + + turn_centers: list[float] = [] + for i in range(1, len(coords) - 1): + # Find index ~HALF_WINDOW before and after + start_i = i + while start_i > 0 and cum_dists[i] - cum_dists[start_i] < HALF_WINDOW: + start_i -= 1 + end_i = i + while end_i < len(coords) - 1 and cum_dists[end_i] - cum_dists[i] < HALF_WINDOW: + end_i += 1 + if start_i == i or end_i == i: + continue + + b1 = _bearing(coords[start_i], coords[i]) + b2 = _bearing(coords[i], coords[end_i]) + diff = abs(b2 - b1) % 360 + if diff > 180: + diff = 360 - diff + if diff >= turn_angle_deg: + turn_centers.append(cum_dists[i]) + + if not turn_centers: + return [] + + # Cluster turns within 150 m of each other → one segment per cluster + clusters: list[list[float]] = [[turn_centers[0]]] + for d in turn_centers[1:]: + if d - clusters[-1][-1] < 150: + clusters[-1].append(d) + else: + clusters.append([d]) + + segments = [] + for cluster in clusters: + center = sum(cluster) / len(cluster) + start = max(0.0, center - HALF_WINDOW) + end = min(total, center + HALF_WINDOW) + segments.append((f"Turn at {center / 1000:.1f} km", start, end)) + return segments + + +def generate_hill_segments( + data_points: list[dict], + gradient_pct: float = 5.0, +) -> list[tuple[str, float, float]]: + """ + Detect uphill sections using activity data points (with altitude_m + distance_m). + Returns list of (name, start_m, end_m). + """ + pts = [ + (p["distance_m"], p["altitude_m"]) + for p in data_points + if p.get("distance_m") is not None and p.get("altitude_m") is not None + ] + if len(pts) < 10: + return [] + pts.sort(key=lambda x: x[0]) + dists = [p[0] for p in pts] + alts = [p[1] for p in pts] + + # Smooth altitude with a sliding window to reduce GPS noise + SMOOTH = 10 + smooth_alts = [] + for i in range(len(alts)): + lo, hi = max(0, i - SMOOTH), min(len(alts), i + SMOOTH + 1) + smooth_alts.append(sum(alts[lo:hi]) / (hi - lo)) + + grad_threshold = gradient_pct / 100.0 + MIN_HILL_M = 200.0 + + in_hill = False + hill_start_idx = 0 + segments = [] + + for i in range(1, len(dists)): + d_dist = dists[i] - dists[i - 1] + if d_dist <= 0: + continue + grad = (smooth_alts[i] - smooth_alts[i - 1]) / d_dist + + if grad >= grad_threshold and not in_hill: + in_hill = True + hill_start_idx = i - 1 + elif grad < grad_threshold and in_hill: + length = dists[i - 1] - dists[hill_start_idx] + if length >= MIN_HILL_M: + gain = round(smooth_alts[i - 1] - smooth_alts[hill_start_idx]) + start_km = dists[hill_start_idx] / 1000 + segments.append(( + f"Hill at {start_km:.1f} km (+{gain} m)", + dists[hill_start_idx], + dists[i - 1], + )) + in_hill = False + + if in_hill: + length = dists[-1] - dists[hill_start_idx] + if length >= MIN_HILL_M: + gain = round(smooth_alts[-1] - smooth_alts[hill_start_idx]) + start_km = dists[hill_start_idx] / 1000 + segments.append(( + f"Hill at {start_km:.1f} km (+{gain} m)", + dists[hill_start_idx], + dists[-1], + )) + + return segments + + STANDARD_DISTANCES = [ (400, "400m"), (800, "800m"), diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py index 50b79f1..a087b17 100644 --- a/backend/app/workers/tasks.py +++ b/backend/app/workers/tasks.py @@ -314,6 +314,7 @@ def detect_route(activity_id: int, user_id: int): if route.reference_polyline and routes_are_similar( new_act.polyline, route.reference_polyline, new_act.bounding_box, route.bounding_box, + dist1=new_act.distance_m, dist2=route.distance_m, ): new_act.named_route_id = route.id db.commit() @@ -326,8 +327,8 @@ def detect_route(activity_id: int, user_id: int): Activity.named_route_id == None, Activity.id != activity_id, Activity.polyline != None, - Activity.distance_m >= (new_act.distance_m or 0) * 0.8, - Activity.distance_m <= (new_act.distance_m or 0) * 1.2, + Activity.distance_m >= (new_act.distance_m or 0) * 0.95, + Activity.distance_m <= (new_act.distance_m or 0) * 1.05, ) ).scalars().all() @@ -335,6 +336,7 @@ def detect_route(activity_id: int, user_id: int): if routes_are_similar( new_act.polyline, candidate.polyline, new_act.bounding_box, candidate.bounding_box, + dist1=new_act.distance_m, dist2=candidate.distance_m, ): older = candidate if candidate.start_time < new_act.start_time else new_act newer = new_act if candidate.start_time < new_act.start_time else candidate diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 17219e9..55f1d37 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,6 +8,7 @@ import ActivitiesPage from './pages/ActivitiesPage' import ActivityDetailPage from './pages/ActivityDetailPage' import HealthPage from './pages/HealthPage' import RoutesPage from './pages/RoutesPage' +import SegmentsPage from './pages/SegmentsPage' import RecordsPage from './pages/RecordsPage' import UploadPage from './pages/UploadPage' import ProfilePage from './pages/ProfilePage' @@ -34,6 +35,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/activity/MetricTimeline.jsx b/frontend/src/components/activity/MetricTimeline.jsx index 6b2b35d..2ca95a7 100644 --- a/frontend/src/components/activity/MetricTimeline.jsx +++ b/frontend/src/components/activity/MetricTimeline.jsx @@ -62,16 +62,21 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH const domains = useMemo(() => { const result = {} for (const m of activeMetricConfigs) { - const vals = chartData.map(p => p[m.key]).filter(v => v != null) + let vals = chartData.map(p => p[m.key]).filter(v => v != null) if (!vals.length) continue + // Clamp GPS speed outliers (spikes cause absurd pace labels like 0:01/km) + if (m.key === 'speed_ms') { + const speedCap = sportType === 'cycling' ? 25 : 12 + vals = vals.filter(v => v > 0 && v <= speedCap) + if (!vals.length) continue + } const min = Math.min(...vals) const max = Math.max(...vals) const pad = (max - min) * 0.1 || 1 - // For elevation, don't start from 0 - show actual range result[m.key] = [min - pad, max + pad] } return result - }, [chartData, activeMetricConfigs]) + }, [chartData, activeMetricConfigs, sportType]) if (!chartData.length) { return ( @@ -115,6 +120,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH width={40} tickFormatter={v => { if (metric.key === 'speed_ms') { + if (v <= 0 || v > 25) return '' if (sportType === 'cycling') return `${(v * 3.6).toFixed(0)}` const spm = 1000 / v return `${Math.floor(spm/60)}:${String(Math.floor(spm%60)).padStart(2,'0')}` diff --git a/frontend/src/components/ui/Layout.jsx b/frontend/src/components/ui/Layout.jsx index 881172f..93e657b 100644 --- a/frontend/src/components/ui/Layout.jsx +++ b/frontend/src/components/ui/Layout.jsx @@ -6,6 +6,7 @@ const nav = [ { to: '/activities', label: 'Activities', icon: '🏃' }, { to: '/health', label: 'Health', icon: '❤️' }, { to: '/routes', label: 'Routes', icon: '🗺️' }, + { to: '/segments', label: 'Segments', icon: '📏' }, { to: '/records', label: 'Records', icon: '🏆' }, { to: '/upload', label: 'Import', icon: '⬆️' }, { to: '/profile', label: 'Profile', icon: '⚙️' }, diff --git a/frontend/src/pages/ActivitiesPage.jsx b/frontend/src/pages/ActivitiesPage.jsx index 67efcf0..9ed2aa7 100644 --- a/frontend/src/pages/ActivitiesPage.jsx +++ b/frontend/src/pages/ActivitiesPage.jsx @@ -1,6 +1,7 @@ import { useState } from 'react' -import { Link } from 'react-router-dom' +import { Link, useSearchParams, useNavigate } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' +import { format } from 'date-fns' import api from '../utils/api' import { formatDuration, formatDistance, formatPace, formatHeartRate, @@ -10,24 +11,38 @@ import { const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking'] export default function ActivitiesPage() { + const [searchParams] = useSearchParams() + const navigate = useNavigate() const [sport, setSport] = useState('all') const [page, setPage] = useState(1) + const fromParam = searchParams.get('from') + const toParam = searchParams.get('to') + const { data: activities, isLoading } = useQuery({ - queryKey: ['activities', sport, page], + queryKey: ['activities', sport, page, fromParam, toParam], queryFn: () => api.get('/activities/', { params: { sport_type: sport === 'all' ? undefined : sport, page, per_page: 20, + from_date: fromParam ? new Date(fromParam).toISOString() : undefined, + to_date: toParam ? new Date(toParam + 'T23:59:59').toISOString() : undefined, }, }).then(r => r.data), }) + const { data: ytdStats } = useQuery({ + queryKey: ['ytd-stats'], + queryFn: () => api.get('/activities/stats/ytd').then(r => r.data), + }) + + const clearDateFilter = () => navigate('/activities') + return (
-
+

Activities

+ {/* YTD stats */} + {ytdStats && ( +
+ {ytdStats.running_km > 0 && ( + 🏃 {ytdStats.running_km.toFixed(0)} km this year + )} + {ytdStats.cycling_km > 0 && ( + 🚴 {ytdStats.cycling_km.toFixed(0)} km this year + )} +
+ )} + + {/* Date filter chip */} + {fromParam && ( +
+ + Week of {format(new Date(fromParam), 'MMM d, yyyy')} + + +
+ )} + {/* Sport filter */}
{SPORTS.map(s => ( diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 8d302ed..df14566 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -1,7 +1,7 @@ -import { Link } from 'react-router-dom' +import { Link, useNavigate } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' -import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays } from 'date-fns' +import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } from 'date-fns' import api from '../utils/api' import StatCard from '../components/ui/StatCard' import { @@ -10,6 +10,8 @@ import { } from '../utils/format' function WeeklyChart({ activities }) { + const navigate = useNavigate() + if (!activities?.length) return (
No activities yet
) @@ -23,26 +25,39 @@ function WeeklyChart({ activities }) { const data = weeks.map(weekStart => { const weekKey = format(weekStart, 'MMM d') - const weekEnd = new Date(weekStart) - weekEnd.setDate(weekEnd.getDate() + 7) + const weekEnd = addDays(weekStart, 7) const km = activities .filter(a => { const t = new Date(a.start_time) return t >= weekStart && t < weekEnd }) .reduce((s, a) => s + (a.distance_m || 0) / 1000, 0) - return { week: weekKey, km: parseFloat(km.toFixed(2)) } + return { + week: weekKey, + km: parseFloat(km.toFixed(2)), + weekStartISO: format(weekStart, 'yyyy-MM-dd'), + weekEndISO: format(weekEnd, 'yyyy-MM-dd'), + } }) + const handleBarClick = (entry) => { + if (entry?.activePayload?.[0]?.payload) { + const { weekStartISO, weekEndISO } = entry.activePayload[0].payload + navigate(`/activities?from=${weekStartISO}&to=${weekEndISO}`) + } + } + return ( - + `${v.toFixed(0)}`} /> [`${v.toFixed(1)} km`, 'Distance']} /> + formatter={(v) => [`${v.toFixed(1)} km`, 'Distance']} + cursor={{ fill: 'rgba(59,130,246,0.1)' }} /> @@ -73,8 +88,12 @@ export default function DashboardPage() { queryFn: () => api.get('/records/', { params: { sport_type: 'running' } }).then(r => r.data), }) + const { data: ytdStats } = useQuery({ + queryKey: ['ytd-stats'], + queryFn: () => api.get('/activities/stats/ytd').then(r => r.data), + }) + const latest = healthSummary?.latest - const totalDistance = recentActivities?.reduce((s, a) => s + (a.distance_m || 0), 0) ?? 0 return (
@@ -84,8 +103,8 @@ export default function DashboardPage() {
- - + +
diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index 354c0f7..1eff81c 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -103,7 +103,7 @@ function BodyBatteryChart({ bb }) { })) return ( -
+

Body Battery

@@ -340,22 +340,26 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, onOlder, onNewer,
- {/* 24-hour heart rate chart */} - {intradayHr?.length > 0 && ( -
-
-

24-hour Heart Rate

- {day.avg_hr_day && ( - avg {Math.round(day.avg_hr_day)} bpm - )} -
- + {/* 24-hour heart rate chart + body battery (side by side) */} + {(intradayHr?.length > 0 || bodyBattery) && ( +
0 && bodyBattery ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'}`}> + {intradayHr?.length > 0 && ( +
+
+

24-hour Heart Rate

+ {day.avg_hr_day && ( + avg {Math.round(day.avg_hr_day)} bpm + )} +
+
+ +
+
+ )} +
)} - {/* Body battery */} - - {/* Activity strip */}
diff --git a/frontend/src/pages/SegmentsPage.jsx b/frontend/src/pages/SegmentsPage.jsx new file mode 100644 index 0000000..40484c8 --- /dev/null +++ b/frontend/src/pages/SegmentsPage.jsx @@ -0,0 +1,322 @@ +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { format } from 'date-fns' +import api from '../utils/api' +import { formatDuration, formatDistance } from '../utils/format' + +function formatSegmentDist(m) { + if (m == null) return '--' + return m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${Math.round(m)} m` +} + +function SegmentRow({ seg, routeId, onDeleted }) { + const [showTimes, setShowTimes] = useState(false) + const queryClient = useQueryClient() + + const { data: times, isLoading: timesLoading } = useQuery({ + queryKey: ['segment-times', routeId, seg.id], + queryFn: () => api.get(`/routes/${routeId}/segments/${seg.id}/times`).then(r => r.data), + enabled: showTimes, + }) + + const deleteMut = useMutation({ + mutationFn: () => api.delete(`/routes/${routeId}/segments/${seg.id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['segments', routeId] }) + if (onDeleted) onDeleted() + }, + }) + + const bestTime = times?.length ? Math.min(...times.map(t => t.duration_s)) : null + const lastTime = times?.[0]?.duration_s ?? null + + return ( +
+
+
+
+ {seg.name} + {seg.auto_generated && ( + auto + )} +
+

+ {formatSegmentDist(seg.start_distance_m)} – {formatSegmentDist(seg.end_distance_m)} + ({formatSegmentDist(seg.end_distance_m - seg.start_distance_m)}) +

+
+
+ {showTimes && !timesLoading && bestTime && ( +
+

Best

+

{formatDuration(bestTime)}

+
+ )} + {showTimes && !timesLoading && lastTime && ( +
+

Last

+

{formatDuration(lastTime)}

+
+ )} + + +
+
+ + {showTimes && ( +
+ {timesLoading &&

Loading times…

} + {!timesLoading && !times?.length && ( +

No times recorded yet

+ )} + {times?.length > 0 && ( +
+ {times.map((t, i) => ( +
+ + {formatDuration(t.duration_s)} + + + {t.name} + + {format(new Date(t.date), 'd MMM yyyy')} +
+ ))} +
+ )} +
+ )} +
+ ) +} + +function NewSegmentForm({ routeId, onCreated }) { + const queryClient = useQueryClient() + const [name, setName] = useState('') + const [startKm, setStartKm] = useState('') + const [endKm, setEndKm] = useState('') + const [open, setOpen] = useState(false) + + const mut = useMutation({ + mutationFn: (data) => api.post(`/routes/${routeId}/segments`, data).then(r => r.data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['segments', routeId] }) + setName(''); setStartKm(''); setEndKm(''); setOpen(false) + if (onCreated) onCreated() + }, + }) + + if (!open) { + return ( + + ) + } + + const handleSubmit = (e) => { + e.preventDefault() + const start = parseFloat(startKm) * 1000 + const end = parseFloat(endKm) * 1000 + if (!name || isNaN(start) || isNaN(end) || end <= start) return + mut.mutate({ name, start_distance_m: start, end_distance_m: end }) + } + + return ( +
+

New segment

+ setName(e.target.value)} + className="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-blue-500" + required + /> +
+ setStartKm(e.target.value)} + className="flex-1 bg-gray-800 border border-gray-700 text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-blue-500" + required + /> + setEndKm(e.target.value)} + className="flex-1 bg-gray-800 border border-gray-700 text-white text-sm rounded px-3 py-1.5 focus:outline-none focus:border-blue-500" + required + /> +
+
+ + +
+
+ ) +} + +export default function SegmentsPage() { + const [selectedRouteId, setSelectedRouteId] = useState(null) + const [autoGenLoading, setAutoGenLoading] = useState(null) + const [hillGradient, setHillGradient] = useState(5) + const queryClient = useQueryClient() + + const { data: routes } = useQuery({ + queryKey: ['routes'], + queryFn: () => api.get('/routes/').then(r => r.data), + }) + + const selectedRoute = routes?.find(r => r.id === selectedRouteId) + + const { data: segments, isLoading: segsLoading } = useQuery({ + queryKey: ['segments', selectedRouteId], + queryFn: () => api.get(`/routes/${selectedRouteId}/segments`).then(r => r.data), + enabled: !!selectedRouteId, + }) + + const autoGenMut = useMutation({ + mutationFn: ({ type, opts }) => + api.post(`/routes/${selectedRouteId}/segments/auto`, { type, ...opts }).then(r => r.data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['segments', selectedRouteId] }) + setAutoGenLoading(null) + }, + onError: (err) => { + alert(err?.response?.data?.detail || 'Auto-generate failed') + setAutoGenLoading(null) + }, + }) + + const handleAutoGen = (type, opts = {}) => { + setAutoGenLoading(type) + autoGenMut.mutate({ type, opts }) + } + + return ( +
+
+

Segments

+
+ + {/* Route selector */} +
+ + {!routes?.length ? ( +

No named routes yet. Create one on the Routes page.

+ ) : ( + + )} +
+ + {selectedRoute && ( +
+ {/* Route info */} +
+
+

{selectedRoute.name}

+

+ {selectedRoute.sport_type && {selectedRoute.sport_type}} + {selectedRoute.distance_m && · {formatDistance(selectedRoute.distance_m)}} + {selectedRoute.auto_detected && (auto-detected)} +

+
+
+ + {/* Auto-generate controls */} +
+

Auto-generate segments

+
+ + +
+ +
+ + setHillGradient(parseInt(e.target.value) || 5)} + className="w-12 bg-gray-800 border border-gray-700 text-white text-xs rounded px-2 py-1 text-center focus:outline-none focus:border-blue-500" + /> + % +
+
+
+

Auto-generate replaces previously auto-generated segments. Manual segments are kept.

+
+ + {/* Segments list */} +
+
+

Segments

+ {segments?.length > 0 && ( + {segments.length} segment{segments.length !== 1 ? 's' : ''} + )} +
+ + {segsLoading &&

Loading…

} + + {!segsLoading && !segments?.length && ( +

No segments yet. Use auto-generate above or add one manually.

+ )} + + {segments?.map(seg => ( + + ))} + + +
+
+ )} +
+ ) +}