Segments and Av HR update
This commit is contained in:
@@ -104,12 +104,12 @@ Required in `.env` (or passed to Docker Compose):
|
||||
| `VITE_MAPBOX_TOKEN` | Optional — enables satellite tile layer |
|
||||
| `POCKETID_ISSUER` / `POCKETID_CLIENT_ID` / `POCKETID_CLIENT_SECRET` | Optional OIDC |
|
||||
|
||||
## Debugging and troubleshooting
|
||||
- The latest build will always be running in docker at ~/milevault_docker with the following container names:
|
||||
## Rules
|
||||
- The current build will always be running in docker at ~/milevault_docker with the following container names:
|
||||
`milevault_backend`
|
||||
`milevault_db`
|
||||
`milevault_frontend`
|
||||
`milevault_redis`
|
||||
`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.
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy import select, desc, func
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -36,6 +36,7 @@ class RouteOut(BaseModel):
|
||||
distance_m: Optional[float]
|
||||
auto_detected: Optional[bool]
|
||||
created_at: datetime
|
||||
activity_count: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -48,6 +49,7 @@ class SegmentOut(BaseModel):
|
||||
end_distance_m: float
|
||||
description: Optional[str]
|
||||
auto_generated: Optional[bool] = False
|
||||
auto_generated_type: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -71,12 +73,26 @@ async def list_routes(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
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(
|
||||
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)
|
||||
.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")
|
||||
@@ -352,11 +368,12 @@ async def auto_generate_segments(
|
||||
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
|
||||
# Clear only auto-generated segments of the same type so other auto types are preserved
|
||||
await db.execute(
|
||||
sql_delete(RouteSegment).where(
|
||||
RouteSegment.route_id == route_id,
|
||||
RouteSegment.auto_generated == True,
|
||||
RouteSegment.auto_generated_type == body.type,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -403,6 +420,7 @@ async def auto_generate_segments(
|
||||
start_distance_m=start_m,
|
||||
end_distance_m=end_m,
|
||||
auto_generated=True,
|
||||
auto_generated_type=body.type,
|
||||
)
|
||||
db.add(seg)
|
||||
new_segments.append(seg)
|
||||
|
||||
@@ -69,9 +69,35 @@ async def init_db():
|
||||
await conn.execute(text(
|
||||
"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:
|
||||
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
|
||||
# index (only current records must be unique per user/sport/distance).
|
||||
# The old constraint also covered is_current_record=False rows, causing
|
||||
|
||||
@@ -182,6 +182,7 @@ class RouteSegment(Base):
|
||||
end_distance_m = Column(Float, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
auto_generated = Column(Boolean, default=False)
|
||||
auto_generated_type = Column(String(20), nullable=True) # '1km' | 'turns' | 'hills'
|
||||
|
||||
route = relationship("NamedRoute", back_populates="segments")
|
||||
|
||||
|
||||
@@ -258,13 +258,17 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db,
|
||||
if 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
|
||||
if hr_raw:
|
||||
raw_vals = hr_raw.get("heartRateValues") or []
|
||||
intraday = [[int(ts), int(v)] for ts, v in raw_vals if v is not None]
|
||||
if 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
|
||||
if bb and intraday:
|
||||
|
||||
@@ -16,36 +16,94 @@ function decodePolyline(encoded) {
|
||||
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
|
||||
const VW = 100
|
||||
const VH = 80
|
||||
const PAD = 6
|
||||
|
||||
export default function RouteMiniMap({ polyline, sportType, width = 80, height = 60 }) {
|
||||
const pathD = useMemo(() => {
|
||||
if (!polyline) return null
|
||||
const coords = decodePolyline(polyline)
|
||||
if (coords.length < 2) return null
|
||||
function buildPaths(polyline, segStartM, segEndM) {
|
||||
if (!polyline) return null
|
||||
const coords = decodePolyline(polyline)
|
||||
if (coords.length < 2) return null
|
||||
|
||||
const lats = coords.map(c => c[0])
|
||||
const lngs = coords.map(c => c[1])
|
||||
const minLat = Math.min(...lats), maxLat = Math.max(...lats)
|
||||
const minLng = Math.min(...lngs), maxLng = Math.max(...lngs)
|
||||
const latRange = maxLat - minLat || 0.001
|
||||
const lngRange = maxLng - minLng || 0.001
|
||||
const lats = coords.map(c => c[0])
|
||||
const lngs = coords.map(c => c[1])
|
||||
const minLat = Math.min(...lats), maxLat = Math.max(...lats)
|
||||
const minLng = Math.min(...lngs), maxLng = Math.max(...lngs)
|
||||
const latRange = maxLat - minLat || 0.001
|
||||
const lngRange = maxLng - minLng || 0.001
|
||||
|
||||
const drawW = VW - PAD * 2
|
||||
const drawH = VH - PAD * 2
|
||||
const scale = Math.min(drawW / lngRange, drawH / latRange)
|
||||
const offX = PAD + (drawW - lngRange * scale) / 2
|
||||
const offY = PAD + (drawH - latRange * scale) / 2
|
||||
const drawW = VW - PAD * 2
|
||||
const drawH = VH - PAD * 2
|
||||
const scale = Math.min(drawW / lngRange, drawH / latRange)
|
||||
const offX = PAD + (drawW - lngRange * scale) / 2
|
||||
const offY = PAD + (drawH - latRange * scale) / 2
|
||||
|
||||
return coords.map((c, i) => {
|
||||
const x = offX + (c[1] - minLng) * scale
|
||||
const y = offY + (maxLat - c[0]) * scale
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
|
||||
}).join(' ')
|
||||
}, [polyline])
|
||||
const toXY = ([lat, lng]) => [
|
||||
offX + (lng - minLng) * 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)}`
|
||||
}).join(' ')
|
||||
|
||||
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 = {
|
||||
viewBox: `0 0 ${VW} ${VH}`,
|
||||
@@ -54,15 +112,22 @@ export default function RouteMiniMap({ polyline, sportType, width = 80, height =
|
||||
style: { background: '#111827', width, height },
|
||||
}
|
||||
|
||||
if (!pathD) return (
|
||||
if (!paths) return (
|
||||
<svg {...svgProps}>
|
||||
<text x={VW / 2} y={VH / 2} textAnchor="middle" dominantBaseline="middle" fill="#374151" fontSize="10">—</text>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const baseColor = paths.segPath ? '#374151' : sportColor(sportType)
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -153,16 +153,71 @@ function BodyBatteryChart({ bb, hiresValues }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SleepStagesBar({ deep, light, rem, awake }) {
|
||||
const total = (deep || 0) + (light || 0) + (rem || 0) + (awake || 0)
|
||||
if (!total) return null
|
||||
const pct = s => `${((s || 0) / total * 100).toFixed(1)}%`
|
||||
// Sleep timeline bar spanning from sleep_start to sleep_end with proportional stage coloring
|
||||
function SleepTimeline({ sleepStart, sleepEnd, deep, light, rem, awake }) {
|
||||
if (!sleepStart || !sleepEnd) return null
|
||||
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 (
|
||||
<div className="flex rounded-full overflow-hidden h-2.5 w-full">
|
||||
<div style={{ width: pct(deep), backgroundColor: '#6366f1' }} />
|
||||
<div style={{ width: pct(rem), backgroundColor: '#8b5cf6' }} />
|
||||
<div style={{ width: pct(light), backgroundColor: '#a78bfa' }} />
|
||||
<div style={{ width: pct(awake), backgroundColor: '#374151' }} />
|
||||
<div className="space-y-1.5">
|
||||
{/* Time bar */}
|
||||
<div className="relative">
|
||||
<div className="flex rounded-md overflow-hidden h-5 w-full">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -253,11 +308,12 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, onOlder,
|
||||
</div>
|
||||
{hasSleepStages ? (
|
||||
<>
|
||||
<SleepStagesBar
|
||||
<SleepTimeline
|
||||
sleepStart={day.sleep_start} sleepEnd={day.sleep_end}
|
||||
deep={day.sleep_deep_s} light={day.sleep_light_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'],
|
||||
['REM', day.sleep_rem_s, '#8b5cf6'],
|
||||
|
||||
@@ -11,61 +11,87 @@ function formatSegmentDist(m) {
|
||||
return m >= 1000 ? `${(m / 1000).toFixed(2)} km` : `${Math.round(m)} m`
|
||||
}
|
||||
|
||||
function SegmentRow({ seg, routeId, onDeleted }) {
|
||||
const [showTimes, setShowTimes] = useState(false)
|
||||
function SegmentRow({ seg, routeId, routePolyline, sportType }) {
|
||||
const [expanded, setExpanded] = 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()
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['segments', routeId] }),
|
||||
})
|
||||
|
||||
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="border border-gray-800 rounded-lg overflow-hidden">
|
||||
{/* 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 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>
|
||||
{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>
|
||||
<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)}
|
||||
<span className="ml-2 text-gray-600">({formatSegmentDist(seg.end_distance_m - seg.start_distance_m)})</span>
|
||||
</p>
|
||||
{/* Times preview row */}
|
||||
{!timesLoading && (
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
{bestTime && (
|
||||
<span className="text-xs font-mono text-yellow-400">
|
||||
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>
|
||||
)}
|
||||
{timesLoading && <p className="text-xs text-gray-600 mt-1">Loading times…</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>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{times?.length > 0 && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{expanded ? 'Hide' : 'All'}
|
||||
</button>
|
||||
)}
|
||||
{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}
|
||||
@@ -77,27 +103,20 @@ function SegmentRow({ seg, routeId, onDeleted }) {
|
||||
</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>
|
||||
))}
|
||||
{/* Expanded times list */}
|
||||
{expanded && times?.length > 0 && (
|
||||
<div className="border-t border-gray-800 px-3 pb-3 pt-2 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 ${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>
|
||||
@@ -243,9 +262,14 @@ export default function SegmentsPage() {
|
||||
height={80}
|
||||
/>
|
||||
<p className="text-xs font-medium text-white mt-2 truncate">{r.name}</p>
|
||||
{r.distance_m && (
|
||||
<p className="text-xs text-gray-500">{(r.distance_m / 1000).toFixed(1)} km</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
{r.distance_m && (
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
@@ -260,6 +284,7 @@ export default function SegmentsPage() {
|
||||
<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.activity_count > 0 && <span> · {selectedRoute.activity_count} runs</span>}
|
||||
{selectedRoute.auto_detected && <span className="ml-1 text-gray-600">(auto-detected)</span>}
|
||||
</p>
|
||||
</div>
|
||||
@@ -303,7 +328,7 @@ export default function SegmentsPage() {
|
||||
</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>
|
||||
|
||||
{/* Segments list */}
|
||||
@@ -322,7 +347,13 @@ export default function SegmentsPage() {
|
||||
)}
|
||||
|
||||
{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} />
|
||||
|
||||
Reference in New Issue
Block a user