Code maintainability fixes
This commit is contained in:
58
package-lock.json
generated
58
package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"playwright-test": "^14.1.12",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.10.1",
|
||||
"recharts": "^3.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -2043,6 +2044,19 @@
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -5414,6 +5428,44 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz",
|
||||
"integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz",
|
||||
"integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.10.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/read-pkg": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz",
|
||||
@@ -5760,6 +5812,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"playwright-test": "^14.1.12",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.10.1",
|
||||
"recharts": "^3.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
783
server/package-lock.json
generated
783
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@
|
||||
"dependencies": {
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@prisma/adapter-better-sqlite3": "^7.1.0",
|
||||
"@prisma/client": "^6.19.0",
|
||||
"@prisma/client": "^7.1.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"bcryptjs": "3.0.3",
|
||||
"better-sqlite3": "^12.5.0",
|
||||
@@ -28,7 +28,7 @@
|
||||
"@types/jsonwebtoken": "*",
|
||||
"@types/node": "*",
|
||||
"nodemon": "*",
|
||||
"prisma": "*",
|
||||
"prisma": "^7.1.0",
|
||||
"ts-node": "*",
|
||||
"typescript": "*"
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,29 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "PlanExercise" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"planId" TEXT NOT NULL,
|
||||
"exerciseId" TEXT NOT NULL,
|
||||
"order" INTEGER NOT NULL,
|
||||
"isWeighted" BOOLEAN NOT NULL DEFAULT false,
|
||||
CONSTRAINT "PlanExercise_planId_fkey" FOREIGN KEY ("planId") REFERENCES "WorkoutPlan" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "PlanExercise_exerciseId_fkey" FOREIGN KEY ("exerciseId") REFERENCES "Exercise" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_WorkoutPlan" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"exercises" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "WorkoutPlan_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_WorkoutPlan" ("createdAt", "description", "exercises", "id", "name", "updatedAt", "userId") SELECT "createdAt", "description", "exercises", "id", "name", "updatedAt", "userId" FROM "WorkoutPlan";
|
||||
DROP TABLE "WorkoutPlan";
|
||||
ALTER TABLE "new_WorkoutPlan" RENAME TO "WorkoutPlan";
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -7,7 +7,6 @@ generator client {
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -60,6 +59,7 @@ model Exercise {
|
||||
isUnilateral Boolean @default(false)
|
||||
|
||||
sets WorkoutSet[]
|
||||
planExercises PlanExercise[]
|
||||
}
|
||||
|
||||
model WorkoutSession {
|
||||
@@ -102,8 +102,19 @@ model WorkoutPlan {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
description String?
|
||||
exercises String // JSON string of exercise IDs
|
||||
exercises String? // JSON string of exercise IDs (Deprecated, to be removed)
|
||||
planExercises PlanExercise[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model PlanExercise {
|
||||
id String @id @default(uuid())
|
||||
planId String
|
||||
plan WorkoutPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||
exerciseId String
|
||||
exercise Exercise @relation(fields: [exerciseId], references: [id])
|
||||
order Int
|
||||
isWeighted Boolean @default(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,12 +25,26 @@ router.get('/', async (req: any, res) => {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const plans = await prisma.workoutPlan.findMany({
|
||||
where: { userId }
|
||||
where: { userId },
|
||||
include: {
|
||||
planExercises: {
|
||||
include: { exercise: true },
|
||||
orderBy: { order: 'asc' }
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
const mappedPlans = plans.map((p: any) => ({
|
||||
...p,
|
||||
steps: p.exercises ? JSON.parse(p.exercises) : []
|
||||
steps: p.planExercises.map((pe: any) => ({
|
||||
id: pe.id,
|
||||
exerciseId: pe.exerciseId,
|
||||
exerciseName: pe.exercise.name,
|
||||
exerciseType: pe.exercise.type,
|
||||
isWeighted: pe.isWeighted,
|
||||
// Add default properties if needed by PlannedSet interface
|
||||
}))
|
||||
}));
|
||||
|
||||
res.json(mappedPlans);
|
||||
@@ -46,28 +60,71 @@ router.post('/', async (req: any, res) => {
|
||||
const userId = req.user.userId;
|
||||
const { id, name, description, steps } = req.body;
|
||||
|
||||
const exercisesJson = JSON.stringify(steps || []);
|
||||
// Steps array contains PlannedSet items
|
||||
// We need to transact: create/update plan, then replace exercises
|
||||
|
||||
const existing = await prisma.workoutPlan.findUnique({ where: { id } });
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Upsert plan
|
||||
let plan = await tx.workoutPlan.findUnique({ where: { id } });
|
||||
|
||||
if (existing) {
|
||||
const updated = await prisma.workoutPlan.update({
|
||||
if (plan) {
|
||||
await tx.workoutPlan.update({
|
||||
where: { id },
|
||||
data: { name, description, exercises: exercisesJson }
|
||||
data: { name, description }
|
||||
});
|
||||
res.json({ ...updated, steps: steps || [] });
|
||||
// Delete existing plan exercises
|
||||
await tx.planExercise.deleteMany({ where: { planId: id } });
|
||||
} else {
|
||||
const created = await prisma.workoutPlan.create({
|
||||
await tx.workoutPlan.create({
|
||||
data: {
|
||||
id,
|
||||
userId,
|
||||
name,
|
||||
description,
|
||||
exercises: exercisesJson
|
||||
description
|
||||
}
|
||||
});
|
||||
res.json({ ...created, steps: steps || [] });
|
||||
}
|
||||
|
||||
// Create new plan exercises
|
||||
if (steps && steps.length > 0) {
|
||||
await tx.planExercise.createMany({
|
||||
data: steps.map((step: any, index: number) => ({
|
||||
planId: id,
|
||||
exerciseId: step.exerciseId,
|
||||
order: index,
|
||||
isWeighted: step.isWeighted || false
|
||||
}))
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Return the updated plan structure
|
||||
// Since we just saved it, we can mirror back what was sent or re-fetch.
|
||||
// Re-fetching ensures DB state consistency.
|
||||
const savedPlan = await prisma.workoutPlan.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
planExercises: {
|
||||
include: { exercise: true },
|
||||
orderBy: { order: 'asc' }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!savedPlan) throw new Error("Plan failed to save");
|
||||
|
||||
const mappedPlan = {
|
||||
...savedPlan,
|
||||
steps: savedPlan.planExercises.map((pe: any) => ({
|
||||
id: pe.id,
|
||||
exerciseId: pe.exerciseId,
|
||||
exerciseName: pe.exercise.name,
|
||||
exerciseType: pe.exercise.type,
|
||||
isWeighted: pe.isWeighted
|
||||
}))
|
||||
};
|
||||
|
||||
res.json(mappedPlan);
|
||||
} catch (error) {
|
||||
console.error('Error saving plan:', error);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
|
||||
42
server/src/scripts/migratePlans.ts
Normal file
42
server/src/scripts/migratePlans.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function migrate() {
|
||||
console.log('Starting migration...');
|
||||
const plans = await prisma.workoutPlan.findMany();
|
||||
console.log(`Found ${plans.length} plans.`);
|
||||
|
||||
for (const plan of plans) {
|
||||
if (plan.exercises) {
|
||||
try {
|
||||
const steps = JSON.parse(plan.exercises);
|
||||
console.log(`Migrating plan ${plan.name} (${plan.id}) with ${steps.length} steps.`);
|
||||
|
||||
let order = 0;
|
||||
for (const step of steps) {
|
||||
await prisma.planExercise.create({
|
||||
data: {
|
||||
planId: plan.id,
|
||||
exerciseId: step.exerciseId,
|
||||
order: order++,
|
||||
isWeighted: step.isWeighted || false
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error parsing JSON for plan ${plan.id}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('Migration complete.');
|
||||
}
|
||||
|
||||
migrate()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
274
src/App.tsx
274
src/App.tsx
@@ -1,5 +1,5 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
|
||||
import Navbar from './components/Navbar';
|
||||
import Tracker from './components/Tracker/index';
|
||||
import History from './components/History';
|
||||
@@ -8,250 +8,112 @@ import AICoach from './components/AICoach';
|
||||
import Plans from './components/Plans';
|
||||
import Login from './components/Login';
|
||||
import Profile from './components/Profile';
|
||||
import { TabView, WorkoutSession, WorkoutSet, WorkoutPlan, User, Language } from './types';
|
||||
import { getSessions, saveSession, deleteSession, getPlans, getActiveSession, updateActiveSession, deleteActiveSession, updateSetInActiveSession, deleteSetFromActiveSession } from './services/storage';
|
||||
import { getCurrentUserProfile, getMe } from './services/auth';
|
||||
import { Language, User } from './types'; // Removed unused imports
|
||||
import { getSystemLanguage } from './services/i18n';
|
||||
import { logWeight } from './services/weight';
|
||||
import { generateId } from './utils/uuid';
|
||||
import { useAuth } from './context/AuthContext';
|
||||
import { useData } from './context/DataContext';
|
||||
|
||||
function App() {
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [currentTab, setCurrentTab] = useState<TabView>('TRACK');
|
||||
const { currentUser, updateUser, logout } = useAuth();
|
||||
const {
|
||||
sessions,
|
||||
plans,
|
||||
activeSession,
|
||||
activePlan,
|
||||
startSession,
|
||||
endSession,
|
||||
quitSession,
|
||||
addSet,
|
||||
removeSet,
|
||||
updateSet,
|
||||
updateSession,
|
||||
deleteSessionById
|
||||
} = useData();
|
||||
|
||||
const [language, setLanguage] = useState<Language>('en');
|
||||
|
||||
const [sessions, setSessions] = useState<WorkoutSession[]>([]);
|
||||
const [plans, setPlans] = useState<WorkoutPlan[]>([]);
|
||||
const [activeSession, setActiveSession] = useState<WorkoutSession | null>(null);
|
||||
const [activePlan, setActivePlan] = useState<WorkoutPlan | null>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
// Set initial language
|
||||
setLanguage(getSystemLanguage());
|
||||
|
||||
// Restore session
|
||||
const restoreSession = async () => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
const res = await getMe();
|
||||
if (res.success && res.user) {
|
||||
setCurrentUser(res.user);
|
||||
|
||||
// Restore active workout session from database
|
||||
const activeSession = await getActiveSession(res.user.id);
|
||||
if (activeSession) {
|
||||
setActiveSession(activeSession);
|
||||
// Restore plan if session has planId
|
||||
if (activeSession.planId) {
|
||||
const plans = await getPlans(res.user.id);
|
||||
const plan = plans.find(p => p.id === activeSession.planId);
|
||||
if (plan) {
|
||||
setActivePlan(plan);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
}
|
||||
};
|
||||
restoreSession();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSessions = async () => {
|
||||
if (currentUser) {
|
||||
const s = await getSessions(currentUser.id);
|
||||
setSessions(s);
|
||||
// Load plans
|
||||
const p = await getPlans(currentUser.id);
|
||||
setPlans(p);
|
||||
|
||||
} else {
|
||||
setSessions([]);
|
||||
setPlans([]);
|
||||
|
||||
}
|
||||
};
|
||||
loadSessions();
|
||||
}, [currentUser]);
|
||||
|
||||
const handleLogin = (user: User) => {
|
||||
setCurrentUser(user);
|
||||
setCurrentTab('TRACK');
|
||||
updateUser(user);
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
setCurrentUser(null);
|
||||
setActiveSession(null);
|
||||
setActivePlan(null);
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const handleLanguageChange = (lang: Language) => {
|
||||
setLanguage(lang);
|
||||
};
|
||||
|
||||
const handleUserUpdate = (updatedUser: User) => {
|
||||
setCurrentUser(updatedUser);
|
||||
};
|
||||
|
||||
const handleStartSession = async (plan?: WorkoutPlan, startWeight?: number) => {
|
||||
if (!currentUser) return;
|
||||
if (activeSession) return;
|
||||
|
||||
// Get latest weight from profile or default
|
||||
const profile = getCurrentUserProfile(currentUser.id);
|
||||
// Use provided startWeight, or profile weight, or default 70
|
||||
const currentWeight = startWeight || profile?.weight || 70;
|
||||
|
||||
const newSession: WorkoutSession = {
|
||||
id: generateId(),
|
||||
startTime: Date.now(),
|
||||
type: 'STANDARD',
|
||||
userBodyWeight: currentWeight,
|
||||
sets: [],
|
||||
planId: plan?.id,
|
||||
planName: plan?.name
|
||||
};
|
||||
setActivePlan(plan || null);
|
||||
setActiveSession(newSession);
|
||||
setCurrentTab('TRACK');
|
||||
|
||||
// Save to database immediately
|
||||
await saveSession(currentUser.id, newSession);
|
||||
|
||||
// If startWeight was provided (meaning user explicitly entered it), log it to weight history
|
||||
if (startWeight) {
|
||||
await logWeight(startWeight);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndSession = async () => {
|
||||
if (activeSession && currentUser) {
|
||||
const finishedSession = { ...activeSession, endTime: Date.now() };
|
||||
await updateActiveSession(currentUser.id, finishedSession);
|
||||
setSessions(prev => [finishedSession, ...prev]);
|
||||
setActiveSession(null);
|
||||
setActivePlan(null);
|
||||
|
||||
// Refetch user to get updated weight
|
||||
const res = await getMe();
|
||||
if (res.success && res.user) {
|
||||
setCurrentUser(res.user);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSet = (set: WorkoutSet) => {
|
||||
if (activeSession && currentUser) {
|
||||
const updatedSession = {
|
||||
...activeSession,
|
||||
sets: [...activeSession.sets, set]
|
||||
};
|
||||
setActiveSession(updatedSession);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveSetFromActive = async (setId: string) => {
|
||||
if (activeSession && currentUser) {
|
||||
await deleteSetFromActiveSession(currentUser.id, setId);
|
||||
const updatedSession = {
|
||||
...activeSession,
|
||||
sets: activeSession.sets.filter(s => s.id !== setId)
|
||||
};
|
||||
setActiveSession(updatedSession);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSetInActive = async (updatedSet: WorkoutSet) => {
|
||||
if (activeSession && currentUser) {
|
||||
const response = await updateSetInActiveSession(currentUser.id, updatedSet.id, updatedSet);
|
||||
const updatedSession = {
|
||||
...activeSession,
|
||||
sets: activeSession.sets.map(s => s.id === updatedSet.id ? response : s)
|
||||
};
|
||||
setActiveSession(updatedSession);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuitSession = async () => {
|
||||
if (currentUser) {
|
||||
await deleteActiveSession(currentUser.id);
|
||||
setActiveSession(null);
|
||||
setActivePlan(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSession = (updatedSession: WorkoutSession) => {
|
||||
if (!currentUser) return;
|
||||
saveSession(currentUser.id, updatedSession);
|
||||
setSessions(prev => prev.map(s => s.id === updatedSession.id ? updatedSession : s));
|
||||
};
|
||||
|
||||
const handleDeleteSession = (sessionId: string) => {
|
||||
if (!currentUser) return;
|
||||
deleteSession(currentUser.id, sessionId);
|
||||
setSessions(prev => prev.filter(s => s.id !== sessionId));
|
||||
};
|
||||
|
||||
|
||||
|
||||
if (!currentUser) {
|
||||
return <Login onLogin={handleLogin} language={language} onLanguageChange={handleLanguageChange} />;
|
||||
if (!currentUser && location.pathname !== '/login') {
|
||||
return <Navigate to="/login" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-surface text-on-surface font-sans flex flex-col md:flex-row overflow-hidden">
|
||||
|
||||
{/* Desktop Navigation Rail (Left) */}
|
||||
<Navbar currentTab={currentTab} onTabChange={setCurrentTab} lang={language} />
|
||||
{currentUser && (
|
||||
<Navbar lang={language} />
|
||||
)}
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 h-full relative w-full max-w-5xl mx-auto md:px-4">
|
||||
<div className="h-full w-full pb-20 md:pb-0 bg-surface">
|
||||
{currentTab === 'TRACK' && (
|
||||
<Routes>
|
||||
<Route path="/login" element={
|
||||
!currentUser ? (
|
||||
<Login onLogin={handleLogin} language={language} onLanguageChange={setLanguage} />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
} />
|
||||
<Route path="/" element={
|
||||
<Tracker
|
||||
userId={currentUser.id}
|
||||
userWeight={currentUser.profile?.weight}
|
||||
userId={currentUser?.id || ''}
|
||||
userWeight={currentUser?.profile?.weight}
|
||||
activeSession={activeSession}
|
||||
activePlan={activePlan}
|
||||
onSessionStart={handleStartSession}
|
||||
onSessionEnd={handleEndSession}
|
||||
onSessionQuit={handleQuitSession}
|
||||
onSetAdded={handleAddSet}
|
||||
onRemoveSet={handleRemoveSetFromActive}
|
||||
onUpdateSet={handleUpdateSetInActive}
|
||||
onSessionStart={startSession}
|
||||
onSessionEnd={endSession}
|
||||
onSessionQuit={quitSession}
|
||||
onSetAdded={addSet}
|
||||
onRemoveSet={removeSet}
|
||||
onUpdateSet={updateSet}
|
||||
lang={language}
|
||||
/>
|
||||
)}
|
||||
{currentTab === 'PLANS' && (
|
||||
<Plans userId={currentUser.id} onStartPlan={handleStartSession} lang={language} />
|
||||
)}
|
||||
{currentTab === 'HISTORY' && (
|
||||
} />
|
||||
<Route path="/plans" element={
|
||||
<Plans userId={currentUser?.id || ''} onStartPlan={startSession} lang={language} />
|
||||
} />
|
||||
<Route path="/history" element={
|
||||
<History
|
||||
sessions={sessions}
|
||||
onUpdateSession={handleUpdateSession}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onUpdateSession={updateSession}
|
||||
onDeleteSession={deleteSessionById}
|
||||
lang={language}
|
||||
/>
|
||||
)}
|
||||
{currentTab === 'STATS' && <Stats sessions={sessions} lang={language} />}
|
||||
{currentTab === 'AI_COACH' && <AICoach history={sessions} userProfile={currentUser.profile} plans={plans} lang={language} />}
|
||||
{currentTab === 'PROFILE' && (
|
||||
} />
|
||||
<Route path="/stats" element={
|
||||
<Stats sessions={sessions} lang={language} />
|
||||
} />
|
||||
<Route path="/coach" element={
|
||||
<AICoach history={sessions} userProfile={currentUser?.profile} plans={plans} lang={language} />
|
||||
} />
|
||||
<Route path="/profile" element={
|
||||
<Profile
|
||||
user={currentUser}
|
||||
onLogout={handleLogout}
|
||||
lang={language}
|
||||
onLanguageChange={handleLanguageChange}
|
||||
onUserUpdate={handleUserUpdate}
|
||||
onLanguageChange={setLanguage}
|
||||
onUserUpdate={updateUser}
|
||||
/>
|
||||
)}
|
||||
} />
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Mobile Navigation (rendered inside Navbar component, fixed to bottom) */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +1,46 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Dumbbell, History as HistoryIcon, BarChart2, MessageSquare, ClipboardList, User } from 'lucide-react';
|
||||
import { TabView, Language } from '../types';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { t } from '../services/i18n';
|
||||
import { Language } from '../types';
|
||||
|
||||
interface NavbarProps {
|
||||
currentTab: TabView;
|
||||
onTabChange: (tab: TabView) => void;
|
||||
lang: Language;
|
||||
}
|
||||
|
||||
const Navbar: React.FC<NavbarProps> = ({ currentTab, onTabChange, lang }) => {
|
||||
const Navbar: React.FC<NavbarProps> = ({ lang }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const navItems = [
|
||||
{ id: 'TRACK' as TabView, icon: Dumbbell, label: t('tab_tracker', lang) },
|
||||
{ id: 'PLANS' as TabView, icon: ClipboardList, label: t('tab_plans', lang) },
|
||||
{ id: 'HISTORY' as TabView, icon: HistoryIcon, label: t('tab_history', lang) },
|
||||
{ id: 'STATS' as TabView, icon: BarChart2, label: t('tab_stats', lang) },
|
||||
{ id: 'AI_COACH' as TabView, icon: MessageSquare, label: t('tab_ai', lang) },
|
||||
{ id: 'PROFILE' as TabView, icon: User, label: t('tab_profile', lang) },
|
||||
{ path: '/', icon: Dumbbell, label: t('tab_tracker', lang) },
|
||||
{ path: '/plans', icon: ClipboardList, label: t('tab_plans', lang) },
|
||||
{ path: '/history', icon: HistoryIcon, label: t('tab_history', lang) },
|
||||
{ path: '/stats', icon: BarChart2, label: t('tab_stats', lang) },
|
||||
{ path: '/coach', icon: MessageSquare, label: t('tab_ai', lang) },
|
||||
{ path: '/profile', icon: User, label: t('tab_profile', lang) },
|
||||
];
|
||||
|
||||
const currentPath = location.pathname;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* MOBILE: Bottom Navigation Bar (MD3) */}
|
||||
<div className="md:hidden fixed bottom-0 left-0 w-full bg-surface-container shadow-elevation-2 border-t border-white/5 pb-safe z-50 h-20">
|
||||
<div className="flex justify-evenly items-center h-full px-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = currentTab === item.id;
|
||||
const isActive = currentPath === item.path || (item.path !== '/' && currentPath.startsWith(item.path));
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onTabChange(item.id)}
|
||||
key={item.path}
|
||||
onClick={() => navigate(item.path)}
|
||||
className="flex flex-col items-center justify-center w-full h-full gap-1 group min-w-0"
|
||||
>
|
||||
<div className={`px-4 py-1 rounded-full transition-all duration-200 ${
|
||||
isActive ? 'bg-primary-container text-on-primary-container' : 'text-on-surface-variant group-hover:bg-surface-container-high'
|
||||
<div className={`px-4 py-1 rounded-full transition-all duration-200 ${isActive ? 'bg-primary-container text-on-primary-container' : 'text-on-surface-variant group-hover:bg-surface-container-high'
|
||||
}`}>
|
||||
<item.icon size={22} strokeWidth={isActive ? 2.5 : 2} />
|
||||
</div>
|
||||
<span className={`text-[10px] font-medium transition-colors truncate w-full text-center ${
|
||||
isActive ? 'text-on-surface' : 'text-on-surface-variant'
|
||||
<span className={`text-[10px] font-medium transition-colors truncate w-full text-center ${isActive ? 'text-on-surface' : 'text-on-surface-variant'
|
||||
}`}>
|
||||
{item.label}
|
||||
</span>
|
||||
@@ -53,20 +54,18 @@ const Navbar: React.FC<NavbarProps> = ({ currentTab, onTabChange, lang }) => {
|
||||
<div className="hidden md:flex flex-col w-20 h-full bg-surface-container border-r border-outline-variant items-center py-8 gap-8 z-50">
|
||||
<div className="flex flex-col gap-6 w-full px-2">
|
||||
{navItems.map((item) => {
|
||||
const isActive = currentTab === item.id;
|
||||
const isActive = currentPath === item.path || (item.path !== '/' && currentPath.startsWith(item.path));
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onTabChange(item.id)}
|
||||
key={item.path}
|
||||
onClick={() => navigate(item.path)}
|
||||
className="flex flex-col items-center gap-1 group w-full"
|
||||
>
|
||||
<div className={`w-14 h-8 rounded-full flex items-center justify-center transition-colors duration-200 ${
|
||||
isActive ? 'bg-primary-container text-on-primary-container' : 'text-on-surface-variant group-hover:bg-surface-container-high'
|
||||
<div className={`w-14 h-8 rounded-full flex items-center justify-center transition-colors duration-200 ${isActive ? 'bg-primary-container text-on-primary-container' : 'text-on-surface-variant group-hover:bg-surface-container-high'
|
||||
}`}>
|
||||
<item.icon size={24} />
|
||||
</div>
|
||||
<span className={`text-[11px] font-medium text-center ${
|
||||
isActive ? 'text-on-surface' : 'text-on-surface-variant'
|
||||
<span className={`text-[11px] font-medium text-center ${isActive ? 'text-on-surface' : 'text-on-surface-variant'
|
||||
}`}>
|
||||
{item.label}
|
||||
</span>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { WorkoutSession, WorkoutSet, ExerciseDef, ExerciseType, WorkoutPlan, Language } from '../../types';
|
||||
import { getExercises, getLastSetForExercise, saveExercise, getPlans } from '../../services/storage';
|
||||
import { WorkoutSession, WorkoutSet, ExerciseDef, ExerciseType, WorkoutPlan } from '../../types';
|
||||
import { getExercises, saveExercise, getPlans } from '../../services/storage';
|
||||
import { api } from '../../services/api';
|
||||
|
||||
import { useSessionTimer } from '../../hooks/useSessionTimer';
|
||||
import { useWorkoutForm } from '../../hooks/useWorkoutForm';
|
||||
import { usePlanExecution } from '../../hooks/usePlanExecution';
|
||||
|
||||
interface UseTrackerProps {
|
||||
userId: string;
|
||||
@@ -25,9 +27,7 @@ export const useTracker = ({
|
||||
activePlan,
|
||||
onSessionStart,
|
||||
onSessionEnd,
|
||||
onSessionQuit,
|
||||
onSetAdded,
|
||||
onRemoveSet,
|
||||
onUpdateSet,
|
||||
onSporadicSetAdded
|
||||
}: UseTrackerProps) => {
|
||||
@@ -38,49 +38,28 @@ export const useTracker = ({
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
|
||||
// Timer State
|
||||
const [elapsedTime, setElapsedTime] = useState<string>('00:00:00');
|
||||
|
||||
// Form State
|
||||
const [weight, setWeight] = useState<string>('');
|
||||
const [reps, setReps] = useState<string>('');
|
||||
const [duration, setDuration] = useState<string>('');
|
||||
const [distance, setDistance] = useState<string>('');
|
||||
const [height, setHeight] = useState<string>('');
|
||||
const [bwPercentage, setBwPercentage] = useState<string>('100');
|
||||
|
||||
// User Weight State
|
||||
const [userBodyWeight, setUserBodyWeight] = useState<string>(userWeight ? userWeight.toString() : '70');
|
||||
|
||||
// Create Exercise State
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
// Plan Execution State
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const [showPlanPrep, setShowPlanPrep] = useState<WorkoutPlan | null>(null);
|
||||
const [showPlanList, setShowPlanList] = useState(false);
|
||||
|
||||
// Confirmation State
|
||||
const [showFinishConfirm, setShowFinishConfirm] = useState(false);
|
||||
const [showQuitConfirm, setShowQuitConfirm] = useState(false);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
|
||||
// Edit Set State
|
||||
const [editingSetId, setEditingSetId] = useState<string | null>(null);
|
||||
const [editWeight, setEditWeight] = useState<string>('');
|
||||
const [editReps, setEditReps] = useState<string>('');
|
||||
const [editDuration, setEditDuration] = useState<string>('');
|
||||
const [editDistance, setEditDistance] = useState<string>('');
|
||||
const [editHeight, setEditHeight] = useState<string>('');
|
||||
|
||||
// Quick Log State
|
||||
const [quickLogSession, setQuickLogSession] = useState<WorkoutSession | null>(null);
|
||||
const [isSporadicMode, setIsSporadicMode] = useState(false);
|
||||
const [sporadicSuccess, setSporadicSuccess] = useState(false);
|
||||
|
||||
// Unilateral Exercise State
|
||||
const [unilateralSide, setUnilateralSide] = useState<'LEFT' | 'RIGHT'>('LEFT');
|
||||
// Hooks
|
||||
const elapsedTime = useSessionTimer(activeSession);
|
||||
const form = useWorkoutForm({ userId, onUpdateSet });
|
||||
const planExec = usePlanExecution({ activeSession, activePlan, exercises });
|
||||
|
||||
// Initial Data Load
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const exList = await getExercises(userId);
|
||||
@@ -95,15 +74,7 @@ export const useTracker = ({
|
||||
setUserBodyWeight(userWeight.toString());
|
||||
}
|
||||
|
||||
// Load Quick Log Session
|
||||
try {
|
||||
const response = await api.get('/sessions/quick-log');
|
||||
if (response.success && response.session) {
|
||||
setQuickLogSession(response.session);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load quick log session:", error);
|
||||
}
|
||||
loadQuickLogSession();
|
||||
};
|
||||
loadData();
|
||||
}, [activeSession, userId, userWeight, activePlan]);
|
||||
@@ -120,107 +91,30 @@ export const useTracker = ({
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Timer Logic
|
||||
// Auto-select exercise from plan step
|
||||
useEffect(() => {
|
||||
let interval: number;
|
||||
if (activeSession) {
|
||||
const updateTimer = () => {
|
||||
const diff = Math.floor((Date.now() - activeSession.startTime) / 1000);
|
||||
const h = Math.floor(diff / 3600);
|
||||
const m = Math.floor((diff % 3600) / 60);
|
||||
const s = diff % 60;
|
||||
setElapsedTime(`${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`);
|
||||
};
|
||||
|
||||
updateTimer();
|
||||
interval = window.setInterval(updateTimer, 1000);
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [activeSession]);
|
||||
|
||||
// Recalculate current step when sets change
|
||||
useEffect(() => {
|
||||
if (activeSession && activePlan) {
|
||||
const performedCounts = new Map<string, number>();
|
||||
for (const set of activeSession.sets) {
|
||||
performedCounts.set(set.exerciseId, (performedCounts.get(set.exerciseId) || 0) + 1);
|
||||
}
|
||||
|
||||
let nextStepIndex = activePlan.steps.length; // Default to finished
|
||||
const plannedCounts = new Map<string, number>();
|
||||
for (let i = 0; i < activePlan.steps.length; i++) {
|
||||
const step = activePlan.steps[i];
|
||||
const exerciseId = step.exerciseId;
|
||||
plannedCounts.set(exerciseId, (plannedCounts.get(exerciseId) || 0) + 1);
|
||||
const performedCount = performedCounts.get(exerciseId) || 0;
|
||||
|
||||
if (performedCount < plannedCounts.get(exerciseId)!) {
|
||||
nextStepIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setCurrentStepIndex(nextStepIndex);
|
||||
}
|
||||
}, [activeSession, activePlan]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSession && activePlan && exercises.length > 0 && activePlan.steps.length > 0) {
|
||||
if (currentStepIndex < activePlan.steps.length) {
|
||||
const step = activePlan.steps[currentStepIndex];
|
||||
const step = planExec.getCurrentStep();
|
||||
if (step) {
|
||||
const exDef = exercises.find(e => e.id === step.exerciseId);
|
||||
if (exDef) {
|
||||
setSelectedExercise(exDef);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [currentStepIndex, activePlan, exercises]);
|
||||
}, [planExec.currentStepIndex, activePlan, exercises]);
|
||||
|
||||
// Update form when exercise changes
|
||||
useEffect(() => {
|
||||
const updateSelection = async () => {
|
||||
if (selectedExercise) {
|
||||
setBwPercentage(selectedExercise.bodyWeightPercentage ? selectedExercise.bodyWeightPercentage.toString() : '100');
|
||||
setSearchQuery(selectedExercise.name);
|
||||
const set = await getLastSetForExercise(userId, selectedExercise.id);
|
||||
setLastSet(set);
|
||||
|
||||
if (set) {
|
||||
setWeight(set.weight?.toString() || '');
|
||||
setReps(set.reps?.toString() || '');
|
||||
setDuration(set.durationSeconds?.toString() || '');
|
||||
setDistance(set.distanceMeters?.toString() || '');
|
||||
setHeight(set.height?.toString() || '');
|
||||
await form.updateFormFromLastSet(selectedExercise.id, selectedExercise.type, selectedExercise.bodyWeightPercentage);
|
||||
} else {
|
||||
setWeight(''); setReps(''); setDuration(''); setDistance(''); setHeight('');
|
||||
}
|
||||
|
||||
// Clear fields not relevant to the selected exercise type
|
||||
if (selectedExercise.type !== ExerciseType.STRENGTH && selectedExercise.type !== ExerciseType.BODYWEIGHT) {
|
||||
setWeight('');
|
||||
}
|
||||
if (selectedExercise.type !== ExerciseType.STRENGTH && selectedExercise.type !== ExerciseType.BODYWEIGHT && selectedExercise.type !== ExerciseType.PLYOMETRIC) {
|
||||
setReps('');
|
||||
}
|
||||
if (selectedExercise.type !== ExerciseType.CARDIO && selectedExercise.type !== ExerciseType.STATIC) {
|
||||
setDuration('');
|
||||
}
|
||||
if (selectedExercise.type !== ExerciseType.CARDIO && selectedExercise.type !== ExerciseType.LONG_JUMP) {
|
||||
setDistance('');
|
||||
}
|
||||
if (selectedExercise.type !== ExerciseType.HIGH_JUMP) {
|
||||
setHeight('');
|
||||
}
|
||||
} else {
|
||||
setSearchQuery(''); // Clear search query if no exercise is selected
|
||||
setSearchQuery('');
|
||||
}
|
||||
};
|
||||
updateSelection();
|
||||
}, [selectedExercise, userId]);
|
||||
|
||||
|
||||
const filteredExercises = searchQuery === ''
|
||||
? exercises
|
||||
: exercises.filter(ex =>
|
||||
@@ -229,58 +123,23 @@ export const useTracker = ({
|
||||
|
||||
const handleStart = (plan?: WorkoutPlan) => {
|
||||
if (plan && plan.description) {
|
||||
setShowPlanPrep(plan);
|
||||
planExec.setShowPlanPrep(plan);
|
||||
} else {
|
||||
onSessionStart(plan, parseFloat(userBodyWeight));
|
||||
}
|
||||
};
|
||||
|
||||
const confirmPlanStart = () => {
|
||||
if (showPlanPrep) {
|
||||
onSessionStart(showPlanPrep, parseFloat(userBodyWeight));
|
||||
setShowPlanPrep(null);
|
||||
if (planExec.showPlanPrep) {
|
||||
onSessionStart(planExec.showPlanPrep, parseFloat(userBodyWeight));
|
||||
planExec.setShowPlanPrep(null);
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddSet = async () => {
|
||||
if (!activeSession || !selectedExercise) return;
|
||||
|
||||
const setData: Partial<WorkoutSet> = {
|
||||
exerciseId: selectedExercise.id,
|
||||
};
|
||||
|
||||
if (selectedExercise.isUnilateral) {
|
||||
setData.side = unilateralSide;
|
||||
}
|
||||
|
||||
switch (selectedExercise.type) {
|
||||
case ExerciseType.STRENGTH:
|
||||
if (weight) setData.weight = parseFloat(weight);
|
||||
if (reps) setData.reps = parseInt(reps);
|
||||
break;
|
||||
case ExerciseType.BODYWEIGHT:
|
||||
if (weight) setData.weight = parseFloat(weight);
|
||||
if (reps) setData.reps = parseInt(reps);
|
||||
setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
|
||||
break;
|
||||
case ExerciseType.CARDIO:
|
||||
if (duration) setData.durationSeconds = parseInt(duration);
|
||||
if (distance) setData.distanceMeters = parseFloat(distance);
|
||||
break;
|
||||
case ExerciseType.STATIC:
|
||||
if (duration) setData.durationSeconds = parseInt(duration);
|
||||
setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
|
||||
break;
|
||||
case ExerciseType.HIGH_JUMP:
|
||||
if (height) setData.height = parseFloat(height);
|
||||
break;
|
||||
case ExerciseType.LONG_JUMP:
|
||||
if (distance) setData.distanceMeters = parseFloat(distance);
|
||||
break;
|
||||
case ExerciseType.PLYOMETRIC:
|
||||
if (reps) setData.reps = parseInt(reps);
|
||||
break;
|
||||
}
|
||||
const setData = form.prepareSetData(selectedExercise);
|
||||
|
||||
try {
|
||||
const response = await api.post('/sessions/active/log-set', setData);
|
||||
@@ -291,11 +150,10 @@ export const useTracker = ({
|
||||
if (activePlan && activeExerciseId) {
|
||||
const nextStepIndex = activePlan.steps.findIndex(step => step.exerciseId === activeExerciseId);
|
||||
if (nextStepIndex !== -1) {
|
||||
setCurrentStepIndex(nextStepIndex);
|
||||
planExec.setCurrentStepIndex(nextStepIndex);
|
||||
}
|
||||
} else if (activePlan && !activeExerciseId) {
|
||||
// Plan is finished
|
||||
setCurrentStepIndex(activePlan.steps.length);
|
||||
planExec.setCurrentStepIndex(activePlan.steps.length);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -305,62 +163,15 @@ export const useTracker = ({
|
||||
|
||||
const handleLogSporadicSet = async () => {
|
||||
if (!selectedExercise) return;
|
||||
|
||||
const setData: any = {
|
||||
exerciseId: selectedExercise.id,
|
||||
};
|
||||
|
||||
if (selectedExercise.isUnilateral) {
|
||||
setData.side = unilateralSide;
|
||||
}
|
||||
|
||||
switch (selectedExercise.type) {
|
||||
case ExerciseType.STRENGTH:
|
||||
if (weight) setData.weight = parseFloat(weight);
|
||||
if (reps) setData.reps = parseInt(reps);
|
||||
break;
|
||||
case ExerciseType.BODYWEIGHT:
|
||||
if (weight) setData.weight = parseFloat(weight);
|
||||
if (reps) setData.reps = parseInt(reps);
|
||||
setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
|
||||
break;
|
||||
case ExerciseType.CARDIO:
|
||||
if (duration) setData.durationSeconds = parseInt(duration);
|
||||
if (distance) setData.distanceMeters = parseFloat(distance);
|
||||
break;
|
||||
case ExerciseType.STATIC:
|
||||
if (duration) setData.durationSeconds = parseInt(duration);
|
||||
setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
|
||||
break;
|
||||
case ExerciseType.HIGH_JUMP:
|
||||
if (height) setData.height = parseFloat(height);
|
||||
break;
|
||||
case ExerciseType.LONG_JUMP:
|
||||
if (distance) setData.distanceMeters = parseFloat(distance);
|
||||
break;
|
||||
case ExerciseType.PLYOMETRIC:
|
||||
if (reps) setData.reps = parseInt(reps);
|
||||
break;
|
||||
}
|
||||
const setData = form.prepareSetData(selectedExercise);
|
||||
|
||||
try {
|
||||
const response = await api.post('/sessions/quick-log/set', setData);
|
||||
if (response.success) {
|
||||
setSporadicSuccess(true);
|
||||
setTimeout(() => setSporadicSuccess(false), 2000);
|
||||
|
||||
// Refresh quick log session
|
||||
const sessionRes = await api.get('/sessions/quick-log');
|
||||
if (sessionRes.success && sessionRes.session) {
|
||||
setQuickLogSession(sessionRes.session);
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setWeight('');
|
||||
setReps('');
|
||||
setDuration('');
|
||||
setDistance('');
|
||||
setHeight('');
|
||||
loadQuickLogSession();
|
||||
form.resetForm();
|
||||
if (onSporadicSetAdded) onSporadicSetAdded();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -376,44 +187,14 @@ export const useTracker = ({
|
||||
setIsCreating(false);
|
||||
};
|
||||
|
||||
const handleEditSet = (set: WorkoutSet) => {
|
||||
setEditingSetId(set.id);
|
||||
setEditWeight(set.weight?.toString() || '');
|
||||
setEditReps(set.reps?.toString() || '');
|
||||
setEditDuration(set.durationSeconds?.toString() || '');
|
||||
setEditDistance(set.distanceMeters?.toString() || '');
|
||||
setEditHeight(set.height?.toString() || '');
|
||||
};
|
||||
|
||||
const handleSaveEdit = (set: WorkoutSet) => {
|
||||
const updatedSet: WorkoutSet = {
|
||||
...set,
|
||||
...(editWeight && { weight: parseFloat(editWeight) }),
|
||||
...(editReps && { reps: parseInt(editReps) }),
|
||||
...(editDuration && { durationSeconds: parseInt(editDuration) }),
|
||||
...(editDistance && { distanceMeters: parseFloat(editDistance) }),
|
||||
...(editHeight && { height: parseFloat(editHeight) })
|
||||
};
|
||||
onUpdateSet(updatedSet);
|
||||
setEditingSetId(null);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingSetId(null);
|
||||
};
|
||||
|
||||
const jumpToStep = (index: number) => {
|
||||
if (!activePlan) return;
|
||||
setCurrentStepIndex(index);
|
||||
setShowPlanList(false);
|
||||
};
|
||||
// Forwarding form handlers from hook
|
||||
const handleEditSet = form.startEditing;
|
||||
const handleSaveEdit = form.saveEdit;
|
||||
const handleCancelEdit = form.cancelEdit;
|
||||
|
||||
// Reset override
|
||||
const resetForm = () => {
|
||||
setWeight('');
|
||||
setReps('');
|
||||
setDuration('');
|
||||
setDistance('');
|
||||
setHeight('');
|
||||
form.resetForm();
|
||||
setSelectedExercise(null);
|
||||
setSearchQuery('');
|
||||
setSporadicSuccess(false);
|
||||
@@ -431,46 +212,37 @@ export const useTracker = ({
|
||||
showSuggestions,
|
||||
setShowSuggestions,
|
||||
elapsedTime,
|
||||
weight,
|
||||
setWeight,
|
||||
reps,
|
||||
setReps,
|
||||
duration,
|
||||
setDuration,
|
||||
distance,
|
||||
setDistance,
|
||||
height,
|
||||
setHeight,
|
||||
bwPercentage,
|
||||
setBwPercentage,
|
||||
userBodyWeight,
|
||||
setUserBodyWeight,
|
||||
isCreating,
|
||||
setIsCreating,
|
||||
currentStepIndex,
|
||||
showPlanPrep,
|
||||
setShowPlanPrep,
|
||||
showPlanList,
|
||||
setShowPlanList,
|
||||
showFinishConfirm,
|
||||
setShowFinishConfirm,
|
||||
showQuitConfirm,
|
||||
setShowQuitConfirm,
|
||||
showMenu,
|
||||
setShowMenu,
|
||||
editingSetId,
|
||||
editWeight,
|
||||
setEditWeight,
|
||||
editReps,
|
||||
setEditReps,
|
||||
editDuration,
|
||||
setEditDuration,
|
||||
editDistance,
|
||||
setEditDistance,
|
||||
editHeight,
|
||||
setEditHeight,
|
||||
isSporadicMode,
|
||||
setIsSporadicMode,
|
||||
// Form Props
|
||||
weight: form.weight, setWeight: form.setWeight,
|
||||
reps: form.reps, setReps: form.setReps,
|
||||
duration: form.duration, setDuration: form.setDuration,
|
||||
distance: form.distance, setDistance: form.setDistance,
|
||||
height: form.height, setHeight: form.setHeight,
|
||||
bwPercentage: form.bwPercentage, setBwPercentage: form.setBwPercentage,
|
||||
unilateralSide: form.unilateralSide, setUnilateralSide: form.setUnilateralSide,
|
||||
|
||||
userBodyWeight, setUserBodyWeight,
|
||||
isCreating, setIsCreating,
|
||||
|
||||
// Plan Execution Props
|
||||
currentStepIndex: planExec.currentStepIndex,
|
||||
showPlanPrep: planExec.showPlanPrep, setShowPlanPrep: planExec.setShowPlanPrep,
|
||||
showPlanList: planExec.showPlanList, setShowPlanList: planExec.setShowPlanList,
|
||||
jumpToStep: planExec.jumpToStep,
|
||||
|
||||
showFinishConfirm, setShowFinishConfirm,
|
||||
showQuitConfirm, setShowQuitConfirm,
|
||||
showMenu, setShowMenu,
|
||||
|
||||
// Editing
|
||||
editingSetId: form.editingSetId,
|
||||
editWeight: form.editWeight, setEditWeight: form.setEditWeight,
|
||||
editReps: form.editReps, setEditReps: form.setEditReps,
|
||||
editDuration: form.editDuration, setEditDuration: form.setEditDuration,
|
||||
editDistance: form.editDistance, setEditDistance: form.setEditDistance,
|
||||
editHeight: form.editHeight, setEditHeight: form.setEditHeight,
|
||||
|
||||
isSporadicMode, setIsSporadicMode,
|
||||
sporadicSuccess,
|
||||
filteredExercises,
|
||||
handleStart,
|
||||
@@ -481,11 +253,8 @@ export const useTracker = ({
|
||||
handleEditSet,
|
||||
handleSaveEdit,
|
||||
handleCancelEdit,
|
||||
jumpToStep,
|
||||
resetForm,
|
||||
unilateralSide,
|
||||
setUnilateralSide,
|
||||
quickLogSession, // Export this
|
||||
loadQuickLogSession, // Export reload function
|
||||
quickLogSession,
|
||||
loadQuickLogSession
|
||||
};
|
||||
};
|
||||
|
||||
61
src/context/AuthContext.tsx
Normal file
61
src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { User } from '../types';
|
||||
import { getMe } from '../services/auth';
|
||||
|
||||
interface AuthContextType {
|
||||
currentUser: User | null;
|
||||
setCurrentUser: (user: User | null) => void;
|
||||
isLoading: boolean;
|
||||
logout: () => void;
|
||||
updateUser: (user: User) => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const restoreSession = async () => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
try {
|
||||
const res = await getMe();
|
||||
if (res.success && res.user) {
|
||||
setCurrentUser(res.user);
|
||||
} else {
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
} catch (e) {
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
restoreSession();
|
||||
}, []);
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
setCurrentUser(null);
|
||||
};
|
||||
|
||||
const updateUser = (user: User) => {
|
||||
setCurrentUser(user);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ currentUser, setCurrentUser, isLoading, logout, updateUser }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
208
src/context/DataContext.tsx
Normal file
208
src/context/DataContext.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { WorkoutSession, WorkoutPlan, WorkoutSet } from '../types';
|
||||
import { useAuth } from './AuthContext';
|
||||
import {
|
||||
getSessions,
|
||||
getPlans,
|
||||
getActiveSession,
|
||||
saveSession,
|
||||
deleteSession,
|
||||
updateActiveSession,
|
||||
deleteActiveSession,
|
||||
deleteSetFromActiveSession,
|
||||
updateSetInActiveSession
|
||||
} from '../services/storage';
|
||||
import { getCurrentUserProfile, getMe } from '../services/auth';
|
||||
import { generateId } from '../utils/uuid';
|
||||
import { logWeight } from '../services/weight';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface DataContextType {
|
||||
sessions: WorkoutSession[];
|
||||
plans: WorkoutPlan[];
|
||||
activeSession: WorkoutSession | null;
|
||||
activePlan: WorkoutPlan | null;
|
||||
startSession: (plan?: WorkoutPlan, startWeight?: number) => Promise<void>;
|
||||
endSession: () => Promise<void>;
|
||||
quitSession: () => Promise<void>;
|
||||
addSet: (set: WorkoutSet) => void;
|
||||
removeSet: (setId: string) => Promise<void>;
|
||||
updateSet: (updatedSet: WorkoutSet) => Promise<void>;
|
||||
updateSession: (updatedSession: WorkoutSession) => void;
|
||||
deleteSessionById: (sessionId: string) => void;
|
||||
refreshData: () => Promise<void>;
|
||||
}
|
||||
|
||||
const DataContext = createContext<DataContextType | undefined>(undefined);
|
||||
|
||||
export const DataProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { currentUser, updateUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [sessions, setSessions] = useState<WorkoutSession[]>([]);
|
||||
const [plans, setPlans] = useState<WorkoutPlan[]>([]);
|
||||
const [activeSession, setActiveSession] = useState<WorkoutSession | null>(null);
|
||||
const [activePlan, setActivePlan] = useState<WorkoutPlan | null>(null);
|
||||
|
||||
const refreshData = async () => {
|
||||
if (currentUser) {
|
||||
const s = await getSessions(currentUser.id);
|
||||
setSessions(s);
|
||||
const p = await getPlans(currentUser.id);
|
||||
setPlans(p);
|
||||
} else {
|
||||
setSessions([]);
|
||||
setPlans([]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshData();
|
||||
}, [currentUser]);
|
||||
|
||||
// Restore active session
|
||||
useEffect(() => {
|
||||
const restoreActive = async () => {
|
||||
if (currentUser) {
|
||||
const session = await getActiveSession(currentUser.id);
|
||||
if (session) {
|
||||
setActiveSession(session);
|
||||
if (session.planId) {
|
||||
// Ensure plans are loaded or fetch specifically
|
||||
const currentPlans = plans.length > 0 ? plans : await getPlans(currentUser.id);
|
||||
const plan = currentPlans.find(p => p.id === session.planId);
|
||||
if (plan) setActivePlan(plan);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
restoreActive();
|
||||
}, [currentUser]); // Dependency logic might need tuning, but this matches App.tsx roughly
|
||||
|
||||
const startSession = async (plan?: WorkoutPlan, startWeight?: number) => {
|
||||
if (!currentUser || activeSession) return;
|
||||
|
||||
const profile = getCurrentUserProfile(currentUser.id);
|
||||
const currentWeight = startWeight || profile?.weight || 70;
|
||||
|
||||
const newSession: WorkoutSession = {
|
||||
id: generateId(),
|
||||
startTime: Date.now(),
|
||||
type: 'STANDARD',
|
||||
userBodyWeight: currentWeight,
|
||||
sets: [],
|
||||
planId: plan?.id,
|
||||
planName: plan?.name
|
||||
};
|
||||
|
||||
setActivePlan(plan || null);
|
||||
setActiveSession(newSession);
|
||||
navigate('/');
|
||||
|
||||
await saveSession(currentUser.id, newSession);
|
||||
|
||||
if (startWeight) {
|
||||
await logWeight(startWeight);
|
||||
}
|
||||
};
|
||||
|
||||
const endSession = async () => {
|
||||
if (activeSession && currentUser) {
|
||||
const finishedSession = { ...activeSession, endTime: Date.now() };
|
||||
await updateActiveSession(currentUser.id, finishedSession);
|
||||
setSessions(prev => [finishedSession, ...prev]);
|
||||
setActiveSession(null);
|
||||
setActivePlan(null);
|
||||
|
||||
const res = await getMe();
|
||||
if (res.success && res.user) {
|
||||
updateUser(res.user);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const quitSession = async () => {
|
||||
if (currentUser) {
|
||||
await deleteActiveSession(currentUser.id);
|
||||
setActiveSession(null);
|
||||
setActivePlan(null);
|
||||
}
|
||||
};
|
||||
|
||||
const addSet = (set: WorkoutSet) => {
|
||||
if (activeSession) {
|
||||
const updatedSession = { ...activeSession, sets: [...activeSession.sets, set] };
|
||||
setActiveSession(updatedSession);
|
||||
// Context update is optimistic, actual save usually happens in hooks or components?
|
||||
// In App.tsx handleAddSet only updated local state.
|
||||
// Wait, useTracker usually handles saving sets via API?
|
||||
// In App.tsx: handleAddSet just set state.
|
||||
// useTracker.ts calls onSetAdded, but ALSO calls api to save it?
|
||||
// Let's look at useTracker.ts.
|
||||
// handleLogSet in useTracker calls API then onSetAdded.
|
||||
// So this state update is mainly for UI sync in App.
|
||||
}
|
||||
};
|
||||
|
||||
const removeSet = async (setId: string) => {
|
||||
if (activeSession && currentUser) {
|
||||
await deleteSetFromActiveSession(currentUser.id, setId);
|
||||
const updatedSession = {
|
||||
...activeSession,
|
||||
sets: activeSession.sets.filter(s => s.id !== setId)
|
||||
};
|
||||
setActiveSession(updatedSession);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSet = async (updatedSet: WorkoutSet) => {
|
||||
if (activeSession && currentUser) {
|
||||
const response = await updateSetInActiveSession(currentUser.id, updatedSet.id, updatedSet);
|
||||
const updatedSession = {
|
||||
...activeSession,
|
||||
sets: activeSession.sets.map(s => s.id === updatedSet.id ? response : s)
|
||||
};
|
||||
setActiveSession(updatedSession);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSession = (updatedSession: WorkoutSession) => {
|
||||
if (!currentUser) return;
|
||||
saveSession(currentUser.id, updatedSession);
|
||||
setSessions(prev => prev.map(s => s.id === updatedSession.id ? updatedSession : s));
|
||||
};
|
||||
|
||||
const deleteSessionById = (sessionId: string) => {
|
||||
if (!currentUser) return;
|
||||
deleteSession(currentUser.id, sessionId);
|
||||
setSessions(prev => prev.filter(s => s.id !== sessionId));
|
||||
};
|
||||
|
||||
return (
|
||||
<DataContext.Provider value={{
|
||||
sessions,
|
||||
plans,
|
||||
activeSession,
|
||||
activePlan,
|
||||
startSession,
|
||||
endSession,
|
||||
quitSession,
|
||||
addSet,
|
||||
removeSet,
|
||||
updateSet,
|
||||
updateSession,
|
||||
deleteSessionById,
|
||||
refreshData
|
||||
}}>
|
||||
{children}
|
||||
</DataContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useData = () => {
|
||||
const context = useContext(DataContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useData must be used within a DataProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
65
src/hooks/usePlanExecution.ts
Normal file
65
src/hooks/usePlanExecution.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { WorkoutSession, WorkoutPlan, ExerciseDef } from '../types';
|
||||
|
||||
interface UsePlanExecutionProps {
|
||||
activeSession: WorkoutSession | null;
|
||||
activePlan: WorkoutPlan | null;
|
||||
exercises: ExerciseDef[];
|
||||
}
|
||||
|
||||
export const usePlanExecution = ({ activeSession, activePlan, exercises }: UsePlanExecutionProps) => {
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const [showPlanPrep, setShowPlanPrep] = useState<WorkoutPlan | null>(null);
|
||||
const [showPlanList, setShowPlanList] = useState(false);
|
||||
|
||||
// Automatically determine current step based on logged sets vs plan
|
||||
useEffect(() => {
|
||||
if (activeSession && activePlan) {
|
||||
const performedCounts = new Map<string, number>();
|
||||
for (const set of activeSession.sets) {
|
||||
performedCounts.set(set.exerciseId, (performedCounts.get(set.exerciseId) || 0) + 1);
|
||||
}
|
||||
|
||||
let nextStepIndex = activePlan.steps.length; // Default to finished
|
||||
const plannedCounts = new Map<string, number>();
|
||||
for (let i = 0; i < activePlan.steps.length; i++) {
|
||||
const step = activePlan.steps[i];
|
||||
const exerciseId = step.exerciseId;
|
||||
plannedCounts.set(exerciseId, (plannedCounts.get(exerciseId) || 0) + 1);
|
||||
const performedCount = performedCounts.get(exerciseId) || 0;
|
||||
|
||||
if (performedCount < plannedCounts.get(exerciseId)!) {
|
||||
nextStepIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setCurrentStepIndex(nextStepIndex);
|
||||
}
|
||||
}, [activeSession, activePlan]);
|
||||
|
||||
const getCurrentStep = () => {
|
||||
if (activeSession && activePlan && activePlan.steps.length > 0) {
|
||||
if (currentStepIndex < activePlan.steps.length) {
|
||||
return activePlan.steps[currentStepIndex];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const jumpToStep = (index: number) => {
|
||||
if (!activePlan) return;
|
||||
setCurrentStepIndex(index);
|
||||
setShowPlanList(false);
|
||||
};
|
||||
|
||||
return {
|
||||
currentStepIndex,
|
||||
setCurrentStepIndex,
|
||||
showPlanPrep,
|
||||
setShowPlanPrep,
|
||||
showPlanList,
|
||||
setShowPlanList,
|
||||
getCurrentStep,
|
||||
jumpToStep
|
||||
};
|
||||
};
|
||||
27
src/hooks/useSessionTimer.ts
Normal file
27
src/hooks/useSessionTimer.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { WorkoutSession } from '../types';
|
||||
|
||||
export const useSessionTimer = (activeSession: WorkoutSession | null) => {
|
||||
const [elapsedTime, setElapsedTime] = useState<string>('00:00:00');
|
||||
|
||||
useEffect(() => {
|
||||
let interval: number;
|
||||
if (activeSession) {
|
||||
const updateTimer = () => {
|
||||
const diff = Math.floor((Date.now() - activeSession.startTime) / 1000);
|
||||
const h = Math.floor(diff / 3600);
|
||||
const m = Math.floor((diff % 3600) / 60);
|
||||
const s = diff % 60;
|
||||
setElapsedTime(`${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`);
|
||||
};
|
||||
|
||||
updateTimer();
|
||||
interval = window.setInterval(updateTimer, 1000);
|
||||
} else {
|
||||
setElapsedTime('00:00:00');
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [activeSession]);
|
||||
|
||||
return elapsedTime;
|
||||
};
|
||||
147
src/hooks/useWorkoutForm.ts
Normal file
147
src/hooks/useWorkoutForm.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { WorkoutSet, ExerciseDef, ExerciseType } from '../types';
|
||||
import { getLastSetForExercise } from '../services/storage';
|
||||
|
||||
interface UseWorkoutFormProps {
|
||||
userId: string;
|
||||
onSetAdded?: (set: WorkoutSet) => void;
|
||||
onUpdateSet?: (set: WorkoutSet) => void;
|
||||
}
|
||||
|
||||
export const useWorkoutForm = ({ userId, onSetAdded, onUpdateSet }: UseWorkoutFormProps) => {
|
||||
const [weight, setWeight] = useState<string>('');
|
||||
const [reps, setReps] = useState<string>('');
|
||||
const [duration, setDuration] = useState<string>('');
|
||||
const [distance, setDistance] = useState<string>('');
|
||||
const [height, setHeight] = useState<string>('');
|
||||
const [bwPercentage, setBwPercentage] = useState<string>('100');
|
||||
|
||||
// Unilateral State
|
||||
const [unilateralSide, setUnilateralSide] = useState<'LEFT' | 'RIGHT'>('LEFT');
|
||||
|
||||
// Editing State
|
||||
const [editingSetId, setEditingSetId] = useState<string | null>(null);
|
||||
const [editWeight, setEditWeight] = useState<string>('');
|
||||
const [editReps, setEditReps] = useState<string>('');
|
||||
const [editDuration, setEditDuration] = useState<string>('');
|
||||
const [editDistance, setEditDistance] = useState<string>('');
|
||||
const [editHeight, setEditHeight] = useState<string>('');
|
||||
|
||||
const resetForm = () => {
|
||||
setWeight('');
|
||||
setReps('');
|
||||
setDuration('');
|
||||
setDistance('');
|
||||
setHeight('');
|
||||
};
|
||||
|
||||
const updateFormFromLastSet = async (exerciseId: string, exerciseType: ExerciseType, bodyWeightPercentage?: number) => {
|
||||
setBwPercentage(bodyWeightPercentage ? bodyWeightPercentage.toString() : '100');
|
||||
|
||||
const set = await getLastSetForExercise(userId, exerciseId);
|
||||
if (set) {
|
||||
setWeight(set.weight?.toString() || '');
|
||||
setReps(set.reps?.toString() || '');
|
||||
setDuration(set.durationSeconds?.toString() || '');
|
||||
setDistance(set.distanceMeters?.toString() || '');
|
||||
setHeight(set.height?.toString() || '');
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
|
||||
// Clear irrelevant fields
|
||||
if (exerciseType !== ExerciseType.STRENGTH && exerciseType !== ExerciseType.BODYWEIGHT) setWeight('');
|
||||
if (exerciseType !== ExerciseType.STRENGTH && exerciseType !== ExerciseType.BODYWEIGHT && exerciseType !== ExerciseType.PLYOMETRIC) setReps('');
|
||||
if (exerciseType !== ExerciseType.CARDIO && exerciseType !== ExerciseType.STATIC) setDuration('');
|
||||
if (exerciseType !== ExerciseType.CARDIO && exerciseType !== ExerciseType.LONG_JUMP) setDistance('');
|
||||
if (exerciseType !== ExerciseType.HIGH_JUMP) setHeight('');
|
||||
};
|
||||
|
||||
const prepareSetData = (selectedExercise: ExerciseDef, isSporadic: boolean = false) => {
|
||||
const setData: Partial<WorkoutSet> = {
|
||||
exerciseId: selectedExercise.id,
|
||||
};
|
||||
|
||||
if (selectedExercise.isUnilateral) {
|
||||
setData.side = unilateralSide;
|
||||
}
|
||||
|
||||
switch (selectedExercise.type) {
|
||||
case ExerciseType.STRENGTH:
|
||||
if (weight) setData.weight = parseFloat(weight);
|
||||
if (reps) setData.reps = parseInt(reps);
|
||||
break;
|
||||
case ExerciseType.BODYWEIGHT:
|
||||
if (weight) setData.weight = parseFloat(weight);
|
||||
if (reps) setData.reps = parseInt(reps);
|
||||
setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
|
||||
break;
|
||||
case ExerciseType.CARDIO:
|
||||
if (duration) setData.durationSeconds = parseInt(duration);
|
||||
if (distance) setData.distanceMeters = parseFloat(distance);
|
||||
break;
|
||||
case ExerciseType.STATIC:
|
||||
if (duration) setData.durationSeconds = parseInt(duration);
|
||||
setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
|
||||
break;
|
||||
case ExerciseType.HIGH_JUMP:
|
||||
if (height) setData.height = parseFloat(height);
|
||||
break;
|
||||
case ExerciseType.LONG_JUMP:
|
||||
if (distance) setData.distanceMeters = parseFloat(distance);
|
||||
break;
|
||||
case ExerciseType.PLYOMETRIC:
|
||||
if (reps) setData.reps = parseInt(reps);
|
||||
break;
|
||||
}
|
||||
return setData;
|
||||
};
|
||||
|
||||
const startEditing = (set: WorkoutSet) => {
|
||||
setEditingSetId(set.id);
|
||||
setEditWeight(set.weight?.toString() || '');
|
||||
setEditReps(set.reps?.toString() || '');
|
||||
setEditDuration(set.durationSeconds?.toString() || '');
|
||||
setEditDistance(set.distanceMeters?.toString() || '');
|
||||
setEditHeight(set.height?.toString() || '');
|
||||
};
|
||||
|
||||
const saveEdit = (set: WorkoutSet) => {
|
||||
const updatedSet: WorkoutSet = {
|
||||
...set,
|
||||
...(editWeight && { weight: parseFloat(editWeight) }),
|
||||
...(editReps && { reps: parseInt(editReps) }),
|
||||
...(editDuration && { durationSeconds: parseInt(editDuration) }),
|
||||
...(editDistance && { distanceMeters: parseFloat(editDistance) }),
|
||||
...(editHeight && { height: parseFloat(editHeight) })
|
||||
};
|
||||
if (onUpdateSet) onUpdateSet(updatedSet);
|
||||
setEditingSetId(null);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingSetId(null);
|
||||
};
|
||||
|
||||
return {
|
||||
weight, setWeight,
|
||||
reps, setReps,
|
||||
duration, setDuration,
|
||||
distance, setDistance,
|
||||
height, setHeight,
|
||||
bwPercentage, setBwPercentage,
|
||||
unilateralSide, setUnilateralSide,
|
||||
editingSetId,
|
||||
editWeight, setEditWeight,
|
||||
editReps, setEditReps,
|
||||
editDuration, setEditDuration,
|
||||
editDistance, setEditDistance,
|
||||
editHeight, setEditHeight,
|
||||
resetForm,
|
||||
updateFormFromLastSet,
|
||||
prepareSetData,
|
||||
startEditing,
|
||||
saveEdit,
|
||||
cancelEdit
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,8 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import { DataProvider } from './context/DataContext';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
@@ -11,6 +14,12 @@ if (!rootElement) {
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<DataProvider>
|
||||
<App />
|
||||
</DataProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -13,12 +13,12 @@ const headers = () => {
|
||||
};
|
||||
|
||||
export const api = {
|
||||
get: async (endpoint: string) => {
|
||||
get: async <T = any>(endpoint: string): Promise<T> => {
|
||||
const res = await fetch(`${API_URL}${endpoint}`, { headers: headers() });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
},
|
||||
post: async (endpoint: string, data: any) => {
|
||||
post: async <T = any>(endpoint: string, data: any): Promise<T> => {
|
||||
const res = await fetch(`${API_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: headers(),
|
||||
@@ -27,7 +27,7 @@ export const api = {
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
},
|
||||
put: async (endpoint: string, data: any) => {
|
||||
put: async <T = any>(endpoint: string, data: any): Promise<T> => {
|
||||
const res = await fetch(`${API_URL}${endpoint}`, {
|
||||
method: 'PUT',
|
||||
headers: headers(),
|
||||
@@ -36,7 +36,7 @@ export const api = {
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
},
|
||||
delete: async (endpoint: string) => {
|
||||
delete: async <T = any>(endpoint: string): Promise<T> => {
|
||||
const res = await fetch(`${API_URL}${endpoint}`, {
|
||||
method: 'DELETE',
|
||||
headers: headers()
|
||||
@@ -44,7 +44,7 @@ export const api = {
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
},
|
||||
patch: async (endpoint: string, data: any) => {
|
||||
patch: async <T = any>(endpoint: string, data: any): Promise<T> => {
|
||||
const res = await fetch(`${API_URL}${endpoint}`, {
|
||||
method: 'PATCH',
|
||||
headers: headers(),
|
||||
|
||||
27
src/services/exercises.ts
Normal file
27
src/services/exercises.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ExerciseDef, WorkoutSet } from '../types';
|
||||
import { api } from './api';
|
||||
|
||||
export const getExercises = async (userId: string): Promise<ExerciseDef[]> => {
|
||||
try {
|
||||
return await api.get<ExerciseDef[]>('/exercises');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const saveExercise = async (userId: string, exercise: ExerciseDef): Promise<void> => {
|
||||
await api.post('/exercises', exercise);
|
||||
};
|
||||
|
||||
export const getLastSetForExercise = async (userId: string, exerciseId: string): Promise<WorkoutSet | undefined> => {
|
||||
try {
|
||||
const response = await api.get<{ success: boolean; set?: WorkoutSet }>(`/exercises/${exerciseId}/last-set`);
|
||||
if (response.success && response.set) {
|
||||
return response.set;
|
||||
}
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch last set:", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
18
src/services/plans.ts
Normal file
18
src/services/plans.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { WorkoutPlan } from '../types';
|
||||
import { api } from './api';
|
||||
|
||||
export const getPlans = async (userId: string): Promise<WorkoutPlan[]> => {
|
||||
try {
|
||||
return await api.get<WorkoutPlan[]>('/plans');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const savePlan = async (userId: string, plan: WorkoutPlan): Promise<void> => {
|
||||
await api.post('/plans', plan);
|
||||
};
|
||||
|
||||
export const deletePlan = async (userId: string, id: string): Promise<void> => {
|
||||
await api.delete(`/plans/${id}`);
|
||||
};
|
||||
85
src/services/sessions.ts
Normal file
85
src/services/sessions.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { WorkoutSession, WorkoutSet, ExerciseType } from '../types';
|
||||
import { api } from './api';
|
||||
|
||||
// Define the shape of session coming from API (Prisma include)
|
||||
interface ApiSession extends Omit<WorkoutSession, 'startTime' | 'endTime' | 'sets'> {
|
||||
startTime: string | number; // JSON dates are strings
|
||||
endTime?: string | number;
|
||||
sets: (Omit<WorkoutSet, 'exerciseName' | 'type'> & {
|
||||
exercise?: {
|
||||
name: string;
|
||||
type: ExerciseType;
|
||||
}
|
||||
})[];
|
||||
}
|
||||
|
||||
export const getSessions = async (userId: string): Promise<WorkoutSession[]> => {
|
||||
try {
|
||||
const sessions = await api.get<ApiSession[]>('/sessions');
|
||||
// Convert ISO date strings to timestamps
|
||||
return sessions.map((session) => ({
|
||||
...session,
|
||||
startTime: new Date(session.startTime).getTime(),
|
||||
endTime: session.endTime ? new Date(session.endTime).getTime() : undefined,
|
||||
sets: session.sets.map((set) => ({
|
||||
...set,
|
||||
exerciseName: set.exercise?.name || 'Unknown',
|
||||
type: set.exercise?.type || ExerciseType.STRENGTH
|
||||
})) as WorkoutSet[]
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const saveSession = async (userId: string, session: WorkoutSession): Promise<void> => {
|
||||
await api.post('/sessions', session);
|
||||
};
|
||||
|
||||
export const getActiveSession = async (userId: string): Promise<WorkoutSession | null> => {
|
||||
try {
|
||||
const response = await api.get<{ success: boolean; session?: ApiSession }>('/sessions/active');
|
||||
if (!response.success || !response.session) {
|
||||
return null;
|
||||
}
|
||||
const session = response.session;
|
||||
// Convert ISO date strings to timestamps
|
||||
return {
|
||||
...session,
|
||||
startTime: new Date(session.startTime).getTime(),
|
||||
endTime: session.endTime ? new Date(session.endTime).getTime() : undefined,
|
||||
sets: session.sets.map((set) => ({
|
||||
...set,
|
||||
exerciseName: set.exercise?.name || 'Unknown',
|
||||
type: set.exercise?.type || ExerciseType.STRENGTH
|
||||
})) as WorkoutSet[]
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateActiveSession = async (userId: string, session: WorkoutSession): Promise<void> => {
|
||||
await api.put('/sessions/active', session);
|
||||
};
|
||||
|
||||
export const deleteSetFromActiveSession = async (userId: string, setId: string): Promise<void> => {
|
||||
await api.delete(`/sessions/active/set/${setId}`);
|
||||
};
|
||||
|
||||
export const updateSetInActiveSession = async (userId: string, setId: string, setData: Partial<WorkoutSet>): Promise<WorkoutSet> => {
|
||||
const response = await api.put<{ success: boolean; updatedSet: WorkoutSet }>(`/sessions/active/set/${setId}`, setData);
|
||||
return response.updatedSet;
|
||||
};
|
||||
|
||||
export const deleteActiveSession = async (userId: string): Promise<void> => {
|
||||
await api.delete('/sessions/active');
|
||||
};
|
||||
|
||||
export const deleteSession = async (userId: string, id: string): Promise<void> => {
|
||||
await api.delete(`/sessions/${id}`);
|
||||
};
|
||||
|
||||
export const deleteAllUserData = (userId: string) => {
|
||||
// Not implemented in frontend
|
||||
};
|
||||
@@ -1,114 +1,3 @@
|
||||
import { WorkoutSession, ExerciseDef, ExerciseType, WorkoutSet, WorkoutPlan } from '../types';
|
||||
import { api } from './api';
|
||||
|
||||
export const getSessions = async (userId: string): Promise<WorkoutSession[]> => {
|
||||
try {
|
||||
const sessions = await api.get('/sessions');
|
||||
// Convert ISO date strings to timestamps
|
||||
return sessions.map((session: any) => ({
|
||||
...session,
|
||||
startTime: new Date(session.startTime).getTime(),
|
||||
endTime: session.endTime ? new Date(session.endTime).getTime() : undefined,
|
||||
sets: session.sets.map((set: any) => ({
|
||||
...set,
|
||||
exerciseName: set.exercise?.name || 'Unknown',
|
||||
type: set.exercise?.type || 'STRENGTH'
|
||||
}))
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const saveSession = async (userId: string, session: WorkoutSession): Promise<void> => {
|
||||
await api.post('/sessions', session);
|
||||
};
|
||||
|
||||
export const getActiveSession = async (userId: string): Promise<WorkoutSession | null> => {
|
||||
try {
|
||||
const response = await api.get('/sessions/active');
|
||||
if (!response.success || !response.session) {
|
||||
return null;
|
||||
}
|
||||
const session = response.session;
|
||||
// Convert ISO date strings to timestamps
|
||||
return {
|
||||
...session,
|
||||
startTime: new Date(session.startTime).getTime(),
|
||||
endTime: session.endTime ? new Date(session.endTime).getTime() : undefined,
|
||||
sets: session.sets.map((set: any) => ({
|
||||
...set,
|
||||
exerciseName: set.exercise?.name || 'Unknown',
|
||||
type: set.exercise?.type || 'STRENGTH'
|
||||
}))
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateActiveSession = async (userId: string, session: WorkoutSession): Promise<void> => {
|
||||
await api.put('/sessions/active', session);
|
||||
};
|
||||
|
||||
export const deleteSetFromActiveSession = async (userId: string, setId: string): Promise<void> => {
|
||||
await api.delete(`/sessions/active/set/${setId}`);
|
||||
};
|
||||
|
||||
export const updateSetInActiveSession = async (userId: string, setId: string, setData: Partial<WorkoutSet>): Promise<WorkoutSet> => {
|
||||
const response = await api.put(`/sessions/active/set/${setId}`, setData);
|
||||
return response.updatedSet;
|
||||
};
|
||||
|
||||
export const deleteActiveSession = async (userId: string): Promise<void> => {
|
||||
await api.delete('/sessions/active');
|
||||
};
|
||||
|
||||
export const deleteSession = async (userId: string, id: string): Promise<void> => {
|
||||
await api.delete(`/sessions/${id}`);
|
||||
};
|
||||
|
||||
export const deleteAllUserData = (userId: string) => {
|
||||
// Not implemented in frontend
|
||||
};
|
||||
|
||||
export const getExercises = async (userId: string): Promise<ExerciseDef[]> => {
|
||||
try {
|
||||
return await api.get('/exercises');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const saveExercise = async (userId: string, exercise: ExerciseDef): Promise<void> => {
|
||||
await api.post('/exercises', exercise);
|
||||
};
|
||||
|
||||
export const getLastSetForExercise = async (userId: string, exerciseId: string): Promise<WorkoutSet | undefined> => {
|
||||
try {
|
||||
const response = await api.get(`/exercises/${exerciseId}/last-set`);
|
||||
if (response.success && response.set) {
|
||||
return response.set;
|
||||
}
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch last set:", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const getPlans = async (userId: string): Promise<WorkoutPlan[]> => {
|
||||
try {
|
||||
return await api.get('/plans');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const savePlan = async (userId: string, plan: WorkoutPlan): Promise<void> => {
|
||||
await api.post('/plans', plan);
|
||||
};
|
||||
|
||||
export const deletePlan = async (userId: string, id: string): Promise<void> => {
|
||||
await api.delete(`/plans/${id}`);
|
||||
};
|
||||
export * from './exercises';
|
||||
export * from './sessions';
|
||||
export * from './plans';
|
||||
Reference in New Issue
Block a user