import { useState, useEffect, useRef, useMemo } from "react"; import { Dumbbell, Calendar, Scale, TrendingUp, Plus, X, Check, Play, Pause, RotateCcw, ChevronLeft, ChevronRight, Clock, Trash2, Settings, Flame, Target, Pencil } from "lucide-react"; import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ReferenceLine } from "recharts"; const STORAGE_KEY = "saadi-app-data"; const WEEKDAYS = ["S", "M", "T", "W", "T", "F", "S"]; const MONTHS = ["January","February","March","April","May","June","July","August","September","October","November","December"]; const pad2 = (n) => String(n).padStart(2, "0"); const toDateStr = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; const todayStr = () => toDateStr(new Date()); const parseDateStr = (s) => { const [y, m, d] = s.split("-").map(Number); return new Date(y, m - 1, d); }; const fmtShort = (s) => parseDateStr(s).toLocaleDateString("en-US", { month: "short", day: "numeric" }); const fmtLong = (s) => parseDateStr(s).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }); const epley1RM = (w, r) => (!w || !r) ? 0 : (r <= 1 ? Math.round(w) : Math.round(w * (1 + r / 30))); const uid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 7); function fmtElapsed(totalSec) { totalSec = Math.max(0, Math.floor(totalSec)); const h = Math.floor(totalSec / 3600); const m = Math.floor((totalSec % 3600) / 60); const s = totalSec % 60; if (h > 0) return `${h}:${pad2(m)}:${pad2(s)}`; return `${m}:${pad2(s)}`; } function playBeeps() { try { const Ctx = window.AudioContext || window.webkitAudioContext; const ctx = new Ctx(); [0, 0.5, 1].forEach((t) => { const o = ctx.createOscillator(); const g = ctx.createGain(); o.connect(g); g.connect(ctx.destination); o.frequency.value = 880; o.type = "sine"; g.gain.setValueAtTime(0.0001, ctx.currentTime + t); g.gain.exponentialRampToValueAtTime(0.35, ctx.currentTime + t + 0.01); g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + t + 0.35); o.start(ctx.currentTime + t); o.stop(ctx.currentTime + t + 0.4); }); } catch (e) {} } function defaultData() { return { workouts: [], templates: [], bodyweight: [], exerciseDefaults: {}, exerciseTargets: {}, settings: { targetWeight: null } }; } const SEED_TEMPLATES = [ { id: "seed-chest-shoulders-tri", name: "Chest + Shoulders + Triceps", exercises: [ { name: "Bench Press", target: "4x5-8" }, { name: "Incline Dumbbell Press", target: "3x8-10" }, { name: "Cable Fly", target: "3x12-15" }, { name: "Seated Dumbbell Shoulder Press", target: "3x8-10" }, { name: "Cable Lateral Raises", target: "4x12-20" }, { name: "Overhead Cable Extension", target: "4x10-15" }, { name: "Close-Grip Bench Press or Weighted Dips", target: "3x6-10" }, { name: "Rope Pushdowns", target: "3x12-15" } ]}, { id: "seed-back-biceps", name: "Back + Biceps", exercises: [ { name: "Pull-Ups or Lat Pulldowns", target: "4x8-12" }, { name: "Chest-Supported Row", target: "4x8-10" }, { name: "Single-Arm Cable Row", target: "3x10-12" }, { name: "Straight-Arm Pulldown", target: "3x12-15" }, { name: "EZ Bar Curl", target: "4x8-10" }, { name: "Incline Dumbbell Curl", target: "3x10-12" }, { name: "Hammer Curl", target: "3x10-12" }, { name: "Reverse Pec Deck", target: "3x15-20" } ]}, { id: "seed-arms-heavy", name: "Arms (Heavy)", exercises: [ { name: "Close-Grip Bench Press", target: "4x6-8" }, { name: "Skull Crushers", target: "4x8-10" }, { name: "Overhead Cable Extension", target: "3x10-12" }, { name: "EZ Bar Curl", target: "4x8-10" }, { name: "Incline Dumbbell Curl", target: "3x10-12" }, { name: "Hammer Curl", target: "3x10-12" } ]}, { id: "seed-legs", name: "Legs", exercises: [ { name: "45° Leg Press", target: "4x10-15" }, { name: "Romanian Deadlift", target: "4x8-10" }, { name: "Bulgarian Split Squat", target: "3x10 each leg" }, { name: "Leg Curl", target: "3x10-15" }, { name: "Leg Extension", target: "3x12-15" }, { name: "Standing or Seated Calf Raises", target: "4x12-20" } ]}, { id: "seed-arms-shoulders-core", name: "Arms + Shoulders + Core", exercises: [ { name: "Rope Pushdowns", target: "4x12-15" }, { name: "Single-Arm Cable Overhead Extension", target: "3x12-15" }, { name: "Preacher Curl", target: "4x10-12" }, { name: "Bayesian Cable Curl", target: "3x10-12" }, { name: "Cable Lateral Raises", target: "5x15-20" }, { name: "Rear Delt Fly", target: "4x15-20" }, { name: "Hanging Leg Raises", target: "3x10-15" }, { name: "Cable Crunches", target: "3x12-15" }, { name: "Plank", target: "3x45-60 sec" } ]} ]; function applySeedTemplates(d) { if (d.settings && d.settings.templatesSeededV1) return d; const existingIds = new Set(d.templates.map((t) => t.id)); const toAdd = SEED_TEMPLATES.filter((t) => !existingIds.has(t.id)); return { ...d, templates: [...d.templates, ...toAdd], settings: { ...d.settings, templatesSeededV1: true } }; } const CSS = ` .app-page{ min-height:100dvh; background:#000; display:flex; justify-content:center; font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","Segoe UI",Roboto,sans-serif; } .app-root{ width:100%; max-width:430px; min-height:100dvh; background:#0b0b10; color:#f2f2f5; display:flex; flex-direction:column; position:relative; overflow:hidden; } .loading-screen{ align-items:center; justify-content:center; gap:10px; } .logo-mark{ width:52px; height:52px; border-radius:16px; background:linear-gradient(135deg,#ff8a3d,#ff3b5c); display:flex; align-items:center; justify-content:center; margin-bottom:6px; flex-shrink:0; } .logo-mark.small{ width:26px; height:26px; border-radius:8px; margin:0 8px 0 0; } .loading-title{ font-size:26px; font-weight:800; letter-spacing:2px; } .loading-sub{ color:#8a8a93; font-size:13px; } .topbar{ display:flex; align-items:center; justify-content:space-between; padding:calc(16px + env(safe-area-inset-top)) 18px 8px; } .brand{ display:flex; align-items:center; font-weight:800; font-size:18px; letter-spacing:1px; } .scroll-area{ flex:1; overflow-y:auto; padding:6px 16px 112px; -webkit-overflow-scrolling:touch; } .tab-title{ font-size:24px; font-weight:800; margin:6px 0 14px; } .bottom-nav{ position:absolute; bottom:0; left:0; right:0; display:flex; background:rgba(18,18,24,0.92); backdrop-filter:blur(14px); border-top:1px solid #20202a; padding:8px 4px calc(10px + env(safe-area-inset-bottom)); } .nav-btn{ flex:1; display:flex; flex-direction:column; align-items:center; gap:3px; background:none; border:none; color:#74747f; padding:6px 0; font-size:11px; font-weight:600; } .nav-btn.active{ color:#ff7a45; } .fab-timer{ position:absolute; right:16px; bottom:84px; z-index:5; width:54px; height:54px; border-radius:50%; background:linear-gradient(135deg,#ff8a3d,#ff3b5c); border:none; color:#fff; display:flex; align-items:center; justify-content:center; box-shadow:0 6px 18px rgba(255,80,50,0.35); font-size:12px; font-weight:700; } .fab-timer.active{ width:auto; padding:0 14px; border-radius:27px; } .card{ background:#15151b; border:1px solid #212129; border-radius:16px; padding:14px; margin-bottom:12px; } .stat-row{ display:flex; gap:10px; } .stat-box{ flex:1; background:#15151b; border:1px solid #212129; border-radius:14px; padding:12px 6px; text-align:center; } .stat-num{ font-size:18px; font-weight:800; } .streak-val{ display:inline-flex; align-items:center; gap:3px; } .stat-label{ font-size:10.5px; color:#85858f; margin-top:3px; text-transform:uppercase; letter-spacing:0.4px; } .btn-primary{ background:linear-gradient(90deg,#ff7a45,#ff3b5c); color:#fff; border:none; border-radius:14px; font-weight:700; font-size:15px; padding:13px 18px; display:flex; align-items:center; justify-content:center; gap:6px; } .btn-primary.full{ width:100%; } .btn-primary.big{ padding:16px; font-size:16px; } .btn-primary.small{ padding:10px 13px; border-radius:12px; } .btn-primary.tiny{ padding:7px 12px; font-size:13px; border-radius:10px; } .btn-primary.round{ width:56px; height:56px; border-radius:50%; padding:0; } .btn-ghost{ background:#1c1c24; border:1px solid #2a2a35; color:#f2f2f5; border-radius:12px; padding:11px; font-weight:600; font-size:13.5px; display:flex; align-items:center; justify-content:center; gap:6px; width:100%; } .btn-ghost.full{ margin-top:6px; } .btn-ghost.danger{ color:#ff6b6b; border-color:#3a2228; background:#1c1418; } .link-btn{ background:none; border:none; color:#ff7a45; font-weight:600; font-size:13px; } .link-btn.danger{ color:#ff6b6b; } .link-btn.danger.confirm{ text-decoration:underline; } .icon-btn{ background:none; border:none; color:#cfcfd6; padding:4px; display:flex; cursor:pointer; } .icon-btn.ghost{ color:#85858f; } .icon-btn-lg{ background:#1c1c24; border:1px solid #2a2a35; border-radius:50%; width:46px; height:46px; display:flex; align-items:center; justify-content:center; color:#cfcfd6; } .section-row{ display:flex; align-items:center; justify-content:space-between; margin:4px 0 8px; } .section-title{ font-size:14px; font-weight:700; color:#cfcfd6; text-transform:uppercase; letter-spacing:0.5px; } .empty-hint{ color:#73737d; font-size:13px; padding:8px 2px 16px; } .template-scroll{ display:flex; gap:10px; overflow-x:auto; padding-bottom:6px; } .template-chip{ flex:0 0 auto; min-width:168px; background:#15151b; border:1px solid #212129; border-radius:14px; padding:12px; } .template-chip-name{ font-weight:700; font-size:14px; } .template-chip-sub{ font-size:11.5px; color:#85858f; margin:3px 0 10px; } .template-chip-actions{ display:flex; align-items:center; gap:6px; } .history-card{ display:flex; align-items:center; justify-content:space-between; cursor:pointer; } .hc-name{ font-weight:700; font-size:14.5px; } .hc-date{ font-size:12px; color:#85858f; margin-top:2px; } .hc-meta{ display:flex; gap:10px; font-size:12px; color:#9a9aa5; align-items:center; } .active-head{ display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; gap:10px; } .workout-name-input{ background:none; border:none; color:#f2f2f5; font-size:21px; font-weight:800; padding:0; flex:1; min-width:0; } .ex-card-head{ display:flex; align-items:flex-start; justify-content:space-between; margin-bottom:8px; } .ex-name{ font-weight:700; font-size:15px; } .ex-target{ font-size:11.5px; color:#ff8a5a; margin-top:2px; } .set-row{ display:flex; align-items:center; gap:6px; padding:6px 0; border-top:1px solid #1d1d25; } .set-row:first-of-type{ border-top:none; } .set-idx{ width:18px; color:#73737d; font-size:12px; font-weight:700; } .set-input{ width:54px; background:#1c1c24; border:1px solid #2a2a35; border-radius:9px; color:#f2f2f5; padding:7px 6px; font-size:14px; text-align:center; } .set-unit{ font-size:11px; color:#73737d; } .set-x{ color:#73737d; } .set-display{ font-size:13px; color:#cfcfd6; padding:3px 0; } .add-ex-row{ display:flex; gap:8px; } .text-input{ width:100%; background:#1c1c24; border:1px solid #2a2a35; border-radius:11px; color:#f2f2f5; padding:11px 12px; font-size:14px; } .text-input.small{ width:64px; flex:none; } .text-input.target{ width:96px; flex:none; } .text-input.date{ flex:none; width:130px; } .field-label{ font-size:12px; font-weight:700; color:#9a9aa5; text-transform:uppercase; letter-spacing:0.4px; margin-bottom:7px; display:flex; align-items:center; gap:5px; } .field-label-row{ display:flex; align-items:center; justify-content:space-between; margin-bottom:4px; } .log-weight-row{ display:flex; gap:8px; } .big-stat{ font-size:22px; font-weight:800; } .template-row{ display:flex; gap:6px; margin-bottom:8px; align-items:center; } .sheet-backdrop{ position:absolute; inset:0; background:rgba(0,0,0,0.55); z-index:20; display:flex; align-items:flex-end; animation:fadeIn .15s ease-out; } .sheet-panel{ width:100%; max-height:82%; background:#13131a; border-radius:22px 22px 0 0; border:1px solid #232330; padding:10px 18px 24px; overflow-y:auto; animation:slideUp .22s ease-out; } .sheet-grip{ width:36px; height:4px; background:#33333d; border-radius:3px; margin:4px auto 10px; } .sheet-head{ display:flex; align-items:center; justify-content:space-between; font-weight:800; font-size:16px; margin-bottom:12px; } .sheet-subtitle{ font-size:13px; color:#9a9aa5; margin-bottom:10px; font-weight:600; } @keyframes slideUp{ from{ transform:translateY(30px); opacity:0; } to{ transform:translateY(0); opacity:1; } } @keyframes fadeIn{ from{ opacity:0; } to{ opacity:1; } } .cal-nav{ display:flex; align-items:center; justify-content:space-between; margin-bottom:8px; } .cal-month-label{ font-weight:700; font-size:15px; } .cal-weekdays{ display:grid; grid-template-columns:repeat(7,1fr); text-align:center; color:#73737d; font-size:11px; margin-bottom:4px; } .cal-grid{ display:grid; grid-template-columns:repeat(7,1fr); gap:4px; } .cal-cell{ position:relative; aspect-ratio:1; background:none; border:none; border-radius:10px; display:flex; flex-direction:column; align-items:center; justify-content:center; color:#e4e4e8; font-size:13px; cursor:pointer; } .cal-cell.empty{ visibility:hidden; } .cal-cell.today{ border:1.5px solid #ff7a45; } .cal-cell.selected{ background:#1c1c24; } .cal-check{ color:#ff7a45; margin-top:1px; } .cal-daynum{ line-height:1; } .chip-scroll{ display:flex; gap:8px; overflow-x:auto; padding-bottom:4px; } .chip{ flex:0 0 auto; background:#1c1c24; border:1px solid #2a2a35; color:#cfcfd6; border-radius:20px; padding:8px 14px; font-size:13px; font-weight:600; } .chip.active{ background:linear-gradient(90deg,#ff7a45,#ff3b5c); color:#fff; border:none; } .legend-row{ display:flex; gap:16px; margin-top:6px; font-size:12px; color:#9a9aa5; } .legend-row .dot{ display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:5px; } .legend-row .dot.orange{ background:#ff7a45; } .legend-row .dot.blue{ background:#5b8cff; } .timer-wrap{ display:flex; flex-direction:column; align-items:center; padding:8px 0; } .timer-circle{ width:180px; height:180px; border-radius:50%; display:flex; align-items:center; justify-content:center; margin-bottom:18px; } .timer-circle-inner{ width:152px; height:152px; border-radius:50%; background:#13131a; display:flex; align-items:center; justify-content:center; font-size:34px; font-weight:800; } .timer-adjust-row{ display:flex; gap:10px; margin-bottom:18px; } .timer-controls{ display:flex; align-items:center; gap:18px; } .settings-info{ color:#9a9aa5; font-size:13.5px; line-height:1.6; margin-bottom:18px; } .settings-info strong{ color:#f2f2f5; } input[type="date"]{ color-scheme: dark; } ::-webkit-scrollbar{ width:0; height:0; } button{ cursor:pointer; } `; function Sheet({ open, onClose, title, children }) { if (!open) return null; return (
e.stopPropagation()}>
{title}
{children}
); } function StatBox({ label, value, accent }) { return (
{value}
{label}
); } function TemplateEditor({ initial, onSave }) { const [name, setName] = useState(initial ? initial.name : ""); const [rows, setRows] = useState( initial && initial.exercises && initial.exercises.length ? initial.exercises.map((e) => ({ name: e.name, target: e.target || "" })) : [{ name: "", target: "" }] ); function updateRow(i, field, val) { setRows((r) => r.map((row, idx) => (idx !== i ? row : { ...row, [field]: val }))); } function addRow() { setRows((r) => [...r, { name: "", target: "" }]); } function removeRow(i) { setRows((r) => r.filter((_, idx) => idx !== i)); } function save() { const exercises = rows .filter((r) => r.name.trim()) .map((r) => ({ name: r.name.trim(), target: r.target.trim() })); if (!name.trim() || exercises.length === 0) return; onSave({ id: initial ? initial.id : uid(), name: name.trim(), exercises }); } return (
Template name setName(e.target.value)} /> Exercises {rows.map((row, i) => (
updateRow(i, "name", e.target.value)} /> updateRow(i, "target", e.target.value)} />
))}
); } function WorkoutTab({ data, setData, activeWorkout, setActiveWorkout, startRest }) { const [historySheet, setHistorySheet] = useState(null); const [showTemplateEditor, setShowTemplateEditor] = useState(false); const [editingTemplate, setEditingTemplate] = useState(null); const [exerciseInput, setExerciseInput] = useState(""); const [confirmDiscard, setConfirmDiscard] = useState(false); const exerciseHistoryNames = useMemo(() => { const s = new Set(Object.keys(data.exerciseDefaults)); data.workouts.forEach((w) => w.exercises.forEach((e) => s.add(e.name))); return Array.from(s).sort(); }, [data]); function startBlank() { setActiveWorkout({ id: uid(), date: todayStr(), name: "Workout", startedAt: Date.now(), exercises: [] }); } function startFromTemplate(t) { setActiveWorkout({ id: uid(), date: todayStr(), name: t.name, startedAt: Date.now(), exercises: t.exercises.map((e) => ({ id: uid(), name: e.name, target: e.target, sets: [] })) }); } function addExercise() { if (!exerciseInput.trim()) return; setActiveWorkout((w) => ({ ...w, exercises: [...w.exercises, { id: uid(), name: exerciseInput.trim(), sets: [] }] })); setExerciseInput(""); } function removeExercise(exId) { setActiveWorkout((w) => ({ ...w, exercises: w.exercises.filter((e) => e.id !== exId) })); } function addSet(exId) { setActiveWorkout((w) => { const exercises = w.exercises.map((ex) => { if (ex.id !== exId) return ex; const prev = ex.sets[ex.sets.length - 1]; const def = data.exerciseDefaults[ex.name]; const weight = prev ? prev.weight : (def ? def.weight : 0); const reps = prev ? prev.reps : (def ? def.reps : 0); return { ...ex, sets: [...ex.sets, { weight, reps }] }; }); return { ...w, exercises }; }); startRest(); } function updateSet(exId, idx, field, val) { setActiveWorkout((w) => ({ ...w, exercises: w.exercises.map((ex) => (ex.id !== exId ? ex : { ...ex, sets: ex.sets.map((s, i) => (i !== idx ? s : { ...s, [field]: val === "" ? "" : Number(val) })) })) })); } function removeSet(exId, idx) { setActiveWorkout((w) => ({ ...w, exercises: w.exercises.map((ex) => (ex.id !== exId ? ex : { ...ex, sets: ex.sets.filter((_, i) => i !== idx) })) })); } function finishWorkout() { const cleaned = activeWorkout.exercises .filter((e) => e.sets.length > 0) .map((e) => ({ ...e, sets: e.sets.map((s) => ({ weight: Number(s.weight) || 0, reps: Number(s.reps) || 0 })) })); if (cleaned.length === 0) { setActiveWorkout(null); return; } const finished = { ...activeWorkout, exercises: cleaned, durationMin: Math.max(1, Math.round((Date.now() - activeWorkout.startedAt) / 60000)) }; const newDefaults = { ...data.exerciseDefaults }; cleaned.forEach((ex) => { const last = ex.sets[ex.sets.length - 1]; if (last) newDefaults[ex.name] = { weight: last.weight, reps: last.reps }; }); setData((d) => ({ ...d, workouts: [finished, ...d.workouts], exerciseDefaults: newDefaults })); setActiveWorkout(null); } function deleteWorkout(id) { setData((d) => ({ ...d, workouts: d.workouts.filter((w) => w.id !== id) })); setHistorySheet(null); } function deleteTemplate(id) { setData((d) => ({ ...d, templates: d.templates.filter((t) => t.id !== id) })); } function openNewTemplate() { setEditingTemplate(null); setShowTemplateEditor(true); } function openEditTemplate(t) { setEditingTemplate(t); setShowTemplateEditor(true); } function saveTemplate(t) { setData((d) => { const exists = d.templates.some((x) => x.id === t.id); const templates = exists ? d.templates.map((x) => (x.id === t.id ? t : x)) : [...d.templates, t]; return { ...d, templates }; }); setShowTemplateEditor(false); setEditingTemplate(null); } const weekAgo = new Date(); weekAgo.setDate(weekAgo.getDate() - 6); const weekAgoStr = toDateStr(weekAgo); const thisWeek = data.workouts.filter((w) => w.date >= weekAgoStr).length; const total = data.workouts.length; const workoutDateSet = useMemo(() => new Set(data.workouts.map((w) => w.date)), [data.workouts]); let streak = 0; { let cur = new Date(); let cs = todayStr(); if (!workoutDateSet.has(cs)) { cur.setDate(cur.getDate() - 1); cs = toDateStr(cur); } while (workoutDateSet.has(cs)) { streak++; cur.setDate(cur.getDate() - 1); cs = toDateStr(cur); } } if (activeWorkout) { const elapsedSec = Math.floor((Date.now() - activeWorkout.startedAt) / 1000); const totalSets = activeWorkout.exercises.reduce((a, e) => a + e.sets.length, 0); return (
setActiveWorkout((w) => ({ ...w, name: e.target.value }))} /> {!confirmDiscard ? ( ) : ( )}
{activeWorkout.exercises.map((ex) => (
{ex.name}
{ex.target &&
Target: {ex.target}
}
{ex.sets.map((s, i) => (
{i + 1} updateSet(ex.id, i, "weight", e.target.value)} /> lbs × updateSet(ex.id, i, "reps", e.target.value)} /> reps
))}
))}
setExerciseInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") addExercise(); }} /> {exerciseHistoryNames.map((n) =>
); } return (

Workouts

{streak}} accent />
Templates
{data.templates.length === 0 ? (
No templates yet — build one to quick-start a routine.
) : (
{data.templates.map((t) => (
{t.name}
{t.exercises.length} exercises
))}
)}
Recent Workouts
{data.workouts.length === 0 ? (
Your workout history will show up here.
) : data.workouts.slice(0, 30).map((w) => (
setHistorySheet(w)}>
{w.name}
{fmtLong(w.date)}
{w.exercises.length} ex {w.exercises.reduce((a, e) => a + e.sets.length, 0)} sets {w.durationMin && {w.durationMin} min}
))} setHistorySheet(null)} title={historySheet ? fmtLong(historySheet.date) : ""}> {historySheet && (
{historySheet.name}
{historySheet.exercises.map((ex) => (
{ex.name}
{ex.sets.map((s, i) => (
Set {i + 1} — {s.weight} lbs × {s.reps}
))}
))}
)}
{ setShowTemplateEditor(false); setEditingTemplate(null); }} title={editingTemplate ? "Edit Template" : "New Template"}>
); } function CalendarTab({ data }) { const [view, setView] = useState(() => { const d = new Date(); return { y: d.getFullYear(), m: d.getMonth() }; }); const [selectedDay, setSelectedDay] = useState(null); const workoutsByDate = useMemo(() => { const map = {}; data.workouts.forEach((w) => { (map[w.date] = map[w.date] || []).push(w); }); return map; }, [data.workouts]); const firstDay = new Date(view.y, view.m, 1); const startWeekday = firstDay.getDay(); const daysInMonth = new Date(view.y, view.m + 1, 0).getDate(); const cells = []; for (let i = 0; i < startWeekday; i++) cells.push(null); for (let d = 1; d <= daysInMonth; d++) cells.push(d); function prevMonth() { setView((v) => (v.m === 0 ? { y: v.y - 1, m: 11 } : { y: v.y, m: v.m - 1 })); } function nextMonth() { setView((v) => (v.m === 11 ? { y: v.y + 1, m: 0 } : { y: v.y, m: v.m + 1 })); } function goToday() { const d = new Date(); setView({ y: d.getFullYear(), m: d.getMonth() }); setSelectedDay(todayStr()); } const today = todayStr(); const selectedWorkouts = selectedDay ? (workoutsByDate[selectedDay] || []) : []; return (

Calendar

{MONTHS[view.m]} {view.y}
{WEEKDAYS.map((w, i) => {w})}
{cells.map((d, i) => { if (d === null) return
; const ds = `${view.y}-${pad2(view.m + 1)}-${pad2(d)}`; const has = !!workoutsByDate[ds]; const isToday = ds === today; const isSelected = ds === selectedDay; return ( ); })}
{selectedDay && (
{fmtLong(selectedDay)}
{selectedWorkouts.length === 0 ? (
No workout logged this day.
) : selectedWorkouts.map((w) => (
{w.name}
{w.exercises.map((ex) => (
{ex.name} — {ex.sets.length} sets
))}
))}
)}
); } function BodyTab({ data, setData }) { const [weightInput, setWeightInput] = useState(""); const [dateInput, setDateInput] = useState(todayStr()); const [editingTarget, setEditingTarget] = useState(false); const [targetInput, setTargetInput] = useState(data.settings.targetWeight ?? ""); const sorted = useMemo(() => [...data.bodyweight].sort((a, b) => a.date.localeCompare(b.date)), [data.bodyweight]); const last = sorted.length ? sorted[sorted.length - 1].weight : null; const target = data.settings.targetWeight; function logWeight() { const w = parseFloat(weightInput); if (!w || w <= 0 || !dateInput) return; setData((d) => { const idx = d.bodyweight.findIndex((b) => b.date === dateInput); let bw; if (idx >= 0) { bw = [...d.bodyweight]; bw[idx] = { date: dateInput, weight: w }; } else bw = [...d.bodyweight, { date: dateInput, weight: w }]; return { ...d, bodyweight: bw }; }); setWeightInput(""); } function deleteEntry(date) { setData((d) => ({ ...d, bodyweight: d.bodyweight.filter((b) => b.date !== date) })); } function saveTarget() { const v = parseFloat(targetInput); setData((d) => ({ ...d, settings: { ...d.settings, targetWeight: v > 0 ? v : null } })); setEditingTarget(false); } const chartData = sorted.map((b) => ({ ...b, label: fmtShort(b.date) })); return (

Body Weight

Log Weight
setWeightInput(e.target.value)} /> setDateInput(e.target.value)} />
{sorted.length >= 2 && (
Trend {target && }
)}
Target Weight {!editingTarget && }
{editingTarget ? (
setTargetInput(e.target.value)} />
) : (
{target ? `${target} lbs` : "Not set"}
)}
History
{sorted.length === 0 ? (
Log your first weigh-in above.
) : [...sorted].reverse().map((b) => (
{fmtLong(b.date)}
{b.weight} lbs
))}
); } function StatsTab({ data, setData }) { const exerciseNames = useMemo(() => { const s = new Set(); data.workouts.forEach((w) => w.exercises.forEach((e) => { if (e.sets.length) s.add(e.name); })); return Array.from(s).sort(); }, [data.workouts]); const [selected, setSelected] = useState(exerciseNames[0] || ""); useEffect(() => { if (!selected && exerciseNames.length) setSelected(exerciseNames[0]); }, [exerciseNames, selected]); const [editingTarget, setEditingTarget] = useState(false); const [targetInput, setTargetInput] = useState(""); const series = useMemo(() => { if (!selected) return []; const pts = []; data.workouts.forEach((w) => { const ex = w.exercises.find((e) => e.name === selected); if (!ex || !ex.sets.length) return; let maxW = 0, best1 = 0; ex.sets.forEach((s) => { if (s.weight > maxW) maxW = s.weight; const e1 = epley1RM(s.weight, s.reps); if (e1 > best1) best1 = e1; }); pts.push({ date: w.date, label: fmtShort(w.date), maxWeight: maxW, est1rm: best1 }); }); return pts.sort((a, b) => a.date.localeCompare(b.date)); }, [data.workouts, selected]); const bestEver1rm = series.length ? Math.max(...series.map((p) => p.est1rm)) : 0; const bestEverMax = series.length ? Math.max(...series.map((p) => p.maxWeight)) : 0; const target = data.exerciseTargets[selected]; function saveTarget() { const v = parseFloat(targetInput); setData((d) => ({ ...d, exerciseTargets: { ...d.exerciseTargets, [selected]: v > 0 ? v : undefined } })); setEditingTarget(false); } return (

Exercise Stats

{exerciseNames.length === 0 ? (
Log a workout to see progress charts here.
) : ( <>
{exerciseNames.map((n) => ( ))}
{selected} — Trend {target && }
Max Weight Est. 1RM
Target Max Weight {!editingTarget && }
{editingTarget ? (
setTargetInput(e.target.value)} />
) : (
{target ? `${target} lbs` : "Not set"}
)}
)}
); } function RestTimerContent({ duration, remaining, running, onAdjust, onToggle, onReset }) { const pct = Math.max(0, Math.min(1, remaining / duration)); return (
{fmtElapsed(remaining)}
); } function FloatingTimerButton({ remaining, duration, running, onClick }) { const active = running || remaining !== duration; return ( ); } function SettingsContent({ onReset }) { const [confirm, setConfirm] = useState(false); return (

Saadi stores your workouts, templates, and body weight logs privately on this device.

Units: lbs

{!confirm ? ( ) : ( )}
); } export default function Saadi() { const [data, setData] = useState(null); const [loaded, setLoaded] = useState(false); const [tab, setTab] = useState("workout"); const [activeWorkout, setActiveWorkout] = useState(null); const [, setTick] = useState(0); const [settingsOpen, setSettingsOpen] = useState(false); const [timerSheetOpen, setTimerSheetOpen] = useState(false); const [restDuration, setRestDuration] = useState(90); const [restRemaining, setRestRemaining] = useState(90); const [restRunning, setRestRunning] = useState(false); const restRef = useRef(null); const saveRef = useRef(null); useEffect(() => { let cancelled = false; (async () => { try { const res = await window.storage.get(STORAGE_KEY, false); if (cancelled) return; if (res && res.value) { try { const parsed = JSON.parse(res.value); let merged = { ...defaultData(), ...parsed, exerciseDefaults: parsed.exerciseDefaults || {}, exerciseTargets: parsed.exerciseTargets || {}, settings: { ...defaultData().settings, ...(parsed.settings || {}) } }; merged = applySeedTemplates(merged); setData(merged); } catch { setData(applySeedTemplates(defaultData())); } } else { setData(applySeedTemplates(defaultData())); } } catch (e) { if (!cancelled) setData(applySeedTemplates(defaultData())); } finally { if (!cancelled) setLoaded(true); } })(); return () => { cancelled = true; }; }, []); useEffect(() => { if (!loaded || !data) return; if (saveRef.current) clearTimeout(saveRef.current); saveRef.current = setTimeout(() => { window.storage.set(STORAGE_KEY, JSON.stringify(data), false).catch(() => {}); }, 400); return () => { if (saveRef.current) clearTimeout(saveRef.current); }; }, [data, loaded]); useEffect(() => { if (!activeWorkout) return; const id = setInterval(() => setTick((t) => t + 1), 1000); return () => clearInterval(id); }, [activeWorkout]); useEffect(() => { if (restRunning) { restRef.current = setInterval(() => { setRestRemaining((r) => { if (r <= 1) { setRestRunning(false); playBeeps(); return 0; } return r - 1; }); }, 1000); } return () => clearInterval(restRef.current); }, [restRunning]); function startRest() { setRestRemaining(restDuration); setRestRunning(true); } function toggleRest() { if (restRemaining === 0) setRestRemaining(restDuration); setRestRunning((r) => !r); } function resetRest() { setRestRunning(false); setRestRemaining(restDuration); } function adjustRest(delta) { setRestDuration((d) => { const nd = Math.max(15, d + delta); setRestRemaining(nd); setRestRunning(false); return nd; }); } function resetAllData() { setData(defaultData()); setActiveWorkout(null); setSettingsOpen(false); } if (!loaded || !data) { return (
SAADI
Track. Measure. Progress.
); } const TABS = [ { key: "workout", label: "Workout", icon: Dumbbell }, { key: "calendar", label: "Calendar", icon: Calendar }, { key: "body", label: "Body", icon: Scale }, { key: "stats", label: "Stats", icon: TrendingUp } ]; return (
SAADI
{tab === "workout" && } {tab === "calendar" && } {tab === "body" && } {tab === "stats" && }
setTimerSheetOpen(true)} />
{TABS.map((t) => { const Icon = t.icon; return ( ); })}
setTimerSheetOpen(false)} title="Rest Timer"> setSettingsOpen(false)} title="Settings">
); }