All tweaks added
Build and push images / build-backend (push) Successful in 33s
Build and push images / build-worker (push) Successful in 32s
Build and push images / build-frontend (push) Failing after 6s

This commit is contained in:
2026-06-06 18:10:35 +01:00
parent 043b3b7269
commit ec5a01d12a
92 changed files with 7517 additions and 784 deletions
@@ -0,0 +1,105 @@
import { useEffect, useRef } from 'react'
import L from 'leaflet'
import { sportColor } from '../../utils/format'
delete L.Icon.Default.prototype._getIconUrl
L.Icon.Default.mergeOptions({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
})
const TILE_LAYERS = {
dark: {
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
},
street: {
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
},
satellite: {
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: '© <a href="https://www.esri.com/">Esri</a>',
},
}
function decodePolyline(encoded) {
const coords = []
let index = 0, lat = 0, lng = 0
while (index < encoded.length) {
let b, shift = 0, result = 0
do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
lat += (result & 1) ? ~(result >> 1) : result >> 1
shift = 0; result = 0
do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
lng += (result & 1) ? ~(result >> 1) : result >> 1
coords.push([lat / 1e5, lng / 1e5])
}
return coords
}
export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'dark' }) {
const mapRef = useRef(null)
const mapInstanceRef = useRef(null)
const markerRef = useRef(null)
const trackRef = useRef(null)
const tileLayerRef = useRef(null)
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return
mapInstanceRef.current = L.map(mapRef.current, { zoomControl: true, attributionControl: true })
const tile = TILE_LAYERS['dark']
tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, maxZoom: 19 })
.addTo(mapInstanceRef.current)
return () => { mapInstanceRef.current?.remove(); mapInstanceRef.current = null }
}, [])
// Switch tile layer when mapType changes
useEffect(() => {
if (!mapInstanceRef.current) return
const tile = TILE_LAYERS[mapType] || TILE_LAYERS.dark
if (tileLayerRef.current) {
tileLayerRef.current.remove()
}
tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, maxZoom: 19 })
.addTo(mapInstanceRef.current)
}, [mapType])
// Draw route
useEffect(() => {
if (!mapInstanceRef.current || !polyline) return
if (trackRef.current) trackRef.current.remove()
const coords = decodePolyline(polyline)
if (!coords.length) return
trackRef.current = L.polyline(coords, { color: sportColor(sportType), weight: 3, opacity: 0.9 })
.addTo(mapInstanceRef.current)
mapInstanceRef.current.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] })
if (coords.length > 0) {
const dot = (color) => L.divIcon({
html: `<div style="width:12px;height:12px;background:${color};border:2px solid white;border-radius:50%"></div>`,
iconSize: [12, 12], iconAnchor: [6, 6], className: '',
})
L.marker(coords[0], { icon: dot('#22c55e') }).addTo(mapInstanceRef.current)
L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(mapInstanceRef.current)
}
}, [polyline, sportType])
// Position marker on timeline hover
useEffect(() => {
if (!mapInstanceRef.current || !dataPoints || !hoveredDistance) return
const point = dataPoints.find(p => p.distance_m >= hoveredDistance)
if (!point?.latitude || !point?.longitude) return
if (markerRef.current) {
markerRef.current.setLatLng([point.latitude, point.longitude])
} else {
const icon = L.divIcon({
html: '<div style="width:14px;height:14px;background:#fff;border:3px solid #3b82f6;border-radius:50%;box-shadow:0 0 6px rgba(59,130,246,0.8)"></div>',
iconSize: [14, 14], iconAnchor: [7, 7], className: '',
})
markerRef.current = L.marker([point.latitude, point.longitude], { icon }).addTo(mapInstanceRef.current)
}
}, [hoveredDistance, dataPoints])
return <div ref={mapRef} style={{ height: '100%', width: '100%', background: '#1a1a2e' }} />
}
@@ -0,0 +1,43 @@
const ZONE_CONFIG = [
{ key: 'z1', label: 'Z1 Recovery', color: '#60a5fa' },
{ key: 'z2', label: 'Z2 Base', color: '#34d399' },
{ key: 'z3', label: 'Z3 Tempo', color: '#fbbf24' },
{ key: 'z4', label: 'Z4 Threshold', color: '#f97316' },
{ key: 'z5', label: 'Z5 Max', color: '#f43f5e' },
]
export default function HRZoneBar({ zones }) {
return (
<div className="space-y-2">
{/* Stacked bar */}
<div className="flex h-4 rounded-full overflow-hidden gap-0.5">
{ZONE_CONFIG.map(({ key, color }) => {
const pct = zones[key] || 0
if (pct < 0.5) return null
return (
<div
key={key}
style={{ width: `${pct}%`, backgroundColor: color }}
className="h-full"
title={`${key.toUpperCase()}: ${pct}%`}
/>
)
})}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-4">
{ZONE_CONFIG.map(({ key, label, color }) => {
const pct = zones[key] || 0
return (
<div key={key} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: color }} />
<span className="text-xs text-gray-400">{label}</span>
<span className="text-xs font-medium text-white">{pct}%</span>
</div>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,40 @@
import { formatDuration, formatDistance, formatPace, formatHeartRate, formatCadence } from '../../utils/format'
export default function LapTable({ laps, sportType }) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 border-b border-gray-800">
<th className="text-left pb-2 font-medium">Lap</th>
<th className="text-right pb-2 font-medium">Distance</th>
<th className="text-right pb-2 font-medium">Time</th>
<th className="text-right pb-2 font-medium">Pace</th>
<th className="text-right pb-2 font-medium">Avg HR</th>
<th className="text-right pb-2 font-medium">Cadence</th>
<th className="text-right pb-2 font-medium">Power</th>
</tr>
</thead>
<tbody>
{laps.map((lap) => (
<tr key={lap.lap_number} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors">
<td className="py-2 text-gray-400">{lap.lap_number}</td>
<td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m)}</td>
<td className="py-2 text-right text-gray-200">{formatDuration(lap.duration_s)}</td>
<td className="py-2 text-right text-gray-200">{formatPace(lap.avg_speed_ms, sportType)}</td>
<td className="py-2 text-right">
<span className="text-red-400">{formatHeartRate(lap.avg_heart_rate)}</span>
</td>
<td className="py-2 text-right text-gray-400">
{lap.avg_cadence ? formatCadence(lap.avg_cadence, sportType) : '--'}
</td>
<td className="py-2 text-right text-gray-400">
{lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
@@ -0,0 +1,147 @@
import { useMemo } from 'react'
import {
ComposedChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer,
} from 'recharts'
import { formatPace, formatCadence } from '../../utils/format'
function downsample(points, maxPoints = 500) {
if (points.length <= maxPoints) return points
const step = Math.ceil(points.length / maxPoints)
return points.filter((_, i) => i % step === 0)
}
function buildChartData(dataPoints, activeMetrics) {
return dataPoints
.filter(p => p.timestamp)
.map(p => {
const row = { distance_m: p.distance_m ?? 0 }
for (const key of activeMetrics) {
row[key] = (p[key] != null && p[key] !== 0) ? p[key] : null
}
return row
})
}
const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover }) => {
if (!active || !payload?.length) return null
if (onHover) onHover(label)
return (
<div className="bg-gray-900 border border-gray-700 rounded-lg p-3 text-xs shadow-xl">
<p className="text-gray-400 mb-1">{(label / 1000).toFixed(2)} km</p>
{payload.map(entry => {
const metric = metrics.find(m => m.key === entry.dataKey)
if (!metric || entry.value == null) return null
let display = entry.value.toFixed(1)
if (entry.dataKey === 'speed_ms') display = formatPace(entry.value, sportType)
else if (entry.dataKey === 'heart_rate') display = `${Math.round(entry.value)} bpm`
else if (entry.dataKey === 'cadence') display = formatCadence(entry.value, sportType)
else if (entry.dataKey === 'power') display = `${Math.round(entry.value)} W`
else if (entry.dataKey === 'temperature_c') display = `${entry.value.toFixed(1)} °C`
else if (entry.dataKey === 'altitude_m') display = `${entry.value.toFixed(0)} m`
return (
<div key={entry.dataKey} className="flex items-center gap-2">
<span style={{ color: entry.color }}></span>
<span className="text-gray-300">{metric.label}:</span>
<span className="text-white font-medium">{display}</span>
</div>
)
})}
</div>
)
}
export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onHoverDistance, sportType }) {
const chartData = useMemo(() =>
downsample(buildChartData(dataPoints, activeMetrics)),
[dataPoints, activeMetrics]
)
const activeMetricConfigs = metrics.filter(m => activeMetrics.includes(m.key))
const domains = useMemo(() => {
const result = {}
for (const m of activeMetricConfigs) {
const vals = chartData.map(p => p[m.key]).filter(v => v != null)
if (!vals.length) continue
const min = Math.min(...vals)
const max = Math.max(...vals)
const pad = (max - min) * 0.1 || 1
// For elevation, don't start from 0 - show actual range
result[m.key] = [min - pad, max + pad]
}
return result
}, [chartData, activeMetricConfigs])
if (!chartData.length) {
return (
<div className="flex items-center justify-center h-48 text-gray-600 text-sm">
No timeline data available
</div>
)
}
return (
<div className="space-y-4">
{activeMetricConfigs.map((metric, idx) => {
const domain = domains[metric.key] || ['auto', 'auto']
const hasData = chartData.some(p => p[metric.key] != null)
if (!hasData) return null
return (
<div key={metric.key}>
<div className="flex items-center gap-2 mb-1">
<span style={{ color: metric.color }} className="text-xs font-medium">{metric.label}</span>
{metric.unit && <span className="text-xs text-gray-600">({metric.unit})</span>}
</div>
<ResponsiveContainer width="100%" height={100}>
<ComposedChart data={chartData} margin={{ top: 2, right: 8, bottom: 2, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis
dataKey="distance_m"
type="number"
domain={['dataMin', 'dataMax']}
tickFormatter={v => `${(v / 1000).toFixed(1)}`}
tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={false}
tickLine={false}
hide={idx < activeMetricConfigs.length - 1}
/>
<YAxis
domain={domain}
tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={false}
tickLine={false}
width={40}
tickFormatter={v => {
if (metric.key === 'speed_ms') {
if (sportType === 'cycling') return `${(v * 3.6).toFixed(0)}`
const spm = 1000 / v
return `${Math.floor(spm/60)}:${String(Math.floor(spm%60)).padStart(2,'0')}`
}
if (metric.key === 'cadence') return Math.round(v * (sportType === 'running' ? 2 : 1))
return Math.round(v)
}}
/>
<Tooltip
content={<CustomTooltip metrics={metrics} sportType={sportType} onHover={onHoverDistance} />}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey={metric.key}
stroke={metric.color}
strokeWidth={1.5}
dot={false}
isAnimationActive={false}
connectNulls={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
)
})}
<p className="text-xs text-gray-600 text-center">Distance (km)</p>
</div>
)
}