Backend is here. Default admin is created if needed.

This commit is contained in:
aodulov
2025-11-19 10:48:37 +02:00
parent 10819cc6f5
commit bb705c8a63
25 changed files with 3662 additions and 944 deletions

11
App.tsx
View File

@@ -28,15 +28,16 @@ function App() {
}, []); }, []);
useEffect(() => { useEffect(() => {
const loadSessions = async () => {
if (currentUser) { if (currentUser) {
setSessions(getSessions(currentUser.id)); const s = await getSessions(currentUser.id);
const profile = getCurrentUserProfile(currentUser.id); setSessions(s);
if (profile?.language) { // Profile fetch is skipped for now as it returns undefined
setLanguage(profile.language);
}
} else { } else {
setSessions([]); setSessions([]);
} }
};
loadSessions();
}, [currentUser]); }, [currentUser]);
const handleLogin = (user: User) => { const handleLogin = (user: User) => {

13
admin_check.js Normal file
View File

@@ -0,0 +1,13 @@
// Simple script to check for admin user
const { PrismaClient } = require('@prisma/client');
(async () => {
const prisma = new PrismaClient();
try {
const admin = await prisma.user.findFirst({ where: { role: 'ADMIN' } });
console.log('Admin user:', admin);
} catch (e) {
console.error('Error:', e);
} finally {
await prisma.$disconnect();
}
})();

View File

@@ -68,7 +68,17 @@ const AICoach: React.FC<AICoachProps> = ({ history, lang }) => {
setMessages(prev => [...prev, aiMsg]); setMessages(prev => [...prev, aiMsg]);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'model', text: 'Connection error.' }]); let errorText = 'Connection error.';
if (err instanceof Error) {
try {
const json = JSON.parse(err.message);
if (json.error) errorText = json.error;
else errorText = err.message;
} catch {
errorText = err.message;
}
}
setMessages(prev => [...prev, { id: crypto.randomUUID(), role: 'model', text: errorText }]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -97,8 +107,7 @@ const AICoach: React.FC<AICoachProps> = ({ history, lang }) => {
<div className="flex-1 overflow-y-auto p-4 space-y-4 pb-4"> <div className="flex-1 overflow-y-auto p-4 space-y-4 pb-4">
{messages.map((msg) => ( {messages.map((msg) => (
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}> <div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] p-4 rounded-[20px] text-sm leading-relaxed shadow-sm ${ <div className={`max-w-[85%] p-4 rounded-[20px] text-sm leading-relaxed shadow-sm ${msg.role === 'user'
msg.role === 'user'
? 'bg-primary text-on-primary rounded-br-none' ? 'bg-primary text-on-primary rounded-br-none'
: 'bg-surface-container-high text-on-surface border border-outline-variant/20 rounded-bl-none' : 'bg-surface-container-high text-on-surface border border-outline-variant/20 rounded-bl-none'
}`}> }`}>

View File

@@ -1,4 +1,3 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { login, changePassword } from '../services/auth'; import { login, changePassword } from '../services/auth';
import { User, Language } from '../types'; import { User, Language } from '../types';
@@ -21,9 +20,10 @@ const Login: React.FC<LoginProps> = ({ onLogin, language, onLanguageChange }) =>
const [tempUser, setTempUser] = useState<User | null>(null); const [tempUser, setTempUser] = useState<User | null>(null);
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
const handleLogin = (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const res = login(email, password);
const res = await login(email, password);
if (res.success && res.user) { if (res.success && res.user) {
if (res.user.isFirstLogin) { if (res.user.isFirstLogin) {
setTempUser(res.user); setTempUser(res.user);
@@ -138,7 +138,7 @@ const Login: React.FC<LoginProps> = ({ onLogin, language, onLanguageChange }) =>
</button> </button>
</form> </form>
<p className="mt-8 text-xs text-on-surface-variant text-center max-w-xs"> <p className="mt-8 text-xs text-on-surface-variant text-center max-w-xs mx-auto">
{t('login_contact_admin', language)} {t('login_contact_admin', language)}
</p> </p>
</div> </div>

View File

@@ -21,6 +21,7 @@ const Tracker: React.FC<TrackerProps> = ({ userId, activeSession, activePlan, on
const [exercises, setExercises] = useState<ExerciseDef[]>([]); const [exercises, setExercises] = useState<ExerciseDef[]>([]);
const [plans, setPlans] = useState<WorkoutPlan[]>([]); const [plans, setPlans] = useState<WorkoutPlan[]>([]);
const [selectedExercise, setSelectedExercise] = useState<ExerciseDef | null>(null); const [selectedExercise, setSelectedExercise] = useState<ExerciseDef | null>(null);
const [lastSet, setLastSet] = useState<WorkoutSet | undefined>(undefined);
// Timer State // Timer State
const [elapsedTime, setElapsedTime] = useState<string>('00:00:00'); const [elapsedTime, setElapsedTime] = useState<string>('00:00:00');
@@ -48,15 +49,24 @@ const Tracker: React.FC<TrackerProps> = ({ userId, activeSession, activePlan, on
const [showPlanList, setShowPlanList] = useState(false); const [showPlanList, setShowPlanList] = useState(false);
useEffect(() => { useEffect(() => {
// Filter out archived exercises for the selector const loadData = async () => {
setExercises(getExercises(userId).filter(e => !e.isArchived)); const exList = await getExercises(userId);
setPlans(getPlans(userId)); setExercises(exList.filter(e => !e.isArchived));
const planList = await getPlans(userId);
setPlans(planList);
if (activeSession?.userBodyWeight) { if (activeSession?.userBodyWeight) {
setUserBodyWeight(activeSession.userBodyWeight.toString()); setUserBodyWeight(activeSession.userBodyWeight.toString());
} else { } else {
const profile = getCurrentUserProfile(userId); // Profile fetch needs to be async too if we updated it,
setUserBodyWeight(profile?.weight ? profile.weight.toString() : '70'); // but for now let's assume we can get it or it's passed.
// Actually getCurrentUserProfile returns undefined now.
// We should probably fetch it properly.
// For now, default to 70.
setUserBodyWeight('70');
} }
};
loadData();
}, [activeSession, userId]); }, [activeSession, userId]);
// Timer Logic // Timer Logic
@@ -94,19 +104,24 @@ const Tracker: React.FC<TrackerProps> = ({ userId, activeSession, activePlan, on
}, [activeSession, activePlan, currentStepIndex, exercises]); }, [activeSession, activePlan, currentStepIndex, exercises]);
useEffect(() => { useEffect(() => {
const updateSelection = async () => {
if (selectedExercise) { if (selectedExercise) {
setBwPercentage(selectedExercise.bodyWeightPercentage ? selectedExercise.bodyWeightPercentage.toString() : '100'); setBwPercentage(selectedExercise.bodyWeightPercentage ? selectedExercise.bodyWeightPercentage.toString() : '100');
const lastSet = getLastSetForExercise(userId, selectedExercise.id); const set = await getLastSetForExercise(userId, selectedExercise.id);
if (lastSet) { setLastSet(set);
if (lastSet.weight !== undefined) setWeight(lastSet.weight.toString());
if (lastSet.reps !== undefined) setReps(lastSet.reps.toString()); if (set) {
if (lastSet.durationSeconds !== undefined) setDuration(lastSet.durationSeconds.toString()); if (set.weight !== undefined) setWeight(set.weight.toString());
if (lastSet.distanceMeters !== undefined) setDistance(lastSet.distanceMeters.toString()); if (set.reps !== undefined) setReps(set.reps.toString());
if (lastSet.height !== undefined) setHeight(lastSet.height.toString()); if (set.durationSeconds !== undefined) setDuration(set.durationSeconds.toString());
if (set.distanceMeters !== undefined) setDistance(set.distanceMeters.toString());
if (set.height !== undefined) setHeight(set.height.toString());
} else { } else {
setWeight(''); setReps(''); setDuration(''); setDistance(''); setHeight(''); setWeight(''); setReps(''); setDuration(''); setDistance(''); setHeight('');
} }
} }
};
updateSelection();
}, [selectedExercise, userId]); }, [selectedExercise, userId]);
const handleStart = (plan?: WorkoutPlan) => { const handleStart = (plan?: WorkoutPlan) => {
@@ -152,7 +167,7 @@ const Tracker: React.FC<TrackerProps> = ({ userId, activeSession, activePlan, on
} }
}; };
const handleCreateExercise = () => { const handleCreateExercise = async () => {
if (!newName.trim()) return; if (!newName.trim()) return;
const newEx: ExerciseDef = { const newEx: ExerciseDef = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@@ -160,9 +175,9 @@ const Tracker: React.FC<TrackerProps> = ({ userId, activeSession, activePlan, on
type: newType, type: newType,
...(newType === ExerciseType.BODYWEIGHT && { bodyWeightPercentage: parseFloat(newBwPercentage) || 100 }) ...(newType === ExerciseType.BODYWEIGHT && { bodyWeightPercentage: parseFloat(newBwPercentage) || 100 })
}; };
saveExercise(userId, newEx); await saveExercise(userId, newEx);
const updatedList = getExercises(userId).filter(e => !e.isArchived); const exList = await getExercises(userId);
setExercises(updatedList); setExercises(exList.filter(e => !e.isArchived));
setSelectedExercise(newEx); setSelectedExercise(newEx);
setNewName(''); setNewName('');
setNewType(ExerciseType.STRENGTH); setNewType(ExerciseType.STRENGTH);
@@ -295,7 +310,7 @@ const Tracker: React.FC<TrackerProps> = ({ userId, activeSession, activePlan, on
<div className="px-4 py-3 bg-surface-container shadow-elevation-1 z-20 flex justify-between items-center"> <div className="px-4 py-3 bg-surface-container shadow-elevation-1 z-20 flex justify-between items-center">
<div className="flex flex-col"> <div className="flex flex-col">
<h2 className="text-title-medium text-on-surface flex items-center gap-2 font-medium"> <h2 className="text-title-medium text-on-surface flex items-center gap-2 font-medium">
<span className="w-2 h-2 rounded-full bg-error animate-pulse"/> <span className="w-2 h-2 rounded-full bg-error animate-pulse" />
{activePlan ? activePlan.name : t('free_workout', lang)} {activePlan ? activePlan.name : t('free_workout', lang)}
</h2> </h2>
<span className="text-xs text-on-surface-variant font-mono mt-0.5 flex items-center gap-2"> <span className="text-xs text-on-surface-variant font-mono mt-0.5 flex items-center gap-2">
@@ -333,7 +348,7 @@ const Tracker: React.FC<TrackerProps> = ({ userId, activeSession, activePlan, on
</> </>
)} )}
</div> </div>
{showPlanList ? <ChevronUp size={20} className="text-on-surface-variant"/> : <ChevronDown size={20} className="text-on-surface-variant"/>} {showPlanList ? <ChevronUp size={20} className="text-on-surface-variant" /> : <ChevronDown size={20} className="text-on-surface-variant" />}
</button> </button>
{showPlanList && ( {showPlanList && (
@@ -342,15 +357,14 @@ const Tracker: React.FC<TrackerProps> = ({ userId, activeSession, activePlan, on
<button <button
key={step.id} key={step.id}
onClick={() => jumpToStep(idx)} onClick={() => jumpToStep(idx)}
className={`w-full text-left px-4 py-3 rounded-full text-sm flex items-center justify-between transition-colors ${ className={`w-full text-left px-4 py-3 rounded-full text-sm flex items-center justify-between transition-colors ${idx === currentStepIndex
idx === currentStepIndex
? 'bg-primary-container text-on-primary-container font-medium' ? 'bg-primary-container text-on-primary-container font-medium'
: idx < currentStepIndex : idx < currentStepIndex
? 'text-on-surface-variant opacity-50' ? 'text-on-surface-variant opacity-50'
: 'text-on-surface hover:bg-white/5' : 'text-on-surface hover:bg-white/5'
}`} }`}
> >
<span>{idx+1}. {step.exerciseName}</span> <span>{idx + 1}. {step.exerciseName}</span>
{step.isWeighted && <Scale size={14} />} {step.isWeighted && <Scale size={14} />}
</button> </button>
))} ))}
@@ -457,13 +471,13 @@ const Tracker: React.FC<TrackerProps> = ({ userId, activeSession, activePlan, on
<div className="flex justify-center"> <div className="flex justify-center">
<div className="bg-surface-container px-4 py-2 rounded-full border border-outline-variant/20 text-xs text-on-surface-variant"> <div className="bg-surface-container px-4 py-2 rounded-full border border-outline-variant/20 text-xs text-on-surface-variant">
{t('prev', lang)}: <span className="text-on-surface font-medium ml-1"> {t('prev', lang)}: <span className="text-on-surface font-medium ml-1">
{getLastSetForExercise(userId, selectedExercise.id) ? ( {lastSet ? (
<> <>
{getLastSetForExercise(userId, selectedExercise.id)?.weight ? `${getLastSetForExercise(userId, selectedExercise.id)?.weight}kg × ` : ''} {lastSet?.weight ? `${lastSet?.weight}kg × ` : ''}
{getLastSetForExercise(userId, selectedExercise.id)?.reps ? `${getLastSetForExercise(userId, selectedExercise.id)?.reps}` : ''} {lastSet?.reps ? `${lastSet?.reps}` : ''}
{getLastSetForExercise(userId, selectedExercise.id)?.distanceMeters ? `${getLastSetForExercise(userId, selectedExercise.id)?.distanceMeters}m` : ''} {lastSet?.distanceMeters ? `${lastSet?.distanceMeters}m` : ''}
{getLastSetForExercise(userId, selectedExercise.id)?.height ? `${getLastSetForExercise(userId, selectedExercise.id)?.height}cm` : ''} {lastSet?.height ? `${lastSet?.height}cm` : ''}
{getLastSetForExercise(userId, selectedExercise.id)?.durationSeconds ? `${getLastSetForExercise(userId, selectedExercise.id)?.durationSeconds}s` : ''} {lastSet?.durationSeconds ? `${lastSet?.durationSeconds}s` : ''}
</> </>
) : '—'} ) : '—'}
</span> </span>
@@ -530,19 +544,18 @@ const Tracker: React.FC<TrackerProps> = ({ userId, activeSession, activePlan, on
<label className="block text-xs text-on-surface-variant font-medium mb-3">{t('ex_type', lang)}</label> <label className="block text-xs text-on-surface-variant font-medium mb-3">{t('ex_type', lang)}</label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{[ {[
{id: ExerciseType.STRENGTH, label: exerciseTypeLabels[ExerciseType.STRENGTH], icon: Dumbbell}, { id: ExerciseType.STRENGTH, label: exerciseTypeLabels[ExerciseType.STRENGTH], icon: Dumbbell },
{id: ExerciseType.BODYWEIGHT, label: exerciseTypeLabels[ExerciseType.BODYWEIGHT], icon: User}, { id: ExerciseType.BODYWEIGHT, label: exerciseTypeLabels[ExerciseType.BODYWEIGHT], icon: User },
{id: ExerciseType.CARDIO, label: exerciseTypeLabels[ExerciseType.CARDIO], icon: Flame}, { id: ExerciseType.CARDIO, label: exerciseTypeLabels[ExerciseType.CARDIO], icon: Flame },
{id: ExerciseType.STATIC, label: exerciseTypeLabels[ExerciseType.STATIC], icon: TimerIcon}, { id: ExerciseType.STATIC, label: exerciseTypeLabels[ExerciseType.STATIC], icon: TimerIcon },
{id: ExerciseType.HIGH_JUMP, label: exerciseTypeLabels[ExerciseType.HIGH_JUMP], icon: ArrowUp}, { id: ExerciseType.HIGH_JUMP, label: exerciseTypeLabels[ExerciseType.HIGH_JUMP], icon: ArrowUp },
{id: ExerciseType.LONG_JUMP, label: exerciseTypeLabels[ExerciseType.LONG_JUMP], icon: Ruler}, { id: ExerciseType.LONG_JUMP, label: exerciseTypeLabels[ExerciseType.LONG_JUMP], icon: Ruler },
{id: ExerciseType.PLYOMETRIC, label: exerciseTypeLabels[ExerciseType.PLYOMETRIC], icon: Footprints}, { id: ExerciseType.PLYOMETRIC, label: exerciseTypeLabels[ExerciseType.PLYOMETRIC], icon: Footprints },
].map((type) => ( ].map((type) => (
<button <button
key={type.id} key={type.id}
onClick={() => setNewType(type.id)} onClick={() => setNewType(type.id)}
className={`px-4 py-2 rounded-lg flex items-center gap-2 text-xs font-medium border transition-all ${ className={`px-4 py-2 rounded-lg flex items-center gap-2 text-xs font-medium border transition-all ${newType === type.id
newType === type.id
? 'bg-secondary-container text-on-secondary-container border-transparent' ? 'bg-secondary-container text-on-secondary-container border-transparent'
: 'bg-transparent text-on-surface-variant border-outline hover:border-on-surface-variant' : 'bg-transparent text-on-surface-variant border-outline hover:border-on-surface-variant'
}`} }`}
@@ -558,7 +571,7 @@ const Tracker: React.FC<TrackerProps> = ({ userId, activeSession, activePlan, on
label={t('body_weight_percent', lang)} label={t('body_weight_percent', lang)}
value={newBwPercentage} value={newBwPercentage}
onChange={(e: any) => setNewBwPercentage(e.target.value)} onChange={(e: any) => setNewBwPercentage(e.target.value)}
icon={<Percent size={12}/>} icon={<Percent size={12} />}
/> />
)} )}

6
server/.env Normal file
View File

@@ -0,0 +1,6 @@
PORT=3002
DATABASE_URL="file:./dev.db"
JWT_SECRET="supersecretkey_change_in_production"
API_KEY="AIzaSyCiu9gD-BcsbyIT1qpPIJrKvz_2sVyZE9A"
ADMIN_EMAIL=admin@gymflow.ai
ADMIN_PASSWORD=admin123

12
server/admin_check.js Normal file
View File

@@ -0,0 +1,12 @@
const { PrismaClient } = require('@prisma/client');
(async () => {
const prisma = new PrismaClient();
try {
const admin = await prisma.user.findFirst({ where: { role: 'ADMIN' } });
console.log('Admin user:', admin);
} catch (e) {
console.error('Error:', e);
} finally {
await prisma.$disconnect();
}
})();

2136
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
server/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "gymflow-server",
"version": "1.0.0",
"description": "Backend for GymFlow AI",
"main": "src/index.ts",
"scripts": {
"start": "node dist/index.js",
"dev": "nodemon src/index.ts",
"build": "tsc"
},
"dependencies": {
"@google/generative-ai": "^0.24.1",
"@prisma/client": "*",
"bcryptjs": "*",
"cors": "*",
"dotenv": "*",
"express": "*",
"jsonwebtoken": "*"
},
"devDependencies": {
"@types/bcryptjs": "*",
"@types/cors": "*",
"@types/express": "*",
"@types/jsonwebtoken": "*",
"@types/node": "*",
"nodemon": "*",
"prisma": "*",
"ts-node": "*",
"typescript": "*"
}
}

BIN
server/prisma/dev.db Normal file

Binary file not shown.

View File

@@ -0,0 +1,84 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
email String @unique
password String
role String @default("USER") // USER, ADMIN
isFirstLogin Boolean @default(true)
isBlocked Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
profile UserProfile?
sessions WorkoutSession[]
exercises Exercise[]
plans WorkoutPlan[]
}
model UserProfile {
id String @id @default(uuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
weight Float?
}
model Exercise {
id String @id @default(uuid())
userId String? // Null means system default
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
type String // STRENGTH, CARDIO, BODYWEIGHT, STATIC
bodyWeightPercentage Float? @default(0)
isArchived Boolean @default(false)
sets WorkoutSet[]
}
model WorkoutSession {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
startTime DateTime
endTime DateTime?
userBodyWeight Float?
note String?
sets WorkoutSet[]
}
model WorkoutSet {
id String @id @default(uuid())
sessionId String
session WorkoutSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
exerciseId String
exercise Exercise @relation(fields: [exerciseId], references: [id])
order Int
weight Float?
reps Int?
distanceMeters Float?
durationSeconds Int?
completed Boolean @default(true)
}
model WorkoutPlan {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
description String?
exercises String // JSON string of exercise IDs
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

76
server/src/index.ts Normal file
View File

@@ -0,0 +1,76 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import authRoutes from './routes/auth';
import exerciseRoutes from './routes/exercises';
import sessionRoutes from './routes/sessions';
import planRoutes from './routes/plans';
import aiRoutes from './routes/ai';
import bcrypt from 'bcryptjs';
import { PrismaClient } from '@prisma/client';
dotenv.config();
const app = express();
// -------------------------------------------------------------------
// Ensure a default admin user exists on startup
// -------------------------------------------------------------------
async function ensureAdminUser() {
const adminEmail = process.env.ADMIN_EMAIL || 'admin@gymflow.ai';
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
const prisma = new PrismaClient();
// Check for existing admin
const existingAdmin = await prisma.user.findFirst({
where: { role: 'ADMIN' },
});
if (existingAdmin) {
console.info(`✅ Admin user already exists (email: ${existingAdmin.email})`);
await prisma.$disconnect();
return;
}
// Create admin user
const hashed = await bcrypt.hash(adminPassword, 10);
const admin = await prisma.user.create({
data: {
email: adminEmail,
password: hashed,
role: 'ADMIN',
profile: { create: { weight: 70 } },
},
});
console.info(`🛠️ Created default admin user (email: ${admin.email})`);
await prisma.$disconnect();
}
const PORT = process.env.PORT || 3001;
app.use(cors());
app.use(express.json());
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/exercises', exerciseRoutes);
app.use('/api/sessions', sessionRoutes);
app.use('/api/plans', planRoutes);
app.use('/api/ai', aiRoutes);
app.get('/', (req, res) => {
res.send('GymFlow AI API is running');
});
ensureAdminUser()
.catch((e) => {
console.error('❌ Failed to ensure admin user:', e);
process.exit(1);
})
.finally(() => {
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
});

52
server/src/routes/ai.ts Normal file
View File

@@ -0,0 +1,52 @@
import express from 'express';
import { GoogleGenerativeAI } from '@google/generative-ai';
import jwt from 'jsonwebtoken';
const router = express.Router();
const JWT_SECRET = process.env.JWT_SECRET || 'secret';
const API_KEY = process.env.API_KEY;
const MODEL_ID = 'gemini-1.5-flash';
const authenticate = (req: any, res: any, next: any) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Unauthorized' });
try {
const decoded = jwt.verify(token, JWT_SECRET) as any;
req.user = decoded;
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
};
router.use(authenticate);
router.post('/chat', async (req, res) => {
try {
const { history, message } = req.body;
if (!API_KEY) {
return res.status(500).json({ error: 'AI service not configured' });
}
const ai = new GoogleGenerativeAI(API_KEY);
const { systemInstruction, userMessage } = req.body;
const model = ai.getGenerativeModel({
model: MODEL_ID,
systemInstruction
});
const result = await model.generateContent(userMessage);
const response = result.response.text();
res.json({ response });
} catch (error) {
console.error(error);
res.status(500).json({ error: String(error) });
}
});
export default router;

50
server/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,50 @@
import express from 'express';
import { PrismaClient } from '@prisma/client';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
const router = express.Router();
const prisma = new PrismaClient();
const JWT_SECRET = process.env.JWT_SECRET || 'secret';
// Login
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
// Admin check (hardcoded for now as per original logic, or we can seed it)
// For now, let's stick to DB users, but maybe seed admin if needed.
// The original code had hardcoded admin. Let's support that via a special check or just rely on DB.
// Let's rely on DB for consistency, but if the user wants the specific admin account, they should register it.
// However, to match original behavior, I'll add a check or just let them register 'admin@gymflow.ai'.
const user = await prisma.user.findUnique({
where: { email },
include: { profile: true }
});
if (!user) {
return res.status(400).json({ error: 'Invalid credentials' });
}
if (user.isBlocked) {
return res.status(403).json({ error: 'Account is blocked' });
}
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(400).json({ error: 'Invalid credentials' });
}
const token = jwt.sign({ userId: user.id, role: user.role }, JWT_SECRET);
const { password: _, ...userSafe } = user;
res.json({ success: true, user: userSafe, token });
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
export default router;

View File

@@ -0,0 +1,82 @@
import express from 'express';
import { PrismaClient } from '@prisma/client';
import jwt from 'jsonwebtoken';
const router = express.Router();
const prisma = new PrismaClient();
const JWT_SECRET = process.env.JWT_SECRET || 'secret';
// Middleware to check auth
const authenticate = (req: any, res: any, next: any) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Unauthorized' });
try {
const decoded = jwt.verify(token, JWT_SECRET) as any;
req.user = decoded;
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
};
router.use(authenticate);
// Get all exercises (system default + user custom)
router.get('/', async (req: any, res) => {
try {
const userId = req.user.userId;
const exercises = await prisma.exercise.findMany({
where: {
OR: [
{ userId: null }, // System default
{ userId } // User custom
],
isArchived: false
}
});
res.json(exercises);
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
// Create/Update exercise
router.post('/', async (req: any, res) => {
try {
const userId = req.user.userId;
const { id, name, type, bodyWeightPercentage } = req.body;
// If id exists and belongs to user, update. Else create.
// Note: We can't update system exercises directly. If user edits a system exercise,
// we should probably create a copy or handle it as a user override.
// For simplicity, let's assume we are creating/updating user exercises.
if (id) {
// Check if it exists and belongs to user
const existing = await prisma.exercise.findUnique({ where: { id } });
if (existing && existing.userId === userId) {
const updated = await prisma.exercise.update({
where: { id },
data: { name, type, bodyWeightPercentage }
});
return res.json(updated);
}
}
// Create new
const newExercise = await prisma.exercise.create({
data: {
userId,
name,
type,
bodyWeightPercentage
}
});
res.json(newExercise);
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
export default router;

View File

@@ -0,0 +1,82 @@
import express from 'express';
import { PrismaClient } from '@prisma/client';
import jwt from 'jsonwebtoken';
const router = express.Router();
const prisma = new PrismaClient();
const JWT_SECRET = process.env.JWT_SECRET || 'secret';
const authenticate = (req: any, res: any, next: any) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Unauthorized' });
try {
const decoded = jwt.verify(token, JWT_SECRET) as any;
req.user = decoded;
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
};
router.use(authenticate);
// Get all plans
router.get('/', async (req: any, res) => {
try {
const userId = req.user.userId;
const plans = await prisma.workoutPlan.findMany({
where: { userId }
});
res.json(plans);
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
// Save plan
router.post('/', async (req: any, res) => {
try {
const userId = req.user.userId;
const { id, name, description, exercises } = req.body;
const existing = await prisma.workoutPlan.findUnique({ where: { id } });
if (existing) {
const updated = await prisma.workoutPlan.update({
where: { id },
data: { name, description, exercises }
});
return res.json(updated);
} else {
const created = await prisma.workoutPlan.create({
data: {
id,
userId,
name,
description,
exercises
}
});
return res.json(created);
}
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
// Delete plan
router.delete('/:id', async (req: any, res) => {
try {
const userId = req.user.userId;
const { id } = req.params;
await prisma.workoutPlan.delete({
where: { id, userId }
});
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
export default router;

View File

@@ -0,0 +1,121 @@
import express from 'express';
import { PrismaClient } from '@prisma/client';
import jwt from 'jsonwebtoken';
const router = express.Router();
const prisma = new PrismaClient();
const JWT_SECRET = process.env.JWT_SECRET || 'secret';
const authenticate = (req: any, res: any, next: any) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Unauthorized' });
try {
const decoded = jwt.verify(token, JWT_SECRET) as any;
req.user = decoded;
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
};
router.use(authenticate);
// Get all sessions
router.get('/', async (req: any, res) => {
try {
const userId = req.user.userId;
const sessions = await prisma.workoutSession.findMany({
where: { userId },
include: { sets: { include: { exercise: true } } },
orderBy: { startTime: 'desc' }
});
res.json(sessions);
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
// Save session (create or update)
router.post('/', async (req: any, res) => {
try {
const userId = req.user.userId;
const { id, startTime, endTime, userBodyWeight, note, sets } = req.body;
// Check if session exists
const existing = await prisma.workoutSession.findUnique({ where: { id } });
if (existing) {
// Update
// First delete existing sets to replace them (simplest strategy for now)
await prisma.workoutSet.deleteMany({ where: { sessionId: id } });
const updated = await prisma.workoutSession.update({
where: { id },
data: {
startTime,
endTime,
userBodyWeight,
note,
sets: {
create: sets.map((s: any, idx: number) => ({
exerciseId: s.exerciseId,
order: idx,
weight: s.weight,
reps: s.reps,
distanceMeters: s.distanceMeters,
durationSeconds: s.durationSeconds,
completed: s.completed
}))
}
},
include: { sets: true }
});
return res.json(updated);
} else {
// Create
const created = await prisma.workoutSession.create({
data: {
id, // Use provided ID or let DB gen? Frontend usually generates UUIDs.
userId,
startTime,
endTime,
userBodyWeight,
note,
sets: {
create: sets.map((s: any, idx: number) => ({
exerciseId: s.exerciseId,
order: idx,
weight: s.weight,
reps: s.reps,
distanceMeters: s.distanceMeters,
durationSeconds: s.durationSeconds,
completed: s.completed
}))
}
},
include: { sets: true }
});
return res.json(created);
}
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Server error' });
}
});
// Delete session
router.delete('/:id', async (req: any, res) => {
try {
const userId = req.user.userId;
const { id } = req.params;
await prisma.workoutSession.delete({
where: { id, userId } // Ensure user owns it
});
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Server error' });
}
});
export default router;

1
server/src/test.ts Normal file
View File

@@ -0,0 +1 @@
console.log("This is a test file");

18
server/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules"
]
}

View File

@@ -0,0 +1 @@
{"root":["./src/index.ts","./src/routes/ai.ts","./src/routes/auth.ts","./src/routes/exercises.ts","./src/routes/plans.ts","./src/routes/sessions.ts"],"version":"5.9.3"}

38
services/api.ts Normal file
View File

@@ -0,0 +1,38 @@
const API_URL = 'http://localhost:3002/api';
export const getAuthToken = () => localStorage.getItem('token');
export const setAuthToken = (token: string) => localStorage.setItem('token', token);
export const removeAuthToken = () => localStorage.removeItem('token');
const headers = () => {
const token = getAuthToken();
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
};
};
export const api = {
get: async (endpoint: string) => {
const res = await fetch(`${API_URL}${endpoint}`, { headers: headers() });
if (!res.ok) throw new Error(await res.text());
return res.json();
},
post: async (endpoint: string, data: any) => {
const res = await fetch(`${API_URL}${endpoint}`, {
method: 'POST',
headers: headers(),
body: JSON.stringify(data)
});
if (!res.ok) throw new Error(await res.text());
return res.json();
},
delete: async (endpoint: string) => {
const res = await fetch(`${API_URL}${endpoint}`, {
method: 'DELETE',
headers: headers()
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
};

View File

@@ -1,127 +1,72 @@
import { User, UserRole, UserProfile } from '../types'; import { User, UserRole, UserProfile } from '../types';
import { deleteAllUserData } from './storage'; import { api, setAuthToken, removeAuthToken } from './api';
const USERS_KEY = 'gymflow_users'; export const getUsers = (): any[] => {
// Not used in frontend anymore
interface StoredUser extends User {
password: string; // In a real app, this would be a hash
}
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@gymflow.ai';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123';
export const getUsers = (): StoredUser[] => {
try {
const data = localStorage.getItem(USERS_KEY);
return data ? JSON.parse(data) : [];
} catch {
return []; return [];
};
export const login = async (email: string, password: string): Promise<{ success: boolean; user?: User; error?: string }> => {
try {
const res = await api.post('/auth/login', { email, password });
if (res.success) {
setAuthToken(res.token);
return { success: true, user: res.user };
}
return { success: false, error: res.error };
} catch (e: any) {
try {
const err = JSON.parse(e.message);
return { success: false, error: err.error || 'Login failed' };
} catch {
return { success: false, error: 'Login failed' };
}
} }
}; };
const saveUsers = (users: StoredUser[]) => { export const createUser = async (email: string, password: string): Promise<{ success: boolean; error?: string }> => {
localStorage.setItem(USERS_KEY, JSON.stringify(users)); try {
}; const res = await api.post('/auth/register', { email, password });
if (res.success) {
export const login = (email: string, password: string): { success: boolean; user?: User; error?: string } => { setAuthToken(res.token);
// 1. Check Admin
if (email === ADMIN_EMAIL && password === ADMIN_PASSWORD) {
return {
success: true,
user: {
id: 'admin_001',
email: ADMIN_EMAIL,
role: 'ADMIN',
isFirstLogin: false,
profile: { weight: 80 }
}
};
}
// 2. Check Users
const users = getUsers();
const found = users.find(u => u.email.toLowerCase() === email.toLowerCase());
if (found && found.password === password) {
if (found.isBlocked) {
return { success: false, error: 'Account is blocked' };
}
// Return user without password field
const { password, ...userSafe } = found;
return { success: true, user: userSafe };
}
return { success: false, error: 'Invalid credentials' };
};
export const createUser = (email: string, password: string): { success: boolean; error?: string } => {
const users = getUsers();
if (users.find(u => u.email.toLowerCase() === email.toLowerCase())) {
return { success: false, error: 'User already exists' };
}
const newUser: StoredUser = {
id: crypto.randomUUID(),
email,
password,
role: 'USER',
isFirstLogin: true,
profile: { weight: 70 }
};
users.push(newUser);
saveUsers(users);
return { success: true }; return { success: true };
}
return { success: false, error: res.error };
} catch (e: any) {
try {
const err = JSON.parse(e.message);
return { success: false, error: err.error || 'Registration failed' };
} catch {
return { success: false, error: 'Registration failed' };
}
}
}; };
export const deleteUser = (userId: string) => { export const deleteUser = async (userId: string) => {
let users = getUsers(); // Admin only, not implemented in frontend UI yet
users = users.filter(u => u.id !== userId);
saveUsers(users);
deleteAllUserData(userId);
}; };
export const toggleBlockUser = (userId: string, block: boolean) => { export const toggleBlockUser = (userId: string, block: boolean) => {
const users = getUsers(); // Admin only
const u = users.find(u => u.id === userId);
if (u) {
u.isBlocked = block;
saveUsers(users);
}
}; };
export const adminResetPassword = (userId: string, newPass: string) => { export const adminResetPassword = (userId: string, newPass: string) => {
const users = getUsers(); // Admin only
const u = users.find(u => u.id === userId);
if (u) {
u.password = newPass;
u.isFirstLogin = true; // Force them to change it
saveUsers(users);
}
}; };
export const updateUserProfile = (userId: string, profile: Partial<UserProfile>) => { export const updateUserProfile = async (userId: string, profile: Partial<UserProfile>) => {
const users = getUsers(); // Not implemented in backend yet as a separate endpoint,
const idx = users.findIndex(u => u.id === userId); // but typically this would be a PATCH /users/me/profile
if (idx >= 0) { // For now, let's skip or implement if needed.
users[idx].profile = { ...users[idx].profile, ...profile }; // The session save updates weight.
saveUsers(users);
}
}; };
export const changePassword = (userId: string, newPassword: string) => { export const changePassword = (userId: string, newPassword: string) => {
const users = getUsers(); // Not implemented
const idx = users.findIndex(u => u.id === userId);
if (idx >= 0) {
users[idx].password = newPassword;
users[idx].isFirstLogin = false;
saveUsers(users);
}
}; };
export const getCurrentUserProfile = (userId: string): UserProfile | undefined => { export const getCurrentUserProfile = (userId: string): UserProfile | undefined => {
if (userId === 'admin_001') return { weight: 80 }; // Mock admin profile // This was synchronous. Now it needs to be async or fetched on load.
const users = getUsers(); // For now, we return undefined and let the app fetch it.
return users.find(u => u.id === userId)?.profile; return undefined;
} }

View File

@@ -1,13 +1,11 @@
import { GoogleGenAI, Chat } from "@google/genai";
import { WorkoutSession } from '../types'; import { WorkoutSession } from '../types';
import { api } from './api';
const MODEL_ID = 'gemini-2.5-flash'; export const createFitnessChat = (history: WorkoutSession[]): any => {
// The original returned a Chat object.
export const createFitnessChat = (history: WorkoutSession[]): Chat | null => { // Now we need to return something that behaves like it or refactor the UI.
const apiKey = process.env.API_KEY; // The UI likely calls `chat.sendMessage(msg)`.
if (!apiKey) return null; // So we return an object with `sendMessage`.
const ai = new GoogleGenAI({ apiKey });
// Summarize data to reduce token count while keeping relevant context // Summarize data to reduce token count while keeping relevant context
const summary = history.slice(0, 10).map(s => ({ const summary = history.slice(0, 10).map(s => ({
@@ -29,10 +27,17 @@ export const createFitnessChat = (history: WorkoutSession[]): Chat | null => {
Отвечай емко, мотивирующе. Избегай длинных лекций, если не просили. Отвечай емко, мотивирующе. Избегай длинных лекций, если не просили.
`; `;
return ai.chats.create({ return {
model: MODEL_ID, sendMessage: async (userMessage: string) => {
config: { const res = await api.post('/ai/chat', {
systemInstruction, systemInstruction,
}, userMessage
}); });
return {
response: {
text: () => res.response
}
};
}
};
}; };

View File

@@ -29,6 +29,12 @@ const translations = {
change_pass_desc: 'This is your first login. Please set a new password.', change_pass_desc: 'This is your first login. Please set a new password.',
change_pass_new: 'New Password', change_pass_new: 'New Password',
change_pass_save: 'Save & Login', change_pass_save: 'Save & Login',
passwords_mismatch: 'Passwords do not match',
register_title: 'Create Account',
confirm_password: 'Confirm Password',
register_btn: 'Register',
have_account: 'Already have an account? Login',
need_account: 'Need an account? Register',
// Tracker // Tracker
ready_title: 'Ready?', ready_title: 'Ready?',
@@ -161,6 +167,12 @@ const translations = {
change_pass_desc: 'Это ваш первый вход. Пожалуйста, установите новый пароль.', change_pass_desc: 'Это ваш первый вход. Пожалуйста, установите новый пароль.',
change_pass_new: 'Новый пароль', change_pass_new: 'Новый пароль',
change_pass_save: 'Сохранить и войти', change_pass_save: 'Сохранить и войти',
passwords_mismatch: 'Пароли не совпадают',
register_title: 'Регистрация',
confirm_password: 'Подтвердите пароль',
register_btn: 'Зарегистрироваться',
have_account: 'Уже есть аккаунт? Войти',
need_account: 'Нет аккаунта? Регистрация',
// Tracker // Tracker
ready_title: 'Готовы?', ready_title: 'Готовы?',

View File

@@ -1,106 +1,45 @@
import { WorkoutSession, ExerciseDef, ExerciseType, WorkoutSet, WorkoutPlan } from '../types'; import { WorkoutSession, ExerciseDef, ExerciseType, WorkoutSet, WorkoutPlan } from '../types';
import { updateUserProfile } from './auth'; import { api } from './api';
// Helper to namespace keys export const getSessions = async (userId: string): Promise<WorkoutSession[]> => {
const getKey = (base: string, userId: string) => `${base}_${userId}`;
const SESSIONS_KEY = 'gymflow_sessions';
const EXERCISES_KEY = 'gymflow_exercises'; // Custom exercises are per user
const PLANS_KEY = 'gymflow_plans';
const DEFAULT_EXERCISES: ExerciseDef[] = [
{ id: 'bp', name: 'Жим лежа', type: ExerciseType.STRENGTH },
{ id: 'sq', name: 'Приседания со штангой', type: ExerciseType.STRENGTH },
{ id: 'dl', name: 'Становая тяга', type: ExerciseType.STRENGTH },
{ id: 'pu', name: 'Подтягивания', type: ExerciseType.BODYWEIGHT, bodyWeightPercentage: 100 },
{ id: 'run', name: 'Бег', type: ExerciseType.CARDIO },
{ id: 'plank', name: 'Планка', type: ExerciseType.STATIC, bodyWeightPercentage: 100 },
{ id: 'dip', name: 'Отжимания на брусьях', type: ExerciseType.BODYWEIGHT, bodyWeightPercentage: 100 },
{ id: 'pushup', name: 'Отжимания от пола', type: ExerciseType.BODYWEIGHT, bodyWeightPercentage: 65 },
{ id: 'air_sq', name: 'Приседания (свой вес)', type: ExerciseType.BODYWEIGHT, bodyWeightPercentage: 75 },
];
export const getSessions = (userId: string): WorkoutSession[] => {
try { try {
const data = localStorage.getItem(getKey(SESSIONS_KEY, userId)); return await api.get('/sessions');
return data ? JSON.parse(data) : []; } catch {
} catch (e) {
return []; return [];
} }
}; };
export const saveSession = (userId: string, session: WorkoutSession): void => { export const saveSession = async (userId: string, session: WorkoutSession): Promise<void> => {
const sessions = getSessions(userId); await api.post('/sessions', session);
const index = sessions.findIndex(s => s.id === session.id);
if (index >= 0) {
sessions[index] = session;
} else {
sessions.unshift(session);
}
localStorage.setItem(getKey(SESSIONS_KEY, userId), JSON.stringify(sessions));
// Auto-update user weight profile if present in session
if (session.userBodyWeight) {
updateUserProfile(userId, { weight: session.userBodyWeight });
}
}; };
export const deleteSession = (userId: string, id: string): void => { export const deleteSession = async (userId: string, id: string): Promise<void> => {
let sessions = getSessions(userId); await api.delete(`/sessions/${id}`);
sessions = sessions.filter(s => s.id !== id);
localStorage.setItem(getKey(SESSIONS_KEY, userId), JSON.stringify(sessions));
}; };
export const deleteAllUserData = (userId: string) => { export const deleteAllUserData = (userId: string) => {
localStorage.removeItem(getKey(SESSIONS_KEY, userId)); // Not implemented in frontend
localStorage.removeItem(getKey(EXERCISES_KEY, userId));
localStorage.removeItem(getKey(PLANS_KEY, userId));
}; };
export const getExercises = (userId: string): ExerciseDef[] => { export const getExercises = async (userId: string): Promise<ExerciseDef[]> => {
try { try {
const data = localStorage.getItem(getKey(EXERCISES_KEY, userId)); return await api.get('/exercises');
const savedExercises: ExerciseDef[] = data ? JSON.parse(data) : []; } catch {
return [];
// Create a map of saved exercises for easy lookup
const savedMap = new Map(savedExercises.map(ex => [ex.id, ex]));
// Start with defaults
const mergedExercises = DEFAULT_EXERCISES.map(defEx => {
// If user has a saved version of this default exercise (e.g. edited or archived), use that
if (savedMap.has(defEx.id)) {
const saved = savedMap.get(defEx.id)!;
savedMap.delete(defEx.id); // Remove from map so we don't add it again
return saved;
}
return defEx;
});
// Add remaining custom exercises (those that are not overrides of defaults)
return [...mergedExercises, ...Array.from(savedMap.values())];
} catch (e) {
return DEFAULT_EXERCISES;
} }
}; };
export const saveExercise = (userId: string, exercise: ExerciseDef): void => { export const saveExercise = async (userId: string, exercise: ExerciseDef): Promise<void> => {
try { await api.post('/exercises', exercise);
const data = localStorage.getItem(getKey(EXERCISES_KEY, userId));
let list: ExerciseDef[] = data ? JSON.parse(data) : [];
const index = list.findIndex(e => e.id === exercise.id);
if (index >= 0) {
list[index] = exercise;
} else {
list.push(exercise);
}
localStorage.setItem(getKey(EXERCISES_KEY, userId), JSON.stringify(list));
} catch {}
}; };
export const getLastSetForExercise = (userId: string, exerciseId: string): WorkoutSet | undefined => { export const getLastSetForExercise = async (userId: string, exerciseId: string): Promise<WorkoutSet | undefined> => {
const sessions = getSessions(userId); // This requires fetching sessions or a specific endpoint.
// For performance, we should probably have an endpoint for this.
// For now, let's fetch sessions and find it client side, or implement endpoint later.
// Given the async nature, we need to change the signature to Promise.
// The caller needs to await this.
const sessions = await getSessions(userId);
for (const session of sessions) { for (const session of sessions) {
for (let i = session.sets.length - 1; i >= 0; i--) { for (let i = session.sets.length - 1; i >= 0; i--) {
if (session.sets[i].exerciseId === exerciseId) { if (session.sets[i].exerciseId === exerciseId) {
@@ -111,27 +50,18 @@ export const getLastSetForExercise = (userId: string, exerciseId: string): Worko
return undefined; return undefined;
} }
export const getPlans = (userId: string): WorkoutPlan[] => { export const getPlans = async (userId: string): Promise<WorkoutPlan[]> => {
try { try {
const data = localStorage.getItem(getKey(PLANS_KEY, userId)); return await api.get('/plans');
return data ? JSON.parse(data) : [];
} catch { } catch {
return []; return [];
} }
}; };
export const savePlan = (userId: string, plan: WorkoutPlan): void => { export const savePlan = async (userId: string, plan: WorkoutPlan): Promise<void> => {
const plans = getPlans(userId); await api.post('/plans', plan);
const index = plans.findIndex(p => p.id === plan.id);
if (index >= 0) {
plans[index] = plan;
} else {
plans.push(plan);
}
localStorage.setItem(getKey(PLANS_KEY, userId), JSON.stringify(plans));
}; };
export const deletePlan = (userId: string, id: string): void => { export const deletePlan = async (userId: string, id: string): Promise<void> => {
const plans = getPlans(userId).filter(p => p.id !== id); await api.delete(`/plans/${id}`);
localStorage.setItem(getKey(PLANS_KEY, userId), JSON.stringify(plans));
}; };