Ongoing workout session data is persistent now

This commit is contained in:
AG
2025-11-28 19:00:56 +02:00
parent 4c632e164e
commit d07431e4ff
10 changed files with 375 additions and 48 deletions

86
App.tsx
View File

@@ -9,7 +9,7 @@ import Plans from './components/Plans';
import Login from './components/Login'; import Login from './components/Login';
import Profile from './components/Profile'; import Profile from './components/Profile';
import { TabView, WorkoutSession, WorkoutSet, WorkoutPlan, User, Language } from './types'; import { TabView, WorkoutSession, WorkoutSet, WorkoutPlan, User, Language } from './types';
import { getSessions, saveSession, deleteSession, getPlans } from './services/storage'; import { getSessions, saveSession, deleteSession, getPlans, getActiveSession, updateActiveSession, deleteActiveSession } from './services/storage';
import { getCurrentUserProfile, getMe } from './services/auth'; import { getCurrentUserProfile, getMe } from './services/auth';
import { getSystemLanguage } from './services/i18n'; import { getSystemLanguage } from './services/i18n';
import { generateId } from './utils/uuid'; import { generateId } from './utils/uuid';
@@ -35,6 +35,20 @@ function App() {
const res = await getMe(); const res = await getMe();
if (res.success && res.user) { if (res.success && res.user) {
setCurrentUser(res.user); setCurrentUser(res.user);
// Restore active workout session from database
const activeSession = await getActiveSession(res.user.id);
if (activeSession) {
setActiveSession(activeSession);
// Restore plan if session has planId
if (activeSession.planId) {
const plans = await getPlans(res.user.id);
const plan = plans.find(p => p.id === activeSession.planId);
if (plan) {
setActivePlan(plan);
}
}
}
} else { } else {
localStorage.removeItem('token'); localStorage.removeItem('token');
} }
@@ -78,7 +92,7 @@ function App() {
setCurrentUser(updatedUser); setCurrentUser(updatedUser);
}; };
const handleStartSession = (plan?: WorkoutPlan, startWeight?: number) => { const handleStartSession = async (plan?: WorkoutPlan, startWeight?: number) => {
if (!currentUser) return; if (!currentUser) return;
// Get latest weight from profile or default // Get latest weight from profile or default
@@ -97,12 +111,15 @@ function App() {
setActivePlan(plan || null); setActivePlan(plan || null);
setActiveSession(newSession); setActiveSession(newSession);
setCurrentTab('TRACK'); setCurrentTab('TRACK');
// Save to database immediately
await saveSession(currentUser.id, newSession);
}; };
const handleEndSession = async () => { const handleEndSession = async () => {
if (activeSession && currentUser) { if (activeSession && currentUser) {
const finishedSession = { ...activeSession, endTime: Date.now() }; const finishedSession = { ...activeSession, endTime: Date.now() };
await saveSession(currentUser.id, finishedSession); await updateActiveSession(currentUser.id, finishedSession);
setSessions(prev => [finishedSession, ...prev]); setSessions(prev => [finishedSession, ...prev]);
setActiveSession(null); setActiveSession(null);
setActivePlan(null); setActivePlan(null);
@@ -115,39 +132,47 @@ function App() {
} }
}; };
const handleAddSet = (set: WorkoutSet) => { const handleAddSet = async (set: WorkoutSet) => {
if (activeSession) { if (activeSession && currentUser) {
setActiveSession(prev => { const updatedSession = {
if (!prev) return null; ...activeSession,
return { sets: [...activeSession.sets, set]
...prev, };
sets: [...prev.sets, set] setActiveSession(updatedSession);
}; // Save to database
}); await updateActiveSession(currentUser.id, updatedSession);
} }
}; };
const handleRemoveSetFromActive = (setId: string) => { const handleRemoveSetFromActive = async (setId: string) => {
if (activeSession) { if (activeSession && currentUser) {
setActiveSession(prev => { const updatedSession = {
if (!prev) return null; ...activeSession,
return { sets: activeSession.sets.filter(s => s.id !== setId)
...prev, };
sets: prev.sets.filter(s => s.id !== setId) setActiveSession(updatedSession);
}; // Save to database
}); await updateActiveSession(currentUser.id, updatedSession);
} }
}; };
const handleUpdateSetInActive = (updatedSet: WorkoutSet) => { const handleUpdateSetInActive = async (updatedSet: WorkoutSet) => {
if (activeSession) { if (activeSession && currentUser) {
setActiveSession(prev => { const updatedSession = {
if (!prev) return null; ...activeSession,
return { sets: activeSession.sets.map(s => s.id === updatedSet.id ? updatedSet : s)
...prev, };
sets: prev.sets.map(s => s.id === updatedSet.id ? updatedSet : s) setActiveSession(updatedSession);
}; // Save to database
}); await updateActiveSession(currentUser.id, updatedSession);
}
};
const handleQuitSession = async () => {
if (currentUser) {
await deleteActiveSession(currentUser.id);
setActiveSession(null);
setActivePlan(null);
} }
}; };
@@ -184,6 +209,7 @@ function App() {
activePlan={activePlan} activePlan={activePlan}
onSessionStart={handleStartSession} onSessionStart={handleStartSession}
onSessionEnd={handleEndSession} onSessionEnd={handleEndSession}
onSessionQuit={handleQuitSession}
onSetAdded={handleAddSet} onSetAdded={handleAddSet}
onRemoveSet={handleRemoveSetFromActive} onRemoveSet={handleRemoveSetFromActive}
onUpdateSet={handleUpdateSetInActive} onUpdateSet={handleUpdateSetInActive}

View File

@@ -75,8 +75,11 @@ const Profile: React.FC<ProfileProps> = ({ user, onLogout, lang, onLanguageChang
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [user.id, user.role, JSON.stringify(user.profile)]); }, [user.id, user.role, JSON.stringify(user.profile)]);
const refreshUserList = () => { const refreshUserList = async () => {
setAllUsers(getUsers()); const res = await getUsers();
if (res.success && res.users) {
setAllUsers(res.users);
}
}; };
const refreshExercises = async () => { const refreshExercises = async () => {
@@ -143,16 +146,16 @@ const Profile: React.FC<ProfileProps> = ({ user, onLogout, lang, onLanguageChang
} }
}; };
const handleAdminDeleteUser = (uid: string) => { const handleAdminDeleteUser = async (uid: string) => {
if (confirm(t('delete_confirm', lang))) { if (confirm(t('delete_confirm', lang))) {
deleteUser(uid); await deleteUser(uid);
refreshUserList(); await refreshUserList();
} }
}; };
const handleAdminBlockUser = (uid: string, isBlocked: boolean) => { const handleAdminBlockUser = async (uid: string, isBlocked: boolean) => {
toggleBlockUser(uid, isBlocked); await toggleBlockUser(uid, isBlocked);
refreshUserList(); await refreshUserList();
}; };
const handleAdminResetPass = (uid: string) => { const handleAdminResetPass = (uid: string) => {

View File

@@ -14,6 +14,7 @@ interface TrackerProps {
activePlan: WorkoutPlan | null; activePlan: WorkoutPlan | null;
onSessionStart: (plan?: WorkoutPlan, startWeight?: number) => void; onSessionStart: (plan?: WorkoutPlan, startWeight?: number) => void;
onSessionEnd: () => void; onSessionEnd: () => void;
onSessionQuit: () => void;
onSetAdded: (set: WorkoutSet) => void; onSetAdded: (set: WorkoutSet) => void;
onRemoveSet: (setId: string) => void; onRemoveSet: (setId: string) => void;
onUpdateSet: (set: WorkoutSet) => void; onUpdateSet: (set: WorkoutSet) => void;
@@ -23,7 +24,7 @@ interface TrackerProps {
import FilledInput from './FilledInput'; import FilledInput from './FilledInput';
import ExerciseModal from './ExerciseModal'; import ExerciseModal from './ExerciseModal';
const Tracker: React.FC<TrackerProps> = ({ userId, userWeight, activeSession, activePlan, onSessionStart, onSessionEnd, onSetAdded, onRemoveSet, onUpdateSet, lang }) => { const Tracker: React.FC<TrackerProps> = ({ userId, userWeight, activeSession, activePlan, onSessionStart, onSessionEnd, onSessionQuit, onSetAdded, onRemoveSet, onUpdateSet, lang }) => {
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);
@@ -665,8 +666,7 @@ const Tracker: React.FC<TrackerProps> = ({ userId, userWeight, activeSession, ac
<button <button
onClick={() => { onClick={() => {
setShowQuitConfirm(false); setShowQuitConfirm(false);
// Quit without saving - just navigate away or reset onSessionQuit();
window.location.reload();
}} }}
className="px-6 py-2.5 rounded-full bg-green-600 text-white font-medium hover:bg-green-700" className="px-6 py-2.5 rounded-full bg-green-600 text-white font-medium hover:bg-green-700"
> >

Binary file not shown.

View File

@@ -18,7 +18,7 @@ const app = express();
// ------------------------------------------------------------------- // -------------------------------------------------------------------
async function ensureAdminUser() { async function ensureAdminUser() {
const adminEmail = process.env.ADMIN_EMAIL || 'admin@gymflow.ai'; const adminEmail = process.env.ADMIN_EMAIL || 'admin@gymflow.ai';
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123'; const adminPassword = process.env.ADMIN_PASSWORD || 'admin1234';
const prisma = new PrismaClient(); const prisma = new PrismaClient();

View File

@@ -62,6 +62,47 @@ router.post('/login', async (req, res) => {
} }
}); });
// Register
router.post('/register', async (req, res) => {
try {
const { email, password } = req.body;
// Check if user already exists
const existingUser = await prisma.user.findUnique({ where: { email } });
if (existingUser) {
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({
data: {
email,
password: hashedPassword,
role: 'USER',
profile: {
create: {
weight: 70
}
}
},
include: { profile: true }
});
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) {
console.error(error);
res.status(500).json({ error: 'Server error' });
}
});
// Change Password // Change Password
router.post('/change-password', async (req, res) => { router.post('/change-password', async (req, res) => {
@@ -138,4 +179,92 @@ router.patch('/profile', async (req, res) => {
} }
}); });
// Admin: Get All Users
router.get('/users', async (req, res) => {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Unauthorized' });
const decoded = jwt.verify(token, JWT_SECRET) as any;
if (decoded.role !== 'ADMIN') {
return res.status(403).json({ error: 'Admin access required' });
}
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
role: true,
isBlocked: true,
isFirstLogin: true,
profile: true
}
});
res.json({ success: true, users });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Server error' });
}
});
// Admin: Delete User
router.delete('/users/:id', async (req, res) => {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Unauthorized' });
const decoded = jwt.verify(token, JWT_SECRET) as any;
if (decoded.role !== 'ADMIN') {
return res.status(403).json({ error: 'Admin access required' });
}
const { id } = req.params;
// Prevent deleting self
if (id === decoded.userId) {
return res.status(400).json({ error: 'Cannot delete yourself' });
}
await prisma.user.delete({ where: { id } });
res.json({ success: true });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Server error' });
}
});
// Admin: Toggle Block User
router.patch('/users/:id/block', async (req, res) => {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'Unauthorized' });
const decoded = jwt.verify(token, JWT_SECRET) as any;
if (decoded.role !== 'ADMIN') {
return res.status(403).json({ error: 'Admin access required' });
}
const { id } = req.params;
const { block } = req.body;
// Prevent blocking self
if (id === decoded.userId) {
return res.status(400).json({ error: 'Cannot block yourself' });
}
await prisma.user.update({
where: { id },
data: { isBlocked: block }
});
res.json({ success: true });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Server error' });
}
});
export default router; export default router;

