/* 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 (
);
})()}
>
)}
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 (
{value}
{unit &&
{unit} }
{hasDelta &&
}
{subValue && {subValue}
}
{hasSpark && (
)}
{(source || hasSpark) && (
{source ? : }
{hasSpark && 7d }
)}
);
};
const Stat = ({ 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 (
);
})}
);
};
// OJO: nombre distinto del de screen-plans.jsx (que toma minutos). Ambos
// archivos se cargan como