/* Plans — calendario semanal real (parseado desde el markdown del LLM) + * nutrición (todavía MOCK) + form de generación. * * Antes el calendario era 100% MOCK (`MOCK.planWeek`). Ahora: * 1. `screen-plans.jsx` lista los planes guardados (`/plans/`). * 2. Si hay alguno tipo "training", carga el detalle (`/plans/{id}`), * pasa `plan.content.answer` (markdown) por `parseTrainingPlan` * (definido en `utils-plan-parser.jsx`) y dibuja el calendario. * 3. Si el parser devuelve `null` (LLM no respetó el formato esperado), * mostramos el markdown crudo con `MarkdownRender` y un eyebrow * explicativo. La opción "pedir reformat al coach" queda como TODO. * 4. El generador (`NewPlanForm`) post-procesa la respuesta inmediata y * muestra preview con el calendario antes de guardar; si falla parser, * muestra el markdown + botón "Reintentar" con instrucciones reforzadas. * * MOCK que sigue: * - Pantalla "Nutrición" entera (`NutritionPlan`): no estamos parseando * `/agents/plan/nutrition` aún (formato del LLM nutricionista no * consistente; ver README §"MOCK que siguen pendientes"). * - Stats agregadas (`carga sem.`, `volumen`, `25%`) del header del plan * activo: el parser todavía no calcula esto (lo añadimos cuando el LLM * devuelva `load` por sesión de forma confiable). Ahora derivamos * `weeks count` y `sessions/sem promedio` de la estructura parseada. */ const Plans = ({ density, dataState, setRoute, inDemo }) => { const [view, setView] = React.useState("training"); const [selectedSession, setSelectedSession] = React.useState(null); // null = "no cargado", [] = "cargado vacío", [{...}] = lista. const [plans, setPlans] = React.useState(null); const [activePlan, setActivePlan] = React.useState(null); // detalle (con content) const [loadError, setLoadError] = React.useState(null); // Cargar lista de planes y detalle del primero "training". React.useEffect(() => { if (inDemo) return; let alive = true; (async () => { try { const res = await API.listPlans(); const items = Array.isArray(res) ? res : (res?.plans || res?.items || []); if (!alive) return; setPlans(items); const trainingPlan = items.find((p) => (p.type || p.plan_type) === "training"); if (trainingPlan) { try { const detail = await API.getPlan(trainingPlan.id); if (alive) setActivePlan(detail); } catch (e) { if (alive && e.status !== 401) setLoadError(e.message); } } } catch (e) { if (e.status === 401 || e.status === 403 || e.status === 422) return; if (alive) setLoadError(e.message); } })(); return () => { alive = false; }; }, [inDemo]); return (
} onClick={() => setView("new")}>Generar nuevo plan
{view === "training" && ( { try { const detail = await API.getPlan(id); setActivePlan(detail); setSelectedSession(null); } catch (e) { setLoadError(e.message); } }} /> )} {view === "nutrition" && } {view === "new" && ( { // El backend ya persistió el plan en /agents/plan/training. Aprovechamos // el id del detail para refetchearlo (canonical) y refrescar la lista // de planes para que el usuario lo vea en futuras navegaciones. setActivePlan(detail); setView("training"); if (!inDemo && detail?.id) { try { const list = await API.listPlans(); const items = Array.isArray(list) ? list : (list?.plans || []); setPlans(items); const fresh = await API.getPlan(detail.id); if (fresh) setActivePlan(fresh); } catch (e) { if (e.status !== 401 && e.status !== 403) { setLoadError(e.message || "No se pudo refrescar el plan recién guardado."); } } } }} /> )}
); }; /* ───────── TrainingPlan ───────── */ const TrainingPlan = ({ setSelectedSession, selectedSession, activePlan, plans, inDemo, loadError, onSelectPlan }) => { // Demo: usamos el MOCK directo (markdown reconstruido para que pase por parser). const planMarkdown = React.useMemo(() => { if (inDemo) return demoPlanMarkdown(); if (!activePlan) return null; const content = activePlan.content; if (!content) return null; if (typeof content === "string") return content; if (typeof content === "object") { return content.answer || content.text || content.markdown || null; } return null; }, [activePlan, inDemo]); const parsed = React.useMemo(() => { if (!planMarkdown) return null; try { return parseTrainingPlan(planMarkdown); } catch (e) { return null; } }, [planMarkdown]); // Empty / loading states. if (!inDemo && !activePlan && plans !== null && plans.length === 0) { return ( } onClick={() => window.dispatchEvent(new CustomEvent("af-plans-new"))}>Generar mi primer plan} /> ); } if (!inDemo && !activePlan && plans === null) { return
CARGANDO PLAN…
; } if (loadError && !activePlan && !inDemo) { return (
No se pudo cargar el plan. {loadError}
); } // Fallback si no hay markdown parseable. if (!parsed) { return ( ); } return ( ); }; /* ───────── PlanCalendar (estructura parseada) ───────── */ const PlanCalendar = ({ parsed, plans, activePlan, onSelectPlan, selectedSession, setSelectedSession }) => { const [activeWeekIdx, setActiveWeekIdx] = React.useState(0); React.useEffect(() => { setActiveWeekIdx(0); }, [activePlan?.id]); const totalSessions = parsed.weeks.reduce((acc, w) => acc + w.days.reduce((a, d) => a + d.sessions.filter((s) => s.tone !== "rest").length, 0), 0); const sessionsPerWeek = parsed.weeks.length ? Math.round(totalSessions / parsed.weeks.length) : 0; const goal = activePlan?.content?.goal || activePlan?.content?.constraints?.goal || parsed.title; const week = parsed.weeks[activeWeekIdx] || parsed.weeks[0]; return ( <> {/* Header */}
PLAN ACTIVO · GENERADO POR {(activePlan?.agents_used || ["coach"]).join(" + ").toUpperCase()} {activePlan?.created_at && <> · {formatPlanDate(activePlan.created_at)}}

{parsed.title}

{parsed.weeks.length} semanas · {sessionsPerWeek} sesiones/sem {goal && goal !== parsed.title && <> · objetivo: {goal}}
{plans && plans.length > 1 && ( )}
{parsed.summary && (
Por qué este plan: {truncate(parsed.summary, 320)}
)}
{/* Tabs de semana */}
CALENDARIO · {parsed.weeks.length} SEMANAS
{parsed.weeks.map((w, i) => ( ))}
Suave Intenso Largo Fuerza
{/* Grid 7 columnas para la semana activa */}
{["Lun","Mar","Mié","Jue","Vie","Sáb","Dom"].map((d) => (
{d}
))} {week.days.map((d, di) => ( { if (d.sessions.length) { setSelectedSession({ ...d.sessions[0], dayLabel: d.label, week: week.n }); } else { setSelectedSession({ name: "Descanso", tone: "rest", dayLabel: d.label, week: week.n }); } }} active={selectedSession && selectedSession.dayLabel === d.label && selectedSession.week === week.n} /> ))}
{/* Detalle de sesión */} {selectedSession && setSelectedSession(null)} />} {/* Por qué este plan */} {parsed.summary && parsed.summary.length > 100 && (
POR QUÉ ASÍ · NARRATIVA DEL COACH
{parsed.summary}
)} ); }; /* ───────── PlanFallback (markdown crudo) ───────── */ const PlanFallback = ({ plans, activePlan, onSelectPlan, rawMarkdown }) => ( <>
PLAN COMPLETO (SIN PARSEAR)
El coach devolvió el plan pero no en el formato esperado por el calendario. Mostramos el markdown crudo. Podés volver a generarlo con instrucciones más estrictas.
window.dispatchEvent(new CustomEvent("af-plans-new"))}>Regenerar plan {/* TODO: botón "Pedir al coach que reformatee" → POST /agents/ask con instrucción de reformatear `rawMarkdown` a la estructura esperada. */}
{plans && plans.length > 1 && activePlan && ( )}
); /* ───────── PlanDayCell ───────── */ const PlanDayCell = ({ day, onClick, active }) => { // Tomamos la sesión "principal" de cada día — la primera no-rest si la hay. const session = day.sessions.find((s) => s.tone !== "rest") || day.sessions[0]; const tone = session?.tone || "rest"; const colors = { rest: { line: "var(--text-4)", bg: "var(--bg-2)" }, easy: { line: "var(--info)", bg: "color-mix(in srgb, var(--info) 8%, var(--bg-1))" }, long: { line: "var(--good)", bg: "color-mix(in srgb, var(--good) 10%, var(--bg-1))" }, hard: { line: "var(--bad)", bg: "color-mix(in srgb, var(--bad) 10%, var(--bg-1))" }, strength: { line: "var(--accent)", bg: "color-mix(in srgb, var(--accent) 10%, var(--bg-1))" }, }; const c = colors[tone] || colors.rest; const subtitle = session ? [session.intensity, session.duration_min ? `${formatDuration(session.duration_min)}` : null].filter(Boolean).join(" · ") : "Descanso"; return ( ); }; /* ───────── SessionDetail ───────── */ const SessionDetail = ({ session, onClose }) => (
SEMANA {session.week} · {(session.dayLabel || "").toUpperCase()}
Cerrar

{session.name}

{session.notes && (
{session.notes}
)}
PARÁMETROS
{session.intensity &&
Intensidad {session.intensity}
} {session.duration_min &&
Duración {formatDuration(session.duration_min)}
} {session.load &&
Carga {session.load}
} {!session.intensity && !session.duration_min && !session.load && (
Sin parámetros adicionales
)}
{Array.isArray(session.variants) && session.variants.length > 0 && (
OPCIONES
{session.variants .slice() .sort((a, b) => (a.kind === "primary" ? -1 : b.kind === "primary" ? 1 : 0)) .map((v, i) => (
{v.kind === "primary" ? "Principal" : "Alternativa"} {v.sport && ( {v.sport} )}
{v.name}
{[v.intensity, v.duration_min ? formatDuration(v.duration_min) : null, v.load ? `carga ${v.load}` : null].filter(Boolean).join(" · ") || "—"}
{v.notes &&
{v.notes}
}
))}
)}
); /* ───────── NutritionPlan (sigue MOCK) ───────── */ /* NutritionPlan — stub honesto mientras el parser nutricional no esté listo. * Antes mostraba macros y comidas inventadas con un mini-aviso "datos de muestra". * Eso confundía a usuarios reales (parecía un plan basado en sus datos). * Ahora: card sobria con CTA hacia Coach para usar el nutricionista vía chat. */ const NutritionPlan = () => (
NUTRICIÓN · PRÓXIMAMENTE

El planificador nutricional está en desarrollo.

Cuando esté listo, vas a poder pedirle al nutricionista un plan basado en tus objetivos, tu carga de entrenamiento y tus datos de composición corporal (Eufy / Garmin). Mientras tanto podés conversar con él directamente desde el coach.
} onClick={() => window.dispatchEvent(new CustomEvent("af-route", { detail: "agents" }))} > Hablar con el nutricionista
); /* ───────── NewPlanForm ───────── */ // Distancias clásicas de running. Cada chip llena `goal` con una plantilla // editable. Si el usuario quiere otro deporte, simplemente escribe libremente. const RUNNING_PRESETS = [ { id: "5k", label: "5K", goal: "5K — bajar mi PR", weeks: 6, sessions: 4 }, { id: "10k", label: "10K", goal: "10K — bajar mi PR", weeks: 8, sessions: 4 }, { id: "21k", label: "Media maratón", goal: "Media maratón — terminar en buen tiempo", weeks: 12, sessions: 4 }, { id: "42k", label: "Maratón", goal: "Maratón — terminar en buen tiempo", weeks: 16, sessions: 5 }, ]; const NewPlanForm = ({ setView, inDemo, onSaved }) => { const [stage, setStage] = React.useState("form"); // form | loading | preview | error const [errorMsg, setErrorMsg] = React.useState(null); const [generated, setGenerated] = React.useState(null); // {id, ...result} const [params, setParams] = React.useState({ goal: "", weeks: 4, sessions: 4, long_day: "Saturday", restrictions: "", }); // Preferencias de deportes — cargadas desde /config/user. Permite editar // dentro del form o dejar las que ya estaban guardadas. `null` mientras // carga; `{sports: [], ...}` cuando llegó la respuesta del backend. const [prefs, setPrefs] = React.useState(null); const [supportedSports, setSupportedSports] = React.useState([]); const [savePrefs, setSavePrefs] = React.useState(true); React.useEffect(() => { if (inDemo) { setPrefs({ sports: [{ id: "running", name: "Carrera", is_primary: true }], weekly_sessions: null }); setSupportedSports([ { id: "running", name: "Carrera" }, { id: "cycling", name: "Bici" }, { id: "swimming", name: "Natación" }, { id: "strength", name: "Fuerza / gym" }, ]); return; } let alive = true; (async () => { try { const res = await API.getUserPrefs(); if (!alive) return; setSupportedSports(res?.supported_sports || []); setPrefs(res?.prefs || { sports: [], weekly_sessions: null, long_session_day: null }); } catch (e) { if (alive && (e.status !== 401 && e.status !== 403)) { setPrefs({ sports: [], weekly_sessions: null, long_session_day: null }); } } })(); return () => { alive = false; }; }, [inDemo]); const togglePrefSport = (sportId) => { setPrefs((p) => { if (!p) return p; const sports = [...(p.sports || [])]; const idx = sports.findIndex((s) => s.id === sportId); const supported = supportedSports.find((s) => s.id === sportId); if (idx >= 0) { const wasPrimary = sports[idx].is_primary; sports.splice(idx, 1); // Si quitamos el primary y queda al menos uno, el primero se vuelve primary. if (wasPrimary && sports.length > 0) sports[0].is_primary = true; } else if (supported) { sports.push({ id: sportId, name: supported.name, is_primary: sports.length === 0 }); } return { ...p, sports }; }); }; const setPrimarySport = (sportId) => { setPrefs((p) => { if (!p) return p; const sports = (p.sports || []).map((s) => ({ ...s, is_primary: s.id === sportId })); return { ...p, sports }; }); }; const applyPreset = (preset) => { setParams((p) => ({ ...p, goal: preset.goal, weeks: preset.weeks, sessions: preset.sessions })); // Asegurarse que running esté en los deportes (y como primary si no había nada). setPrefs((p) => { if (!p) return p; const sports = [...(p.sports || [])]; const exists = sports.find((s) => s.id === "running"); if (!exists) { sports.unshift({ id: "running", name: "Carrera", is_primary: sports.length === 0 }); } return { ...p, sports }; }); }; const generate = async () => { setStage("loading"); setErrorMsg(null); if (inDemo) { // Demo: sintetizamos un resultado con el markdown demo. setTimeout(() => { setGenerated({ id: "demo-1", content: { answer: demoPlanMarkdown(), goal: params.goal } }); setStage("preview"); }, 1600); return; } try { // Persistir prefs antes de generar (si el usuario marcó la opción) — // así el LLM las lee en server-side desde /config/user. El form también // las pasa como constraints redundantes para registrar la intención. if (savePrefs && prefs && prefs.sports && prefs.sports.length > 0) { try { await API.setUserPrefs({ sports: prefs.sports, weekly_sessions: Number(params.sessions), long_session_day: params.long_day, }); } catch (_) { // No bloqueamos la generación si falla la persistencia de prefs. } } const result = await API.planTraining({ goal: params.goal, weeks: Number(params.weeks), sessions_per_week: Number(params.sessions), long_day: params.long_day, restrictions: params.restrictions || undefined, sports: (prefs?.sports || []).map((s) => ({ id: s.id, name: s.name, is_primary: !!s.is_primary })), }); setGenerated({ id: result.id, type: "training", created_at: result.created_at, agents_used: [result.agent || "coach"], content: result, }); setStage("preview"); } catch (e) { if (e.status === 412 || /llm.*not.*configured/i.test(e.message || "")) { setErrorMsg("Configurá un LLM en Configuración → LLM antes de generar planes."); } else if (e.network) { setErrorMsg("El backend no responde. Probá de nuevo en un momento."); } else { setErrorMsg(e.message || "No se pudo generar el plan."); } setStage("error"); } }; if (stage === "loading") { return (
GENERANDO PLAN
{[ { l: "Consultando coach", done: true }, { l: "Estructurando semanas", loading: true }, { l: "Validando carga vs. tu HRV reciente", pending: true }, ].map((s, i) => (
{s.done ? : s.loading ?
:
} {s.l}
))}
☁ enviando a OpenAI · ~30–60s · gasto estimado $0.08
); } if (stage === "preview" && generated) { const md = generated.content?.answer || ""; const parsed = parseTrainingPlan(md); return ( <>
{parsed ? "Plan generado · revisalo y guardalo" : "Plan generado · sin estructura clara"}
{parsed ? `${parsed.weeks.length} semanas · ${parsed.weeks.reduce((a, w) => a + w.days.filter((d) => d.sessions.some((s) => s.tone !== "rest")).length, 0)} sesiones totales` : "El LLM no respetó el formato esperado. Podés guardarlo igual y verlo crudo, o reintentar con instrucciones más estrictas."}
{ setStage("form"); setGenerated(null); }}>Descartar {!parsed && Reintentar} onSaved && onSaved(generated)}>Guardar y activar
{/* Preview del plan recién generado */} {}} selectedSession={null} activePlan={generated} plans={[]} inDemo={false} onSelectPlan={() => {}} /> ); } return (
NUEVO PLAN DE ENTRENAMIENTO
{stage === "error" && errorMsg && (
No se generó el plan. {errorMsg} {/llm/i.test(errorMsg) && (
{ window.dispatchEvent(new CustomEvent("af-route", { detail: "settings" })); }}>Ir a Configuración → LLM
)}
)} {/* Presets clásicos de running — un click llena objetivo + semanas + sesiones. */}
PRESETS DE CARRERA
{RUNNING_PRESETS.map((p) => ( ))}
Tocá un preset para llenar el objetivo y los parámetros base. Podés editarlos abajo.
setParams({ ...params, goal: e.target.value })} placeholder="ej: Media maratón sub 1h45 · 5K sub 22 · base aeróbica multi-deporte" style={fieldStyle} /> setParams({ ...params, weeks: e.target.value })} min={2} max={26} style={fieldStyle} /> setParams({ ...params, sessions: e.target.value })} min={2} max={10} style={fieldStyle} />