const { useState, useEffect, useRef } = React; // ========================================== // CONFIGURACIÓN DE ENTORNO // ========================================== const USE_LOCAL_STORAGE = false; const API_URL = 'api.php'; const DAYS_OF_WEEK = ["Domingo", "Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado"]; /** Fecha local YYYY-MM-DD (completados por día calendario, no por weekday) */ const todayISO = () => { const d = new Date(); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; }; // ========================================== // CATÁLOGO DE EJERCICIOS POR PARTE DEL CUERPO // video_url queda listo para el futuro (null por ahora) // ========================================== const BODY_PARTS = [ { id: 'chest', name: 'Pecho' }, { id: 'back', name: 'Espalda' }, { id: 'shoulders', name: 'Hombros' }, { id: 'arms', name: 'Brazos' }, { id: 'legs', name: 'Piernas' }, { id: 'glutes', name: 'Glúteos' }, { id: 'core', name: 'Core / Abs' }, { id: 'cardio', name: 'Cardio' }, ]; const EXERCISE_CATALOG = [ // Pecho { id: 'pushups', bodyPart: 'chest', name: 'Flexiones', description: 'Manos al ancho de hombros, cuerpo en línea recta. Baja el pecho hacia el suelo y empuja hacia arriba.', is_time: false, defaultSets: 3, defaultReps: 12, video_url: null }, { id: 'pushups-wide', bodyPart: 'chest', name: 'Flexiones abiertas', description: 'Manos más abiertas que los hombros. Enfatiza pecho exterior. Baja controlando y sube.', is_time: false, defaultSets: 3, defaultReps: 10, video_url: null }, { id: 'pushups-diamond', bodyPart: 'chest', name: 'Flexiones diamante', description: 'Manos juntas formando un diamante bajo el pecho. Enfatiza tríceps y pecho interno.', is_time: false, defaultSets: 3, defaultReps: 8, video_url: null }, { id: 'dips-chair', bodyPart: 'chest', name: 'Fondos en silla', description: 'Manos en el borde de una silla, baja flexionando codos y sube empujando.', is_time: false, defaultSets: 3, defaultReps: 12, video_url: null }, // Espalda { id: 'superman', bodyPart: 'back', name: 'Superman', description: 'Boca abajo, eleva brazos y piernas a la vez. Mantén y baja con control.', is_time: false, defaultSets: 3, defaultReps: 12, video_url: null }, { id: 'bird-dog', bodyPart: 'back', name: 'Bird dog', description: 'A cuatro patas, extiende brazo y pierna opuestos. Mantén el tronco estable.', is_time: false, defaultSets: 3, defaultReps: 10, video_url: null }, { id: 'reverse-snow', bodyPart: 'back', name: 'Ángel inverso', description: 'Boca abajo, brazos a los lados. Desliza los brazos hacia arriba como un ángel en la nieve.', is_time: false, defaultSets: 3, defaultReps: 12, video_url: null }, { id: 'door-row', bodyPart: 'back', name: 'Remo en puerta', description: 'Sujeta el marco de la puerta e inclínate hacia atrás. Tira del pecho hacia las manos.', is_time: false, defaultSets: 3, defaultReps: 12, video_url: null }, // Hombros { id: 'pike-pushups', bodyPart: 'shoulders', name: 'Flexiones pike', description: 'Cadera arriba (forma de V). Baja la cabeza hacia el suelo flexionando codos.', is_time: false, defaultSets: 3, defaultReps: 8, video_url: null }, { id: 'arm-circles', bodyPart: 'shoulders', name: 'Círculos de brazos', description: 'Brazos extendidos a los lados. Haz círculos amplios hacia adelante y atrás.', is_time: true, defaultSets: 3, defaultReps: 30, video_url: null }, { id: 'shoulder-taps', bodyPart: 'shoulders', name: 'Toques de hombro', description: 'En posición de plancha, toca un hombro con la mano opuesta sin balancear la cadera.', is_time: false, defaultSets: 3, defaultReps: 16, video_url: null }, // Brazos { id: 'triceps-dips', bodyPart: 'arms', name: 'Fondos de tríceps', description: 'Manos en silla detrás de ti. Baja flexionando codos y sube sin bloquearlos.', is_time: false, defaultSets: 3, defaultReps: 12, video_url: null }, { id: 'diamond-close', bodyPart: 'arms', name: 'Flexiones cerradas', description: 'Manos juntas bajo el pecho. Baja y sube enfatizando tríceps.', is_time: false, defaultSets: 3, defaultReps: 10, video_url: null }, { id: 'bicep-iso', bodyPart: 'arms', name: 'Curl isométrico', description: 'Con una bandera/toalla o peso improvisado, mantén la flexión de codo a 90°.', is_time: true, defaultSets: 3, defaultReps: 25, video_url: null }, // Piernas { id: 'squats', bodyPart: 'legs', name: 'Sentadillas', description: 'Pies al ancho de hombros. Baja como si te sentaras, rodillas alineadas con los pies.', is_time: false, defaultSets: 3, defaultReps: 15, video_url: null }, { id: 'lunges', bodyPart: 'legs', name: 'Zancadas', description: 'Da un paso largo hacia adelante y baja la rodilla trasera. Alterna piernas.', is_time: false, defaultSets: 3, defaultReps: 12, video_url: null }, { id: 'bulgarian', bodyPart: 'legs', name: 'Sentadilla búlgara', description: 'Pie trasero apoyado en silla. Baja con la pierna delantera controlando el movimiento.', is_time: false, defaultSets: 3, defaultReps: 10, video_url: null }, { id: 'calf-raises', bodyPart: 'legs', name: 'Elevaciones de gemelos', description: 'De pie, sube sobre las puntas de los pies y baja lento.', is_time: false, defaultSets: 3, defaultReps: 20, video_url: null }, { id: 'wall-sit', bodyPart: 'legs', name: 'Sentadilla en pared', description: 'Espalda contra la pared, muslos paralelos al suelo. Mantén la posición.', is_time: true, defaultSets: 3, defaultReps: 40, video_url: null }, // Glúteos { id: 'glute-bridge', bodyPart: 'glutes', name: 'Puente de glúteos', description: 'Boca arriba, pies en el suelo. Eleva la cadera apretando glúteos y baja.', is_time: false, defaultSets: 3, defaultReps: 15, video_url: null }, { id: 'hip-thrust', bodyPart: 'glutes', name: 'Hip thrust', description: 'Espalda apoyada en un sofá/banco. Empuja la cadera hacia arriba y baja controlando.', is_time: false, defaultSets: 3, defaultReps: 12, video_url: null }, { id: 'donkey-kick', bodyPart: 'glutes', name: 'Patada de glúteo', description: 'A cuatro patas, eleva una pierna hacia atrás manteniendo la rodilla flexionada.', is_time: false, defaultSets: 3, defaultReps: 12, video_url: null }, { id: 'fire-hydrant', bodyPart: 'glutes', name: 'Hidrante', description: 'A cuatro patas, abre la rodilla hacia el lado sin girar la cadera.', is_time: false, defaultSets: 3, defaultReps: 12, video_url: null }, // Core { id: 'plank', bodyPart: 'core', name: 'Plancha', description: 'Antebrazos en el suelo, cuerpo en línea. Aprieta abdomen y glúteos. No dejes caer la cadera.', is_time: true, defaultSets: 3, defaultReps: 30, video_url: null }, { id: 'crunches', bodyPart: 'core', name: 'Crunches', description: 'Boca arriba, eleva hombros del suelo contrayendo el abdomen. Baja lento.', is_time: false, defaultSets: 3, defaultReps: 20, video_url: null }, { id: 'leg-raises', bodyPart: 'core', name: 'Elevaciones de piernas', description: 'Boca arriba, sube las piernas rectas y bájalas sin tocar el suelo.', is_time: false, defaultSets: 3, defaultReps: 12, video_url: null }, { id: 'russian-twist', bodyPart: 'core', name: 'Giro ruso', description: 'Sentado, tronco inclinado. Gira el torso de lado a lado tocando el suelo.', is_time: false, defaultSets: 3, defaultReps: 20, video_url: null }, { id: 'dead-bug', bodyPart: 'core', name: 'Dead bug', description: 'Boca arriba, brazo y pierna opuestos se extienden. Mantén la espalda pegada al suelo.', is_time: false, defaultSets: 3, defaultReps: 10, video_url: null }, { id: 'side-plank', bodyPart: 'core', name: 'Plancha lateral', description: 'Apoyado en un antebrazo, cuerpo en línea de lado. Mantén cadera elevada.', is_time: true, defaultSets: 3, defaultReps: 25, video_url: null }, // Cardio { id: 'jumping-jacks', bodyPart: 'cardio', name: 'Jumping jacks', description: 'Salta abriendo piernas y elevando brazos, luego vuelve a posición inicial.', is_time: false, defaultSets: 3, defaultReps: 30, video_url: null }, { id: 'high-knees', bodyPart: 'cardio', name: 'Rodillas altas', description: 'Corre en el sitio elevando las rodillas lo más alto posible.', is_time: true, defaultSets: 3, defaultReps: 30, video_url: null }, { id: 'burpees', bodyPart: 'cardio', name: 'Burpees', description: 'Agáchate, plancha, flexión opcional, salta arriba con brazos. Ritmo constante.', is_time: false, defaultSets: 3, defaultReps: 8, video_url: null }, { id: 'mountain-climbers', bodyPart: 'cardio', name: 'Mountain climbers', description: 'En plancha, lleva rodillas al pecho alternando rápido.', is_time: true, defaultSets: 3, defaultReps: 30, video_url: null }, { id: 'walk', bodyPart: 'cardio', name: 'Caminar', description: 'Camina a ritmo moderado. Mantén postura erguida y respiración constante.', is_time: true, defaultSets: 1, defaultReps: 300, video_url: null }, ]; const emptyExercise = () => ({ name: '', description: '', video_url: null, sets: 3, reps: 10, is_time: false, rest_secs: 60 }); const exerciseFromCatalog = (item) => ({ name: item.name, description: item.description || '', video_url: item.video_url || null, sets: item.defaultSets || 3, reps: item.defaultReps || 10, is_time: !!item.is_time, rest_secs: 60, }); // ========================================== // SONIDOS — motor en sounds.js (HTMLAudio + WAV) // ========================================== const DEFAULT_SOUND_ENABLED = true; const SoundFx = window.SoundFx || { applyFromUser: () => ({ sound_enabled: true }), setEnabled() {}, isEnabled: () => false, unlock() {}, rep() {}, start() {}, tick() {}, setDone() {}, restDone() {}, workoutDone() {}, preview() {}, }; const normalizeSoundPrefs = (user) => SoundFx.applyFromUser(user); // ========================================== // ICONOS (SVG, estilo línea) // ========================================== const Icon = ({ path, size = "icon", fill = false }) => ( {path} ); const Icons = { dumbbell: } />, settings: } />, play: } fill={true} />, moon: } />, sun: } />, plus: } size="icon-sm" />, trash: } size="icon-sm" />, close: } size="icon-sm" />, repeat: } size="icon-sm" />, clock: } size="icon-sm" />, info: } size="icon-sm" />, help: } size="icon-sm" />, back: } size="icon-sm" />, catalog: } size="icon-sm" />, routines: } />, logout: } size="icon-sm" />, user: } size="icon-sm" />, check: } size="icon-sm" />, volume: } />, volumeOff: } />, edit: } size="icon-sm" />, download: } size="icon-sm" />, share: } size="icon-sm" />, message: } size="icon-sm" />, }; /** Logo RutinaSimple (SVG/CSS) */ function AppLogo({ size = 72, className = '' }) { return ( ); } const DEFAULT_ROUTINES = [ { id: 1, name: "Pecho y Tríceps", day_of_week: 1, exercises: [ { id: 101, name: "Flexiones", description: "Manos al ancho de los hombros, cuerpo recto. Baja controlando el pecho.", video_url: null, sets: 3, reps: 15, is_time: false, rest_secs: 30 }, { id: 102, name: "Fondos en silla", description: "Apoya las manos en el borde de la silla y baja el cuerpo flexionando los codos.", video_url: null, sets: 3, reps: 12, is_time: false, rest_secs: 45 } ] }, { id: 2, name: "Core Intenso", day_of_week: 1, exercises: [ { id: 103, name: "Plancha", description: "Antebrazos en el suelo, cuerpo en línea recta. Aprieta el abdomen.", video_url: null, sets: 3, reps: 30, is_time: true, rest_secs: 60 } ] } ]; // ========================================== // AUTH + SERVICIO DE DATOS // ========================================== const AUTH_TOKEN_KEY = 'rutinasimple_token'; const AUTH_USER_KEY = 'rutinasimple_user'; const LOCAL_USERS_KEY = 'rutinasimple_users'; const apiFetch = async (action, options = {}) => { const token = localStorage.getItem(AUTH_TOKEN_KEY); const headers = { 'Content-Type': 'application/json', ...(options.headers || {}), }; if (token) headers['X-Auth-Token'] = token; const res = await fetch(`${API_URL}?action=${action}`, { ...options, headers, }); const data = await res.json().catch(() => ({ error: 'Respuesta inválida del servidor' })); if (!res.ok || data.error) { throw new Error(data.error || 'Error de servidor'); } return data; }; const AuthService = { getStoredUser() { try { const raw = localStorage.getItem(AUTH_USER_KEY); return raw ? JSON.parse(raw) : null; } catch { return null; } }, getToken() { return localStorage.getItem(AUTH_TOKEN_KEY); }, persistSession(token, user) { localStorage.setItem(AUTH_TOKEN_KEY, token); localStorage.setItem(AUTH_USER_KEY, JSON.stringify(user)); }, clearSession() { localStorage.removeItem(AUTH_TOKEN_KEY); localStorage.removeItem(AUTH_USER_KEY); }, async register({ name, email, password }) { if (USE_LOCAL_STORAGE) { const users = JSON.parse(localStorage.getItem(LOCAL_USERS_KEY) || '[]'); const normalized = email.trim().toLowerCase(); if (users.some(u => u.email === normalized)) { throw new Error('Ya existe una cuenta con ese email'); } if (password.length < 6) { throw new Error('La contraseña debe tener al menos 6 caracteres'); } const user = { id: Date.now(), name: name.trim(), email: normalized, avatar: null, sound_enabled: DEFAULT_SOUND_ENABLED, }; users.push({ ...user, password }); localStorage.setItem(LOCAL_USERS_KEY, JSON.stringify(users)); const token = 'local_' + user.id; this.persistSession(token, user); SoundFx.applyFromUser(user); // Rutinas de ejemplo solo para la primera cuenta local const key = `rutinasimple_routines_${user.id}`; if (!localStorage.getItem(key)) { localStorage.setItem(key, JSON.stringify(DEFAULT_ROUTINES)); } return { token, user }; } const data = await apiFetch('register', { method: 'POST', body: JSON.stringify({ name, email, password }), }); this.persistSession(data.token, data.user); SoundFx.applyFromUser(data.user); return data; }, async login({ email, password }) { if (USE_LOCAL_STORAGE) { const users = JSON.parse(localStorage.getItem(LOCAL_USERS_KEY) || '[]'); const normalized = email.trim().toLowerCase(); const found = users.find(u => u.email === normalized && u.password === password); if (!found) throw new Error('Email o contraseña incorrectos'); const prefs = normalizeSoundPrefs(found); const user = { id: found.id, name: found.name, email: found.email, avatar: found.avatar || null, ...prefs, }; const token = 'local_' + user.id; this.persistSession(token, user); SoundFx.applyFromUser(user); return { token, user }; } const data = await apiFetch('login', { method: 'POST', body: JSON.stringify({ email, password }), }); this.persistSession(data.token, data.user); SoundFx.applyFromUser(data.user); return data; }, async logout() { if (!USE_LOCAL_STORAGE) { try { await apiFetch('logout', { method: 'POST', body: '{}' }); } catch (_) {} } this.clearSession(); }, async me() { const stored = this.getStoredUser(); const token = this.getToken(); if (!token || !stored) return null; if (USE_LOCAL_STORAGE) { const prefs = normalizeSoundPrefs(stored); const user = { ...stored, ...prefs }; SoundFx.applyFromUser(user); return user; } try { const data = await apiFetch('me'); this.persistSession(token, data.user); SoundFx.applyFromUser(data.user); return data.user; } catch { this.clearSession(); return null; } }, async updateProfile(patch = {}) { const payload = {}; if (Object.prototype.hasOwnProperty.call(patch, 'name')) { const trimmed = (patch.name || '').trim(); if (!trimmed) throw new Error('El nombre no puede estar vacío'); payload.name = trimmed; } if (!Object.keys(payload).length) { throw new Error('Nada que actualizar'); } if (USE_LOCAL_STORAGE) { const stored = this.getStoredUser(); const token = this.getToken(); if (!stored || !token) throw new Error('No autenticado'); const users = JSON.parse(localStorage.getItem(LOCAL_USERS_KEY) || '[]'); const idx = users.findIndex(u => u.id === stored.id); const next = { ...stored, ...payload }; if (idx > -1) { users[idx] = { ...users[idx], ...payload }; localStorage.setItem(LOCAL_USERS_KEY, JSON.stringify(users)); } const user = { ...next, ...normalizeSoundPrefs(next) }; this.persistSession(token, user); SoundFx.applyFromUser(user); return { user }; } const data = await apiFetch('update_profile', { method: 'POST', body: JSON.stringify(payload), }); const token = this.getToken(); this.persistSession(token, data.user); SoundFx.applyFromUser(data.user); return data; }, async updateSoundPrefs({ sound_enabled }) { const payload = { sound_enabled: !!sound_enabled }; if (USE_LOCAL_STORAGE) { const stored = this.getStoredUser(); const token = this.getToken(); if (!stored || !token) throw new Error('No autenticado'); const users = JSON.parse(localStorage.getItem(LOCAL_USERS_KEY) || '[]'); const idx = users.findIndex(u => u.id === stored.id); const next = { ...stored, ...payload }; if (idx > -1) { users[idx] = { ...users[idx], ...payload }; localStorage.setItem(LOCAL_USERS_KEY, JSON.stringify(users)); } const user = { ...next, ...normalizeSoundPrefs(next) }; this.persistSession(token, user); SoundFx.applyFromUser(user); return { user }; } const data = await apiFetch('update_sound_prefs', { method: 'POST', body: JSON.stringify(payload), }); const token = this.getToken(); this.persistSession(token, data.user); SoundFx.applyFromUser(data.user); return data; }, async sendFeedback({ category, message, contact_email }) { if (USE_LOCAL_STORAGE) { throw new Error('El feedback requiere conexión con el servidor'); } return apiFetch('send_feedback', { method: 'POST', body: JSON.stringify({ category, message, contact_email }), }); }, async uploadAvatar(file) { if (USE_LOCAL_STORAGE) { const stored = this.getStoredUser(); const token = this.getToken(); if (!stored || !token) throw new Error('No autenticado'); const dataUrl = await readFileAsDataURL(file); const users = JSON.parse(localStorage.getItem(LOCAL_USERS_KEY) || '[]'); const idx = users.findIndex(u => u.id === stored.id); if (idx > -1) { users[idx] = { ...users[idx], avatar: dataUrl }; localStorage.setItem(LOCAL_USERS_KEY, JSON.stringify(users)); } const user = { ...stored, avatar: dataUrl }; this.persistSession(token, user); return { user }; } const form = new FormData(); form.append('avatar', file); const token = this.getToken(); const res = await fetch(`${API_URL}?action=upload_avatar`, { method: 'POST', headers: token ? { 'X-Auth-Token': token } : {}, body: form, }); const data = await res.json().catch(() => ({ error: 'Respuesta inválida del servidor' })); if (!res.ok || data.error) throw new Error(data.error || 'Error al subir la foto'); this.persistSession(token, data.user); return data; }, }; function readFileAsDataURL(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(new Error('No se pudo leer la imagen')); reader.readAsDataURL(file); }); } async function compressImage(file, maxSize = 512) { if (!file || !file.type.startsWith('image/')) { throw new Error('Selecciona una imagen válida'); } return new Promise((resolve, reject) => { const img = new Image(); const url = URL.createObjectURL(file); img.onload = () => { try { let { width, height } = img; const scale = Math.min(1, maxSize / Math.max(width, height)); width = Math.max(1, Math.round(width * scale)); height = Math.max(1, Math.round(height * scale)); const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, width, height); canvas.toBlob( (blob) => { URL.revokeObjectURL(url); if (!blob) { reject(new Error('No se pudo procesar la imagen')); return; } resolve(new File([blob], 'avatar.jpg', { type: 'image/jpeg' })); }, 'image/jpeg', 0.86 ); } catch (err) { URL.revokeObjectURL(url); reject(err); } }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('No se pudo cargar la imagen')); }; img.src = url; }); } function avatarUrl(user) { if (!user?.avatar) return null; if (user.avatar.startsWith('data:') || user.avatar.startsWith('http')) return user.avatar; return user.avatar; } const DataService = { routinesKey(userId) { return `rutinasimple_routines_${userId || 'guest'}`; }, completionsKey(userId) { return `rutinasimple_completions_${userId || 'guest'}`; }, async getAllRoutines(user) { if (USE_LOCAL_STORAGE) { const key = this.routinesKey(user?.id); const stored = localStorage.getItem(key); if (!stored) return []; return JSON.parse(stored); } return apiFetch('get_all'); }, async saveRoutine(routineData, user) { if (USE_LOCAL_STORAGE) { const key = this.routinesKey(user?.id); let routines = await this.getAllRoutines(user); let routineId = routineData.id; if (routineId) { const index = routines.findIndex(r => r.id === routineId); if (index > -1) { routineData.exercises = routineData.exercises.map((ex, i) => ({ ...ex, id: ex.id || Date.now() + i })); routines[index] = routineData; } } else { const newRoutine = { ...routineData, id: Date.now(), exercises: routineData.exercises.map((ex, i) => ({ ...ex, id: Date.now() + i })) }; routines.push(newRoutine); routineId = newRoutine.id; } localStorage.setItem(key, JSON.stringify(routines)); return { success: true, routine_id: routineId }; } return apiFetch('save_routine', { method: 'POST', body: JSON.stringify(routineData), }); }, async deleteRoutine(id, user) { if (USE_LOCAL_STORAGE) { const key = this.routinesKey(user?.id); let routines = await this.getAllRoutines(user); routines = routines.filter(r => r.id !== id); localStorage.setItem(key, JSON.stringify(routines)); return { success: true }; } return apiFetch('delete_routine', { method: 'POST', body: JSON.stringify({ id }), }); }, _readLocalCompletions(user) { try { const raw = localStorage.getItem(this.completionsKey(user?.id)); return raw ? JSON.parse(raw) : []; } catch { return []; } }, _localIdsForDate(user, date) { return this._readLocalCompletions(user) .filter(c => c.completed_on === date) .map(c => Number(c.routine_id)); }, _saveLocalCompletion(user, routineId, date) { const key = this.completionsKey(user?.id); const all = this._readLocalCompletions(user); const id = Number(routineId); const exists = all.some(c => Number(c.routine_id) === id && c.completed_on === date); if (!exists) { all.push({ routine_id: id, completed_on: date }); localStorage.setItem(key, JSON.stringify(all)); } }, async getCompletions(user, date = todayISO()) { const localIds = this._localIdsForDate(user, date); if (USE_LOCAL_STORAGE) { return { date, routine_ids: localIds }; } try { const remote = await apiFetch(`get_completions&date=${encodeURIComponent(date)}`); const remoteIds = Array.isArray(remote?.routine_ids) ? remote.routine_ids.map(Number) : []; return { date, routine_ids: [...new Set([...remoteIds, ...localIds])] }; } catch (err) { console.warn('Completions API:', err.message); return { date, routine_ids: localIds }; } }, async completeRoutine(routineId, user, date = todayISO()) { // Siempre guardamos en local para que el inicio se actualice aunque falle la API/tabla this._saveLocalCompletion(user, routineId, date); if (USE_LOCAL_STORAGE) { return { success: true, routine_id: Number(routineId), completed_on: date }; } try { return await apiFetch('complete_routine', { method: 'POST', body: JSON.stringify({ routine_id: routineId, completed_on: date }), }); } catch (err) { console.warn('complete_routine API:', err.message); return { success: true, routine_id: Number(routineId), completed_on: date }; } }, }; // ========================================== // COMPONENTES // ========================================== function AuthScreen({ onAuth }) { const [mode, setMode] = useState('login'); // login | register const [name, setName] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const switchMode = (next) => { setMode(next); setError(''); setShowPassword(false); }; const handleSubmit = async (e) => { e.preventDefault(); setError(''); setLoading(true); try { const data = mode === 'login' ? await AuthService.login({ email, password }) : await AuthService.register({ name, email, password }); onAuth(data.user); } catch (err) { setError(err.message || 'No se pudo continuar'); } finally { setLoading(false); } }; return (

