diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts index 3a974cd..ca12400 100644 --- a/server/src/services/session.service.ts +++ b/server/src/services/session.service.ts @@ -55,6 +55,9 @@ export class SessionService { reps: s.reps, distanceMeters: s.distanceMeters, durationSeconds: s.durationSeconds, + height: s.height, + bodyWeightPercentage: s.bodyWeightPercentage, + side: s.side, completed: s.completed !== undefined ? s.completed : true })) } @@ -95,6 +98,9 @@ export class SessionService { reps: s.reps, distanceMeters: s.distanceMeters, durationSeconds: s.durationSeconds, + height: s.height, + bodyWeightPercentage: s.bodyWeightPercentage, + side: s.side, completed: s.completed !== undefined ? s.completed : true })) } @@ -160,6 +166,9 @@ export class SessionService { reps: s.reps, distanceMeters: s.distanceMeters, durationSeconds: s.durationSeconds, + height: s.height, + bodyWeightPercentage: s.bodyWeightPercentage, + side: s.side, completed: s.completed !== undefined ? s.completed : true })) } @@ -248,6 +257,8 @@ export class SessionService { 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 }, include: { exercise: true } @@ -261,7 +272,7 @@ export class SessionService { } static async logSetToActiveSession(userId: string, data: any) { - const { exerciseId, reps, weight, distanceMeters, durationSeconds, side } = data; + const { exerciseId, reps, weight, distanceMeters, durationSeconds, side, height, bodyWeightPercentage } = data; const activeSession = await prisma.workoutSession.findFirst({ where: { userId, endTime: null, type: 'STANDARD' }, @@ -283,6 +294,8 @@ export class SessionService { weight: weight ? parseFloat(weight) : 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 }, diff --git a/server/test.db b/server/test.db index 110acd1..3b02c90 100644 Binary files a/server/test.db and b/server/test.db differ diff --git a/src/components/EditSetModal.tsx b/src/components/EditSetModal.tsx new file mode 100644 index 0000000..c814841 --- /dev/null +++ b/src/components/EditSetModal.tsx @@ -0,0 +1,217 @@ +import React, { useState, useEffect } from 'react'; +import { Dumbbell, Activity, Percent, Timer, ArrowRight, ArrowUp, Save, Calendar, Clock } from 'lucide-react'; +import { WorkoutSet, ExerciseType, ExerciseDef, Language } from '../types'; +import { t } from '../services/i18n'; +import { formatSetMetrics } from '../utils/setFormatting'; +import { Modal } from './ui/Modal'; +import { Button } from './ui/Button'; + +interface EditSetModalProps { + isOpen: boolean; + onClose: () => void; + set: WorkoutSet; + exerciseDef?: ExerciseDef; + onSave: (updatedSet: WorkoutSet) => Promise | void; + lang: Language; +} + +const EditSetModal: React.FC = ({ + isOpen, + onClose, + set: initialSet, + exerciseDef, + onSave, + lang +}) => { + const [set, setSet] = useState(initialSet); + + // Reset state when modal opens with a new set + useEffect(() => { + setSet(initialSet); + }, [initialSet, isOpen]); + + const handleUpdate = (field: keyof WorkoutSet, value: any) => { + setSet(prev => ({ ...prev, [field]: value })); + }; + + const handleSave = async () => { + await onSave(set); + onClose(); + }; + + // Date/Time handling + const setDate = new Date(set.timestamp); + const dateStr = dateStrFromDate(setDate); + const timeStr = timeStrFromDate(setDate); + + function dateStrFromDate(d: Date) { + // YYYY-MM-DD + const pad = (n: number) => n < 10 ? '0' + n : n; + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + } + + function timeStrFromDate(d: Date) { + // HH:mm + const pad = (n: number) => n < 10 ? '0' + n : n; + return `${pad(d.getHours())}:${pad(d.getMinutes())}`; + } + + const handleDateChange = (newDateStr: string) => { + // Keep time, change date + const current = new Date(set.timestamp); + const [y, m, d] = newDateStr.split('-').map(Number); + const newDate = new Date(y, m - 1, d, current.getHours(), current.getMinutes(), current.getSeconds()); + handleUpdate('timestamp', newDate.getTime()); + }; + + const handleTimeChange = (newTimeStr: string) => { + // Keep date, change time + const current = new Date(set.timestamp); + const [h, min] = newTimeStr.split(':').map(Number); + const newDate = new Date(current.getFullYear(), current.getMonth(), current.getDate(), h, min, current.getSeconds()); + handleUpdate('timestamp', newDate.getTime()); + }; + + // Determine functionality based on resolved type + // Fallback: Check feature availability based on Type String or Prop existence + const type = (exerciseDef?.type || set.type) as string; + + const hasWeight = ['STRENGTH', 'BODYWEIGHT', 'STATIC', 'Strength', 'Bodyweight', 'Static'].includes(type) || set.weight !== null; + const hasReps = ['STRENGTH', 'BODYWEIGHT', 'PLYOMETRIC', 'Strength', 'Bodyweight', 'Plyometric'].includes(type) || set.reps !== null; + const hasBwPercent = ['BODYWEIGHT', 'STATIC', 'Bodyweight', 'Static'].includes(type) || set.bodyWeightPercentage !== null; + const hasTime = ['CARDIO', 'STATIC', 'Cardio', 'Static'].includes(type) || set.durationSeconds !== null; + const hasDist = ['CARDIO', 'LONG_JUMP', 'Cardio', 'Long Jump'].includes(type) || set.distanceMeters !== null; + const hasHeight = ['HIGH_JUMP', 'High Jump'].includes(type) || set.height !== null; + + return ( + +
+
+

+ {set.exerciseName} +

+

+ {exerciseDef?.type || set.type} + {set.side && ` • ${t(set.side.toLowerCase() as any, lang)}`} +

+
+ + {/* Date & Time */} +
+
+ + handleDateChange(e.target.value)} + className="w-full bg-transparent text-sm text-on-surface focus:outline-none" + /> +
+
+ + handleTimeChange(e.target.value)} + className="w-full bg-transparent text-sm text-on-surface focus:outline-none" + /> +
+
+ + {/* Metrics */} +
+ {hasWeight && ( +
+ + handleUpdate('weight', parseFloat(e.target.value))} + placeholder="0" + /> +
+ )} + {hasReps && ( +
+ + handleUpdate('reps', parseInt(e.target.value))} + placeholder="0" + /> +
+ )} + {hasBwPercent && ( +
+ + handleUpdate('bodyWeightPercentage', parseFloat(e.target.value))} + placeholder="100" + /> +
+ )} + {hasTime && ( +
+ + handleUpdate('durationSeconds', parseFloat(e.target.value))} + placeholder="0" + /> +
+ )} + {hasDist && ( +
+ + handleUpdate('distanceMeters', parseFloat(e.target.value))} + placeholder="0" + /> +
+ )} + {hasHeight && ( +
+ + handleUpdate('height', parseFloat(e.target.value))} + placeholder="0" + /> +
+ )} +
+ +
+ +
+
+
+ ); +}; + +export default EditSetModal; diff --git a/src/components/History.tsx b/src/components/History.tsx index b4a572e..c06f65c 100644 --- a/src/components/History.tsx +++ b/src/components/History.tsx @@ -9,6 +9,7 @@ import { getExercises } from '../services/storage'; import { Button } from './ui/Button'; import { Card } from './ui/Card'; import { Modal } from './ui/Modal'; +import EditSetModal from './EditSetModal'; import FilledInput from './FilledInput'; interface HistoryProps { @@ -25,6 +26,38 @@ const History: React.FC = ({ lang }) => { const [deletingId, setDeletingId] = useState(null); const [deletingSetInfo, setDeletingSetInfo] = useState<{ sessionId: string, setId: string } | null>(null); + const [editingSetInfo, setEditingSetInfo] = useState<{ sessionId: string, set: WorkoutSet } | null>(null); + + const handleSaveSingleSet = async (updatedSet: WorkoutSet) => { + if (!editingSetInfo) return; + const session = sessions.find(s => s.id === editingSetInfo.sessionId); + if (session) { + const updatedSets = session.sets.map(s => s.id === updatedSet.id ? updatedSet : s); + const updatedSession = { ...session, sets: updatedSets }; + try { + await updateSession(updatedSession); + setEditingSetInfo(null); + } catch (e) { + console.error("Failed to update set", e); + } + } + }; + + const handleSaveSetFromModal = async (updatedSet: WorkoutSet) => { + if (!editingSetInfo) return; + + // If editing within the session edit modal, update local state only + if (editingSession && editingSession.id === editingSetInfo.sessionId) { + const updatedSets = editingSession.sets.map(s => + s.id === updatedSet.id ? updatedSet : s + ); + setEditingSession({ ...editingSession, sets: updatedSets }); + setEditingSetInfo(null); + } else { + // Otherwise save to backend (Quick Log flow) + await handleSaveSingleSet(updatedSet); + } + }; React.useEffect(() => { if (!userId) return; @@ -87,13 +120,7 @@ const History: React.FC = ({ lang }) => { } }; - const handleUpdateSet = (setId: string, field: keyof WorkoutSet, value: number | string) => { - if (!editingSession) return; - const updatedSets = editingSession.sets.map(s => - s.id === setId ? { ...s, [field]: value } : s - ); - setEditingSession({ ...editingSession, sets: updatedSets }); - }; + const handleDeleteSet = (setId: string) => { if (!editingSession) return; @@ -267,7 +294,7 @@ const History: React.FC = ({ lang }) => { // Find the session this set belongs to and open edit mode const parentSession = daySessions.find(s => s.sets.some(st => st.id === set.id)); if (parentSession) { - setEditingSession(JSON.parse(JSON.stringify(parentSession))); + setEditingSetInfo({ sessionId: parentSession.id, set: set }); } }} variant="ghost" @@ -380,122 +407,37 @@ const History: React.FC = ({ lang }) => {

{t('sets_count', lang)} ({editingSession.sets.length})

{editingSession.sets.map((set, idx) => ( -
-
-
- {idx + 1} - {set.exerciseName}{set.side && {t(set.side.toLowerCase() as any, lang)}} +
+
+
+ {idx + 1} + {set.exerciseName} + {set.side && {t(set.side.toLowerCase() as any, lang)}}
+
+ {formatSetMetrics(set, lang)} +
+
+
+
- -
- {(set.type === ExerciseType.STRENGTH || set.type === ExerciseType.BODYWEIGHT || set.type === ExerciseType.STATIC) && ( -
- - handleUpdateSet(set.id, 'weight', parseFloat(e.target.value))} - /> -
- )} - {(set.type === ExerciseType.STRENGTH || set.type === ExerciseType.BODYWEIGHT || set.type === ExerciseType.PLYOMETRIC) && ( -
- - handleUpdateSet(set.id, 'reps', parseInt(e.target.value))} - /> -
- )} - {(set.type === ExerciseType.BODYWEIGHT || set.type === ExerciseType.STATIC) && ( -
- - handleUpdateSet(set.id, 'bodyWeightPercentage', parseFloat(e.target.value))} - /> -
- )} - {(set.type === ExerciseType.CARDIO || set.type === ExerciseType.STATIC) && ( -
- - handleUpdateSet(set.id, 'durationSeconds', parseFloat(e.target.value))} - /> -
- )} - {(set.type === ExerciseType.CARDIO || set.type === ExerciseType.LONG_JUMP) && ( -
- - handleUpdateSet(set.id, 'distanceMeters', parseFloat(e.target.value))} - /> -
- )} - {(set.type === ExerciseType.HIGH_JUMP) && ( -
- - handleUpdateSet(set.id, 'height', parseFloat(e.target.value))} - /> -
- )} -
- {/* Side Selector - Full width on mobile, 1 col on desktop if space */} - {(() => { - const exDef = exercises.find(e => e.id === set.exerciseId); - const showSide = set.side || exDef?.isUnilateral; - - if (!showSide) return null; - - return ( -
- -
- {(['LEFT', 'ALTERNATELY', 'RIGHT'] as const).map((sideOption) => { - const labelMap: Record = { LEFT: 'L', RIGHT: 'R', ALTERNATELY: 'A' }; - return ( - - ); - })} -
-
- ); - })()}
))}
@@ -510,6 +452,16 @@ const History: React.FC = ({ lang }) => { ) } + {editingSetInfo && ( + setEditingSetInfo(null)} + set={editingSetInfo.set} + exerciseDef={exercises.find(e => e.id === editingSetInfo.set.exerciseId)} + onSave={handleSaveSetFromModal} + lang={lang} + /> + )}
); }; diff --git a/src/components/Tracker/ActiveSessionView.tsx b/src/components/Tracker/ActiveSessionView.tsx index de206b7..c40cd28 100644 --- a/src/components/Tracker/ActiveSessionView.tsx +++ b/src/components/Tracker/ActiveSessionView.tsx @@ -10,6 +10,8 @@ import { formatSetMetrics } from '../../utils/setFormatting'; import { useAuth } from '../../context/AuthContext'; import { api } from '../../services/api'; import RestTimerFAB from '../ui/RestTimerFAB'; +import EditSetModal from '../EditSetModal'; + interface ActiveSessionViewProps { tracker: ReturnType; @@ -79,6 +81,15 @@ const ActiveSessionView: React.FC = ({ tracker, activeSe // Timer Logic is now managed in useTracker to persist across re-renders/step changes const { timer } = tracker; + const [editingSet, setEditingSet] = React.useState(null); + + const handleSaveSetFromModal = async (updatedSet: WorkoutSet) => { + if (tracker.updateSet) { + tracker.updateSet(updatedSet); + } + setEditingSet(null); + }; + const handleLogSet = async () => { await handleAddSet(); @@ -244,145 +255,34 @@ const ActiveSessionView: React.FC = ({ tracker, activeSe
{[...activeSession.sets].reverse().map((set: WorkoutSet, idx: number) => { const setNumber = activeSession.sets.length - idx; - const isEditing = editingSetId === set.id; return (
{setNumber}
- {isEditing ? ( -
-
{set.exerciseName}{set.side && {t(set.side.toLowerCase() as any, lang)}}
-
- {set.weight !== undefined && ( - setEditWeight(e.target.value)} - className="px-2 py-1 bg-surface-container-high rounded text-sm text-on-surface border border-outline-variant focus:border-primary focus:outline-none" - placeholder="Weight (kg)" - /> - )} - {set.reps !== undefined && ( - setEditReps(e.target.value)} - className="px-2 py-1 bg-surface-container-high rounded text-sm text-on-surface border border-outline-variant focus:border-primary focus:outline-none" - placeholder="Reps" - /> - )} - {set.durationSeconds !== undefined && ( - setEditDuration(e.target.value)} - className="px-2 py-1 bg-surface-container-high rounded text-sm text-on-surface border border-outline-variant focus:border-primary focus:outline-none" - placeholder="Duration (s)" - /> - )} - {set.distanceMeters !== undefined && ( - setEditDistance(e.target.value)} - className="px-2 py-1 bg-surface-container-high rounded text-sm text-on-surface border border-outline-variant focus:border-primary focus:outline-none" - placeholder="Distance (m)" - /> - )} - {set.height !== undefined && ( - setEditHeight(e.target.value)} - className="px-2 py-1 bg-surface-container-high rounded text-sm text-on-surface border border-outline-variant focus:border-primary focus:outline-none" - placeholder="Height (cm)" - /> - )} - {(() => { - const exDef = exercises.find(e => e.name === set.exerciseName); // Best effort matching by name since set might not have exerciseId deeply populated in some contexts, but id is safer. - // Actually set has exerciseId usually. Let's try to match by ID if possible, else name. - // But wait, ActiveSession sets might not have exerciseId if created ad-hoc? No, they should. - // Let's assume we can look up by name if id missing, or just check set.side presence. - // Detailed look: The session object has sets. - // Ideally check exDef.isUnilateral. - const isUnilateral = set.side || (exercises.find(e => e.name === set.exerciseName)?.isUnilateral); - - if (isUnilateral) { - return ( -
- {(['LEFT', 'ALTERNATELY', 'RIGHT'] as const).map((side) => { - const labelMap: Record = { LEFT: 'L', RIGHT: 'R', ALTERNATELY: 'A' }; - return ( - - ); - })} -
- ) - } - return null; - })()} -
+
+
{set.exerciseName}{set.side && {t(set.side.toLowerCase() as any, lang)}}
+
+ {formatSetMetrics(set, lang)}
- ) : ( -
-
{set.exerciseName}{set.side && {t(set.side.toLowerCase() as any, lang)}}
-
- {formatSetMetrics(set, lang)} -
-
- )} +
-
- {isEditing ? ( - <> - - - - ) : ( - <> - - - - )} +
+ +
); @@ -456,6 +356,18 @@ const ActiveSessionView: React.FC = ({ tracker, activeSe
)} + {/* Edit Set Modal */} + {editingSet && ( + setEditingSet(null)} + set={editingSet} + exerciseDef={tracker.exercises.find(e => e.id === editingSet.exerciseId) || tracker.exercises.find(e => e.name === editingSet.exerciseName)} + onSave={handleSaveSetFromModal} + lang={lang} + /> + )} +
); diff --git a/src/components/Tracker/SporadicView.tsx b/src/components/Tracker/SporadicView.tsx index 1310af3..6cc544d 100644 --- a/src/components/Tracker/SporadicView.tsx +++ b/src/components/Tracker/SporadicView.tsx @@ -3,6 +3,7 @@ import { CheckCircle, Plus, Pencil, Trash2, X, Save } from 'lucide-react'; import { Language, WorkoutSet } from '../../types'; import { t } from '../../services/i18n'; import ExerciseModal from '../ExerciseModal'; +import EditSetModal from '../EditSetModal'; import { useTracker } from './useTracker'; import SetLogger from './SetLogger'; import { formatSetMetrics } from '../../utils/setFormatting'; @@ -157,139 +158,35 @@ const SporadicView: React.FC = ({ tracker, lang }) => { {/* Edit Set Modal */} {editingSetId && editingSet && ( -
-
-
-

{t('edit', lang)}

- -
-
- {/* Side Selector */} - {(() => { - const exDef = exercises.find(e => e.name === editingSet.exerciseName); - const isUnilateral = editingSet.side || exDef?.isUnilateral; - - if (isUnilateral) { - return ( -
- -
- {(['LEFT', 'ALTERNATELY', 'RIGHT'] as const).map((side) => { - const labelMap: Record = { LEFT: 'L', RIGHT: 'R', ALTERNATELY: 'A' }; - return ( - - ); - })} -
-
- ) - } - return null; - })()} - - {(editingSet.type === 'STRENGTH' || editingSet.type === 'BODYWEIGHT') && ( - <> -
- - setEditingSet({ ...editingSet, weight: parseFloat(e.target.value) || 0 })} - className="w-full mt-1 px-4 py-2 bg-surface-container-high text-on-surface rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" - /> -
-
- - setEditingSet({ ...editingSet, reps: parseInt(e.target.value) || 0 })} - className="w-full mt-1 px-4 py-2 bg-surface-container-high text-on-surface rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" - /> -
- - )} - {(editingSet.type === 'CARDIO' || editingSet.type === 'STATIC') && ( -
- - setEditingSet({ ...editingSet, durationSeconds: parseInt(e.target.value) || 0 })} - className="w-full mt-1 px-4 py-2 bg-surface-container-high text-on-surface rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" - /> -
- )} - {editingSet.type === 'CARDIO' && ( -
- - setEditingSet({ ...editingSet, distanceMeters: parseFloat(e.target.value) || 0 })} - className="w-full mt-1 px-4 py-2 bg-surface-container-high text-on-surface rounded-lg focus:outline-none focus:ring-2 focus:ring-primary" - /> -
- )} -
-
- - -
-
-
+ { + setEditingSetId(null); + setEditingSet(null); + }} + set={editingSet} + exerciseDef={exercises.find(e => e.id === editingSet.exerciseId || e.name === editingSet.exerciseName)} + onSave={async (updatedSet) => { + try { + const response = await fetch(`/api/sessions/active/set/${editingSetId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token')}` + }, + body: JSON.stringify(updatedSet) + }); + if (response.ok) { + await loadQuickLogSession(); + setEditingSetId(null); + setEditingSet(null); + } + } catch (error) { + console.error('Failed to update set:', error); + } + }} + lang={lang} + /> )} {/* Delete Confirmation Modal */} diff --git a/src/components/Tracker/useTracker.ts b/src/components/Tracker/useTracker.ts index 95ab525..9decf39 100644 --- a/src/components/Tracker/useTracker.ts +++ b/src/components/Tracker/useTracker.ts @@ -263,6 +263,7 @@ export const useTracker = (props: any) => { // Props ignored/removed onSessionEnd: endSession, onSessionQuit: quitSession, onRemoveSet: removeSet, + updateSet: handleUpdateSetWrapper, activeSession, // Need this in view timer // Expose timer to views }; diff --git a/src/services/sessions.ts b/src/services/sessions.ts index 9b556b6..876acb2 100644 --- a/src/services/sessions.ts +++ b/src/services/sessions.ts @@ -9,7 +9,9 @@ interface ApiSession extends Omit => endTime: session.endTime ? new Date(session.endTime).getTime() : undefined, sets: session.sets.map((set) => ({ ...set, - exerciseName: set.exercise?.name || 'Unknown', - type: set.exercise?.type || ExerciseType.STRENGTH + exerciseName: set.exerciseName || set.exercise?.name || 'Unknown', + type: set.type || set.exercise?.type || ExerciseType.STRENGTH })) as WorkoutSet[] })); } catch { @@ -58,8 +60,8 @@ export const getActiveSession = async (userId: string): Promise ({ ...set, - exerciseName: set.exercise?.name || 'Unknown', - type: set.exercise?.type || ExerciseType.STRENGTH + exerciseName: set.exerciseName || set.exercise?.name || 'Unknown', + type: set.type || set.exercise?.type || ExerciseType.STRENGTH })) as WorkoutSet[] }; } catch {