Segments and Av HR update
This commit is contained in:
@@ -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