/* Activities — list + detail */ /* Mock fallback para "Volumen semanal · km" (4 semanas). * Coincide con el shape del endpoint /data/activities/summary?bucket=week&n=4 * y se usa SOLO si: (a) modo demo; o (b) el backend está caído. */ const VOLUME_MOCK_PERIODS = [ { period_start: "2026-04-13", period_end: "2026-04-19", label: "S-3", count: 3, total_distance_m: 32000, total_duration_s: 11400, by_type: { running: 3, cycling: 0 } }, { period_start: "2026-04-20", period_end: "2026-04-26", label: "S-2", count: 5, total_distance_m: 38000, total_duration_s: 14000, by_type: { running: 4, cycling: 1 } }, { period_start: "2026-04-27", period_end: "2026-05-03", label: "S-1", count: 5, total_distance_m: 42000, total_duration_s: 15300, by_type: { running: 4, cycling: 1 } }, { period_start: "2026-05-04", period_end: "2026-05-10", label: "Esta", count: 6, total_distance_m: 49000, total_duration_s: 18000, by_type: { running: 4, cycling: 2 } }, ]; const Activities = ({ density, dataState, setRoute, inDemo }) => { const [selected, setSelected] = React.useState(null); const [filter, setFilter] = React.useState("all"); // En demo arrancamos con MOCK; en producción arrancamos vacío y mostramos // empty state si no hay nada sincronizado. const [activities, setActivities] = React.useState(() => inDemo ? MOCK.activities : []); const [activitiesLoaded, setActivitiesLoaded] = React.useState(inDemo); // Volumen semanal: cableado a /data/activities/summary. En producción // mostramos array vacío (sin badge falso) si no hay datos o el backend falla. const [volumePeriods, setVolumePeriods] = React.useState(() => inDemo ? VOLUME_MOCK_PERIODS : []); const [volumeLoading, setVolumeLoading] = React.useState(!inDemo); React.useEffect(() => { if (inDemo) { setActivities(MOCK.activities); setVolumePeriods(VOLUME_MOCK_PERIODS); setActivitiesLoaded(true); setVolumeLoading(false); return; } let alive = true; (async () => { try { const res = await API.activitiesList({ limit: 50 }); const items = Array.isArray(res) ? res : (res?.rows || res?.items || res?.activities || []); if (alive) setActivities(items.map(window.normalizeActivityRow)); } catch (e) { if (e.status === 401 || e.status === 403 || e.status === 422) return; // network error → quedamos con [] (empty state honesto, no MOCK) } finally { if (alive) setActivitiesLoaded(true); } // Cargar volumen semanal del backend. if (!alive) return; try { const res = await API.activitiesSummary({ bucket: "week", n: 4 }); if (!alive) return; setVolumePeriods(Array.isArray(res?.periods) ? res.periods : []); } catch (e) { if (!alive) return; if (e.status === 401 || e.status === 403 || e.status === 422) return; // Error de red → vacío (no MOCK con badge) setVolumePeriods([]); } finally { if (alive) setVolumeLoading(false); } })(); return () => { alive = false; }; }, [inDemo]); if (selected) return setSelected(null)} setRoute={setRoute} inDemo={inDemo} />; const filtered = filter === "all" ? activities : activities.filter(a => (a.type || "").toLowerCase() === filter); return (
{[ { v: "all", l: "Todas" }, { v: "carrera", l: "Carrera" }, { v: "fuerza", l: "Fuerza" }, { v: "bici", l: "Bici" }, ].map(f => ( setFilter(f.v)}>{f.l} ))}
{(() => { const thisWeekKm = volumePeriods && volumePeriods.length > 0 ? Number(volumePeriods[volumePeriods.length - 1]?.total_distance_m || 0) / 1000 : 0; const weekTxt = thisWeekKm > 0 ? ` · ${thisWeekKm.toFixed(1)} km esta semana` : ""; return `${filtered.length} actividades${weekTxt}`; })()}
{/* Volume bars — sólo cuando hay periodos reales o estamos en demo. */} {(inDemo || volumePeriods.length > 0 || volumeLoading) && ( )} {!activitiesLoaded ? (
cargando actividades…
) : filtered.length === 0 ? (
{activities.length === 0 ? "SIN ACTIVIDADES" : `SIN ACTIVIDADES DE TIPO "${filter.toUpperCase()}"`}
{activities.length === 0 ? "Conectá un proveedor (Garmin, etc.) y esperá al primer sync." : "Probá con otro filtro o sincronizá más historial."}
) : ( {["Fecha", "Tipo", "Detalle", "Duración", "Distancia", "FC", "Pace", "Carga", "TE", ""].map((h) => ( ))} {filtered.map((a, i) => ( setSelected(a.id)} style={{ cursor: "pointer", borderBottom: i === filtered.length - 1 ? "none" : "1px solid var(--line-soft)" }} onMouseEnter={(e) => e.currentTarget.style.background = "var(--bg-2)"} onMouseLeave={(e) => e.currentTarget.style.background = "transparent"} > ))}
{h}
{(a.date || "").replace("2026-", "")} {a.time} {a.type} {a.subtype} {a.dur} {a.dist > 0 ? `${Number(a.dist).toFixed(2)} km` : "—"} {a.hr} {a.pace} {a.load} = 4 ? "var(--bad)" : a.te >= 3 ? "var(--warn)" : "var(--good)" }}>{Number(a.te || 0).toFixed(1)}
)}
); }; /* Toma los puntos planos del backend (`/data/activities/{id}/streams`) * — un array de `{t_offset_s, hr, pace_ms, cadence_spm, power_w, altitude_m, * lat, lon}` — y devuelve series listas para `StreamChart` y un track GPS * normalizado para el mini-mapa. Si no hay datos, devuelve `null`. */ function _processStreams(streams) { if (!streams) return null; const points = Array.isArray(streams.points) ? streams.points : null; if (points && points.length > 1) { const hr = points.map((p) => p.hr).filter((v) => v != null); const elevation = points.map((p) => p.altitude_m).filter((v) => v != null); const paceSecPerKm = points .map((p) => (p.pace_ms && p.pace_ms > 0 ? 1000 / p.pace_ms : null)) .filter((v) => v != null && isFinite(v) && v < 1500); const cadence = points.map((p) => p.cadence_spm).filter((v) => v != null); const gps = points .filter((p) => p.lat != null && p.lon != null) .map((p) => [p.lat, p.lon]); return { hr: hr.length > 1 ? hr : null, pace: paceSecPerKm.length > 1 ? paceSecPerKm : null, elevation: elevation.length > 1 ? elevation : null, cadence: cadence.length > 1 ? cadence : null, gps: gps.length > 1 ? gps : null, }; } // Compat hacia atrás: backend antiguo o mock que ya entrega `{hr, pace, elevation, cadence}`. const st = streams.streams || streams; if (st && (Array.isArray(st.hr) || Array.isArray(st.pace) || Array.isArray(st.elevation))) { return { hr: Array.isArray(st.hr) && st.hr.length > 1 ? st.hr : null, pace: Array.isArray(st.pace) && st.pace.length > 1 ? st.pace : null, elevation: Array.isArray(st.elevation) && st.elevation.length > 1 ? st.elevation : null, cadence: Array.isArray(st.cadence) && st.cadence.length > 1 ? st.cadence : null, gps: null, }; } return null; } /* Convierte `extras.splits` (FIT laps) a filas listas para la tabla de splits. * Cada split: `{n, dur_s, dist_m, avg_hr, avg_pace_ms, avg_cadence, avg_power_w}`. */ function _normalizeSplits(extras) { const raw = extras && extras.splits; if (!Array.isArray(raw) || raw.length === 0) return []; return raw .map((sp) => { const distKm = sp.dist_m != null ? Number(sp.dist_m) / 1000 : null; const durS = sp.dur_s != null ? Number(sp.dur_s) : null; const paceMs = sp.avg_pace_ms != null ? Number(sp.avg_pace_ms) : null; // Pace m:ss/km — preferir avg_pace_ms (m/s) si existe, sino dur/dist. let secPerKm = null; if (paceMs && paceMs > 0) secPerKm = 1000 / paceMs; else if (durS && distKm) secPerKm = durS / distKm; return { n: sp.n, distKm, durS, paceStr: secPerKm && isFinite(secPerKm) ? `${Math.floor(secPerKm / 60)}:${String(Math.round(secPerKm % 60)).padStart(2, "0")}` : "—", secPerKm: secPerKm && isFinite(secPerKm) ? secPerKm : null, avgHr: sp.avg_hr != null ? Math.round(Number(sp.avg_hr)) : null, cadence: sp.avg_cadence != null ? Math.round(Number(sp.avg_cadence)) : null, power: sp.avg_power_w != null ? Math.round(Number(sp.avg_power_w)) : null, }; }) .filter((sp) => sp.distKm != null || sp.durS != null); } /* GpsMap — mapa real con tiles de OpenStreetMap vía Leaflet. * * Por qué OSM/Leaflet: * - Sin API key, sin costo ($0), sin tracking. * - Self-hosted-friendly (sólo necesitamos los tiles públicos de OSM). * - Atribución automática en la esquina inferior derecha (requisito OSM). * - Leaflet es ~40 KB minified, MIT, sin deps adicionales. * * El componente: * 1. Crea un map al montar y dibuja el track como polyline. * 2. fitBounds() centra/zoom-ajusta al track. * 3. Limpia el map al desmontar para no leakear instancias. * 4. Si no hay GPS, renderiza el placeholder "Sin GPS" sin tocar Leaflet. * 5. Mantiene el badge "GPS · N pts" + "elevación +Xm" como overlays. */ const GpsMap = ({ track, elevation }) => { const mapRef = React.useRef(null); const containerRef = React.useRef(null); React.useEffect(() => { if (!track || track.length < 2) return undefined; if (typeof window.L === "undefined") return undefined; if (!containerRef.current) return undefined; // Si quedó una instancia previa (re-render con otro track), la matamos. if (mapRef.current) { mapRef.current.remove(); mapRef.current = null; } const L = window.L; const map = L.map(containerRef.current, { zoomControl: true, attributionControl: true, scrollWheelZoom: false, // evita atrapar el scroll de la página }); mapRef.current = map; // Tile provider: OSM standard. Si quisieras un dark-theme tile sin costo, // CartoDB tiene "dark_all" gratuito con atribución; lo dejamos como TODO. L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, attribution: '© OpenStreetMap', }).addTo(map); const latlngs = track.map(([lat, lon]) => [lat, lon]); const polyline = L.polyline(latlngs, { color: "#e63946", weight: 4, opacity: 0.9, lineJoin: "round", }).addTo(map); // Markers de inicio (verde) y fin (azul) — circle markers para no // bajarnos los iconos PNG por defecto de Leaflet. L.circleMarker(latlngs[0], { radius: 6, color: "#2e7d32", fillColor: "#2e7d32", fillOpacity: 1, weight: 2, }).bindTooltip("Inicio", { direction: "top", offset: [0, -6] }).addTo(map); L.circleMarker(latlngs[latlngs.length - 1], { radius: 5, color: "#1976d2", fillColor: "#1976d2", fillOpacity: 1, weight: 2, }).bindTooltip("Fin", { direction: "top", offset: [0, -6] }).addTo(map); map.fitBounds(polyline.getBounds(), { padding: [22, 22] }); // Recalcular tamaño cuando el contenedor termina de pintarse (Card hace // animaciones de mount). Sin esto a veces Leaflet calcula un viewport // de 0×0 y los tiles salen en la esquina. const resizeTimer = setTimeout(() => map.invalidateSize(), 100); return () => { clearTimeout(resizeTimer); if (mapRef.current) { mapRef.current.remove(); mapRef.current = null; } }; }, [track]); if (!track || track.length < 2) { return (
Sin GPS
(esta actividad no tiene track registrado)
); } return (
GPS · {track.length} pts
{elevation != null && (
elevación +{elevation}m
)}
); }; const ActivityDetail = ({ id, activities, onBack, setRoute, inDemo }) => { // En demo permitimos caer al MOCK si el list no trae nada; en producción // arrancamos con la fila que ya teníamos en la lista (o null mientras // /data/activities/{id} responde) y NUNCA caemos a MOCK.activities[0]. const list = activities && activities.length > 0 ? activities : (inDemo ? MOCK.activities : []); const [a, setA] = React.useState(() => { const fromList = list.find(x => x.id === id); if (fromList) return fromList; return inDemo ? MOCK.activities[0] : null; }); const [s, setS] = React.useState(() => inDemo ? { hr: MOCK.stream.hr, pace: MOCK.stream.pace, elevation: MOCK.stream.elevation, cadence: MOCK.stream.cadence, gps: null } : null); const [streamsLoading, setStreamsLoading] = React.useState(!inDemo); React.useEffect(() => { if (inDemo) return; let alive = true; setStreamsLoading(true); (async () => { try { const detail = await API.activityDetail(id); if (alive && detail) setA(window.normalizeActivityRow(detail)); } catch (e) { if (e.status === 401 || e.status === 403 || e.status === 422) return; } try { const streams = await API.activityStreams(id); if (alive) { const processed = _processStreams(streams); setS(processed); } } catch (e) { if (alive && !(e.status === 401 || e.status === 403 || e.status === 422)) { setS(null); } } finally { if (alive) setStreamsLoading(false); } })(); return () => { alive = false; }; }, [id, inDemo]); // useMemo SIEMPRE se llama (rules of hooks). Si `a` es null devuelve []. const splits = React.useMemo(() => a ? _normalizeSplits(a.extras) : [], [a]); // `a` puede ser null por una fracción de segundo (entre que se entra al // detalle y que /data/activities/{id} responde). Mientras tanto, render // de loading. Sin esto, los a.X de abajo truenan. if (!a) { return (
cargando actividad…
); } const fastestSplit = splits.length > 0 ? Math.min(...splits.filter((sp) => sp.secPerKm != null).map((sp) => sp.secPerKm)) : null; const hasGps = !!(s && s.gps && s.gps.length > 1); const hasStreams = !!(s && (s.hr || s.pace || s.elevation)); const hasSplits = splits.length > 0; return (
{a.date.replace("2026-", "")} · {a.time}{a.source ? ` · ${a.source}` : ""}

{a.subtype || a.type}

{a.type} {a.dist > 0 ? ` · ${Number(a.dist).toFixed(2)} km` : ""} {a.dur && a.dur !== "—" ? ` en ${a.dur}` : ""} {a.pace && a.pace !== "—" ? ` a ${a.pace}/km` : ""}
} onClick={() => setRoute("agents")}> Pregúntale al coach sobre esta actividad
{/* Big stats — sólo lo que tiene valor real (no inventamos). */}
{[ a.dist > 0 ? { l: "Distancia", v: Number(a.dist).toFixed(2), u: "km" } : null, a.dur && a.dur !== "—" ? { l: "Duración", v: a.dur, u: "" } : null, a.pace && a.pace !== "—" ? { l: "Pace medio", v: a.pace, u: "/km" } : null, a.hr ? { l: "FC media", v: String(a.hr), u: "bpm" } : null, a.calories ? { l: "Calorías", v: String(a.calories), u: "kcal" } : null, a.elevation ? { l: "Desnivel", v: `+${a.elevation}`, u: "m" } : null, ].filter(Boolean).map((x, i) => (
{x.l}
{x.v} {x.u}
))}
{/* Map + streams */}
{streamsLoading ?
cargando GPS…
: }
STREAMS · POR SEGUNDO
{hasStreams && s.hr && FC} {hasStreams && s.pace && Pace} {hasStreams && s.elevation && Elevación}
{streamsLoading ? (
cargando streams…
) : hasStreams ? ( 0 ? a.dist : 1} series={[ s.elevation ? { data: s.elevation, color: "var(--text-4)", fill: true, axis: "right", label: "Elev (m)", format: (v) => `${Math.round(v)}` } : null, s.hr ? { data: s.hr, color: "var(--bad)", axis: "left", label: "FC (bpm)", format: (v) => `${Math.round(v)}` } : null, s.pace ? { // Pace se grafica invertido (más rápido = más alto). Para // el label mostramos m:ss reales (no negativos). data: s.pace.map((p) => -p), color: "var(--info)", axis: "left", label: "Pace", format: (v) => { const sec = -v; if (!isFinite(sec) || sec <= 0) return "—"; return `${Math.floor(sec / 60)}:${String(Math.round(sec % 60)).padStart(2, "0")}`; }, } : null, ].filter(Boolean)} height={260} /> ) : (
Sin streams por segundo para esta actividad.
)}
{/* Splits + métricas del dispositivo (sólo lo que tiene valor real) */}
SPLITS · 1 KM
{hasSplits ? ( {["KM", "PACE", "FC", "CADENCIA", ""].map(h => )} {splits.map((sp, i) => { // Barra de pace relativo al split más rápido (visual de qué tan duro fue). const ratio = sp.secPerKm && fastestSplit ? Math.min(1, fastestSplit / sp.secPerKm) : null; return ( ); })}
{h}
{sp.n} {sp.paceStr} {sp.avgHr ?? "—"} {sp.cadence ?? "—"} {ratio != null && (
)}
) : (
Sin splits para esta actividad.
(los splits llegan al sincronizar el FIT — sólo se enriquecen las 5 más recientes)
)}
MÉTRICAS DEL DISPOSITIVO
Pídele al coach un análisis específico de esta sesión arriba ↑
); }; /* VolumeBars — bar chart de volumen semanal por bucket * * Espera la shape devuelta por GET /data/activities/summary: * { periods: [{ period_start, period_end, label, count, total_distance_m, * total_duration_s, by_type: {running, cycling, ...} }] } * * - Convierte total_distance_m → km (÷1000). * - Una sola barra (distancia total) — el desglose por tipo se podría agregar * si más adelante queremos volver al diseño de 3 barras (run/bike/strength). * Por ahora simplificamos para que el contrato del backend sea más limpio. * - Loading: muestra placeholder skeleton. * - fallback === true → badge discreto "datos demo (sin backend)". */ const VolumeBars = ({ periods, loading, fallback, inDemo }) => { const items = Array.isArray(periods) ? periods : []; const kmValues = items.map(p => Number(p?.total_distance_m || 0) / 1000); const maxKm = Math.max(1, ...kmValues); // evitar /0 const totalCount = items.reduce((s, p) => s + (Number(p?.count) || 0), 0); return (
VOLUMEN SEMANAL · KM
{items.length > 0 ? `${items.length} semanas · ${totalCount} actividades` : "Sin datos en el rango"}
{loading && ( cargando… )} {fallback && !inDemo && !loading && ( datos demo (sin backend) )}
{items.length === 0 && !loading ? (
Aún no hay actividades para resumir.
) : (
{items.map((p, i) => { const km = kmValues[i]; const pct = (km / maxKm) * 100; const byType = p.by_type || {}; const typeSummary = Object.entries(byType) .filter(([, v]) => Number(v) > 0) .map(([k, v]) => `${v}× ${k}`) .join(" · "); return (
{p.label || p.period_start || `S${i + 1}`}
0 ? 2 : 0, background: "var(--info)", borderRadius: 2, }} />
{km.toFixed(1)} km
{p.count != null && (
{p.count} act.
)}
); })}
)} ); }; window.Activities = Activities;