View File

@@ -133,6 +133,121 @@ router.post('/', async (req: any, res) => {
} }
}); });
// Get active session (session without endTime)
router.get('/active', async (req: any, res) => {
try {
const userId = req.user.userId;
const activeSession = await prisma.workoutSession.findFirst({
where: {
userId,
endTime: null
},
include: { sets: { include: { exercise: true }, orderBy: { order: 'asc' } } }
});
if (!activeSession) {
return res.json({ success: true, session: null });
}
res.json({ success: true, session: activeSession });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Server error' });
}
});
// Update active session (for real-time set updates)
router.put('/active', async (req: any, res) => {
try {
const userId = req.user.userId;
const { id, startTime, endTime, userBodyWeight, note, planId, planName, sets } = req.body;
// Convert timestamps to Date objects if they are numbers
const start = new Date(startTime);
const end = endTime ? new Date(endTime) : null;
const weight = userBodyWeight ? parseFloat(userBodyWeight) : null;
// Check if session exists and belongs to user
const existing = await prisma.workoutSession.findFirst({
where: { id, userId }
});
if (!existing) {
return res.status(404).json({ error: 'Session not found' });
}
// Delete existing sets to replace them
await prisma.workoutSet.deleteMany({ where: { sessionId: id } });
const updated = await prisma.workoutSession.update({
where: { id },
data: {
startTime: start,
endTime: end,
userBodyWeight: weight,
note,
planId,
planName,
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 !== undefined ? s.completed : true
}))
}
},
include: { sets: { include: { exercise: true } } }
});
// Update user profile weight if session has weight and is finished
if (weight && end) {
await prisma.userProfile.upsert({
where: { userId },
create: { userId, weight },
update: { weight }
});
}
res.json({ success: true, session: updated });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Server error' });
}
});
// Delete active session (quit without saving)
router.delete('/active', async (req: any, res) => {
try {
const userId = req.user.userId;
// Find active session
const activeSession = await prisma.workoutSession.findFirst({
where: {
userId,
endTime: null
}
});
if (!activeSession) {
return res.json({ success: true, message: 'No active session found' });
}
// Delete the session (cascade will delete sets)
await prisma.workoutSession.delete({
where: { id: activeSession.id }
});
res.json({ success: true });
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Server error' });
}
});
// Delete session // Delete session
router.delete('/:id', async (req: any, res) => { router.delete('/:id', async (req: any, res) => {
try { try {

View File

@@ -27,6 +27,15 @@ export const api = {
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
return res.json(); return res.json();
}, },
put: async (endpoint: string, data: any) => {
const res = await fetch(`${API_URL}${endpoint}`, {
method: 'PUT',
headers: headers(),
body: JSON.stringify(data)
});
if (!res.ok) throw new Error(await res.text());
return res.json();
},
delete: async (endpoint: string) => { delete: async (endpoint: string) => {
const res = await fetch(`${API_URL}${endpoint}`, { const res = await fetch(`${API_URL}${endpoint}`, {
method: 'DELETE', method: 'DELETE',

View File

@@ -1,9 +1,13 @@
import { User, UserRole, UserProfile } from '../types'; import { User, UserRole, UserProfile } from '../types';
import { api, setAuthToken, removeAuthToken } from './api'; import { api, setAuthToken, removeAuthToken } from './api';
export const getUsers = (): any[] => { export const getUsers = async (): Promise<{ success: boolean; users?: User[]; error?: string }> => {
// Not used in frontend anymore try {
return []; const res = await api.get('/auth/users');
return res;
} catch (e) {
return { success: false, error: 'Failed to fetch users' };
}
}; };
export const login = async (email: string, password: string): Promise<{ success: boolean; user?: User; error?: string }> => { export const login = async (email: string, password: string): Promise<{ success: boolean; user?: User; error?: string }> => {
@@ -43,11 +47,21 @@ export const createUser = async (email: string, password: string): Promise<{ suc
}; };
export const deleteUser = async (userId: string) => { export const deleteUser = async (userId: string) => {
// Admin only, not implemented in frontend UI yet try {
const res = await api.delete(`/auth/users/${userId}`);
return res;
} catch (e) {
return { success: false, error: 'Failed to delete user' };
}
}; };
export const toggleBlockUser = (userId: string, block: boolean) => { export const toggleBlockUser = async (userId: string, block: boolean) => {
// Admin only try {
const res = await api.patch(`/auth/users/${userId}/block`, { block });
return res;
} catch (e) {
return { success: false, error: 'Failed to update user status' };
}
}; };
export const adminResetPassword = (userId: string, newPass: string) => { export const adminResetPassword = (userId: string, newPass: string) => {

View File

@@ -24,6 +24,37 @@ export const saveSession = async (userId: string, session: WorkoutSession): Prom
await api.post('/sessions', session); await api.post('/sessions', session);
}; };
export const getActiveSession = async (userId: string): Promise<WorkoutSession | null> => {
try {
const response = await api.get('/sessions/active');
if (!response.success || !response.session) {
return null;
}
const session = response.session;
// Convert ISO date strings to timestamps
return {
...session,
startTime: new Date(session.startTime).getTime(),
endTime: session.endTime ? new Date(session.endTime).getTime() : undefined,
sets: session.sets.map((set: any) => ({
...set,
exerciseName: set.exercise?.name || 'Unknown',
type: set.exercise?.type || 'STRENGTH'
}))
};
} catch {
return null;
}
};
export const updateActiveSession = async (userId: string, session: WorkoutSession): Promise<void> => {
await api.put('/sessions/active', session);
};
export const deleteActiveSession = async (userId: string): Promise<void> => {
await api.delete('/sessions/active');
};
export const deleteSession = async (userId: string, id: string): Promise<void> => { export const deleteSession = async (userId: string, id: string): Promise<void> => {
await api.delete(`/sessions/${id}`); await api.delete(`/sessions/${id}`);
}; };