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 |
| `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.
+22 -4
View File
@@ -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)
+26
View File
@@ -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
+1
View File
@@ -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")
+5 -1
View File
@@ -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:
+73 -8
View File
@@ -16,13 +16,20 @@ 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(() => {
function buildPaths(polyline, segStartM, segEndM) {
if (!polyline) return null
const coords = decodePolyline(polyline)
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 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
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(' ')
}, [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 = {
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>
)
}
+67 -11
View File
@@ -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'],
+69 -38
View File
@@ -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>
</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>
{/* 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>
)}
{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>
{timesLoading && <p className="text-xs text-gray-600 mt-1">Loading times</p>}
</div>
)}
<div className="flex items-center gap-2 flex-shrink-0">
{times?.length > 0 && (
<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"
>
{showTimes ? 'Hide' : 'Times'}
{expanded ? 'Hide' : 'All'}
</button>
)}
<button
onClick={() => deleteMut.mutate()}
disabled={deleteMut.isPending}
@@ -77,17 +103,12 @@ 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">
{/* 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 ${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)}
</span>
<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>
)
}
@@ -243,9 +262,14 @@ export default function SegmentsPage() {
height={80}
/>
<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 && (
<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} />