<!DOCTYPE html>
<!-- build: 1784862092 -->
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FORJA — Treino</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Archivo+Black&family=Inter:wght@400;500;600;700&display=swap');
* { box-sizing: border-box; }
body { margin:0; }
button { font-family: inherit; cursor: pointer; }
input, select { font-family: inherit; }
</style>
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js" crossorigin></script>
<script src="https://accounts.google.com/gsi/client" async defer></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" data-presets="react">
const { useState, useEffect, useCallback, useRef } = React;
// CONFIGURE AQUI para habilitar Google Drive como opção de nuvem (reaproveita o mesmo
// Client ID do login com Google, só precisa incluir o escopo drive.file no Console).
const CONFIG = {
GOOGLE_CLIENT_ID: "COLE_AQUI_SEU_GOOGLE_CLIENT_ID.apps.googleusercontent.com",
};
// ---------------------------------------------------------------------------
// CAMADA DE ARMAZENAMENTO — arquivo local (File System Access API) com fallback
// de download/upload, e um conector opcional de Google Drive (arquivo de nome
// fixo "forja-dados.json" na conta do usuário).
// ---------------------------------------------------------------------------
const FS_SUPPORTED = typeof window.showOpenFilePicker === "function";
const DB_NAME = "forja-filestore";
function idbGet(key) {
return new Promise((resolve) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore("handles");
req.onsuccess = () => {
const tx = req.result.transaction("handles", "readonly");
const g = tx.objectStore("handles").get(key);
g.onsuccess = () => resolve(g.result || null);
g.onerror = () => resolve(null);
};
req.onerror = () => resolve(null);
});
}
function idbSet(key, value) {
return new Promise((resolve) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore("handles");
req.onsuccess = () => {
const tx = req.result.transaction("handles", "readwrite");
tx.objectStore("handles").put(value, key);
tx.oncomplete = () => resolve(true);
tx.onerror = () => resolve(false);
};
req.onerror = () => resolve(false);
});
}
async function verifyPermission(handle, mode) {
const opts = { mode };
if ((await handle.queryPermission(opts)) === "granted") return true;
if ((await handle.requestPermission(opts)) === "granted") return true;
return false;
}
const LocalFile = {
supported: FS_SUPPORTED,
async tryResume() {
const handle = await idbGet("lastHandle");
if (!handle) return null;
const ok = await verifyPermission(handle, "readwrite");
return ok ? handle : null;
},
async open() {
const [handle] = await window.showOpenFilePicker({
types: [{ description: "Dados FORJA", accept: { "application/json": [".json"] } }],
});
await idbSet("lastHandle", handle);
return handle;
},
async create() {
const handle = await window.showSaveFilePicker({
suggestedName: "forja-dados.json",
types: [{ description: "Dados FORJA", accept: { "application/json": [".json"] } }],
});
await idbSet("lastHandle", handle);
return handle;
},
async read(handle) {
const file = await handle.getFile();
return JSON.parse(await file.text());
},
async write(handle, data) {
const ok = await verifyPermission(handle, "readwrite");
if (!ok) throw new Error("Permissão negada para escrever no arquivo.");
const writable = await handle.createWritable();
await writable.write(JSON.stringify(data, null, 2));
await writable.close();
},
};
// Fallback pra navegadores sem File System Access API (Safari, Firefox, iOS inteiro).
const FallbackFile = {
openViaUpload() {
return new Promise((resolve, reject) => {
const input = document.createElement("input");
input.type = "file";
input.accept = "application/json";
input.onchange = () => {
const file = input.files[0];
if (!file) return reject(new Error("Nenhum arquivo selecionado."));
const reader = new FileReader();
reader.onload = () => { try { resolve(JSON.parse(reader.result)); } catch { reject(new Error("Arquivo inválido.")); } };
reader.onerror = () => reject(new Error("Falha ao ler o arquivo."));
reader.readAsText(file);
};
input.click();
});
},
downloadAsFile(data, filename = "forja-dados.json") {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
},
};
// Google Drive: guarda um único arquivo de nome fixo na pasta de dados do app
// (escopo drive.file — o Google só deixa esse app enxergar arquivos que ele mesmo criou).
const GoogleDrive = {
tokenClient: null,
accessToken: null,
ready() { return CONFIG.GOOGLE_CLIENT_ID.indexOf("COLE_AQUI") === -1 && window.google && window.google.accounts; },
async connect() {
return new Promise((resolve, reject) => {
this.tokenClient = google.accounts.oauth2.initTokenClient({
client_id: CONFIG.GOOGLE_CLIENT_ID,
scope: "https://www.googleapis.com/auth/drive.file",
callback: (resp) => {
if (resp.error) return reject(new Error("Não foi possível conectar ao Google Drive."));
this.accessToken = resp.access_token;
resolve(resp.access_token);
},
});
this.tokenClient.requestAccessToken();
});
},
async findFile() {
const res = await fetch(
"https://www.googleapis.com/drive/v3/files?q=name%3D%27forja-dados.json%27&spaces=drive&fields=files(id,name)",
{ headers: { Authorization: `Bearer ${this.accessToken}` } }
);
const data = await res.json();
return (data.files && data.files[0]) || null;
},
async readFile(fileId) {
const res = await fetch(`https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`, {
headers: { Authorization: `Bearer ${this.accessToken}` },
});
return res.json();
},
async saveFile(data, fileId) {
const metadata = { name: "forja-dados.json", mimeType: "application/json" };
const boundary = "forja-boundary";
const body =
`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${JSON.stringify(metadata)}\r\n` +
`--${boundary}\r\nContent-Type: application/json\r\n\r\n${JSON.stringify(data)}\r\n--${boundary}--`;
const url = fileId
? `https://www.googleapis.com/upload/drive/v3/files/${fileId}?uploadType=multipart`
: `https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart`;
const res = await fetch(url, {
method: fileId ? "PATCH" : "POST",
headers: { Authorization: `Bearer ${this.accessToken}`, "Content-Type": `multipart/related; boundary=${boundary}` },
body,
});
return res.json();
},
};
// ---------------------------------------------------------------------------
// DADOS DE TREINO
// ---------------------------------------------------------------------------
const GOALS = [
{ id: "ganhar-massa", label: "Ganhar Massa Corporal", icon: "💪", color: "#D4FF3D" },
{ id: "perder-peso", label: "Perder Peso", icon: "🔥", color: "#FF3B30" },
{ id: "manter-fitness", label: "Manter-se Fitness", icon: "⚖️", color: "#3DDCFF" },
{ id: "criar-resistencia", label: "Criar Resistência", icon: "🏃", color: "#FF9F1C" },
{ id: "ganhar-forca", label: "Ganhar Força", icon: "🏋️", color: "#B78CFF" },
{ id: "perder-barriga", label: "Perder Barriga", icon: "⚡", color: "#FF6B9D" },
{ id: "definir-musculos", label: "Definir Músculos", icon: "🎯", color: "#4ADE80" },
];
const GOAL_REP_SCHEME = {
"ganhar-massa": { reps: "8-12", setsDelta: 0 },
"perder-peso": { reps: "12-15", setsDelta: 0 },
"manter-fitness": { reps: "10-12", setsDelta: 0 },
"criar-resistencia": { reps: "15-20", setsDelta: 0 },
"ganhar-forca": { reps: "4-6", setsDelta: 1 },
"perder-barriga": { reps: "15", setsDelta: 0 },
"definir-musculos": { reps: "12-15", setsDelta: 0 },
};
const CARDIO_BIAS_GOALS = new Set(["perder-peso", "perder-barriga", "criar-resistencia"]);
const INTENSITY = {
leve: { label: "Leve", desc: "Ritmo controlado, ideal para começar", setsDelta: -1, rest: "60-90s de descanso", color: "#3DDCFF" },
medio: { label: "Médio", desc: "Equilíbrio entre volume e esforço", setsDelta: 0, rest: "45-60s de descanso", color: "#D4FF3D" },
pesado: { label: "Pesado", desc: "Carga alta, quase até a falha", setsDelta: 1, rest: "90-120s de descanso", color: "#FF3B30" },
};
const MISSION_OPTIONS = [15, 30, 45, 60];
const FIXED_GYMS = [
{ id: "planet-fitness", label: "Planet Fitness", icon: "🪐", note: "Máquinas guiadas e Smith machine. Normalmente sem barra livre, squat rack ou kettlebell." },
{ id: "crunch", label: "Crunch Fitness", icon: "💥", note: "Halteres, barras olímpicas, squat racks, kettlebells e zona funcional." },
{ id: "la-fitness", label: "LA Fitness", icon: "🏋️", note: "Free weights completos, squat racks, Smith machine, TRX." },
{ id: "outra", label: "Outra academia", icon: "🏢", note: "Você marca os equipamentos disponíveis." },
];
const FIXED_EQUIPMENT_PROFILE = {
"planet-fitness": { barbell: false, squatRack: false, kettlebell: false, pullupBar: true, cable: true, machine: true, smith: true, dumbbell: true, bench: true },
"crunch": { barbell: true, squatRack: true, kettlebell: true, pullupBar: true, cable: true, machine: true, smith: true, dumbbell: true, bench: true },
"la-fitness": { barbell: true, squatRack: true, kettlebell: true, pullupBar: true, cable: true, machine: true, smith: true, dumbbell: true, bench: true },
};
// Ícones fazem o papel de "foto de referência" de cada equipamento — optei por isso em vez
// de fotos reais pra não depender de hospedar/licenciar imagens de terceiros.
const EQUIPMENT_FIELDS = [
{ key: "dumbbell", label: "Halteres", icon: "🏋️" },
{ key: "barbell", label: "Barra olímpica", icon: "🔩" },
{ key: "squatRack", label: "Squat rack", icon: "🧱" },
{ key: "smith", label: "Smith machine", icon: "🛠️" },
{ key: "cable", label: "Cabo / polia", icon: "🔗" },
{ key: "machine", label: "Máquinas seletorizadas", icon: "⚙️" },
{ key: "kettlebell", label: "Kettlebell", icon: "🔔" },
{ key: "bench", label: "Banco", icon: "🪑" },
{ key: "pullupBar", label: "Barra fixa", icon: "➖" },
];
const MUSCLE_LIBRARY = {
"Peito": [
{ name: "Supino reto com barra", equip: "barbell", sets: 4, reps: "8-10", repType: "count" },
{ name: "Supino inclinado com halteres", equip: "dumbbell", sets: 3, reps: "10-12", repType: "count" },
{ name: "Supino reto na máquina", equip: "machine", sets: 3, reps: "10-12", repType: "count" },
{ name: "Crucifixo na polia (cross-over)", equip: "cable", sets: 3, reps: "12-15", repType: "count" },
{ name: "Crucifixo com halteres", equip: "dumbbell", sets: 3, reps: "12-15", repType: "count" },
{ name: "Flexão de braço", equip: "bodyweight", sets: 3, reps: "12-15", repType: "count" },
{ name: "Flexão diamante", equip: "bodyweight", sets: 3, reps: "10-12", repType: "count" },
{ name: "Supino no Smith Machine", equip: "smith", sets: 4, reps: "8-10", repType: "count" },
],
"Costas": [
{ name: "Puxada frontal (pulley)", equip: "cable", sets: 4, reps: "8-10", repType: "count" },
{ name: "Remada curvada com barra", equip: "barbell", sets: 4, reps: "8-10", repType: "count" },
{ name: "Remada unilateral com halter", equip: "dumbbell", sets: 3, reps: "10-12", repType: "count" },
{ name: "Remada máquina (cable row)", equip: "cable", sets: 3, reps: "10-12", repType: "count" },
{ name: "Barra fixa (pull-up)", equip: "pullupBar", sets: 3, reps: "máximo", repType: "count" },
{ name: "Puxada supinada", equip: "cable", sets: 3, reps: "10-12", repType: "count" },
{ name: "Remada baixa na máquina", equip: "machine", sets: 3, reps: "10-12", repType: "count" },
{ name: "Superman (extensão lombar no solo)", equip: "bodyweight", sets: 3, reps: "15", repType: "count" },
],
"Pernas": [
{ name: "Agachamento livre com barra", equip: "barbell", sets: 4, reps: "8-10", repType: "count" },
{ name: "Agachamento no Smith Machine", equip: "smith", sets: 4, reps: "10-12", repType: "count" },
{ name: "Leg press 45°", equip: "machine", sets: 4, reps: "10-12", repType: "count" },
{ name: "Cadeira extensora", equip: "machine", sets: 3, reps: "12-15", repType: "count" },
{ name: "Mesa flexora", equip: "machine", sets: 3, reps: "12-15", repType: "count" },
{ name: "Afundo com halteres (lunge)", equip: "dumbbell", sets: 3, reps: "10 cada perna", repType: "count" },
{ name: "Panturrilha em pé", equip: "machine", sets: 4, reps: "15-20", repType: "count" },
{ name: "Agachamento livre (peso corporal)", equip: "bodyweight", sets: 4, reps: "15-20", repType: "count" },
{ name: "Levantamento terra (deadlift)", equip: "barbell", sets: 4, reps: "6-8", repType: "count" },
{ name: "Agachamento sumô com halter", equip: "dumbbell", sets: 3, reps: "12-15", repType: "count" },
],
"Ombros": [
{ name: "Desenvolvimento militar com barra", equip: "barbell", sets: 4, reps: "8-10", repType: "count" },
{ name: "Desenvolvimento com halteres", equip: "dumbbell", sets: 3, reps: "10-12", repType: "count" },
{ name: "Elevação lateral com halteres", equip: "dumbbell", sets: 3, reps: "12-15", repType: "count" },
{ name: "Elevação posterior (crucifixo invertido)", equip: "dumbbell", sets: 3, reps: "12-15", repType: "count" },
{ name: "Desenvolvimento na máquina", equip: "machine", sets: 3, reps: "10-12", repType: "count" },
{ name: "Elevação lateral na polia", equip: "cable", sets: 3, reps: "12-15", repType: "count" },
],
"Braços": [
{ name: "Rosca direta com barra", equip: "barbell", sets: 3, reps: "10-12", repType: "count" },
{ name: "Rosca alternada com halteres", equip: "dumbbell", sets: 3, reps: "12", repType: "count" },
{ name: "Rosca martelo", equip: "dumbbell", sets: 3, reps: "12", repType: "count" },
{ name: "Tríceps corda na polia", equip: "cable", sets: 3, reps: "12-15", repType: "count" },
{ name: "Tríceps testa com barra EZ", equip: "barbell", sets: 3, reps: "10-12", repType: "count" },
{ name: "Tríceps francês com halter", equip: "dumbbell", sets: 3, reps: "12", repType: "count" },
{ name: "Mergulho no banco (dips)", equip: "bench", sets: 3, reps: "10-15", repType: "count" },
],
"Abdômen": [
{ name: "Prancha abdominal", equip: "bodyweight", sets: 3, reps: "40-60s", repType: "time" },
{ name: "Abdominal supra", equip: "bodyweight", sets: 4, reps: "20", repType: "count" },
{ name: "Abdominal infra (elevação de pernas)", equip: "bodyweight", sets: 3, reps: "15", repType: "count" },
{ name: "Russian twist", equip: "bodyweight", sets: 3, reps: "25", repType: "count" },
{ name: "Bicicleta no ar", equip: "bodyweight", sets: 3, reps: "20", repType: "count" },
{ name: "Prancha lateral", equip: "bodyweight", sets: 3, reps: "30s cada lado", repType: "time" },
{ name: "Abdominal canivete (V-up)", equip: "bodyweight", sets: 3, reps: "15", repType: "count" },
{ name: "Dead bug", equip: "bodyweight", sets: 3, reps: "12 cada lado", repType: "count" },
],
"Cardio": [
{ name: "Polichinelo (jumping jack)", equip: "bodyweight", sets: 4, reps: "40s", repType: "time" },
{ name: "Burpee", equip: "bodyweight", sets: 4, reps: "12", repType: "count" },
{ name: "Mountain climbers", equip: "bodyweight", sets: 4, reps: "30s", repType: "time" },
{ name: "Corrida intervalada (HIIT)", equip: "bodyweight", sets: 1, reps: "15 min", repType: "time" },
{ name: "Corda naval (battle rope) ou corrida", equip: "bodyweight", sets: 4, reps: "30s", repType: "time" },
{ name: "Agachamento com salto (jump squat)", equip: "bodyweight", sets: 4, reps: "12", repType: "count" },
{ name: "Escalador em velocidade", equip: "bodyweight", sets: 4, reps: "30s", repType: "time" },
],
"Full Body": [
{ name: "Kettlebell swing", equip: "kettlebell", sets: 4, reps: "15", repType: "count" },
{ name: "Burpee com salto", equip: "bodyweight", sets: 3, reps: "10", repType: "count" },
{ name: "Agachamento com desenvolvimento (thruster leve)", equip: "dumbbell", sets: 3, reps: "12", repType: "count" },
{ name: "Prancha com toque no ombro", equip: "bodyweight", sets: 3, reps: "30s", repType: "time" },
],
};
const SPLIT_TEMPLATES = {
1: [{ title: "Full Body", groups: ["Peito", "Costas", "Pernas", "Abdômen"] }],
2: [
{ title: "Full Body A", groups: ["Peito", "Costas", "Pernas"] },
{ title: "Full Body B", groups: ["Ombros", "Braços", "Abdômen"] },
],
3: [
{ title: "Peito & Tríceps", groups: ["Peito", "Braços"] },
{ title: "Costas & Bíceps", groups: ["Costas", "Braços"] },
{ title: "Pernas & Ombros", groups: ["Pernas", "Ombros"] },
],
4: [
{ title: "Peito & Tríceps", groups: ["Peito", "Braços"] },
{ title: "Costas & Bíceps", groups: ["Costas", "Braços"] },
{ title: "Pernas", groups: ["Pernas"] },
{ title: "Ombros & Abdômen", groups: ["Ombros", "Abdômen"] },
],
5: [
{ title: "Peito & Tríceps", groups: ["Peito", "Braços"] },
{ title: "Costas & Bíceps", groups: ["Costas", "Braços"] },
{ title: "Pernas", groups: ["Pernas"] },
{ title: "Ombros & Abdômen", groups: ["Ombros", "Abdômen"] },
{ title: "Full Body + Core", groups: ["Full Body", "Abdômen"] },
],
6: [
{ title: "Peito", groups: ["Peito"] },
{ title: "Costas", groups: ["Costas"] },
{ title: "Pernas", groups: ["Pernas"] },
{ title: "Ombros", groups: ["Ombros"] },
{ title: "Braços", groups: ["Braços"] },
{ title: "Abdômen & Cardio", groups: ["Abdômen", "Cardio"] },
],
7: [
{ title: "Peito", groups: ["Peito"] },
{ title: "Costas", groups: ["Costas"] },
{ title: "Pernas", groups: ["Pernas"] },
{ title: "Ombros", groups: ["Ombros"] },
{ title: "Braços", groups: ["Braços"] },
{ title: "Abdômen & Cardio", groups: ["Abdômen", "Cardio"] },
{ title: "Cardio & Mobilidade", groups: ["Cardio", "Abdômen"] },
],
};
function shuffle(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function getEquipProfile(gym) {
if (!gym) return null;
if (gym.id !== "outra" && FIXED_EQUIPMENT_PROFILE[gym.id]) return FIXED_EQUIPMENT_PROFILE[gym.id];
return gym.equipment || null; // "outra": usa o que a pessoa marcou; se nada marcado, null = mostra tudo mesmo assim
}
function filterByEquipment(group, equipProfile) {
const list = MUSCLE_LIBRARY[group] || [];
if (!equipProfile) return list; // sem dados confirmados: mostra tudo mesmo assim
return list.filter((ex) => ex.equip === "bodyweight" || equipProfile[ex.equip] !== false);
}
function applyGoalAndIntensity(ex, goalId, intensityId) {
const scheme = GOAL_REP_SCHEME[goalId] || { reps: ex.reps, setsDelta: 0 };
const intensity = INTENSITY[intensityId] || INTENSITY.medio;
const sets = Math.max(2, ex.sets + (scheme.setsDelta || 0) + intensity.setsDelta);
const reps = ex.repType === "count" ? scheme.reps : ex.reps;
return { name: ex.name, sets, reps };
}
function buildSplit(daysPerWeek, goalId) {
const template = SPLIT_TEMPLATES[Math.min(7, Math.max(1, daysPerWeek))];
const biasCardio = CARDIO_BIAS_GOALS.has(goalId);
return template.map((day) => ({
title: day.title,
groups: biasCardio && !day.groups.includes("Cardio") ? [...day.groups, "Cardio"] : day.groups,
}));
}
function generateAIPlan(daysPerWeek, goalId, intensityId, gym) {
const equipProfile = getEquipProfile(gym);
const split = buildSplit(daysPerWeek, goalId);
return split.map((day) => {
const pool = [];
day.groups.forEach((g) => filterByEquipment(g, equipProfile).forEach((ex) => pool.push(ex)));
const picked = shuffle(pool).slice(0, Math.min(5, Math.max(4, day.groups.length * 2)));
return { title: day.title, groups: day.groups, exercises: picked.map((ex) => applyGoalAndIntensity(ex, goalId, intensityId)) };
});
}
function youtubeSearchUrl(name) {
return `https://www.youtube.com/results?search_query=${encodeURIComponent(name + " execução correta exercício")}`;
}
function todayISO() { return new Date().toISOString().slice(0, 10); }
function computeBmi(weightKg, heightCm) {
const h = heightCm / 100;
if (!weightKg || !h) return null;
const bmi = weightKg / (h * h);
let category = "";
if (bmi < 18.5) category = "Abaixo do peso";
else if (bmi < 25) category = "Peso normal";
else if (bmi < 30) category = "Sobrepeso";
else if (bmi < 35) category = "Obesidade grau I";
else if (bmi < 40) category = "Obesidade grau II";
else category = "Obesidade grau III";
return { value: +bmi.toFixed(1), category };
}
const DEFAULT_DATA = () => ({
version: 2,
profile: { name: "", age: "", heightCm: "", weightKg: "", bmi: null, daysPerWeek: 3 },
gym: null,
planMode: null,
goal: null,
missionDays: null,
missionStartDate: null,
intensity: null,
weeklyPlan: [],
completedDays: [],
currentDayIndex: 0,
sessionHistory: [],
metrics: [],
});
// ---------------------------------------------------------------------------
// APP
// ---------------------------------------------------------------------------
function App() {
const [authChecked, setAuthChecked] = useState(false);
const [me, setMe] = useState(null);
const [storageMode, setStorageMode] = useState(null); // 'local' | 'local-fallback' | 'drive'
const [fileHandle, setFileHandle] = useState(null);
const [driveFileId, setDriveFileId] = useState(null);
const [data, setData] = useState(null);
const [view, setView] = useState("loading");
const [storageError, setStorageError] = useState("");
const [resumeHandle, setResumeHandle] = useState(null);
useEffect(() => {
(async () => {
try {
const meRes = await fetch("/api/me", { credentials: "include" });
if (!meRes.ok) { window.location.href = "/login.html"; return; }
const meData = await meRes.json();
if (meData.status !== "active") { window.location.href = "/login.html"; return; }
setMe(meData);
const handle = await LocalFile.tryResume();
setResumeHandle(handle);
setView("storage-gate");
} catch (e) {
window.location.href = "/login.html";
} finally {
setAuthChecked(true);
}
})();
}, []);
function afterDataLoaded(loaded) {
const merged = { ...DEFAULT_DATA(), ...loaded, profile: { ...DEFAULT_DATA().profile, ...(loaded.profile || {}) } };
setData(merged);
setView(merged.profile.name ? "app" : "onboarding");
}
async function handleOpenLocal() {
setStorageError("");
try {
const handle = await LocalFile.open();
const parsed = await LocalFile.read(handle);
setFileHandle(handle); setStorageMode("local");
afterDataLoaded(parsed);
} catch (e) { setStorageError("Não foi possível abrir o arquivo: " + e.message); }
}
async function handleResumeLocal() {
setStorageError("");
try {
const parsed = await LocalFile.read(resumeHandle);
setFileHandle(resumeHandle); setStorageMode("local");
afterDataLoaded(parsed);
} catch (e) { setStorageError("Não foi possível reabrir o último arquivo: " + e.message); }
}
async function handleCreateLocal() {
setStorageError("");
try {
const handle = await LocalFile.create();
const fresh = DEFAULT_DATA();
await LocalFile.write(handle, fresh);
setFileHandle(handle); setStorageMode("local");
afterDataLoaded(fresh);
} catch (e) { setStorageError("Não foi possível criar o arquivo: " + e.message); }
}
async function handleOpenFallback() {
setStorageError("");
try {
const parsed = await FallbackFile.openViaUpload();
setStorageMode("local-fallback");
afterDataLoaded(parsed);
} catch (e) { setStorageError(e.message); }
}
function handleCreateFallback() {
setStorageMode("local-fallback");
afterDataLoaded(DEFAULT_DATA());
}
async function handleConnectDrive() {
setStorageError("");
try {
await GoogleDrive.connect();
const existing = await GoogleDrive.findFile();
if (existing) {
const parsed = await GoogleDrive.readFile(existing.id);
setDriveFileId(existing.id);
afterDataLoaded(parsed);
} else {
const fresh = DEFAULT_DATA();
const created = await GoogleDrive.saveFile(fresh, null);
setDriveFileId(created.id);
afterDataLoaded(fresh);
}
setStorageMode("drive");
} catch (e) { setStorageError("Não foi possível conectar ao Google Drive: " + e.message); }
}
const persist = useCallback(async (next) => {
setData(next);
try {
if (storageMode === "local" && fileHandle) await LocalFile.write(fileHandle, next);
else if (storageMode === "local-fallback") FallbackFile.downloadAsFile(next);
else if (storageMode === "drive") await GoogleDrive.saveFile(next, driveFileId);
} catch (e) { setStorageError("Falha ao salvar: " + e.message); }
}, [storageMode, fileHandle, driveFileId]);
async function logout() {
await fetch("/api/logout", { method: "POST", credentials: "include" });
window.location.href = "/login.html";
}
if (!authChecked || view === "loading") {
return <div style={styles.shell}><div style={styles.loadingWrap}><div style={styles.spinner} /><p style={{ color: "#8A8C82", marginTop: 12 }}>Carregando...</p></div></div>;
}
return (
<div style={styles.shell}>
<div style={styles.topbar}>
<div style={styles.brand}><span style={styles.brandDotWrap}>💪</span><span style={styles.brandText}>FORJA<span style={{ color: "#D4FF3D" }}>.</span></span></div>
<div style={{ display: "flex", gap: 8 }}>
{me?.is_admin && <a href="/admin.html" style={styles.ghostBtn}>Admin</a>}
<button style={styles.ghostBtn} onClick={logout}>Sair</button>
</div>
</div>
{view === "storage-gate" && (
<StorageGate resumeHandle={resumeHandle} error={storageError}
onOpenLocal={handleOpenLocal} onResumeLocal={handleResumeLocal} onCreateLocal={handleCreateLocal}
onOpenFallback={handleOpenFallback} onCreateFallback={handleCreateFallback}
onConnectDrive={handleConnectDrive} />
)}
{view === "onboarding" && data && <Onboarding data={data} onFinish={(next) => { persist(next); setView("app"); }} />}
{view === "app" && data && <MainApp data={data} persist={persist} storageMode={storageMode} onRestart={() => setView("storage-gate")} />}
</div>
);
}
function StorageGate({ resumeHandle, error, onOpenLocal, onResumeLocal, onCreateLocal, onOpenFallback, onCreateFallback, onConnectDrive }) {
return (
<div style={styles.container}>
<div style={styles.eyebrow}>SEUS DADOS</div>
<h1 style={styles.h1}>Onde estão seus dados?</h1>
<p style={styles.pMuted}>
Seus dados (perfil, treinos, progresso) ficam num arquivo que só você guarda — local ou na nuvem.
A cada acesso, é só apontar pra ele de novo.
</p>
{!LocalFile.supported && (
<div style={styles.infoBox}>
Seu navegador não suporta abrir/salvar um arquivo diretamente (isso é normal no Safari/iPhone e no Firefox).
Vamos usar o modo alternativo: você faz upload do arquivo pra abrir, e cada alteração baixa uma versão nova pra você substituir.
</div>
)}
{LocalFile.supported && resumeHandle && (
<button style={styles.bigOptionBtn} onClick={onResumeLocal}>
<span style={{ fontSize: 22 }}>⚡</span>
<div style={{ flex: 1, textAlign: "left" }}><div style={styles.dayTitle}>Continuar com o último arquivo</div><div style={styles.gymNote}>Reabre o arquivo que você já usou neste navegador</div></div>
</button>
)}
{LocalFile.supported ? (
<>
<button style={styles.bigOptionBtn} onClick={onOpenLocal}>
<span style={{ fontSize: 22 }}>📂</span>
<div style={{ flex: 1, textAlign: "left" }}><div style={styles.dayTitle}>Abrir arquivo local</div><div style={styles.gymNote}>Escolha um forja-dados.json já existente no seu dispositivo</div></div>
</button>
<button style={styles.bigOptionBtn} onClick={onCreateLocal}>
<span style={{ fontSize: 22 }}>✨</span>
<div style={{ flex: 1, textAlign: "left" }}><div style={styles.dayTitle}>Criar novo arquivo local</div><div style={styles.gymNote}>Primeira vez? Comece do zero aqui</div></div>
</button>
</>
) : (
<>
<button style={styles.bigOptionBtn} onClick={onOpenFallback}>
<span style={{ fontSize: 22 }}>📂</span>
<div style={{ flex: 1, textAlign: "left" }}><div style={styles.dayTitle}>Abrir arquivo (upload)</div><div style={styles.gymNote}>Selecione seu forja-dados.json</div></div>
</button>
<button style={styles.bigOptionBtn} onClick={onCreateFallback}>
<span style={{ fontSize: 22 }}>✨</span>
<div style={{ flex: 1, textAlign: "left" }}><div style={styles.dayTitle}>Começar do zero</div><div style={styles.gymNote}>A cada mudança, vamos baixar um arquivo atualizado pra você guardar</div></div>
</button>
</>
)}
<button style={styles.bigOptionBtn} onClick={onConnectDrive}>
<span style={{ fontSize: 22 }}>☁️</span>
<div style={{ flex: 1, textAlign: "left" }}>
<div style={styles.dayTitle}>Conectar Google Drive</div>
<div style={styles.gymNote}>{GoogleDrive.ready() ? "Salva um forja-dados.json na sua conta Google" : "Configure GOOGLE_CLIENT_ID no código pra habilitar"}</div>
</div>
</button>
<button style={{ ...styles.bigOptionBtn, opacity: 0.5 }} disabled>
<span style={{ fontSize: 22 }}>📦</span>
<div style={{ flex: 1, textAlign: "left" }}><div style={styles.dayTitle}>Conectar Dropbox</div><div style={styles.gymNote}>Em breve — peça pra eu construir esse conector também</div></div>
</button>
{error && <div style={styles.errorBox}>{error}</div>}
</div>
);
}
// ---------------------------------------------------------------------------
// ONBOARDING
// ---------------------------------------------------------------------------
function Onboarding({ data, onFinish }) {
const [step, setStep] = useState(0);
const [profile, setProfile] = useState(data.profile);
const [gym, setGym] = useState(data.gym || { id: null, label: "", equipment: {} });
const [planMode, setPlanMode] = useState(data.planMode || "ia");
const [goal, setGoal] = useState(data.goal);
const [missionDays, setMissionDays] = useState(data.missionDays);
const [intensity, setIntensity] = useState(data.intensity);
const [manualDayIdx, setManualDayIdx] = useState(0);
const [manualSelections, setManualSelections] = useState([]);
const bmi = computeBmi(parseFloat(profile.weightKg), parseFloat(profile.heightCm));
function next() { setStep((s) => s + 1); }
function back() { setStep((s) => Math.max(0, s - 1)); }
function finalizeIA() {
const plan = generateAIPlan(profile.daysPerWeek, goal, intensity, gym);
finish(plan);
}
function finish(plan) {
const next = {
...data,
profile: { ...profile, bmi },
gym, planMode, goal, missionDays, intensity,
missionStartDate: todayISO(),
weeklyPlan: plan,
completedDays: new Array(plan.length).fill(false),
currentDayIndex: 0,
};
onFinish(next);
}
// --- Passo 0: perfil ---
if (step === 0) {
return (
<div style={styles.container}>
<div style={styles.eyebrow}>PASSO 1 · SEU PERFIL</div>
<h1 style={styles.h1}>Vamos te conhecer</h1>
<label style={styles.label}>Nome</label>
<input style={styles.input} value={profile.name} onChange={(e) => setProfile({ ...profile, name: e.target.value })} />
<div style={styles.rowGrid}>
<div><label style={styles.label}>Idade</label><input style={styles.input} type="number" value={profile.age} onChange={(e) => setProfile({ ...profile, age: e.target.value })} /></div>
<div><label style={styles.label}>Altura (cm)</label><input style={styles.input} type="number" value={profile.heightCm} onChange={(e) => setProfile({ ...profile, heightCm: e.target.value })} /></div>
</div>
<label style={styles.label}>Peso (kg)</label>
<input style={styles.input} type="number" value={profile.weightKg} onChange={(e) => setProfile({ ...profile, weightKg: e.target.value })} />
{bmi && (
<div style={styles.bmiBox}>
<div style={{ fontFamily: "'Archivo Black', sans-serif", fontSize: 24 }}>{bmi.value}</div>
<div style={{ fontSize: 13, color: "#B9BBAE" }}>IMC · {bmi.category}</div>
<div style={styles.pMutedSmall}>O IMC é uma estimativa geral — não leva em conta massa muscular ou composição corporal.</div>
</div>
)}
<button style={styles.completeBtn} disabled={!profile.name} onClick={next}>Próximo</button>
</div>
);
}
// --- Passo 1: dias por semana ---
if (step === 1) {
return (
<div style={styles.container}>
<button style={styles.backBtn} onClick={back}>← Voltar</button>
<div style={styles.eyebrow}>PASSO 2 · FREQUÊNCIA</div>
<h1 style={styles.h1}>Quantos dias por semana?</h1>
<div style={styles.chipGrid}>
{[1, 2, 3, 4, 5, 6, 7].map((n) => (
<button key={n} style={{ ...styles.chipOption, ...(profile.daysPerWeek === n ? styles.chipOptionActive : {}) }} onClick={() => setProfile({ ...profile, daysPerWeek: n })}>{n}</button>
))}
</div>
<button style={styles.completeBtn} onClick={next}>Próximo</button>
</div>
);
}
// --- Passo 2: academia ---
if (step === 2) {
return (
<div style={styles.container}>
<button style={styles.backBtn} onClick={back}>← Voltar</button>
<div style={styles.eyebrow}>PASSO 3 · ACADEMIA</div>
<h1 style={styles.h1}>Onde você treina?</h1>
<div style={styles.gymList}>
{FIXED_GYMS.map((g) => (
<button key={g.id} style={{ ...styles.gymCard, borderColor: gym.id === g.id ? "#D4FF3D" : "#23241D" }} onClick={() => setGym({ id: g.id, label: g.label, equipment: {} })}>
<div style={{ fontSize: 22 }}>{g.icon}</div>
<div style={{ flex: 1 }}><div style={styles.dayTitle}>{g.label}</div><div style={styles.gymNote}>{g.note}</div></div>
</button>
))}
</div>
{gym.id === "outra" && (
<div style={styles.settingsCard}>
<label style={styles.label}>Nome da academia</label>
<input style={styles.input} value={gym.label} onChange={(e) => setGym({ ...gym, label: e.target.value })} />
<div style={{ ...styles.pMutedSmall, marginTop: 12 }}>Marque os equipamentos disponíveis:</div>
<div style={styles.equipGrid}>
{EQUIPMENT_FIELDS.map((f) => (
<button key={f.key} style={{ ...styles.equipChip, ...(gym.equipment[f.key] ? styles.equipChipActive : {}) }} onClick={() => setGym({ ...gym, equipment: { ...gym.equipment, [f.key]: !gym.equipment[f.key] } })}>
<span style={{ fontSize: 16 }}>{f.icon}</span> {f.label}
</button>
))}
</div>
</div>
)}
<button style={styles.completeBtn} disabled={!gym.id} onClick={next}>Próximo</button>
</div>
);
}
// --- Passo 3: modo de treino ---
if (step === 3) {
return (
<div style={styles.container}>
<button style={styles.backBtn} onClick={back}>← Voltar</button>
<div style={styles.eyebrow}>PASSO 4 · MONTAGEM DO TREINO</div>
<h1 style={styles.h1}>Quem monta o treino?</h1>
<button style={{ ...styles.bigOptionBtn, borderColor: planMode === "ia" ? "#D4FF3D" : "#23241D" }} onClick={() => setPlanMode("ia")}>
<span style={{ fontSize: 22 }}>🤖</span>
<div style={{ flex: 1, textAlign: "left" }}><div style={styles.dayTitle}>Deixar a IA montar</div><div style={styles.gymNote}>Sequência automática por grupo muscular, com base nos equipamentos da sua academia</div></div>
</button>
<button style={{ ...styles.bigOptionBtn, borderColor: planMode === "manual" ? "#D4FF3D" : "#23241D" }} onClick={() => setPlanMode("manual")}>
<span style={{ fontSize: 22 }}>✍️</span>
<div style={{ flex: 1, textAlign: "left" }}><div style={styles.dayTitle}>Montar eu mesmo</div><div style={styles.gymNote}>Escolha os exercícios de cada grupo muscular, dia por dia</div></div>
</button>
<button style={styles.completeBtn} onClick={next}>Próximo</button>
</div>
);
}
// --- Passo 4: meta ---
if (step === 4) {
return (
<div style={styles.container}>
<button style={styles.backBtn} onClick={back}>← Voltar</button>
<div style={styles.eyebrow}>PASSO 5 · META</div>
<h1 style={styles.h1}>Qual é a sua missão?</h1>
<div style={styles.goalGrid}>
{GOALS.map((g) => (
<button key={g.id} style={{ ...styles.goalCard, borderColor: goal === g.id ? g.color : g.color + "40" }} onClick={() => setGoal(g.id)}>
<div style={{ fontSize: 26 }}>{g.icon}</div>
<div style={{ ...styles.goalTitle, color: g.color }}>{g.label}</div>
</button>
))}
</div>
<button style={styles.completeBtn} disabled={!goal} onClick={next}>Próximo</button>
</div>
);
}
// --- Passo 5: duração da missão ---
if (step === 5) {
return (
<div style={styles.container}>
<button style={styles.backBtn} onClick={back}>← Voltar</button>
<div style={styles.eyebrow}>PASSO 6 · DURAÇÃO</div>
<h1 style={styles.h1}>Por quantos dias?</h1>
<p style={styles.pMuted}>Sua missão de acompanhamento — o dashboard mostra seu progresso dentro desse período.</p>
<div style={styles.chipGrid}>
{MISSION_OPTIONS.map((n) => (
<button key={n} style={{ ...styles.chipOption, minWidth: 70, ...(missionDays === n ? styles.chipOptionActive : {}) }} onClick={() => setMissionDays(n)}>{n} dias</button>
))}
</div>
<button style={styles.completeBtn} disabled={!missionDays} onClick={next}>Próximo</button>
</div>
);
}
// --- Passo 6: intensidade ---
if (step === 6) {
return (
<div style={styles.container}>
<button style={styles.backBtn} onClick={back}>← Voltar</button>
<div style={styles.eyebrow}>PASSO 7 · INTENSIDADE</div>
<h1 style={styles.h1}>Qual ritmo?</h1>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{Object.entries(INTENSITY).map(([id, info]) => (
<button key={id} style={{ ...styles.intensityCard, borderColor: intensity === id ? info.color : info.color + "40" }} onClick={() => setIntensity(id)}>
<div><div style={{ ...styles.dayTitle, color: info.color }}>{info.label}</div><div style={styles.gymNote}>{info.desc} · {info.rest}</div></div>
</button>
))}
</div>
<button style={styles.completeBtn} disabled={!intensity} onClick={() => setStep(planMode === "ia" ? 7 : 8)}>Próximo</button>
</div>
);
}
// --- Passo 7 (modo IA): revisão e geração ---
if (step === 7 && planMode === "ia") {
return (
<div style={styles.container}>
<button style={styles.backBtn} onClick={back}>← Voltar</button>
<div style={styles.eyebrow}>TUDO PRONTO</div>
<h1 style={styles.h1}>Gerar meu treino</h1>
<p style={styles.pMuted}>{profile.daysPerWeek}x por semana · {gym.label} · {GOALS.find((g) => g.id === goal)?.label} · {missionDays} dias · {INTENSITY[intensity]?.label}</p>
<button style={styles.completeBtn} onClick={finalizeIA}>🤖 Gerar treino com IA</button>
</div>
);
}
// --- Passo 8 (modo manual): montagem dia a dia ---
if (step === 8 && planMode === "manual") {
const split = buildSplit(profile.daysPerWeek, goal);
const equipProfile = getEquipProfile(gym);
const dayTemplate = split[manualDayIdx];
const selectedForDay = manualSelections[manualDayIdx] || [];
function toggleExercise(ex) {
const copy = [...manualSelections];
const current = copy[manualDayIdx] || [];
const exists = current.find((e) => e.name === ex.name);
copy[manualDayIdx] = exists ? current.filter((e) => e.name !== ex.name) : [...current, ex];
setManualSelections(copy);
}
function nextDay() {
if (manualDayIdx < split.length - 1) setManualDayIdx(manualDayIdx + 1);
else {
const plan = split.map((day, i) => ({
title: day.title, groups: day.groups,
exercises: (manualSelections[i] || []).map((ex) => applyGoalAndIntensity(ex, goal, intensity)),
}));
finish(plan);
}
}
return (
<div style={styles.container}>
<div style={styles.eyebrow}>MONTAGEM MANUAL · DIA {manualDayIdx + 1} DE {split.length}</div>
<h1 style={styles.h1}>{dayTemplate.title}</h1>
<p style={styles.pMuted}>Escolha os exercícios deste dia. Selecionados: {selectedForDay.length}.</p>
{dayTemplate.groups.map((group) => (
<div key={group} style={{ marginBottom: 18 }}>
<div style={styles.eyebrow}>{group.toUpperCase()}</div>
<div style={styles.exList}>
{filterByEquipment(group, equipProfile).map((ex) => {
const checked = !!selectedForDay.find((e) => e.name === ex.name);
return (
<button key={ex.name} style={{ ...styles.exRow, borderColor: checked ? "#D4FF3D" : "#23241D" }} onClick={() => toggleExercise(ex)}>
<span>{checked ? "✅" : "⬜"}</span>
<div style={{ flex: 1, textAlign: "left" }}><div style={styles.exName}>{ex.name}</div><div style={styles.exMeta}>{ex.sets} séries × {ex.reps}</div></div>
</button>
);
})}
</div>
</div>
))}
<button style={styles.completeBtn} disabled={selectedForDay.length === 0} onClick={nextDay}>
{manualDayIdx < split.length - 1 ? "Próximo dia" : "Concluir montagem"}
</button>
</div>
);
}
return null;
}
// ---------------------------------------------------------------------------
// APP PRINCIPAL (treino + dashboard + config)
// ---------------------------------------------------------------------------
function MainApp({ data, persist, storageMode, onRestart }) {
const [tab, setTab] = useState("treino");
const [view, setView] = useState("week");
const [activeDay, setActiveDay] = useState(null);
const [checkedEx, setCheckedEx] = useState({});
const [metricForm, setMetricForm] = useState({ weight: "", waist: "" });
function openDay(idx) { setActiveDay(idx); setCheckedEx({}); setView("day"); }
function completeDay() {
const nextCompleted = [...data.completedDays];
nextCompleted[activeDay] = true;
const nextIdx = nextCompleted.findIndex((d) => !d);
const history = [{ date: todayISO(), title: data.weeklyPlan[activeDay].title }, ...(data.sessionHistory || [])].slice(0, 90);
persist({ ...data, completedDays: nextCompleted, currentDayIndex: nextIdx === -1 ? data.currentDayIndex : nextIdx, sessionHistory: history });
setView("week");
}
function resetWeek() {
persist({ ...data, completedDays: new Array(data.weeklyPlan.length).fill(false), currentDayIndex: 0 });
}
function addMetric() {
const w = parseFloat(metricForm.weight), waist = parseFloat(metricForm.waist);
if (!w && !waist) return;
persist({ ...data, metrics: [...(data.metrics || []), { date: todayISO(), weight: isNaN(w) ? null : w, waist: isNaN(waist) ? null : waist }] });
setMetricForm({ weight: "", waist: "" });
}
const goalMeta = GOALS.find((g) => g.id === data.goal);
const intensityMeta = INTENSITY[data.intensity];
const missionDaysElapsed = data.missionStartDate ? Math.floor((new Date(todayISO()) - new Date(data.missionStartDate)) / 86400000) + 1 : 0;
const missionDaysLeft = data.missionDays ? Math.max(0, data.missionDays - missionDaysElapsed) : 0;
const expectedSessions = data.missionDays ? Math.round((data.profile.daysPerWeek || 3) * (data.missionDays / 7)) : 0;
const missionProgressPct = expectedSessions ? Math.min(100, Math.round(((data.sessionHistory || []).length / expectedSessions) * 100)) : 0;
return (
<div>
<div style={styles.tabBar}>
<button style={{ ...styles.tabBtn, ...(tab === "treino" ? styles.tabBtnActive : {}) }} onClick={() => { setTab("treino"); setView("week"); }}>Treino</button>
<button style={{ ...styles.tabBtn, ...(tab === "dashboard" ? styles.tabBtnActive : {}) }} onClick={() => { setTab("dashboard"); setView("dashboard"); }}>Dashboard</button>
<button style={{ ...styles.tabBtn, ...(tab === "config" ? styles.tabBtnActive : {}) }} onClick={() => { setTab("config"); setView("config"); }}>Config</button>
</div>
{view === "week" && (
<div style={styles.container}>
<div style={styles.eyebrow}>{goalMeta?.label?.toUpperCase()}</div>
<h1 style={styles.h1}>Olá, {data.profile.name}</h1>
<div style={styles.chipsRow}>
<span style={styles.chip}>{data.gym?.label}</span>
<span style={{ ...styles.chip, color: intensityMeta?.color }}>{intensityMeta?.label}</span>
<span style={styles.chip}>Missão de {data.missionDays} dias</span>
</div>
{data.missionDays && (
<div style={{ marginBottom: 20 }}>
<div style={styles.pMutedSmall}>Dia {Math.min(missionDaysElapsed, data.missionDays)} de {data.missionDays} · {missionDaysLeft} dias restantes</div>
<div style={styles.barTrack}><div style={{ ...styles.barFill, width: `${missionProgressPct}%` }} /></div>
</div>
)}
<div style={styles.progressTrack}>
{data.weeklyPlan.map((_, i) => (<div key={i} style={{ ...styles.progressDot, background: data.completedDays[i] ? "#D4FF3D" : "#2A2C24" }} />))}
</div>
<div style={styles.dayList}>
{data.weeklyPlan.map((d, i) => {
const isDone = data.completedDays[i];
const isNext = i === data.currentDayIndex && !isDone;
return (
<button key={i} style={{ ...styles.dayCard, borderColor: isNext ? "#D4FF3D" : "#23241D" }} onClick={() => openDay(i)}>
<div style={styles.dayLeft}>
<span style={{ fontSize: 18 }}>{isDone ? "✅" : "⚪"}</span>
<div><div style={styles.dayNumber}>DIA {i + 1}{isNext ? " · CONTINUAR" : ""}</div><div style={styles.dayTitle}>{d.title}</div><div style={styles.dayCount}>{d.exercises.length} exercícios</div></div>
</div>
</button>
);
})}
</div>
<button style={styles.resetBtn} onClick={resetWeek}>Reiniciar semana</button>
</div>
)}
{view === "day" && activeDay !== null && (
<div style={styles.container}>
<button style={styles.backBtn} onClick={() => setView("week")}>← Voltar</button>
<div style={styles.eyebrow}>DIA {activeDay + 1} DE {data.weeklyPlan.length} · {intensityMeta?.label?.toUpperCase()}</div>
<h1 style={styles.h1}>{data.weeklyPlan[activeDay].title}</h1>
<p style={styles.pMuted}>{intensityMeta?.rest}.</p>
<div style={styles.exList}>
{data.weeklyPlan[activeDay].exercises.map((ex, i) => (
<div key={i} style={{ ...styles.exRowStatic, opacity: checkedEx[i] ? 0.55 : 1 }}>
<button style={styles.exCheck} onClick={() => setCheckedEx((p) => ({ ...p, [i]: !p[i] }))}>{checkedEx[i] ? "✅" : "⚪"}</button>
<div style={{ flex: 1 }}><div style={{ ...styles.exName, textDecoration: checkedEx[i] ? "line-through" : "none" }}>{ex.name}</div><div style={styles.exMeta}>{ex.sets} séries × {ex.reps}</div></div>
<a href={youtubeSearchUrl(ex.name)} target="_blank" rel="noopener noreferrer" style={styles.videoBtn}>▶️</a>
</div>
))}
</div>
<button style={styles.completeBtn} onClick={completeDay}>Concluir treino do dia</button>
</div>
)}
{view === "dashboard" && (
<DashboardView data={data} metricForm={metricForm} setMetricForm={setMetricForm} onAddMetric={addMetric} missionDaysElapsed={missionDaysElapsed} missionDaysLeft={missionDaysLeft} missionProgressPct={missionProgressPct} />
)}
{view === "config" && <ConfigView data={data} persist={persist} storageMode={storageMode} onRestart={onRestart} />}
</div>
);
}
function DashboardView({ data, metricForm, setMetricForm, onAddMetric, missionDaysElapsed, missionDaysLeft, missionProgressPct }) {
const sessions = data.sessionHistory || [];
const metrics = data.metrics || [];
const firstMetric = metrics[0], lastMetric = metrics[metrics.length - 1];
const weightDelta = firstMetric && lastMetric && firstMetric.weight != null && lastMetric.weight != null ? +(lastMetric.weight - firstMetric.weight).toFixed(1) : null;
const waistDelta = firstMetric && lastMetric && firstMetric.waist != null && lastMetric.waist != null ? +(lastMetric.waist - firstMetric.waist).toFixed(1) : null;
return (
<div style={styles.container}>
<div style={styles.eyebrow}>DESEMPENHO</div>
<h1 style={styles.h1}>Dashboard</h1>
<div style={styles.statsGrid}>
<div style={styles.statCard}><div style={styles.statValue}>{sessions.length}</div><div style={styles.statLabel}>treinos concluídos</div></div>
<div style={styles.statCard}><div style={styles.statValue}>{missionProgressPct}%</div><div style={styles.statLabel}>progresso da missão</div></div>
<div style={styles.statCard}><div style={{ ...styles.statValue, color: weightDelta == null ? "#F2F2ED" : weightDelta <= 0 ? "#D4FF3D" : "#FF3B30" }}>{weightDelta == null ? "—" : `${weightDelta > 0 ? "+" : ""}${weightDelta}`}</div><div style={styles.statLabel}>variação de peso</div></div>
<div style={styles.statCard}><div style={{ ...styles.statValue, color: waistDelta == null ? "#F2F2ED" : waistDelta <= 0 ? "#D4FF3D" : "#FF3B30" }}>{waistDelta == null ? "—" : `${waistDelta > 0 ? "+" : ""}${waistDelta}`}</div><div style={styles.statLabel}>variação de cintura</div></div>
</div>
<div style={{ ...styles.eyebrow, marginTop: 24 }}>REGISTRAR MEDIDAS DE HOJE</div>
<div style={styles.metricForm}>
<input style={styles.input} placeholder="Peso" inputMode="decimal" value={metricForm.weight} onChange={(e) => setMetricForm((p) => ({ ...p, weight: e.target.value }))} />
<input style={styles.input} placeholder="Cintura" inputMode="decimal" value={metricForm.waist} onChange={(e) => setMetricForm((p) => ({ ...p, waist: e.target.value }))} />
<button style={styles.addMetricBtn} onClick={onAddMetric}>+</button>
</div>
{sessions.length > 0 && (
<>
<div style={{ ...styles.eyebrow, marginTop: 22 }}>ÚLTIMAS SESSÕES</div>
<div style={styles.historyList}>{sessions.slice(0, 8).map((s, i) => (<div key={i} style={styles.historyRow}><span>{s.title}</span><span style={styles.historyDate}>{s.date}</span></div>))}</div>
</>
)}
</div>
);
}
function ConfigView({ data, persist, storageMode, onRestart }) {
return (
<div style={styles.container}>
<div style={styles.eyebrow}>CONFIGURAÇÕES</div>
<h1 style={styles.h1}>Perfil e dados</h1>
<div style={styles.settingsCard}>
<div style={styles.dayTitle}>{data.profile.name}</div>
<div style={styles.gymNote}>{data.profile.age} anos · {data.profile.heightCm}cm · {data.profile.weightKg}kg{data.profile.bmi ? ` · IMC ${data.profile.bmi.value} (${data.profile.bmi.category})` : ""}</div>
<div style={{ ...styles.gymNote, marginTop: 6 }}>Academia: {data.gym?.label} · {data.profile.daysPerWeek}x/semana</div>
</div>
<div style={{ ...styles.eyebrow, marginTop: 22 }}>ARMAZENAMENTO</div>
<div style={styles.infoBox}>
Modo atual: <b>{storageMode === "local" ? "arquivo local" : storageMode === "local-fallback" ? "upload/download manual" : storageMode === "drive" ? "Google Drive" : "—"}</b>.
Toda vez que você entrar de novo, vai precisar reconectar essa fonte de dados.
</div>
<button style={{ ...styles.ghostBtnBlock, marginTop: 10 }} onClick={onRestart}>Trocar arquivo / fonte de dados</button>
<button style={{ ...styles.ghostBtnBlock, marginTop: 10 }} onClick={() => persist({ ...data, weeklyPlan: [], profile: { ...data.profile, name: "" } })}>
Refazer todo o onboarding
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// ESTILOS
// ---------------------------------------------------------------------------
const styles = {
shell: { minHeight: "100vh", background: "#0E0F0C", color: "#F2F2ED", fontFamily: "Inter, sans-serif", paddingBottom: 40 },
loadingWrap: { display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100vh" },
spinner: { width: 36, height: 36, border: "3px solid #23241D", borderTopColor: "#D4FF3D", borderRadius: "50%", animation: "spin 0.8s linear infinite" },
topbar: { display: "flex", justifyContent: "space-between", alignItems: "center", padding: "18px 20px", borderBottom: "1px solid #1D1E17" },
brand: { display: "flex", alignItems: "center", gap: 10 },
brandDotWrap: { fontSize: 18 },
brandText: { fontFamily: "'Archivo Black', sans-serif", fontSize: 18, letterSpacing: 1 },
ghostBtn: { background: "transparent", border: "1px solid #2A2C24", color: "#B9BBAE", padding: "8px 14px", borderRadius: 999, fontSize: 13, fontWeight: 600, textDecoration: "none" },
ghostBtnBlock: { width: "100%", background: "transparent", border: "1px solid #2A2C24", color: "#B9BBAE", padding: "12px", borderRadius: 12, fontSize: 13, fontWeight: 700 },
tabBar: { display: "flex", gap: 8, padding: "12px 20px 0", maxWidth: 640, margin: "0 auto" },
tabBtn: { flex: 1, background: "#16170F", border: "1px solid #23241D", color: "#8A8C82", padding: "10px", borderRadius: 12, fontSize: 13, fontWeight: 700 },
tabBtnActive: { color: "#0E0F0C", background: "#D4FF3D", border: "1px solid #D4FF3D" },
container: { maxWidth: 640, margin: "0 auto", padding: "28px 20px" },
eyebrow: { fontSize: 12, fontWeight: 700, letterSpacing: 1.5, color: "#8A8C82", marginBottom: 10 },
h1: { fontFamily: "'Archivo Black', sans-serif", fontSize: 28, lineHeight: 1.15, margin: "0 0 10px 0" },
pMuted: { color: "#9A9C90", fontSize: 14.5, lineHeight: 1.6, marginBottom: 20 },
pMutedSmall: { color: "#787A6E", fontSize: 12, marginTop: 6, marginBottom: 6 },
label: { fontSize: 12, fontWeight: 700, color: "#8A8C82", letterSpacing: 0.5, display: "block", marginBottom: 6, marginTop: 14 },
input: { width: "100%", background: "#16170F", border: "1px solid #23241D", borderRadius: 12, padding: "12px 14px", color: "#F2F2ED", fontSize: 14 },
rowGrid: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 },
bmiBox: { background: "#16170F", border: "1px solid #23241D", borderRadius: 14, padding: 16, marginTop: 18, textAlign: "center" },
chipGrid: { display: "flex", flexWrap: "wrap", gap: 10, marginBottom: 22 },
chipOption: { background: "#16170F", border: "1px solid #23241D", color: "#B9BBAE", borderRadius: 12, padding: "12px 18px", fontSize: 15, fontWeight: 700, minWidth: 50 },
chipOptionActive: { background: "#D4FF3D22", border: "1px solid #D4FF3D", color: "#D4FF3D" },
chipsRow: { display: "flex", gap: 8, marginBottom: 14, flexWrap: "wrap" },
chip: { fontSize: 12, fontWeight: 700, background: "#16170F", border: "1px solid #23241D", padding: "5px 10px", borderRadius: 999, color: "#B9BBAE" },
goalGrid: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, marginBottom: 22 },
goalCard: { background: "#16170F", border: "1px solid", borderRadius: 16, padding: 16, display: "flex", flexDirection: "column", alignItems: "flex-start", textAlign: "left" },
goalTitle: { fontWeight: 800, fontSize: 14, marginTop: 8 },
gymList: { display: "flex", flexDirection: "column", gap: 10, marginBottom: 16 },
gymCard: { background: "#16170F", border: "1px solid", borderRadius: 14, padding: "14px 16px", display: "flex", alignItems: "center", gap: 14, textAlign: "left" },
gymNote: { fontSize: 12.5, color: "#8A8C82", marginTop: 3, lineHeight: 1.4 },
bigOptionBtn: { width: "100%", background: "#16170F", border: "1px solid #23241D", borderRadius: 14, padding: "14px 16px", display: "flex", alignItems: "center", gap: 14, textAlign: "left", marginBottom: 10 },
intensityCard: { background: "#16170F", border: "1px solid", borderRadius: 14, padding: "16px", textAlign: "left" },
progressTrack: { display: "flex", gap: 6, marginBottom: 20 },
progressDot: { flex: 1, height: 6, borderRadius: 4 },
dayList: { display: "flex", flexDirection: "column", gap: 10 },
dayCard: { background: "#16170F", border: "1px solid", borderRadius: 14, padding: "14px 16px" },
dayLeft: { display: "flex", alignItems: "center", gap: 14, textAlign: "left" },
dayNumber: { fontSize: 11, fontWeight: 700, color: "#8A8C82", letterSpacing: 1 },
dayTitle: { fontSize: 16, fontWeight: 700, margin: "2px 0" },
dayCount: { fontSize: 12.5, color: "#787A6E" },
resetBtn: { marginTop: 20, background: "transparent", border: "none", color: "#6E7064", fontSize: 13, fontWeight: 600, padding: 0 },
backBtn: { background: "transparent", border: "none", color: "#8A8C82", fontSize: 13.5, fontWeight: 600, padding: 0, marginBottom: 18 },
exList: { display: "flex", flexDirection: "column", gap: 8, marginBottom: 20 },
exRow: { display: "flex", alignItems: "center", gap: 10, background: "#16170F", border: "1px solid", borderRadius: 12, padding: "10px 12px", textAlign: "left" },
exRowStatic: { display: "flex", alignItems: "center", gap: 12, background: "#16170F", border: "1px solid #23241D", borderRadius: 14, padding: "12px 14px" },
exCheck: { background: "transparent", border: "none", padding: 0, fontSize: 18 },
exName: { fontSize: 14.5, fontWeight: 700 },
exMeta: { fontSize: 12, color: "#8A8C82", marginTop: 2 },
videoBtn: { textDecoration: "none", fontSize: 18 },
completeBtn: { width: "100%", background: "#D4FF3D", color: "#0E0F0C", border: "none", borderRadius: 999, padding: "16px", fontSize: 15, fontWeight: 800, marginTop: 8 },
statsGrid: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 },
statCard: { background: "#16170F", border: "1px solid #23241D", borderRadius: 14, padding: "16px", textAlign: "center" },
statValue: { fontFamily: "'Archivo Black', sans-serif", fontSize: 24 },
statLabel: { fontSize: 11.5, color: "#8A8C82", marginTop: 4 },
barTrack: { height: 8, background: "#1D1E17", borderRadius: 4, overflow: "hidden", marginTop: 4 },
barFill: { height: "100%", background: "#D4FF3D", borderRadius: 4 },
metricForm: { display: "flex", gap: 8 },
addMetricBtn: { background: "#D4FF3D", color: "#0E0F0C", border: "none", borderRadius: 12, padding: "0 16px", fontWeight: 800 },
historyList: { display: "flex", flexDirection: "column", gap: 8 },
historyRow: { display: "flex", alignItems: "center", gap: 8, fontSize: 13.5, color: "#B9BBAE" },
historyDate: { marginLeft: "auto", color: "#6E7064", fontSize: 12 },
settingsCard: { background: "#16170F", border: "1px solid #23241D", borderRadius: 14, padding: "16px" },
equipGrid: { display: "flex", flexWrap: "wrap", gap: 8, marginTop: 10 },
equipChip: { display: "flex", alignItems: "center", gap: 6, background: "#1D1E17", border: "1px solid #2A2C24", color: "#B9BBAE", borderRadius: 999, padding: "7px 12px", fontSize: 12.5, fontWeight: 600 },
equipChipActive: { background: "#D4FF3D22", border: "1px solid #D4FF3D", color: "#D4FF3D" },
infoBox: { background: "#16170F", border: "1px solid #23241D", borderRadius: 14, padding: "14px 16px", fontSize: 12.5, color: "#9A9C90", lineHeight: 1.6, marginBottom: 14 },
errorBox: { background: "#2A1414", border: "1px solid #FF3B3055", color: "#FF7A6E", fontSize: 13, padding: "12px 14px", borderRadius: 10, marginTop: 10, lineHeight: 1.5 },
};
const styleSheet = document.createElement("style");
styleSheet.textContent = "@keyframes spin { to { transform: rotate(360deg); } }";
document.head.appendChild(styleSheet);
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
</script>
</body>
</html>