/* Métricas — deep-dive tabs */ const METRIC_TABS = [ { value: "sleep", label: "Sueño", icon: "moon", color: "var(--info)", unit: "h", key: "sleepHours", scoreKey: "sleepScore" }, { value: "hrv", label: "HRV", icon: "heart", color: "var(--good)", unit: "ms", key: "hrv" }, { value: "rhr", label: "FC reposo", icon: "flame", color: "var(--accent)", unit: "bpm", key: "rhr" }, { value: "weight", label: "Peso", icon: "weight", color: "var(--warn)", unit: "kg", key: "weight" }, { value: "body", label: "Composición", icon: "user", color: "var(--info)", unit: "%", key: "bodyFat" }, { value: "energy", label: "Energía", icon: "zap", color: "var(--good)", unit: "", key: "energy" }, ]; const Metrics = ({ density, dataState, inDemo }) => { const [tab, setTab] = React.useState("hrv"); const [range, setRange] = React.useState("30d"); const [provider, setProvider] = React.useState(""); const m = METRIC_TABS.find((x) => x.value === tab); const days = range === "7d" ? 7 : range === "30d" ? 30 : range === "90d" ? 90 : 90; const [daily, setDaily] = React.useState(() => inDemo ? MOCK.daily : null); const [loading, setLoading] = React.useState(!inDemo); const [fetchError, setFetchError] = React.useState(null); React.useEffect(() => { if (inDemo) { setDaily(MOCK.daily); setLoading(false); return; } let alive = true; setLoading(true); setFetchError(null); (async () => { try { const today = new Date(); const from = new Date(today); from.setDate(from.getDate() - days); const fmt = (d) => d.toISOString().slice(0, 10); const res = await API.dailyData({ from: fmt(from), to: fmt(today), provider }); if (!alive) return; const items = Array.isArray(res) ? res : (res?.rows || res?.items || res?.daily || []); // Backend devuelve los días desc; lo invertimos para gráfico cronológico. const ordered = [...items].reverse().map(window.normalizeDailyRow); setDaily(ordered); } catch (e) { if (alive) { if (e.status === 401 || e.status === 403 || e.status === 422) return; setFetchError(e.message || "No se pudo cargar datos diarios."); // Sin backend → caer al MOCK con badge de aviso. setDaily(MOCK.daily); } } finally { if (alive) setLoading(false); } })(); return () => { alive = false; }; }, [inDemo, days, provider]); // Datos = solo los días que tienen valor real para esta métrica. const allRows = daily || []; const data = allRows.slice(-days).filter((d) => d[m.key] != null && !isNaN(Number(d[m.key]))); const values = data.map((d) => Number(d[m.key])); const hasData = values.length > 0; const avg = hasData ? (values.reduce((a, b) => a + b, 0) / values.length) : null; const minV = hasData ? Math.min(...values) : null; const maxV = hasData ? Math.max(...values) : null; const first = hasData ? values[0] : null; const last = hasData ? values[values.length - 1] : null; const delta = (hasData && first !== 0 && first != null) ? ((last - first) / first) * 100 : null; const fmt1 = (n) => (n == null || isNaN(n)) ? "—" : Number(n).toFixed(1); // Etiquetas equiespaciadas — no asume cantidades fijas. Toma min(5, data.length). const xLabels = (() => { if (data.length === 0) return []; const n = Math.min(5, data.length); const out = []; for (let i = 0; i < n; i++) { const idx = Math.floor((data.length - 1) * (i / Math.max(1, n - 1))); out.push(data[idx].label); } return out; })(); return (
{/* Tabs */}
{METRIC_TABS.map((t) => ( ))}
{/* Estado de carga / errores / sin datos. */} {loading && (
cargando datos…
)} {!loading && fetchError && (
Aviso: {fetchError} — mostrando datos de muestra.
)} {/* Hero stats */}
{m.label.toUpperCase()} · {range.toUpperCase()}
{fmt1(avg)} {m.unit} media
min {fmt1(minV)} máx {fmt1(maxV)} {delta != null && ( tendencia )}
{hasData ? ( ) : ( )}
{/* Side: lectura derivada de los datos reales. */}
LECTURA
{/* Distribution + events — calculadas desde los datos reales. */}
DISTRIBUCIÓN POR DÍA DE LA SEMANA
{hasData ? : }
EVENTOS RECIENTES
{values.length} mediciones
{data.slice(-8).reverse().map((d, i) => (
{d.label} {fmt1(d[m.key])} {m.unit}
))} {data.length === 0 && (
sin mediciones en este rango
)}
); }; /* ───────── helpers de la pantalla métrica ───────── */ const EmptyMetric = ({ label, compact }) => (
Sin mediciones de {label.toLowerCase()} en este rango.
Sincronizá tu provider o ampliá el rango.
); /* MetricVerdict — texto interpretativo basado en valor real, no hardcoded. * Reglas conservadoras: sólo afirmamos lo que los números soportan. */ const MetricVerdict = ({ metric, avg, delta, hasData, sampleSize, rangeDays }) => { if (!hasData || avg == null) { return (
No hay suficientes datos para esta métrica en el rango seleccionado.
); } const goodDown = metric.value === "rhr" || metric.value === "weight" || metric.value === "body"; const direction = delta == null ? "estable" : (delta > 1.5 ? (goodDown ? "subiendo" : "subiendo") : delta < -1.5 ? (goodDown ? "bajando" : "bajando") : "estable"); const tone = delta == null ? "info" : (Math.abs(delta) < 1.5 ? "info" : (goodDown ? (delta < 0 ? "good" : "warn") : (delta > 0 ? "good" : "warn"))); const headline = (() => { if (direction === "estable") return `Tu ${metric.label.toLowerCase()} está estable.`; if (metric.value === "hrv") return delta > 0 ? "HRV en alza — recuperación trabajando." : "HRV cayendo — atento a la carga."; if (metric.value === "sleep") return delta > 0 ? "Estás durmiendo más." : "Estás durmiendo menos."; if (metric.value === "rhr") return delta < 0 ? "Tu FC reposo baja — buena señal." : "Tu FC reposo sube — descanso o estrés agudo."; if (metric.value === "weight") return delta < 0 ? "Estás bajando peso." : "Estás subiendo peso."; if (metric.value === "body") return delta < 0 ? "Bajando grasa corporal." : "Subiendo grasa corporal."; if (metric.value === "energy") return delta > 0 ? "Energía al alza." : "Energía a la baja."; return `Tendencia ${direction}.`; })(); const detail = `Media ${avg.toFixed(1)}${metric.unit ? ` ${metric.unit}` : ""} sobre ${sampleSize} día${sampleSize === 1 ? "" : "s"} (rango ${rangeDays}d).${delta != null ? ` Cambio ${delta > 0 ? "+" : ""}${delta.toFixed(1)}%.` : ""}`; return ( <>
{headline}
{detail}
Para una lectura cualitativa más profunda, abrí "Coach" y preguntale por tendencias.
); }; /* DowDistribution — promedio por día de la semana basado en `data`. * El día se infiere de `d.date` (ISO yyyy-mm-dd). */ const DowDistribution = ({ data, metricKey, color }) => { // 0=Lun ... 6=Dom (queremos arrancar en lunes) const buckets = [[], [], [], [], [], [], []]; data.forEach((d) => { if (!d.date) return; const dt = new Date(d.date + "T12:00:00Z"); if (isNaN(dt.getTime())) return; const jsDow = dt.getUTCDay(); // 0=Sun ... 6=Sat const localDow = (jsDow + 6) % 7; // 0=Mon ... 6=Sun const v = Number(d[metricKey]); if (!isNaN(v)) buckets[localDow].push(v); }); const avgs = buckets.map((arr) => arr.length === 0 ? 0 : arr.reduce((a, b) => a + b, 0) / arr.length); const counts = buckets.map((arr) => arr.length); // Bars espera valores en alguna escala; multiplicamos por 10 para darle altura visual igual al original. const max = Math.max(...avgs); const scaleFactor = max > 0 ? 100 / max : 1; return ( <> v * scaleFactor)} labels={["L", "M", "X", "J", "V", "S", "D"]} width={460} height={120} color={color} />
muestras por día: {counts.join(" · ")}
); }; window.Metrics = Metrics;