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 |
|
| `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.
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -16,36 +16,94 @@ 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
|
|
||||||
|
|
||||||
const lats = coords.map(c => c[0])
|
const lats = coords.map(c => c[0])
|
||||||
const lngs = coords.map(c => c[1])
|
const lngs = coords.map(c => c[1])
|
||||||
const minLat = Math.min(...lats), maxLat = Math.max(...lats)
|
const minLat = Math.min(...lats), maxLat = Math.max(...lats)
|
||||||
const minLng = Math.min(...lngs), maxLng = Math.max(...lngs)
|
const minLng = Math.min(...lngs), maxLng = Math.max(...lngs)
|
||||||
const latRange = maxLat - minLat || 0.001
|
const latRange = maxLat - minLat || 0.001
|
||||||
const lngRange = maxLng - minLng || 0.001
|
const lngRange = maxLng - minLng || 0.001
|
||||||
|
|
||||||
const drawW = VW - PAD * 2
|
const drawW = VW - PAD * 2
|
||||||
const drawH = VH - PAD * 2
|
const drawH = VH - PAD * 2
|
||||||
const scale = Math.min(drawW / lngRange, drawH / latRange)
|
const scale = Math.min(drawW / lngRange, drawH / latRange)
|
||||||
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,
|
||||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
|
]
|
||||||
}).join(' ')
|
|
||||||
}, [polyline])
|
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 = {
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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>
|
||||||
|
{/* 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>
|
||||||
<div className="flex items-center gap-3 text-right flex-shrink-0">
|
|
||||||
{showTimes && !timesLoading && bestTime && (
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
<div>
|
{times?.length > 0 && (
|
||||||
<p className="text-xs text-gray-500">Best</p>
|
<button
|
||||||
<p className="text-sm font-mono font-semibold text-yellow-400">{formatDuration(bestTime)}</p>
|
onClick={() => setExpanded(v => !v)}
|
||||||
</div>
|
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
|
<button
|
||||||
onClick={() => deleteMut.mutate()}
|
onClick={() => deleteMut.mutate()}
|
||||||
disabled={deleteMut.isPending}
|
disabled={deleteMut.isPending}
|
||||||
@@ -77,27 +103,20 @@ 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 && (
|
{times.map((t, i) => (
|
||||||
<p className="text-xs text-gray-600">No times recorded yet</p>
|
<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'}`}>
|
||||||
{times?.length > 0 && (
|
{formatDuration(t.duration_s)}
|
||||||
<div className="space-y-1">
|
</span>
|
||||||
{times.map((t, i) => (
|
<Link to={`/activities/${t.activity_id}`} className="text-gray-500 hover:text-blue-400 transition-colors truncate">
|
||||||
<div key={t.activity_id} className="flex items-center gap-3 text-xs">
|
{t.name}
|
||||||
<span className={`font-mono font-semibold w-14 ${i === 0 && t.duration_s === bestTime ? 'text-yellow-400' : 'text-gray-300'}`}>
|
</Link>
|
||||||
{formatDuration(t.duration_s)}
|
<span className="text-gray-700 flex-shrink-0">{format(new Date(t.date), 'd MMM yyyy')}</span>
|
||||||
</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>
|
</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>
|
||||||
{r.distance_m && (
|
<div className="flex items-center justify-between mt-0.5">
|
||||||
<p className="text-xs text-gray-500">{(r.distance_m / 1000).toFixed(1)} km</p>
|
{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>
|
</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} />
|
||||||
|
|||||||
Reference in New Issue
Block a user