/* Settings — providers, LLM, privacidad, sistema. Cableado a la API real * con fallback a MOCK cuando el backend no responde. */ // Format an ISO timestamp as a short, relative human string in es-ES. // "hace 12 min" / "hace 3 h" / "ayer 21:14" / "21 abr 09:30". const formatRelative = (iso) => { if (!iso) return "—"; let raw = iso; if (typeof raw === "string" && /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(raw) && !/[zZ]|[+-]\d{2}:?\d{2}$/.test(raw)) { raw = raw.replace(" ", "T") + "Z"; } const d = typeof raw === "string" ? new Date(raw) : raw; if (!(d instanceof Date) || isNaN(d.getTime())) return "—"; const now = new Date(); const diffMs = now.getTime() - d.getTime(); const diffMin = Math.round(diffMs / 60000); 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`; const sameYear = d.getFullYear() === now.getFullYear(); const time = d.toLocaleTimeString("es-ES", { hour: "2-digit", minute: "2-digit" }); if (diffH < 48) return `ayer ${time}`; const months = ["ene","feb","mar","abr","may","jun","jul","ago","sep","oct","nov","dic"]; const m = months[d.getMonth()]; const dd = String(d.getDate()).padStart(2, "0"); return sameYear ? `${dd} ${m} ${time}` : `${dd} ${m} ${d.getFullYear()}`; }; const SETTINGS_AGENTS = { coach: { label: "Coach", icon: "coach", color: "var(--accent)" }, nutri: { label: "Nutricionista", icon: "apple", color: "var(--good)" }, medical: { label: "Médico", icon: "medical", color: "var(--info)" }, }; const Settings = ({ density, inDemo, onLogout, setRoute }) => { const [tab, setTab] = React.useState("providers"); return (
{tab === "providers" && } {tab === "llm" && } {tab === "usage" && } {tab === "privacy" && } {tab === "system" && }
); }; const SettingsProviders = ({ setRoute }) => { const [providers, setProviders] = React.useState(null); const [error, setError] = React.useState(null); const [syncing, setSyncing] = React.useState(null); const [connectFor, setConnectFor] = React.useState(null); // provider name for inline modal const load = React.useCallback(async () => { if (API.demoMode()) { setProviders(MOCK.providers); return; } try { const data = await API.listProviders(); // /providers/ trae sólo el registry (nombre/modos/caps); el estado // de conexión vive en /providers/{name}/status. Merge en paralelo. const list = Array.isArray(data) ? data : (data?.providers || []); const statuses = await Promise.all( list.map(p => { const name = typeof p === "string" ? p : (p.name || p.id); return name ? API.providerStatus(name).catch(() => null) : Promise.resolve(null); }) ); const normalized = list.map((p, i) => { const base = typeof p === "string" ? { name: p, mode: "POLL", caps: [] } : { name: p.name || p.id || "?", mode: p.mode || (p.modes?.[0]) || "POLL", caps: p.capabilities || p.caps || [], }; const st = statuses[i] || {}; const auth = st.auth || st.status; // /status usa {auth}, /auth/status usa {status} const lastSyncIso = st.last_sync || st.lastSync || null; return { ...base, logo: (base.name || "?")[0]?.toUpperCase(), state: auth === "logged_in" ? "ok" : "off", lastSync: lastSyncIso ? formatRelative(lastSyncIso) : (auth === "logged_in" ? "—" : "—"), syncing: !!st.syncing, }; }); setProviders(normalized); setError(null); } catch (e) { setError(e.message); // Si la lista de providers ya estaba cargada antes (re-fetch tras // un sync), la dejamos como estaba en lugar de pisarla con MOCK. // Si nunca cargó, mostramos lista vacía + mensaje de error abajo. if (providers === null) setProviders([]); } }, [providers]); React.useEffect(() => { load(); }, [load]); const triggerSync = async (name) => { setSyncing(name); try { await API.sync(name); await load(); } catch (e) { alert(`Sync falló: ${e.message}`); } finally { setSyncing(null); } }; // En demo seguimos mostrando MOCK.providers para preview; en producción // usamos lo que vino del backend. Mientras carga, lista vacía. const list = providers || (API.demoMode() ? MOCK.providers : []); const connected = list.filter((p) => p.state === "ok"); const available = list.filter((p) => p.state !== "ok"); const [showAddModal, setShowAddModal] = React.useState(false); return ( <> {error && (
Backend no responde: {error}
)}
PROVEEDORES CONECTADOS
{connected.length > 0 && available.length > 0 && ( } onClick={() => setShowAddModal(true)} > Agregar proveedor )}
{connected.length === 0 ? ( // Empty state — el primer onboarding visible cuando no hay nada.
No tenés proveedores conectados aún.
Conectá Garmin, Eufy u Oura para que el coach trabaje con tus datos reales. Sin proveedores, los planes se generan a ciegas.
{available.length > 0 ? ( } onClick={() => setShowAddModal(true)}> Agregar tu primer proveedor ) : (
no hay proveedores disponibles en el registry
)}
) : ( {connected.map((p, i) => ( triggerSync(p.name)} last={i === connected.length - 1} /> ))} )} {showAddModal && ( setShowAddModal(false)} onPick={(name) => { setShowAddModal(false); setConnectFor(name); }} /> )} {connectFor && ( setConnectFor(null)} onConnected={() => { setConnectFor(null); load(); }} setRoute={setRoute} /> )} ); }; // Inline connect flow used by SettingsProviders. // Garmin: email + password + (MFA code si Garmin lo pide). // Eufy / Oura: por ahora derivamos al asistente de configuración (que tiene // el flujo BLE / PAT completo); el botón es un atajo coherente. /* ConnectedProviderRow — fila por proveedor activo con sync + menú "⋯". * Antes había Sync y Desconectar siempre visibles (ruido). Ahora el menú * mantiene el row limpio y sólo expone acciones secundarias bajo demanda. */ const ConnectedProviderRow = ({ provider, syncing, onSync, last }) => { const p = provider; const [menuOpen, setMenuOpen] = React.useState(false); React.useEffect(() => { if (!menuOpen) return undefined; const close = () => setMenuOpen(false); window.addEventListener("click", close); return () => window.removeEventListener("click", close); }, [menuOpen]); return (
{p.logo}
{p.name}
{p.mode} {p.caps.slice(0, 4).map((c) => ( {c} ))} {p.caps.length > 4 && ( +{p.caps.length - 4} )}
Activo
sincronizado {p.lastSync}
e.stopPropagation()}> } onClick={onSync} disabled={syncing} > {syncing ? "Sync…" : "Sincronizar"} setMenuOpen((v) => !v)} title="Más opciones" > ⋯ {menuOpen && (
)}
); }; /* AddProviderModal — grilla de proveedores aún no conectados. Reusa los * estilos modalBackdrop/modalBody existentes. Click sobre una tarjeta dispara * el flujo `setConnectFor(name)` del parent que abre el wizard inline * apropiado (Garmin: email+MFA; Eufy: BLE pair; Oura: PAT). Todo dentro del * mismo modal — no se sale a otra pantalla. */ const AddProviderModal = ({ available, onClose, onPick }) => (
e.stopPropagation()} style={{ ...modalBody, width: "min(640px, 100%)" }}>
AGREGAR PROVEEDOR
¿Qué fuente de datos querés sumar?
Cerrar
{available.length === 0 ? (
No quedan proveedores por sumar — ya tenés conectados todos los disponibles.
) : (
{available.map((p) => ( ))}
)}
); /* EufyConnectModal — wraps the existing EufyPair (loaded by screen-onboarding) * inside a sized modal. Antes derivábamos al asistente full-screen; ahora todo * el wizard de scan/configure/listener-restart vive dentro del mismo modal. */ const EufyConnectModal = ({ onClose, onConnected }) => { // El sub-componente maneja sus propios pasos (idle / scanning / results) // pero pide que el "outer state" se exponga. Acá lo hosteamos. const [pairState, setPairState] = React.useState("idle"); const EufyPairComp = (typeof window !== "undefined") ? window.EufyPair : null; return (
e.stopPropagation()} style={{ ...modalBody, width: "min(640px, 100%)", maxHeight: "88vh", overflowY: "auto" }}>
CONECTAR · EUFY
Cerrar
{EufyPairComp ? ( ) : (
Cargando wizard de Eufy…
)}
); }; /* OuraConnectModal — pequeño formulario inline para el PAT (personal access * token) de Oura Cloud. El backend valida el token contra /v2/usercollection * /personal_info y lo persiste cifrado en common.secrets. */ const OuraConnectModal = ({ onClose, onConnected }) => { const [pat, setPat] = React.useState(""); const [stage, setStage] = React.useState("idle"); // idle | submitting | done | error const [errMsg, setErrMsg] = React.useState(null); const submit = async () => { if (!pat || pat.trim().length < 16) { setErrMsg("Pegá tu Personal Access Token (lo generás en cloud.ouraring.com → Personal Access Tokens)."); return; } setStage("submitting"); setErrMsg(null); try { const res = await API.startAuth("oura", { style: "api_key", credentials: { personal_access_token: pat.trim() }, }); if (res?.status === "logged_in" || res?.auth === "logged_in") { setStage("done"); setTimeout(() => onConnected && onConnected(), 600); } else { setStage("error"); setErrMsg(res?.error || "No se pudo validar el PAT con Oura."); } } catch (e) { setStage("error"); setErrMsg(e.detail?.detail || e.message || "Error verificando el PAT."); } }; const fs = (typeof window !== "undefined" && window.fieldStyle3) || { width: "100%", padding: "10px 12px", background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-2)", fontSize: 13, color: "var(--text)", outline: "none", marginTop: 4, }; return (
e.stopPropagation()} style={modalBody}>
CONECTAR · OURA
Cerrar
Pegá tu Personal Access Token
Generá uno en{" "} cloud.ouraring.com → Personal Access Tokens . El token se guarda cifrado con Fernet en este servidor; nunca sale del contenedor.
{stage === "done" ? (
✓ Oura conectado. Cerrando…
) : ( <> setPat(e.target.value)} placeholder="ULNZQ7..." autoComplete="off" spellCheck={false} disabled={stage === "submitting"} style={{ ...fs, fontFamily: "var(--font-mono)" }} /> {errMsg && (
Error: {errMsg}
)}
Cancelar {stage === "submitting" ? "Validando…" : "Conectar"}
)}
); }; const ConnectModal = ({ provider, onClose, onConnected, setRoute }) => { const [email, setEmail] = React.useState(""); const [password, setPassword] = React.useState(""); const [mfa, setMfa] = React.useState(""); const [stage, setStage] = React.useState("idle"); // idle | submitting | awaiting_mfa | done | error const [errMsg, setErrMsg] = React.useState(null); const [waitInfo, setWaitInfo] = React.useState(null); // {elapsedSec} mientras pollea const close = () => onClose && onClose(); // Sondear /auth/status hasta llegar a un estado terminal. Garmin con // Cloudflare puede tardar 3-5 min (mobile→widget→portal con backoffs // de 13-19s entre intentos). Por eso 5 min de timeout en lugar de 2. const pollUntilTerminal = async (timeoutMs = 300000, onTick) => { const t0 = Date.now(); while (Date.now() - t0 < timeoutMs) { await new Promise(r => setTimeout(r, 1500)); try { const s = await API.providerStatus("garmin"); const auth = s?.auth || s?.status; const elapsedSec = Math.round((Date.now() - t0) / 1000); if (onTick) onTick({ elapsedSec, auth }); if (auth === "logged_in") return { ok: true, status: auth }; if (auth === "awaiting_mfa") return { ok: false, mfa: true, status: auth }; if (auth === "error") return { ok: false, status: auth, error: s.auth_error || s.error }; } catch (_) { /* sigue sondeando */ } } return { ok: false, status: "timeout", error: "Garmin tardó más de 5 min — probablemente la IP está rate-limiteada por Cloudflare. El backend sigue intentando en background; podés cerrar y revisar el estado en unos minutos.", }; }; const startGarmin = async () => { if (!email || !password) { setErrMsg("Email y contraseña requeridos."); return; } setStage("submitting"); setErrMsg(null); try { await API.startAuth("garmin", { style: "interactive", credentials: { email, password }, }); // El POST /auth/start retorna casi inmediato; el auth real corre en // background. Polleamos /auth/status hasta estado terminal. setWaitInfo({ elapsedSec: 0 }); const final = await pollUntilTerminal(300000, (tick) => setWaitInfo(tick)); setWaitInfo(null); if (final.ok) { setStage("done"); setTimeout(() => { onConnected && onConnected(); }, 600); } else if (final.mfa) { setStage("awaiting_mfa"); } else { setStage("error"); setErrMsg(final.error || "No se pudo iniciar sesión."); } } catch (e) { setStage("error"); setErrMsg(e.detail?.detail || e.message || "No se pudo iniciar sesión con Garmin."); } }; const submitMfa = async () => { if (!mfa) { setErrMsg("Pegá el código MFA que te llegó al email."); return; } setStage("submitting"); setErrMsg(null); try { await API.submitMfa("garmin", { code: mfa }); // submit_mfa también termina el auth en background; sondear status. const final = await pollUntilTerminal(60000); if (final.ok) { setStage("done"); setTimeout(() => { onConnected && onConnected(); }, 600); } else if (final.mfa) { setStage("awaiting_mfa"); setErrMsg("El backend sigue esperando MFA. Reintentá con el código."); } else { setStage("error"); setErrMsg(final.error || "Código MFA rechazado."); } } catch (e) { setStage("error"); setErrMsg(e.detail?.detail || e.message || "Error verificando MFA."); } }; // Eufy: wizard inline reusando window.EufyPair (definido en screen-onboarding). // Antes redirigíamos a /onboarding y el flujo se sentía partido. if (provider === "eufy") { return { onConnected && onConnected(); }} />; } if (provider === "oura") { return { onConnected && onConnected(); }} />; } return (
e.stopPropagation()} style={modalBody}>
CONECTAR · GARMIN
Iniciá sesión con Garmin Connect.
Tus credenciales nunca salen del contenedor. Garmin puede pedir un código de verificación de dispositivo al primer login.
{stage !== "awaiting_mfa" && stage !== "done" && ( <>
setEmail(e.target.value)} placeholder="email@dominio.com" style={modalField} disabled={stage === "submitting"} /> setPassword(e.target.value)} placeholder="contraseña" style={modalField} disabled={stage === "submitting"} onKeyDown={(e) => { if (e.key === "Enter") startGarmin(); }} />
{stage === "submitting" && waitInfo && (
Conectando contra Garmin · {waitInfo.elapsedSec}s
Cloudflare a veces nos hace esperar 2-5 min entre intentos. Podés cerrar la modal — el backend sigue intentando y el provider se va a marcar como Activo cuando termine.
)} {errMsg &&
{errMsg}
}
{stage === "submitting" ? "Cerrar (sigue en background)" : "Cancelar"} {stage === "submitting" ? "Conectando…" : "Iniciar sesión"}
)} {stage === "awaiting_mfa" && ( <>
Garmin te envió un código de verificación. Revisá tu email (incluyendo spam).
setMfa(e.target.value)} placeholder="código (6 dígitos)" style={{ ...modalField, marginBottom: 12, fontFamily: "var(--font-mono)" }} autoFocus onKeyDown={(e) => { if (e.key === "Enter") submitMfa(); }} /> {errMsg &&
{errMsg}
}
{ setStage("idle"); setMfa(""); setErrMsg(null); }}>← volver
Reenviar Verificar
)} {stage === "done" && (
Garmin conectado.
)}
); }; const modalBackdrop = { position: "fixed", inset: 0, zIndex: 1000, background: "color-mix(in srgb, #000 65%, transparent)", display: "grid", placeItems: "center", padding: 24, }; const modalBody = { width: "min(440px, 100%)", background: "var(--bg-1)", border: "1px solid var(--line)", borderRadius: "var(--r-3, 12px)", padding: "20px 22px", boxShadow: "0 24px 80px -20px rgba(0,0,0,0.6)", }; const modalField = { background: "var(--bg)", border: "1px solid var(--line)", borderRadius: "var(--r-2)", color: "var(--text)", padding: "10px 12px", fontSize: 13.5, width: "100%", outline: "none", }; const SettingsLLM = () => { // provider en lowercase para que matchee el registro del backend // (LLM_PROVIDERS["openai"]). Los selects muestran labels capitalizados. const [config, setConfig] = React.useState({ provider: "openai", model: "gpt-4o-mini", api_key: "", base_url: "", configured: false, // true tras un GET /config/llm que devuelve { configured: true } }); const [missing, setMissing] = React.useState(false); const [testStatus, setTestStatus] = React.useState({ kind: "idle" }); React.useEffect(() => { if (API.demoMode()) return; API.getLLMConfig().then((c) => { // Backend devuelve { configured, provider, model_default, models_per_agent, base_url }. const isConfigured = c?.configured === true; setConfig((prev) => ({ ...prev, provider: (c?.provider || prev.provider).toLowerCase(), model: c?.model_default || c?.default_model || c?.model || prev.model, base_url: c?.base_url || "", api_key: "", // nunca se pre-rellena; el backend nunca devuelve la key en claro configured: isConfigured, })); setMissing(!isConfigured); }).catch((e) => { // 401/403 los maneja el evento global; 404/otros marcan "no configurado". setMissing(true); }); }, []); const testConnection = async () => { if (!config.api_key || config.api_key.includes("•")) { setTestStatus({ kind: "bad", msg: "Pegá una API key real para probar." }); return; } setTestStatus({ kind: "testing" }); const t0 = performance.now(); try { await API.setLLMConfig({ provider: config.provider.toLowerCase(), model_default: config.model, api_key: config.api_key, base_url: config.base_url || null, }); setTestStatus({ kind: "ok", ms: Math.round(performance.now() - t0) }); setConfig((prev) => ({ ...prev, configured: true, api_key: "" })); setMissing(false); } catch (e) { setTestStatus({ kind: "bad", msg: e.message }); } }; return ( <> {missing && (
LLM sin configurar. Sin esto el chat con agentes y la generación de planes están deshabilitados. Completá los campos abajo para activar al coach.
)}
PROVEEDOR LLM
setConfig({ ...config, api_key: e.target.value })} style={settingsField} /> setConfig({ ...config, base_url: e.target.value })} style={settingsField} />
} onClick={testConnection} disabled={testStatus.kind === "testing"}> {testStatus.kind === "testing" ? "Probando…" : "Probar conexión"} {testStatus.kind === "ok" && ( ok · {testStatus.ms}ms )} {testStatus.kind === "bad" && ( {testStatus.msg} )}
MODELO POR AGENTE
{Object.entries(SETTINGS_AGENTS).map(([k, a], i) => (
{a.label}
))}
); }; const SettingsUsage = () => { const [usage, setUsage] = React.useState(null); const [loading, setLoading] = React.useState(true); const [err, setErr] = React.useState(null); React.useEffect(() => { if (API.demoMode()) { setUsage({ by_agent: [ { agent: "coach", prompt: 12000, completion: 4500, cost: "0.18" }, { agent: "briefing.coach", prompt: 8000, completion: 1200, cost: "0.10" }, ], by_model: [{ model: "gpt-4o-mini", prompt: 20000, completion: 5700, cost: "0.28" }], total_tokens: 25700, total_cost_usd: "0.28", }); setLoading(false); return; } API.getLLMUsage() .then((u) => { setUsage(u); setLoading(false); }) .catch((e) => { setErr(e.message || "No se pudo cargar uso"); setLoading(false); }); }, []); const totalTokens = Number(usage?.total_tokens || 0); const totalCost = Number(usage?.total_cost_usd || 0); const byAgent = Array.isArray(usage?.by_agent) ? usage.by_agent : []; const byModel = Array.isArray(usage?.by_model) ? usage.by_model : []; return ( <>
USO LLM · ACUMULADO
{loading ? (
cargando uso…
) : err ? (
{err}
) : totalTokens === 0 ? (
Sin llamadas LLM registradas todavía.
Generá un briefing o un plan para empezar a ver consumo aquí.
) : ( <>
{byAgent.length > 0 && (
POR AGENTE
{["AGENTE", "PROMPT", "COMPLETION", "USD"].map((h) => ( ))} {byAgent.map((row, i) => ( ))}
{h}
{row.agent} {fmtTokens(Number(row.prompt))} {fmtTokens(Number(row.completion))} ${Number(row.cost || 0).toFixed(4)}
)} {byModel.length > 0 && (
POR MODELO
{["MODELO", "PROMPT", "COMPLETION", "USD"].map((h) => ( ))} {byModel.map((row, i) => ( ))}
{h}
{row.model} {fmtTokens(Number(row.prompt))} {fmtTokens(Number(row.completion))} ${Number(row.cost || 0).toFixed(4)}
)} )} ); }; const SettingsPrivacy = () => ( <>
PRIVACIDAD
OpenAI procesará tus datos en sus servidores. Si querés procesamiento 100% local, configurá Ollama cuando esté disponible. Tus datos crudos nunca salen del contenedor; solo lo que mandás explícitamente al chat.
); const SettingsSystem = ({ onLogout, setRoute }) => { const [info, setInfo] = React.useState(null); React.useEffect(() => { if (API.demoMode()) { setInfo({ version: "0.4.2", status: "demo" }); return; } Promise.all([API.health().catch(() => null), API.version().catch(() => null)]).then(([h, v]) => { setInfo({ version: v?.version || h?.version || "0.4.2", status: h?.status || "running", }); }); }, []); const v = info?.version || "0.4.2"; return ( <>
SISTEMA
{API.getUser()?.email || (API.demoMode() ? "demo" : "—")} { (onLogout ? onLogout() : (async () => { await API.authLogout(); window.location.reload(); })()); }}>Cerrar sesión } /> {API.getBase() || "(mismo origen)"} } /> ver últimos 1000 →} last />
CONFIGURACIÓN INICIAL
Asistente de configuración
Recorré de nuevo el flujo guiado para conectar proveedores y configurar el LLM. Útil cuando agregás un dispositivo nuevo o cambiás de modelo.
setRoute && setRoute("onboarding")} > Abrir asistente →
EXPORTAR
}>Descargar dump DuckDB }>Exportar CSV de actividades }>Exportar histórico LLM
); }; const PrivacyRow = ({ title, body, on, last, disabled }) => (
{title} {disabled && próximamente}
{body}
); const Toggle = ({ on, disabled }) => (
); const SysRow = ({ l, v, last }) => (
{l}
{v}
); const UsageStat = ({ label, value, unit }) => (
{label}
{value} {unit && {unit}}
); const settingsField = { width: "100%", padding: "8px 10px", background: "var(--bg-2)", border: "1px solid var(--line)", borderRadius: "var(--r-2)", fontSize: 12.5, color: "var(--text)", outline: "none", marginTop: 4, }; const SettingsField = ({ label, children, full }) => (
{label}
{children}
); function fmtTokens(n) { if (n == null) return "—"; if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`; if (n >= 1000) return `${(n / 1000).toFixed(1)}k`; return String(n); } function maskToken(t) { if (!t) return "—"; if (t.length <= 8) return "•".repeat(t.length); return t.slice(0, 4) + "•".repeat(Math.min(12, t.length - 8)) + t.slice(-4); } window.Settings = Settings;