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 b9cda1a..bf88365 100644 Binary files a/server/prisma/dev.db and b/server/prisma/dev.db differ 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;