Code maintainability fixes

This commit is contained in:
AG
2025-12-06 11:32:40 +02:00
parent a13ef9f479
commit 4106f3b783
23 changed files with 1775 additions and 796 deletions

58
package-lock.json generated
View File

@@ -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",

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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,8 +28,8 @@
"@types/jsonwebtoken": "*",
"@types/node": "*",
"nodemon": "*",
"prisma": "*",
"prisma": "^7.1.0",
"ts-node": "*",
"typescript": "*"
}
}
}

Binary file not shown.

View File

@@ -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;

View File

@@ -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)
}

View File

@@ -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({
where: { id },
data: { name, description, exercises: exercisesJson }
});
res.json({ ...updated, steps: steps || [] });
} else {
const created = await prisma.workoutPlan.create({
data: {
id,
userId,
name,
description,
exercises: exercisesJson
if (plan) {
await tx.workoutPlan.update({
where: { id },
data: { name, description }
});
// Delete existing plan exercises
await tx.planExercise.deleteMany({ where: { planId: id } });
} else {
await tx.workoutPlan.create({
data: {
id,
userId,
name,
description
}
});
}
// 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' }
}
});
res.json({ ...created, steps: steps || [] });
}
}
});
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' });

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

View File

@@ -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' && (
<Tracker
userId={currentUser.id}
userWeight={currentUser.profile?.weight}
activeSession={activeSession}
activePlan={activePlan}
onSessionStart={handleStartSession}
onSessionEnd={handleEndSession}
onSessionQuit={handleQuitSession}
onSetAdded={handleAddSet}
onRemoveSet={handleRemoveSetFromActive}
onUpdateSet={handleUpdateSetInActive}
lang={language}
/>
)}
{currentTab === 'PLANS' && (
<Plans userId={currentUser.id} onStartPlan={handleStartSession} lang={language} />
)}
{currentTab === 'HISTORY' && (
<History
sessions={sessions}
onUpdateSession={handleUpdateSession}
onDeleteSession={handleDeleteSession}
lang={language}
/>
)}
{currentTab === 'STATS' && <Stats sessions={sessions} lang={language} />}
{currentTab === 'AI_COACH' && <AICoach history={sessions} userProfile={currentUser.profile} plans={plans} lang={language} />}
{currentTab === 'PROFILE' && (
<Profile
user={currentUser}
onLogout={handleLogout}
lang={language}
onLanguageChange={handleLanguageChange}
onUserUpdate={handleUserUpdate}
/>
)}
<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}
activeSession={activeSession}
activePlan={activePlan}
onSessionStart={startSession}
onSessionEnd={endSession}
onSessionQuit={quitSession}
onSetAdded={addSet}
onRemoveSet={removeSet}
onUpdateSet={updateSet}
lang={language}
/>
} />
<Route path="/plans" element={
<Plans userId={currentUser?.id || ''} onStartPlan={startSession} lang={language} />
} />
<Route path="/history" element={
<History
sessions={sessions}
onUpdateSession={updateSession}
onDeleteSession={deleteSessionById}
lang={language}
/>
} />
<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={setLanguage}
onUserUpdate={updateUser}
/>
} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</div>
</main>
{/* Mobile Navigation (rendered inside Navbar component, fixed to bottom) */}
</div>
);
}

View File

@@ -1,47 +1,48 @@
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'
}`}>
<item.icon size={22} strokeWidth={isActive ? 2.5 : 2} />
<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'
}`}>
{item.label}
<span className={`text-[10px] font-medium transition-colors truncate w-full text-center ${isActive ? 'text-on-surface' : 'text-on-surface-variant'
}`}>
{item.label}
</span>
</button>
);
@@ -51,29 +52,27 @@ const Navbar: React.FC<NavbarProps> = ({ currentTab, onTabChange, lang }) => {
{/* DESKTOP: Navigation Rail (MD3) */}
<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;
return (
<button
key={item.id}
onClick={() => onTabChange(item.id)}
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'
}`}>
<item.icon size={24} />
</div>
<span className={`text-[11px] font-medium text-center ${
isActive ? 'text-on-surface' : 'text-on-surface-variant'
}`}>
{item.label}
</span>
</button>
);
})}
</div>
<div className="flex flex-col gap-6 w-full px-2">
{navItems.map((item) => {
const isActive = currentPath === item.path || (item.path !== '/' && currentPath.startsWith(item.path));
return (
<button
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'
}`}>
<item.icon size={24} />
</div>
<span className={`text-[11px] font-medium text-center ${isActive ? 'text-on-surface' : 'text-on-surface-variant'
}`}>
{item.label}
</span>
</button>
);
})}
</div>
</div>
</>
);

View File

@@ -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];
if (step) {
const exDef = exercises.find(e => e.id === step.exerciseId);
if (exDef) {
setSelectedExercise(exDef);
}
}
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() || '');
} 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('');
}
await form.updateFormFromLastSet(selectedExercise.id, selectedExercise.type, selectedExercise.bodyWeightPercentage);
} 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
};
};

View 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
View 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;
};

View 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
};
};

View 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
View 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
};
};

View File

@@ -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>
<App />
<BrowserRouter>
<AuthProvider>
<DataProvider>
<App />
</DataProvider>
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
);

View File

@@ -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
View 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
View 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
View 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
};

View File

@@ -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';