Segments and Av HR update
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 7s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 22s

This commit is contained in:
2026-06-07 17:12:27 +01:00
parent 4a4cbdcc92
commit bf1920eb9d
8 changed files with 299 additions and 98 deletions
+3 -3
View File
@@ -104,12 +104,12 @@ Required in `.env` (or passed to Docker Compose):
| `VITE_MAPBOX_TOKEN` | Optional — enables satellite tile layer | | `VITE_MAPBOX_TOKEN` | Optional — enables satellite tile layer |
| `POCKETID_ISSUER` / `POCKETID_CLIENT_ID` / `POCKETID_CLIENT_SECRET` | Optional OIDC | | `POCKETID_ISSUER` / `POCKETID_CLIENT_ID` / `POCKETID_CLIENT_SECRET` | Optional OIDC |
## Debugging and troubleshooting ## Rules
- The latest build will always be running in docker at ~/milevault_docker with the following container names: - The current build will always be running in docker at ~/milevault_docker with the following container names:
`milevault_backend` `milevault_backend`
`milevault_db` `milevault_db`
`milevault_frontend` `milevault_frontend`
`milevault_redis` `milevault_redis`
`milevault_worker` `milevault_worker`
- When an issue is highlighted by the user, check the logs on these containers for the error, do not spin up new containers, rectify the issues in ~/milevault where the development is happening - When an issue is highlighted by the user, check the logs on these containers for the error, do not spin up new containers, use these for finding the problem, rectify the issues in ~/milevault project without running the updated versions, push to git instead.
- Do NOT patch the running files under any circumstances, fix the development files. - Do NOT patch the running files under any circumstances, fix the development files.
+22 -4
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc from sqlalchemy import select, desc, func
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -36,6 +36,7 @@ class RouteOut(BaseModel):
distance_m: Optional[float] distance_m: Optional[float]
auto_detected: Optional[bool] auto_detected: Optional[bool]
created_at: datetime created_at: datetime
activity_count: int = 0
class Config: class Config:
from_attributes = True from_attributes = True
@@ -48,6 +49,7 @@ class SegmentOut(BaseModel):
end_distance_m: float end_distance_m: float
description: Optional[str] description: Optional[str]
auto_generated: Optional[bool] = False auto_generated: Optional[bool] = False
auto_generated_type: Optional[str] = None
class Config: class Config:
from_attributes = True from_attributes = True
@@ -71,12 +73,26 @@ async def list_routes(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
# Fetch routes with activity counts in one query
count_subq = (
select(Activity.named_route_id, func.count(Activity.id).label("cnt"))
.where(Activity.user_id == current_user.id, Activity.named_route_id.isnot(None))
.group_by(Activity.named_route_id)
.subquery()
)
result = await db.execute( result = await db.execute(
select(NamedRoute) select(NamedRoute, func.coalesce(count_subq.c.cnt, 0).label("activity_count"))
.outerjoin(count_subq, NamedRoute.id == count_subq.c.named_route_id)
.where(NamedRoute.user_id == current_user.id) .where(NamedRoute.user_id == current_user.id)
.order_by(desc(NamedRoute.created_at)) .order_by(desc(NamedRoute.created_at))
) )
return result.scalars().all() rows = result.all()
out = []
for route, cnt in rows:
d = {c.name: getattr(route, c.name) for c in route.__table__.columns}
d["activity_count"] = cnt
out.append(RouteOut(**d))
return out
@router.get("/recent-activities") @router.get("/recent-activities")
@@ -352,11 +368,12 @@ async def auto_generate_segments(
if body.type not in ("1km", "turns", "hills"): if body.type not in ("1km", "turns", "hills"):
raise HTTPException(status_code=400, detail="type must be '1km', 'turns', or 'hills'") raise HTTPException(status_code=400, detail="type must be '1km', 'turns', or 'hills'")
# Clear existing auto-generated segments of this type # Clear only auto-generated segments of the same type so other auto types are preserved
await db.execute( await db.execute(
sql_delete(RouteSegment).where( sql_delete(RouteSegment).where(
RouteSegment.route_id == route_id, RouteSegment.route_id == route_id,
RouteSegment.auto_generated == True, RouteSegment.auto_generated == True,
RouteSegment.auto_generated_type == body.type,
) )
) )
@@ -403,6 +420,7 @@ async def auto_generate_segments(
start_distance_m=start_m, start_distance_m=start_m,
end_distance_m=end_m, end_distance_m=end_m,
auto_generated=True, auto_generated=True,
auto_generated_type=body.type,
) )
db.add(seg) db.add(seg)
new_segments.append(seg) new_segments.append(seg)
+26
View File
@@ -69,9 +69,35 @@ async def init_db():
await conn.execute(text( await conn.execute(text(
"ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated BOOLEAN DEFAULT FALSE" "ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated BOOLEAN DEFAULT FALSE"
)) ))
await conn.execute(text(
"ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated_type VARCHAR(20)"
))
except Exception as e: except Exception as e:
print(f"route_segments column migration skipped: {e}") print(f"route_segments column migration skipped: {e}")
# Backfill avg_hr_day / max_hr_day from intraday_hr for Garmin Connect synced days
try:
async with engine.begin() as conn:
await conn.execute(text("""
UPDATE health_metrics SET
avg_hr_day = sub.avg_hr,
max_hr_day = sub.max_hr
FROM (
SELECT id,
AVG((elem->>1)::float) AS avg_hr,
MAX((elem->>1)::float) AS max_hr
FROM health_metrics,
json_array_elements(intraday_hr) AS elem
WHERE (avg_hr_day IS NULL OR max_hr_day IS NULL)
AND intraday_hr IS NOT NULL
AND (elem->>1)::float > 0
GROUP BY id
) sub
WHERE health_metrics.id = sub.id
"""))
except Exception as e:
print(f"avg_hr_day backfill 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
+1
View File
@@ -182,6 +182,7 @@ class RouteSegment(Base):
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) auto_generated = Column(Boolean, default=False)
auto_generated_type = Column(String(20), nullable=True) # '1km' | 'turns' | 'hills'
route = relationship("NamedRoute", back_populates="segments") route = relationship("NamedRoute", back_populates="segments")
+5 -1
View File
@@ -258,13 +258,17 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db,
if bb: if bb:
row["body_battery"] = _json.dumps(bb) row["body_battery"] = _json.dumps(bb)
# Intraday heart rate — store non-null [epoch_ms, bpm] pairs # Intraday heart rate — store non-null [epoch_ms, bpm] pairs + compute daily averages
intraday = None intraday = None
if hr_raw: if hr_raw:
raw_vals = hr_raw.get("heartRateValues") or [] raw_vals = hr_raw.get("heartRateValues") or []
intraday = [[int(ts), int(v)] for ts, v in raw_vals if v is not None] intraday = [[int(ts), int(v)] for ts, v in raw_vals if v is not None]
if intraday: if intraday:
row["intraday_hr"] = intraday row["intraday_hr"] = intraday
hr_vals = [v for _, v in intraday if v > 0]
if hr_vals:
row["avg_hr_day"] = round(sum(hr_vals) / len(hr_vals), 1)
row["max_hr_day"] = float(max(hr_vals))
# High-resolution body battery derived from BB checkpoints + intraday HR # High-resolution body battery derived from BB checkpoints + intraday HR
if bb and intraday: if bb and intraday:
+73 -8
View File
@@ -16,13 +16,20 @@ function decodePolyline(encoded) {
return coords return coords
} }
function haversineDist([lat1, lng1], [lat2, lng2]) {
const R = 6371000
const dLat = (lat2 - lat1) * Math.PI / 180
const dLng = (lng2 - lng1) * Math.PI / 180
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}
// Internal viewBox dimensions — path is always drawn into this space, SVG scales it // Internal viewBox dimensions — path is always drawn into this space, SVG scales it
const VW = 100 const VW = 100
const VH = 80 const VH = 80
const PAD = 6 const PAD = 6
export default function RouteMiniMap({ polyline, sportType, width = 80, height = 60 }) { function buildPaths(polyline, segStartM, segEndM) {
const pathD = useMemo(() => {
if (!polyline) return null if (!polyline) return null
const coords = decodePolyline(polyline) const coords = decodePolyline(polyline)
if (coords.length < 2) return null if (coords.length < 2) return null
@@ -40,12 +47,63 @@ export default function RouteMiniMap({ polyline, sportType, width = 80, height =
const offX = PAD + (drawW - lngRange * scale) / 2 const offX = PAD + (drawW - lngRange * scale) / 2
const offY = PAD + (drawH - latRange * scale) / 2 const offY = PAD + (drawH - latRange * scale) / 2
return coords.map((c, i) => { const toXY = ([lat, lng]) => [
const x = offX + (c[1] - minLng) * scale offX + (lng - minLng) * scale,
const y = offY + (maxLat - c[0]) * scale offY + (maxLat - lat) * scale,
]
const fullPath = coords.map((c, i) => {
const [x, y] = toXY(c)
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}` return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
}).join(' ') }).join(' ')
}, [polyline])
if (segStartM == null || segEndM == null) return { fullPath, segPath: null }
// Compute cumulative distances to find segment slice
const cumDist = [0]
for (let i = 1; i < coords.length; i++) {
cumDist.push(cumDist[i - 1] + haversineDist(coords[i - 1], coords[i]))
}
const totalDist = cumDist[cumDist.length - 1] || 1
// Interpolate a point at a given distance along the route
const interpAt = (targetM) => {
for (let i = 1; i < cumDist.length; i++) {
if (cumDist[i] >= targetM || i === cumDist.length - 1) {
const t = cumDist[i] === cumDist[i - 1] ? 0 : (targetM - cumDist[i - 1]) / (cumDist[i] - cumDist[i - 1])
const lat = coords[i - 1][0] + t * (coords[i][0] - coords[i - 1][0])
const lng = coords[i - 1][1] + t * (coords[i][1] - coords[i - 1][1])
return [lat, lng]
}
}
return coords[coords.length - 1]
}
const clampedStart = Math.max(0, Math.min(segStartM, totalDist))
const clampedEnd = Math.max(0, Math.min(segEndM, totalDist))
// Collect segment points: interpolated start + all interior coords + interpolated end
const segCoords = [interpAt(clampedStart)]
for (let i = 0; i < coords.length; i++) {
if (cumDist[i] > clampedStart && cumDist[i] < clampedEnd) {
segCoords.push(coords[i])
}
}
segCoords.push(interpAt(clampedEnd))
const segPath = segCoords.map((c, i) => {
const [x, y] = toXY(c)
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
}).join(' ')
return { fullPath, segPath }
}
export default function RouteMiniMap({ polyline, sportType, width = 80, height = 60, segmentStartM, segmentEndM }) {
const paths = useMemo(
() => buildPaths(polyline, segmentStartM, segmentEndM),
[polyline, segmentStartM, segmentEndM],
)
const svgProps = { const svgProps = {
viewBox: `0 0 ${VW} ${VH}`, viewBox: `0 0 ${VW} ${VH}`,
@@ -54,15 +112,22 @@ export default function RouteMiniMap({ polyline, sportType, width = 80, height =
style: { background: '#111827', width, height }, style: { background: '#111827', width, height },
} }
if (!pathD) return ( if (!paths) return (
<svg {...svgProps}> <svg {...svgProps}>
<text x={VW / 2} y={VH / 2} textAnchor="middle" dominantBaseline="middle" fill="#374151" fontSize="10"></text> <text x={VW / 2} y={VH / 2} textAnchor="middle" dominantBaseline="middle" fill="#374151" fontSize="10"></text>
</svg> </svg>
) )
const baseColor = paths.segPath ? '#374151' : sportColor(sportType)
return ( return (
<svg {...svgProps}> <svg {...svgProps}>
<path d={pathD} fill="none" stroke={sportColor(sportType)} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" /> <path d={paths.fullPath} fill="none" stroke={baseColor} strokeWidth={paths.segPath ? 1.5 : 2}
strokeLinejoin="round" strokeLinecap="round" />
{paths.segPath && (
<path d={paths.segPath} fill="none" stroke="#f97316" strokeWidth="3"
strokeLinejoin="round" strokeLinecap="round" />
)}
</svg> </svg>
) )
} }
+67 -11
View File
@@ -153,16 +153,71 @@ function BodyBatteryChart({ bb, hiresValues }) {
) )
} }
function SleepStagesBar({ deep, light, rem, awake }) { // Sleep timeline bar spanning from sleep_start to sleep_end with proportional stage coloring
const total = (deep || 0) + (light || 0) + (rem || 0) + (awake || 0) function SleepTimeline({ sleepStart, sleepEnd, deep, light, rem, awake }) {
if (!total) return null if (!sleepStart || !sleepEnd) return null
const pct = s => `${((s || 0) / total * 100).toFixed(1)}%` const stageSecs = (deep || 0) + (light || 0) + (rem || 0) + (awake || 0)
if (!stageSecs) return null
const startMs = new Date(sleepStart).getTime()
const endMs = new Date(sleepEnd).getTime()
const windowMs = endMs - startMs
if (windowMs <= 0) return null
// Build stage segments proportional to duration, but rendered across the sleep window
const stages = [
{ key: 'deep', secs: deep || 0, color: '#6366f1', label: 'Deep' },
{ key: 'rem', secs: rem || 0, color: '#8b5cf6', label: 'REM' },
{ key: 'light', secs: light || 0, color: '#a78bfa', label: 'Light' },
{ key: 'awake', secs: awake || 0, color: '#374151', label: 'Awake' },
].filter(s => s.secs > 0)
// Generate hour tick marks within the sleep window
const startHour = new Date(startMs)
startHour.setMinutes(0, 0, 0)
startHour.setHours(startHour.getHours() + 1)
const ticks = []
let tick = startHour.getTime()
while (tick < endMs) {
const pct = Math.min(100, Math.max(0, (tick - startMs) / windowMs * 100))
ticks.push({ pct, label: new Date(tick).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) })
tick += 3600000
}
return ( return (
<div className="flex rounded-full overflow-hidden h-2.5 w-full"> <div className="space-y-1.5">
<div style={{ width: pct(deep), backgroundColor: '#6366f1' }} /> {/* Time bar */}
<div style={{ width: pct(rem), backgroundColor: '#8b5cf6' }} /> <div className="relative">
<div style={{ width: pct(light), backgroundColor: '#a78bfa' }} /> <div className="flex rounded-md overflow-hidden h-5 w-full">
<div style={{ width: pct(awake), backgroundColor: '#374151' }} /> {stages.map((s, i) => (
<div
key={s.key}
style={{ width: `${(s.secs / stageSecs * 100).toFixed(2)}%`, backgroundColor: s.color }}
/>
))}
</div>
{/* Tick marks */}
{ticks.map((t, i) => (
<div key={i} className="absolute top-0 h-5 flex flex-col items-center pointer-events-none" style={{ left: `${t.pct}%` }}>
<div className="w-px h-full bg-black/40" />
</div>
))}
</div>
{/* Time labels */}
<div className="relative h-4">
<span className="absolute left-0 text-xs text-gray-500" style={{ transform: 'translateX(-0%)' }}>
{new Date(startMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
</span>
{ticks.map((t, i) => (
<span key={i} className="absolute text-xs text-gray-600"
style={{ left: `${t.pct}%`, transform: 'translateX(-50%)' }}>
{t.label}
</span>
))}
<span className="absolute right-0 text-xs text-gray-500">
{new Date(endMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</div> </div>
) )
} }
@@ -253,11 +308,12 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, onOlder,
</div> </div>
{hasSleepStages ? ( {hasSleepStages ? (
<> <>
<SleepStagesBar <SleepTimeline
sleepStart={day.sleep_start} sleepEnd={day.sleep_end}
deep={day.sleep_deep_s} light={day.sleep_light_s} deep={day.sleep_deep_s} light={day.sleep_light_s}
rem={day.sleep_rem_s} awake={day.sleep_awake_s} rem={day.sleep_rem_s} awake={day.sleep_awake_s}
/> />
<div className="flex flex-wrap gap-x-5 gap-y-1.5"> <div className="flex flex-wrap gap-x-5 gap-y-1.5 mt-1">
{[ {[
['Deep', day.sleep_deep_s, '#6366f1'], ['Deep', day.sleep_deep_s, '#6366f1'],
['REM', day.sleep_rem_s, '#8b5cf6'], ['REM', day.sleep_rem_s, '#8b5cf6'],
+69 -38
View File
@@ -11,61 +11,87 @@ function formatSegmentDist(m) {
return m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${Math.round(m)} m` return m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${Math.round(m)} m`
} }
function SegmentRow({ seg, routeId, onDeleted }) { function SegmentRow({ seg, routeId, routePolyline, sportType }) {
const [showTimes, setShowTimes] = useState(false) const [expanded, setExpanded] = useState(false)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { data: times, isLoading: timesLoading } = useQuery({ const { data: times, isLoading: timesLoading } = useQuery({
queryKey: ['segment-times', routeId, seg.id], queryKey: ['segment-times', routeId, seg.id],
queryFn: () => api.get(`/routes/${routeId}/segments/${seg.id}/times`).then(r => r.data), queryFn: () => api.get(`/routes/${routeId}/segments/${seg.id}/times`).then(r => r.data),
enabled: showTimes,
}) })
const deleteMut = useMutation({ const deleteMut = useMutation({
mutationFn: () => api.delete(`/routes/${routeId}/segments/${seg.id}`), mutationFn: () => api.delete(`/routes/${routeId}/segments/${seg.id}`),
onSuccess: () => { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['segments', routeId] }),
queryClient.invalidateQueries({ queryKey: ['segments', routeId] })
if (onDeleted) onDeleted()
},
}) })
const bestTime = times?.length ? Math.min(...times.map(t => t.duration_s)) : null const bestTime = times?.length ? Math.min(...times.map(t => t.duration_s)) : null
const lastTime = times?.[0]?.duration_s ?? null const lastTime = times?.[0]?.duration_s ?? null
return ( return (
<div className="border border-gray-800 rounded-lg p-3 space-y-2"> <div className="border border-gray-800 rounded-lg overflow-hidden">
<div className="flex items-center gap-3"> {/* Main row */}
<div className="flex items-center gap-3 p-3">
{/* Segment mini-map */}
<div className="flex-shrink-0">
<RouteMiniMap
polyline={routePolyline}
sportType={sportType}
width={72}
height={56}
segmentStartM={seg.start_distance_m}
segmentEndM={seg.end_distance_m}
/>
</div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-white truncate">{seg.name}</span> <span className="text-sm font-medium text-white truncate">{seg.name}</span>
{seg.auto_generated && ( {seg.auto_generated && (
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-800 text-gray-500">auto</span> <span className="text-xs px-1.5 py-0.5 rounded bg-gray-800 text-gray-500">
{seg.auto_generated_type || 'auto'}
</span>
)} )}
</div> </div>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500 mt-0.5">
{formatSegmentDist(seg.start_distance_m)} {formatSegmentDist(seg.end_distance_m)} {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> <span className="ml-2 text-gray-600">({formatSegmentDist(seg.end_distance_m - seg.start_distance_m)})</span>
</p> </p>
</div> {/* Times preview row */}
<div className="flex items-center gap-3 text-right flex-shrink-0"> {!timesLoading && (
{showTimes && !timesLoading && bestTime && ( <div className="flex items-center gap-3 mt-1">
<div> {bestTime && (
<p className="text-xs text-gray-500">Best</p> <span className="text-xs font-mono text-yellow-400">
<p className="text-sm font-mono font-semibold text-yellow-400">{formatDuration(bestTime)}</p> Best {formatDuration(bestTime)}
</span>
)}
{lastTime && lastTime !== bestTime && (
<span className="text-xs font-mono text-gray-400">
Last {formatDuration(lastTime)}
</span>
)}
{times?.length > 0 && (
<span className="text-xs text-gray-600">
{times.length} run{times.length !== 1 ? 's' : ''}
</span>
)}
{times?.length === 0 && (
<span className="text-xs text-gray-600">No times yet</span>
)}
</div> </div>
)} )}
{showTimes && !timesLoading && lastTime && ( {timesLoading && <p className="text-xs text-gray-600 mt-1">Loading times</p>}
<div>
<p className="text-xs text-gray-500">Last</p>
<p className="text-sm font-mono text-gray-300">{formatDuration(lastTime)}</p>
</div> </div>
)}
<div className="flex items-center gap-2 flex-shrink-0">
{times?.length > 0 && (
<button <button
onClick={() => setShowTimes(v => !v)} onClick={() => setExpanded(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" 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'} {expanded ? 'Hide' : 'All'}
</button> </button>
)}
<button <button
onClick={() => deleteMut.mutate()} onClick={() => deleteMut.mutate()}
disabled={deleteMut.isPending} disabled={deleteMut.isPending}
@@ -77,17 +103,12 @@ function SegmentRow({ seg, routeId, onDeleted }) {
</div> </div>
</div> </div>
{showTimes && ( {/* Expanded times list */}
<div className="pl-1"> {expanded && times?.length > 0 && (
{timesLoading && <p className="text-xs text-gray-600">Loading times</p>} <div className="border-t border-gray-800 px-3 pb-3 pt-2 space-y-1">
{!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) => ( {times.map((t, i) => (
<div key={t.activity_id} className="flex items-center gap-3 text-xs"> <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'}`}> <span className={`font-mono font-semibold w-14 ${t.duration_s === bestTime ? 'text-yellow-400' : 'text-gray-300'}`}>
{formatDuration(t.duration_s)} {formatDuration(t.duration_s)}
</span> </span>
<Link to={`/activities/${t.activity_id}`} className="text-gray-500 hover:text-blue-400 transition-colors truncate"> <Link to={`/activities/${t.activity_id}`} className="text-gray-500 hover:text-blue-400 transition-colors truncate">
@@ -99,8 +120,6 @@ function SegmentRow({ seg, routeId, onDeleted }) {
</div> </div>
)} )}
</div> </div>
)}
</div>
) )
} }
@@ -243,9 +262,14 @@ export default function SegmentsPage() {
height={80} height={80}
/> />
<p className="text-xs font-medium text-white mt-2 truncate">{r.name}</p> <p className="text-xs font-medium text-white mt-2 truncate">{r.name}</p>
<div className="flex items-center justify-between mt-0.5">
{r.distance_m && ( {r.distance_m && (
<p className="text-xs text-gray-500">{(r.distance_m / 1000).toFixed(1)} km</p> <p className="text-xs text-gray-500">{(r.distance_m / 1000).toFixed(1)} km</p>
)} )}
{r.activity_count > 0 && (
<p className="text-xs text-gray-500">{r.activity_count} run{r.activity_count !== 1 ? 's' : ''}</p>
)}
</div>
</button> </button>
))} ))}
</div> </div>
@@ -260,6 +284,7 @@ export default function SegmentsPage() {
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
{selectedRoute.sport_type && <span className="capitalize">{selectedRoute.sport_type}</span>} {selectedRoute.sport_type && <span className="capitalize">{selectedRoute.sport_type}</span>}
{selectedRoute.distance_m && <span> · {formatDistance(selectedRoute.distance_m)}</span>} {selectedRoute.distance_m && <span> · {formatDistance(selectedRoute.distance_m)}</span>}
{selectedRoute.activity_count > 0 && <span> · {selectedRoute.activity_count} runs</span>}
{selectedRoute.auto_detected && <span className="ml-1 text-gray-600">(auto-detected)</span>} {selectedRoute.auto_detected && <span className="ml-1 text-gray-600">(auto-detected)</span>}
</p> </p>
</div> </div>
@@ -303,7 +328,7 @@ export default function SegmentsPage() {
</div> </div>
</div> </div>
</div> </div>
<p className="text-xs text-gray-600">Auto-generate replaces previously auto-generated segments. Manual segments are kept.</p> <p className="text-xs text-gray-600">Each auto-generate type (splits, turns, hills) replaces only its own previous segments. Manual segments are always kept.</p>
</div> </div>
{/* Segments list */} {/* Segments list */}
@@ -322,7 +347,13 @@ export default function SegmentsPage() {
)} )}
{segments?.map(seg => ( {segments?.map(seg => (
<SegmentRow key={seg.id} seg={seg} routeId={selectedRouteId} /> <SegmentRow
key={seg.id}
seg={seg}
routeId={selectedRouteId}
routePolyline={selectedRoute.reference_polyline}
sportType={selectedRoute.sport_type}
/>
))} ))}
<NewSegmentForm routeId={selectedRouteId} /> <NewSegmentForm routeId={selectedRouteId} />