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() {
+ {formatSegmentDist(seg.start_distance_m)} – {formatSegmentDist(seg.end_distance_m)} + ({formatSegmentDist(seg.end_distance_m - seg.start_distance_m)}) +
+Best
+{formatDuration(bestTime)}
+Last
+{formatDuration(lastTime)}
+Loading times…
} + {!timesLoading && !times?.length && ( +No times recorded yet
+ )} + {times?.length > 0 && ( +No named routes yet. Create one on the Routes page.
+ ) : ( + + )} ++ {selectedRoute.sport_type && {selectedRoute.sport_type}} + {selectedRoute.distance_m && · {formatDistance(selectedRoute.distance_m)}} + {selectedRoute.auto_detected && (auto-detected)} +
+Auto-generate segments
+Auto-generate replaces previously auto-generated segments. Manual segments are kept.
+Loading…
} + + {!segsLoading && !segments?.length && ( +No segments yet. Use auto-generate above or add one manually.
+ )} + + {segments?.map(seg => ( +