/* 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
)}
{/* 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;