// Bortec Cycling — Entrenamientos App (Free)
// Implementation of the "Entrenamientos App - Free" design.
// Responsive: mobile uses a bottom tab bar, desktop (>=900px) uses a left sidebar.
const C = {
accent: '#37cc50',
accentLight: '#5fe077',
panel: '#121212',
card: '#1c1c1c',
cardAlt: '#262626',
text: '#f2f2f2',
textSoft: '#a8a8a8',
textMuted: '#c4c4c4',
border: 'rgba(255,255,255,0.16)',
borderLight: 'rgba(255,255,255,0.18)',
greenBg: '#1f4d2a',
greenText: '#e3fbe7',
greenTextLight: '#8ce89c',
};
const inputStyle = {
minHeight: 42, padding: '8px 12px', fontSize: 14, color: C.text,
background: C.card, border: `1px solid ${C.border}`, borderRadius: 8, outline: 'none',
};
const smallInputStyle = { ...inputStyle, minHeight: 40, fontSize: 13.5 };
const labelStyle = { fontSize: 12, color: C.textSoft };
function primaryBtn(height = 46, fontSize = 15) {
return {
height, borderRadius: 8, border: `1px solid ${C.accent}`, background: 'transparent',
color: C.accent, fontWeight: 500, fontSize, fontFamily: 'Inter, sans-serif', cursor: 'pointer',
};
}
function ghostBtn(height = 46, fontSize = 13.5, color = '#f2f2f2') {
return {
height, borderRadius: 8, border: `1px solid ${C.borderLight}`, background: 'transparent',
color, fontWeight: 500, fontSize, fontFamily: 'Inter, sans-serif', cursor: 'pointer',
};
}
function badge(bg, color, border) {
return {
display: 'inline-flex', alignItems: 'center', fontSize: 11, padding: '3px 10px',
borderRadius: 6, background: bg, color, border,
};
}
function heading(size, margin) {
return {
margin: margin ?? 0, fontSize: size, fontWeight: 500, color: C.text,
fontFamily: "'Oswald', sans-serif", textTransform: 'uppercase', letterSpacing: '0.01em',
lineHeight: size >= 24 ? 1.2 : undefined,
};
}
const statCard = {
borderRadius: 8, background: C.card, padding: '12px 10px',
display: 'flex', flexDirection: 'column', gap: 4,
};
const sectionCard = {
borderRadius: 10, background: C.card, padding: 12,
display: 'flex', flexDirection: 'column', gap: 6,
};
const tableHeadCell = {
textAlign: 'left', fontSize: 10.5, letterSpacing: '0.06em', textTransform: 'uppercase',
color: C.textSoft, padding: '6px 4px', borderBottom: `1px solid ${C.border}`,
};
const tableCell = { padding: '8px 4px', borderBottom: '1px solid rgba(255,255,255,0.08)' };
const PLAN_PHASES = [
{ name: 'Calentamiento', duration: '10 min', zone: 'Z1 · Muy ligero', rpe: '2/10' },
{ name: 'Bloque principal', duration: '3 × 5 min', zone: 'Z2–Z3 · Moderado', rpe: '5/10' },
{ name: 'Recuperación', duration: '2 min c/u', zone: 'Z1 · Muy ligero', rpe: '2/10' },
{ name: 'Enfriamiento', duration: '10 min', zone: 'Z1 · Muy ligero', rpe: '2/10' },
];
const SUGGESTED_RACES = [
{ id: 'r1', name: 'Vuelta al Ajusco', date: '20 sep', location: 'CDMX', distance: '110 km', note: 'Buen siguiente reto después de tu base — subidas moderadas.' },
{ id: 'r2', name: 'Copa Fondista Toluca', date: '8 nov', location: 'Toluca', distance: '60 km', note: 'Ideal si quieres tu primera carrera con grupo.' },
{ id: 'r3', name: 'Reto Bajío', date: '14 feb', location: 'Guanajuato', distance: '95 km', note: 'La proponemos para el grupo de principiantes cada año.' },
];
const WEEK_LABELS = ['L', 'M', 'X', 'J', 'V', 'S', 'D'];
const WEEK_TYPES = ['rest', 'done', 'done', 'rest', 'today', 'plan', 'plan'];
const MONTH_NAMES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
const MONTH_SHORT = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
const TAB_SCREENS = ['inicio', 'plan', 'historial', 'chat', 'carreras'];
const CHAT_LIMIT = 3;
const NAV_ICONS = {
inicio: (color) => (
),
plan: (color) => (
),
historial: (color) => (
),
chat: (color) => (
),
carreras: (color) => (
),
};
class App extends React.Component {
state = {
bootLoading: true,
stage: 'onboarding',
isDesktop: (typeof window !== 'undefined' && window.innerWidth >= 900),
onboardingStep: 0,
loginEmail: '', loginPassword: '',
authMode: 'login', authLoading: false, authError: null, authInfo: null,
userId: null,
screen: 'inicio',
chatInput: '',
chatMessages: [
{ from: 'bot', text: '¡Hola! Soy tu asistente de entrenamiento. Pregúntame lo que quieras sobre tu plan de hoy.' },
],
chatResponsesUsed: 0,
introDone: false,
intake: {
fullName: '', contact: '', age: '', years: '', level: '', modality: '',
days: [], schedule: '', ftp: '', weeklyKm: '', health: '', priorPlan: '',
},
intakeSaving: false, intakeError: null,
perfil: null,
marcas: [],
pendingMark: null,
hasCoachNotification: true,
racesTab: 'mios',
raceGoals: [
{ name: 'Gran Fondo CDMX', date: '12 oct', distance: '80 km' },
],
addRaceOpen: false,
newRaceName: '', newRaceDate: '', newRaceDistance: '',
suggestedRacesMarked: {},
connectedAccounts: [
{ id: 'garmin', name: 'Garmin Connect', connected: false },
{ id: 'tp', name: 'TrainingPeaks', connected: true },
],
pushNotifications: true,
userPlan: 'free', planLocked: true,
liveElapsed: 0, livePaused: false, livePhaseIdx: 0,
lastSessionDuration: '00:00',
selectedRpe: 5,
range: 'semana', rangeOffset: 0,
};
componentDidMount() {
this._timer = setInterval(() => {
if (this.state.screen === 'liveSession' && !this.state.livePaused) {
this.setState(s => ({ liveElapsed: s.liveElapsed + 1 }));
}
}, 1000);
this._onResize = () => this.setState({ isDesktop: window.innerWidth >= 900 });
window.addEventListener('resize', this._onResize);
this.bootstrapAuth();
}
componentWillUnmount() {
clearInterval(this._timer);
window.removeEventListener('resize', this._onResize);
}
async bootstrapAuth() {
try {
const { data: { session } } = await window.sb.auth.getSession();
if (session && session.user) {
await this.loadUserData(session.user.id);
}
} catch (err) {
console.error('No se pudo restaurar la sesión', err);
} finally {
this.setState({ bootLoading: false });
}
}
async loadUserData(userId) {
const [{ data: perfilRow, error: perfilErr }, { data: marcasRows, error: marcasErr }] = await Promise.all([
window.sb.from('perfil').select('*').eq('user_id', userId).maybeSingle(),
window.sb.from('marcas').select('*').eq('user_id', userId).order('fecha', { ascending: false }),
]);
if (perfilErr) console.error('Error cargando perfil', perfilErr);
if (marcasErr) console.error('Error cargando marcas', marcasErr);
this.setState({
userId,
perfil: perfilRow || null,
marcas: marcasRows || [],
introDone: !!perfilRow,
stage: perfilRow ? 'app' : 'intake',
screen: 'inicio',
});
}
classifyMark(text) {
if (/\d{1,2}:\d{2}(:\d{2})?/.test(text)) return 'tiempo';
if (/\d+\s?(w\b|watts?)/i.test(text)) return 'potencia';
if (/\d+\s?(km\/h|kmh)/i.test(text)) return 'velocidad';
if (/(récord|record|marca|mejor tiempo|\bpr\b|logré)/i.test(text)) return 'record';
return 'marca';
}
formatFecha(iso) {
const d = new Date(iso);
return d.getDate() + ' ' + MONTH_SHORT[d.getMonth()].toLowerCase();
}
async handleAuthSubmit() {
const { loginEmail, loginPassword, authMode } = this.state;
const email = loginEmail.trim();
const password = loginPassword;
if (!email || !password) {
this.setState({ authError: 'Ingresa tu correo y contraseña.' });
return;
}
this.setState({ authLoading: true, authError: null, authInfo: null });
try {
if (authMode === 'register') {
const { data, error } = await window.sb.auth.signUp({ email, password });
if (error) throw error;
if (!data.session) {
this.setState({ authLoading: false, authMode: 'login', authInfo: 'Cuenta creada. Revisa tu correo para confirmarla y luego inicia sesión.' });
return;
}
await this.loadUserData(data.user.id);
} else {
const { data, error } = await window.sb.auth.signInWithPassword({ email, password });
if (error) throw error;
await this.loadUserData(data.user.id);
}
this.setState({ authLoading: false, loginPassword: '' });
} catch (err) {
this.setState({ authLoading: false, authError: err.message || 'Ocurrió un error, intenta de nuevo.' });
}
}
async logout() {
try {
await window.sb.auth.signOut();
} catch (err) {
console.error('Error al cerrar sesión', err);
}
this.setState({
stage: 'login', screen: 'inicio', loginEmail: '', loginPassword: '',
userId: null, perfil: null, marcas: [], introDone: false,
authMode: 'login', authError: null, authInfo: null,
});
}
answerFor(text) {
const t = text.toLowerCase();
if (t.includes('zona')) return { text: 'Las zonas de FC miden qué tan duro trabajas: Zona 1-2 son ritmos suaves para construir base, Zona 3+ ya es esfuerzo alto. Hoy tu plan pide Zona 2 — cómodo, conversacional.', escalate: false };
if (t.includes('dolor') || t.includes('lesion') || t.includes('lesión')) return { text: 'Si sientes dolor agudo, detén el entrenamiento y descansa. Las molestias musculares leves son normales, el dolor articular no.', escalate: true };
if (t.includes('plan') || t.includes('cambiar') || t.includes('ajustar')) return { text: 'Puedo darte pautas generales, pero los cambios de plan los confirma tu entrenador.', escalate: true };
if (t.includes('comer') || t.includes('comida') || t.includes('nutri')) return { text: 'Antes de rodar, algo ligero en carbohidratos 1-2 horas antes basta. Después del entreno, prioriza proteína y buena hidratación.', escalate: false };
return { text: 'Buena pregunta — no tengo una respuesta certera para eso.', escalate: true };
}
seededRandom(seed) {
const x = Math.sin(seed) * 10000;
return x - Math.floor(x);
}
getMonday(d) {
const date = new Date(d);
const day = date.getDay();
const diff = (day === 0 ? -6 : 1) - day;
date.setDate(date.getDate() + diff);
date.setHours(0, 0, 0, 0);
return date;
}
detectMark(text) {
const hasTimeOrNumber = /\d{1,2}:\d{2}(:\d{2})?/.test(text) || /\d+\s?(km\/h|kmh|w\b|watts?)/i.test(text);
const hasMarkWord = /(récord|record|marca|mejor tiempo|\bpr\b|logré)/i.test(text);
return hasTimeOrNumber || hasMarkWord;
}
async confirmMark(save) {
const mark = this.state.pendingMark;
if (!mark) return;
if (!save) {
this.setState(s => ({
pendingMark: null,
chatMessages: [...s.chatMessages, { from: 'bot', label: 'Asistente IA', text: 'Entendido, no la guardé.' }],
}));
return;
}
const { userId } = this.state;
const tipo = this.classifyMark(mark);
this.setState({ pendingMark: null });
const { data, error } = await window.sb.from('marcas')
.insert({ user_id: userId, tipo, valor: mark, fuente: 'chat' })
.select()
.maybeSingle();
if (error) {
this.setState(s => ({
chatMessages: [...s.chatMessages, { from: 'bot', label: 'Asistente IA', text: 'No pude guardar la marca (' + error.message + ').' }],
}));
return;
}
this.setState(s => ({
marcas: [data, ...s.marcas],
chatMessages: [...s.chatMessages, { from: 'bot', label: 'Asistente IA', text: 'Guardado en tus marcas ✅' }],
}));
}
sendText(text) {
const clean = (text || '').trim();
if (!clean || this.state.pendingMark) return;
if (this.detectMark(clean)) {
this.setState(s => ({
chatInput: '',
chatMessages: [...s.chatMessages, { from: 'user', text: clean }, { from: 'bot', label: 'Asistente IA', text: 'Parece una marca nueva: "' + clean + '". ¿La guardo en tu perfil de marcas?' }],
pendingMark: clean,
}));
return;
}
if (this.state.chatResponsesUsed >= CHAT_LIMIT) return;
const { text: reply, escalate } = this.answerFor(clean);
this.setState(s => {
const msgs = [...s.chatMessages, { from: 'user', text: clean }, { from: 'bot', label: 'Asistente IA', text: reply }];
if (escalate) msgs.push({ from: 'system', text: 'Notificado a Ana, tu entrenadora — te responderá aquí mismo.' });
return { chatInput: '', chatMessages: msgs, chatResponsesUsed: s.chatResponsesUsed + 1 };
});
if (escalate) {
setTimeout(() => {
this.setState(s => ({
chatMessages: [...s.chatMessages, { from: 'coach', label: 'Ana · Entrenadora', text: 'Vi tu mensaje — lo revisamos en tu próxima sesión, mientras tanto no fuerces el ritmo.' }],
}));
}, 2200);
}
}
go(screen, extra) {
this.setState({ screen, ...(extra || {}) });
}
renderOnboarding() {
const { onboardingStep } = this.state;
const next = () => {
if (onboardingStep === 0) this.setState({ onboardingStep: 1 });
else this.setState({ stage: 'login' });
};
const dot = (active) => ({ height: 4, borderRadius: 2, width: active ? 22 : 10, background: active ? C.accent : '#3a3a3a' });
const photo = (label) => (
{label}
);
return (
{onboardingStep === 0 && (
{photo('foto: ciclista rodando en montaña')}
Entrena con un plan hecho para ti
Planes claros, tu progreso siempre visible, y el respaldo real de tu entrenador.
)}
{onboardingStep === 1 && (
{photo('foto: ciclista en carrera')}
Habla directo con tu entrenador
Resuelve dudas al instante y recibe comentarios sobre cada sesión que completas.
)}
{onboardingStep === 0 ? 'Siguiente' : 'Comenzar'}
);
}
renderLogin() {
const { loginEmail, loginPassword, authMode, authLoading, authError, authInfo } = this.state;
const isRegister = authMode === 'register';
const submit = () => this.handleAuthSubmit();
return (
);
}
renderIntake() {
const { intake, intakeSaving, intakeError } = this.state;
const setField = (field) => (e) => this.setState(s => ({ intake: { ...s.intake, [field]: e.target.value } }));
const toggleDay = (key) => () => this.setState(s => ({
intake: { ...s.intake, days: s.intake.days.includes(key) ? s.intake.days.filter(d => d !== key) : [...s.intake.days, key] },
}));
const submit = async () => {
const { userId } = this.state;
if (!userId) {
this.setState({ intakeError: 'Sesión no válida, inicia sesión de nuevo.' });
return;
}
this.setState({ intakeSaving: true, intakeError: null });
const row = {
user_id: userId,
nombre: intake.fullName.trim(),
contacto: intake.contact.trim(),
edad: intake.age.trim(),
anos_entrenando: intake.years.trim(),
nivel: intake.level,
modalidad: intake.modality,
dias_disponibles: intake.days,
disponibilidad_semana: intake.schedule.trim(),
disponibilidad_finde: intake.weeklyKm.trim(),
ftp: intake.ftp.trim(),
condiciones_salud: intake.health.trim(),
acepta_responsabilidad: intake.priorPlan === 'Acepto',
};
const { data, error } = await window.sb.from('perfil')
.upsert(row, { onConflict: 'user_id' })
.select()
.maybeSingle();
if (error) {
this.setState({ intakeSaving: false, intakeError: error.message });
return;
}
this.setState({ intakeSaving: false, perfil: data, introDone: true, stage: 'app', screen: 'intakeInvite' });
};
return (
Cuéntanos de ti
Esto ayuda a tu entrenador a armar tu plan. Toma 2 minutos.
Nombre completo
Contacto (WhatsApp o email)
Nivel autopercibido
Selecciona…
Principiante
Intermedio
Avanzado
Modalidad
Selecciona…
MTB
Ruta
Ambas
Días disponibles para entrenar
{WEEK_LABELS.map(key => {
const selected = intake.days.includes(key);
return (
{key}
);
})}
Tiempo disponible de lunes a viernes
Lesiones o condiciones de salud
Liberación de responsabilidad
Declaro que la información entregada es correcta y que participo en las actividades y planes de entrenamiento de Bortec Cycling bajo mi propia responsabilidad, liberando a Bortec Cycling de cualquier lesión o daño derivado de mi práctica del ciclismo.
{['Acepto', 'No acepto'].map(label => {
const active = intake.priorPlan === label;
return (
this.setState(s => ({ intake: { ...s.intake, priorPlan: label } }))} style={{
padding: '7px 16px', fontSize: 12.5, cursor: 'pointer',
color: active ? C.accent : C.text,
boxShadow: active ? `inset 0 0 0 1px ${C.accent}` : 'none',
}}>{label}
);
})}
{intakeError &&
{intakeError}
}
{intakeSaving ? 'Guardando…' : 'Continuar'}
);
}
renderSidebar() {
const { screen } = this.state;
const itemStyle = (active) => ({
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 10px', borderRadius: 8, cursor: 'pointer',
background: active ? 'rgba(55,204,80,0.12)' : 'transparent',
});
const color = (active) => active ? C.accent : C.textSoft;
const item = (key, label, onClick) => (
{NAV_ICONS[key](color(screen === key))}
{label}
);
return (
{item('inicio', 'Inicio', () => this.go('inicio'))}
{item('plan', 'Plan', () => this.go('plan'))}
{item('historial', 'Progreso', () => this.go('historial'))}
{item('chat', 'Asistente', () => this.go('chat'))}
{item('carreras', 'Carreras', () => this.go('carreras'))}
this.go('profile')} style={itemStyle(screen === 'profile')}>
{(this.props.userName || '?').trim().charAt(0).toUpperCase() || '?'}
Perfil
);
}
renderHeader() {
const { screen, chatResponsesUsed } = this.state;
const { userName } = this.props;
const isDesktop = this.state.isDesktop;
if (!TAB_SCREENS.includes(screen)) return null;
const chatAvailable = chatResponsesUsed < CHAT_LIMIT;
return (
this.go('profile')} style={{ width: 26, height: 26, borderRadius: '50%', background: C.card, border: `1px solid ${C.borderLight}`, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, color: C.text, cursor: 'pointer' }}>{(userName || '?').trim().charAt(0).toUpperCase() || '?'}
{screen === 'inicio' && (
Hola, {userName}
Tu semana
Racha: 4 días
)}
{screen === 'plan' && (
this.go('inicio')} style={{ cursor: 'pointer', flex: 'none' }}>
Plan de hoy
Resistencia base
)}
{screen === 'historial' && (
)}
{screen === 'chat' && (
Asistente
{chatAvailable &&
{Math.max(CHAT_LIMIT - chatResponsesUsed, 0)}/{CHAT_LIMIT} respuestas }
Resuelve tus dudas
)}
{screen === 'carreras' && (
)}
);
}
renderInicio() {
const { weeklySessionGoal, showPowerMetric } = this.props;
const { hasCoachNotification } = this.state;
const sessionsDone = 3;
const weekDays = WEEK_LABELS.map((label, i) => {
const t = WEEK_TYPES[i];
const isToday = t === 'today';
return {
label,
bg: isToday ? '#1a2e1e' : 'transparent',
labelColor: isToday ? C.greenTextLight : C.textSoft,
dotColor: t === 'done' ? C.accent : (t === 'today' || t === 'plan') ? '#2a8f3c' : 'transparent',
};
});
return (
{hasCoachNotification && (
{ this.setState({ hasCoachNotification: false }); this.go('historial'); }} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 10, background: C.greenBg, cursor: 'pointer' }}>
Ana (tu entrenador) comentó tu última sesión
Ver
)}
FC reposo
58 bpm
{showPowerMetric && (
Potencia prom.
142 W
)}
{!showPowerMetric && (
Esfuerzo (RPE)
4/10
)}
Sesiones
{sessionsDone}/{weeklySessionGoal}
{weekDays.map((d, i) => (
))}
Vas por buen camino. Cuatro semanas seguidas entrenando — la constancia es lo que construye la forma física, no la intensidad.
);
}
renderPlan() {
const intensityRaw = [15, 20, 60, 25, 65, 25, 65, 25, 60, 20, 15, 10];
const intensityBars = intensityRaw.map(v => ({ height: v + '%', color: v > 45 ? C.accent : C.greenBg }));
return (
Principiante
45 min
Zona FC 2 · 120–140 bpm
Entrenamiento de resistencia base. Mantén un ritmo donde puedas conversar sin ahogarte — ese es el punto.
Perfil de intensidad
{intensityBars.map((b, i) => (
))}
Fase
Dur.
Zona
RPE
{PLAN_PHASES.map((p, i) => (
{p.name}
{p.duration}
{p.zone}
{p.rpe}
))}
Consejo: si en el bloque principal no puedes mantener la respiración controlada, baja un poco el ritmo — el progreso viene de repetir, no de forzar.
this.go('liveSession', { liveElapsed: 0, livePaused: false, livePhaseIdx: 0 })} style={primaryBtn(46, 15)}>Comenzar entrenamiento
);
}
renderHistorial() {
const { range, rangeOffset, userPlan, marcas } = this.state;
const isPremium = userPlan === 'premium';
const rangeOptions = [
{ key: 'semana', label: 'Semana' },
{ key: 'mes', label: 'Mes' },
{ key: 'anio', label: 'Año' },
].map(o => ({ label: o.label, key: o.key, active: range === o.key }));
const todayDate = new Date();
let periodLabel = '', periodBars = [];
const fmtDay = (d) => d.getDate() + ' ' + MONTH_SHORT[d.getMonth()].toLowerCase();
if (range === 'semana') {
const monday = this.getMonday(todayDate);
monday.setDate(monday.getDate() - rangeOffset * 7);
const sunday = new Date(monday); sunday.setDate(monday.getDate() + 6);
periodLabel = fmtDay(monday) + ' – ' + fmtDay(sunday) + (rangeOffset === 0 ? ' · esta semana' : '');
periodBars = WEEK_LABELS.map((lbl, i) => {
const dayDate = new Date(monday); dayDate.setDate(monday.getDate() + i);
const isFuture = dayDate > todayDate;
const seed = monday.getTime() / 86400000 + i * 7.3;
const val = isFuture ? 0 : Math.round(20 + this.seededRandom(seed) * 70);
const isToday = dayDate.toDateString() === todayDate.toDateString();
return { label: lbl, labelColor: isToday ? C.greenTextLight : C.textSoft, height: Math.max(val, 4) + '%', color: isToday ? C.accent : C.greenBg };
});
} else if (range === 'mes') {
const monthDate = new Date(todayDate.getFullYear(), todayDate.getMonth() - rangeOffset, 1);
periodLabel = MONTH_NAMES[monthDate.getMonth()] + ' ' + monthDate.getFullYear();
const daysInMonth = new Date(monthDate.getFullYear(), monthDate.getMonth() + 1, 0).getDate();
const weeksCount = Math.ceil(daysInMonth / 7);
periodBars = Array.from({ length: weeksCount }, (_, i) => {
const seed = monthDate.getTime() / 2629800000 + i * 3.7;
const val = Math.round(25 + this.seededRandom(seed) * 65);
const isCurrentWeek = rangeOffset === 0 && i === Math.floor((todayDate.getDate() - 1) / 7);
return { label: 'S' + (i + 1), labelColor: isCurrentWeek ? C.greenTextLight : C.textSoft, height: val + '%', color: isCurrentWeek ? C.accent : C.greenBg };
});
} else {
const year = todayDate.getFullYear() - rangeOffset;
periodLabel = String(year);
periodBars = MONTH_SHORT.map((m, i) => {
const isFuture = rangeOffset === 0 && i > todayDate.getMonth();
const seed = year * 12 + i * 1.9;
const val = isFuture ? 0 : Math.round(20 + this.seededRandom(seed) * 70);
const isCurrent = rangeOffset === 0 && i === todayDate.getMonth();
return { label: m, labelColor: isCurrent ? C.greenTextLight : C.textSoft, height: Math.max(val, 4) + '%', color: isCurrent ? C.accent : C.greenBg };
});
}
const canGoNext = rangeOffset > 0;
const coachComments = [
{ session: 'Resistencia base', date: '3 ago', text: 'Buen ritmo constante, mantuviste la Zona 2 casi toda la sesión. Sigue así.' },
{ session: 'Recuperación activa', date: '1 ago', text: 'Notaste bien la diferencia entre esfuerzo suave y moderado — eso es justo lo que buscamos esta semana.' },
];
return (
{rangeOptions.map(r => (
this.setState({ range: r.key, rangeOffset: 0 })} style={{
padding: '7px 14px', fontSize: 12.5, cursor: 'pointer',
color: r.active ? C.accent : C.text,
boxShadow: r.active ? `inset 0 0 0 1px ${C.accent}` : 'none',
}}>{r.label}
))}
this.setState(s => ({ rangeOffset: (s.rangeOffset || 0) + 1 }))} style={{ cursor: 'pointer', flex: 'none' }}>
{periodLabel}
{ if (canGoNext) this.setState(s => ({ rangeOffset: Math.max((s.rangeOffset || 0) - 1, 0) })); }} style={{ cursor: canGoNext ? 'pointer' : 'default', flex: 'none', opacity: canGoNext ? 1 : 0.3 }}>
{periodBars.map((h, i) => (
))}
FC reposo (30d)
58 ▼2
Distancia total
128 km
Racha actual
4 sem.
Comentarios de tu entrenador
{isPremium && Premium }
{isPremium && coachComments.map((c, i) => (
{c.session}
{c.date}
{c.text}
— Ana, entrenadora
))}
{!isPremium && (
Los comentarios personalizados de tu entrenador sobre cada sesión están disponibles en Premium.
this.go('profile')} style={primaryBtn(34, 12.5)}>Mejorar a Premium
)}
Tus marcas recientes
{marcas.length === 0 && (
Aún no hay marcas registradas. Cuéntaselas al Asistente en el chat.
)}
{marcas.length > 0 && (
Fecha
Tipo
Valor
{marcas.slice(0, 8).map((m) => (
{this.formatFecha(m.fecha)}
{m.tipo}
{m.valor}
))}
)}
);
}
renderChat() {
const { chatMessages, chatInput, pendingMark, chatResponsesUsed } = this.state;
const chatAvailable = chatResponsesUsed < CHAT_LIMIT;
if (!chatAvailable) {
return (
Llegaste al límite de {CHAT_LIMIT} respuestas
Mejora a Premium para preguntas ilimitadas al Asistente y a tu entrenador.
this.go('profile')} style={{ ...primaryBtn(38, 13), marginTop: 6, padding: '0 16px' }}>Mejorar a Premium
);
}
const displayMessages = chatMessages.map(m => ({
isSystem: m.from === 'system',
isBubble: m.from !== 'system',
justify: m.from === 'user' ? 'flex-end' : 'flex-start',
align: m.from === 'user' ? 'flex-end' : 'flex-start',
bg: m.from === 'user' ? C.accent : m.from === 'coach' ? C.greenBg : C.card,
color: m.from === 'user' ? '#0a0a0a' : m.from === 'coach' ? C.greenText : C.text,
label: m.label || '',
text: m.text,
}));
const quickQuestions = ['¿Qué es Zona 2?', '¿Puedo entrenar con dolor?', '¿Cómo ajusto el plan?'];
return (
{displayMessages.map((m, i) => (
{m.isSystem && {m.text}
}
{m.isBubble && (
)}
))}
{pendingMark && (
this.confirmMark(true)} style={{ flex: 'none', fontSize: 12, padding: '6px 14px', borderRadius: 999, border: `1px solid ${C.accent}`, color: C.accent, cursor: 'pointer' }}>Sí, guardar
this.confirmMark(false)} style={{ flex: 'none', fontSize: 12, padding: '6px 14px', borderRadius: 999, border: `1px solid ${C.borderLight}`, color: C.textMuted, cursor: 'pointer' }}>No, gracias
)}
{!pendingMark && (
{quickQuestions.map((q, i) => (
this.sendText(q)} style={{ flex: 'none', whiteSpace: 'nowrap', fontSize: 12, padding: '6px 12px', borderRadius: 999, border: `1px solid ${C.borderLight}`, color: C.textMuted, cursor: 'pointer' }}>{q}
))}
)}
this.setState({ chatInput: e.target.value })} onKeyDown={e => { if (e.key === 'Enter') this.sendText(chatInput); }} placeholder="Escribe tu pregunta…" style={{ flex: 1, minHeight: 40, padding: '8px 14px', fontSize: 13.5, color: C.text, background: C.card, border: `1px solid ${C.border}`, borderRadius: 20, outline: 'none' }} />
this.sendText(chatInput)} style={{ flex: 'none', width: 40, height: 40, borderRadius: '50%', background: C.accent, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
);
}
renderCarreras() {
const { racesTab, raceGoals, addRaceOpen, newRaceName, newRaceDate, newRaceDistance, suggestedRacesMarked } = this.state;
const raceTabOptions = [
{ key: 'mios', label: 'Mis objetivos' },
{ key: 'sugeridas', label: 'Sugeridas' },
];
const suggestedRaces = SUGGESTED_RACES.filter(r => !suggestedRacesMarked[r.id]);
const submitNewRace = () => {
if (!newRaceName.trim()) return;
this.setState(s => ({
raceGoals: [...s.raceGoals, { name: newRaceName.trim(), date: newRaceDate.trim() || 'Sin fecha', distance: newRaceDistance.trim() || '—' }],
addRaceOpen: false, newRaceName: '', newRaceDate: '', newRaceDistance: '',
}));
};
return (
{raceTabOptions.map(rt => (
this.setState({ racesTab: rt.key })} style={{
padding: '7px 14px', fontSize: 12.5, cursor: 'pointer',
color: racesTab === rt.key ? C.accent : C.text,
boxShadow: racesTab === rt.key ? `inset 0 0 0 1px ${C.accent}` : 'none',
}}>{rt.label}
))}
{racesTab === 'mios' && (
{raceGoals.length === 0 && (
Aún no tienes carreras objetivo
Añade una o mira las que sugiere tu entrenador.
)}
{raceGoals.map((g, i) => (
{g.name}
Objetivo
this.setState(s => ({ raceGoals: s.raceGoals.filter((_, j) => j !== i) }))} style={{ cursor: 'pointer', color: C.textSoft, fontSize: 13 }}>×
{g.date} · {g.distance}
))}
{addRaceOpen && (
this.setState({ newRaceName: e.target.value })} placeholder="Nombre de la carrera" style={{ minHeight: 36, padding: '6px 10px', fontSize: 13, color: C.text, background: '#121212', border: `1px solid ${C.border}`, borderRadius: 8, outline: 'none' }} />
this.setState({ newRaceDate: e.target.value })} placeholder="Fecha" style={{ flex: 1, minHeight: 36, padding: '6px 10px', fontSize: 13, color: C.text, background: '#121212', border: `1px solid ${C.border}`, borderRadius: 8, outline: 'none' }} />
this.setState({ newRaceDistance: e.target.value })} placeholder="Distancia" style={{ flex: 1, minHeight: 36, padding: '6px 10px', fontSize: 13, color: C.text, background: '#121212', border: `1px solid ${C.border}`, borderRadius: 8, outline: 'none' }} />
Guardar carrera
)}
{!addRaceOpen && (
this.setState({ addRaceOpen: true })} style={ghostBtn(42, 13)}>+ Añadir carrera objetivo
)}
)}
{racesTab === 'sugeridas' && (
Carreras que tu entrenador conoce y recomienda para tu nivel.
{suggestedRaces.map(s => (
{s.name}
{s.date}
{s.location} · {s.distance}
{s.note}
{
if (this.state.suggestedRacesMarked[s.id]) return;
this.setState(prev => ({
suggestedRacesMarked: { ...prev.suggestedRacesMarked, [s.id]: true },
raceGoals: [...prev.raceGoals, { name: s.name, date: s.date, distance: s.distance }],
}));
}} style={{ ...primaryBtn(32, 12), alignSelf: 'flex-start', marginTop: 2, padding: '0 12px' }}>Marcar como objetivo
))}
)}
);
}
renderProfile() {
const { userName, userEmail } = this.props;
const { userPlan, marcas, connectedAccounts, pushNotifications, perfil } = this.state;
const isPremium = userPlan === 'premium';
const push = pushNotifications;
const displayName = (perfil && perfil.nombre) || userName;
return (
this.go('inicio')} style={{ cursor: 'pointer', flex: 'none' }}>
Perfil
{(displayName || '?').trim().charAt(0).toUpperCase() || '?'}
{displayName}
{userEmail}
Tu plan
{isPremium ? 'Premium' : 'Free'}
{isPremium && Activo }
{isPremium ? 'Incluye comentarios personalizados de tu entrenador en cada sesión.' : 'Plan gratuito. Mejora a Premium para recibir comentarios de tu entrenador sobre cada sesión.'}
Mejorar a Premium
Tus marcas
{marcas.length === 0 && (
Aún no tienes marcas guardadas. Cuéntaselas al Asistente en el chat.
)}
{marcas.map((m) => (
{m.valor}
{m.tipo}
{this.formatFecha(m.fecha)}
))}
Cuentas conectadas
{connectedAccounts.map(a => (
{a.name}
this.setState(s => ({ connectedAccounts: s.connectedAccounts.map(x => x.id === a.id ? { ...x, connected: !x.connected } : x) }))} style={a.connected ? primaryBtn(30, 12) : ghostBtn(30, 12)}>{a.connected ? 'Conectado' : 'Conectar'}
))}
Notificaciones
Notificaciones push
this.setState(s => ({ pushNotifications: !s.pushNotifications }))} style={{ width: 40, height: 22, borderRadius: 11, background: push ? C.greenBg : '#2e2e2e', padding: 2, cursor: 'pointer', display: 'flex', justifyContent: push ? 'flex-end' : 'flex-start' }}>
this.logout()} style={ghostBtn(44, 13.5, C.textMuted)}>Cerrar sesión
);
}
renderLiveSession() {
const { liveElapsed, livePaused, livePhaseIdx } = this.state;
const mm = String(Math.floor(liveElapsed / 60)).padStart(2, '0');
const ss = String(liveElapsed % 60).padStart(2, '0');
const liveTimeLabel = mm + ':' + ss;
const liveFc = Math.round(128 + 6 * Math.sin(liveElapsed / 3));
const showNextPhaseBtn = livePhaseIdx < PLAN_PHASES.length - 1;
return (
{PLAN_PHASES[livePhaseIdx].name}
{liveTimeLabel}
{showNextPhaseBtn && (
this.setState(s => ({ livePhaseIdx: Math.min(s.livePhaseIdx + 1, PLAN_PHASES.length - 1) }))} style={ghostBtn(36, 13)}>Siguiente fase
)}
this.setState(s => ({ livePaused: !s.livePaused }))} style={{ ...ghostBtn(46, 14), flex: 1 }}>{livePaused ? 'Reanudar' : 'Pausar'}
this.setState({ screen: 'summary', lastSessionDuration: liveTimeLabel })} style={{ ...primaryBtn(46, 14), flex: 1 }}>Finalizar
);
}
renderIntakeInvite() {
const { userName } = this.props;
return (
¡Listo, {userName}!
Ahora cuéntanos tus marcas actuales y qué carreras tienes en la mira, en Mis objetivos .
this.go('carreras')} style={{ ...primaryBtn(46, 14), width: '100%' }}>Completar mis objetivos
this.go('plan')} style={{ ...ghostBtn(44, 13.5, C.textMuted), width: '100%' }}>Ir a mi plan de hoy
);
}
renderSummary() {
const { lastSessionDuration, selectedRpe } = this.state;
const rpeOptions = Array.from({ length: 10 }, (_, i) => i + 1);
const saveSummary = () => {
this.setState({ screen: 'inicio' });
};
return (
¡Buen trabajo!
Sesión completada — así te fue:
Duración
{lastSessionDuration}
FC promedio
132 bpm
¿Qué tan duro se sintió? (RPE)
{rpeOptions.map(v => (
this.setState({ selectedRpe: v })} style={{
width: 30, height: 30, borderRadius: 7, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 12.5, cursor: 'pointer',
border: `1px solid ${v === selectedRpe ? C.accent : 'rgba(255,255,255,0.18)'}`,
color: v === selectedRpe ? C.accent : C.textMuted,
}}>{v}
))}
Guardar sesión
);
}
renderTabBar() {
const { screen } = this.state;
if (!TAB_SCREENS.includes(screen)) return null;
const tabStyle = (active) => ({
flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4,
padding: '6px 0', cursor: 'pointer', opacity: active ? 1 : 0.7,
});
const tabColor = (active) => active ? C.accent : C.textSoft;
const tab = (key, label, onClick) => (
{NAV_ICONS[key](tabColor(screen === key))}
{label}
);
return (
{tab('inicio', 'Inicio', () => this.go('inicio'))}
{tab('plan', 'Plan', () => this.go('plan'))}
{tab('historial', 'Progreso', () => this.go('historial'))}
{tab('chat', 'Asistente', () => this.go('chat'))}
{tab('carreras', 'Carreras', () => this.go('carreras'))}
);
}
renderAppStage() {
const { screen } = this.state;
const isDesktop = this.state.isDesktop;
return (
{isDesktop && this.renderSidebar()}
{this.renderHeader()}
{screen === 'inicio' && this.renderInicio()}
{screen === 'plan' && this.renderPlan()}
{screen === 'historial' && this.renderHistorial()}
{screen === 'chat' && this.renderChat()}
{screen === 'carreras' && this.renderCarreras()}
{screen === 'profile' && this.renderProfile()}
{screen === 'liveSession' && this.renderLiveSession()}
{screen === 'intakeInvite' && this.renderIntakeInvite()}
{screen === 'summary' && this.renderSummary()}
{!isDesktop && this.renderTabBar()}
);
}
renderAuthBrandPanel() {
return (
Entrena con un plan hecho para ti
Planes claros, tu progreso siempre visible, y el respaldo real de tu entrenador — todo en un solo lugar.
);
}
render() {
const { stage, bootLoading } = this.state;
const isDesktop = this.state.isDesktop;
if (bootLoading) {
return (
Cargando…
);
}
const isAuthStage = stage === 'onboarding' || stage === 'login' || stage === 'intake';
const formWidth = stage === 'intake' ? 480 : 420;
const formBox = (
{stage === 'onboarding' && this.renderOnboarding()}
{stage === 'login' && this.renderLogin()}
{stage === 'intake' && this.renderIntake()}
);
return (
{isDesktop && isAuthStage && this.renderAuthBrandPanel()}
{isAuthStage && (
{formBox}
)}
{stage === 'app' && this.renderAppStage()}
);
}
}
App.defaultProps = {
userName: 'Marco',
userEmail: 'atleta@bortec.mx',
weeklySessionGoal: 4,
showPowerMetric: false,
};
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render( );