Add segments, YTD stats, route matching fixes, body battery layout, pace fix
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -75,6 +75,30 @@ class LapOut(BaseModel):
|
|||||||
from_attributes = True
|
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])
|
@router.get("/", response_model=List[ActivitySummary])
|
||||||
async def list_activities(
|
async def list_activities(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
|
|||||||
@@ -47,11 +47,25 @@ class SegmentOut(BaseModel):
|
|||||||
start_distance_m: float
|
start_distance_m: float
|
||||||
end_distance_m: float
|
end_distance_m: float
|
||||||
description: Optional[str]
|
description: Optional[str]
|
||||||
|
auto_generated: Optional[bool] = False
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
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])
|
@router.get("/", response_model=List[RouteOut])
|
||||||
async def list_routes(
|
async def list_routes(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -253,12 +267,23 @@ async def assign_activity_to_route(
|
|||||||
return {"status": "ok"}
|
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])
|
@router.get("/{route_id}/segments", response_model=List[SegmentOut])
|
||||||
async def list_segments(
|
async def list_segments(
|
||||||
route_id: int,
|
route_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
await _get_owned_route(route_id, current_user.id, db)
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(RouteSegment)
|
select(RouteSegment)
|
||||||
.where(RouteSegment.route_id == route_id)
|
.where(RouteSegment.route_id == route_id)
|
||||||
@@ -274,14 +299,169 @@ async def create_segment(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
await _get_owned_route(route_id, current_user.id, db)
|
||||||
segment = RouteSegment(
|
segment = RouteSegment(
|
||||||
route_id=route_id,
|
route_id=route_id,
|
||||||
name=body.name,
|
name=body.name,
|
||||||
start_distance_m=body.start_distance_m,
|
start_distance_m=body.start_distance_m,
|
||||||
end_distance_m=body.end_distance_m,
|
end_distance_m=body.end_distance_m,
|
||||||
description=body.description,
|
description=body.description,
|
||||||
|
auto_generated=False,
|
||||||
)
|
)
|
||||||
db.add(segment)
|
db.add(segment)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(segment)
|
await db.refresh(segment)
|
||||||
return 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
|
||||||
|
|||||||
@@ -63,6 +63,15 @@ async def init_db():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"health_metrics column migration skipped: {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
|
# Replace the all-columns unique constraint on personal_records with a partial
|
||||||
# index (only current records must be unique per user/sport/distance).
|
# index (only current records must be unique per user/sport/distance).
|
||||||
# The old constraint also covered is_current_record=False rows, causing
|
# The old constraint also covered is_current_record=False rows, causing
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ class RouteSegment(Base):
|
|||||||
start_distance_m = Column(Float, nullable=False)
|
start_distance_m = Column(Float, nullable=False)
|
||||||
end_distance_m = Column(Float, nullable=False)
|
end_distance_m = Column(Float, nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
|
auto_generated = Column(Boolean, default=False)
|
||||||
|
|
||||||
route = relationship("NamedRoute", back_populates="segments")
|
route = relationship("NamedRoute", back_populates="segments")
|
||||||
|
|
||||||
|
|||||||
@@ -63,11 +63,21 @@ def routes_are_similar(
|
|||||||
bb1: Optional[dict],
|
bb1: Optional[dict],
|
||||||
bb2: Optional[dict],
|
bb2: Optional[dict],
|
||||||
dtw_threshold_m: float = 80.0,
|
dtw_threshold_m: float = 80.0,
|
||||||
|
dist1: Optional[float] = None,
|
||||||
|
dist2: Optional[float] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Returns True if two activities are on sufficiently similar routes.
|
Returns True if two activities are on sufficiently similar routes.
|
||||||
First does a cheap bounding box check, then DTW on downsampled tracks.
|
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 bb1 and bb2:
|
||||||
if not bounding_boxes_overlap(bb1, bb2):
|
if not bounding_boxes_overlap(bb1, bb2):
|
||||||
return False
|
return False
|
||||||
@@ -164,6 +174,154 @@ def find_best_split_time(
|
|||||||
return best
|
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 = [
|
STANDARD_DISTANCES = [
|
||||||
(400, "400m"),
|
(400, "400m"),
|
||||||
(800, "800m"),
|
(800, "800m"),
|
||||||
|
|||||||
@@ -314,6 +314,7 @@ def detect_route(activity_id: int, user_id: int):
|
|||||||
if route.reference_polyline and routes_are_similar(
|
if route.reference_polyline and routes_are_similar(
|
||||||
new_act.polyline, route.reference_polyline,
|
new_act.polyline, route.reference_polyline,
|
||||||
new_act.bounding_box, route.bounding_box,
|
new_act.bounding_box, route.bounding_box,
|
||||||
|
dist1=new_act.distance_m, dist2=route.distance_m,
|
||||||
):
|
):
|
||||||
new_act.named_route_id = route.id
|
new_act.named_route_id = route.id
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -326,8 +327,8 @@ def detect_route(activity_id: int, user_id: int):
|
|||||||
Activity.named_route_id == None,
|
Activity.named_route_id == None,
|
||||||
Activity.id != activity_id,
|
Activity.id != activity_id,
|
||||||
Activity.polyline != None,
|
Activity.polyline != None,
|
||||||
Activity.distance_m >= (new_act.distance_m or 0) * 0.8,
|
Activity.distance_m >= (new_act.distance_m or 0) * 0.95,
|
||||||
Activity.distance_m <= (new_act.distance_m or 0) * 1.2,
|
Activity.distance_m <= (new_act.distance_m or 0) * 1.05,
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
|
|
||||||
@@ -335,6 +336,7 @@ def detect_route(activity_id: int, user_id: int):
|
|||||||
if routes_are_similar(
|
if routes_are_similar(
|
||||||
new_act.polyline, candidate.polyline,
|
new_act.polyline, candidate.polyline,
|
||||||
new_act.bounding_box, candidate.bounding_box,
|
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
|
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
|
newer = new_act if candidate.start_time < new_act.start_time else candidate
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import ActivitiesPage from './pages/ActivitiesPage'
|
|||||||
import ActivityDetailPage from './pages/ActivityDetailPage'
|
import ActivityDetailPage from './pages/ActivityDetailPage'
|
||||||
import HealthPage from './pages/HealthPage'
|
import HealthPage from './pages/HealthPage'
|
||||||
import RoutesPage from './pages/RoutesPage'
|
import RoutesPage from './pages/RoutesPage'
|
||||||
|
import SegmentsPage from './pages/SegmentsPage'
|
||||||
import RecordsPage from './pages/RecordsPage'
|
import RecordsPage from './pages/RecordsPage'
|
||||||
import UploadPage from './pages/UploadPage'
|
import UploadPage from './pages/UploadPage'
|
||||||
import ProfilePage from './pages/ProfilePage'
|
import ProfilePage from './pages/ProfilePage'
|
||||||
@@ -34,6 +35,7 @@ export default function App() {
|
|||||||
<Route path="activities/:id" element={<ActivityDetailPage />} />
|
<Route path="activities/:id" element={<ActivityDetailPage />} />
|
||||||
<Route path="health" element={<HealthPage />} />
|
<Route path="health" element={<HealthPage />} />
|
||||||
<Route path="routes" element={<RoutesPage />} />
|
<Route path="routes" element={<RoutesPage />} />
|
||||||
|
<Route path="segments" element={<SegmentsPage />} />
|
||||||
<Route path="records" element={<RecordsPage />} />
|
<Route path="records" element={<RecordsPage />} />
|
||||||
<Route path="upload" element={<UploadPage />} />
|
<Route path="upload" element={<UploadPage />} />
|
||||||
<Route path="profile" element={<ProfilePage />} />
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
|
|||||||
@@ -62,16 +62,21 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
|||||||
const domains = useMemo(() => {
|
const domains = useMemo(() => {
|
||||||
const result = {}
|
const result = {}
|
||||||
for (const m of activeMetricConfigs) {
|
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
|
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 min = Math.min(...vals)
|
||||||
const max = Math.max(...vals)
|
const max = Math.max(...vals)
|
||||||
const pad = (max - min) * 0.1 || 1
|
const pad = (max - min) * 0.1 || 1
|
||||||
// For elevation, don't start from 0 - show actual range
|
|
||||||
result[m.key] = [min - pad, max + pad]
|
result[m.key] = [min - pad, max + pad]
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}, [chartData, activeMetricConfigs])
|
}, [chartData, activeMetricConfigs, sportType])
|
||||||
|
|
||||||
if (!chartData.length) {
|
if (!chartData.length) {
|
||||||
return (
|
return (
|
||||||
@@ -115,6 +120,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
|
|||||||
width={40}
|
width={40}
|
||||||
tickFormatter={v => {
|
tickFormatter={v => {
|
||||||
if (metric.key === 'speed_ms') {
|
if (metric.key === 'speed_ms') {
|
||||||
|
if (v <= 0 || v > 25) return ''
|
||||||
if (sportType === 'cycling') return `${(v * 3.6).toFixed(0)}`
|
if (sportType === 'cycling') return `${(v * 3.6).toFixed(0)}`
|
||||||
const spm = 1000 / v
|
const spm = 1000 / v
|
||||||
return `${Math.floor(spm/60)}:${String(Math.floor(spm%60)).padStart(2,'0')}`
|
return `${Math.floor(spm/60)}:${String(Math.floor(spm%60)).padStart(2,'0')}`
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const nav = [
|
|||||||
{ to: '/activities', label: 'Activities', icon: '🏃' },
|
{ to: '/activities', label: 'Activities', icon: '🏃' },
|
||||||
{ to: '/health', label: 'Health', icon: '❤️' },
|
{ to: '/health', label: 'Health', icon: '❤️' },
|
||||||
{ to: '/routes', label: 'Routes', icon: '🗺️' },
|
{ to: '/routes', label: 'Routes', icon: '🗺️' },
|
||||||
|
{ to: '/segments', label: 'Segments', icon: '📏' },
|
||||||
{ to: '/records', label: 'Records', icon: '🏆' },
|
{ to: '/records', label: 'Records', icon: '🏆' },
|
||||||
{ to: '/upload', label: 'Import', icon: '⬆️' },
|
{ to: '/upload', label: 'Import', icon: '⬆️' },
|
||||||
{ to: '/profile', label: 'Profile', icon: '⚙️' },
|
{ to: '/profile', label: 'Profile', icon: '⚙️' },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
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 { useQuery } from '@tanstack/react-query'
|
||||||
|
import { format } from 'date-fns'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import {
|
import {
|
||||||
formatDuration, formatDistance, formatPace, formatHeartRate,
|
formatDuration, formatDistance, formatPace, formatHeartRate,
|
||||||
@@ -10,24 +11,38 @@ import {
|
|||||||
const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking']
|
const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking']
|
||||||
|
|
||||||
export default function ActivitiesPage() {
|
export default function ActivitiesPage() {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
const [sport, setSport] = useState('all')
|
const [sport, setSport] = useState('all')
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
const fromParam = searchParams.get('from')
|
||||||
|
const toParam = searchParams.get('to')
|
||||||
|
|
||||||
const { data: activities, isLoading } = useQuery({
|
const { data: activities, isLoading } = useQuery({
|
||||||
queryKey: ['activities', sport, page],
|
queryKey: ['activities', sport, page, fromParam, toParam],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get('/activities/', {
|
api.get('/activities/', {
|
||||||
params: {
|
params: {
|
||||||
sport_type: sport === 'all' ? undefined : sport,
|
sport_type: sport === 'all' ? undefined : sport,
|
||||||
page,
|
page,
|
||||||
per_page: 20,
|
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),
|
}).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 (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h1 className="text-2xl font-bold text-white">Activities</h1>
|
<h1 className="text-2xl font-bold text-white">Activities</h1>
|
||||||
<Link
|
<Link
|
||||||
to="/upload"
|
to="/upload"
|
||||||
@@ -37,6 +52,28 @@ export default function ActivitiesPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* YTD stats */}
|
||||||
|
{ytdStats && (
|
||||||
|
<div className="flex gap-4 mb-4 text-sm">
|
||||||
|
{ytdStats.running_km > 0 && (
|
||||||
|
<span className="text-blue-400">🏃 {ytdStats.running_km.toFixed(0)} km this year</span>
|
||||||
|
)}
|
||||||
|
{ytdStats.cycling_km > 0 && (
|
||||||
|
<span className="text-orange-400">🚴 {ytdStats.cycling_km.toFixed(0)} km this year</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Date filter chip */}
|
||||||
|
{fromParam && (
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<span className="text-xs bg-blue-600/20 text-blue-300 border border-blue-500/30 px-3 py-1 rounded-full">
|
||||||
|
Week of {format(new Date(fromParam), 'MMM d, yyyy')}
|
||||||
|
</span>
|
||||||
|
<button onClick={clearDateFilter} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">✕ Clear</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Sport filter */}
|
{/* Sport filter */}
|
||||||
<div className="flex gap-2 mb-6 flex-wrap">
|
<div className="flex gap-2 mb-6 flex-wrap">
|
||||||
{SPORTS.map(s => (
|
{SPORTS.map(s => (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
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 api from '../utils/api'
|
||||||
import StatCard from '../components/ui/StatCard'
|
import StatCard from '../components/ui/StatCard'
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
} from '../utils/format'
|
} from '../utils/format'
|
||||||
|
|
||||||
function WeeklyChart({ activities }) {
|
function WeeklyChart({ activities }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
if (!activities?.length) return (
|
if (!activities?.length) return (
|
||||||
<div className="flex items-center justify-center h-36 text-gray-600 text-sm">No activities yet</div>
|
<div className="flex items-center justify-center h-36 text-gray-600 text-sm">No activities yet</div>
|
||||||
)
|
)
|
||||||
@@ -23,26 +25,39 @@ function WeeklyChart({ activities }) {
|
|||||||
|
|
||||||
const data = weeks.map(weekStart => {
|
const data = weeks.map(weekStart => {
|
||||||
const weekKey = format(weekStart, 'MMM d')
|
const weekKey = format(weekStart, 'MMM d')
|
||||||
const weekEnd = new Date(weekStart)
|
const weekEnd = addDays(weekStart, 7)
|
||||||
weekEnd.setDate(weekEnd.getDate() + 7)
|
|
||||||
const km = activities
|
const km = activities
|
||||||
.filter(a => {
|
.filter(a => {
|
||||||
const t = new Date(a.start_time)
|
const t = new Date(a.start_time)
|
||||||
return t >= weekStart && t < weekEnd
|
return t >= weekStart && t < weekEnd
|
||||||
})
|
})
|
||||||
.reduce((s, a) => s + (a.distance_m || 0) / 1000, 0)
|
.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 (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={140}>
|
<ResponsiveContainer width="100%" height={140}>
|
||||||
<BarChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={20}>
|
<BarChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={20}
|
||||||
|
onClick={handleBarClick} style={{ cursor: 'pointer' }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
|
||||||
<XAxis dataKey="week" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} />
|
<XAxis dataKey="week" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} />
|
||||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
|
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
|
||||||
tickFormatter={v => `${v.toFixed(0)}`} />
|
tickFormatter={v => `${v.toFixed(0)}`} />
|
||||||
<Tooltip contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
|
<Tooltip contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
|
||||||
formatter={(v) => [`${v.toFixed(1)} km`, 'Distance']} />
|
formatter={(v) => [`${v.toFixed(1)} km`, 'Distance']}
|
||||||
|
cursor={{ fill: 'rgba(59,130,246,0.1)' }} />
|
||||||
<Bar dataKey="km" fill="#3b82f6" radius={[3, 3, 0, 0]} isAnimationActive={false} />
|
<Bar dataKey="km" fill="#3b82f6" radius={[3, 3, 0, 0]} isAnimationActive={false} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -73,8 +88,12 @@ export default function DashboardPage() {
|
|||||||
queryFn: () => api.get('/records/', { params: { sport_type: 'running' } }).then(r => r.data),
|
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 latest = healthSummary?.latest
|
||||||
const totalDistance = recentActivities?.reduce((s, a) => s + (a.distance_m || 0), 0) ?? 0
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
@@ -84,8 +103,8 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
<StatCard label="Recent activities" value={recentActivities?.length ?? 0} />
|
<StatCard label="Running this year" value={ytdStats ? `${ytdStats.running_km.toFixed(0)} km` : '--'} accent="blue" />
|
||||||
<StatCard label="Total distance" value={formatDistance(totalDistance)} accent="blue" />
|
<StatCard label="Cycling this year" value={ytdStats ? `${ytdStats.cycling_km.toFixed(0)} km` : '--'} accent="orange" />
|
||||||
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)} accent="red" />
|
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)} accent="red" />
|
||||||
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)} />
|
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ function BodyBatteryChart({ bb }) {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4 h-full">
|
||||||
<h3 className="text-sm font-medium text-gray-300">Body Battery</h3>
|
<h3 className="text-sm font-medium text-gray-300">Body Battery</h3>
|
||||||
|
|
||||||
<div className="flex items-center gap-8">
|
<div className="flex items-center gap-8">
|
||||||
@@ -340,21 +340,25 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, onOlder, onNewer,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 24-hour heart rate chart */}
|
{/* 24-hour heart rate chart + body battery (side by side) */}
|
||||||
|
{(intradayHr?.length > 0 || bodyBattery) && (
|
||||||
|
<div className={`grid gap-4 ${intradayHr?.length > 0 && bodyBattery ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'}`}>
|
||||||
{intradayHr?.length > 0 && (
|
{intradayHr?.length > 0 && (
|
||||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 flex flex-col">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h3 className="text-sm font-medium text-gray-300">24-hour Heart Rate</h3>
|
<h3 className="text-sm font-medium text-gray-300">24-hour Heart Rate</h3>
|
||||||
{day.avg_hr_day && (
|
{day.avg_hr_day && (
|
||||||
<span className="text-xs text-gray-500">avg {Math.round(day.avg_hr_day)} bpm</span>
|
<span className="text-xs text-gray-500">avg {Math.round(day.avg_hr_day)} bpm</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
<IntradayHrChart values={intradayHr} />
|
<IntradayHrChart values={intradayHr} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Body battery */}
|
|
||||||
<BodyBatteryChart bb={bodyBattery} />
|
<BodyBatteryChart bb={bodyBattery} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Activity strip */}
|
{/* Activity strip */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="border border-gray-800 rounded-lg p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-white truncate">{seg.name}</span>
|
||||||
|
{seg.auto_generated && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-800 text-gray-500">auto</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{formatSegmentDist(seg.start_distance_m)} – {formatSegmentDist(seg.end_distance_m)}
|
||||||
|
<span className="ml-2 text-gray-600">({formatSegmentDist(seg.end_distance_m - seg.start_distance_m)})</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-right flex-shrink-0">
|
||||||
|
{showTimes && !timesLoading && bestTime && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">Best</p>
|
||||||
|
<p className="text-sm font-mono font-semibold text-yellow-400">{formatDuration(bestTime)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showTimes && !timesLoading && lastTime && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">Last</p>
|
||||||
|
<p className="text-sm font-mono text-gray-300">{formatDuration(lastTime)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTimes(v => !v)}
|
||||||
|
className="text-xs text-blue-400 hover:text-blue-300 transition-colors px-2 py-1 rounded border border-blue-500/30 hover:border-blue-400/50"
|
||||||
|
>
|
||||||
|
{showTimes ? 'Hide' : 'Times'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteMut.mutate()}
|
||||||
|
disabled={deleteMut.isPending}
|
||||||
|
className="text-xs text-gray-600 hover:text-red-400 transition-colors"
|
||||||
|
title="Delete segment"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showTimes && (
|
||||||
|
<div className="pl-1">
|
||||||
|
{timesLoading && <p className="text-xs text-gray-600">Loading times…</p>}
|
||||||
|
{!timesLoading && !times?.length && (
|
||||||
|
<p className="text-xs text-gray-600">No times recorded yet</p>
|
||||||
|
)}
|
||||||
|
{times?.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{times.map((t, i) => (
|
||||||
|
<div key={t.activity_id} className="flex items-center gap-3 text-xs">
|
||||||
|
<span className={`font-mono font-semibold w-14 ${i === 0 && t.duration_s === bestTime ? 'text-yellow-400' : 'text-gray-300'}`}>
|
||||||
|
{formatDuration(t.duration_s)}
|
||||||
|
</span>
|
||||||
|
<Link to={`/activities/${t.activity_id}`} className="text-gray-500 hover:text-blue-400 transition-colors truncate">
|
||||||
|
{t.name}
|
||||||
|
</Link>
|
||||||
|
<span className="text-gray-700 flex-shrink-0">{format(new Date(t.date), 'd MMM yyyy')}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="w-full text-left text-xs text-blue-400 hover:text-blue-300 border border-dashed border-blue-500/30 hover:border-blue-400/50 rounded-lg px-3 py-2 transition-colors"
|
||||||
|
>
|
||||||
|
+ Add segment manually
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<form onSubmit={handleSubmit} className="border border-gray-700 rounded-lg p-3 space-y-2">
|
||||||
|
<p className="text-xs text-gray-400 font-medium">New segment</p>
|
||||||
|
<input
|
||||||
|
type="text" placeholder="Name (e.g. The big hill)"
|
||||||
|
value={name} onChange={e => 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
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="number" placeholder="Start (km)" step="0.01" min="0"
|
||||||
|
value={startKm} onChange={e => 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
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number" placeholder="End (km)" step="0.01" min="0"
|
||||||
|
value={endKm} onChange={e => 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
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" disabled={mut.isPending}
|
||||||
|
className="flex-1 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm py-1.5 rounded transition-colors">
|
||||||
|
{mut.isPending ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setOpen(false)}
|
||||||
|
className="px-4 text-sm text-gray-500 hover:text-gray-300 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Segments</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Route selector */}
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||||
|
<label className="block text-xs text-gray-500 mb-2">Select a route</label>
|
||||||
|
{!routes?.length ? (
|
||||||
|
<p className="text-sm text-gray-600">No named routes yet. <Link to="/routes" className="text-blue-400 hover:underline">Create one on the Routes page.</Link></p>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={selectedRouteId ?? ''}
|
||||||
|
onChange={e => setSelectedRouteId(e.target.value ? parseInt(e.target.value) : null)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">— choose a route —</option>
|
||||||
|
{routes.map(r => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.name}{r.distance_m ? ` (${(r.distance_m / 1000).toFixed(1)} km)` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedRoute && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Route info */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">{selectedRoute.name}</h2>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{selectedRoute.sport_type && <span className="capitalize">{selectedRoute.sport_type}</span>}
|
||||||
|
{selectedRoute.distance_m && <span> · {formatDistance(selectedRoute.distance_m)}</span>}
|
||||||
|
{selectedRoute.auto_detected && <span className="ml-1 text-gray-600">(auto-detected)</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto-generate controls */}
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
|
||||||
|
<p className="text-xs font-medium text-gray-400">Auto-generate segments</p>
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => handleAutoGen('1km')}
|
||||||
|
disabled={autoGenLoading === '1km'}
|
||||||
|
className="text-sm px-3 py-1.5 rounded-lg bg-blue-600/20 text-blue-300 border border-blue-500/30 hover:bg-blue-600/30 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{autoGenLoading === '1km' ? 'Generating…' : '📏 1 km splits'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleAutoGen('turns')}
|
||||||
|
disabled={autoGenLoading === 'turns'}
|
||||||
|
className="text-sm px-3 py-1.5 rounded-lg bg-purple-600/20 text-purple-300 border border-purple-500/30 hover:bg-purple-600/30 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{autoGenLoading === 'turns' ? 'Generating…' : '↩️ Detect turns'}
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleAutoGen('hills', { gradient_pct: hillGradient })}
|
||||||
|
disabled={autoGenLoading === 'hills'}
|
||||||
|
className="text-sm px-3 py-1.5 rounded-lg bg-green-600/20 text-green-300 border border-green-500/30 hover:bg-green-600/30 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{autoGenLoading === 'hills' ? 'Generating…' : '⛰️ Detect hills'}
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-xs text-gray-500">≥</span>
|
||||||
|
<input
|
||||||
|
type="number" min="1" max="30" step="1"
|
||||||
|
value={hillGradient}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-500">%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600">Auto-generate replaces previously auto-generated segments. Manual segments are kept.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Segments list */}
|
||||||
|
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-medium text-gray-300">Segments</h3>
|
||||||
|
{segments?.length > 0 && (
|
||||||
|
<span className="text-xs text-gray-600">{segments.length} segment{segments.length !== 1 ? 's' : ''}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{segsLoading && <p className="text-sm text-gray-600">Loading…</p>}
|
||||||
|
|
||||||
|
{!segsLoading && !segments?.length && (
|
||||||
|
<p className="text-sm text-gray-600">No segments yet. Use auto-generate above or add one manually.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{segments?.map(seg => (
|
||||||
|
<SegmentRow key={seg.id} seg={seg} routeId={selectedRouteId} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
<NewSegmentForm routeId={selectedRouteId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user