Critical Stability & Performance fixes. Excessive Log Set button gone on QIuck Log screen
This commit is contained in:
383
src/components/Tracker/ActiveSessionView.tsx
Normal file
383
src/components/Tracker/ActiveSessionView.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
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 SetLogger from './SetLogger';
|
||||
|
||||
interface ActiveSessionViewProps {
|
||||
tracker: ReturnType<typeof useTracker>;
|
||||
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;
|
||||
onRemoveSet: (setId: string) => void;
|
||||
}
|
||||
|
||||
const ActiveSessionView: React.FC<ActiveSessionViewProps> = ({ 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 isPlanFinished = activePlan && currentStepIndex >= activePlan.steps.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full max-h-full overflow-hidden relative bg-surface">
|
||||
<div className="px-4 py-3 bg-surface-container shadow-elevation-1 z-20 flex justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-title-medium text-on-surface flex items-center gap-2 font-medium">
|
||||
<span className="w-2 h-2 rounded-full bg-error animate-pulse" />
|
||||
{activePlan ? activePlan.name : t('free_workout', lang)}
|
||||
</h2>
|
||||
<span className="text-xs text-on-surface-variant font-mono mt-0.5 flex items-center gap-2">
|
||||
<span className="bg-surface-container-high px-1.5 py-0.5 rounded text-on-surface font-bold">{elapsedTime}</span>
|
||||
{activeSession.userBodyWeight ? ` • ${activeSession.userBodyWeight}kg` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<button
|
||||
onClick={() => setShowFinishConfirm(true)}
|
||||
className="px-5 py-2 rounded-full bg-error-container text-on-error-container text-sm font-medium hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t('finish', lang)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
className="p-2 rounded-full bg-surface-container-high text-on-surface hover:bg-surface-container-highest transition-colors"
|
||||
>
|
||||
<MoreVertical size={20} />
|
||||
</button>
|
||||
{showMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-30"
|
||||
onClick={() => setShowMenu(false)}
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-2 bg-surface-container rounded-xl shadow-elevation-3 overflow-hidden z-40 min-w-[200px]">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowMenu(false);
|
||||
setShowQuitConfirm(true);
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left text-error hover:bg-error-container/20 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<X size={18} />
|
||||
{t('quit_no_save', lang)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activePlan && (
|
||||
<div className="bg-surface-container-low border-b border-outline-variant">
|
||||
<button
|
||||
onClick={() => setShowPlanList(!showPlanList)}
|
||||
className="w-full px-4 py-3 flex justify-between items-center"
|
||||
>
|
||||
<div className="flex flex-col items-start">
|
||||
{isPlanFinished ? (
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<CheckSquare size={18} />
|
||||
<span className="font-bold">{t('plan_completed', lang)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[10px] text-primary font-medium tracking-wider">{t('step', lang)} {currentStepIndex + 1} {t('of', lang)} {activePlan.steps.length}</span>
|
||||
<div className="font-medium text-on-surface flex items-center gap-2">
|
||||
{activePlan.steps[currentStepIndex].exerciseName}
|
||||
{activePlan.steps[currentStepIndex].isWeighted && <Scale size={12} className="text-primary" />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{showPlanList ? <ChevronUp size={20} className="text-on-surface-variant" /> : <ChevronDown size={20} className="text-on-surface-variant" />}
|
||||
</button>
|
||||
|
||||
{showPlanList && (
|
||||
<div className="max-h-48 overflow-y-auto bg-surface-container-high p-2 space-y-1 animate-in slide-in-from-top-2">
|
||||
{activePlan.steps.map((step, idx) => (
|
||||
<button
|
||||
key={step.id}
|
||||
onClick={() => jumpToStep(idx)}
|
||||
className={`w-full text-left px-4 py-3 rounded-full text-sm flex items-center justify-between transition-colors ${idx === currentStepIndex
|
||||
? 'bg-primary-container text-on-primary-container font-medium'
|
||||
: idx < currentStepIndex
|
||||
? 'text-on-surface-variant opacity-50'
|
||||
: 'text-on-surface hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<span>{idx + 1}. {step.exerciseName}</span>
|
||||
{step.isWeighted && <Scale size={14} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 pb-32 space-y-6">
|
||||
|
||||
<SetLogger
|
||||
tracker={tracker}
|
||||
lang={lang}
|
||||
onLogSet={handleAddSet}
|
||||
/>
|
||||
|
||||
{activeSession.sets.length > 0 && (
|
||||
<div className="pt-4">
|
||||
<h3 className="text-sm text-primary font-medium px-2 mb-3 tracking-wide">{t('history_section', lang)}</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{[...activeSession.sets].reverse().map((set: WorkoutSet, idx: number) => {
|
||||
const setNumber = activeSession.sets.length - idx;
|
||||
const isEditing = editingSetId === set.id;
|
||||
return (
|
||||
<div key={set.id} className="flex justify-between items-center p-4 bg-surface-container rounded-xl shadow-elevation-1 animate-in fade-in slide-in-from-bottom-2">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="w-8 h-8 rounded-full bg-secondary-container text-on-secondary-container flex items-center justify-center text-xs font-bold">
|
||||
{setNumber}
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<div className="flex-1">
|
||||
<div className="text-base font-medium text-on-surface mb-2">{set.exerciseName}{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase() as any, lang)}</span>}</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{set.weight !== undefined && (
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={editWeight}
|
||||
onChange={(e) => setEditWeight(e.target.value)}
|
||||
className="px-2 py-1 bg-surface-container-high rounded text-sm text-on-surface border border-outline-variant focus:border-primary focus:outline-none"
|
||||
placeholder="Weight (kg)"
|
||||
/>
|
||||
)}
|
||||
{set.reps !== undefined && (
|
||||
<input
|
||||
type="number"
|
||||
value={editReps}
|
||||
onChange={(e) => setEditReps(e.target.value)}
|
||||
className="px-2 py-1 bg-surface-container-high rounded text-sm text-on-surface border border-outline-variant focus:border-primary focus:outline-none"
|
||||
placeholder="Reps"
|
||||
/>
|
||||
)}
|
||||
{set.durationSeconds !== undefined && (
|
||||
<input
|
||||
type="number"
|
||||
value={editDuration}
|
||||
onChange={(e) => setEditDuration(e.target.value)}
|
||||
className="px-2 py-1 bg-surface-container-high rounded text-sm text-on-surface border border-outline-variant focus:border-primary focus:outline-none"
|
||||
placeholder="Duration (s)"
|
||||
/>
|
||||
)}
|
||||
{set.distanceMeters !== undefined && (
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={editDistance}
|
||||
onChange={(e) => setEditDistance(e.target.value)}
|
||||
className="px-2 py-1 bg-surface-container-high rounded text-sm text-on-surface border border-outline-variant focus:border-primary focus:outline-none"
|
||||
placeholder="Distance (m)"
|
||||
/>
|
||||
)}
|
||||
{set.height !== undefined && (
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={editHeight}
|
||||
onChange={(e) => setEditHeight(e.target.value)}
|
||||
className="px-2 py-1 bg-surface-container-high rounded text-sm text-on-surface border border-outline-variant focus:border-primary focus:outline-none"
|
||||
placeholder="Height (cm)"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-base font-medium text-on-surface">{set.exerciseName}{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase() as any, lang)}</span>}</div>
|
||||
<div className="text-sm text-on-surface-variant">
|
||||
{set.type === ExerciseType.STRENGTH &&
|
||||
`${set.weight ? `${set.weight}kg` : ''} ${set.reps ? `x ${set.reps}` : ''}`.trim()
|
||||
}
|
||||
{set.type === ExerciseType.BODYWEIGHT &&
|
||||
`${set.weight ? `${set.weight}kg` : ''} ${set.reps ? `x ${set.reps}` : ''}`.trim()
|
||||
}
|
||||
{set.type === ExerciseType.CARDIO &&
|
||||
`${set.durationSeconds ? `${set.durationSeconds}s` : ''} ${set.distanceMeters ? `/ ${set.distanceMeters}m` : ''}`.trim()
|
||||
}
|
||||
{set.type === ExerciseType.STATIC &&
|
||||
`${set.durationSeconds ? `${set.durationSeconds}s` : ''}`.trim()
|
||||
}
|
||||
{set.type === ExerciseType.HIGH_JUMP &&
|
||||
`${set.height ? `${set.height}cm` : ''}`.trim()
|
||||
}
|
||||
{set.type === ExerciseType.LONG_JUMP &&
|
||||
`${set.distanceMeters ? `${set.distanceMeters}m` : ''}`.trim()
|
||||
}
|
||||
{set.type === ExerciseType.PLYOMETRIC &&
|
||||
`${set.reps ? `x ${set.reps}` : ''}`.trim()
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className="p-2 text-on-surface-variant hover:text-on-surface hover:bg-surface-container-high rounded-full transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSaveEdit(set)}
|
||||
className="p-2 text-primary hover:bg-primary-container/20 rounded-full transition-colors"
|
||||
>
|
||||
<CheckCircle size={20} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleEditSet(set)}
|
||||
className="p-2 text-on-surface-variant hover:text-primary hover:bg-primary-container/20 rounded-full transition-colors"
|
||||
>
|
||||
<Edit size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRemoveSet(set.id)}
|
||||
className="p-2 text-on-surface-variant hover:text-error hover:bg-error-container/10 rounded-full transition-colors"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCreating && (
|
||||
<ExerciseModal
|
||||
isOpen={isCreating}
|
||||
onClose={() => setIsCreating(false)}
|
||||
onSave={handleCreateExercise}
|
||||
lang={lang}
|
||||
existingExercises={exercises}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Finish Confirmation Dialog */}
|
||||
{showFinishConfirm && (
|
||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-6 backdrop-blur-sm">
|
||||
<div className="w-full max-w-sm bg-surface-container rounded-[28px] p-6 shadow-elevation-3">
|
||||
<h3 className="text-2xl font-normal text-on-surface mb-2">{t('finish_confirm_title', lang)}</h3>
|
||||
<p className="text-on-surface-variant text-sm mb-8">{t('finish_confirm_msg', lang)}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowFinishConfirm(false)}
|
||||
className="px-6 py-2.5 rounded-full text-primary font-medium hover:bg-white/5"
|
||||
>
|
||||
{t('cancel', lang)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowFinishConfirm(false);
|
||||
onSessionEnd();
|
||||
}}
|
||||
className="px-6 py-2.5 rounded-full bg-green-600 text-white font-medium hover:bg-green-700"
|
||||
>
|
||||
{t('confirm', lang)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quit Without Saving Confirmation Dialog */}
|
||||
{showQuitConfirm && (
|
||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-6 backdrop-blur-sm">
|
||||
<div className="w-full max-w-sm bg-surface-container rounded-[28px] p-6 shadow-elevation-3">
|
||||
<h3 className="text-2xl font-normal text-error mb-2">{t('quit_confirm_title', lang)}</h3>
|
||||
<p className="text-on-surface-variant text-sm mb-8">{t('quit_confirm_msg', lang)}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowQuitConfirm(false)}
|
||||
className="px-6 py-2.5 rounded-full text-primary font-medium hover:bg-white/5"
|
||||
>
|
||||
{t('cancel', lang)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowQuitConfirm(false);
|
||||
onSessionQuit();
|
||||
}}
|
||||
className="px-6 py-2.5 rounded-full bg-green-600 text-white font-medium hover:bg-green-700"
|
||||
>
|
||||
{t('confirm', lang)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActiveSessionView;
|
||||
115
src/components/Tracker/IdleView.tsx
Normal file
115
src/components/Tracker/IdleView.tsx
Normal file
@@ -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<typeof useTracker>;
|
||||
lang: Language;
|
||||
}
|
||||
|
||||
const IdleView: React.FC<IdleViewProps> = ({ tracker, lang }) => {
|
||||
const {
|
||||
userBodyWeight,
|
||||
setUserBodyWeight,
|
||||
handleStart,
|
||||
setIsSporadicMode,
|
||||
plans,
|
||||
showPlanPrep,
|
||||
setShowPlanPrep,
|
||||
confirmPlanStart
|
||||
} = tracker;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-4 md:p-8 overflow-y-auto relative">
|
||||
<div className="flex-1 flex flex-col items-center justify-center space-y-12">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-24 h-24 rounded-full bg-surface-container-high flex items-center justify-center text-primary shadow-elevation-1">
|
||||
<Dumbbell size={40} />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-normal text-on-surface">{t('ready_title', lang)}</h1>
|
||||
<p className="text-on-surface-variant text-sm">{t('ready_subtitle', lang)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-sm bg-surface-container rounded-2xl p-6 flex flex-col items-center gap-4 shadow-elevation-1">
|
||||
<label className="text-xs text-on-surface-variant font-bold tracking-wide flex items-center gap-2">
|
||||
<User size={14} />
|
||||
{t('my_weight', lang)}
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
className="text-5xl font-normal text-on-surface tabular-nums bg-transparent text-center w-full focus:outline-none"
|
||||
value={userBodyWeight}
|
||||
onChange={(e) => setUserBodyWeight(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-on-surface-variant">{t('change_in_profile', lang)}</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-xs space-y-3">
|
||||
<button
|
||||
onClick={() => handleStart()}
|
||||
className="w-full h-16 rounded-full bg-primary text-on-primary font-medium text-lg shadow-elevation-2 hover:shadow-elevation-3 active:shadow-elevation-1 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<PlayCircle size={24} />
|
||||
{t('free_workout', lang)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsSporadicMode(true)}
|
||||
className="w-full h-12 rounded-full bg-surface-container-high text-on-surface font-medium text-base hover:bg-surface-container-highest transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus size={20} />
|
||||
{t('quick_log', lang)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{plans.length > 0 && (
|
||||
<div className="w-full max-w-md mt-8">
|
||||
<h3 className="text-sm text-on-surface-variant font-medium px-4 mb-3">{t('or_choose_plan', lang)}</h3>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{plans.map(plan => (
|
||||
<button
|
||||
key={plan.id}
|
||||
onClick={() => handleStart(plan)}
|
||||
className="flex items-center justify-between p-4 bg-surface-container rounded-xl hover:bg-surface-container-high transition-colors border border-outline-variant/20"
|
||||
>
|
||||
<div className="text-left">
|
||||
<div className="text-base font-medium text-on-surface">{plan.name}</div>
|
||||
<div className="text-xs text-on-surface-variant">{plan.steps.length} {t('exercises_count', lang)}</div>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-secondary-container text-on-secondary-container flex items-center justify-center">
|
||||
<ArrowRight size={20} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPlanPrep && (
|
||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-6 backdrop-blur-sm">
|
||||
<div className="w-full max-w-sm bg-surface-container rounded-[28px] p-6 shadow-elevation-3">
|
||||
<h3 className="text-2xl font-normal text-on-surface mb-4">{showPlanPrep.name}</h3>
|
||||
<div className="bg-surface-container-high p-4 rounded-xl text-on-surface-variant text-sm mb-8">
|
||||
<div className="text-xs font-bold text-primary mb-2">{t('prep_title', lang)}</div>
|
||||
{showPlanPrep.description || t('prep_no_instructions', lang)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => setShowPlanPrep(null)} className="px-6 py-2.5 rounded-full text-primary font-medium hover:bg-white/5">{t('cancel', lang)}</button>
|
||||
<button onClick={confirmPlanStart} className="px-6 py-2.5 rounded-full bg-primary text-on-primary font-medium">{t('start', lang)}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IdleView;
|
||||
181
src/components/Tracker/SetLogger.tsx
Normal file
181
src/components/Tracker/SetLogger.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
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 SetLoggerProps {
|
||||
tracker: ReturnType<typeof useTracker>;
|
||||
lang: Language;
|
||||
onLogSet: () => void;
|
||||
isSporadic?: boolean;
|
||||
}
|
||||
|
||||
const SetLogger: React.FC<SetLoggerProps> = ({ tracker, lang, onLogSet, isSporadic = false }) => {
|
||||
const {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
setShowSuggestions,
|
||||
showSuggestions,
|
||||
filteredExercises,
|
||||
setSelectedExercise,
|
||||
selectedExercise,
|
||||
weight,
|
||||
setWeight,
|
||||
reps,
|
||||
setReps,
|
||||
duration,
|
||||
setDuration,
|
||||
distance,
|
||||
setDistance,
|
||||
height,
|
||||
setHeight,
|
||||
setIsCreating,
|
||||
sporadicSuccess,
|
||||
activePlan,
|
||||
currentStepIndex,
|
||||
unilateralSide,
|
||||
setUnilateralSide
|
||||
} = tracker;
|
||||
|
||||
const isPlanFinished = activePlan && currentStepIndex >= activePlan.steps.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Exercise Selection */}
|
||||
<div className="relative">
|
||||
<FilledInput
|
||||
label={t('select_exercise', lang)}
|
||||
value={searchQuery}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setShowSuggestions(true);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setSearchQuery('');
|
||||
setShowSuggestions(true);
|
||||
}}
|
||||
onBlur={() => setTimeout(() => setShowSuggestions(false), 100)}
|
||||
icon={<Dumbbell size={10} />}
|
||||
autoComplete="off"
|
||||
type="text"
|
||||
rightElement={
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
className="p-2 text-primary hover:bg-primary-container/20 rounded-full"
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{showSuggestions && (
|
||||
<div className="absolute top-full left-0 w-full bg-surface-container rounded-xl shadow-elevation-3 overflow-hidden z-20 mt-1 max-h-60 overflow-y-auto animate-in fade-in slide-in-from-top-2">
|
||||
{filteredExercises.length > 0 ? (
|
||||
filteredExercises.map(ex => (
|
||||
<button
|
||||
key={ex.id}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setSelectedExercise(ex);
|
||||
setSearchQuery(ex.name);
|
||||
setShowSuggestions(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-3 text-on-surface hover:bg-surface-container-high transition-colors text-lg"
|
||||
>
|
||||
{ex.name}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-3 text-on-surface-variant text-lg">{t('no_exercises_found', lang)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedExercise && (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 duration-300 space-y-6">
|
||||
{/* Unilateral Exercise Toggle */}
|
||||
{selectedExercise.isUnilateral && (
|
||||
<div className="flex items-center gap-2 bg-surface-container rounded-full p-1">
|
||||
<button
|
||||
onClick={() => setUnilateralSide('LEFT')}
|
||||
className={`w-full text-center px-4 py-2 rounded-full text-sm font-medium transition-colors ${unilateralSide === 'LEFT' ? 'bg-primary-container text-on-primary-container' : 'text-on-surface-variant hover:bg-surface-container-high'
|
||||
}`}
|
||||
>
|
||||
{t('left', lang)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUnilateralSide('RIGHT')}
|
||||
className={`w-full text-center px-4 py-2 rounded-full text-sm font-medium transition-colors ${unilateralSide === 'RIGHT' ? 'bg-secondary-container text-on-secondary-container' : 'text-on-surface-variant hover:bg-surface-container-high'
|
||||
}`}
|
||||
>
|
||||
{t('right', lang)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input Forms */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.STATIC) && (
|
||||
<FilledInput
|
||||
label={selectedExercise.type === ExerciseType.BODYWEIGHT ? t('add_weight', lang) : t('weight_kg', lang)}
|
||||
value={weight}
|
||||
step="0.1"
|
||||
onChange={(e: any) => setWeight(e.target.value)}
|
||||
icon={<Scale size={10} />}
|
||||
autoFocus={!isSporadic && activePlan && !isPlanFinished && activePlan.steps[currentStepIndex]?.isWeighted && (selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.STRENGTH)}
|
||||
/>
|
||||
)}
|
||||
{(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.PLYOMETRIC) && (
|
||||
<FilledInput
|
||||
label={t('reps', lang)}
|
||||
value={reps}
|
||||
onChange={(e: any) => setReps(e.target.value)}
|
||||
icon={<Activity size={10} />}
|
||||
type="number"
|
||||
/>
|
||||
)}
|
||||
{(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.STATIC) && (
|
||||
<FilledInput
|
||||
label={t('time_sec', lang)}
|
||||
value={duration}
|
||||
onChange={(e: any) => setDuration(e.target.value)}
|
||||
icon={<TimerIcon size={10} />}
|
||||
/>
|
||||
)}
|
||||
{(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.LONG_JUMP) && (
|
||||
<FilledInput
|
||||
label={t('dist_m', lang)}
|
||||
value={distance}
|
||||
onChange={(e: any) => setDistance(e.target.value)}
|
||||
icon={<ArrowRight size={10} />}
|
||||
/>
|
||||
)}
|
||||
{(selectedExercise.type === ExerciseType.HIGH_JUMP) && (
|
||||
<FilledInput
|
||||
label={t('height_cm', lang)}
|
||||
value={height}
|
||||
onChange={(e: any) => setHeight(e.target.value)}
|
||||
icon={<ArrowUp size={10} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onLogSet}
|
||||
className={`w-full h-14 font-medium text-lg rounded-full shadow-elevation-2 hover:shadow-elevation-3 active:scale-[0.98] transition-all flex items-center justify-center gap-2 ${isSporadic && sporadicSuccess
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-primary-container text-on-primary-container'
|
||||
}`}
|
||||
>
|
||||
{isSporadic && sporadicSuccess ? <CheckCircle size={24} /> : (isSporadic ? <Plus size={24} /> : <CheckCircle size={24} />)}
|
||||
<span>{isSporadic && sporadicSuccess ? t('saved', lang) : t('log_set', lang)}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetLogger;
|
||||
286
src/components/Tracker/SporadicView.tsx
Normal file
286
src/components/Tracker/SporadicView.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { CheckCircle, Plus, Pencil, Trash2, X, Save } from 'lucide-react';
|
||||
import { Language, WorkoutSet } from '../../types';
|
||||
import { t } from '../../services/i18n';
|
||||
import ExerciseModal from '../ExerciseModal';
|
||||
import { useTracker } from './useTracker';
|
||||
import SetLogger from './SetLogger';
|
||||
|
||||
interface SporadicViewProps {
|
||||
tracker: ReturnType<typeof useTracker>;
|
||||
lang: Language;
|
||||
}
|
||||
|
||||
const SporadicView: React.FC<SporadicViewProps> = ({ tracker, lang }) => {
|
||||
const {
|
||||
handleLogSporadicSet,
|
||||
setIsSporadicMode,
|
||||
isCreating,
|
||||
setIsCreating,
|
||||
handleCreateExercise,
|
||||
exercises,
|
||||
resetForm,
|
||||
quickLogSession,
|
||||
selectedExercise,
|
||||
loadQuickLogSession
|
||||
} = tracker;
|
||||
|
||||
const [todaysSets, setTodaysSets] = useState<WorkoutSet[]>([]);
|
||||
const [editingSetId, setEditingSetId] = useState<string | null>(null);
|
||||
const [editingSet, setEditingSet] = useState<WorkoutSet | null>(null);
|
||||
const [deletingSetId, setDeletingSetId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (quickLogSession && quickLogSession.sets) {
|
||||
// Sets are already ordered by timestamp desc in the backend query, but let's ensure
|
||||
setTodaysSets([...quickLogSession.sets].sort((a, b) => b.timestamp - a.timestamp));
|
||||
} else {
|
||||
setTodaysSets([]);
|
||||
}
|
||||
}, [quickLogSession]);
|
||||
|
||||
const renderSetMetrics = (set: WorkoutSet) => {
|
||||
const metrics: string[] = [];
|
||||
if (set.weight) metrics.push(`${set.weight} ${t('weight_kg', lang)}`);
|
||||
if (set.reps) metrics.push(`${set.reps} ${t('reps', lang)}`);
|
||||
if (set.durationSeconds) metrics.push(`${set.durationSeconds} ${t('time_sec', lang)}`);
|
||||
if (set.distanceMeters) metrics.push(`${set.distanceMeters} ${t('dist_m', lang)}`);
|
||||
if (set.height) metrics.push(`${set.height} ${t('height_cm', lang)}`);
|
||||
return metrics.join(' / ');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full max-h-full overflow-hidden relative bg-surface">
|
||||
<div className="px-4 py-3 bg-surface-container shadow-elevation-1 z-20 flex justify-between items-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setIsSporadicMode(false);
|
||||
}}
|
||||
className="text-error font-medium text-sm hover:opacity-80 transition-opacity"
|
||||
>
|
||||
{t('quit', lang)}
|
||||
</button>
|
||||
<div className="flex flex-col items-center">
|
||||
<h2 className="text-title-medium text-on-surface flex items-center gap-2 font-medium">
|
||||
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
|
||||
{t('quick_log', lang)}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogSporadicSet}
|
||||
className={`px-5 py-2 rounded-full text-sm font-medium transition-all ${selectedExercise
|
||||
? 'bg-primary-container text-on-primary-container hover:opacity-90 shadow-elevation-1'
|
||||
: 'bg-surface-container-high text-on-surface-variant opacity-50 cursor-not-allowed'
|
||||
}`}
|
||||
disabled={!selectedExercise}
|
||||
>
|
||||
{t('log_set', lang)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 pb-32 space-y-6">
|
||||
<SetLogger
|
||||
tracker={tracker}
|
||||
lang={lang}
|
||||
onLogSet={handleLogSporadicSet}
|
||||
isSporadic={true}
|
||||
/>
|
||||
|
||||
{/* History Section */}
|
||||
{todaysSets.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-title-medium font-medium mb-3">{t('history_section', lang)}</h3>
|
||||
<div className="space-y-2">
|
||||
{todaysSets.map((set, idx) => (
|
||||
<div key={set.id} className="bg-surface-container rounded-lg p-3 flex items-center justify-between shadow-elevation-1 animate-in fade-in">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="w-8 h-8 rounded-full bg-secondary-container text-on-secondary-container flex items-center justify-center text-xs font-bold">
|
||||
{todaysSets.length - idx}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-on-surface">{set.exerciseName}{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase() as any, lang)}</span>}</p>
|
||||
<p className="text-sm text-on-surface-variant">{renderSetMetrics(set)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingSetId(set.id);
|
||||
setEditingSet(JSON.parse(JSON.stringify(set)));
|
||||
}}
|
||||
className="p-2 text-on-surface-variant hover:text-primary hover:bg-surface-container-high rounded-full transition-colors"
|
||||
>
|
||||
<Pencil size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingSetId(set.id)}
|
||||
className="p-2 text-on-surface-variant hover:text-error hover:bg-surface-container-high rounded-full transition-colors"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCreating && (
|
||||
<ExerciseModal
|
||||
isOpen={isCreating}
|
||||
onClose={() => setIsCreating(false)}
|
||||
onSave={handleCreateExercise}
|
||||
lang={lang}
|
||||
existingExercises={exercises}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Set Modal */}
|
||||
{editingSetId && editingSet && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="bg-surface-container w-full max-w-md rounded-[28px] p-6 shadow-elevation-3 max-h-[80vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-xl font-normal text-on-surface">{t('edit', lang)}</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingSetId(null);
|
||||
setEditingSet(null);
|
||||
}}
|
||||
className="p-2 hover:bg-surface-container-high rounded-full transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{(editingSet.type === 'STRENGTH' || editingSet.type === 'BODYWEIGHT') && (
|
||||
<>
|
||||
<div>
|
||||
<label className="text-sm text-on-surface-variant">{t('weight_kg', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={editingSet.weight || ''}
|
||||
onChange={(e) => setEditingSet({ ...editingSet, weight: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full mt-1 px-4 py-2 bg-surface-container-high text-on-surface rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-on-surface-variant">{t('reps', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editingSet.reps || ''}
|
||||
onChange={(e) => setEditingSet({ ...editingSet, reps: parseInt(e.target.value) || 0 })}
|
||||
className="w-full mt-1 px-4 py-2 bg-surface-container-high text-on-surface rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(editingSet.type === 'CARDIO' || editingSet.type === 'STATIC') && (
|
||||
<div>
|
||||
<label className="text-sm text-on-surface-variant">{t('time_sec', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={editingSet.durationSeconds || ''}
|
||||
onChange={(e) => setEditingSet({ ...editingSet, durationSeconds: parseInt(e.target.value) || 0 })}
|
||||
className="w-full mt-1 px-4 py-2 bg-surface-container-high text-on-surface rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{editingSet.type === 'CARDIO' && (
|
||||
<div>
|
||||
<label className="text-sm text-on-surface-variant">{t('dist_m', lang)}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={editingSet.distanceMeters || ''}
|
||||
onChange={(e) => setEditingSet({ ...editingSet, distanceMeters: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full mt-1 px-4 py-2 bg-surface-container-high text-on-surface rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingSetId(null);
|
||||
setEditingSet(null);
|
||||
}}
|
||||
className="px-4 py-2 rounded-full text-primary font-medium hover:bg-white/5"
|
||||
>
|
||||
{t('cancel', lang)}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/active/set/${editingSetId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify(editingSet)
|
||||
});
|
||||
if (response.ok) {
|
||||
await loadQuickLogSession();
|
||||
setEditingSetId(null);
|
||||
setEditingSet(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update set:', error);
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 rounded-full bg-primary text-on-primary font-medium flex items-center gap-2"
|
||||
>
|
||||
<Save size={18} />
|
||||
{t('save', lang)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deletingSetId && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="bg-surface-container w-full max-w-xs rounded-[28px] p-6 shadow-elevation-3">
|
||||
<h3 className="text-xl font-normal text-on-surface mb-2">{t('delete', lang)}</h3>
|
||||
<p className="text-sm text-on-surface-variant mb-8">{t('delete_confirm', lang)}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setDeletingSetId(null)}
|
||||
className="px-4 py-2 rounded-full text-primary font-medium hover:bg-white/5"
|
||||
>
|
||||
{t('cancel', lang)}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/sessions/active/set/${deletingSetId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
await loadQuickLogSession();
|
||||
setDeletingSetId(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete set:', error);
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 rounded-full bg-error-container text-on-error-container font-medium"
|
||||
>
|
||||
{t('delete', lang)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SporadicView;
|
||||
49
src/components/Tracker/index.tsx
Normal file
49
src/components/Tracker/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
|
||||
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<TrackerProps> = (props) => {
|
||||
const tracker = useTracker(props);
|
||||
const { isSporadicMode } = tracker;
|
||||
const { activeSession, lang, onSessionEnd, onSessionQuit, onRemoveSet } = props;
|
||||
|
||||
if (activeSession) {
|
||||
return (
|
||||
<ActiveSessionView
|
||||
tracker={tracker}
|
||||
activeSession={activeSession}
|
||||
lang={lang}
|
||||
onSessionEnd={onSessionEnd}
|
||||
onSessionQuit={onSessionQuit}
|
||||
onRemoveSet={onRemoveSet}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSporadicMode) {
|
||||
return <SporadicView tracker={tracker} lang={lang} />;
|
||||
}
|
||||
|
||||
return <IdleView tracker={tracker} lang={lang} />;
|
||||
};
|
||||
|
||||
export default Tracker;
|
||||
491
src/components/Tracker/useTracker.ts
Normal file
491
src/components/Tracker/useTracker.ts
Normal file
@@ -0,0 +1,491 @@
|
||||
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';
|
||||
|
||||
|
||||
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<ExerciseDef[]>([]);
|
||||
const [plans, setPlans] = useState<WorkoutPlan[]>([]);
|
||||
const [selectedExercise, setSelectedExercise] = useState<ExerciseDef | null>(null);
|
||||
const [lastSet, setLastSet] = useState<WorkoutSet | undefined>(undefined);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
|
||||
// Timer State
|
||||
const [elapsedTime, setElapsedTime] = useState<string>('00:00:00');
|
||||
|
||||
// Form State
|
||||
const [weight, setWeight] = useState<string>('');
|
||||
const [reps, setReps] = useState<string>('');
|
||||
const [duration, setDuration] = useState<string>('');
|
||||
const [distance, setDistance] = useState<string>('');
|
||||
const [height, setHeight] = useState<string>('');
|
||||
const [bwPercentage, setBwPercentage] = useState<string>('100');
|
||||
|
||||
// User Weight State
|
||||
const [userBodyWeight, setUserBodyWeight] = useState<string>(userWeight ? userWeight.toString() : '70');
|
||||
|
||||
// Create Exercise State
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
// Plan Execution State
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const [showPlanPrep, setShowPlanPrep] = useState<WorkoutPlan | null>(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<string | null>(null);
|
||||
const [editWeight, setEditWeight] = useState<string>('');
|
||||
const [editReps, setEditReps] = useState<string>('');
|
||||
const [editDuration, setEditDuration] = useState<string>('');
|
||||
const [editDistance, setEditDistance] = useState<string>('');
|
||||
const [editHeight, setEditHeight] = useState<string>('');
|
||||
|
||||
// Quick Log State
|
||||
const [quickLogSession, setQuickLogSession] = useState<WorkoutSession | null>(null);
|
||||
const [isSporadicMode, setIsSporadicMode] = useState(false);
|
||||
const [sporadicSuccess, setSporadicSuccess] = useState(false);
|
||||
|
||||
// Unilateral Exercise State
|
||||
const [unilateralSide, setUnilateralSide] = useState<'LEFT' | 'RIGHT'>('LEFT');
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
// Load Quick Log Session
|
||||
try {
|
||||
const response = await api.get('/sessions/quick-log');
|
||||
if (response.success && response.session) {
|
||||
setQuickLogSession(response.session);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load quick log session:", error);
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}, [activeSession, userId, userWeight, activePlan]);
|
||||
|
||||
// Function to reload Quick Log session
|
||||
const loadQuickLogSession = async () => {
|
||||
try {
|
||||
const response = await api.get('/sessions/quick-log');
|
||||
if (response.success && response.session) {
|
||||
setQuickLogSession(response.session);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load quick log session:", error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// 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<string, number>();
|
||||
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<string, number>();
|
||||
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');
|
||||
setSearchQuery(selectedExercise.name);
|
||||
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<WorkoutSet> = {
|
||||
exerciseId: selectedExercise.id,
|
||||
};
|
||||
|
||||
if (selectedExercise.isUnilateral) {
|
||||
setData.side = unilateralSide;
|
||||
}
|
||||
|
||||
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 setData: any = {
|
||||
exerciseId: selectedExercise.id,
|
||||
};
|
||||
|
||||
if (selectedExercise.isUnilateral) {
|
||||
setData.side = unilateralSide;
|
||||
}
|
||||
|
||||
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/quick-log/set', setData);
|
||||
if (response.success) {
|
||||
setSporadicSuccess(true);
|
||||
setTimeout(() => setSporadicSuccess(false), 2000);
|
||||
|
||||
// Refresh quick log session
|
||||
const sessionRes = await api.get('/sessions/quick-log');
|
||||
if (sessionRes.success && sessionRes.session) {
|
||||
setQuickLogSession(sessionRes.session);
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setWeight('');
|
||||
setReps('');
|
||||
setDuration('');
|
||||
setDistance('');
|
||||
setHeight('');
|
||||
if (onSporadicSetAdded) onSporadicSetAdded();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to log quick log 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);
|
||||
setSearchQuery(newEx.name);
|
||||
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);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setWeight('');
|
||||
setReps('');
|
||||
setDuration('');
|
||||
setDistance('');
|
||||
setHeight('');
|
||||
setSelectedExercise(null);
|
||||
setSearchQuery('');
|
||||
setSporadicSuccess(false);
|
||||
};
|
||||
|
||||
return {
|
||||
exercises,
|
||||
plans,
|
||||
activePlan,
|
||||
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,
|
||||
resetForm,
|
||||
unilateralSide,
|
||||
setUnilateralSide,
|
||||
quickLogSession, // Export this
|
||||
loadQuickLogSession, // Export reload function
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user