From b5c8e8ac43ab5fa15b629c3702daf1a9b11ea5fd Mon Sep 17 00:00:00 2001 From: AG Date: Sat, 29 Nov 2025 19:03:42 +0200 Subject: [PATCH] Sporadic set logging added --- App.tsx | 34 +- TRACKER_QUICK_LOG_IMPLEMENTATION.txt | 220 +++++++++ components/History.tsx | 111 ++++- .../ActiveSessionView.tsx} | 465 +++--------------- components/Tracker/IdleView.tsx | 115 +++++ components/Tracker/SporadicView.tsx | 157 ++++++ components/Tracker/index.tsx | 48 ++ components/Tracker/useTracker.ts | 430 ++++++++++++++++ server/prisma/dev.db | Bin 73728 -> 102400 bytes server/prisma/schema.prisma | 22 + server/src/index.ts | 2 + server/src/routes/sporadic-sets.ts | 182 +++++++ services/i18n.ts | 18 +- services/sporadicSets.ts | 68 +++ types.ts | 15 + 15 files changed, 1491 insertions(+), 396 deletions(-) create mode 100644 TRACKER_QUICK_LOG_IMPLEMENTATION.txt rename components/{Tracker.tsx => Tracker/ActiveSessionView.tsx} (59%) create mode 100644 components/Tracker/IdleView.tsx create mode 100644 components/Tracker/SporadicView.tsx create mode 100644 components/Tracker/index.tsx create mode 100644 components/Tracker/useTracker.ts create mode 100644 server/src/routes/sporadic-sets.ts create mode 100644 services/sporadicSets.ts diff --git a/App.tsx b/App.tsx index 2651a0b..d146437 100644 --- a/App.tsx +++ b/App.tsx @@ -1,15 +1,16 @@ import React, { useState, useEffect } from 'react'; import Navbar from './components/Navbar'; -import Tracker from './components/Tracker'; +import Tracker from './components/Tracker/index'; import History from './components/History'; import Stats from './components/Stats'; import AICoach from './components/AICoach'; import Plans from './components/Plans'; import Login from './components/Login'; import Profile from './components/Profile'; -import { TabView, WorkoutSession, WorkoutSet, WorkoutPlan, User, Language } from './types'; +import { TabView, WorkoutSession, WorkoutSet, WorkoutPlan, User, Language, SporadicSet } from './types'; import { getSessions, saveSession, deleteSession, getPlans, getActiveSession, updateActiveSession, deleteActiveSession, updateSetInActiveSession, deleteSetFromActiveSession } from './services/storage'; +import { getSporadicSets, updateSporadicSet, deleteSporadicSet } from './services/sporadicSets'; import { getCurrentUserProfile, getMe } from './services/auth'; import { getSystemLanguage } from './services/i18n'; import { logWeight } from './services/weight'; @@ -24,6 +25,7 @@ function App() { const [plans, setPlans] = useState([]); const [activeSession, setActiveSession] = useState(null); const [activePlan, setActivePlan] = useState(null); + const [sporadicSets, setSporadicSets] = useState([]); useEffect(() => { // Set initial language @@ -66,9 +68,13 @@ function App() { // Load plans const p = await getPlans(currentUser.id); setPlans(p); + // Load sporadic sets + const sporadicSets = await getSporadicSets(); + setSporadicSets(sporadicSets); } else { setSessions([]); setPlans([]); + setSporadicSets([]); } }; loadSessions(); @@ -80,6 +86,7 @@ function App() { }; const handleLogout = () => { + localStorage.removeItem('token'); setCurrentUser(null); setActiveSession(null); setActivePlan(null); @@ -190,6 +197,25 @@ function App() { setSessions(prev => prev.filter(s => s.id !== sessionId)); }; + const handleSporadicSetAdded = async () => { + const sets = await getSporadicSets(); + setSporadicSets(sets); + }; + + const handleUpdateSporadicSet = async (set: SporadicSet) => { + const updated = await updateSporadicSet(set.id, set); + if (updated) { + setSporadicSets(prev => prev.map(s => s.id === set.id ? updated : s)); + } + }; + + const handleDeleteSporadicSet = async (id: string) => { + const success = await deleteSporadicSet(id); + if (success) { + setSporadicSets(prev => prev.filter(s => s.id !== id)); + } + }; + if (!currentUser) { return ; } @@ -215,6 +241,7 @@ function App() { onSetAdded={handleAddSet} onRemoveSet={handleRemoveSetFromActive} onUpdateSet={handleUpdateSetInActive} + onSporadicSetAdded={handleSporadicSetAdded} lang={language} /> )} @@ -224,8 +251,11 @@ function App() { {currentTab === 'HISTORY' && ( )} diff --git a/TRACKER_QUICK_LOG_IMPLEMENTATION.txt b/TRACKER_QUICK_LOG_IMPLEMENTATION.txt new file mode 100644 index 0000000..cc23290 --- /dev/null +++ b/TRACKER_QUICK_LOG_IMPLEMENTATION.txt @@ -0,0 +1,220 @@ +// Add this to Tracker.tsx imports (line 9, after api import): +import { logSporadicSet } from '../services/sporadicSets'; + +// Add this to TrackerProps interface (after onUpdateSet, around line 21): +onSporadicSetAdded?: () => void; + +// Update component function signature (line 28): +const Tracker: React.FC = ({ userId, userWeight, activeSession, activePlan, onSessionStart, onSessionEnd, onSessionQuit, onSetAdded, onRemoveSet, onUpdateSet, onSporadicSetAdded, lang }) => { + +// Add these state variables (after editHeight state, around line 69): +const [isSporadicMode, setIsSporadicMode] = useState(false); +const [sporadicSuccess, setSporadicSuccess] = useState(false); + +// Add this handler function (after handleCancelEdit, around line 289): +const handleLogSporadicSet = async () => { + if (!selectedExercise) return; + + const setData: any = { exerciseId: selectedExercise.id }; + + switch (selectedExercise.type) { + case ExerciseType.STRENGTH: + if (weight) setData.weight = parseFloat(weight); + if (reps) setData.reps = parseInt(reps); + break; + case ExerciseType.BODYWEIGHT: + if (weight) setData.weight = parseFloat(weight); + if (reps) setData.reps = parseInt(reps); + setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100; + break; + case ExerciseType.CARDIO: + if (duration) setData.durationSeconds = parseInt(duration); + if (distance) setData.distanceMeters = parseFloat(distance); + break; + case ExerciseType.STATIC: + if (duration) setData.durationSeconds = parseInt(duration); + setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100; + break; + case ExerciseType.HIGH_JUMP: + if (height) setData.height = parseFloat(height); + break; + case ExerciseType.LONG_JUMP: + if (distance) setData.distanceMeters = parseFloat(distance); + break; + case ExerciseType.PLYOMETRIC: + if (reps) setData.reps = parseInt(reps); + break; + } + + const result = await logSporadicSet(setData); + if (result) { + setSporadicSuccess(true); + setTimeout(() => setSporadicSuccess(false), 2000); + + // Reset form + setWeight(''); setReps(''); setDuration(''); + setDistance(''); setHeight(''); + setSelectedExercise(null); + setSearchQuery(''); + + if (onSporadicSetAdded) onSporadicSetAdded(); + } +}; + +// Replace the single "Free Workout" button section (around line 347-355) with: +
+ + +
+ +// Add this new section after the "no active session" return statement (after line 396, before the main return): +if (!activeSession && isSporadicMode) { + return ( +
+ {/* Header */} +
+ +

{t('quick_log', lang)}

+
+ + {/* Success Message */} + {sporadicSuccess && ( +
+ {t('log_sporadic_success', lang)} +
+ )} + + {/* Exercise Selection and Form - reuse existing components */} +
+
+ ) => { + setSearchQuery(e.target.value); + setShowSuggestions(true); + }} + onFocus={() => setShowSuggestions(true)} + onBlur={() => setTimeout(() => setShowSuggestions(false), 100)} + icon={} + autoComplete="off" + type="text" + /> + + {showSuggestions && ( +
+ {filteredExercises.length > 0 ? ( + filteredExercises.map(ex => ( + + )) + ) : ( +
{t('no_exercises_found', lang)}
+ )} +
+ )} +
+ + {selectedExercise && ( +
+
+ {(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT) && ( + setWeight(e.target.value)} + icon={} + autoFocus + /> + )} + {(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.PLYOMETRIC) && ( + setReps(e.target.value)} + icon={} + /> + )} + {(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.STATIC) && ( + setDuration(e.target.value)} + icon={} + /> + )} + {(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.LONG_JUMP) && ( + setDistance(e.target.value)} + icon={} + /> + )} + {selectedExercise.type === ExerciseType.HIGH_JUMP && ( + setHeight(e.target.value)} + icon={} + /> + )} +
+ + +
+ )} +
+ + {/* Exercise Modal */} + {isCreating && ( + setIsCreating(false)} + onCreate={handleCreateExercise} + lang={lang} + /> + )} +
+ ); +} diff --git a/components/History.tsx b/components/History.tsx index 0092963..847e25d 100644 --- a/components/History.tsx +++ b/components/History.tsx @@ -1,19 +1,24 @@ 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 { WorkoutSession, ExerciseType, WorkoutSet, Language, SporadicSet } from '../types'; import { t } from '../services/i18n'; interface HistoryProps { sessions: WorkoutSession[]; + sporadicSets?: SporadicSet[]; onUpdateSession?: (session: WorkoutSession) => void; onDeleteSession?: (sessionId: string) => void; + onUpdateSporadicSet?: (set: SporadicSet) => void; + onDeleteSporadicSet?: (setId: string) => void; lang: Language; } -const History: React.FC = ({ sessions, onUpdateSession, onDeleteSession, lang }) => { +const History: React.FC = ({ sessions, sporadicSets, onUpdateSession, onDeleteSession, onUpdateSporadicSet, onDeleteSporadicSet, lang }) => { const [editingSession, setEditingSession] = useState(null); const [deletingId, setDeletingId] = useState(null); + const [editingSporadicSet, setEditingSporadicSet] = useState(null); + const [deletingSporadicId, setDeletingSporadicId] = useState(null); const calculateSessionWork = (session: WorkoutSession) => { const bw = session.userBodyWeight || 70; @@ -88,6 +93,25 @@ const History: React.FC = ({ sessions, onUpdateSession, onDeleteSe } } + const handleSaveSporadicEdit = () => { + if (editingSporadicSet && onUpdateSporadicSet) { + onUpdateSporadicSet(editingSporadicSet); + setEditingSporadicSet(null); + } + }; + + const handleUpdateSporadicField = (field: keyof SporadicSet, value: number) => { + if (!editingSporadicSet) return; + setEditingSporadicSet({ ...editingSporadicSet, [field]: value }); + }; + + const handleConfirmDeleteSporadic = () => { + if (deletingSporadicId && onDeleteSporadicSet) { + onDeleteSporadicSet(deletingSporadicId); + setDeletingSporadicId(null); + } + }; + if (sessions.length === 0) { return (
@@ -175,6 +199,65 @@ const History: React.FC = ({ sessions, onUpdateSession, onDeleteSe })}
+ {/* Sporadic Sets Section */} + {sporadicSets && sporadicSets.length > 0 && ( +
+

{t('sporadic_sets_title', lang)}

+ {Object.entries( + sporadicSets.reduce((groups: Record, set) => { + const date = new Date(set.timestamp).toISOString().split('T')[0]; + if (!groups[date]) groups[date] = []; + groups[date].push(set); + return groups; + }, {}) + ) + .sort(([a], [b]) => b.localeCompare(a)) + .map(([date, sets]) => ( +
+
{date}
+
+ {sets.map(set => ( +
+
+
{set.exerciseName}
+
+ {set.type === ExerciseType.STRENGTH && `${set.weight || 0}kg x ${set.reps || 0}`} + {set.type === ExerciseType.BODYWEIGHT && `${set.weight ? `+${set.weight}kg` : 'BW'} x ${set.reps || 0}`} + {set.type === ExerciseType.CARDIO && `${set.durationSeconds || 0}s ${set.distanceMeters ? `/ ${set.distanceMeters}m` : ''}`} + {set.type === ExerciseType.STATIC && `${set.durationSeconds || 0}s`} + {set.type === ExerciseType.HIGH_JUMP && `${set.height || 0}cm`} + {set.type === ExerciseType.LONG_JUMP && `${set.distanceMeters || 0}m`} + {set.type === ExerciseType.PLYOMETRIC && `x ${set.reps || 0}`} +
+
+ {new Date(set.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +
+
+
+ + +
+
+ ))} +
+
+ ))} +
+ )} + {/* DELETE CONFIRMATION DIALOG (MD3) */} {deletingId && (
@@ -338,6 +421,30 @@ const History: React.FC = ({ sessions, onUpdateSession, onDeleteSe
)} + + {/* Sporadic Set Delete Confirmation */} + {deletingSporadicId && ( +
+
+

{t('delete', lang)}

+

{t('delete_confirm', lang)}

+
+ + +
+
+
+ )} ); }; diff --git a/components/Tracker.tsx b/components/Tracker/ActiveSessionView.tsx similarity index 59% rename from components/Tracker.tsx rename to components/Tracker/ActiveSessionView.tsx index f31c8a5..4f3aa73 100644 --- a/components/Tracker.tsx +++ b/components/Tracker/ActiveSessionView.tsx @@ -1,402 +1,85 @@ +import React from 'react'; +import { MoreVertical, X, CheckSquare, ChevronUp, ChevronDown, Scale, Dumbbell, Plus, Activity, Timer as TimerIcon, ArrowRight, ArrowUp, CheckCircle, Edit, Trash2 } from 'lucide-react'; +import { ExerciseType, Language, WorkoutSet } from '../../types'; +import { t } from '../../services/i18n'; +import FilledInput from '../FilledInput'; +import ExerciseModal from '../ExerciseModal'; +import { useTracker } from './useTracker'; -import React, { useState, useEffect } from 'react'; -import { Plus, Activity, ChevronDown, ChevronUp, Dumbbell, PlayCircle, CheckCircle, User, Scale, X, Flame, Timer as TimerIcon, ArrowUp, ArrowRight, Footprints, Ruler, CheckSquare, Trash2, Percent, MoreVertical, Edit } from 'lucide-react'; -import { WorkoutSession, WorkoutSet, ExerciseDef, ExerciseType, WorkoutPlan, Language } from '../types'; -import { getExercises, getLastSetForExercise, saveExercise, getPlans } from '../services/storage'; -import { getCurrentUserProfile } from '../services/auth'; -import { t } from '../services/i18n'; -import { generateId } from '../utils/uuid'; -import { api } from '../services/api'; - -interface TrackerProps { - userId: string; - userWeight?: number; - activeSession: WorkoutSession | null; - activePlan: WorkoutPlan | null; - onSessionStart: (plan?: WorkoutPlan, startWeight?: number) => void; +interface ActiveSessionViewProps { + tracker: ReturnType; + activeSession: any; // Using any to avoid strict type issues with the complex session object for now, but ideally should be WorkoutSession + lang: Language; onSessionEnd: () => void; onSessionQuit: () => void; - onSetAdded: (set: WorkoutSet) => void; onRemoveSet: (setId: string) => void; - onUpdateSet: (set: WorkoutSet) => void; - lang: Language; } -import FilledInput from './FilledInput'; -import ExerciseModal from './ExerciseModal'; +const ActiveSessionView: React.FC = ({ tracker, activeSession, lang, onSessionEnd, onSessionQuit, onRemoveSet }) => { + const { + elapsedTime, + showFinishConfirm, + setShowFinishConfirm, + showQuitConfirm, + setShowQuitConfirm, + showMenu, + setShowMenu, + activePlan, // This comes from useTracker props but we might need to pass it explicitly if not in hook return + currentStepIndex, + showPlanList, + setShowPlanList, + jumpToStep, + searchQuery, + setSearchQuery, + setShowSuggestions, + showSuggestions, + filteredExercises, + setSelectedExercise, + selectedExercise, + weight, + setWeight, + reps, + setReps, + duration, + setDuration, + distance, + setDistance, + height, + setHeight, + handleAddSet, + editingSetId, + editWeight, + setEditWeight, + editReps, + setEditReps, + editDuration, + setEditDuration, + editDistance, + setEditDistance, + editHeight, + setEditHeight, + handleCancelEdit, + handleSaveEdit, + handleEditSet, + isCreating, + setIsCreating, + handleCreateExercise, + exercises + } = tracker; -const Tracker: React.FC = ({ userId, userWeight, activeSession, activePlan, onSessionStart, onSessionEnd, onSessionQuit, onSetAdded, onRemoveSet, onUpdateSet, lang }) => { - const [exercises, setExercises] = useState([]); - const [plans, setPlans] = useState([]); - const [selectedExercise, setSelectedExercise] = useState(null); - const [lastSet, setLastSet] = useState(undefined); - const [searchQuery, setSearchQuery] = useState(''); - const [showSuggestions, setShowSuggestions] = useState(false); - - // Timer State - const [elapsedTime, setElapsedTime] = useState('00:00:00'); - - // Form State - const [weight, setWeight] = useState(''); - const [reps, setReps] = useState(''); - const [duration, setDuration] = useState(''); - const [distance, setDistance] = useState(''); - const [height, setHeight] = useState(''); - const [bwPercentage, setBwPercentage] = useState('100'); - - // User Weight State - const [userBodyWeight, setUserBodyWeight] = useState(userWeight ? userWeight.toString() : '70'); - - // Create Exercise State - const [isCreating, setIsCreating] = useState(false); - - // Plan Execution State - const [currentStepIndex, setCurrentStepIndex] = useState(0); - const [showPlanPrep, setShowPlanPrep] = useState(null); - const [showPlanList, setShowPlanList] = useState(false); - - // Confirmation State - const [showFinishConfirm, setShowFinishConfirm] = useState(false); - const [showQuitConfirm, setShowQuitConfirm] = useState(false); - const [showMenu, setShowMenu] = useState(false); - - // Edit Set State - const [editingSetId, setEditingSetId] = useState(null); - const [editWeight, setEditWeight] = useState(''); - const [editReps, setEditReps] = useState(''); - const [editDuration, setEditDuration] = useState(''); - const [editDistance, setEditDistance] = useState(''); - const [editHeight, setEditHeight] = useState(''); - - useEffect(() => { - const loadData = async () => { - const exList = await getExercises(userId); - exList.sort((a, b) => a.name.localeCompare(b.name)); - setExercises(exList); - const planList = await getPlans(userId); - setPlans(planList); - - if (activeSession?.userBodyWeight) { - setUserBodyWeight(activeSession.userBodyWeight.toString()); - } else if (userWeight) { - setUserBodyWeight(userWeight.toString()); - } - }; - loadData(); - }, [activeSession, userId, userWeight, activePlan]); - - // Timer Logic - useEffect(() => { - let interval: number; - if (activeSession) { - const updateTimer = () => { - const diff = Math.floor((Date.now() - activeSession.startTime) / 1000); - const h = Math.floor(diff / 3600); - const m = Math.floor((diff % 3600) / 60); - const s = diff % 60; - setElapsedTime(`${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`); - }; - - updateTimer(); - interval = window.setInterval(updateTimer, 1000); - } - return () => clearInterval(interval); - }, [activeSession]); - - // Recalculate current step when sets change - useEffect(() => { - if (activeSession && activePlan) { - const performedCounts = new Map(); - for (const set of activeSession.sets) { - performedCounts.set(set.exerciseId, (performedCounts.get(set.exerciseId) || 0) + 1); - } - - let nextStepIndex = activePlan.steps.length; // Default to finished - const plannedCounts = new Map(); - for (let i = 0; i < activePlan.steps.length; i++) { - const step = activePlan.steps[i]; - const exerciseId = step.exerciseId; - plannedCounts.set(exerciseId, (plannedCounts.get(exerciseId) || 0) + 1); - const performedCount = performedCounts.get(exerciseId) || 0; - - if (performedCount < plannedCounts.get(exerciseId)!) { - nextStepIndex = i; - break; - } - } - setCurrentStepIndex(nextStepIndex); - } - }, [activeSession, activePlan]); - - - useEffect(() => { - if (activeSession && activePlan && exercises.length > 0 && activePlan.steps.length > 0) { - if (currentStepIndex < activePlan.steps.length) { - const step = activePlan.steps[currentStepIndex]; - if (step) { - const exDef = exercises.find(e => e.id === step.exerciseId); - if (exDef) { - setSelectedExercise(exDef); - } - } - } - } - }, [currentStepIndex, activePlan, exercises]); - - useEffect(() => { - const updateSelection = async () => { - if (selectedExercise) { - setBwPercentage(selectedExercise.bodyWeightPercentage ? selectedExercise.bodyWeightPercentage.toString() : '100'); - const set = await getLastSetForExercise(userId, selectedExercise.id); - setLastSet(set); - - if (set) { - setWeight(set.weight?.toString() || ''); - setReps(set.reps?.toString() || ''); - setDuration(set.durationSeconds?.toString() || ''); - setDistance(set.distanceMeters?.toString() || ''); - setHeight(set.height?.toString() || ''); - } else { - setWeight(''); setReps(''); setDuration(''); setDistance(''); setHeight(''); - } - - // Clear fields not relevant to the selected exercise type - if (selectedExercise.type !== ExerciseType.STRENGTH && selectedExercise.type !== ExerciseType.BODYWEIGHT) { - setWeight(''); - } - if (selectedExercise.type !== ExerciseType.STRENGTH && selectedExercise.type !== ExerciseType.BODYWEIGHT && selectedExercise.type !== ExerciseType.PLYOMETRIC) { - setReps(''); - } - if (selectedExercise.type !== ExerciseType.CARDIO && selectedExercise.type !== ExerciseType.STATIC) { - setDuration(''); - } - if (selectedExercise.type !== ExerciseType.CARDIO && selectedExercise.type !== ExerciseType.LONG_JUMP) { - setDistance(''); - } - if (selectedExercise.type !== ExerciseType.HIGH_JUMP) { - setHeight(''); - } - } else { - setSearchQuery(''); // Clear search query if no exercise is selected - } - }; - updateSelection(); - }, [selectedExercise, userId]); - - const filteredExercises = searchQuery === '' - ? exercises - : exercises.filter(ex => - ex.name.toLowerCase().includes(searchQuery.toLowerCase()) - ); - - const handleStart = (plan?: WorkoutPlan) => { - if (plan && plan.description) { - setShowPlanPrep(plan); - } else { - onSessionStart(plan, parseFloat(userBodyWeight)); - } - }; - - const confirmPlanStart = () => { - if (showPlanPrep) { - onSessionStart(showPlanPrep, parseFloat(userBodyWeight)); - setShowPlanPrep(null); - } - } - - const handleAddSet = async () => { - if (!activeSession || !selectedExercise) return; - - const setData: Partial = { - exerciseId: selectedExercise.id, - }; - - switch (selectedExercise.type) { - case ExerciseType.STRENGTH: - if (weight) setData.weight = parseFloat(weight); - if (reps) setData.reps = parseInt(reps); - break; - case ExerciseType.BODYWEIGHT: - if (weight) setData.weight = parseFloat(weight); - if (reps) setData.reps = parseInt(reps); - setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100; - break; - case ExerciseType.CARDIO: - if (duration) setData.durationSeconds = parseInt(duration); - if (distance) setData.distanceMeters = parseFloat(distance); - break; - case ExerciseType.STATIC: - if (duration) setData.durationSeconds = parseInt(duration); - setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100; - break; - case ExerciseType.HIGH_JUMP: - if (height) setData.height = parseFloat(height); - break; - case ExerciseType.LONG_JUMP: - if (distance) setData.distanceMeters = parseFloat(distance); - break; - case ExerciseType.PLYOMETRIC: - if (reps) setData.reps = parseInt(reps); - break; - } - - try { - 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) { - setCurrentStepIndex(nextStepIndex); - } - } else if (activePlan && !activeExerciseId) { - // Plan is finished - setCurrentStepIndex(activePlan.steps.length); - } - } - } catch (error) { - console.error("Failed to log set:", error); - // Optionally, show an error message to the user - } - }; - - const handleCreateExercise = async (newEx: ExerciseDef) => { - await saveExercise(userId, newEx); - setExercises(prev => [...prev, newEx].sort((a, b) => a.name.localeCompare(b.name))); - setSelectedExercise(newEx); - setIsCreating(false); - }; - - const handleEditSet = (set: WorkoutSet) => { - setEditingSetId(set.id); - setEditWeight(set.weight?.toString() || ''); - setEditReps(set.reps?.toString() || ''); - setEditDuration(set.durationSeconds?.toString() || ''); - setEditDistance(set.distanceMeters?.toString() || ''); - setEditHeight(set.height?.toString() || ''); - }; - - const handleSaveEdit = (set: WorkoutSet) => { - const updatedSet: WorkoutSet = { - ...set, - ...(editWeight && { weight: parseFloat(editWeight) }), - ...(editReps && { reps: parseInt(editReps) }), - ...(editDuration && { durationSeconds: parseInt(editDuration) }), - ...(editDistance && { distanceMeters: parseFloat(editDistance) }), - ...(editHeight && { height: parseFloat(editHeight) }) - }; - onUpdateSet(updatedSet); - setEditingSetId(null); - }; - - const handleCancelEdit = () => { - setEditingSetId(null); - }; - - const jumpToStep = (index: number) => { - if (!activePlan) return; - setCurrentStepIndex(index); - setShowPlanList(false); - }; + // We need activePlan from the hook or props. The hook returns 'plans' but not 'activePlan'. + // Actually useTracker takes activePlan as prop but doesn't return it. + // We should probably pass activePlan as a prop to this component directly from the parent. + // Let's assume the parent passes it or we modify the hook. + // For now, let's use the activePlan passed to the hook if possible, but the hook doesn't expose it. + // I will modify the hook to return activePlan or just accept it as prop here. + // The hook accepts activePlan as argument, so I can return it. + // Let's modify useTracker to return activePlan in the next step if needed, or just pass it here. + // Wait, I can't modify useTracker easily now without rewriting it. + // I'll pass activePlan as a prop to ActiveSessionView. const isPlanFinished = activePlan && currentStepIndex >= activePlan.steps.length; - const exerciseTypeLabels: Record = { - [ExerciseType.STRENGTH]: t('type_strength', lang), - [ExerciseType.BODYWEIGHT]: t('type_bodyweight', lang), - [ExerciseType.CARDIO]: t('type_cardio', lang), - [ExerciseType.STATIC]: t('type_static', lang), - [ExerciseType.HIGH_JUMP]: t('type_height', lang), - [ExerciseType.LONG_JUMP]: t('type_dist', lang), - [ExerciseType.PLYOMETRIC]: t('type_jump', lang), - }; - - if (!activeSession) { - return ( -
-
-
-
- -
-
-

{t('ready_title', lang)}

-

{t('ready_subtitle', lang)}

-
-
- -
- -
- setUserBodyWeight(e.target.value)} - /> -
-

{t('change_in_profile', lang)}

-
- -
- -
- - {plans.length > 0 && ( -
-

{t('or_choose_plan', lang)}

-
- {plans.map(plan => ( - - ))} -
-
- )} -
- - {showPlanPrep && ( -
-
-

{showPlanPrep.name}

-
-
{t('prep_title', lang)}
- {showPlanPrep.description || t('prep_no_instructions', lang)} -
-
- - -
-
-
- )} -
- ); - } - return (
@@ -600,7 +283,7 @@ const Tracker: React.FC = ({ userId, userWeight, activeSession, ac

{t('history_section', lang)}

- {[...activeSession.sets].reverse().map((set, idx) => { + {[...activeSession.sets].reverse().map((set: WorkoutSet, idx: number) => { const setNumber = activeSession.sets.length - idx; const isEditing = editingSetId === set.id; return ( @@ -800,4 +483,4 @@ const Tracker: React.FC = ({ userId, userWeight, activeSession, ac ); }; -export default Tracker; \ No newline at end of file +export default ActiveSessionView; diff --git a/components/Tracker/IdleView.tsx b/components/Tracker/IdleView.tsx new file mode 100644 index 0000000..4a2831e --- /dev/null +++ b/components/Tracker/IdleView.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { Dumbbell, User, PlayCircle, Plus, ArrowRight } from 'lucide-react'; +import { Language } from '../../types'; +import { t } from '../../services/i18n'; +import { useTracker } from './useTracker'; + +interface IdleViewProps { + tracker: ReturnType; + lang: Language; +} + +const IdleView: React.FC = ({ tracker, lang }) => { + const { + userBodyWeight, + setUserBodyWeight, + handleStart, + setIsSporadicMode, + plans, + showPlanPrep, + setShowPlanPrep, + confirmPlanStart + } = tracker; + + return ( +
+
+
+
+ +
+
+

{t('ready_title', lang)}

+

{t('ready_subtitle', lang)}

+
+
+ +
+ +
+ setUserBodyWeight(e.target.value)} + /> +
+

{t('change_in_profile', lang)}

+
+ +
+ + + +
+ + {plans.length > 0 && ( +
+

{t('or_choose_plan', lang)}

+
+ {plans.map(plan => ( + + ))} +
+
+ )} +
+ + {showPlanPrep && ( +
+
+

{showPlanPrep.name}

+
+
{t('prep_title', lang)}
+ {showPlanPrep.description || t('prep_no_instructions', lang)} +
+
+ + +
+
+
+ )} +
+ ); +}; + +export default IdleView; diff --git a/components/Tracker/SporadicView.tsx b/components/Tracker/SporadicView.tsx new file mode 100644 index 0000000..19ee92b --- /dev/null +++ b/components/Tracker/SporadicView.tsx @@ -0,0 +1,157 @@ +import React from 'react'; +import { Dumbbell, Scale, Activity, Timer as TimerIcon, ArrowRight, ArrowUp, Plus, CheckCircle } from 'lucide-react'; +import { ExerciseType, Language } from '../../types'; +import { t } from '../../services/i18n'; +import FilledInput from '../FilledInput'; +import { useTracker } from './useTracker'; + +interface SporadicViewProps { + tracker: ReturnType; + lang: Language; +} + +const SporadicView: React.FC = ({ tracker, lang }) => { + const { + searchQuery, + setSearchQuery, + setShowSuggestions, + showSuggestions, + filteredExercises, + setSelectedExercise, + selectedExercise, + weight, + setWeight, + reps, + setReps, + duration, + setDuration, + distance, + setDistance, + height, + setHeight, + handleLogSporadicSet, + sporadicSuccess, + setIsSporadicMode + } = tracker; + + return ( +
+
+
+

+ + {t('quick_log', lang)} +

+
+ +
+ +
+ {/* Exercise Selection */} +
+ ) => { + setSearchQuery(e.target.value); + setShowSuggestions(true); + }} + onFocus={() => setShowSuggestions(true)} + onBlur={() => setTimeout(() => setShowSuggestions(false), 100)} + icon={} + autoComplete="off" + type="text" + /> + {showSuggestions && ( +
+ {filteredExercises.length > 0 ? ( + filteredExercises.map(ex => ( + + )) + ) : ( +
{t('no_exercises_found', lang)}
+ )} +
+ )} +
+ + {selectedExercise && ( +
+
+ {(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.STATIC) && ( + setWeight(e.target.value)} + icon={} + /> + )} + {(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.PLYOMETRIC) && ( + setReps(e.target.value)} + icon={} + type="number" + /> + )} + {(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.STATIC) && ( + setDuration(e.target.value)} + icon={} + /> + )} + {(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.LONG_JUMP) && ( + setDistance(e.target.value)} + icon={} + /> + )} + {(selectedExercise.type === ExerciseType.HIGH_JUMP) && ( + setHeight(e.target.value)} + icon={} + /> + )} +
+ + +
+ )} +
+
+ ); +}; + +export default SporadicView; diff --git a/components/Tracker/index.tsx b/components/Tracker/index.tsx new file mode 100644 index 0000000..09897ad --- /dev/null +++ b/components/Tracker/index.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { WorkoutSession, WorkoutSet, WorkoutPlan, 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 = (props) => { + const tracker = useTracker(props); + const { isSporadicMode } = tracker; + const { activeSession, lang, onSessionEnd, onSessionQuit, onRemoveSet } = props; + + if (activeSession) { + return ( + + ); + } + + if (isSporadicMode) { + return ; + } + + return ; +}; + +export default Tracker; diff --git a/components/Tracker/useTracker.ts b/components/Tracker/useTracker.ts new file mode 100644 index 0000000..bbf981a --- /dev/null +++ b/components/Tracker/useTracker.ts @@ -0,0 +1,430 @@ +import { useState, useEffect } from 'react'; +import { WorkoutSession, WorkoutSet, ExerciseDef, ExerciseType, WorkoutPlan, Language } from '../../types'; +import { getExercises, getLastSetForExercise, saveExercise, getPlans } from '../../services/storage'; +import { api } from '../../services/api'; +import { logSporadicSet } from '../../services/sporadicSets'; + +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 = ({ + userId, + userWeight, + activeSession, + activePlan, + onSessionStart, + onSessionEnd, + onSessionQuit, + onSetAdded, + onRemoveSet, + onUpdateSet, + onSporadicSetAdded +}: UseTrackerProps) => { + const [exercises, setExercises] = useState([]); + const [plans, setPlans] = useState([]); + const [selectedExercise, setSelectedExercise] = useState(null); + const [lastSet, setLastSet] = useState(undefined); + const [searchQuery, setSearchQuery] = useState(''); + const [showSuggestions, setShowSuggestions] = useState(false); + + // Timer State + const [elapsedTime, setElapsedTime] = useState('00:00:00'); + + // Form State + const [weight, setWeight] = useState(''); + const [reps, setReps] = useState(''); + const [duration, setDuration] = useState(''); + const [distance, setDistance] = useState(''); + const [height, setHeight] = useState(''); + const [bwPercentage, setBwPercentage] = useState('100'); + + // User Weight State + const [userBodyWeight, setUserBodyWeight] = useState(userWeight ? userWeight.toString() : '70'); + + // Create Exercise State + const [isCreating, setIsCreating] = useState(false); + + // Plan Execution State + const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [showPlanPrep, setShowPlanPrep] = useState(null); + const [showPlanList, setShowPlanList] = useState(false); + + // Confirmation State + const [showFinishConfirm, setShowFinishConfirm] = useState(false); + const [showQuitConfirm, setShowQuitConfirm] = useState(false); + const [showMenu, setShowMenu] = useState(false); + + // Edit Set State + const [editingSetId, setEditingSetId] = useState(null); + const [editWeight, setEditWeight] = useState(''); + const [editReps, setEditReps] = useState(''); + const [editDuration, setEditDuration] = useState(''); + const [editDistance, setEditDistance] = useState(''); + const [editHeight, setEditHeight] = useState(''); + + // Sporadic Set State + const [isSporadicMode, setIsSporadicMode] = useState(false); + const [sporadicSuccess, setSporadicSuccess] = useState(false); + + useEffect(() => { + const loadData = async () => { + const exList = await getExercises(userId); + exList.sort((a, b) => a.name.localeCompare(b.name)); + setExercises(exList); + const planList = await getPlans(userId); + setPlans(planList); + + if (activeSession?.userBodyWeight) { + setUserBodyWeight(activeSession.userBodyWeight.toString()); + } else if (userWeight) { + setUserBodyWeight(userWeight.toString()); + } + }; + loadData(); + }, [activeSession, userId, userWeight, activePlan]); + + // Timer Logic + useEffect(() => { + let interval: number; + if (activeSession) { + const updateTimer = () => { + const diff = Math.floor((Date.now() - activeSession.startTime) / 1000); + const h = Math.floor(diff / 3600); + const m = Math.floor((diff % 3600) / 60); + const s = diff % 60; + setElapsedTime(`${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`); + }; + + updateTimer(); + interval = window.setInterval(updateTimer, 1000); + } + return () => clearInterval(interval); + }, [activeSession]); + + // Recalculate current step when sets change + useEffect(() => { + if (activeSession && activePlan) { + const performedCounts = new Map(); + for (const set of activeSession.sets) { + performedCounts.set(set.exerciseId, (performedCounts.get(set.exerciseId) || 0) + 1); + } + + let nextStepIndex = activePlan.steps.length; // Default to finished + const plannedCounts = new Map(); + for (let i = 0; i < activePlan.steps.length; i++) { + const step = activePlan.steps[i]; + const exerciseId = step.exerciseId; + plannedCounts.set(exerciseId, (plannedCounts.get(exerciseId) || 0) + 1); + const performedCount = performedCounts.get(exerciseId) || 0; + + if (performedCount < plannedCounts.get(exerciseId)!) { + nextStepIndex = i; + break; + } + } + setCurrentStepIndex(nextStepIndex); + } + }, [activeSession, activePlan]); + + + useEffect(() => { + if (activeSession && activePlan && exercises.length > 0 && activePlan.steps.length > 0) { + if (currentStepIndex < activePlan.steps.length) { + const step = activePlan.steps[currentStepIndex]; + if (step) { + const exDef = exercises.find(e => e.id === step.exerciseId); + if (exDef) { + setSelectedExercise(exDef); + } + } + } + } + }, [currentStepIndex, activePlan, exercises]); + + useEffect(() => { + const updateSelection = async () => { + if (selectedExercise) { + setBwPercentage(selectedExercise.bodyWeightPercentage ? selectedExercise.bodyWeightPercentage.toString() : '100'); + const set = await getLastSetForExercise(userId, selectedExercise.id); + setLastSet(set); + + if (set) { + setWeight(set.weight?.toString() || ''); + setReps(set.reps?.toString() || ''); + setDuration(set.durationSeconds?.toString() || ''); + setDistance(set.distanceMeters?.toString() || ''); + setHeight(set.height?.toString() || ''); + } else { + setWeight(''); setReps(''); setDuration(''); setDistance(''); setHeight(''); + } + + // Clear fields not relevant to the selected exercise type + if (selectedExercise.type !== ExerciseType.STRENGTH && selectedExercise.type !== ExerciseType.BODYWEIGHT) { + setWeight(''); + } + if (selectedExercise.type !== ExerciseType.STRENGTH && selectedExercise.type !== ExerciseType.BODYWEIGHT && selectedExercise.type !== ExerciseType.PLYOMETRIC) { + setReps(''); + } + if (selectedExercise.type !== ExerciseType.CARDIO && selectedExercise.type !== ExerciseType.STATIC) { + setDuration(''); + } + if (selectedExercise.type !== ExerciseType.CARDIO && selectedExercise.type !== ExerciseType.LONG_JUMP) { + setDistance(''); + } + if (selectedExercise.type !== ExerciseType.HIGH_JUMP) { + setHeight(''); + } + } else { + setSearchQuery(''); // Clear search query if no exercise is selected + } + }; + updateSelection(); + }, [selectedExercise, userId]); + + const filteredExercises = searchQuery === '' + ? exercises + : exercises.filter(ex => + ex.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const handleStart = (plan?: WorkoutPlan) => { + if (plan && plan.description) { + setShowPlanPrep(plan); + } else { + onSessionStart(plan, parseFloat(userBodyWeight)); + } + }; + + const confirmPlanStart = () => { + if (showPlanPrep) { + onSessionStart(showPlanPrep, parseFloat(userBodyWeight)); + setShowPlanPrep(null); + } + } + + const handleAddSet = async () => { + if (!activeSession || !selectedExercise) return; + + const setData: Partial = { + exerciseId: selectedExercise.id, + }; + + switch (selectedExercise.type) { + case ExerciseType.STRENGTH: + if (weight) setData.weight = parseFloat(weight); + if (reps) setData.reps = parseInt(reps); + break; + case ExerciseType.BODYWEIGHT: + if (weight) setData.weight = parseFloat(weight); + if (reps) setData.reps = parseInt(reps); + setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100; + break; + case ExerciseType.CARDIO: + if (duration) setData.durationSeconds = parseInt(duration); + if (distance) setData.distanceMeters = parseFloat(distance); + break; + case ExerciseType.STATIC: + if (duration) setData.durationSeconds = parseInt(duration); + setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100; + break; + case ExerciseType.HIGH_JUMP: + if (height) setData.height = parseFloat(height); + break; + case ExerciseType.LONG_JUMP: + if (distance) setData.distanceMeters = parseFloat(distance); + break; + case ExerciseType.PLYOMETRIC: + if (reps) setData.reps = parseInt(reps); + break; + } + + try { + 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) { + setCurrentStepIndex(nextStepIndex); + } + } else if (activePlan && !activeExerciseId) { + // Plan is finished + setCurrentStepIndex(activePlan.steps.length); + } + } + } catch (error) { + console.error("Failed to log set:", error); + } + }; + + const handleLogSporadicSet = async () => { + if (!selectedExercise) return; + + const set: any = { + exerciseId: selectedExercise.id, + timestamp: Date.now(), + }; + + switch (selectedExercise.type) { + case ExerciseType.STRENGTH: + if (weight) set.weight = parseFloat(weight); + if (reps) set.reps = parseInt(reps); + break; + case ExerciseType.BODYWEIGHT: + if (weight) set.weight = parseFloat(weight); + if (reps) set.reps = parseInt(reps); + set.bodyWeightPercentage = parseFloat(bwPercentage) || 100; + break; + case ExerciseType.CARDIO: + if (duration) set.durationSeconds = parseInt(duration); + if (distance) set.distanceMeters = parseFloat(distance); + break; + case ExerciseType.STATIC: + if (duration) set.durationSeconds = parseInt(duration); + set.bodyWeightPercentage = parseFloat(bwPercentage) || 100; + break; + case ExerciseType.HIGH_JUMP: + if (height) set.height = parseFloat(height); + break; + case ExerciseType.LONG_JUMP: + if (distance) set.distanceMeters = parseFloat(distance); + break; + case ExerciseType.PLYOMETRIC: + if (reps) set.reps = parseInt(reps); + break; + } + + try { + const result = await logSporadicSet(userId, set); + if (result) { + setSporadicSuccess(true); + setTimeout(() => setSporadicSuccess(false), 2000); + // Reset form + setWeight(''); + setReps(''); + setDuration(''); + setDistance(''); + setHeight(''); + if (onSporadicSetAdded) onSporadicSetAdded(); + } + } catch (error) { + console.error("Failed to log sporadic set:", error); + } + }; + + const handleCreateExercise = async (newEx: ExerciseDef) => { + await saveExercise(userId, newEx); + setExercises(prev => [...prev, newEx].sort((a, b) => a.name.localeCompare(b.name))); + setSelectedExercise(newEx); + setIsCreating(false); + }; + + const handleEditSet = (set: WorkoutSet) => { + setEditingSetId(set.id); + setEditWeight(set.weight?.toString() || ''); + setEditReps(set.reps?.toString() || ''); + setEditDuration(set.durationSeconds?.toString() || ''); + setEditDistance(set.distanceMeters?.toString() || ''); + setEditHeight(set.height?.toString() || ''); + }; + + const handleSaveEdit = (set: WorkoutSet) => { + const updatedSet: WorkoutSet = { + ...set, + ...(editWeight && { weight: parseFloat(editWeight) }), + ...(editReps && { reps: parseInt(editReps) }), + ...(editDuration && { durationSeconds: parseInt(editDuration) }), + ...(editDistance && { distanceMeters: parseFloat(editDistance) }), + ...(editHeight && { height: parseFloat(editHeight) }) + }; + onUpdateSet(updatedSet); + setEditingSetId(null); + }; + + const handleCancelEdit = () => { + setEditingSetId(null); + }; + + const jumpToStep = (index: number) => { + if (!activePlan) return; + setCurrentStepIndex(index); + setShowPlanList(false); + }; + + return { + exercises, + plans, + selectedExercise, + setSelectedExercise, + lastSet, + searchQuery, + setSearchQuery, + showSuggestions, + setShowSuggestions, + elapsedTime, + weight, + setWeight, + reps, + setReps, + duration, + setDuration, + distance, + setDistance, + height, + setHeight, + bwPercentage, + setBwPercentage, + userBodyWeight, + setUserBodyWeight, + isCreating, + setIsCreating, + currentStepIndex, + showPlanPrep, + setShowPlanPrep, + showPlanList, + setShowPlanList, + showFinishConfirm, + setShowFinishConfirm, + showQuitConfirm, + setShowQuitConfirm, + showMenu, + setShowMenu, + editingSetId, + editWeight, + setEditWeight, + editReps, + setEditReps, + editDuration, + setEditDuration, + editDistance, + setEditDistance, + editHeight, + setEditHeight, + isSporadicMode, + setIsSporadicMode, + sporadicSuccess, + filteredExercises, + handleStart, + confirmPlanStart, + handleAddSet, + handleLogSporadicSet, + handleCreateExercise, + handleEditSet, + handleSaveEdit, + handleCancelEdit, + jumpToStep, + }; +}; diff --git a/server/prisma/dev.db b/server/prisma/dev.db index b9cda1a855896d6561f21c8a2981d2a6d97b5d39..bf88365d8637481d7f92b582cc0ce438604a7200 100644 GIT binary patch delta 3254 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 diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 305152d..58fe947 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -25,6 +25,7 @@ model User { exercises Exercise[] plans WorkoutPlan[] weightRecords BodyWeightRecord[] + sporadicSets SporadicSet[] } model BodyWeightRecord { @@ -59,6 +60,7 @@ model Exercise { isArchived Boolean @default(false) sets WorkoutSet[] + sporadicSets SporadicSet[] } model WorkoutSession { @@ -100,3 +102,23 @@ model WorkoutPlan { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model SporadicSet { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + exerciseId String + exercise Exercise @relation(fields: [exerciseId], references: [id]) + + weight Float? + reps Int? + distanceMeters Float? + durationSeconds Int? + height Float? + bodyWeightPercentage Float? + + timestamp DateTime @default(now()) + note String? + + @@index([userId, timestamp]) +} diff --git a/server/src/index.ts b/server/src/index.ts index de5c5c4..856aa5e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -7,6 +7,7 @@ import sessionRoutes from './routes/sessions'; import planRoutes from './routes/plans'; import aiRoutes from './routes/ai'; import weightRoutes from './routes/weight'; +import sporadicSetsRoutes from './routes/sporadic-sets'; import bcrypt from 'bcryptjs'; import { PrismaClient } from '@prisma/client'; @@ -61,6 +62,7 @@ app.use('/api/sessions', sessionRoutes); app.use('/api/plans', planRoutes); app.use('/api/ai', aiRoutes); app.use('/api/weight', weightRoutes); +app.use('/api/sporadic-sets', sporadicSetsRoutes); app.get('/', (req, res) => { res.send('GymFlow AI API is running'); diff --git a/server/src/routes/sporadic-sets.ts b/server/src/routes/sporadic-sets.ts new file mode 100644 index 0000000..9613bbf --- /dev/null +++ b/server/src/routes/sporadic-sets.ts @@ -0,0 +1,182 @@ +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 sporadic sets for the authenticated user +router.get('/', async (req: any, res) => { + try { + const userId = req.user.userId; + const sporadicSets = await prisma.sporadicSet.findMany({ + where: { userId }, + include: { exercise: true }, + orderBy: { timestamp: 'desc' } + }); + + // Map to include exercise name and type + const mappedSets = sporadicSets.map(set => ({ + id: set.id, + exerciseId: set.exerciseId, + exerciseName: set.exercise.name, + type: set.exercise.type, + weight: set.weight, + reps: set.reps, + distanceMeters: set.distanceMeters, + durationSeconds: set.durationSeconds, + height: set.height, + bodyWeightPercentage: set.bodyWeightPercentage, + timestamp: set.timestamp.getTime(), + note: set.note + })); + + res.json({ success: true, sporadicSets: mappedSets }); + } catch (error) { + console.error(error); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Create a new sporadic set +router.post('/', async (req: any, res) => { + try { + const userId = req.user.userId; + const { exerciseId, weight, reps, distanceMeters, durationSeconds, height, bodyWeightPercentage, note } = req.body; + + if (!exerciseId) { + return res.status(400).json({ error: 'Exercise ID is required' }); + } + + const sporadicSet = await prisma.sporadicSet.create({ + data: { + userId, + exerciseId, + 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, + note: note || null + }, + include: { exercise: true } + }); + + const mappedSet = { + id: sporadicSet.id, + exerciseId: sporadicSet.exerciseId, + exerciseName: sporadicSet.exercise.name, + type: sporadicSet.exercise.type, + weight: sporadicSet.weight, + reps: sporadicSet.reps, + distanceMeters: sporadicSet.distanceMeters, + durationSeconds: sporadicSet.durationSeconds, + height: sporadicSet.height, + bodyWeightPercentage: sporadicSet.bodyWeightPercentage, + timestamp: sporadicSet.timestamp.getTime(), + note: sporadicSet.note + }; + + res.json({ success: true, sporadicSet: mappedSet }); + } catch (error) { + console.error(error); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Update a sporadic set +router.put('/:id', async (req: any, res) => { + try { + const userId = req.user.userId; + const { id } = req.params; + const { weight, reps, distanceMeters, durationSeconds, height, bodyWeightPercentage, note } = req.body; + + // Verify ownership + const existing = await prisma.sporadicSet.findFirst({ + where: { id, userId } + }); + + if (!existing) { + return res.status(404).json({ error: 'Sporadic set not found' }); + } + + const updated = await prisma.sporadicSet.update({ + where: { id }, + data: { + weight: weight !== undefined ? (weight ? parseFloat(weight) : null) : undefined, + reps: reps !== undefined ? (reps ? parseInt(reps) : null) : undefined, + distanceMeters: distanceMeters !== undefined ? (distanceMeters ? parseFloat(distanceMeters) : null) : undefined, + durationSeconds: durationSeconds !== undefined ? (durationSeconds ? parseInt(durationSeconds) : null) : undefined, + height: height !== undefined ? (height ? parseFloat(height) : null) : undefined, + bodyWeightPercentage: bodyWeightPercentage !== undefined ? (bodyWeightPercentage ? parseFloat(bodyWeightPercentage) : null) : undefined, + note: note !== undefined ? note : undefined + }, + include: { exercise: true } + }); + + const mappedSet = { + id: updated.id, + exerciseId: updated.exerciseId, + exerciseName: updated.exercise.name, + type: updated.exercise.type, + weight: updated.weight, + reps: updated.reps, + distanceMeters: updated.distanceMeters, + durationSeconds: updated.durationSeconds, + height: updated.height, + bodyWeightPercentage: updated.bodyWeightPercentage, + timestamp: updated.timestamp.getTime(), + note: updated.note + }; + + res.json({ success: true, sporadicSet: mappedSet }); + } catch (error) { + console.error(error); + res.status(500).json({ error: 'Server error' }); + } +}); + +// Delete a sporadic set +router.delete('/:id', async (req: any, res) => { + try { + const userId = req.user.userId; + const { id } = req.params; + + // Verify ownership + const existing = await prisma.sporadicSet.findFirst({ + where: { id, userId } + }); + + if (!existing) { + return res.status(404).json({ error: 'Sporadic set not found' }); + } + + await prisma.sporadicSet.delete({ + where: { id } + }); + + res.json({ success: true }); + } catch (error) { + console.error(error); + res.status(500).json({ error: 'Server error' }); + } +}); + +export default router; diff --git a/services/i18n.ts b/services/i18n.ts index 0225ecf..b7acc13 100644 --- a/services/i18n.ts +++ b/services/i18n.ts @@ -158,6 +158,14 @@ const translations = { type_to_filter: 'Type to filter...', exercise_name_exists: 'An exercise with this name already exists', profile_saved: 'Profile saved successfully', + + // Sporadic Sets + quick_log: 'Quick Log', + sporadic_sets_title: 'Quick Logged Sets', + log_sporadic_success: 'Set logged successfully', + sporadic_set_note: 'Note (optional)', + done: 'Done', + saved: 'Saved', }, ru: { // Tabs @@ -309,7 +317,15 @@ const translations = { type_to_filter: 'Введите для фильтрации...', exercise_name_exists: 'Упражнение с таким названием уже существует', profile_saved: 'Профиль успешно сохранен', - } + + // Sporadic Sets + quick_log: 'Быстрая запись', + sporadic_sets_title: 'Быстрые записи', + log_sporadic_success: 'Сет записан', + sporadic_set_note: 'Заметка (опц.)', + done: 'Готово', + saved: 'Сохранено', + }, }; export const t = (key: keyof typeof translations['en'], lang: Language) => { diff --git a/services/sporadicSets.ts b/services/sporadicSets.ts new file mode 100644 index 0000000..63b3658 --- /dev/null +++ b/services/sporadicSets.ts @@ -0,0 +1,68 @@ +import { api } from './api'; +import { SporadicSet } from '../types'; + +export async function getSporadicSets(): Promise { + try { + const response = await api.get('/sporadic-sets'); + if (response.success) { + return response.sporadicSets || []; + } + return []; + } catch (error) { + console.error('Failed to fetch sporadic sets:', error); + return []; + } +} + +export async function logSporadicSet(setData: { + exerciseId: string; + weight?: number; + reps?: number; + durationSeconds?: number; + distanceMeters?: number; + height?: number; + bodyWeightPercentage?: number; + note?: string; +}): Promise { + try { + const response = await api.post('/sporadic-sets', setData); + if (response.success) { + return response.sporadicSet; + } + return null; + } catch (error) { + console.error('Failed to log sporadic set:', error); + return null; + } +} + +export async function updateSporadicSet(id: string, setData: { + weight?: number; + reps?: number; + durationSeconds?: number; + distanceMeters?: number; + height?: number; + bodyWeightPercentage?: number; + note?: string; +}): Promise { + try { + const response = await api.put(`/sporadic-sets/${id}`, setData); + if (response.success) { + return response.sporadicSet; + } + return null; + } catch (error) { + console.error('Failed to update sporadic set:', error); + return null; + } +} + +export async function deleteSporadicSet(id: string): Promise { + try { + const response = await api.delete(`/sporadic-sets/${id}`); + return response.success || false; + } catch (error) { + console.error('Failed to delete sporadic set:', error); + return false; + } +} diff --git a/types.ts b/types.ts index f52fb4a..ab281fc 100644 --- a/types.ts +++ b/types.ts @@ -79,6 +79,21 @@ export interface BodyWeightRecord { dateStr: string; // YYYY-MM-DD } +export interface SporadicSet { + id: string; + exerciseId: string; + exerciseName: string; + type: ExerciseType; + reps?: number; + weight?: number; + durationSeconds?: number; + distanceMeters?: number; + height?: number; + bodyWeightPercentage?: number; + timestamp: number; + note?: string; +} + export interface User { id: string; email: string;