1. Tailwind migretion. 2. Backend Type Safety. 3. Context Refactoring.
This commit is contained in:
116
index.html
116
index.html
@@ -1,86 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>GymFlow AI</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Roboto', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
// Material 3 Dark Theme approximation
|
||||
'surface': '#141218', // Very dark background
|
||||
'surface-container-low': '#1D1B20',
|
||||
'surface-container': '#211F26', // Cards
|
||||
'surface-container-high': '#2B2930', // Input fields
|
||||
'on-surface': '#E6E0E9',
|
||||
'on-surface-variant': '#CAC4D0',
|
||||
|
||||
'primary': '#D0BCFF', // M3 Purple/Lavender tone often used as primary in dark mode, but keeping Emerald logic
|
||||
// Let's map to Emerald for fitness vibe but M3 structure
|
||||
'm3-primary': '#80D8FF', // Light Blue
|
||||
'm3-on-primary': '#003355',
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>GymFlow AI</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
// Using the Emerald scheme from previous request but mapped to M3 tokens
|
||||
'primary': '#6EE7B7', // Emerald 300 (Light for dark mode)
|
||||
'on-primary': '#003828', // Emerald 900
|
||||
'primary-container': '#00513B', // Emerald 800
|
||||
'on-primary-container': '#6EE7B7', // Emerald 300
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
'secondary': '#CCC2DC',
|
||||
'secondary-container': '#4A4458',
|
||||
'on-secondary-container': '#E8DEF8',
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
'tertiary': '#EFB8C8',
|
||||
'tertiary-container': '#633B48',
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #49454F;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
'error': '#F2B8B5',
|
||||
'error-container': '#8C1D18',
|
||||
|
||||
'outline': '#938F99',
|
||||
'outline-variant': '#49454F'
|
||||
},
|
||||
boxShadow: {
|
||||
'elevation-1': '0px 1px 3px 1px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.30)',
|
||||
'elevation-2': '0px 2px 6px 2px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.30)',
|
||||
'elevation-3': '0px 4px 8px 3px rgba(0, 0, 0, 0.15), 0px 1px 3px 0px rgba(0, 0, 0, 0.30)',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
}
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #49454F;
|
||||
border-radius: 3px;
|
||||
}
|
||||
/* Hide number input arrows */
|
||||
input[type=number]::-webkit-inner-spin-button,
|
||||
input[type=number]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
/* Hide number input arrows */
|
||||
input[type=number]::-webkit-inner-spin-button,
|
||||
input[type=number]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
@@ -93,10 +48,13 @@
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/src/index.css">
|
||||
<link rel="stylesheet" href="/src/index.css">
|
||||
</head>
|
||||
<body class="bg-surface text-on-surface h-screen overflow-hidden selection:bg-primary-container selection:text-on-primary-container">
|
||||
<div id="root"></div>
|
||||
|
||||
<body
|
||||
class="bg-surface text-on-surface h-screen overflow-hidden selection:bg-primary-container selection:text-on-primary-container">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
835
package-lock.json
generated
835
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,10 @@
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@types/node": "^22.14.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"concurrently": "^8.2.2",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
12
server/package-lock.json
generated
12
server/package-lock.json
generated
@@ -18,7 +18,8 @@
|
||||
"dotenv": "17.2.3",
|
||||
"express": "5.1.0",
|
||||
"jsonwebtoken": "9.0.2",
|
||||
"ts-node-dev": "^2.0.0"
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "*",
|
||||
@@ -3454,6 +3455,15 @@
|
||||
"dependencies": {
|
||||
"grammex": "^3.1.10"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz",
|
||||
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"dotenv": "17.2.3",
|
||||
"express": "5.1.0",
|
||||
"jsonwebtoken": "9.0.2",
|
||||
"ts-node-dev": "^2.0.0"
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "*",
|
||||
|
||||
Binary file not shown.
15
server/src/middleware/validate.ts
Normal file
15
server/src/middleware/validate.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ZodSchema } from 'zod';
|
||||
|
||||
export const validate = (schema: ZodSchema<any>) => async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await schema.parseAsync({
|
||||
body: req.body,
|
||||
query: req.query,
|
||||
params: req.params,
|
||||
});
|
||||
return next();
|
||||
} catch (error) {
|
||||
return res.status(400).json(error);
|
||||
}
|
||||
};
|
||||
@@ -13,8 +13,8 @@ interface AuthRequest extends Request {
|
||||
|
||||
const router = express.Router();
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'secret';
|
||||
const API_KEY = process.env.API_KEY;
|
||||
const MODEL_ID = 'gemini-2.0-flash';
|
||||
const API_KEY = process.env.GEMINI_API_KEY || process.env.API_KEY;
|
||||
const MODEL_ID = 'gemini-flash-lite-latest';
|
||||
|
||||
// Store chat sessions in memory (in production, use Redis or similar)
|
||||
const chatSessions = new Map<string, any>();
|
||||
|
||||
@@ -2,6 +2,8 @@ import express from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import prisma from '../lib/prisma';
|
||||
import { validate } from '../middleware/validate';
|
||||
import { loginSchema, registerSchema, changePasswordSchema, updateProfileSchema } from '../schemas/auth';
|
||||
|
||||
const router = express.Router();
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'secret';
|
||||
@@ -30,7 +32,7 @@ router.get('/me', async (req, res) => {
|
||||
});
|
||||
|
||||
// Login
|
||||
router.post('/login', async (req, res) => {
|
||||
router.post('/login', validate(loginSchema), async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
@@ -63,7 +65,7 @@ router.post('/login', async (req, res) => {
|
||||
});
|
||||
|
||||
// Register
|
||||
router.post('/register', async (req, res) => {
|
||||
router.post('/register', validate(registerSchema), async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
@@ -73,10 +75,6 @@ router.post('/register', async (req, res) => {
|
||||
return res.status(400).json({ error: 'User already exists' });
|
||||
}
|
||||
|
||||
if (!password || password.length < 4) {
|
||||
return res.status(400).json({ error: 'Password too short' });
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
@@ -104,7 +102,7 @@ router.post('/register', async (req, res) => {
|
||||
});
|
||||
|
||||
// Change Password
|
||||
router.post('/change-password', async (req, res) => {
|
||||
router.post('/change-password', validate(changePasswordSchema), async (req, res) => {
|
||||
|
||||
try {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
@@ -118,10 +116,6 @@ router.post('/change-password', async (req, res) => {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
if (!newPassword || newPassword.length < 4) {
|
||||
return res.status(400).json({ error: 'Password too short' });
|
||||
}
|
||||
|
||||
const hashed = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
await prisma.user.update({
|
||||
@@ -140,7 +134,7 @@ router.post('/change-password', async (req, res) => {
|
||||
});
|
||||
|
||||
// Update Profile
|
||||
router.patch('/profile', async (req, res) => {
|
||||
router.patch('/profile', validate(updateProfileSchema), async (req, res) => {
|
||||
try {
|
||||
const token = req.headers.authorization?.split(' ')[1];
|
||||
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import express from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import prisma from '../lib/prisma';
|
||||
import { validate } from '../middleware/validate';
|
||||
import { sessionSchema, logSetSchema, updateSetSchema } from '../schemas/sessions';
|
||||
|
||||
const router = express.Router();
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'secret';
|
||||
@@ -53,7 +55,7 @@ router.get('/', async (req: any, res) => {
|
||||
});
|
||||
|
||||
// Save session (create or update)
|
||||
router.post('/', async (req: any, res) => {
|
||||
router.post('/', validate(sessionSchema), async (req: any, res) => {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const { id, startTime, endTime, userBodyWeight, note, planId, planName, sets } = req.body;
|
||||
@@ -148,15 +150,6 @@ router.post('/', async (req: any, res) => {
|
||||
return res.json(created);
|
||||
}
|
||||
|
||||
// Update user profile weight if session has weight and is finished (for update case too)
|
||||
if (weight && end) {
|
||||
await prisma.userProfile.upsert({
|
||||
where: { userId },
|
||||
create: { userId, weight },
|
||||
update: { weight }
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
@@ -188,7 +181,7 @@ router.get('/active', async (req: any, res) => {
|
||||
});
|
||||
|
||||
// Update active session (for real-time set updates)
|
||||
router.put('/active', async (req: any, res) => {
|
||||
router.put('/active', validate(sessionSchema), async (req: any, res) => {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const { id, startTime, endTime, userBodyWeight, note, planId, planName, sets } = req.body;
|
||||
@@ -293,7 +286,7 @@ router.get('/quick-log', async (req: any, res) => {
|
||||
});
|
||||
|
||||
// Log a set to today's quick log session
|
||||
router.post('/quick-log/set', async (req: any, res) => {
|
||||
router.post('/quick-log/set', validate(logSetSchema), async (req: any, res) => {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const { exerciseId, weight, reps, distanceMeters, durationSeconds, height, bodyWeightPercentage, note, side } = req.body;
|
||||
@@ -355,7 +348,7 @@ router.post('/quick-log/set', async (req: any, res) => {
|
||||
});
|
||||
|
||||
// Log a set to the active session
|
||||
router.post('/active/log-set', async (req: any, res) => {
|
||||
router.post('/active/log-set', validate(logSetSchema), async (req: any, res) => {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const { exerciseId, reps, weight, distanceMeters, durationSeconds, side } = req.body;
|
||||
@@ -486,7 +479,7 @@ router.put('/active/set/:setId', async (req: any, res) => {
|
||||
});
|
||||
|
||||
// Update a set in the active session (STANDARD or QUICK_LOG)
|
||||
router.patch('/active/set/:setId', async (req: any, res) => {
|
||||
router.patch('/active/set/:setId', validate(updateSetSchema), async (req: any, res) => {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const { setId } = req.params;
|
||||
@@ -591,113 +584,4 @@ router.delete('/:id', async (req: any, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get today's quick log session
|
||||
router.get('/quick-log', async (req: any, res) => {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const endOfDay = new Date();
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
const session = await prisma.workoutSession.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
type: 'QUICK_LOG',
|
||||
startTime: {
|
||||
gte: startOfDay,
|
||||
lte: endOfDay
|
||||
}
|
||||
},
|
||||
include: { sets: { include: { exercise: true }, orderBy: { timestamp: 'desc' } } }
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return res.json({ success: true, session: null });
|
||||
}
|
||||
|
||||
// Map exercise properties to sets for frontend compatibility
|
||||
const mappedSession = {
|
||||
...session,
|
||||
sets: session.sets.map((set: any) => ({
|
||||
...set,
|
||||
exerciseName: set.exercise.name,
|
||||
type: set.exercise.type
|
||||
}))
|
||||
};
|
||||
|
||||
res.json({ success: true, session: mappedSession });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Log a set to today's quick log session
|
||||
router.post('/quick-log/set', async (req: any, res) => {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const { exerciseId, weight, reps, distanceMeters, durationSeconds, height, bodyWeightPercentage, note, side } = req.body;
|
||||
|
||||
const startOfDay = new Date();
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const endOfDay = new Date();
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
// Find or create today's quick log session
|
||||
let session = await prisma.workoutSession.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
type: 'QUICK_LOG',
|
||||
startTime: {
|
||||
gte: startOfDay,
|
||||
lte: endOfDay
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
session = await prisma.workoutSession.create({
|
||||
data: {
|
||||
userId,
|
||||
startTime: startOfDay,
|
||||
type: 'QUICK_LOG',
|
||||
note: 'Daily Quick Log'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create the set
|
||||
const newSet = await prisma.workoutSet.create({
|
||||
data: {
|
||||
sessionId: session.id,
|
||||
exerciseId,
|
||||
order: 0, // Order not strictly enforced for quick log
|
||||
weight: weight ? parseFloat(weight) : null,
|
||||
reps: reps ? parseInt(reps) : null,
|
||||
distanceMeters: distanceMeters ? parseFloat(distanceMeters) : null,
|
||||
durationSeconds: durationSeconds ? parseInt(durationSeconds) : null,
|
||||
height: height ? parseFloat(height) : null,
|
||||
bodyWeightPercentage: bodyWeightPercentage ? parseFloat(bodyWeightPercentage) : null,
|
||||
side: side || null,
|
||||
completed: true,
|
||||
timestamp: new Date()
|
||||
},
|
||||
include: { exercise: true }
|
||||
});
|
||||
|
||||
const mappedSet = {
|
||||
...newSet,
|
||||
exerciseName: newSet.exercise.name,
|
||||
type: newSet.exercise.type
|
||||
};
|
||||
|
||||
res.json({ success: true, newSet: mappedSet });
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
32
server/src/schemas/auth.ts
Normal file
32
server/src/schemas/auth.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const loginSchema = z.object({
|
||||
body: z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
}),
|
||||
});
|
||||
|
||||
export const registerSchema = z.object({
|
||||
body: z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(4),
|
||||
role: z.enum(['USER', 'ADMIN']).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const changePasswordSchema = z.object({
|
||||
body: z.object({
|
||||
newPassword: z.string().min(4),
|
||||
}),
|
||||
});
|
||||
|
||||
export const updateProfileSchema = z.object({
|
||||
body: z.object({
|
||||
weight: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
gender: z.string().optional(),
|
||||
birthDate: z.string().optional(),
|
||||
language: z.string().optional()
|
||||
})
|
||||
})
|
||||
47
server/src/schemas/sessions.ts
Normal file
47
server/src/schemas/sessions.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const sessionSchema = z.object({
|
||||
body: z.object({
|
||||
startTime: z.union([z.number(), z.string(), z.date()]), // Date.now or ISO string
|
||||
endTime: z.union([z.number(), z.string(), z.date()]).nullable().optional(),
|
||||
userBodyWeight: z.number().nullable().optional(),
|
||||
note: z.string().nullable().optional(),
|
||||
planId: z.string().nullable().optional(),
|
||||
planName: z.string().nullable().optional(),
|
||||
sets: z.array(z.object({
|
||||
exerciseId: z.string(),
|
||||
weight: z.number().nullable().optional(),
|
||||
reps: z.number().nullable().optional(),
|
||||
distanceMeters: z.number().nullable().optional(),
|
||||
durationSeconds: z.number().nullable().optional(),
|
||||
completed: z.boolean().optional().default(true),
|
||||
side: z.string().nullable().optional()
|
||||
}))
|
||||
})
|
||||
});
|
||||
|
||||
export const logSetSchema = z.object({
|
||||
body: z.object({
|
||||
exerciseId: z.string(),
|
||||
weight: z.number().nullable().optional(),
|
||||
reps: z.number().nullable().optional(),
|
||||
distanceMeters: z.number().nullable().optional(),
|
||||
durationSeconds: z.number().nullable().optional(),
|
||||
height: z.number().nullable().optional(),
|
||||
bodyWeightPercentage: z.number().nullable().optional(),
|
||||
note: z.string().nullable().optional(),
|
||||
side: z.string().nullable().optional(),
|
||||
})
|
||||
});
|
||||
|
||||
export const updateSetSchema = z.object({
|
||||
body: z.object({
|
||||
weight: z.number().nullable().optional(),
|
||||
reps: z.number().nullable().optional(),
|
||||
distanceMeters: z.number().nullable().optional(),
|
||||
durationSeconds: z.number().nullable().optional(),
|
||||
height: z.number().nullable().optional(),
|
||||
bodyWeightPercentage: z.number().nullable().optional(),
|
||||
side: z.string().nullable().optional(),
|
||||
})
|
||||
});
|
||||
44
src/App.tsx
44
src/App.tsx
@@ -8,27 +8,12 @@ import AICoach from './components/AICoach';
|
||||
import Plans from './components/Plans';
|
||||
import Login from './components/Login';
|
||||
import Profile from './components/Profile';
|
||||
import { Language, User } from './types'; // Removed unused imports
|
||||
import { Language, User } from './types';
|
||||
import { getSystemLanguage } from './services/i18n';
|
||||
import { useAuth } from './context/AuthContext';
|
||||
import { useData } from './context/DataContext';
|
||||
|
||||
function App() {
|
||||
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 navigate = useNavigate();
|
||||
@@ -70,36 +55,19 @@ function App() {
|
||||
)
|
||||
} />
|
||||
<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}
|
||||
/>
|
||||
<Tracker lang={language} />
|
||||
} />
|
||||
<Route path="/plans" element={
|
||||
<Plans userId={currentUser?.id || ''} onStartPlan={startSession} lang={language} />
|
||||
<Plans lang={language} />
|
||||
} />
|
||||
<Route path="/history" element={
|
||||
<History
|
||||
sessions={sessions}
|
||||
onUpdateSession={updateSession}
|
||||
onDeleteSession={deleteSessionById}
|
||||
lang={language}
|
||||
/>
|
||||
<History lang={language} />
|
||||
} />
|
||||
<Route path="/stats" element={
|
||||
<Stats sessions={sessions} lang={language} />
|
||||
<Stats lang={language} />
|
||||
} />
|
||||
<Route path="/coach" element={
|
||||
<AICoach history={sessions} userProfile={currentUser?.profile} plans={plans} lang={language} />
|
||||
<AICoach lang={language} />
|
||||
} />
|
||||
<Route path="/profile" element={
|
||||
<Profile
|
||||
|
||||
@@ -6,11 +6,10 @@ import { WorkoutSession, Language, UserProfile, WorkoutPlan } from '../types';
|
||||
import { Chat, GenerateContentResponse } from '@google/genai';
|
||||
import { t } from '../services/i18n';
|
||||
import { generateId } from '../utils/uuid';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useSession } from '../context/SessionContext';
|
||||
|
||||
interface AICoachProps {
|
||||
history: WorkoutSession[];
|
||||
userProfile?: UserProfile;
|
||||
plans?: WorkoutPlan[];
|
||||
lang: Language;
|
||||
}
|
||||
|
||||
@@ -20,7 +19,11 @@ interface Message {
|
||||
text: string;
|
||||
}
|
||||
|
||||
const AICoach: React.FC<AICoachProps> = ({ history, userProfile, plans, lang }) => {
|
||||
const AICoach: React.FC<AICoachProps> = ({ lang }) => {
|
||||
const { currentUser } = useAuth();
|
||||
const { sessions: history, plans } = useSession();
|
||||
const userProfile = currentUser?.profile;
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{ id: 'intro', role: 'model', text: t('ai_intro', lang) }
|
||||
]);
|
||||
|
||||
@@ -3,16 +3,16 @@ import React, { useState } from 'react';
|
||||
import { Calendar, Clock, TrendingUp, Gauge, Pencil, Trash2, X, Save, ArrowRight, ArrowUp, Timer, Activity, Dumbbell, Percent } from 'lucide-react';
|
||||
import { WorkoutSession, ExerciseType, WorkoutSet, Language } from '../types';
|
||||
import { t } from '../services/i18n';
|
||||
import { useSession } from '../context/SessionContext';
|
||||
|
||||
interface HistoryProps {
|
||||
sessions: WorkoutSession[];
|
||||
onUpdateSession?: (session: WorkoutSession) => void;
|
||||
onDeleteSession?: (sessionId: string) => void;
|
||||
lang: Language;
|
||||
}
|
||||
|
||||
const History: React.FC<HistoryProps> = ({ sessions, onUpdateSession, onDeleteSession, lang }) => {
|
||||
const History: React.FC<HistoryProps> = ({ lang }) => {
|
||||
const { sessions, updateSession, deleteSession } = useSession();
|
||||
const [editingSession, setEditingSession] = useState<WorkoutSession | null>(null);
|
||||
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [deletingSetInfo, setDeletingSetInfo] = useState<{ sessionId: string, setId: string } | null>(null);
|
||||
|
||||
@@ -60,10 +60,15 @@ const History: React.FC<HistoryProps> = ({ sessions, onUpdateSession, onDeleteSe
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (editingSession && onUpdateSession) {
|
||||
onUpdateSession(editingSession);
|
||||
setEditingSession(null);
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (editingSession) {
|
||||
try {
|
||||
await updateSession(editingSession);
|
||||
setEditingSession(null);
|
||||
} catch (e) {
|
||||
console.error("Failed to update session", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -83,11 +88,15 @@ const History: React.FC<HistoryProps> = ({ sessions, onUpdateSession, onDeleteSe
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (deletingId && onDeleteSession) {
|
||||
onDeleteSession(deletingId);
|
||||
setDeletingId(null);
|
||||
} else if (deletingSetInfo && onUpdateSession) {
|
||||
const handleConfirmDelete = async () => {
|
||||
if (deletingId) {
|
||||
try {
|
||||
await deleteSession(deletingId);
|
||||
setDeletingId(null);
|
||||
} catch (e) {
|
||||
console.error("Failed to delete session", e);
|
||||
}
|
||||
} else if (deletingSetInfo) {
|
||||
// Find the session
|
||||
const session = sessions.find(s => s.id === deletingSetInfo.sessionId);
|
||||
if (session) {
|
||||
@@ -96,7 +105,11 @@ const History: React.FC<HistoryProps> = ({ sessions, onUpdateSession, onDeleteSe
|
||||
...session,
|
||||
sets: session.sets.filter(s => s.id !== deletingSetInfo.setId)
|
||||
};
|
||||
onUpdateSession(updatedSession);
|
||||
try {
|
||||
await updateSession(updatedSession);
|
||||
} catch (e) {
|
||||
console.error("Failed to update session after set delete", e);
|
||||
}
|
||||
}
|
||||
setDeletingSetInfo(null);
|
||||
}
|
||||
@@ -104,6 +117,7 @@ const History: React.FC<HistoryProps> = ({ sessions, onUpdateSession, onDeleteSe
|
||||
|
||||
|
||||
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-on-surface-variant p-8 text-center">
|
||||
|
||||
@@ -2,21 +2,26 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, ChevronRight, List, ArrowUp, ArrowDown, Scale, Edit2, User, Flame, Timer as TimerIcon, Ruler, Footprints, Activity, Percent, CheckCircle, GripVertical } from 'lucide-react';
|
||||
import { WorkoutPlan, ExerciseDef, PlannedSet, Language, ExerciseType } from '../types';
|
||||
import { getPlans, savePlan, deletePlan, getExercises, saveExercise } from '../services/storage';
|
||||
import { getExercises, saveExercise } from '../services/storage';
|
||||
import { t } from '../services/i18n';
|
||||
import { generateId } from '../utils/uuid';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useSession } from '../context/SessionContext';
|
||||
import { useActiveWorkout } from '../context/ActiveWorkoutContext';
|
||||
|
||||
import FilledInput from './FilledInput';
|
||||
import { toTitleCase } from '../utils/text';
|
||||
|
||||
interface PlansProps {
|
||||
userId: string;
|
||||
onStartPlan: (plan: WorkoutPlan) => void;
|
||||
lang: Language;
|
||||
}
|
||||
|
||||
const Plans: React.FC<PlansProps> = ({ userId, onStartPlan, lang }) => {
|
||||
const [plans, setPlans] = useState<WorkoutPlan[]>([]);
|
||||
const Plans: React.FC<PlansProps> = ({ lang }) => {
|
||||
const { currentUser } = useAuth();
|
||||
const userId = currentUser?.id || '';
|
||||
const { plans, savePlan, deletePlan } = useSession();
|
||||
const { startSession } = useActiveWorkout();
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
@@ -39,9 +44,6 @@ const Plans: React.FC<PlansProps> = ({ userId, onStartPlan, lang }) => {
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const fetchedPlans = await getPlans(userId);
|
||||
setPlans(fetchedPlans);
|
||||
|
||||
const fetchedExercises = await getExercises(userId);
|
||||
// Filter out archived exercises
|
||||
if (Array.isArray(fetchedExercises)) {
|
||||
@@ -50,7 +52,7 @@ const Plans: React.FC<PlansProps> = ({ userId, onStartPlan, lang }) => {
|
||||
setAvailableExercises([]);
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
if (userId) loadData();
|
||||
}, [userId]);
|
||||
|
||||
const handleCreateNew = () => {
|
||||
@@ -72,18 +74,14 @@ const Plans: React.FC<PlansProps> = ({ userId, onStartPlan, lang }) => {
|
||||
const handleSave = async () => {
|
||||
if (!name.trim() || !editId) return;
|
||||
const newPlan: WorkoutPlan = { id: editId, name, description, steps };
|
||||
await savePlan(userId, newPlan);
|
||||
const updated = await getPlans(userId);
|
||||
setPlans(updated);
|
||||
await savePlan(newPlan);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (confirm(t('delete_confirm', lang))) {
|
||||
await deletePlan(userId, id);
|
||||
const updated = await getPlans(userId);
|
||||
setPlans(updated);
|
||||
await deletePlan(id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -391,7 +389,7 @@ const Plans: React.FC<PlansProps> = ({ userId, onStartPlan, lang }) => {
|
||||
{plan.steps.length} {t('exercises_count', lang)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onStartPlan(plan)}
|
||||
onClick={() => startSession(plan)}
|
||||
className="flex items-center gap-2 bg-primary text-on-primary px-5 py-2 rounded-full text-sm font-medium hover:shadow-elevation-2 transition-all"
|
||||
>
|
||||
<PlayCircle size={18} />
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { WorkoutSession, ExerciseType, Language, BodyWeightRecord } from '../types';
|
||||
import { getWeightHistory } from '../services/weight';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts';
|
||||
import { t } from '../services/i18n';
|
||||
import { useSession } from '../context/SessionContext';
|
||||
|
||||
interface StatsProps {
|
||||
sessions: WorkoutSession[];
|
||||
lang: Language;
|
||||
}
|
||||
|
||||
const Stats: React.FC<StatsProps> = ({ sessions, lang }) => {
|
||||
const Stats: React.FC<StatsProps> = ({ lang }) => {
|
||||
const { sessions } = useSession();
|
||||
const [weightRecords, setWeightRecords] = useState<BodyWeightRecord[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,30 +1,18 @@
|
||||
|
||||
import React from 'react';
|
||||
import { WorkoutSession, WorkoutSet, WorkoutPlan, Language } from '../../types';
|
||||
import { Language } from '../../types';
|
||||
import { useTracker } from './useTracker';
|
||||
import IdleView from './IdleView';
|
||||
import SporadicView from './SporadicView';
|
||||
import ActiveSessionView from './ActiveSessionView';
|
||||
|
||||
interface TrackerProps {
|
||||
userId: string;
|
||||
userWeight?: number;
|
||||
activeSession: WorkoutSession | null;
|
||||
activePlan: WorkoutPlan | null;
|
||||
onSessionStart: (plan?: WorkoutPlan, startWeight?: number) => void;
|
||||
onSessionEnd: () => void;
|
||||
onSessionQuit: () => void;
|
||||
onSetAdded: (set: WorkoutSet) => void;
|
||||
onRemoveSet: (setId: string) => void;
|
||||
onUpdateSet: (set: WorkoutSet) => void;
|
||||
onSporadicSetAdded?: () => void;
|
||||
lang: Language;
|
||||
}
|
||||
|
||||
const Tracker: React.FC<TrackerProps> = (props) => {
|
||||
const tracker = useTracker(props);
|
||||
const { isSporadicMode } = tracker;
|
||||
const { activeSession, lang, onSessionEnd, onSessionQuit, onRemoveSet } = props;
|
||||
const Tracker: React.FC<TrackerProps> = ({ lang }) => {
|
||||
const tracker = useTracker({}); // No props needed, hook uses context
|
||||
const { activeSession, isSporadicMode, onSessionEnd, onSessionQuit, onRemoveSet } = tracker;
|
||||
|
||||
if (activeSession) {
|
||||
return (
|
||||
|
||||
@@ -1,36 +1,32 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { WorkoutSession, WorkoutSet, ExerciseDef, ExerciseType, WorkoutPlan } from '../../types';
|
||||
import { WorkoutSession, WorkoutSet, ExerciseDef, 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';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { useActiveWorkout } from '../../context/ActiveWorkoutContext';
|
||||
import { useSession } from '../../context/SessionContext';
|
||||
|
||||
interface UseTrackerProps {
|
||||
userId: string;
|
||||
userWeight?: number;
|
||||
activeSession: WorkoutSession | null;
|
||||
activePlan: WorkoutPlan | null;
|
||||
onSessionStart: (plan?: WorkoutPlan, startWeight?: number) => void;
|
||||
onSessionEnd: () => void;
|
||||
onSessionQuit: () => void;
|
||||
onSetAdded: (set: WorkoutSet) => void;
|
||||
onRemoveSet: (setId: string) => void;
|
||||
onUpdateSet: (set: WorkoutSet) => void;
|
||||
onSporadicSetAdded?: () => void;
|
||||
}
|
||||
export const useTracker = (props: any) => { // Props ignored/removed
|
||||
const { currentUser } = useAuth();
|
||||
const userId = currentUser?.id || '';
|
||||
const userWeight = currentUser?.profile?.weight;
|
||||
|
||||
const {
|
||||
activeSession,
|
||||
activePlan,
|
||||
startSession,
|
||||
addSet,
|
||||
updateSet,
|
||||
quitSession,
|
||||
endSession,
|
||||
removeSet
|
||||
} = useActiveWorkout();
|
||||
|
||||
const { refreshData: refreshHistory } = useSession();
|
||||
|
||||
export const useTracker = ({
|
||||
userId,
|
||||
userWeight,
|
||||
activeSession,
|
||||
activePlan,
|
||||
onSessionStart,
|
||||
onSessionEnd,
|
||||
onSetAdded,
|
||||
onUpdateSet,
|
||||
onSporadicSetAdded
|
||||
}: UseTrackerProps) => {
|
||||
const [exercises, setExercises] = useState<ExerciseDef[]>([]);
|
||||
const [plans, setPlans] = useState<WorkoutPlan[]>([]);
|
||||
const [selectedExercise, setSelectedExercise] = useState<ExerciseDef | null>(null);
|
||||
@@ -56,11 +52,18 @@ export const useTracker = ({
|
||||
|
||||
// Hooks
|
||||
const elapsedTime = useSessionTimer(activeSession);
|
||||
const form = useWorkoutForm({ userId, onUpdateSet });
|
||||
// useWorkoutForm needs onUpdateSet. But context updateSet signature might be different?
|
||||
// context: updateSet(setId, updates). useWorkoutForm expects onUpdateSet(set).
|
||||
// We can adaptor.
|
||||
const handleUpdateSetWrapper = (set: WorkoutSet) => {
|
||||
updateSet(set.id, set);
|
||||
};
|
||||
const form = useWorkoutForm({ userId, onUpdateSet: handleUpdateSetWrapper });
|
||||
const planExec = usePlanExecution({ activeSession, activePlan, exercises });
|
||||
|
||||
// Initial Data Load
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
const loadData = async () => {
|
||||
const exList = await getExercises(userId);
|
||||
exList.sort((a, b) => a.name.localeCompare(b.name));
|
||||
@@ -82,7 +85,7 @@ export const useTracker = ({
|
||||
// Function to reload Quick Log session
|
||||
const loadQuickLogSession = async () => {
|
||||
try {
|
||||
const response = await api.get('/sessions/quick-log');
|
||||
const response = await api.get<{ success: boolean; session?: WorkoutSession }>('/sessions/quick-log');
|
||||
if (response.success && response.session) {
|
||||
setQuickLogSession(response.session);
|
||||
}
|
||||
@@ -125,40 +128,21 @@ export const useTracker = ({
|
||||
if (plan && plan.description) {
|
||||
planExec.setShowPlanPrep(plan);
|
||||
} else {
|
||||
onSessionStart(plan, parseFloat(userBodyWeight));
|
||||
startSession(plan, parseFloat(userBodyWeight));
|
||||
}
|
||||
};
|
||||
|
||||
const confirmPlanStart = () => {
|
||||
if (planExec.showPlanPrep) {
|
||||
onSessionStart(planExec.showPlanPrep, parseFloat(userBodyWeight));
|
||||
startSession(planExec.showPlanPrep, parseFloat(userBodyWeight));
|
||||
planExec.setShowPlanPrep(null);
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddSet = async () => {
|
||||
if (!activeSession || !selectedExercise) return;
|
||||
|
||||
const setData = form.prepareSetData(selectedExercise);
|
||||
|
||||
try {
|
||||
const response = await api.post('/sessions/active/log-set', setData);
|
||||
if (response.success) {
|
||||
const { newSet, activeExerciseId } = response;
|
||||
onSetAdded(newSet);
|
||||
|
||||
if (activePlan && activeExerciseId) {
|
||||
const nextStepIndex = activePlan.steps.findIndex(step => step.exerciseId === activeExerciseId);
|
||||
if (nextStepIndex !== -1) {
|
||||
planExec.setCurrentStepIndex(nextStepIndex);
|
||||
}
|
||||
} else if (activePlan && !activeExerciseId) {
|
||||
planExec.setCurrentStepIndex(activePlan.steps.length);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to log set:", error);
|
||||
}
|
||||
await addSet(setData);
|
||||
};
|
||||
|
||||
const handleLogSporadicSet = async () => {
|
||||
@@ -172,7 +156,7 @@ export const useTracker = ({
|
||||
setTimeout(() => setSporadicSuccess(false), 2000);
|
||||
loadQuickLogSession();
|
||||
form.resetForm();
|
||||
if (onSporadicSetAdded) onSporadicSetAdded();
|
||||
refreshHistory();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to log quick log set:", error);
|
||||
@@ -255,6 +239,14 @@ export const useTracker = ({
|
||||
handleCancelEdit,
|
||||
resetForm,
|
||||
quickLogSession,
|
||||
loadQuickLogSession
|
||||
loadQuickLogSession,
|
||||
|
||||
// Pass through context methods for UI to use
|
||||
onSessionEnd: endSession,
|
||||
onSessionQuit: quitSession,
|
||||
onRemoveSet: removeSet,
|
||||
activeSession // Need this in view
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
||||
225
src/context/ActiveWorkoutContext.tsx
Normal file
225
src/context/ActiveWorkoutContext.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { WorkoutSession, WorkoutPlan, WorkoutSet } from '../types';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { useSession } from './SessionContext';
|
||||
import {
|
||||
getActiveSession,
|
||||
updateActiveSession,
|
||||
deleteActiveSession,
|
||||
addSetToActiveSession,
|
||||
deleteSetFromActiveSession,
|
||||
updateSetInActiveSession,
|
||||
saveSession
|
||||
} from '../services/sessions';
|
||||
import { getPlans } from '../services/plans';
|
||||
import { generateId } from '../utils/uuid';
|
||||
import { logWeight } from '../services/weight';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface ActiveWorkoutContextType {
|
||||
activeSession: WorkoutSession | null;
|
||||
activePlan: WorkoutPlan | null;
|
||||
isLoading: boolean;
|
||||
startSession: (plan?: WorkoutPlan, startWeight?: number) => Promise<void>;
|
||||
endSession: () => Promise<void>;
|
||||
quitSession: () => Promise<void>;
|
||||
addSet: (set: Partial<WorkoutSet>) => Promise<void>;
|
||||
removeSet: (setId: string) => Promise<void>;
|
||||
updateSet: (setId: string, updates: Partial<WorkoutSet>) => Promise<void>;
|
||||
updateSessionNote: (note: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const ActiveWorkoutContext = createContext<ActiveWorkoutContextType | undefined>(undefined);
|
||||
|
||||
export const ActiveWorkoutProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { currentUser, updateUser } = useAuth();
|
||||
const { refreshData: refreshHistory } = useSession(); // Access session history refresh
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [activeSession, setActiveSession] = useState<WorkoutSession | null>(null);
|
||||
const [activePlan, setActivePlan] = useState<WorkoutPlan | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
// Restore active session on mount
|
||||
useEffect(() => {
|
||||
const restoreActive = async () => {
|
||||
if (currentUser) {
|
||||
try {
|
||||
const session = await getActiveSession(currentUser.id);
|
||||
if (session) {
|
||||
setActiveSession(session);
|
||||
if (session.planId) {
|
||||
// Ideally fetch specific plan, but fetching all for now to find it
|
||||
// Or session could include plan details? Backend stores planName/ID.
|
||||
// We need the plan object for steps.
|
||||
const plans = await getPlans(currentUser.id);
|
||||
const plan = plans.find(p => p.id === session.planId);
|
||||
if (plan) setActivePlan(plan);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to restore active session", e);
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
restoreActive();
|
||||
}, [currentUser]);
|
||||
|
||||
const startSession = async (plan?: WorkoutPlan, startWeight?: number) => {
|
||||
if (!currentUser || activeSession) return;
|
||||
|
||||
const newSession: WorkoutSession = {
|
||||
id: generateId(),
|
||||
startTime: Date.now(),
|
||||
type: 'STANDARD',
|
||||
userBodyWeight: startWeight,
|
||||
sets: [],
|
||||
planId: plan?.id,
|
||||
planName: plan?.name
|
||||
};
|
||||
|
||||
// Optimistic update
|
||||
setActivePlan(plan || null);
|
||||
setActiveSession(newSession);
|
||||
navigate('/');
|
||||
|
||||
try {
|
||||
await saveSession(currentUser.id, newSession);
|
||||
if (startWeight) {
|
||||
await logWeight(startWeight);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to start session", error);
|
||||
// Revert state?
|
||||
setActiveSession(null);
|
||||
setActivePlan(null);
|
||||
}
|
||||
};
|
||||
|
||||
const endSession = async () => {
|
||||
if (activeSession && currentUser) {
|
||||
const finishedSession = { ...activeSession, endTime: Date.now() };
|
||||
|
||||
// Optimistic clear
|
||||
setActiveSession(null);
|
||||
setActivePlan(null);
|
||||
|
||||
try {
|
||||
await updateActiveSession(currentUser.id, finishedSession);
|
||||
await refreshHistory(); // Refresh history in SessionContext
|
||||
} catch (error) {
|
||||
console.error("Failed to end session", error);
|
||||
// Restore state? This is tricky.
|
||||
setActiveSession(activeSession);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const quitSession = async () => {
|
||||
if (currentUser) {
|
||||
// Optimistic clear
|
||||
setActiveSession(null);
|
||||
setActivePlan(null);
|
||||
|
||||
try {
|
||||
await deleteActiveSession(currentUser.id);
|
||||
} catch (error) {
|
||||
console.error("Failed to quit session", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addSet = async (setData: Partial<WorkoutSet>) => {
|
||||
if (activeSession && currentUser) {
|
||||
try {
|
||||
// Call API first to get ID and calculated fields if any
|
||||
// The API expects: exerciseId, reps, weight, etc.
|
||||
const response = await addSetToActiveSession(currentUser.id, setData);
|
||||
|
||||
// Response should contain the new set or updated session
|
||||
// Our backend returns { success: true, newSet: ..., activeExerciseId: ... }
|
||||
// or similar. I need to type the response properly or cast it.
|
||||
// Assuming response.newSet needs to be added.
|
||||
|
||||
if (response.success && response.newSet) {
|
||||
setActiveSession(prev => prev ? ({
|
||||
...prev,
|
||||
sets: [...prev.sets, response.newSet]
|
||||
}) : null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to add set", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeSet = async (setId: string) => {
|
||||
if (activeSession && currentUser) {
|
||||
// Optimistic
|
||||
setActiveSession(prev => prev ? ({
|
||||
...prev,
|
||||
sets: prev.sets.filter(s => s.id !== setId)
|
||||
}) : null);
|
||||
|
||||
try {
|
||||
await deleteSetFromActiveSession(currentUser.id, setId);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete set", error);
|
||||
// Revert?
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateSet = async (setId: string, updates: Partial<WorkoutSet>) => {
|
||||
if (activeSession && currentUser) {
|
||||
// Optimistic
|
||||
setActiveSession(prev => prev ? ({
|
||||
...prev,
|
||||
sets: prev.sets.map(s => s.id === setId ? { ...s, ...updates } : s)
|
||||
}) : null);
|
||||
|
||||
try {
|
||||
await updateSetInActiveSession(currentUser.id, setId, updates);
|
||||
} catch (error) {
|
||||
console.error("Failed to update set", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateSessionNote = async (note: string) => {
|
||||
if (activeSession && currentUser) {
|
||||
setActiveSession(prev => prev ? ({ ...prev, note }) : null);
|
||||
try {
|
||||
await updateActiveSession(currentUser.id, { ...activeSession, note });
|
||||
} catch (error) {
|
||||
console.error("Failed to update note", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ActiveWorkoutContext.Provider value={{
|
||||
activeSession,
|
||||
activePlan,
|
||||
isLoading,
|
||||
startSession,
|
||||
endSession,
|
||||
quitSession,
|
||||
addSet,
|
||||
removeSet,
|
||||
updateSet,
|
||||
updateSessionNote
|
||||
}}>
|
||||
{children}
|
||||
</ActiveWorkoutContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useActiveWorkout = () => {
|
||||
const context = useContext(ActiveWorkoutContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useActiveWorkout must be used within an ActiveWorkoutProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,208 +0,0 @@
|
||||
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;
|
||||
};
|
||||
133
src/context/SessionContext.tsx
Normal file
133
src/context/SessionContext.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { WorkoutSession, WorkoutPlan } from '../types';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { getSessions, saveSession, deleteSession as apiDeleteSession } from '../services/sessions';
|
||||
import { getPlans, savePlan as apiSavePlan, deletePlan as apiDeletePlan } from '../services/plans';
|
||||
|
||||
interface SessionContextType {
|
||||
sessions: WorkoutSession[];
|
||||
plans: WorkoutPlan[];
|
||||
isLoading: boolean;
|
||||
refreshData: () => Promise<void>;
|
||||
updateSession: (session: WorkoutSession) => Promise<void>;
|
||||
deleteSession: (id: string) => Promise<void>;
|
||||
savePlan: (plan: WorkoutPlan) => Promise<void>;
|
||||
deletePlan: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const SessionContext = createContext<SessionContextType | undefined>(undefined);
|
||||
|
||||
export const SessionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { currentUser } = useAuth();
|
||||
const [sessions, setSessions] = useState<WorkoutSession[]>([]);
|
||||
const [plans, setPlans] = useState<WorkoutPlan[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
const refreshData = useCallback(async () => {
|
||||
if (!currentUser) {
|
||||
setSessions([]);
|
||||
setPlans([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const [fetchedSessions, fetchedPlans] = await Promise.all([
|
||||
getSessions(currentUser.id),
|
||||
getPlans(currentUser.id)
|
||||
]);
|
||||
setSessions(fetchedSessions);
|
||||
setPlans(fetchedPlans);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshData();
|
||||
}, [refreshData]);
|
||||
|
||||
const updateSession = async (session: WorkoutSession) => {
|
||||
if (!currentUser) return;
|
||||
try {
|
||||
await saveSession(currentUser.id, session);
|
||||
setSessions(prev => {
|
||||
const existing = prev.find(s => s.id === session.id);
|
||||
if (existing) {
|
||||
return prev.map(s => s.id === session.id ? session : s);
|
||||
} else {
|
||||
return [session, ...prev];
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update session', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSession = async (id: string) => {
|
||||
if (!currentUser) return;
|
||||
try {
|
||||
await apiDeleteSession(currentUser.id, id);
|
||||
setSessions(prev => prev.filter(s => s.id !== id));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete session', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const savePlan = async (plan: WorkoutPlan) => {
|
||||
if (!currentUser) return;
|
||||
try {
|
||||
await apiSavePlan(currentUser.id, plan);
|
||||
setPlans(prev => {
|
||||
const existing = prev.find(p => p.id === plan.id);
|
||||
if (existing) {
|
||||
return prev.map(p => p.id === plan.id ? plan : p);
|
||||
} else {
|
||||
return [...prev, plan];
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save plan', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const deletePlan = async (id: string) => {
|
||||
if (!currentUser) return;
|
||||
try {
|
||||
await apiDeletePlan(currentUser.id, id);
|
||||
setPlans(prev => prev.filter(p => p.id !== id));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete plan', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SessionContext.Provider value={{
|
||||
sessions,
|
||||
plans,
|
||||
isLoading,
|
||||
refreshData,
|
||||
updateSession,
|
||||
deleteSession,
|
||||
savePlan,
|
||||
deletePlan
|
||||
}}>
|
||||
{children}
|
||||
</SessionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSession = () => {
|
||||
const context = useContext(SessionContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSession must be used within a SessionProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -2,7 +2,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 { SessionProvider } from './context/SessionContext';
|
||||
import { ActiveWorkoutProvider } from './context/ActiveWorkoutContext';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
@@ -16,9 +17,11 @@ root.render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<DataProvider>
|
||||
<App />
|
||||
</DataProvider>
|
||||
<SessionProvider>
|
||||
<ActiveWorkoutProvider>
|
||||
<App />
|
||||
</ActiveWorkoutProvider>
|
||||
</SessionProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
|
||||
@@ -80,6 +80,11 @@ export const deleteSession = async (userId: string, id: string): Promise<void> =
|
||||
await api.delete(`/sessions/${id}`);
|
||||
};
|
||||
|
||||
|
||||
export const addSetToActiveSession = async (userId: string, setData: any): Promise<any> => {
|
||||
return await api.post('/sessions/active/log-set', setData);
|
||||
};
|
||||
|
||||
export const deleteAllUserData = (userId: string) => {
|
||||
// Not implemented in frontend
|
||||
};
|
||||
|
||||
50
tailwind.config.js
Normal file
50
tailwind.config.js
Normal file
@@ -0,0 +1,50 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Roboto', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
// Material 3 Dark Theme approximation
|
||||
'surface': '#141218', // Very dark background
|
||||
'surface-container-low': '#1D1B20',
|
||||
'surface-container': '#211F26', // Cards
|
||||
'surface-container-high': '#2B2930', // Input fields
|
||||
'on-surface': '#E6E0E9',
|
||||
'on-surface-variant': '#CAC4D0',
|
||||
|
||||
// Using the Emerald scheme from previous request but mapped to M3 tokens
|
||||
'primary': '#6EE7B7', // Emerald 300 (Light for dark mode)
|
||||
'on-primary': '#003828', // Emerald 900
|
||||
'primary-container': '#00513B', // Emerald 800
|
||||
'on-primary-container': '#6EE7B7', // Emerald 300
|
||||
|
||||
'secondary': '#CCC2DC',
|
||||
'secondary-container': '#4A4458',
|
||||
'on-secondary-container': '#E8DEF8',
|
||||
|
||||
'tertiary': '#EFB8C8',
|
||||
'tertiary-container': '#633B48',
|
||||
|
||||
'error': '#F2B8B5',
|
||||
'error-container': '#8C1D18',
|
||||
'on-error': '#601410',
|
||||
'on-error-container': '#F9DEDC',
|
||||
|
||||
'outline': '#938F99',
|
||||
'outline-variant': '#49454F'
|
||||
},
|
||||
boxShadow: {
|
||||
'elevation-1': '0px 1px 3px 1px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.30)',
|
||||
'elevation-2': '0px 2px 6px 2px rgba(0, 0, 0, 0.15), 0px 1px 2px 0px rgba(0, 0, 0, 0.30)',
|
||||
'elevation-3': '0px 4px 8px 3px rgba(0, 0, 0, 0.15), 0px 1px 3px 0px rgba(0, 0, 0, 0.30)',
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
Reference in New Issue
Block a user