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 (
);
}
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 (
);
}
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
))}
))}
);
}
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
{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 (
);
}
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 (
{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">
);
}