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",
|
"playwright-test": "^14.1.12",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.10.1",
|
||||||
"recharts": "^3.4.1"
|
"recharts": "^3.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -2043,6 +2044,19 @@
|
|||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -5414,6 +5428,44 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/read-pkg": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz",
|
||||||
@@ -5760,6 +5812,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"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": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"playwright-test": "^14.1.12",
|
"playwright-test": "^14.1.12",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.10.1",
|
||||||
"recharts": "^3.4.1"
|
"recharts": "^3.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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": {
|
"dependencies": {
|
||||||
"@google/generative-ai": "^0.24.1",
|
"@google/generative-ai": "^0.24.1",
|
||||||
"@prisma/adapter-better-sqlite3": "^7.1.0",
|
"@prisma/adapter-better-sqlite3": "^7.1.0",
|
||||||
"@prisma/client": "^6.19.0",
|
"@prisma/client": "^7.1.0",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"bcryptjs": "3.0.3",
|
"bcryptjs": "3.0.3",
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
"@types/jsonwebtoken": "*",
|
"@types/jsonwebtoken": "*",
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
"nodemon": "*",
|
"nodemon": "*",
|
||||||
"prisma": "*",
|
"prisma": "^7.1.0",
|
||||||
"ts-node": "*",
|
"ts-node": "*",
|
||||||
"typescript": "*"
|
"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 {
|
datasource db {
|
||||||
provider = "sqlite"
|
provider = "sqlite"
|
||||||
url = env("DATABASE_URL")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -60,6 +59,7 @@ model Exercise {
|
|||||||
isUnilateral Boolean @default(false)
|
isUnilateral Boolean @default(false)
|
||||||
|
|
||||||
sets WorkoutSet[]
|
sets WorkoutSet[]
|
||||||
|
planExercises PlanExercise[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model WorkoutSession {
|
model WorkoutSession {
|
||||||
@@ -102,8 +102,19 @@ model WorkoutPlan {
|
|||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
name String
|
name String
|
||||||
description 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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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 {
|
try {
|
||||||
const userId = req.user.userId;
|
const userId = req.user.userId;
|
||||||
const plans = await prisma.workoutPlan.findMany({
|
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) => ({
|
const mappedPlans = plans.map((p: any) => ({
|
||||||
...p,
|
...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);
|
res.json(mappedPlans);
|
||||||
@@ -46,28 +60,71 @@ router.post('/', async (req: any, res) => {
|
|||||||
const userId = req.user.userId;
|
const userId = req.user.userId;
|
||||||
const { id, name, description, steps } = req.body;
|
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) {
|
if (plan) {
|
||||||
const updated = await prisma.workoutPlan.update({
|
await tx.workoutPlan.update({
|
||||||
where: { id },
|
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 {
|
} else {
|
||||||
const created = await prisma.workoutPlan.create({
|
await tx.workoutPlan.create({
|
||||||
data: {
|
data: {
|
||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
name,
|
name,
|
||||||
description,
|
description
|
||||||
exercises: exercisesJson
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error('Error saving plan:', error);
|
console.error('Error saving plan:', error);
|
||||||
res.status(500).json({ error: 'Server 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 React, { useState, useEffect } from 'react';
|
||||||
|
import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import Navbar from './components/Navbar';
|
import Navbar from './components/Navbar';
|
||||||
import Tracker from './components/Tracker/index';
|
import Tracker from './components/Tracker/index';
|
||||||
import History from './components/History';
|
import History from './components/History';
|
||||||
@@ -8,250 +8,112 @@ import AICoach from './components/AICoach';
|
|||||||
import Plans from './components/Plans';
|
import Plans from './components/Plans';
|
||||||
import Login from './components/Login';
|
import Login from './components/Login';
|
||||||
import Profile from './components/Profile';
|
import Profile from './components/Profile';
|
||||||
import { TabView, WorkoutSession, WorkoutSet, WorkoutPlan, User, Language } from './types';
|
import { Language, User } from './types'; // Removed unused imports
|
||||||
import { getSessions, saveSession, deleteSession, getPlans, getActiveSession, updateActiveSession, deleteActiveSession, updateSetInActiveSession, deleteSetFromActiveSession } from './services/storage';
|
|
||||||
import { getCurrentUserProfile, getMe } from './services/auth';
|
|
||||||
import { getSystemLanguage } from './services/i18n';
|
import { getSystemLanguage } from './services/i18n';
|
||||||
import { logWeight } from './services/weight';
|
import { useAuth } from './context/AuthContext';
|
||||||
import { generateId } from './utils/uuid';
|
import { useData } from './context/DataContext';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
const { currentUser, updateUser, logout } = useAuth();
|
||||||
const [currentTab, setCurrentTab] = useState<TabView>('TRACK');
|
const {
|
||||||
|
sessions,
|
||||||
|
plans,
|
||||||
|
activeSession,
|
||||||
|
activePlan,
|
||||||
|
startSession,
|
||||||
|
endSession,
|
||||||
|
quitSession,
|
||||||
|
addSet,
|
||||||
|
removeSet,
|
||||||
|
updateSet,
|
||||||
|
updateSession,
|
||||||
|
deleteSessionById
|
||||||
|
} = useData();
|
||||||
|
|
||||||
const [language, setLanguage] = useState<Language>('en');
|
const [language, setLanguage] = useState<Language>('en');
|
||||||
|
const navigate = useNavigate();
|
||||||
const [sessions, setSessions] = useState<WorkoutSession[]>([]);
|
const location = useLocation();
|
||||||
const [plans, setPlans] = useState<WorkoutPlan[]>([]);
|
|
||||||
const [activeSession, setActiveSession] = useState<WorkoutSession | null>(null);
|
|
||||||
const [activePlan, setActivePlan] = useState<WorkoutPlan | null>(null);
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Set initial language
|
|
||||||
setLanguage(getSystemLanguage());
|
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) => {
|
const handleLogin = (user: User) => {
|
||||||
setCurrentUser(user);
|
updateUser(user);
|
||||||
setCurrentTab('TRACK');
|
navigate('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem('token');
|
logout();
|
||||||
setCurrentUser(null);
|
navigate('/login');
|
||||||
setActiveSession(null);
|
|
||||||
setActivePlan(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLanguageChange = (lang: Language) => {
|
if (!currentUser && location.pathname !== '/login') {
|
||||||
setLanguage(lang);
|
return <Navigate to="/login" />;
|
||||||
};
|
|
||||||
|
|
||||||
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} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-screen bg-surface text-on-surface font-sans flex flex-col md:flex-row overflow-hidden">
|
<div className="h-screen w-screen bg-surface text-on-surface font-sans flex flex-col md:flex-row overflow-hidden">
|
||||||
|
{currentUser && (
|
||||||
{/* Desktop Navigation Rail (Left) */}
|
<Navbar lang={language} />
|
||||||
<Navbar currentTab={currentTab} onTabChange={setCurrentTab} lang={language} />
|
)}
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<main className="flex-1 h-full relative w-full max-w-5xl mx-auto md:px-4">
|
<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">
|
<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
|
<Tracker
|
||||||
userId={currentUser.id}
|
userId={currentUser?.id || ''}
|
||||||
userWeight={currentUser.profile?.weight}
|
userWeight={currentUser?.profile?.weight}
|
||||||
activeSession={activeSession}
|
activeSession={activeSession}
|
||||||
activePlan={activePlan}
|
activePlan={activePlan}
|
||||||
onSessionStart={handleStartSession}
|
onSessionStart={startSession}
|
||||||
onSessionEnd={handleEndSession}
|
onSessionEnd={endSession}
|
||||||
onSessionQuit={handleQuitSession}
|
onSessionQuit={quitSession}
|
||||||
onSetAdded={handleAddSet}
|
onSetAdded={addSet}
|
||||||
onRemoveSet={handleRemoveSetFromActive}
|
onRemoveSet={removeSet}
|
||||||
onUpdateSet={handleUpdateSetInActive}
|
onUpdateSet={updateSet}
|
||||||
lang={language}
|
lang={language}
|
||||||
/>
|
/>
|
||||||
)}
|
} />
|
||||||
{currentTab === 'PLANS' && (
|
<Route path="/plans" element={
|
||||||
<Plans userId={currentUser.id} onStartPlan={handleStartSession} lang={language} />
|
<Plans userId={currentUser?.id || ''} onStartPlan={startSession} lang={language} />
|
||||||
)}
|
} />
|
||||||
{currentTab === 'HISTORY' && (
|
<Route path="/history" element={
|
||||||
<History
|
<History
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
onUpdateSession={handleUpdateSession}
|
onUpdateSession={updateSession}
|
||||||
onDeleteSession={handleDeleteSession}
|
onDeleteSession={deleteSessionById}
|
||||||
lang={language}
|
lang={language}
|
||||||
/>
|
/>
|
||||||
)}
|
} />
|
||||||
{currentTab === 'STATS' && <Stats sessions={sessions} lang={language} />}
|
<Route path="/stats" element={
|
||||||
{currentTab === 'AI_COACH' && <AICoach history={sessions} userProfile={currentUser.profile} plans={plans} lang={language} />}
|
<Stats sessions={sessions} lang={language} />
|
||||||
{currentTab === 'PROFILE' && (
|
} />
|
||||||
|
<Route path="/coach" element={
|
||||||
|
<AICoach history={sessions} userProfile={currentUser?.profile} plans={plans} lang={language} />
|
||||||
|
} />
|
||||||
|
<Route path="/profile" element={
|
||||||
<Profile
|
<Profile
|
||||||
user={currentUser}
|
user={currentUser}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
lang={language}
|
lang={language}
|
||||||
onLanguageChange={handleLanguageChange}
|
onLanguageChange={setLanguage}
|
||||||
onUserUpdate={handleUserUpdate}
|
onUserUpdate={updateUser}
|
||||||
/>
|
/>
|
||||||
)}
|
} />
|
||||||
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Mobile Navigation (rendered inside Navbar component, fixed to bottom) */}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,46 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Dumbbell, History as HistoryIcon, BarChart2, MessageSquare, ClipboardList, User } from 'lucide-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 { t } from '../services/i18n';
|
||||||
|
import { Language } from '../types';
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
currentTab: TabView;
|
|
||||||
onTabChange: (tab: TabView) => void;
|
|
||||||
lang: Language;
|
lang: Language;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Navbar: React.FC<NavbarProps> = ({ currentTab, onTabChange, lang }) => {
|
const Navbar: React.FC<NavbarProps> = ({ lang }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ id: 'TRACK' as TabView, icon: Dumbbell, label: t('tab_tracker', lang) },
|
{ path: '/', icon: Dumbbell, label: t('tab_tracker', lang) },
|
||||||
{ id: 'PLANS' as TabView, icon: ClipboardList, label: t('tab_plans', lang) },
|
{ path: '/plans', icon: ClipboardList, label: t('tab_plans', lang) },
|
||||||
{ id: 'HISTORY' as TabView, icon: HistoryIcon, label: t('tab_history', lang) },
|
{ path: '/history', icon: HistoryIcon, label: t('tab_history', lang) },
|
||||||
{ id: 'STATS' as TabView, icon: BarChart2, label: t('tab_stats', lang) },
|
{ path: '/stats', icon: BarChart2, label: t('tab_stats', lang) },
|
||||||
{ id: 'AI_COACH' as TabView, icon: MessageSquare, label: t('tab_ai', lang) },
|
{ path: '/coach', icon: MessageSquare, label: t('tab_ai', lang) },
|
||||||
{ id: 'PROFILE' as TabView, icon: User, label: t('tab_profile', lang) },
|
{ path: '/profile', icon: User, label: t('tab_profile', lang) },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const currentPath = location.pathname;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* MOBILE: Bottom Navigation Bar (MD3) */}
|
{/* 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="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">
|
<div className="flex justify-evenly items-center h-full px-1">
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const isActive = currentTab === item.id;
|
const isActive = currentPath === item.path || (item.path !== '/' && currentPath.startsWith(item.path));
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.path}
|
||||||
onClick={() => onTabChange(item.id)}
|
onClick={() => navigate(item.path)}
|
||||||
className="flex flex-col items-center justify-center w-full h-full gap-1 group min-w-0"
|
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 ${
|
<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'
|
||||||
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} />
|
<item.icon size={22} strokeWidth={isActive ? 2.5 : 2} />
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-[10px] font-medium transition-colors truncate w-full text-center ${
|
<span className={`text-[10px] font-medium transition-colors truncate w-full text-center ${isActive ? 'text-on-surface' : 'text-on-surface-variant'
|
||||||
isActive ? 'text-on-surface' : 'text-on-surface-variant'
|
|
||||||
}`}>
|
}`}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</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="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">
|
<div className="flex flex-col gap-6 w-full px-2">
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const isActive = currentTab === item.id;
|
const isActive = currentPath === item.path || (item.path !== '/' && currentPath.startsWith(item.path));
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.path}
|
||||||
onClick={() => onTabChange(item.id)}
|
onClick={() => navigate(item.path)}
|
||||||
className="flex flex-col items-center gap-1 group w-full"
|
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 ${
|
<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'
|
||||||
isActive ? 'bg-primary-container text-on-primary-container' : 'text-on-surface-variant group-hover:bg-surface-container-high'
|
|
||||||
}`}>
|
}`}>
|
||||||
<item.icon size={24} />
|
<item.icon size={24} />
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-[11px] font-medium text-center ${
|
<span className={`text-[11px] font-medium text-center ${isActive ? 'text-on-surface' : 'text-on-surface-variant'
|
||||||
isActive ? 'text-on-surface' : 'text-on-surface-variant'
|
|
||||||
}`}>
|
}`}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { WorkoutSession, WorkoutSet, ExerciseDef, ExerciseType, WorkoutPlan, Language } from '../../types';
|
import { WorkoutSession, WorkoutSet, ExerciseDef, ExerciseType, WorkoutPlan } from '../../types';
|
||||||
import { getExercises, getLastSetForExercise, saveExercise, getPlans } from '../../services/storage';
|
import { getExercises, saveExercise, getPlans } from '../../services/storage';
|
||||||
import { api } from '../../services/api';
|
import { api } from '../../services/api';
|
||||||
|
import { useSessionTimer } from '../../hooks/useSessionTimer';
|
||||||
|
import { useWorkoutForm } from '../../hooks/useWorkoutForm';
|
||||||
|
import { usePlanExecution } from '../../hooks/usePlanExecution';
|
||||||
|
|
||||||
interface UseTrackerProps {
|
interface UseTrackerProps {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -25,9 +27,7 @@ export const useTracker = ({
|
|||||||
activePlan,
|
activePlan,
|
||||||
onSessionStart,
|
onSessionStart,
|
||||||
onSessionEnd,
|
onSessionEnd,
|
||||||
onSessionQuit,
|
|
||||||
onSetAdded,
|
onSetAdded,
|
||||||
onRemoveSet,
|
|
||||||
onUpdateSet,
|
onUpdateSet,
|
||||||
onSporadicSetAdded
|
onSporadicSetAdded
|
||||||
}: UseTrackerProps) => {
|
}: UseTrackerProps) => {
|
||||||
@@ -38,49 +38,28 @@ export const useTracker = ({
|
|||||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
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
|
// User Weight State
|
||||||
const [userBodyWeight, setUserBodyWeight] = useState<string>(userWeight ? userWeight.toString() : '70');
|
const [userBodyWeight, setUserBodyWeight] = useState<string>(userWeight ? userWeight.toString() : '70');
|
||||||
|
|
||||||
// Create Exercise State
|
// Create Exercise State
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
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
|
// Confirmation State
|
||||||
const [showFinishConfirm, setShowFinishConfirm] = useState(false);
|
const [showFinishConfirm, setShowFinishConfirm] = useState(false);
|
||||||
const [showQuitConfirm, setShowQuitConfirm] = useState(false);
|
const [showQuitConfirm, setShowQuitConfirm] = useState(false);
|
||||||
const [showMenu, setShowMenu] = 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
|
// Quick Log State
|
||||||
const [quickLogSession, setQuickLogSession] = useState<WorkoutSession | null>(null);
|
const [quickLogSession, setQuickLogSession] = useState<WorkoutSession | null>(null);
|
||||||
const [isSporadicMode, setIsSporadicMode] = useState(false);
|
const [isSporadicMode, setIsSporadicMode] = useState(false);
|
||||||
const [sporadicSuccess, setSporadicSuccess] = useState(false);
|
const [sporadicSuccess, setSporadicSuccess] = useState(false);
|
||||||
|
|
||||||
// Unilateral Exercise State
|
// Hooks
|
||||||
const [unilateralSide, setUnilateralSide] = useState<'LEFT' | 'RIGHT'>('LEFT');
|
const elapsedTime = useSessionTimer(activeSession);
|
||||||
|
const form = useWorkoutForm({ userId, onUpdateSet });
|
||||||
|
const planExec = usePlanExecution({ activeSession, activePlan, exercises });
|
||||||
|
|
||||||
|
// Initial Data Load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
const exList = await getExercises(userId);
|
const exList = await getExercises(userId);
|
||||||
@@ -95,15 +74,7 @@ export const useTracker = ({
|
|||||||
setUserBodyWeight(userWeight.toString());
|
setUserBodyWeight(userWeight.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load Quick Log Session
|
loadQuickLogSession();
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
loadData();
|
loadData();
|
||||||
}, [activeSession, userId, userWeight, activePlan]);
|
}, [activeSession, userId, userWeight, activePlan]);
|
||||||
@@ -120,107 +91,30 @@ export const useTracker = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Auto-select exercise from plan step
|
||||||
|
|
||||||
// Timer Logic
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let interval: number;
|
const step = planExec.getCurrentStep();
|
||||||
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) {
|
if (step) {
|
||||||
const exDef = exercises.find(e => e.id === step.exerciseId);
|
const exDef = exercises.find(e => e.id === step.exerciseId);
|
||||||
if (exDef) {
|
if (exDef) {
|
||||||
setSelectedExercise(exDef);
|
setSelectedExercise(exDef);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}, [planExec.currentStepIndex, activePlan, exercises]);
|
||||||
}
|
|
||||||
}, [currentStepIndex, activePlan, exercises]);
|
|
||||||
|
|
||||||
|
// Update form when exercise changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateSelection = async () => {
|
const updateSelection = async () => {
|
||||||
if (selectedExercise) {
|
if (selectedExercise) {
|
||||||
setBwPercentage(selectedExercise.bodyWeightPercentage ? selectedExercise.bodyWeightPercentage.toString() : '100');
|
|
||||||
setSearchQuery(selectedExercise.name);
|
setSearchQuery(selectedExercise.name);
|
||||||
const set = await getLastSetForExercise(userId, selectedExercise.id);
|
await form.updateFormFromLastSet(selectedExercise.id, selectedExercise.type, selectedExercise.bodyWeightPercentage);
|
||||||
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 {
|
} else {
|
||||||
setWeight(''); setReps(''); setDuration(''); setDistance(''); setHeight('');
|
setSearchQuery('');
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
updateSelection();
|
updateSelection();
|
||||||
}, [selectedExercise, userId]);
|
}, [selectedExercise, userId]);
|
||||||
|
|
||||||
|
|
||||||
const filteredExercises = searchQuery === ''
|
const filteredExercises = searchQuery === ''
|
||||||
? exercises
|
? exercises
|
||||||
: exercises.filter(ex =>
|
: exercises.filter(ex =>
|
||||||
@@ -229,58 +123,23 @@ export const useTracker = ({
|
|||||||
|
|
||||||
const handleStart = (plan?: WorkoutPlan) => {
|
const handleStart = (plan?: WorkoutPlan) => {
|
||||||
if (plan && plan.description) {
|
if (plan && plan.description) {
|
||||||
setShowPlanPrep(plan);
|
planExec.setShowPlanPrep(plan);
|
||||||
} else {
|
} else {
|
||||||
onSessionStart(plan, parseFloat(userBodyWeight));
|
onSessionStart(plan, parseFloat(userBodyWeight));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmPlanStart = () => {
|
const confirmPlanStart = () => {
|
||||||
if (showPlanPrep) {
|
if (planExec.showPlanPrep) {
|
||||||
onSessionStart(showPlanPrep, parseFloat(userBodyWeight));
|
onSessionStart(planExec.showPlanPrep, parseFloat(userBodyWeight));
|
||||||
setShowPlanPrep(null);
|
planExec.setShowPlanPrep(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddSet = async () => {
|
const handleAddSet = async () => {
|
||||||
if (!activeSession || !selectedExercise) return;
|
if (!activeSession || !selectedExercise) return;
|
||||||
|
|
||||||
const setData: Partial<WorkoutSet> = {
|
const setData = form.prepareSetData(selectedExercise);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.post('/sessions/active/log-set', setData);
|
const response = await api.post('/sessions/active/log-set', setData);
|
||||||
@@ -291,11 +150,10 @@ export const useTracker = ({
|
|||||||
if (activePlan && activeExerciseId) {
|
if (activePlan && activeExerciseId) {
|
||||||
const nextStepIndex = activePlan.steps.findIndex(step => step.exerciseId === activeExerciseId);
|
const nextStepIndex = activePlan.steps.findIndex(step => step.exerciseId === activeExerciseId);
|
||||||
if (nextStepIndex !== -1) {
|
if (nextStepIndex !== -1) {
|
||||||
setCurrentStepIndex(nextStepIndex);
|
planExec.setCurrentStepIndex(nextStepIndex);
|
||||||
}
|
}
|
||||||
} else if (activePlan && !activeExerciseId) {
|
} else if (activePlan && !activeExerciseId) {
|
||||||
// Plan is finished
|
planExec.setCurrentStepIndex(activePlan.steps.length);
|
||||||
setCurrentStepIndex(activePlan.steps.length);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -305,62 +163,15 @@ export const useTracker = ({
|
|||||||
|
|
||||||
const handleLogSporadicSet = async () => {
|
const handleLogSporadicSet = async () => {
|
||||||
if (!selectedExercise) return;
|
if (!selectedExercise) return;
|
||||||
|
const setData = form.prepareSetData(selectedExercise);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.post('/sessions/quick-log/set', setData);
|
const response = await api.post('/sessions/quick-log/set', setData);
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setSporadicSuccess(true);
|
setSporadicSuccess(true);
|
||||||
setTimeout(() => setSporadicSuccess(false), 2000);
|
setTimeout(() => setSporadicSuccess(false), 2000);
|
||||||
|
loadQuickLogSession();
|
||||||
// Refresh quick log session
|
form.resetForm();
|
||||||
const sessionRes = await api.get('/sessions/quick-log');
|
|
||||||
if (sessionRes.success && sessionRes.session) {
|
|
||||||
setQuickLogSession(sessionRes.session);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset form
|
|
||||||
setWeight('');
|
|
||||||
setReps('');
|
|
||||||
setDuration('');
|
|
||||||
setDistance('');
|
|
||||||
setHeight('');
|
|
||||||
if (onSporadicSetAdded) onSporadicSetAdded();
|
if (onSporadicSetAdded) onSporadicSetAdded();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -376,44 +187,14 @@ export const useTracker = ({
|
|||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditSet = (set: WorkoutSet) => {
|
// Forwarding form handlers from hook
|
||||||
setEditingSetId(set.id);
|
const handleEditSet = form.startEditing;
|
||||||
setEditWeight(set.weight?.toString() || '');
|
const handleSaveEdit = form.saveEdit;
|
||||||
setEditReps(set.reps?.toString() || '');
|
const handleCancelEdit = form.cancelEdit;
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Reset override
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setWeight('');
|
form.resetForm();
|
||||||
setReps('');
|
|
||||||
setDuration('');
|
|
||||||
setDistance('');
|
|
||||||
setHeight('');
|
|
||||||
setSelectedExercise(null);
|
setSelectedExercise(null);
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setSporadicSuccess(false);
|
setSporadicSuccess(false);
|
||||||
@@ -431,46 +212,37 @@ export const useTracker = ({
|
|||||||
showSuggestions,
|
showSuggestions,
|
||||||
setShowSuggestions,
|
setShowSuggestions,
|
||||||
elapsedTime,
|
elapsedTime,
|
||||||
weight,
|
// Form Props
|
||||||
setWeight,
|
weight: form.weight, setWeight: form.setWeight,
|
||||||
reps,
|
reps: form.reps, setReps: form.setReps,
|
||||||
setReps,
|
duration: form.duration, setDuration: form.setDuration,
|
||||||
duration,
|
distance: form.distance, setDistance: form.setDistance,
|
||||||
setDuration,
|
height: form.height, setHeight: form.setHeight,
|
||||||
distance,
|
bwPercentage: form.bwPercentage, setBwPercentage: form.setBwPercentage,
|
||||||
setDistance,
|
unilateralSide: form.unilateralSide, setUnilateralSide: form.setUnilateralSide,
|
||||||
height,
|
|
||||||
setHeight,
|
userBodyWeight, setUserBodyWeight,
|
||||||
bwPercentage,
|
isCreating, setIsCreating,
|
||||||
setBwPercentage,
|
|
||||||
userBodyWeight,
|
// Plan Execution Props
|
||||||
setUserBodyWeight,
|
currentStepIndex: planExec.currentStepIndex,
|
||||||
isCreating,
|
showPlanPrep: planExec.showPlanPrep, setShowPlanPrep: planExec.setShowPlanPrep,
|
||||||
setIsCreating,
|
showPlanList: planExec.showPlanList, setShowPlanList: planExec.setShowPlanList,
|
||||||
currentStepIndex,
|
jumpToStep: planExec.jumpToStep,
|
||||||
showPlanPrep,
|
|
||||||
setShowPlanPrep,
|
showFinishConfirm, setShowFinishConfirm,
|
||||||
showPlanList,
|
showQuitConfirm, setShowQuitConfirm,
|
||||||
setShowPlanList,
|
showMenu, setShowMenu,
|
||||||
showFinishConfirm,
|
|
||||||
setShowFinishConfirm,
|
// Editing
|
||||||
showQuitConfirm,
|
editingSetId: form.editingSetId,
|
||||||
setShowQuitConfirm,
|
editWeight: form.editWeight, setEditWeight: form.setEditWeight,
|
||||||
showMenu,
|
editReps: form.editReps, setEditReps: form.setEditReps,
|
||||||
setShowMenu,
|
editDuration: form.editDuration, setEditDuration: form.setEditDuration,
|
||||||
editingSetId,
|
editDistance: form.editDistance, setEditDistance: form.setEditDistance,
|
||||||
editWeight,
|
editHeight: form.editHeight, setEditHeight: form.setEditHeight,
|
||||||
setEditWeight,
|
|
||||||
editReps,
|
isSporadicMode, setIsSporadicMode,
|
||||||
setEditReps,
|
|
||||||
editDuration,
|
|
||||||
setEditDuration,
|
|
||||||
editDistance,
|
|
||||||
setEditDistance,
|
|
||||||
editHeight,
|
|
||||||
setEditHeight,
|
|
||||||
isSporadicMode,
|
|
||||||
setIsSporadicMode,
|
|
||||||
sporadicSuccess,
|
sporadicSuccess,
|
||||||
filteredExercises,
|
filteredExercises,
|
||||||
handleStart,
|
handleStart,
|
||||||
@@ -481,11 +253,8 @@ export const useTracker = ({
|
|||||||
handleEditSet,
|
handleEditSet,
|
||||||
handleSaveEdit,
|
handleSaveEdit,
|
||||||
handleCancelEdit,
|
handleCancelEdit,
|
||||||
jumpToStep,
|
|
||||||
resetForm,
|
resetForm,
|
||||||
unilateralSide,
|
quickLogSession,
|
||||||
setUnilateralSide,
|
loadQuickLogSession
|
||||||
quickLogSession, // Export this
|
|
||||||
loadQuickLogSession, // Export reload function
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
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 React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
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 App from './App';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
@@ -11,6 +14,12 @@ if (!rootElement) {
|
|||||||
const root = ReactDOM.createRoot(rootElement);
|
const root = ReactDOM.createRoot(rootElement);
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<DataProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</DataProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
@@ -13,12 +13,12 @@ const headers = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
get: async (endpoint: string) => {
|
get: async <T = any>(endpoint: string): Promise<T> => {
|
||||||
const res = await fetch(`${API_URL}${endpoint}`, { headers: headers() });
|
const res = await fetch(`${API_URL}${endpoint}`, { headers: headers() });
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
return res.json();
|
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}`, {
|
const res = await fetch(`${API_URL}${endpoint}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: headers(),
|
headers: headers(),
|
||||||
@@ -27,7 +27,7 @@ export const api = {
|
|||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
return res.json();
|
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}`, {
|
const res = await fetch(`${API_URL}${endpoint}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: headers(),
|
headers: headers(),
|
||||||
@@ -36,7 +36,7 @@ export const api = {
|
|||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
delete: async (endpoint: string) => {
|
delete: async <T = any>(endpoint: string): Promise<T> => {
|
||||||
const res = await fetch(`${API_URL}${endpoint}`, {
|
const res = await fetch(`${API_URL}${endpoint}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: headers()
|
headers: headers()
|
||||||
@@ -44,7 +44,7 @@ export const api = {
|
|||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
return res.json();
|
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}`, {
|
const res = await fetch(`${API_URL}${endpoint}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: headers(),
|
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';
|
export * from './exercises';
|
||||||
import { api } from './api';
|
export * from './sessions';
|
||||||
|
export * from './plans';
|
||||||
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}`);
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user