/* Dashboard "Hoy" */ // Mini formatter: "hace 12 min" / "hace 3h" / "ayer" / "27 abr". // El backend persiste timestamps UTC pero sin sufijo "Z" (DuckDB TIMESTAMP). // JS por defecto los interpreta como hora local → resta horas. Forzamos UTC // agregando "Z" si el string parece ISO sin tz. function formatRelativeShort(iso) { if (!iso) return "—"; let s = String(iso); if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(s) && !/[zZ]|[+-]\d{2}:?\d{2}$/.test(s)) { s = s.replace(" ", "T") + "Z"; } const d = new Date(s); if (isNaN(d.getTime())) return "—"; const diffMin = Math.round((Date.now() - d.getTime()) / 60000); if (diffMin < 0) return "ahora"; if (diffMin < 1) return "ahora"; if (diffMin < 60) return `hace ${diffMin} min`; const diffH = Math.round(diffMin / 60); if (diffH < 24) return `hace ${diffH}h`; if (diffH < 48) return "ayer"; const months = ["ene","feb","mar","abr","may","jun","jul","ago","sep","oct","nov","dic"]; return `${d.getDate()} ${months[d.getMonth()]}`; } // "sáb 02 may" — fecha corta en español para el eyebrow del hero. function formatBriefingDateShort(iso) { if (!iso) return ""; let s = String(iso); if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(s) && !/[zZ]|[+-]\d{2}:?\d{2}$/.test(s)) { s = s.replace(" ", "T") + "Z"; } const d = new Date(s); if (isNaN(d.getTime())) return ""; const months = ["ene","feb","mar","abr","may","jun","jul","ago","sep","oct","nov","dic"]; const days = ["dom","lun","mar","mié","jue","vie","sáb"]; return `${days[d.getDay()]} ${String(d.getDate()).padStart(2, "0")} ${months[d.getMonth()]}`; } // Trunca a `max` chars sin cortar palabra cuando se puede. function truncateText(txt, max = 280) { if (!txt) return ""; const s = String(txt).trim(); if (s.length <= max) return s; const cut = s.slice(0, max); const lastSpace = cut.lastIndexOf(" "); return (lastSpace > max - 40 ? cut.slice(0, lastSpace) : cut).trimEnd() + "…"; } // Extrae la primera frase del coach reading para el "display" del hero. // El briefing puede tener `title` (ya corto) o `body` (párrafo). Preferimos // `title`; si no hay, tomamos la primera frase del body. function extractCoachHeadline(reading, fallback = "Tu día listo.") { if (!reading) return fallback; const title = (reading.title || "").trim(); if (title) return title; const body = (reading.body || "").trim(); if (!body) return fallback; const firstSentence = body.split(/(?<=[\.\!\?])\s+/)[0] || body; return truncateText(firstSentence, 120); } // Tono visual derivado del recovery score (mantiene el lenguaje de color // "verde / azul / ámbar / rojo" del design original — pero NO inventa el // adjetivo: el TEXTO viene del LLM). function toneFromRecovery(score) { if (score == null) return "var(--text-3)"; if (score >= 75) return "var(--good)"; if (score >= 50) return "var(--info)"; if (score >= 25) return "var(--warn)"; return "var(--bad)"; } // Frases secuenciales que mostramos durante un refresh del briefing/insights. // Imitan el feedback de la modal de Garmin Connect ("escuchando…", "sincronizando…"). const HERO_REFRESH_PHASES = [ "Consultando coach…", "Leyendo tus últimas 72 h…", "Consolidando…", ]; const INSIGHTS_REFRESH_PHASES = [ "Repasando tendencias…", "Identificando patrones…", "Escribiendo insights…", ]; const Today = ({ density, dataState, showDisclaimer, setRoute, inDemo }) => { // Estado de datos. En demo arrancamos con MOCK; en producción arrancamos // VACÍO y sólo poblamos cuando el backend respondió con datos reales. // Antes el dashboard mostraba MOCK durante el primer fetch o cuando había // error de red — eso confundía a usuarios reales (parecía datos suyos). const [daily, setDaily] = React.useState(() => inDemo ? MOCK.daily : []); const [lastActivity, setLastActivity] = React.useState(() => inDemo ? MOCK.activities[0] : null); const [lastActivityHrStream, setLastActivityHrStream] = React.useState(null); const [loaded, setLoaded] = React.useState(inDemo); // true = ya intentamos fetch o estamos en demo // Estado real de los providers para la barra de sync. Cada entrada: // { name, auth: "logged_in"|"unknown"|..., last_sync, syncing } const [providerStates, setProviderStates] = React.useState([]); React.useEffect(() => { if (inDemo) { setLoaded(true); return; } let alive = true; (async () => { // /data/daily — últimos 7 días. try { const today = new Date(); const from = new Date(today); from.setDate(from.getDate() - 30); const fmt = (d) => d.toISOString().slice(0, 10); const res = await API.dailyData({ from: fmt(from), to: fmt(today) }); const items = Array.isArray(res) ? res : (res?.rows || res?.items || res?.daily || []); if (alive && items.length > 0) { // Backend devuelve DESC (más reciente primero); el resto del // screen asume ASC (último elemento = hoy). Ordenamos ASC. const normalized = items.map(normalizeDailyRow); normalized.sort((a, b) => (a.date || "").localeCompare(b.date || "")); setDaily(normalized); } } catch (e) { if (e.status === 401 || e.status === 403 || e.status === 422) return; // bubble // network error → quedamos con [] (empty state honesto, no MOCK) } // /data/activities?limit=1 — última actividad let lastActId = null; try { const res = await API.activitiesList({ limit: 1 }); const items = Array.isArray(res) ? res : (res?.rows || res?.items || res?.activities || []); if (alive && items.length > 0) { // Backend devuelve DESC también; el primero ES la más reciente. const row = normalizeActivityRow(items[0]); setLastActivity(row); lastActId = row.id; } } catch (e) { if (e.status === 401 || e.status === 403 || e.status === 422) return; } // Stream HR real para el sparkline. Si la actividad no tiene streams // (FIT no enriquecido), dejamos el state en null y el card oculta el chart. if (lastActId) { try { const streams = await API.activityStreams(lastActId); const points = Array.isArray(streams?.points) ? streams.points : []; const hr = points.map((p) => p.hr).filter((v) => v != null); if (alive && hr.length > 1) setLastActivityHrStream(hr); } catch (e) { if (e.status === 401 || e.status === 403 || e.status === 422) return; } } // Estado de providers (la sync row del header). Honesto: si Eufy no // está configurado, no decimos "Eufy escuchando BLE". try { const provs = await API.listProviders(); const list = Array.isArray(provs) ? provs : (provs?.providers || []); const states = await Promise.all( list.map(async (p) => { const name = typeof p === "string" ? p : (p.name || p.id); try { const s = await API.providerStatus(name); return { name, ...s }; } catch { return { name, auth: "unknown" }; } }) ); if (alive) setProviderStates(states.filter(s => s && s.name)); } catch (e) { /* sin providers info → render fallback */ } if (alive) setLoaded(true); })(); return () => { alive = false; }; }, [inDemo]); // ───── HOOKS — todos antes de cualquier early-return. // Mover hooks debajo de un return condicional viola las rules of hooks // (orden cambia entre renders) y React desmonta todo → "pantalla negra". const heroRef = React.useRef(null); const insightsRef = React.useRef(null); // Derivados (no son hooks). const hasDaily = daily && daily.length > 0; const _emptyDaily = { sleepScore: null, sleepHours: null, sleepMinutes: null, sleepPhases: null, hrv: null, rhr: null, weight: null, bodyFat: null, energy: null, load: null, stress: null, stressAvg: null, stressMax: null, readiness: null, }; const t = hasDaily ? daily[daily.length - 1] : _emptyDaily; const last7 = daily.slice(-7); const last30 = daily.slice(-30); const partial = dataState === "partial"; const error = dataState === "error"; // ───── Early returns AHORA, después de los hooks. if (!inDemo && !loaded) { return (
cargando datos…
); } if (!inDemo && !hasDaily && !lastActivity) { return ; } if (dataState === "empty") return ; return (
{/* sync status row — sólo proveedores conectados. Los desconectados no * aportan info útil acá; quien quiera agregar uno va a Configuración. */}
{(() => { if (inDemo || providerStates.length === 0) { return ( <> Garmin sincronizado hace 12 min · Eufy escuchando BLE ); } const connected = providerStates.filter((s) => s.auth === "logged_in"); if (connected.length === 0) { return ( sin proveedores conectados ); } return connected.map((s, i) => { const last = s.last_sync ? formatRelativeShort(s.last_sync) : null; return ( {i > 0 && ·} {s.name} {last ? "sincronizado " + last : "conectado"} ); }); })()} {!inDemo && lastActivity && lastActivity.date && ( <> · Última actividad: {formatRelativeShort(lastActivity.date)} )}
}>Sincronizar todo
{/* HERO: today's verdict (briefing del coach + recovery breakdown) */}
{/* El score real de recovery viene del Training Readiness de Garmin (extras.readiness.score, 0-100). Si no está, caemos a body battery (energy_level). */} {(() => { const rs = t.readiness || {}; const recoveryScore = rs.score ?? t.energy ?? null; const ringColor = toneFromRecovery(recoveryScore); const phrase = rs.feedbackShort ? humanizeFeedback(rs.feedbackShort) : "Combinación de HRV, sueño y carga reciente."; return ( <>
RECOVERY SCORE
{phrase}
{rs.feedbackLong && (
{humanizeFeedback(rs.feedbackLong)}
)}
); })()}
{/* 6 indicadores reales del Training Readiness de Garmin: puntuación de sueño, tiempo de recuperación, VFC, carga aguda, sueño 7d, estrés. Si readiness no está disponible para el día, caemos a los campos individuales del daily. */} d.sleepScore != null).reduce((s, d) => s + d.sleepScore, 0) / Math.max(1, last7.filter(d => d.sleepScore != null).length)) : null, recoveryHours: lastActivity?.recoveryHours, hrv: t.hrv, acuteLoad: t.load, stress: t.stress, stressAvg: t.stressAvg, }} />
{/* Vitals row — los providers no siempre rellenan todo (Garmin no reporta peso ni body composition; Eufy no reporta HRV). Mostramos "—" cuando el valor es null para no inventar datos. */}
d.sleepHours).filter(v => v != null)} color="var(--info)" source="Garmin" /> d.hrv).filter(v => v != null)} color="var(--good)" source="Garmin" highlight /> d.rhr).filter(v => v != null)} color="var(--accent)" source="Garmin" /> {(() => { // Card de peso defensiva: cuando no hay lecturas (báscula desconectada // o el usuario aún no se pesó), mostramos sólo la card en estado vacío // sin el arrow de tendencia ni la spark line ni la fuente — antes // mostraba "— ↘ -0.6kg fuente Eufy 7d" todo a la vez, lo cual era // contradictorio. const weightSeries = last30.map(d => d.weight).filter(v => v != null); const hasWeight = t.weight != null; const realDelta = weightSeries.length >= 2 ? Number((weightSeries[weightSeries.length - 1] - weightSeries[0]).toFixed(1)) : null; return ( ); })()}
{/* Two-col: insights + last activity */}
{/* Insights — bueno / atención / mejora */}
{/* Last activity — sólo cuando existe. Si no hay actividades aún * (cuenta nueva o sin sync), mostramos un empty state honesto. */}
ÚLTIMA ACTIVIDAD
{lastActivity && {lastActivity.type}}
{!lastActivity ? (
Sin actividades sincronizadas todavía.
Conectá un proveedor (Garmin) o esperá al próximo auto-sync.
) : ( <>
{lastActivity.dist != null ? Number(lastActivity.dist).toFixed(2) : "—"} km
en {lastActivity.dur}
{lastActivity.subtype} · {(lastActivity.date || "").replace("2026-", "")}, {lastActivity.time}
{/* Mini sparkline del stream HR real. fluid=true → SVG al 100% del * container; antes se mostraba a media anchura porque width estaba * pinneado a 300px y el card era más ancho. */} {(() => { const hr = inDemo ? MOCK.stream.hr : lastActivityHrStream; if (!hr || hr.length < 2) return null; return (
Pulso · {hr.length} pts
); })()} )} setRoute("activities")}> Ver detalle
{/* "EN TU PLAN" — sólo si hay un plan activo guardado en /plans/. */} {/* Evolución 90 días: comparativa primera vs última quincena de los * datos reales de common.daily. Se oculta si no hay suficientes datos. */} {/* HÁBITO EN CONSTRUCCIÓN: feature pendiente, sin backend. Se oculta * hasta que tengamos /habits/. No mostrar mock falso. */} {showDisclaimer && (
Esto no es consejo médico. La información se basa en tendencias de tus datos y conocimiento general. Consulta a un profesional ante cualquier síntoma o duda clínica.
)}
); }; const VitalCard = ({ icon, label, value, unit, subValue, delta, deltaUnit = "", goodDirection = "up", spark, color, source, highlight, stale }) => { const hasSpark = Array.isArray(spark) && spark.length >= 2; const hasDelta = delta != null && !Number.isNaN(delta); return (
{label}
{stale && stale}
{value} {unit && {unit}}
{hasDelta && }
{subValue &&
{subValue}
} {hasSpark && (
)} {(source || hasSpark) && (
{source ? : } {hasSpark && 7d}
)} ); }; const Stat = ({ label, value, unit }) => (
{label}
{value}{unit}
); /* ActivePlanStrip — busca el plan training más reciente en /plans/ y muestra * la primera semana parseada. Si no hay ninguno, no renderiza nada (sin mock). */ const ActivePlanStrip = ({ setRoute, inDemo }) => { const [plan, setPlan] = React.useState(null); const [loaded, setLoaded] = React.useState(false); React.useEffect(() => { if (inDemo) { // En demo, mostrar el preview del MOCK para no quedar con la card vacía. setPlan({ goal: MOCK.user.goal, weekDays: MOCK.planWeek[0] }); setLoaded(true); return; } let alive = true; (async () => { try { const list = await API.listPlans(); const items = Array.isArray(list) ? list : (list?.plans || []); const training = items.find((p) => (p.type || p.plan_type) === "training"); if (!alive) return; if (!training) { setLoaded(true); return; } const detail = await API.getPlan(training.id); if (!alive) return; const md = detail?.content?.answer || detail?.content?.text || detail?.content?.markdown; const parsed = (typeof window.parseTrainingPlan === "function" && md) ? window.parseTrainingPlan(md) : null; if (parsed && parsed.weeks.length > 0) { // Convertir el primer "weeks[0]" en formato compatible con PlanDayMini. // PlanDayMini lee {day, title, desc, tone, load, today} — usá esos keys. const todayDow = (new Date().getDay() + 6) % 7; // 0=Lun ... 6=Dom const weekDays = parsed.weeks[0].days.map((d) => { const sess = d.sessions[0]; const isRest = !sess || sess.tone === "rest"; return { day: d.label, today: d.dow === todayDow, tone: sess?.tone || "rest", title: isRest ? "Descanso" : (sess?.name || "Sesión"), desc: [ sess?.intensity, sess?.duration_min ? `${sess.duration_min}'` : null, ].filter(Boolean).join(" · "), load: sess?.load || null, }; }); setPlan({ goal: detail?.content?.goal || training.goal || parsed.title, weekDays }); } else { setPlan({ goal: detail?.content?.goal || training.goal || "Plan activo", weekDays: null }); } } catch (e) { // Silencio: si falla la API mostramos como "sin plan" (más honesto que mock). } finally { if (alive) setLoaded(true); } })(); return () => { alive = false; }; }, [inDemo]); if (!loaded) return null; if (!plan) return null; return (
EN TU PLAN · ESTA SEMANA
{plan.goal}
activo } onClick={() => setRoute("plans")}>Plan completo
{plan.weekDays ? (
{plan.weekDays.map((s, i) => )}
) : (
El plan está guardado pero no se pudo parsear el calendario semanal. Abrí "Planes" para ver el detalle.
)} ); }; /* EvolutionCard — comparativa primera vs última semana de daily real. */ const EvolutionCard = ({ inDemo }) => { const [rows, setRows] = React.useState(null); React.useEffect(() => { if (inDemo) { setRows(null); return; } let alive = true; (async () => { try { const today = new Date(); const from = new Date(today); from.setDate(from.getDate() - 90); const fmt = (d) => d.toISOString().slice(0, 10); const res = await API.dailyData({ from: fmt(from), to: fmt(today) }); const items = Array.isArray(res) ? res : (res?.rows || []); if (!alive) return; // Backend orden DESC; ordenamos ASC por fecha para tomar primer/último tramo. const ordered = [...items].reverse().map(window.normalizeDailyRow); if (ordered.length < 4) { setRows([]); return; } const headSize = Math.max(3, Math.floor(ordered.length / 6)); const tailSize = headSize; const head = ordered.slice(0, headSize); const tail = ordered.slice(-tailSize); const avg = (arr, key) => { const xs = arr.map((r) => r[key]).filter((v) => v != null && !isNaN(v)); return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null; }; const fields = [ { key: "weight", label: "Peso", unit: "kg", goodDirection: "down", digits: 1 }, { key: "hrv", label: "HRV media", unit: "ms", goodDirection: "up", digits: 0 }, { key: "rhr", label: "FC reposo", unit: "bpm", goodDirection: "down", digits: 0 }, { key: "sleepHours", label: "Sueño medio", unit: "h", goodDirection: "up", digits: 1 }, { key: "energy", label: "Energía media", unit: "", goodDirection: "up", digits: 0 }, ]; const computed = fields.map((f) => { const before = avg(head, f.key); const after = avg(tail, f.key); if (before == null || after == null) return null; const diff = after - before; const goodSign = f.goodDirection === "up" ? diff > 0 : diff < 0; return { label: f.label, before: `${before.toFixed(f.digits)}${f.unit ? " " + f.unit : ""}`, after: `${after.toFixed(f.digits)}${f.unit ? " " + f.unit : ""}`, delta: `${diff > 0 ? "+" : ""}${diff.toFixed(f.digits)}${f.unit ? " " + f.unit : ""}`, good: goodSign, }; }).filter(Boolean); setRows(computed); } catch (e) { if (alive) setRows([]); } })(); return () => { alive = false; }; }, [inDemo]); if (rows === null) return null; if (rows.length === 0) return null; return (
EVOLUCIÓN · ~90 DÍAS
Comparativa primera vs última quincena dentro de los últimos 90 días con datos.
{rows.map((r, i) => ( ))}
); }; const ChangeRow = ({ label, before, after, delta, good, last }) => (
{label}
{before}
{after} {delta}
); const PlanDayMini = ({ session }) => { const colors = { rest: "var(--text-4)", easy: "var(--info)", long: "var(--good)", hard: "var(--bad)", strength: "var(--accent)", }; const c = colors[session.tone] || "var(--text-3)"; const isRest = session.tone === "rest"; return (
{session.day}{session.today && " · HOY"}
{session.title}
{session.desc}
{session.load && (
carga {session.load}
)}
); }; const TodayEmpty = ({ setRoute }) => (
Conecta tu primer dispositivo.
FtiMind no inventa datos. Cuando conectes Garmin o Eufy verás aquí tu energía, sueño y HRV con micro-tendencias de los últimos 7 días.
setRoute("onboarding")} icon={}> Empezar onboarding
); /* Normalizadores: el backend puede devolver shapes ligeramente distintos al MOCK * (snake_case vs camelCase, fechas como ISO, etc). Los mantenemos en este file * para que sea fácil ajustar a medida que estabilicemos el contrato. */ function normalizeDailyRow(r) { // Garmin manda los detalles de sueño (duración + 4 fases) bajo // extras.sleep, y el panel de recovery (training readiness real) // bajo extras.readiness. El resto tiene alias por proveedor. const sleepBlob = (r.extras && r.extras.sleep) || {}; const readiness = (r.extras && r.extras.readiness) || null; const sleepMinTotal = sleepBlob.minutes_total ?? null; const phases = sleepBlob.phases || null; return { date: r.date || r.day, label: r.label || (r.date ? r.date.slice(5).replace("-", "/") : ""), sleepScore: r.sleep_score ?? r.sleepScore ?? null, sleepHours: r.sleep_hours ?? r.sleepHours ?? (sleepMinTotal != null ? sleepMinTotal / 60 : null), sleepMinutes: sleepMinTotal, sleepPhases: phases, hrv: r.hrv ?? r.hrv_overnight ?? r.hrv_avg ?? null, rhr: r.rhr ?? r.resting_hr ?? r.rhr_avg ?? null, weight: r.weight ?? r.weight_kg ?? null, bodyFat: r.body_fat ?? r.bodyFat ?? r.body_fat_pct ?? null, energy: r.energy ?? r.energy_level ?? r.body_battery ?? null, load: r.load ?? r.training_load ?? null, stress: r.stress ?? r.stress_avg ?? null, // 0-100 stress real (avg del día) — viene de get_all_day_stress. stressAvg: (r.extras && r.extras.stress && r.extras.stress.avg) ?? r.stress_avg ?? null, stressMax: (r.extras && r.extras.stress && r.extras.stress.max) ?? null, // Training readiness factors (Garmin) — el panel de recovery los lee. readiness, }; } function normalizeActivityRow(a) { // duration_s + distance_m vienen del backend; el resto de los campos // ricos (TE, average speed, etc.) viven en extras (raw payload de Garmin). const ex = a.extras || {}; const durSec = a.duration_seconds || a.duration_s || (a.durationMinutes ? a.durationMinutes * 60 : 0); const distKm = a.dist ?? a.distance_km ?? (a.distance_m != null ? a.distance_m / 1000 : 0); // Pace: m:ss/km. Garmin no manda pace explícito, lo computamos del par // duration/distance (o de averageSpeed en m/s si está). const computePace = () => { if (a.pace) return a.pace; if (a.avg_pace) return a.avg_pace; const speedMs = ex.averageSpeed ?? a.average_speed ?? null; let secPerKm = null; if (speedMs && speedMs > 0) { secPerKm = 1000 / speedMs; } else if (durSec > 0 && distKm > 0) { secPerKm = durSec / distKm; } if (!secPerKm || !isFinite(secPerKm)) return "—"; const m = Math.floor(secPerKm / 60); const s = Math.round(secPerKm % 60); return `${m}:${String(s).padStart(2, "0")}`; }; // Training effect (aerobic). Garmin's list endpoint NO devuelve el // float; sólo manda *Message strings con un sufijo numérico que NO // mapea linealmente al TE real (ej. "_12" puede corresponder a 3.5). // El número correcto sólo aparece cuando se llama get_activity(id). // El backend ahora enriquece eso en el sync; mientras no esté, dejar null. const teRaw = a.te ?? a.training_effect ?? a.training_effect_aerobic ?? ex.aerobicTrainingEffect ?? ex.training_effect_aerobic ?? null; const teAnaerobic = ex.anaerobicTrainingEffect ?? a.training_effect_anaerobic ?? null; return { id: a.id || a.activity_id, date: a.date || (a.start_at ? a.start_at.slice(0, 10) : (a.start_time ? a.start_time.slice(0, 10) : "")), time: a.time || (a.start_at ? a.start_at.slice(11, 16) : (a.start_time ? a.start_time.slice(11, 16) : "")), type: a.type || a.activity_type || "Actividad", subtype: a.subtype || ex.activityName || a.name || a.title || "", dur: a.dur || a.duration_str || formatDurationFromSeconds(durSec), durMin: a.durMin || (durSec ? Math.round(durSec / 60) : 0), dist: distKm, hr: a.hr ?? a.avg_hr ?? a.heart_rate_avg ?? 0, hrMax: a.hrMax ?? a.max_hr ?? null, pace: computePace(), load: a.load ?? a.training_load ?? ex.activityTrainingLoad ?? null, te: teRaw != null ? Number(teRaw) : null, teAnaerobic: teAnaerobic != null ? Number(teAnaerobic) : null, recoveryHours: a.recovery_time_h ?? ex.recoveryHeartRate ?? null, recovery: a.recovery || a.recovery_time || "—", calories: a.calories ?? a.kcal ?? 0, elevation: a.elevation ?? a.elevation_gain ?? ex.elevationGain ?? 0, source: a.source || a.provider || "Garmin", // El detail screen necesita los splits crudos (extras.splits enriquecidos // del FIT) y el resto del payload Garmin para análisis avanzados. extras: ex, }; } // Convierte códigos de Garmin como "FIND_TIME_TO_RELAX" a copy en español. const FEEDBACK_ES = { FIND_TIME_TO_RELAX: "Encontrá tiempo para relajarte.", RECOVERED: "Recuperado y listo.", WELL_RESTED: "Bien descansado.", TIRED: "Cansado, día suave.", REACHED_ZERO: "Recuperación completada.", LOW_RT_MOD_OR_LOW_SS_POOR: "Recuperación moderada con sueño irregular.", GOOD: "Buena", VERY_GOOD: "Muy buena", POOR: "Pobre", MODERATE: "Moderada", }; const humanizeFeedback = (code) => { if (!code) return ""; return FEEDBACK_ES[code] || code.replaceAll("_", " ").toLowerCase(); }; // Mapea factor (0-100) a tono visual. const factorTone = (pct) => { if (pct == null) return "var(--text-4)"; if (pct >= 75) return "var(--good)"; if (pct >= 50) return "var(--info)"; if (pct >= 25) return "var(--warn)"; return "var(--bad)"; }; // Buckets directos por valor real de cada métrica (independiente de los // factorPercent de Garmin, que a veces son confusos). const sleepScoreTone = (v) => v == null ? "var(--text-4)" : v >= 80 ? "var(--good)" : v >= 60 ? "var(--info)" : v >= 40 ? "var(--warn)" : "var(--bad)"; const recoveryTone = (min) => min == null ? "var(--text-4)" : min < 60 ? "var(--good)" : min < 1440 ? "var(--info)" : min < 4320 ? "var(--warn)" : "var(--bad)"; const stressTone = (v) => v == null ? "var(--text-4)" : v <= 25 ? "var(--good)" : v <= 50 ? "var(--info)" : v <= 75 ? "var(--warn)" : "var(--bad)"; /* Apertura del recovery score: 6 indicadores con icono + valor real + * color por bucket. Si readiness no llegó, usa el fallback del daily. */ const RecoveryBreakdown = ({ readiness, fallback }) => { const r = readiness || {}; const fb = fallback || {}; const rtMin = r.recoveryTime != null ? r.recoveryTime : (fb.recoveryHours != null ? fb.recoveryHours * 60 : null); const recoveryDisplay = rtMin == null ? "—" : rtMin < 1 ? "0 min" : rtMin < 60 ? `${rtMin} min` : `${Math.round(rtMin / 60)}h`; const stressVal = fb.stressAvg ?? fb.stress; // 0-100, MENOR es mejor const items = [ { icon: "moon", label: "Sueño", value: r.sleepScore ?? fb.sleepScore, tone: sleepScoreTone(r.sleepScore ?? fb.sleepScore), }, { icon: "clock", label: "Recuperación", raw: recoveryDisplay, tone: recoveryTone(rtMin), }, { icon: "heart", label: "VFC", value: r.hrvWeeklyAverage ?? fb.hrv, suffix: " ms", tone: factorTone(r.hrvFactorPercent), }, { icon: "flame", label: "Carga aguda", value: r.acuteLoad ?? fb.acuteLoad, tone: factorTone(r.acwrFactorPercent), }, { icon: "moon", label: "Sueño 7d", value: fb.sleepScoreAvg7d, tone: sleepScoreTone(fb.sleepScoreAvg7d), }, { icon: "zap", label: "Estrés", value: stressVal, tone: stressTone(stressVal), }, ]; return (
{items.map((it) => { const display = it.raw || (it.value == null ? "—" : (typeof it.value === "number" ? Math.round(it.value) : it.value) + (it.suffix || "")); const empty = display === "—"; const color = empty ? "var(--text-4)" : it.tone; return (
{it.label} {display}
); })}
); }; // OJO: nombre distinto del de screen-plans.jsx (que toma minutos). Ambos // archivos se cargan como