RutinaSimple

App gratis de rutinas de ejercicio. Fácil de usar, en casa o en el gym.

{mode === 'register' && ( )} {error && (
{Icons.info} {error}
)}

Tus rutinas quedan guardadas en tu cuenta.

); } function RestDayView({ currentDay, title, message, actionLabel, onAction, secondaryLabel, onSecondary }) { return (

{title || `Hoy es ${DAYS_OF_WEEK[currentDay]}`}

{message}

{onAction && actionLabel && ( )} {onSecondary && secondaryLabel && ( )}
); } function TodayRoutine({ routines, completedIds, onStart, onGoToRoutines }) { const currentDay = new Date().getDay(); const todaysRoutines = routines.filter(r => r.day_of_week === currentDay); const doneSet = new Set((completedIds || []).map(Number)); const pendingToday = todaysRoutines.filter(r => !doneSet.has(Number(r.id))); const allTodayDone = todaysRoutines.length > 0 && pendingToday.length === 0; const [selectedRoutine, setSelectedRoutine] = useState(null); useEffect(() => { if (pendingToday.length === 0) { setSelectedRoutine(null); return; } setSelectedRoutine(prev => { if (prev && pendingToday.some(r => r.id === prev.id)) { return pendingToday.find(r => r.id === prev.id) || pendingToday[0]; } return pendingToday[0]; }); }, [routines, currentDay, completedIds]); // Sin rutinas en la cuenta, o sin rutina para hoy → descanso if (routines.length === 0) { return ( ); } if (todaysRoutines.length === 0) { return ( ); } // Todas las de hoy completadas → mensaje de descanso (sin panel de completada) if (allTodayDone) { const lastDone = todaysRoutines[0]; return ( onStart(lastDone)} /> ); } if (!selectedRoutine) return null; const exerciseCount = selectedRoutine.exercises?.length || 0; const totalSets = (selectedRoutine.exercises || []).reduce((sum, ex) => sum + (Number(ex.sets) || 0), 0); const otherPending = pendingToday.filter(r => r.id !== selectedRoutine.id); const doneToday = todaysRoutines.filter(r => doneSet.has(Number(r.id))).length; const dayProgress = Math.round((doneToday / todaysRoutines.length) * 100); const setsFill = Math.min(100, Math.round((totalSets / Math.max(totalSets, 12)) * 100)); const exerciseFill = Math.min(100, Math.round((exerciseCount / Math.max(exerciseCount, 6)) * 100)); return (

Listo para moverte

Hoy · {DAYS_OF_WEEK[currentDay]}

onStart(selectedRoutine)} role="button" tabIndex={0} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') onStart(selectedRoutine); }}>
Tu entrenamiento

