From 4797d0def708d570af7cac5a0c0244bbdf8cb3aa Mon Sep 17 00:00:00 2001 From: abd Date: Sat, 13 Dec 2025 21:40:03 +0200 Subject: [PATCH 01/36] Created a gym plan manager --- firebase.js | 4 +- src/app/gym/page.tsx | 893 +++++++++++++++++++++++++++++++++++++++++ src/app/models/Gym.ts | 148 +++++++ src/app/staticData.tsx | 6 +- 4 files changed, 1049 insertions(+), 2 deletions(-) create mode 100644 src/app/gym/page.tsx create mode 100644 src/app/models/Gym.ts diff --git a/firebase.js b/firebase.js index 20dd092..77a2549 100644 --- a/firebase.js +++ b/firebase.js @@ -1,4 +1,5 @@ import { initializeApp } from "firebase/app"; +import { getAuth } from "firebase/auth"; import { getFirestore } from "firebase/firestore"; const firebaseConfig = { @@ -19,6 +20,7 @@ const firebaseConfig = { const app = initializeApp(firebaseConfig); const db = getFirestore(app); +const auth = getAuth(app); -export { app, db }; +export { app, db, auth }; diff --git a/src/app/gym/page.tsx b/src/app/gym/page.tsx new file mode 100644 index 0000000..f05d902 --- /dev/null +++ b/src/app/gym/page.tsx @@ -0,0 +1,893 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { addDoc, collection, deleteDoc, doc, getDocs, orderBy, query, setDoc, where } from "firebase/firestore"; +import { onAuthStateChanged, signInWithEmailAndPassword, signOut, User } from "firebase/auth"; +import { auth, db } from "../../../firebase"; +import staticData from "../staticData"; +import Card from "../_layouts/card/card"; +import Basic from "../_layouts/texts/basic"; +import LinePulse from "../_layouts/pulse/line"; +import { + GymExercise, + GymLogEntry, + GymPlanDay, + GymSet, + logEntryFromFirestore, + logEntryToFirestore, + planDayFromFirestore, + planDayToFirestore, +} from "../models/Gym"; + +const generateId = () => (typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : Math.random().toString(36).slice(2, 10)); + +const todayISO = () => new Date().toISOString().slice(0, 10); +const lastMonthISO = () => { + const d = new Date(); + d.setMonth(d.getMonth() - 1); + return d.toISOString().slice(0, 10); +}; + +const formatTime = (ts?: number | null) => (ts ? new Date(ts).toLocaleString() : "-"); +const formatDuration = (start?: number | null, end?: number | null) => { + if (!start || !end) return "-"; + const mins = Math.max(0, Math.round((end - start) / 60000)); + return `${mins} min`; +}; + +const ensureSetCount = (count: number, existing: GymSet[]) => { + const safe = Math.max(0, count); + const next = [...existing]; + if (next.length < safe) { + while (next.length < safe) next.push({ weight: null, reps: null, completed: false }); + } else if (next.length > safe) { + next.length = safe; + } + return next; +}; + +const getExerciseStatus = (sets: GymSet[]) => { + const total = sets?.length ?? 0; + const filled = (sets ?? []).filter((s) => s && s.weight !== null && s.weight !== undefined).length; + if (total > 0 && filled === total) return { label: "Completed", color: "text-emerald-300" } as const; + if (filled > 0) return { label: "Partial", color: "text-orange-300" } as const; + return { label: "Unattempted", color: "text-rose-300" } as const; +}; + +const sanitizeLogForWrite = (draft: Omit) => ({ + ...draft, + dayId: draft.dayId ?? "", + dayTitle: draft.dayTitle ?? "", + date: draft.date ?? todayISO(), + note: draft.note ?? null, + startedAt: draft.startedAt ?? null, + completedAt: draft.completedAt ?? null, + status: draft.status ?? null, + exercises: (draft.exercises ?? []).filter(Boolean).map((ex) => { + const sets = Array.isArray(ex.sets) + ? ex.sets.map((s) => { + const hasWeight = s?.weight !== null && s?.weight !== undefined; + return { + weight: s?.weight ?? null, + reps: s?.reps ?? null, + note: s?.note ?? null, + completed: hasWeight ? true : null, + } as GymSet; + }) + : []; + const filled = sets.filter((s) => s.completed).length; + const completed = sets.length > 0 && filled === sets.length ? true : null; + return { + name: ex.name ?? "", + muscleGroup: ex.muscleGroup ?? null, + note: ex.note ?? null, + completed, + sets, + }; + }), +}); + +const gymCollections = staticData.firebaseConst.collections.gym; +type ViewTab = "plan" | "session" | "logs"; + +export default function GymPage() { + const [user, setUser] = useState(null); + const [authLoading, setAuthLoading] = useState(true); + const [authError, setAuthError] = useState(null); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + + const [activeView, setActiveView] = useState("plan"); + + const [plans, setPlans] = useState([]); + const [planDrafts, setPlanDrafts] = useState>({}); + const [plansLoading, setPlansLoading] = useState(false); + const [savingDayId, setSavingDayId] = useState(null); + const [newDayTitle, setNewDayTitle] = useState(""); + + const [logDayId, setLogDayId] = useState(""); + const [logDraft, setLogDraft] = useState | null>(null); + const [logSubmitting, setLogSubmitting] = useState(false); + + const [logsFeed, setLogsFeed] = useState([]); + const [logsFeedLoading, setLogsFeedLoading] = useState(false); + const [logFilterFrom, setLogFilterFrom] = useState(lastMonthISO()); + const [logFilterTo, setLogFilterTo] = useState(todayISO()); + const [logFilterDayId, setLogFilterDayId] = useState(""); + + useEffect(() => { + const unsub = onAuthStateChanged(auth, (u) => { + setUser(u); + setAuthLoading(false); + if (!u) { + setPlans([]); + setPlanDrafts({}); + setLogDayId(""); + setLogDraft(null); + setLogsFeed([]); + } + }); + return () => unsub(); + }, []); + + const loadPlans = async () => { + setPlansLoading(true); + try { + const snap = await getDocs(collection(db, gymCollections.plans)); + const items = snap.docs.map((d) => planDayFromFirestore(d.id, d.data())); + items.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.title.localeCompare(b.title)); + setPlans(items); + const drafts: Record = {}; + items.forEach((p) => (drafts[p.id] = { ...p })); + setPlanDrafts(drafts); + if (items.length && !logDayId) { + setLogDayId(items[0].id); + } + } finally { + setPlansLoading(false); + } + }; + + useEffect(() => { + if (user) { + loadPlans(); + } + }, [user]); + + useEffect(() => { + if (logDayId && user) { + const plan = plans.find((p) => p.id === logDayId); + if (plan) { + setLogDraft(makeLogDraft(plan)); + } + } + }, [logDayId, plans, user]); + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setAuthError(null); + try { + await signInWithEmailAndPassword(auth, email.trim(), password); + } catch (err: any) { + setAuthError(err?.message ?? "Login failed"); + } + }; + + const handleLogout = async () => { + await signOut(auth); + }; + + const handleAddDay = async () => { + if (!newDayTitle.trim()) return; + const payload: GymPlanDay = { + id: "temp", + title: newDayTitle.trim(), + note: "", + order: Date.now(), + exercises: [], + }; + await addDoc(collection(db, gymCollections.plans), planDayToFirestore(payload)); + setNewDayTitle(""); + loadPlans(); + }; + + const updateDraft = (dayId: string, updater: (draft: GymPlanDay) => GymPlanDay) => { + setPlanDrafts((prev) => ({ ...prev, [dayId]: updater(prev[dayId] ?? plans.find((p) => p.id === dayId)!) })); + }; + + const handleSaveDay = async (dayId: string) => { + const draft = planDrafts[dayId]; + if (!draft) return; + setSavingDayId(dayId); + try { + const cleaned: GymPlanDay = { + ...draft, + exercises: (draft.exercises ?? []).filter(Boolean).map((ex) => ({ + ...ex, + sets: Array.isArray(ex.sets) ? ex.sets.map((s) => ({ + weight: s?.weight ?? null, + reps: s?.reps ?? null, + note: s?.note ?? null, + completed: s?.completed ?? null, + })) : [], + })), + }; + await setDoc(doc(db, gymCollections.plans, dayId), planDayToFirestore(cleaned), { merge: true }); + await loadPlans(); + } finally { + setSavingDayId(null); + } + }; + + const handleDeleteDay = async (dayId: string) => { + await deleteDoc(doc(db, gymCollections.plans, dayId)); + if (logDayId === dayId) { + setLogDayId(""); + setLogDraft(null); + } + loadPlans(); + }; + + const makeLogDraft = (plan: GymPlanDay): Omit => ({ + dayId: plan.id, + dayTitle: plan.title, + date: todayISO(), + exercises: plan.exercises.map((ex) => ({ + name: ex.name, + muscleGroup: ex.muscleGroup, + note: "", + completed: null, + sets: ensureSetCount(ex.sets?.length && ex.sets.length > 0 ? ex.sets.length : 3, ex.sets || []).map(() => ({ weight: null, reps: null, note: null } as GymSet)), + })), + note: "", + startedAt: undefined, + completedAt: undefined, + status: undefined, + }); + + const updateLogDraft = (updater: (draft: Omit) => Omit) => { + setLogDraft((prev) => (prev ? updater(prev) : prev)); + }; + + const handleSubmitLog = async () => { + if (!logDraft) return; + setLogSubmitting(true); + try { + const now = Date.now(); + const payload = sanitizeLogForWrite({ + ...logDraft, + createdAt: now, + startedAt: logDraft.startedAt ?? now, + completedAt: logDraft.completedAt ?? now, + status: logDraft.status ?? "completed", + } as any); + await addDoc(collection(db, gymCollections.logs), logEntryToFirestore(payload as any)); + await loadLogsFeed(); + } finally { + setLogSubmitting(false); + } + }; + + const loadLogsFeed = async () => { + if (!user) return; + setLogsFeedLoading(true); + try { + const fromTs = new Date(logFilterFrom).getTime(); + const toTs = new Date(logFilterTo).getTime() + 24 * 60 * 60 * 1000 - 1; + const constraints: any[] = [ + where("createdAt", ">=", fromTs), + where("createdAt", "<=", toTs), + orderBy("createdAt", "desc"), + ]; + if (logFilterDayId) { + constraints.push(where("dayId", "==", logFilterDayId)); + } + const q = query(collection(db, gymCollections.logs), ...constraints); + const snap = await getDocs(q); + setLogsFeed(snap.docs.map((d) => logEntryFromFirestore(d.id, d.data()))); + } finally { + setLogsFeedLoading(false); + } + }; + + useEffect(() => { + if (user && activeView === "logs") { + loadLogsFeed(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user, activeView]); + + useEffect(() => { + if (user && activeView === "logs") { + loadLogsFeed(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [logFilterFrom, logFilterTo, logFilterDayId]); + + const selectedPlan = useMemo(() => plans.find((p) => p.id === logDayId), [plans, logDayId]); + const canEditExercises = !!logDraft?.startedAt; + + return ( +
+
+
+ + {user && ( + + )} +
+ + {!user && ( + }> +
+
+
+ + setEmail(e.target.value)} + type="email" + required + /> +
+
+ + setPassword(e.target.value)} + type="password" + required + /> +
+
+ {authError &&

{authError}

} + +
+
+ )} + + {user && ( +
+
+ {[{ id: "plan", label: "Plan" }, { id: "session", label: "Log Session" }, { id: "logs", label: "Logs" }].map((tab) => ( + + ))} +
+ + {activeView === "plan" && ( + }> +
+
+
+ + setNewDayTitle(e.target.value)} + /> +
+ +
+ + {plansLoading && } + + {!plansLoading && plans.length === 0 && ( +

No plan yet. Add your first day.

+ )} + +
+ {plans.map((plan) => { + const draft = planDrafts[plan.id] ?? plan; + return ( +
+
+
+ + updateDraft(plan.id, (d) => ({ ...d, title: e.target.value }))} + /> +
+
+ + +
+
+ +
+ +