Sporadic set logging added
This commit is contained in:
34
App.tsx
34
App.tsx
@@ -1,15 +1,16 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import Navbar from './components/Navbar';
|
import Navbar from './components/Navbar';
|
||||||
import Tracker from './components/Tracker';
|
import Tracker from './components/Tracker/index';
|
||||||
import History from './components/History';
|
import History from './components/History';
|
||||||
import Stats from './components/Stats';
|
import Stats from './components/Stats';
|
||||||
import AICoach from './components/AICoach';
|
import AICoach from './components/AICoach';
|
||||||
import Plans from './components/Plans';
|
import Plans from './components/Plans';
|
||||||
import Login from './components/Login';
|
import Login from './components/Login';
|
||||||
import Profile from './components/Profile';
|
import Profile from './components/Profile';
|
||||||
import { TabView, WorkoutSession, WorkoutSet, WorkoutPlan, User, Language } from './types';
|
import { TabView, WorkoutSession, WorkoutSet, WorkoutPlan, User, Language, SporadicSet } from './types';
|
||||||
import { getSessions, saveSession, deleteSession, getPlans, getActiveSession, updateActiveSession, deleteActiveSession, updateSetInActiveSession, deleteSetFromActiveSession } from './services/storage';
|
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 { getCurrentUserProfile, getMe } from './services/auth';
|
||||||
import { getSystemLanguage } from './services/i18n';
|
import { getSystemLanguage } from './services/i18n';
|
||||||
import { logWeight } from './services/weight';
|
import { logWeight } from './services/weight';
|
||||||
@@ -24,6 +25,7 @@ function App() {
|
|||||||
const [plans, setPlans] = useState<WorkoutPlan[]>([]);
|
const [plans, setPlans] = useState<WorkoutPlan[]>([]);
|
||||||
const [activeSession, setActiveSession] = useState<WorkoutSession | null>(null);
|
const [activeSession, setActiveSession] = useState<WorkoutSession | null>(null);
|
||||||
const [activePlan, setActivePlan] = useState<WorkoutPlan | null>(null);
|
const [activePlan, setActivePlan] = useState<WorkoutPlan | null>(null);
|
||||||
|
const [sporadicSets, setSporadicSets] = useState<SporadicSet[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Set initial language
|
// Set initial language
|
||||||
@@ -66,9 +68,13 @@ function App() {
|
|||||||
// Load plans
|
// Load plans
|
||||||
const p = await getPlans(currentUser.id);
|
const p = await getPlans(currentUser.id);
|
||||||
setPlans(p);
|
setPlans(p);
|
||||||
|
// Load sporadic sets
|
||||||
|
const sporadicSets = await getSporadicSets();
|
||||||
|
setSporadicSets(sporadicSets);
|
||||||
} else {
|
} else {
|
||||||
setSessions([]);
|
setSessions([]);
|
||||||
setPlans([]);
|
setPlans([]);
|
||||||
|
setSporadicSets([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadSessions();
|
loadSessions();
|
||||||
@@ -80,6 +86,7 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('token');
|
||||||
setCurrentUser(null);
|
setCurrentUser(null);
|
||||||
setActiveSession(null);
|
setActiveSession(null);
|
||||||
setActivePlan(null);
|
setActivePlan(null);
|
||||||
@@ -190,6 +197,25 @@ function App() {
|
|||||||
setSessions(prev => prev.filter(s => s.id !== sessionId));
|
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) {
|
if (!currentUser) {
|
||||||
return <Login onLogin={handleLogin} language={language} onLanguageChange={handleLanguageChange} />;
|
return <Login onLogin={handleLogin} language={language} onLanguageChange={handleLanguageChange} />;
|
||||||
}
|
}
|
||||||
@@ -215,6 +241,7 @@ function App() {
|
|||||||
onSetAdded={handleAddSet}
|
onSetAdded={handleAddSet}
|
||||||
onRemoveSet={handleRemoveSetFromActive}
|
onRemoveSet={handleRemoveSetFromActive}
|
||||||
onUpdateSet={handleUpdateSetInActive}
|
onUpdateSet={handleUpdateSetInActive}
|
||||||
|
onSporadicSetAdded={handleSporadicSetAdded}
|
||||||
lang={language}
|
lang={language}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -224,8 +251,11 @@ function App() {
|
|||||||
{currentTab === 'HISTORY' && (
|
{currentTab === 'HISTORY' && (
|
||||||
<History
|
<History
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
|
sporadicSets={sporadicSets}
|
||||||
onUpdateSession={handleUpdateSession}
|
onUpdateSession={handleUpdateSession}
|
||||||
onDeleteSession={handleDeleteSession}
|
onDeleteSession={handleDeleteSession}
|
||||||
|
onUpdateSporadicSet={handleUpdateSporadicSet}
|
||||||
|
onDeleteSporadicSet={handleDeleteSporadicSet}
|
||||||
lang={language}
|
lang={language}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
220
TRACKER_QUICK_LOG_IMPLEMENTATION.txt
Normal file
220
TRACKER_QUICK_LOG_IMPLEMENTATION.txt
Normal file
@@ -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<TrackerProps> = ({ 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:
|
||||||
|
<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-16 rounded-full bg-secondary-container text-on-secondary-container font-medium text-lg shadow-elevation-1 hover:shadow-elevation-2 transition-all flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<CheckCircle size={24} />
|
||||||
|
{t('quick_log', lang)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Add this new section after the "no active session" return statement (after line 396, before the main return):
|
||||||
|
if (!activeSession && isSporadicMode) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full p-4 md:p-8 overflow-y-auto bg-surface">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsSporadicMode(false);
|
||||||
|
setSelectedExercise(null);
|
||||||
|
setSearchQuery('');
|
||||||
|
}}
|
||||||
|
className="p-2 rounded-full hover:bg-surface-container-high transition-colors"
|
||||||
|
>
|
||||||
|
<X size={24} className="text-on-surface" />
|
||||||
|
</button>
|
||||||
|
<h2 className="text-2xl font-normal text-on-surface">{t('quick_log', lang)}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Success Message */}
|
||||||
|
{sporadicSuccess && (
|
||||||
|
<div className="mb-4 p-4 bg-primary-container text-on-primary-container rounded-xl animate-in fade-in slide-in-from-top-2">
|
||||||
|
{t('log_sporadic_success', lang)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Exercise Selection and Form - reuse existing components */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="relative">
|
||||||
|
<FilledInput
|
||||||
|
label={t('select_exercise', lang)}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
setShowSuggestions(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setShowSuggestions(true)}
|
||||||
|
onBlur={() => setTimeout(() => setShowSuggestions(false), 100)}
|
||||||
|
icon={<Dumbbell size={10} />}
|
||||||
|
autoComplete="off"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCreating(true)}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-primary hover:bg-primary-container/20 rounded-full z-10"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
{filteredExercises.length > 0 ? (
|
||||||
|
filteredExercises.map(ex => (
|
||||||
|
<button
|
||||||
|
key={ex.id}
|
||||||
|
onClick={() => {
|
||||||
|
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="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT) && (
|
||||||
|
<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
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(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} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(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={<Footprints size={10} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedExercise.type === ExerciseType.HIGH_JUMP && (
|
||||||
|
<FilledInput
|
||||||
|
label={t('height_cm', lang)}
|
||||||
|
value={height}
|
||||||
|
onChange={(e: any) => setHeight(e.target.value)}
|
||||||
|
icon={<Ruler size={10} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleLogSporadicSet}
|
||||||
|
className="w-full h-14 rounded-full bg-primary text-on-primary font-medium text-lg shadow-elevation-2 hover:shadow-elevation-3 active:shadow-elevation-1 transition-all"
|
||||||
|
>
|
||||||
|
{t('log_set', lang)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Exercise Modal */}
|
||||||
|
{isCreating && (
|
||||||
|
<ExerciseModal
|
||||||
|
onClose={() => setIsCreating(false)}
|
||||||
|
onCreate={handleCreateExercise}
|
||||||
|
lang={lang}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,24 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Calendar, Clock, TrendingUp, Gauge, Pencil, Trash2, X, Save, ArrowRight, ArrowUp, Timer, Activity, Dumbbell, Percent } from 'lucide-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';
|
import { t } from '../services/i18n';
|
||||||
|
|
||||||
interface HistoryProps {
|
interface HistoryProps {
|
||||||
sessions: WorkoutSession[];
|
sessions: WorkoutSession[];
|
||||||
|
sporadicSets?: SporadicSet[];
|
||||||
onUpdateSession?: (session: WorkoutSession) => void;
|
onUpdateSession?: (session: WorkoutSession) => void;
|
||||||
onDeleteSession?: (sessionId: string) => void;
|
onDeleteSession?: (sessionId: string) => void;
|
||||||
|
onUpdateSporadicSet?: (set: SporadicSet) => void;
|
||||||
|
onDeleteSporadicSet?: (setId: string) => void;
|
||||||
lang: Language;
|
lang: Language;
|
||||||
}
|
}
|
||||||
|
|
||||||
const History: React.FC<HistoryProps> = ({ sessions, onUpdateSession, onDeleteSession, lang }) => {
|
const History: React.FC<HistoryProps> = ({ sessions, sporadicSets, onUpdateSession, onDeleteSession, onUpdateSporadicSet, onDeleteSporadicSet, lang }) => {
|
||||||
const [editingSession, setEditingSession] = useState<WorkoutSession | null>(null);
|
const [editingSession, setEditingSession] = useState<WorkoutSession | null>(null);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const [editingSporadicSet, setEditingSporadicSet] = useState<SporadicSet | null>(null);
|
||||||
|
const [deletingSporadicId, setDeletingSporadicId] = useState<string | null>(null);
|
||||||
|
|
||||||
const calculateSessionWork = (session: WorkoutSession) => {
|
const calculateSessionWork = (session: WorkoutSession) => {
|
||||||
const bw = session.userBodyWeight || 70;
|
const bw = session.userBodyWeight || 70;
|
||||||
@@ -88,6 +93,25 @@ const History: React.FC<HistoryProps> = ({ 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) {
|
if (sessions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-on-surface-variant p-8 text-center">
|
<div className="flex flex-col items-center justify-center h-full text-on-surface-variant p-8 text-center">
|
||||||
@@ -175,6 +199,65 @@ const History: React.FC<HistoryProps> = ({ sessions, onUpdateSession, onDeleteSe
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sporadic Sets Section */}
|
||||||
|
{sporadicSets && sporadicSets.length > 0 && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h3 className="text-xl font-medium text-on-surface mb-4 px-2">{t('sporadic_sets_title', lang)}</h3>
|
||||||
|
{Object.entries(
|
||||||
|
sporadicSets.reduce((groups: Record<string, SporadicSet[]>, 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]) => (
|
||||||
|
<div key={date} className="mb-4">
|
||||||
|
<div className="text-sm text-on-surface-variant px-2 mb-2 font-medium">{date}</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sets.map(set => (
|
||||||
|
<div
|
||||||
|
key={set.id}
|
||||||
|
className="bg-surface-container-low rounded-xl p-4 border border-outline-variant/10 flex justify-between items-center"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-on-surface">{set.exerciseName}</div>
|
||||||
|
<div className="text-sm text-on-surface-variant mt-1">
|
||||||
|
{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}`}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-on-surface-variant mt-1">
|
||||||
|
{new Date(set.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingSporadicSet(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={() => setDeletingSporadicId(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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* DELETE CONFIRMATION DIALOG (MD3) */}
|
{/* DELETE CONFIRMATION DIALOG (MD3) */}
|
||||||
{deletingId && (
|
{deletingId && (
|
||||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||||
@@ -338,6 +421,30 @@ const History: React.FC<HistoryProps> = ({ sessions, onUpdateSession, onDeleteSe
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Sporadic Set Delete Confirmation */}
|
||||||
|
{deletingSporadicId && (
|
||||||
|
<div className="fixed inset-0 z-[60] 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={() => setDeletingSporadicId(null)}
|
||||||
|
className="px-4 py-2 rounded-full text-primary font-medium hover:bg-white/5"
|
||||||
|
>
|
||||||
|
{t('cancel', lang)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirmDeleteSporadic}
|
||||||
|
className="px-4 py-2 rounded-full bg-error-container text-on-error-container font-medium"
|
||||||
|
>
|
||||||
|
{t('delete', lang)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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';
|
interface ActiveSessionViewProps {
|
||||||
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';
|
tracker: ReturnType<typeof useTracker>;
|
||||||
import { WorkoutSession, WorkoutSet, ExerciseDef, ExerciseType, WorkoutPlan, Language } from '../types';
|
activeSession: any; // Using any to avoid strict type issues with the complex session object for now, but ideally should be WorkoutSession
|
||||||
import { getExercises, getLastSetForExercise, saveExercise, getPlans } from '../services/storage';
|
lang: Language;
|
||||||
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;
|
|
||||||
onSessionEnd: () => void;
|
onSessionEnd: () => void;
|
||||||
onSessionQuit: () => void;
|
onSessionQuit: () => void;
|
||||||
onSetAdded: (set: WorkoutSet) => void;
|
|
||||||
onRemoveSet: (setId: string) => void;
|
onRemoveSet: (setId: string) => void;
|
||||||
onUpdateSet: (set: WorkoutSet) => void;
|
|
||||||
lang: Language;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
import FilledInput from './FilledInput';
|
const ActiveSessionView: React.FC<ActiveSessionViewProps> = ({ tracker, activeSession, lang, onSessionEnd, onSessionQuit, onRemoveSet }) => {
|
||||||
import ExerciseModal from './ExerciseModal';
|
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<TrackerProps> = ({ userId, userWeight, activeSession, activePlan, onSessionStart, onSessionEnd, onSessionQuit, onSetAdded, onRemoveSet, onUpdateSet, lang }) => {
|
// We need activePlan from the hook or props. The hook returns 'plans' but not 'activePlan'.
|
||||||
const [exercises, setExercises] = useState<ExerciseDef[]>([]);
|
// Actually useTracker takes activePlan as prop but doesn't return it.
|
||||||
const [plans, setPlans] = useState<WorkoutPlan[]>([]);
|
// We should probably pass activePlan as a prop to this component directly from the parent.
|
||||||
const [selectedExercise, setSelectedExercise] = useState<ExerciseDef | null>(null);
|
// Let's assume the parent passes it or we modify the hook.
|
||||||
const [lastSet, setLastSet] = useState<WorkoutSet | undefined>(undefined);
|
// For now, let's use the activePlan passed to the hook if possible, but the hook doesn't expose it.
|
||||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
// I will modify the hook to return activePlan or just accept it as prop here.
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
// 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.
|
||||||
// Timer State
|
// Wait, I can't modify useTracker easily now without rewriting it.
|
||||||
const [elapsedTime, setElapsedTime] = useState<string>('00:00:00');
|
// I'll pass activePlan as a prop to ActiveSessionView.
|
||||||
|
|
||||||
// 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>('');
|
|
||||||
|
|
||||||
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<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');
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isPlanFinished = activePlan && currentStepIndex >= activePlan.steps.length;
|
const isPlanFinished = activePlan && currentStepIndex >= activePlan.steps.length;
|
||||||
|
|
||||||
const exerciseTypeLabels: Record<ExerciseType, string> = {
|
|
||||||
[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 (
|
|
||||||
<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">
|
|
||||||
<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>
|
|
||||||
</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="absolute 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full max-h-full overflow-hidden relative bg-surface">
|
<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="px-4 py-3 bg-surface-container shadow-elevation-1 z-20 flex justify-between items-center">
|
||||||
@@ -600,7 +283,7 @@ const Tracker: React.FC<TrackerProps> = ({ userId, userWeight, activeSession, ac
|
|||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<h3 className="text-sm text-primary font-medium px-2 mb-3 tracking-wide">{t('history_section', lang)}</h3>
|
<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">
|
<div className="flex flex-col gap-2">
|
||||||
{[...activeSession.sets].reverse().map((set, idx) => {
|
{[...activeSession.sets].reverse().map((set: WorkoutSet, idx: number) => {
|
||||||
const setNumber = activeSession.sets.length - idx;
|
const setNumber = activeSession.sets.length - idx;
|
||||||
const isEditing = editingSetId === set.id;
|
const isEditing = editingSetId === set.id;
|
||||||
return (
|
return (
|
||||||
@@ -800,4 +483,4 @@ const Tracker: React.FC<TrackerProps> = ({ userId, userWeight, activeSession, ac
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Tracker;
|
export default ActiveSessionView;
|
||||||
115
components/Tracker/IdleView.tsx
Normal file
115
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="absolute 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;
|
||||||
157
components/Tracker/SporadicView.tsx
Normal file
157
components/Tracker/SporadicView.tsx
Normal file
@@ -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<typeof useTracker>;
|
||||||
|
lang: Language;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SporadicView: React.FC<SporadicViewProps> = ({ 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 (
|
||||||
|
<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-primary animate-pulse" />
|
||||||
|
{t('quick_log', lang)}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsSporadicMode(false)}
|
||||||
|
className="px-5 py-2 rounded-full bg-primary-container text-on-primary-container text-sm font-medium hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
{t('done', lang)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 pb-32 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={() => setShowSuggestions(true)}
|
||||||
|
onBlur={() => setTimeout(() => setShowSuggestions(false), 100)}
|
||||||
|
icon={<Dumbbell size={10} />}
|
||||||
|
autoComplete="off"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
{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}
|
||||||
|
onClick={() => {
|
||||||
|
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">
|
||||||
|
<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} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(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={handleLogSporadicSet}
|
||||||
|
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 ${sporadicSuccess
|
||||||
|
? 'bg-green-500 text-white'
|
||||||
|
: 'bg-primary-container text-on-primary-container'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{sporadicSuccess ? <CheckCircle size={24} /> : <Plus size={24} />}
|
||||||
|
<span>{sporadicSuccess ? t('saved', lang) : t('log_set', lang)}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SporadicView;
|
||||||
48
components/Tracker/index.tsx
Normal file
48
components/Tracker/index.tsx
Normal file
@@ -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<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;
|
||||||
430
components/Tracker/useTracker.ts
Normal file
430
components/Tracker/useTracker.ts
Normal file
@@ -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<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>('');
|
||||||
|
|
||||||
|
// 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<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');
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
Binary file not shown.
@@ -25,6 +25,7 @@ model User {
|
|||||||
exercises Exercise[]
|
exercises Exercise[]
|
||||||
plans WorkoutPlan[]
|
plans WorkoutPlan[]
|
||||||
weightRecords BodyWeightRecord[]
|
weightRecords BodyWeightRecord[]
|
||||||
|
sporadicSets SporadicSet[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model BodyWeightRecord {
|
model BodyWeightRecord {
|
||||||
@@ -59,6 +60,7 @@ model Exercise {
|
|||||||
isArchived Boolean @default(false)
|
isArchived Boolean @default(false)
|
||||||
|
|
||||||
sets WorkoutSet[]
|
sets WorkoutSet[]
|
||||||
|
sporadicSets SporadicSet[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model WorkoutSession {
|
model WorkoutSession {
|
||||||
@@ -100,3 +102,23 @@ model WorkoutPlan {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import sessionRoutes from './routes/sessions';
|
|||||||
import planRoutes from './routes/plans';
|
import planRoutes from './routes/plans';
|
||||||
import aiRoutes from './routes/ai';
|
import aiRoutes from './routes/ai';
|
||||||
import weightRoutes from './routes/weight';
|
import weightRoutes from './routes/weight';
|
||||||
|
import sporadicSetsRoutes from './routes/sporadic-sets';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ app.use('/api/sessions', sessionRoutes);
|
|||||||
app.use('/api/plans', planRoutes);
|
app.use('/api/plans', planRoutes);
|
||||||
app.use('/api/ai', aiRoutes);
|
app.use('/api/ai', aiRoutes);
|
||||||
app.use('/api/weight', weightRoutes);
|
app.use('/api/weight', weightRoutes);
|
||||||
|
app.use('/api/sporadic-sets', sporadicSetsRoutes);
|
||||||
|
|
||||||
app.get('/', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
res.send('GymFlow AI API is running');
|
res.send('GymFlow AI API is running');
|
||||||
|
|||||||
182
server/src/routes/sporadic-sets.ts
Normal file
182
server/src/routes/sporadic-sets.ts
Normal file
@@ -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;
|
||||||
@@ -158,6 +158,14 @@ const translations = {
|
|||||||
type_to_filter: 'Type to filter...',
|
type_to_filter: 'Type to filter...',
|
||||||
exercise_name_exists: 'An exercise with this name already exists',
|
exercise_name_exists: 'An exercise with this name already exists',
|
||||||
profile_saved: 'Profile saved successfully',
|
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: {
|
ru: {
|
||||||
// Tabs
|
// Tabs
|
||||||
@@ -309,7 +317,15 @@ const translations = {
|
|||||||
type_to_filter: 'Введите для фильтрации...',
|
type_to_filter: 'Введите для фильтрации...',
|
||||||
exercise_name_exists: 'Упражнение с таким названием уже существует',
|
exercise_name_exists: 'Упражнение с таким названием уже существует',
|
||||||
profile_saved: 'Профиль успешно сохранен',
|
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) => {
|
export const t = (key: keyof typeof translations['en'], lang: Language) => {
|
||||||
|
|||||||
68
services/sporadicSets.ts
Normal file
68
services/sporadicSets.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
import { SporadicSet } from '../types';
|
||||||
|
|
||||||
|
export async function getSporadicSets(): Promise<SporadicSet[]> {
|
||||||
|
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<SporadicSet | null> {
|
||||||
|
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<SporadicSet | null> {
|
||||||
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
types.ts
15
types.ts
@@ -79,6 +79,21 @@ export interface BodyWeightRecord {
|
|||||||
dateStr: string; // YYYY-MM-DD
|
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 {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user