{selectedRoutine.name}

{exerciseCount} ejercicios · {totalSets} series

{Icons.play} Empezar ahora
{Icons.play}

Resumen

Ejercicios {exerciseCount}
Series {totalSets}
Progreso del día {doneToday}/{todaysRoutines.length}
{otherPending.length > 0 && ( <>

Otras de hoy

{otherPending.map(r => ( ))}
)}
); } function ActiveTracker({ routine, onFinish, onBack }) { const [currentExIndex, setCurrentExIndex] = useState(0); const [currentRep, setCurrentRep] = useState(0); const [currentSet, setCurrentSet] = useState(1); const [isResting, setIsResting] = useState(false); const [isRunning, setIsRunning] = useState(false); const [timeLeft, setTimeLeft] = useState(0); const [showHelp, setShowHelp] = useState(false); const finishingRef = useRef(false); const exercise = routine.exercises[currentExIndex]; const finishSet = () => { if (finishingRef.current) return; finishingRef.current = true; setCurrentRep(0); setIsRunning(false); if (currentSet >= exercise.sets) { if (currentExIndex + 1 >= routine.exercises.length) { SoundFx.workoutDone(); onFinish(); } else { setIsResting(true); setCurrentSet(1); setCurrentExIndex(prev => prev + 1); } } else { setIsResting(true); setCurrentSet(prev => prev + 1); } // Permite el siguiente ciclo (descanso → serie) sin bloquear queueMicrotask(() => { finishingRef.current = false; }); }; // Cuenta regresiva para ejercicios por tiempo (se pausa al abrir la ayuda) useEffect(() => { if (!isRunning || showHelp) return; if (timeLeft <= 0) { setIsRunning(false); SoundFx.setDone(); finishSet(); return; } SoundFx.tick(timeLeft <= 3); const timer = setInterval(() => setTimeLeft(prev => prev - 1), 1000); return () => clearInterval(timer); }, [isRunning, timeLeft, showHelp]); // Mantener audio desbloqueado mientras se entrena (toques en pantalla) useEffect(() => { const keepAlive = () => SoundFx.unlock(); window.addEventListener('touchstart', keepAlive, { passive: true }); window.addEventListener('mousedown', keepAlive); return () => { window.removeEventListener('touchstart', keepAlive); window.removeEventListener('mousedown', keepAlive); }; }, []); if (!exercise) return null; const handleTap = () => { SoundFx.unlock(); if (exercise.is_time) { if (isRunning) { setIsRunning(false); SoundFx.setDone(); finishSet(); } else { setTimeLeft(Number(exercise.reps) || 0); setIsRunning(true); SoundFx.start(); } return; } if (currentRep + 1 >= exercise.reps) { SoundFx.setDone(); finishSet(); } else { SoundFx.rep(); setCurrentRep(prev => prev + 1); } }; if (isResting) { return setIsResting(false)} />; } const R = 140; const CIRC = 2 * Math.PI * R; const totalTime = Number(exercise.reps) || 0; const repProgress = exercise.is_time ? (isRunning && totalTime > 0 ? (totalTime - timeLeft) / totalTime : 0) : currentRep / Math.max(exercise.reps, 1); return (

Ejercicio {currentExIndex + 1} de {routine.exercises.length}

{exercise.name}

Serie {currentSet} de {exercise.sets}

{Array.from({ length: exercise.sets }).map((_, i) => (
))}
{showHelp && (
setShowHelp(false)}>
e.stopPropagation()}>

Ayuda

{exercise.name}

{exercise.description || 'No hay descripción para este ejercicio. Puedes añadirla al editar la rutina.'}

)}
); } function RestTimer({ seconds, onSkip }) { const [timeLeft, setTimeLeft] = useState(seconds); const skippedRef = useRef(false); const onSkipRef = useRef(onSkip); onSkipRef.current = onSkip; const endRest = (playCue) => { if (skippedRef.current) return; skippedRef.current = true; if (playCue) SoundFx.restDone(); onSkipRef.current(); }; useEffect(() => { if (timeLeft <= 0) { endRest(true); return; } SoundFx.tick(timeLeft <= 3); const timer = setInterval(() => setTimeLeft(prev => prev - 1), 1000); return () => clearInterval(timer); }, [timeLeft]); useEffect(() => { const keepAlive = () => SoundFx.unlock(); window.addEventListener('touchstart', keepAlive, { passive: true }); return () => window.removeEventListener('touchstart', keepAlive); }, []); const formatTime = (secs) => { const m = Math.floor(secs / 60); const s = secs % 60; return `${m}:${s.toString().padStart(2, '0')}`; }; const R = 130; const CIRC = 2 * Math.PI * R; const progress = seconds > 0 ? timeLeft / seconds : 0; return (

Recupera el aliento

Descanso

{formatTime(timeLeft)}
); } function RoutinesScreen({ routines, onDataChange, user, onEditingChange }) { const [isEditing, setIsEditing] = useState(false); const [currentRoutine, setCurrentRoutine] = useState({ name: '', day_of_week: 1, exercises: [] }); // null | 'menu' | 'body' | 'list' const [pickerStep, setPickerStep] = useState(null); const [selectedBodyPart, setSelectedBodyPart] = useState(null); const setEditing = (next) => { setIsEditing(next); if (onEditingChange) onEditingChange(next); }; const handleDelete = async (e, id) => { e.stopPropagation(); if (confirm("¿Estás seguro de eliminar esta rutina?")) { await DataService.deleteRoutine(id, user); onDataChange(); } }; const startEdit = (routine) => { setCurrentRoutine(JSON.parse(JSON.stringify(routine))); setPickerStep(null); setEditing(true); }; const startCreate = () => { setCurrentRoutine({ name: '', day_of_week: 1, exercises: [] }); setPickerStep(null); setEditing(true); }; const addExercise = (exercise) => { setCurrentRoutine(prev => ({ ...prev, exercises: [...prev.exercises, exercise] })); setPickerStep(null); setSelectedBodyPart(null); }; const handleAddCustom = () => addExercise(emptyExercise()); const handlePickFromCatalog = (item) => addExercise(exerciseFromCatalog(item)); const handleRemoveExercise = (index) => { const copy = [...currentRoutine.exercises]; copy.splice(index, 1); setCurrentRoutine({ ...currentRoutine, exercises: copy }); }; const updateExercise = (index, field, value) => { const copy = currentRoutine.exercises.map((ex, i) => i === index ? { ...ex, [field]: value } : ex); setCurrentRoutine({ ...currentRoutine, exercises: copy }); }; const handleSave = async () => { if (!currentRoutine.name || currentRoutine.exercises.length === 0) { return alert("Falta el nombre de la rutina o al menos un ejercicio."); } await DataService.saveRoutine(currentRoutine, user); setEditing(false); setPickerStep(null); onDataChange(); }; const catalogForPart = selectedBodyPart ? EXERCISE_CATALOG.filter(ex => ex.bodyPart === selectedBodyPart) : []; const bodyPartName = BODY_PARTS.find(b => b.id === selectedBodyPart)?.name || ''; if (isEditing) { return (

{currentRoutine.id ? 'Editar' : 'Nueva'} Rutina

setCurrentRoutine({...currentRoutine, name: e.target.value})} />

Ejercicios ({currentRoutine.exercises.length})

{currentRoutine.exercises.map((ex, i) => (
updateExercise(i, 'name', e.target.value)}/>