Critical Stability & Performance fixes. Excessive Log Set button gone on QIuck Log screen

This commit is contained in:
AG
2025-12-06 08:58:44 +02:00
parent 27afacee3f
commit 1c3e15516c
35 changed files with 48 additions and 26 deletions

156
src/components/AICoach.tsx Normal file
View File

@@ -0,0 +1,156 @@
import React, { useState, useRef, useEffect } from 'react';
import { Send, Bot, User, Loader2, AlertTriangle } from 'lucide-react';
import { createFitnessChat } from '../services/geminiService';
import { WorkoutSession, Language, UserProfile, WorkoutPlan } from '../types';
import { Chat, GenerateContentResponse } from '@google/genai';
import { t } from '../services/i18n';
import { generateId } from '../utils/uuid';
interface AICoachProps {
history: WorkoutSession[];
userProfile?: UserProfile;
plans?: WorkoutPlan[];
lang: Language;
}
interface Message {
id: string;
role: 'user' | 'model';
text: string;
}
const AICoach: React.FC<AICoachProps> = ({ history, userProfile, plans, lang }) => {
const [messages, setMessages] = useState<Message[]>([
{ id: 'intro', role: 'model', text: t('ai_intro', lang) }
]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const chatSessionRef = useRef<Chat | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
try {
const chat = createFitnessChat(history, lang, userProfile, plans);
if (chat) {
chatSessionRef.current = chat;
} else {
setError(t('ai_error', lang));
}
} catch (err) {
setError("Failed to initialize AI");
}
}, [history, lang, userProfile, plans]);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
const handleSend = async () => {
if (!input.trim() || !chatSessionRef.current || loading) return;
const userMsg: Message = { id: generateId(), role: 'user', text: input };
setMessages(prev => [...prev, userMsg]);
setInput('');
setLoading(true);
try {
const result: GenerateContentResponse = await chatSessionRef.current.sendMessage(userMsg.text);
const text = result.text;
const aiMsg: Message = {
id: generateId(),
role: 'model',
text: text || "Error generating response."
};
setMessages(prev => [...prev, aiMsg]);
} catch (err) {
console.error(err);
let errorText = 'Connection error.';
if (err instanceof Error) {
try {
const json = JSON.parse(err.message);
if (json.error) errorText = json.error;
else errorText = err.message;
} catch {
errorText = err.message;
}
}
setMessages(prev => [...prev, { id: generateId(), role: 'model', text: errorText }]);
} finally {
setLoading(false);
}
};
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full p-6 text-center text-on-surface-variant">
<AlertTriangle size={48} className="text-error mb-4" />
<p>{error}</p>
</div>
)
}
return (
<div className="flex flex-col h-full bg-surface">
{/* Header */}
<div className="p-4 bg-surface-container shadow-elevation-1 flex items-center gap-3 z-10">
<div className="w-10 h-10 rounded-full bg-secondary-container flex items-center justify-center">
<Bot size={20} className="text-on-secondary-container" />
</div>
<h2 className="text-xl font-normal text-on-surface">{t('ai_expert', lang)}</h2>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 pb-4">
{messages.map((msg) => (
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] p-4 rounded-[20px] text-sm leading-relaxed shadow-sm ${msg.role === 'user'
? 'bg-primary text-on-primary rounded-br-none'
: 'bg-surface-container-high text-on-surface border border-outline-variant/20 rounded-bl-none'
}`}>
{msg.text}
</div>
</div>
))}
{loading && (
<div className="flex justify-start">
<div className="bg-surface-container-high px-4 py-3 rounded-[20px] rounded-bl-none flex gap-2 items-center text-on-surface-variant text-sm">
<Loader2 size={16} className="animate-spin" />
{t('ai_typing', lang)}
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-4 bg-surface-container mt-auto">
<div className="flex gap-2 items-center bg-surface-container-high rounded-full border border-outline-variant px-2 py-1">
<input
type="text"
className="flex-1 bg-transparent border-none px-4 py-3 text-on-surface focus:outline-none placeholder-on-surface-variant"
placeholder={t('ai_placeholder', lang)}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
/>
<button
onClick={handleSend}
disabled={loading || !input.trim()}
className="w-10 h-10 bg-primary rounded-full flex items-center justify-center text-on-primary hover:opacity-90 disabled:opacity-50 transition-opacity shrink-0"
>
<Send size={18} />
</button>
</div>
</div>
</div>
);
};
export default AICoach;

View File

@@ -0,0 +1,155 @@
import React, { useState } from 'react';
import { toTitleCase } from '../utils/text';
import { X, Dumbbell, User, Flame, Timer as TimerIcon, ArrowUp, ArrowRight, Footprints, Ruler, Percent } from 'lucide-react';
import { ExerciseDef, ExerciseType, Language } from '../types';
import { t } from '../services/i18n';
import { generateId } from '../utils/uuid';
import FilledInput from './FilledInput';
interface ExerciseModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (exercise: ExerciseDef) => Promise<void> | void;
lang: Language;
existingExercises?: ExerciseDef[];
}
const ExerciseModal: React.FC<ExerciseModalProps> = ({ isOpen, onClose, onSave, lang, existingExercises = [] }) => {
const [newName, setNewName] = useState('');
const [newType, setNewType] = useState<ExerciseType>(ExerciseType.STRENGTH);
const [newBwPercentage, setNewBwPercentage] = useState<string>('100');
const [isUnilateral, setIsUnilateral] = useState(false);
const [error, setError] = useState<string>('');
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),
};
const handleCreateExercise = async () => {
if (!newName.trim()) return;
// Check for duplicate name (case-insensitive)
const trimmedName = newName.trim();
const isDuplicate = existingExercises.some(
ex => ex.name.toLowerCase() === trimmedName.toLowerCase()
);
if (isDuplicate) {
setError(t('exercise_name_exists', lang) || 'An exercise with this name already exists');
return;
}
const newEx: ExerciseDef = {
id: generateId(),
name: trimmedName,
type: newType,
isUnilateral,
...(newType === ExerciseType.BODYWEIGHT && { bodyWeightPercentage: parseFloat(newBwPercentage) || 100 })
};
await onSave(newEx);
setNewName('');
setNewType(ExerciseType.STRENGTH);
setNewBwPercentage('100');
setIsUnilateral(false);
setError('');
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/60 z-[60] flex items-end sm:items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-surface-container w-full max-w-sm rounded-[28px] p-6 shadow-elevation-3 animate-in slide-in-from-bottom-10 duration-200">
<div className="flex justify-between items-center mb-6">
<h3 className="text-2xl font-normal text-on-surface">{t('create_exercise', lang)}</h3>
<button onClick={onClose} className="p-2 bg-surface-container-high rounded-full hover:bg-outline-variant/20"><X size={20} /></button>
</div>
<div className="space-y-6">
<div>
<FilledInput
label={t('ex_name', lang)}
value={newName}
onChange={(e: any) => {
setNewName(e.target.value);
setError(''); // Clear error when user types
}}
type="text"
autoFocus
autocapitalize="words"
onBlur={() => setNewName(toTitleCase(newName))}
/>
{error && (
<p className="text-xs text-error mt-2 ml-3">{error}</p>
)}
</div>
<div>
<label className="block text-xs text-on-surface-variant font-medium mb-3">{t('ex_type', lang)}</label>
<div className="flex flex-wrap gap-2">
{[
{ id: ExerciseType.STRENGTH, label: exerciseTypeLabels[ExerciseType.STRENGTH], icon: Dumbbell },
{ id: ExerciseType.BODYWEIGHT, label: exerciseTypeLabels[ExerciseType.BODYWEIGHT], icon: User },
{ id: ExerciseType.CARDIO, label: exerciseTypeLabels[ExerciseType.CARDIO], icon: Flame },
{ id: ExerciseType.STATIC, label: exerciseTypeLabels[ExerciseType.STATIC], icon: TimerIcon },
{ id: ExerciseType.HIGH_JUMP, label: exerciseTypeLabels[ExerciseType.HIGH_JUMP], icon: ArrowUp },
{ id: ExerciseType.LONG_JUMP, label: exerciseTypeLabels[ExerciseType.LONG_JUMP], icon: Ruler },
{ id: ExerciseType.PLYOMETRIC, label: exerciseTypeLabels[ExerciseType.PLYOMETRIC], icon: Footprints },
].map((type) => (
<button
key={type.id}
onClick={() => setNewType(type.id)}
className={`px-4 py-2 rounded-lg flex items-center gap-2 text-xs font-medium border transition-all ${newType === type.id
? 'bg-secondary-container text-on-secondary-container border-transparent'
: 'bg-transparent text-on-surface-variant border-outline hover:border-on-surface-variant'
}`}
>
<type.icon size={14} /> {type.label}
</button>
))}
</div>
</div>
{newType === ExerciseType.BODYWEIGHT && (
<FilledInput
label={t('body_weight_percent', lang)}
value={newBwPercentage}
onChange={(e: any) => setNewBwPercentage(e.target.value)}
icon={<Percent size={12} />}
/>
)}
<div className="flex items-center gap-3 px-1">
<input
type="checkbox"
id="isUnilateral"
checked={isUnilateral}
onChange={(e) => setIsUnilateral(e.target.checked)}
className="w-5 h-5 rounded border-2 border-outline bg-surface-container-high checked:bg-primary checked:border-primary cursor-pointer"
/>
<label htmlFor="isUnilateral" className="text-sm text-on-surface cursor-pointer">
{t('unilateral_exercise', lang) || 'Unilateral exercise (separate left/right tracking)'}
</label>
</div>
<div className="flex justify-end mt-4">
<button
onClick={handleCreateExercise}
className="px-8 py-3 bg-primary text-on-primary rounded-full font-medium shadow-elevation-1"
>
{t('create_btn', lang)}
</button>
</div>
</div>
</div>
</div>
);
};
export default ExerciseModal;

View File

@@ -0,0 +1,67 @@
import React from 'react';
import { X } from 'lucide-react';
interface FilledInputProps {
label: string;
value: string | number;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onClear?: () => void;
onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
type?: string;
icon?: React.ReactNode;
autoFocus?: boolean;
step?: string;
inputMode?: "search" | "text" | "email" | "tel" | "url" | "none" | "numeric" | "decimal";
autocapitalize?: "off" | "none" | "on" | "sentences" | "words" | "characters";
autoComplete?: string;
rightElement?: React.ReactNode;
}
const FilledInput: React.FC<FilledInputProps> = ({ label, value, onChange, onClear, onFocus, onBlur, type = "number", icon, autoFocus, step, inputMode, autocapitalize, autoComplete, rightElement }) => {
const handleClear = () => {
const syntheticEvent = {
target: { value: '' }
} as React.ChangeEvent<HTMLInputElement>;
onChange(syntheticEvent);
if (onClear) onClear();
};
return (
<div className="relative group bg-surface-container-high rounded-t-lg border-b border-outline-variant hover:bg-white/5 focus-within:border-primary transition-colors">
<label className="absolute top-2 left-4 text-[10px] font-medium text-on-surface-variant flex items-center gap-1">
{icon} {label}
</label>
<input
type={type}
step={step}
inputMode={inputMode || (type === 'number' ? 'decimal' : 'text')}
autoFocus={autoFocus}
className={`w-full pt-6 pb-2 pl-4 bg-transparent text-2xl text-on-surface focus:outline-none placeholder-transparent ${rightElement ? 'pr-20' : 'pr-10'}`}
placeholder="0"
value={value}
onChange={onChange}
onFocus={onFocus}
onBlur={onBlur}
autoCapitalize={autocapitalize}
autoComplete={autoComplete}
/>
{value !== '' && value !== 0 && (
<button
onClick={handleClear}
className={`absolute top-1/2 -translate-y-1/2 p-2 text-on-surface-variant hover:text-on-surface rounded-full opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity ${rightElement ? 'right-12' : 'right-2'}`}
tabIndex={-1}
>
<X size={16} />
</button>
)}
{rightElement && (
<div className="absolute right-2 top-1/2 -translate-y-1/2 z-10">
{rightElement}
</div>
)}
</div>
);
};
export default FilledInput;

448
src/components/History.tsx Normal file
View File

@@ -0,0 +1,448 @@
import React, { useState } from 'react';
import { Calendar, Clock, TrendingUp, Gauge, Pencil, Trash2, X, Save, ArrowRight, ArrowUp, Timer, Activity, Dumbbell, Percent } from 'lucide-react';
import { WorkoutSession, ExerciseType, WorkoutSet, Language } from '../types';
import { t } from '../services/i18n';
interface HistoryProps {
sessions: WorkoutSession[];
onUpdateSession?: (session: WorkoutSession) => void;
onDeleteSession?: (sessionId: string) => void;
lang: Language;
}
const History: React.FC<HistoryProps> = ({ sessions, onUpdateSession, onDeleteSession, lang }) => {
const [editingSession, setEditingSession] = useState<WorkoutSession | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [deletingSetInfo, setDeletingSetInfo] = useState<{ sessionId: string, setId: string } | null>(null);
const calculateSessionWork = (session: WorkoutSession) => {
const bw = session.userBodyWeight || 70;
return session.sets.reduce((acc, set) => {
let w = 0;
if (set.type === ExerciseType.STRENGTH) {
w = (set.weight || 0) * (set.reps || 0);
}
if (set.type === ExerciseType.BODYWEIGHT) {
const percent = set.bodyWeightPercentage || 100;
const effectiveBw = bw * (percent / 100);
w = (effectiveBw + (set.weight || 0)) * (set.reps || 0);
}
return acc + Math.max(0, w);
}, 0);
};
const formatDateForInput = (timestamp: number) => {
const d = new Date(timestamp);
const pad = (n: number) => n < 10 ? '0' + n : n;
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
const parseDateFromInput = (value: string) => {
return new Date(value).getTime();
};
const formatDuration = (startTime: number, endTime?: number) => {
if (!endTime || isNaN(endTime) || isNaN(startTime)) return '';
const durationMs = endTime - startTime;
if (durationMs < 0 || isNaN(durationMs)) return '';
const hours = Math.floor(durationMs / 3600000);
const minutes = Math.floor((durationMs % 3600000) / 60000);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
if (minutes < 1) {
return '<1m';
}
return `${minutes}m`;
};
const handleSaveEdit = () => {
if (editingSession && onUpdateSession) {
onUpdateSession(editingSession);
setEditingSession(null);
}
};
const handleUpdateSet = (setId: string, field: keyof WorkoutSet, value: number) => {
if (!editingSession) return;
const updatedSets = editingSession.sets.map(s =>
s.id === setId ? { ...s, [field]: value } : s
);
setEditingSession({ ...editingSession, sets: updatedSets });
};
const handleDeleteSet = (setId: string) => {
if (!editingSession) return;
setEditingSession({
...editingSession,
sets: editingSession.sets.filter(s => s.id !== setId)
});
};
const handleConfirmDelete = () => {
if (deletingId && onDeleteSession) {
onDeleteSession(deletingId);
setDeletingId(null);
} else if (deletingSetInfo && onUpdateSession) {
// Find the session
const session = sessions.find(s => s.id === deletingSetInfo.sessionId);
if (session) {
// Create updated session with the set removed
const updatedSession = {
...session,
sets: session.sets.filter(s => s.id !== deletingSetInfo.setId)
};
onUpdateSession(updatedSession);
}
setDeletingSetInfo(null);
}
}
if (sessions.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-on-surface-variant p-8 text-center">
<Clock size={48} className="mb-4 opacity-50" />
<p>{t('history_empty', lang)}</p>
</div>
);
}
return (
<div className="h-full flex flex-col bg-surface">
<div className="p-4 bg-surface-container shadow-elevation-1 z-10">
<h2 className="text-2xl font-normal text-on-surface">{t('tab_history', lang)}</h2>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4 pb-20">
{/* Regular Workout Sessions */}
{sessions.filter(s => s.type === 'STANDARD').map((session) => {
const totalWork = calculateSessionWork(session);
return (
<div
key={session.id}
className="bg-surface-container rounded-xl p-5 shadow-elevation-1 border border-outline-variant/20 cursor-pointer hover:bg-surface-container-high transition-colors"
onClick={() => setEditingSession(JSON.parse(JSON.stringify(session)))}
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 flex-wrap">
<span className="font-medium text-on-surface text-lg">
{new Date(session.startTime).toISOString().split('T')[0]}
</span>
<span className="text-sm text-on-surface-variant">
{new Date(session.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
{session.endTime && (
<span className="text-sm text-on-surface-variant">
{formatDuration(session.startTime, session.endTime)}
</span>
)}
<span className="text-sm text-on-surface-variant">
{session.planName || t('no_plan', lang)}
</span>
{session.userBodyWeight && (
<span className="px-2 py-0.5 rounded-full bg-surface-container-high text-on-surface text-xs">
{session.userBodyWeight}kg
</span>
)}
</div>
<div className="mt-2 text-xs text-on-surface-variant flex items-center">
<span className="inline-flex items-center">
{t('sets_count', lang)}: <span className="text-on-surface font-medium ml-1">{session.sets.length}</span>
</span>
{totalWork > 0 && (
<span className="ml-4 inline-flex items-center gap-1">
<Gauge size={12} />
{(totalWork / 1000).toFixed(1)}t
</span>
)}
</div>
</div>
<div className="flex gap-1">
<button
onClick={(e) => {
e.stopPropagation();
setEditingSession(JSON.parse(JSON.stringify(session)));
}}
className="p-2 text-on-surface-variant hover:text-primary hover:bg-surface-container-high rounded-full transition-colors"
>
<Pencil size={20} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setDeletingId(session.id);
}}
className="p-2 text-on-surface-variant hover:text-error hover:bg-surface-container-high rounded-full transition-colors"
>
<Trash2 size={20} />
</button>
</div>
</div>
</div>
)
})}
{/* Quick Log Sessions */}
{sessions.filter(s => s.type === 'QUICK_LOG').length > 0 && (
<div className="mt-8">
<h3 className="text-xl font-medium text-on-surface mb-4 px-2">{t('quick_log', lang)}</h3>
{Object.entries(
sessions
.filter(s => s.type === 'QUICK_LOG')
.reduce((groups: Record<string, WorkoutSession[]>, session) => {
const date = new Date(session.startTime).toISOString().split('T')[0];
if (!groups[date]) groups[date] = [];
groups[date].push(session);
return groups;
}, {})
)
.sort(([a], [b]) => b.localeCompare(a))
.map(([date, daySessions]) => (
<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">
{daySessions.flatMap(session => session.sets).map((set, idx) => (
<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}
{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase() as any, lang)}</span>}
</div>
<div className="text-sm text-on-surface-variant 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={() => {
// Find the session this set belongs to and open edit mode
const parentSession = daySessions.find(s => s.sets.some(st => st.id === set.id));
if (parentSession) {
setEditingSession(JSON.parse(JSON.stringify(parentSession)));
}
}}
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={() => {
// Find the session and set up for deletion
const parentSession = daySessions.find(s => s.sets.some(st => st.id === set.id));
if (parentSession) {
setDeletingSetInfo({ sessionId: parentSession.id, setId: 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>
)}
</div>
{/* DELETE CONFIRMATION DIALOG (MD3) */}
{(deletingId || deletingSetInfo) && (
<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">
{deletingId ? t('delete_workout', lang) : t('delete_set', lang) || 'Delete Set'}
</h3>
<p className="text-sm text-on-surface-variant mb-8">
{deletingId ? t('delete_confirm', lang) : t('delete_set_confirm', lang) || 'Are you sure you want to delete this set?'}
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => {
setDeletingId(null);
setDeletingSetInfo(null);
}}
className="px-4 py-2 rounded-full text-primary font-medium hover:bg-white/5"
>
{t('cancel', lang)}
</button>
<button
onClick={handleConfirmDelete}
className="px-4 py-2 rounded-full bg-error-container text-on-error-container font-medium"
>
{t('delete', lang)}
</button>
</div>
</div>
</div>
)}
{/* EDIT SESSION FULLSCREEN DIALOG */}
{editingSession && (
<div className="fixed inset-0 z-[60] bg-surface flex flex-col animate-in slide-in-from-bottom-10 duration-200">
<div className="px-4 py-3 border-b border-outline-variant flex justify-between items-center bg-surface-container shadow-elevation-1">
<button onClick={() => setEditingSession(null)} className="text-on-surface-variant hover:text-on-surface">
<X />
</button>
<h2 className="text-lg font-medium text-on-surface">{t('edit', lang)}</h2>
<button onClick={handleSaveEdit} className="text-primary font-medium flex items-center gap-2">
{t('save', lang)}
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Meta Info */}
<div className="bg-surface-container p-4 rounded-xl border border-outline-variant/20 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="bg-surface-container-high rounded-t-lg px-3 py-2 border-b border-outline-variant">
<label className="text-[10px] text-on-surface-variant font-bold block">{t('start_time', lang)}</label>
<input
type="datetime-local"
value={formatDateForInput(editingSession.startTime)}
onChange={(e) => setEditingSession({ ...editingSession, startTime: parseDateFromInput(e.target.value) })}
className="w-full bg-transparent text-on-surface focus:outline-none text-sm mt-1"
/>
</div>
<div className="bg-surface-container-high rounded-t-lg px-3 py-2 border-b border-outline-variant">
<label className="text-[10px] text-on-surface-variant font-bold block">{t('end_time', lang)}</label>
<input
type="datetime-local"
value={editingSession.endTime ? formatDateForInput(editingSession.endTime) : ''}
onChange={(e) => setEditingSession({ ...editingSession, endTime: parseDateFromInput(e.target.value) })}
className="w-full bg-transparent text-on-surface focus:outline-none text-sm mt-1"
/>
</div>
</div>
<div className="bg-surface-container-high rounded-t-lg px-3 py-2 border-b border-outline-variant">
<label className="text-[10px] text-on-surface-variant font-bold block">{t('weight_kg', lang)}</label>
<input
type="number"
value={editingSession.userBodyWeight || ''}
onChange={(e) => setEditingSession({ ...editingSession, userBodyWeight: parseFloat(e.target.value) })}
className="w-full bg-transparent text-on-surface focus:outline-none text-lg mt-1"
/>
</div>
</div>
<div className="space-y-3">
<h3 className="text-sm font-medium text-primary ml-1">{t('sets_count', lang)} ({editingSession.sets.length})</h3>
{editingSession.sets.map((set, idx) => (
<div key={set.id} className="bg-surface-container p-3 rounded-xl border border-outline-variant/20 flex flex-col gap-3 shadow-sm">
<div className="flex justify-between items-center border-b border-outline-variant pb-2">
<div className="flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-secondary-container text-on-secondary-container text-xs font-bold flex items-center justify-center">{idx + 1}</span>
<span className="font-medium text-on-surface text-sm">{set.exerciseName}{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase(), lang)}</span>}</span>
</div>
<button
onClick={() => handleDeleteSet(set.id)}
className="text-on-surface-variant hover:text-error p-1 rounded hover:bg-error-container/10 transition-colors"
title={t('delete', lang)}
>
<Trash2 size={18} />
</button>
</div>
<div className="grid grid-cols-3 gap-2">
{(set.type === ExerciseType.STRENGTH || set.type === ExerciseType.BODYWEIGHT || set.type === ExerciseType.STATIC) && (
<div className="bg-surface-container-high rounded px-2 py-1">
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Dumbbell size={10} /> {t('weight_kg', lang)}</label>
<input
type="number"
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
value={set.weight === 0 ? '' : (set.weight ?? '')}
onChange={(e) => handleUpdateSet(set.id, 'weight', parseFloat(e.target.value))}
/>
</div>
)}
{(set.type === ExerciseType.STRENGTH || set.type === ExerciseType.BODYWEIGHT || set.type === ExerciseType.PLYOMETRIC) && (
<div className="bg-surface-container-high rounded px-2 py-1">
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Activity size={10} /> {t('reps', lang)}</label>
<input
type="number"
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
value={set.reps === 0 ? '' : (set.reps ?? '')}
onChange={(e) => handleUpdateSet(set.id, 'reps', parseInt(e.target.value))}
/>
</div>
)}
{(set.type === ExerciseType.BODYWEIGHT || set.type === ExerciseType.STATIC) && (
<div className="bg-surface-container-high rounded px-2 py-1">
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Percent size={10} /> {t('body_weight_percent', lang)}</label>
<input
type="number"
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
value={set.bodyWeightPercentage === 0 ? '' : (set.bodyWeightPercentage ?? 100)}
onChange={(e) => handleUpdateSet(set.id, 'bodyWeightPercentage', parseFloat(e.target.value))}
/>
</div>
)}
{(set.type === ExerciseType.CARDIO || set.type === ExerciseType.STATIC) && (
<div className="bg-surface-container-high rounded px-2 py-1">
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Timer size={10} /> {t('time_sec', lang)}</label>
<input
type="number"
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
value={set.durationSeconds === 0 ? '' : (set.durationSeconds ?? '')}
onChange={(e) => handleUpdateSet(set.id, 'durationSeconds', parseFloat(e.target.value))}
/>
</div>
)}
{(set.type === ExerciseType.CARDIO || set.type === ExerciseType.LONG_JUMP) && (
<div className="bg-surface-container-high rounded px-2 py-1">
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><ArrowRight size={10} /> {t('dist_m', lang)}</label>
<input
type="number"
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
value={set.distanceMeters === 0 ? '' : (set.distanceMeters ?? '')}
onChange={(e) => handleUpdateSet(set.id, 'distanceMeters', parseFloat(e.target.value))}
/>
</div>
)}
{(set.type === ExerciseType.HIGH_JUMP) && (
<div className="bg-surface-container-high rounded px-2 py-1">
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><ArrowUp size={10} /> {t('height_cm', lang)}</label>
<input
type="number"
className="w-full bg-transparent text-sm text-on-surface focus:outline-none"
value={set.height === 0 ? '' : (set.height ?? '')}
onChange={(e) => handleUpdateSet(set.id, 'height', parseFloat(e.target.value))}
/>
</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
};
export default History;

140
src/components/Login.tsx Normal file
View File

@@ -0,0 +1,140 @@
import React, { useState } from 'react';
import FilledInput from './FilledInput';
import { login, changePassword } from '../services/auth';
import { User, Language } from '../types';
import { Dumbbell, ArrowRight, Lock, Mail, Globe } from 'lucide-react';
import { t } from '../services/i18n';
interface LoginProps {
onLogin: (user: User) => void;
language: Language;
onLanguageChange: (lang: Language) => void;
}
const Login: React.FC<LoginProps> = ({ onLogin, language, onLanguageChange }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
// Force Password Change State
const [needsChange, setNeedsChange] = useState(false);
const [tempUser, setTempUser] = useState<User | null>(null);
const [newPassword, setNewPassword] = useState('');
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
const res = await login(email, password);
if (res.success && res.user) {
if (res.user.isFirstLogin) {
setTempUser(res.user);
setNeedsChange(true);
} else {
onLogin(res.user);
}
} else {
setError(res.error || t('login_error', language));
}
};
const handleChangePassword = async () => {
if (tempUser && newPassword.length >= 4) {
const res = await changePassword(tempUser.id, newPassword);
if (res.success) {
const updatedUser = { ...tempUser, isFirstLogin: false };
onLogin(updatedUser);
} else {
setError(res.error || t('change_pass_error', language));
}
} else {
setError(t('login_password_short', language));
}
};
if (needsChange) {
return (
<div className="h-screen bg-surface flex items-center justify-center p-4">
<div className="w-full max-w-sm bg-surface-container p-8 rounded-[28px] shadow-elevation-2">
<h2 className="text-2xl text-on-surface mb-2">{t('change_pass_title', language)}</h2>
<p className="text-sm text-on-surface-variant mb-6">{t('change_pass_desc', language)}</p>
<div className="space-y-4">
<FilledInput
label={t('change_pass_new', language)}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
type="password"
/>
<button
onClick={handleChangePassword}
className="w-full py-3 bg-primary text-on-primary rounded-full font-medium"
>
{t('change_pass_save', language)}
</button>
</div>
</div>
</div>
)
}
return (
<div className="h-screen bg-surface flex flex-col items-center justify-center p-6 relative">
{/* Language Toggle */}
<div className="absolute top-6 right-6 flex items-center bg-surface-container rounded-full px-3 py-1 gap-2 border border-outline-variant/20">
<Globe size={16} className="text-on-surface-variant" />
<select
value={language}
onChange={(e) => onLanguageChange(e.target.value as Language)}
className="bg-transparent text-sm text-on-surface focus:outline-none appearance-none"
>
<option value="en">English</option>
<option value="ru">Русский</option>
</select>
</div>
<div className="flex flex-col items-center mb-12">
<div className="w-20 h-20 bg-primary-container rounded-2xl flex items-center justify-center text-on-primary-container mb-4 shadow-elevation-2 transform rotate-3">
<Dumbbell size={40} />
</div>
<h1 className="text-3xl font-normal text-on-surface tracking-tight">{t('login_title', language)}</h1>
</div>
<form onSubmit={handleLogin} className="w-full max-w-sm space-y-6">
<div className="space-y-4">
<FilledInput
label={t('login_email', language)}
value={email}
onChange={(e) => setEmail(e.target.value)}
type="email"
icon={<Mail size={16} />}
inputMode="email"
/>
<FilledInput
label={t('login_password', language)}
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
icon={<Lock size={16} />}
/>
</div>
{error && <div className="text-error text-sm text-center bg-error-container/10 p-2 rounded-lg">{error}</div>}
<button
type="submit"
className="w-full py-4 bg-primary text-on-primary rounded-full font-medium text-lg shadow-elevation-1 flex items-center justify-center gap-2 hover:shadow-elevation-2 transition-all"
>
{t('login_btn', language)} <ArrowRight size={20} />
</button>
</form>
<p className="mt-8 text-xs text-on-surface-variant text-center max-w-xs mx-auto">
{t('login_contact_admin', language)}
</p>
</div>
);
};
export default Login;

82
src/components/Navbar.tsx Normal file
View File

@@ -0,0 +1,82 @@
import React from 'react';
import { Dumbbell, History as HistoryIcon, BarChart2, MessageSquare, ClipboardList, User } from 'lucide-react';
import { TabView, Language } from '../types';
import { t } from '../services/i18n';
interface NavbarProps {
currentTab: TabView;
onTabChange: (tab: TabView) => void;
lang: Language;
}
const Navbar: React.FC<NavbarProps> = ({ currentTab, onTabChange, lang }) => {
const navItems = [
{ id: 'TRACK' as TabView, icon: Dumbbell, label: t('tab_tracker', lang) },
{ id: 'PLANS' as TabView, icon: ClipboardList, label: t('tab_plans', lang) },
{ id: 'HISTORY' as TabView, icon: HistoryIcon, label: t('tab_history', lang) },
{ id: 'STATS' as TabView, icon: BarChart2, label: t('tab_stats', lang) },
{ id: 'AI_COACH' as TabView, icon: MessageSquare, label: t('tab_ai', lang) },
{ id: 'PROFILE' as TabView, icon: User, label: t('tab_profile', lang) },
];
return (
<>
{/* MOBILE: Bottom Navigation Bar (MD3) */}
<div className="md:hidden fixed bottom-0 left-0 w-full bg-surface-container shadow-elevation-2 border-t border-white/5 pb-safe z-50 h-20">
<div className="flex justify-evenly items-center h-full px-1">
{navItems.map((item) => {
const isActive = currentTab === item.id;
return (
<button
key={item.id}
onClick={() => onTabChange(item.id)}
className="flex flex-col items-center justify-center w-full h-full gap-1 group min-w-0"
>
<div className={`px-4 py-1 rounded-full transition-all duration-200 ${
isActive ? 'bg-primary-container text-on-primary-container' : 'text-on-surface-variant group-hover:bg-surface-container-high'
}`}>
<item.icon size={22} strokeWidth={isActive ? 2.5 : 2} />
</div>
<span className={`text-[10px] font-medium transition-colors truncate w-full text-center ${
isActive ? 'text-on-surface' : 'text-on-surface-variant'
}`}>
{item.label}
</span>
</button>
);
})}
</div>
</div>
{/* DESKTOP: Navigation Rail (MD3) */}
<div className="hidden md:flex flex-col w-20 h-full bg-surface-container border-r border-outline-variant items-center py-8 gap-8 z-50">
<div className="flex flex-col gap-6 w-full px-2">
{navItems.map((item) => {
const isActive = currentTab === item.id;
return (
<button
key={item.id}
onClick={() => onTabChange(item.id)}
className="flex flex-col items-center gap-1 group w-full"
>
<div className={`w-14 h-8 rounded-full flex items-center justify-center transition-colors duration-200 ${
isActive ? 'bg-primary-container text-on-primary-container' : 'text-on-surface-variant group-hover:bg-surface-container-high'
}`}>
<item.icon size={24} />
</div>
<span className={`text-[11px] font-medium text-center ${
isActive ? 'text-on-surface' : 'text-on-surface-variant'
}`}>
{item.label}
</span>
</button>
);
})}
</div>
</div>
</>
);
};
export default Navbar;

417
src/components/Plans.tsx Normal file
View File

@@ -0,0 +1,417 @@
import React, { useState, useEffect } from 'react';
import { Plus, Trash2, PlayCircle, Dumbbell, Save, X, ChevronRight, List, ArrowUp, ArrowDown, Scale, Edit2, User, Flame, Timer as TimerIcon, Ruler, Footprints, Activity, Percent, CheckCircle, GripVertical } from 'lucide-react';
import { WorkoutPlan, ExerciseDef, PlannedSet, Language, ExerciseType } from '../types';
import { getPlans, savePlan, deletePlan, getExercises, saveExercise } from '../services/storage';
import { t } from '../services/i18n';
import { generateId } from '../utils/uuid';
import FilledInput from './FilledInput';
import { toTitleCase } from '../utils/text';
interface PlansProps {
userId: string;
onStartPlan: (plan: WorkoutPlan) => void;
lang: Language;
}
const Plans: React.FC<PlansProps> = ({ userId, onStartPlan, lang }) => {
const [plans, setPlans] = useState<WorkoutPlan[]>([]);
const [isEditing, setIsEditing] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [steps, setSteps] = useState<PlannedSet[]>([]);
const [availableExercises, setAvailableExercises] = useState<ExerciseDef[]>([]);
const [showExerciseSelector, setShowExerciseSelector] = useState(false);
// Drag and Drop Refs
const dragItem = React.useRef<number | null>(null);
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
// Create Exercise State
const [isCreatingExercise, setIsCreatingExercise] = useState(false);
const [newExName, setNewExName] = useState('');
const [newExType, setNewExType] = useState<ExerciseType>(ExerciseType.STRENGTH);
const [newExBwPercentage, setNewExBwPercentage] = useState<string>('100');
useEffect(() => {
const loadData = async () => {
const fetchedPlans = await getPlans(userId);
setPlans(fetchedPlans);
const fetchedExercises = await getExercises(userId);
// Filter out archived exercises
if (Array.isArray(fetchedExercises)) {
setAvailableExercises(fetchedExercises.filter(e => !e.isArchived));
} else {
setAvailableExercises([]);
}
};
loadData();
}, [userId]);
const handleCreateNew = () => {
setEditId(generateId());
setName('');
setDescription('');
setSteps([]);
setIsEditing(true);
};
const handleEdit = (plan: WorkoutPlan) => {
setEditId(plan.id);
setName(plan.name);
setDescription(plan.description || '');
setSteps(plan.steps);
setIsEditing(true);
};
const handleSave = async () => {
if (!name.trim() || !editId) return;
const newPlan: WorkoutPlan = { id: editId, name, description, steps };
await savePlan(userId, newPlan);
const updated = await getPlans(userId);
setPlans(updated);
setIsEditing(false);
};
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (confirm(t('delete_confirm', lang))) {
await deletePlan(userId, id);
const updated = await getPlans(userId);
setPlans(updated);
}
};
const addStep = (ex: ExerciseDef) => {
const newStep: PlannedSet = {
id: generateId(),
exerciseId: ex.id,
exerciseName: ex.name,
exerciseType: ex.type,
isWeighted: false
};
setSteps([...steps, newStep]);
setShowExerciseSelector(false);
};
const handleCreateExercise = async () => {
if (!newExName.trim()) return;
const newEx: ExerciseDef = {
id: generateId(),
name: newExName.trim(),
type: newExType,
...(newExType === ExerciseType.BODYWEIGHT && { bodyWeightPercentage: parseFloat(newExBwPercentage) || 100 })
};
await saveExercise(userId, newEx);
const exList = await getExercises(userId);
setAvailableExercises(exList.filter(e => !e.isArchived));
// Automatically add the new exercise to the plan
addStep(newEx);
setNewExName('');
setNewExType(ExerciseType.STRENGTH);
setNewExBwPercentage('100');
setIsCreatingExercise(false);
};
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),
};
const toggleWeighted = (stepId: string) => {
setSteps(steps.map(s => s.id === stepId ? { ...s, isWeighted: !s.isWeighted } : s));
};
const removeStep = (stepId: string) => {
setSteps(steps.filter(s => s.id !== stepId));
};
const onDragStart = (index: number) => {
dragItem.current = index;
setDraggingIndex(index);
};
const onDragEnter = (index: number) => {
if (dragItem.current === null) return;
if (dragItem.current === index) return;
const newSteps = [...steps];
const draggedItemContent = newSteps.splice(dragItem.current, 1)[0];
newSteps.splice(index, 0, draggedItemContent);
setSteps(newSteps);
dragItem.current = index;
setDraggingIndex(index);
};
const onDragEnd = () => {
dragItem.current = null;
setDraggingIndex(null);
};
if (isEditing) {
return (
<div className="h-full flex flex-col bg-surface">
<div className="px-4 py-3 bg-surface-container border-b border-outline-variant flex justify-between items-center">
<button onClick={() => setIsEditing(false)} className="p-2 text-on-surface-variant hover:bg-white/5 rounded-full"><X /></button>
<h2 className="text-title-medium font-medium text-on-surface">{t('plan_editor', lang)}</h2>
<button onClick={handleSave} className="p-2 text-primary font-medium">
{t('save', lang)}
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-4 py-2">
<label className="text-[10px] text-on-surface-variant font-medium">{t('ex_name', lang)}</label>
<input
className="w-full bg-transparent text-xl text-on-surface focus:outline-none pt-1 pb-2"
placeholder={t('plan_name_ph', lang)}
value={name}
onChange={(e) => setName(e.target.value)}
autoCapitalize="words"
onBlur={() => setName(toTitleCase(name))}
/>
</div>
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-4 py-2">
<label className="text-[10px] text-on-surface-variant font-medium">{t('prep_title', lang)}</label>
<textarea
className="w-full bg-transparent text-base text-on-surface focus:outline-none pt-1 pb-2 min-h-[80px]"
placeholder={t('plan_desc_ph', lang)}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-3">
<div className="flex justify-between items-center px-2">
<label className="text-sm text-primary font-medium">{t('exercises_list', lang)}</label>
</div>
<div className="space-y-2">
{steps.map((step, idx) => (
<div
key={step.id}
className={`bg-surface-container rounded-xl p-3 flex items-center gap-3 shadow-elevation-1 cursor-move transition-all hover:bg-surface-container-high ${draggingIndex === idx ? 'opacity-50 ring-2 ring-primary bg-surface-container-high' : ''}`}
draggable
onDragStart={() => onDragStart(idx)}
onDragEnter={() => onDragEnter(idx)}
onDragOver={(e) => e.preventDefault()}
onDragEnd={onDragEnd}
>
<div className={`text-on-surface-variant p-1 ${draggingIndex === idx ? 'cursor-grabbing' : 'cursor-grab'}`}>
<GripVertical size={20} />
</div>
<div className="w-8 h-8 rounded-full bg-primary-container text-on-primary-container flex items-center justify-center text-xs font-bold shrink-0">
{idx + 1}
</div>
<div className="flex-1">
<div className="text-base font-medium text-on-surface">{step.exerciseName}</div>
<label className="flex items-center gap-2 mt-1 cursor-pointer w-fit">
<div className={`w-4 h-4 border rounded flex items-center justify-center ${step.isWeighted ? 'bg-primary border-primary' : 'border-outline'}`}>
{step.isWeighted && <Scale size={10} className="text-on-primary" />}
</div>
<input
type="checkbox"
checked={step.isWeighted}
onChange={() => toggleWeighted(step.id)}
className="hidden"
/>
<span className="text-xs text-on-surface-variant">{t('weighted', lang)}</span>
</label>
</div>
<button onClick={() => removeStep(step.id)} className="text-on-surface-variant hover:text-error p-2">
<X size={20} />
</button>
</div>
))}
</div>
<button
onClick={() => setShowExerciseSelector(true)}
className="w-full py-4 rounded-full border border-outline text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary-container/10 transition-all"
>
<Plus size={20} />
{t('add_exercise', lang)}
</button>
</div>
</div>
{showExerciseSelector && (
<div className="fixed inset-0 bg-surface z-50 flex flex-col animate-in slide-in-from-bottom-full duration-200">
<div className="px-4 py-3 border-b border-outline-variant flex justify-between items-center bg-surface-container">
<span className="font-medium text-on-surface">{t('select_exercise', lang)}</span>
<div className="flex gap-2">
<button onClick={() => setIsCreatingExercise(true)} className="p-2 text-primary hover:bg-primary-container/20 rounded-full">
<Plus size={20} />
</button>
<button onClick={() => setShowExerciseSelector(false)}><X /></button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-2">
{availableExercises
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(ex => (
<button
key={ex.id}
onClick={() => addStep(ex)}
className="w-full text-left p-4 border-b border-outline-variant hover:bg-surface-container-high text-on-surface flex justify-between"
>
<span>{ex.name}</span>
<span className="text-xs bg-secondary-container text-on-secondary-container px-2 py-1 rounded-full">{ex.type}</span>
</button>
))}
</div>
{isCreatingExercise && (
<div className="fixed inset-0 bg-surface z-[60] flex flex-col animate-in slide-in-from-bottom-full duration-200">
<div className="px-4 py-3 border-b border-outline-variant flex justify-between items-center bg-surface-container">
<h3 className="text-title-medium font-medium text-on-surface">{t('create_exercise', lang)}</h3>
<button onClick={() => setIsCreatingExercise(false)} className="p-2 text-on-surface-variant hover:bg-white/5 rounded-full"><X /></button>
</div>
<div className="p-4 space-y-6 overflow-y-auto flex-1">
<FilledInput
label={t('ex_name', lang)}
value={newExName}
onChange={(e: any) => setNewExName(e.target.value)}
type="text"
autoFocus
autocapitalize="words"
onBlur={() => setNewExName(toTitleCase(newExName))}
/>
<div>
<label className="block text-xs text-on-surface-variant font-medium mb-3">{t('ex_type', lang)}</label>
<div className="flex flex-wrap gap-2">
{[
{ id: ExerciseType.STRENGTH, label: exerciseTypeLabels[ExerciseType.STRENGTH], icon: Dumbbell },
{ id: ExerciseType.BODYWEIGHT, label: exerciseTypeLabels[ExerciseType.BODYWEIGHT], icon: User },
{ id: ExerciseType.CARDIO, label: exerciseTypeLabels[ExerciseType.CARDIO], icon: Flame },
{ id: ExerciseType.STATIC, label: exerciseTypeLabels[ExerciseType.STATIC], icon: TimerIcon },
{ id: ExerciseType.HIGH_JUMP, label: exerciseTypeLabels[ExerciseType.HIGH_JUMP], icon: ArrowUp },
{ id: ExerciseType.LONG_JUMP, label: exerciseTypeLabels[ExerciseType.LONG_JUMP], icon: Ruler },
{ id: ExerciseType.PLYOMETRIC, label: exerciseTypeLabels[ExerciseType.PLYOMETRIC], icon: Footprints },
].map((type) => (
<button
key={type.id}
onClick={() => setNewExType(type.id)}
className={`px-4 py-2 rounded-lg flex items-center gap-2 text-xs font-medium border transition-all ${newExType === type.id
? 'bg-secondary-container text-on-secondary-container border-transparent'
: 'bg-transparent text-on-surface-variant border-outline hover:border-on-surface-variant'
}`}
>
<type.icon size={14} /> {type.label}
</button>
))}
</div>
</div>
{newExType === ExerciseType.BODYWEIGHT && (
<FilledInput
label={t('body_weight_percent', lang)}
value={newExBwPercentage}
onChange={(e: any) => setNewExBwPercentage(e.target.value)}
icon={<Percent size={12} />}
/>
)}
<div className="flex justify-end mt-4">
<button
onClick={handleCreateExercise}
className="w-full h-14 bg-primary text-on-primary rounded-full font-medium shadow-elevation-1 flex items-center justify-center gap-2"
>
<CheckCircle size={20} />
{t('create_btn', lang)}
</button>
</div>
</div>
</div>
)}
</div>
)}
</div>
);
}
return (
<div className="h-full flex flex-col bg-surface relative">
<div className="p-4 bg-surface-container shadow-elevation-1 z-10">
<h2 className="text-2xl font-normal text-on-surface">{t('my_plans', lang)}</h2>
</div>
<div className="flex-1 p-4 overflow-y-auto space-y-4 pb-24">
{plans.length === 0 ? (
<div className="text-center text-on-surface-variant mt-20 flex flex-col items-center">
<div className="w-16 h-16 bg-surface-container-high rounded-full flex items-center justify-center mb-4">
<List size={32} />
</div>
<p className="text-lg">{t('plans_empty', lang)}</p>
</div>
) : (
plans.map(plan => (
<div key={plan.id} className="bg-surface-container rounded-xl p-4 shadow-elevation-1 border border-outline-variant/20 relative overflow-hidden">
<div className="flex justify-between items-start mb-2">
<h3 className="text-xl font-normal text-on-surface">{plan.name}</h3>
<button
onClick={(e) => handleDelete(plan.id, e)}
className="text-on-surface-variant hover:text-error p-2 rounded-full hover:bg-white/5"
>
<Trash2 size={20} />
</button>
</div>
<div className="absolute top-4 right-14">
<button
onClick={(e) => { e.stopPropagation(); handleEdit(plan); }}
className="text-on-surface-variant hover:text-primary p-2 rounded-full hover:bg-white/5"
>
<Edit2 size={20} />
</button>
</div>
<p className="text-on-surface-variant text-sm line-clamp-2 mb-4 min-h-[1.25rem]">
{plan.description || t('prep_no_instructions', lang)}
</p>
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-primary bg-primary-container/20 px-3 py-1 rounded-full">
{plan.steps.length} {t('exercises_count', lang)}
</div>
<button
onClick={() => onStartPlan(plan)}
className="flex items-center gap-2 bg-primary text-on-primary px-5 py-2 rounded-full text-sm font-medium hover:shadow-elevation-2 transition-all"
>
<PlayCircle size={18} />
{t('start', lang)}
</button>
</div>
</div>
))
)}
</div>
{/* FAB */}
<button
onClick={handleCreateNew}
className="absolute bottom-6 right-6 w-14 h-14 bg-primary-container text-on-primary-container rounded-[16px] shadow-elevation-3 flex items-center justify-center hover:bg-primary hover:text-on-primary transition-colors z-20"
>
<Plus size={28} />
</button>
</div>
);
};
export default Plans;

624
src/components/Profile.tsx Normal file
View File

@@ -0,0 +1,624 @@
import React, { useState, useEffect } from 'react';
import { User, Language, ExerciseDef, ExerciseType, BodyWeightRecord } from '../types';
import { createUser, changePassword, updateUserProfile, getCurrentUserProfile, getUsers, deleteUser, toggleBlockUser, adminResetPassword, getMe } from '../services/auth';
import { getExercises, saveExercise } from '../services/storage';
import { getWeightHistory, logWeight } from '../services/weight';
import { User as UserIcon, LogOut, Save, Shield, UserPlus, Lock, Calendar, Ruler, Scale, PersonStanding, Globe, ChevronDown, ChevronUp, Trash2, Ban, KeyRound, Dumbbell, Archive, ArchiveRestore, Pencil, Plus } from 'lucide-react';
import ExerciseModal from './ExerciseModal';
import FilledInput from './FilledInput';
import { t } from '../services/i18n';
import Snackbar from './Snackbar';
interface ProfileProps {
user: User;
onLogout: () => void;
lang: Language;
onLanguageChange: (lang: Language) => void;
onUserUpdate?: (user: User) => void;
}
const Profile: React.FC<ProfileProps> = ({ user, onLogout, lang, onLanguageChange, onUserUpdate }) => {
// Profile Data
const [weight, setWeight] = useState<string>('');
const [height, setHeight] = useState<string>('');
const [birthDate, setBirthDate] = useState<string>('');
const [gender, setGender] = useState<string>('MALE');
// Weight Tracker
const [weightHistory, setWeightHistory] = useState<BodyWeightRecord[]>([]);
const [todayWeight, setTodayWeight] = useState<string>('');
const [showWeightTracker, setShowWeightTracker] = useState(false);
// Admin: Create User
const [newUserEmail, setNewUserEmail] = useState('');
const [newUserPass, setNewUserPass] = useState('');
const [createMsg, setCreateMsg] = useState('');
// Admin: User List
const [showUserList, setShowUserList] = useState(false);
const [allUsers, setAllUsers] = useState<any[]>([]);
const [adminPassResetInput, setAdminPassResetInput] = useState<{ [key: string]: string }>({});
// Change Password
const [newPassword, setNewPassword] = useState('');
const [passMsg, setPassMsg] = useState('');
// Account Deletion
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// Exercise Management
const [showExercises, setShowExercises] = useState(false);
const [exercises, setExercises] = useState<ExerciseDef[]>([]);
const [showArchived, setShowArchived] = useState(false);
const [editingExercise, setEditingExercise] = useState<ExerciseDef | null>(null);
const [isCreatingEx, setIsCreatingEx] = useState(false);
const [exerciseNameFilter, setExerciseNameFilter] = useState('');
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),
};
useEffect(() => {
// Load profile data from user object (comes from /auth/me endpoint)
if (user.profile) {
if (user.profile.weight) setWeight(user.profile.weight.toString());
if (user.profile.height) setHeight(user.profile.height.toString());
if (user.profile.gender) setGender(user.profile.gender);
if (user.profile.birthDate) setBirthDate(new Date(user.profile.birthDate).toISOString().split('T')[0]);
}
if (user.role === 'ADMIN') {
refreshUserList();
}
refreshExercises();
refreshWeightHistory();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user.id, user.role, JSON.stringify(user.profile)]);
const refreshWeightHistory = async () => {
const history = await getWeightHistory();
setWeightHistory(history);
// Check if we have a weight for today
const today = new Date().toISOString().split('T')[0];
const todayRecord = history.find(r => r.dateStr === today);
if (todayRecord) {
setTodayWeight(todayRecord.weight.toString());
}
};
const refreshUserList = async () => {
const res = await getUsers();
if (res.success && res.users) {
setAllUsers(res.users);
}
};
const refreshExercises = async () => {
const exercises = await getExercises(user.id);
setExercises(exercises);
};
// Snackbar State
const [snackbar, setSnackbar] = useState<{ isOpen: boolean; message: string; type: 'success' | 'error' | 'info' }>({
isOpen: false,
message: '',
type: 'info'
});
const showSnackbar = (message: string, type: 'success' | 'error' | 'info' = 'info') => {
setSnackbar({ isOpen: true, message, type });
};
const handleLogWeight = async () => {
if (!todayWeight) return;
const weightVal = parseFloat(todayWeight);
if (isNaN(weightVal)) return;
const res = await logWeight(weightVal);
if (res) {
showSnackbar('Weight logged successfully', 'success');
refreshWeightHistory();
// Also update the profile weight display if it's today
setWeight(todayWeight);
// And trigger user update to sync across app
const userRes = await getMe();
if (userRes.success && userRes.user && onUserUpdate) {
onUserUpdate(userRes.user);
}
} else {
showSnackbar('Failed to log weight', 'error');
}
};
const handleSaveProfile = async () => {
const res = await updateUserProfile(user.id, {
weight: parseFloat(weight) || undefined,
height: parseFloat(height) || undefined,
gender: gender as any,
birthDate: birthDate ? new Date(birthDate).toISOString() : undefined,
language: lang
});
if (res.success) {
showSnackbar(t('profile_saved', lang) || 'Profile saved successfully', 'success');
// Refetch user data to update the profile in the app state
const userRes = await getMe();
if (userRes.success && userRes.user && onUserUpdate) {
onUserUpdate(userRes.user);
}
} else {
showSnackbar(res.error || 'Failed to save profile', 'error');
}
};
const handleChangePassword = async () => {
if (newPassword.length < 4) {
setPassMsg('Password too short');
return;
}
const res = await changePassword(user.id, newPassword);
if (res.success) {
setPassMsg('Password changed');
setNewPassword('');
} else {
setPassMsg(res.error || 'Error changing password');
}
};
const handleCreateUser = async () => {
const res = await createUser(newUserEmail, newUserPass);
if (res.success) {
setCreateMsg(`${t('user_created', lang)}: ${newUserEmail}`);
setNewUserEmail('');
setNewUserPass('');
refreshUserList();
} else {
setCreateMsg(res.error || 'Error');
}
};
const handleAdminDeleteUser = async (uid: string) => {
if (confirm(t('delete_confirm', lang))) {
await deleteUser(uid);
await refreshUserList();
}
};
const handleAdminBlockUser = async (uid: string, isBlocked: boolean) => {
await toggleBlockUser(uid, isBlocked);
await refreshUserList();
};
const handleAdminResetPass = (uid: string) => {
const pass = adminPassResetInput[uid];
if (pass && pass.length >= 4) {
adminResetPassword(uid, pass);
alert(t('pass_reset', lang));
setAdminPassResetInput({ ...adminPassResetInput, [uid]: '' });
}
};
const handleDeleteMyAccount = () => {
deleteUser(user.id);
onLogout();
};
// Exercise Management Handlers
const handleArchiveExercise = async (ex: ExerciseDef, archive: boolean) => {
const updated = { ...ex, isArchived: archive };
await saveExercise(user.id, updated);
await refreshExercises();
};
const handleSaveExerciseEdit = async () => {
if (editingExercise && editingExercise.name) {
await saveExercise(user.id, editingExercise);
setEditingExercise(null);
await refreshExercises();
}
};
const handleCreateExercise = async (newEx: ExerciseDef) => {
await saveExercise(user.id, newEx);
setIsCreatingEx(false);
await refreshExercises();
};
return (
<div className="h-full flex flex-col bg-surface">
<div className="p-4 bg-surface-container shadow-elevation-1 flex items-center justify-between z-10">
<h2 className="text-xl font-normal text-on-surface flex items-center gap-2">
<UserIcon size={20} />
{t('profile_title', lang)}
</h2>
<button onClick={onLogout} className="text-error flex items-center gap-1 text-sm font-medium hover:bg-error-container/10 px-3 py-1 rounded-full">
<LogOut size={16} /> {t('logout', lang)}
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6 pb-24">
{/* User Info Card */}
<div className="bg-surface-container rounded-xl p-4 border border-outline-variant/20">
<div className="flex items-center gap-4 mb-6">
<div className="w-14 h-14 rounded-full bg-primary-container text-on-primary-container flex items-center justify-center text-xl font-bold">
{user.email[0].toUpperCase()}
</div>
<div>
<div className="text-lg font-medium text-on-surface">{user.email}</div>
<div className="text-xs text-on-surface-variant bg-surface-container-high px-2 py-1 rounded w-fit mt-1 flex items-center gap-1">
{user.role === 'ADMIN' && <Shield size={10} />}
{user.role}
</div>
</div>
</div>
<h3 className="text-sm font-bold text-primary mb-4">{t('personal_data', lang)}</h3>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Scale size={10} /> {t('weight_kg', lang)}</label>
<input
type="number"
step="0.1"
value={weight}
onChange={(e) => setWeight(e.target.value)}
className="w-full bg-transparent text-on-surface focus:outline-none"
/>
</div>
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Ruler size={10} /> {t('height', lang)}</label>
<input type="number" value={height} onChange={(e) => setHeight(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none" />
</div>
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Calendar size={10} /> {t('birth_date', lang)}</label>
<input type="date" value={birthDate} onChange={(e) => setBirthDate(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none text-sm py-1" />
</div>
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><PersonStanding size={10} /> {t('gender', lang)}</label>
<select value={gender} onChange={(e) => setGender(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none text-sm py-1 bg-surface-container-high">
<option value="MALE">{t('male', lang)}</option>
<option value="FEMALE">{t('female', lang)}</option>
<option value="OTHER">{t('other', lang)}</option>
</select>
</div>
</div>
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2 mb-4">
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Globe size={10} /> {t('language', lang)}</label>
<select value={lang} onChange={(e) => onLanguageChange(e.target.value as Language)} className="w-full bg-transparent text-on-surface focus:outline-none text-sm py-1 bg-surface-container-high">
<option value="en">English</option>
<option value="ru">Русский</option>
</select>
</div>
<button onClick={handleSaveProfile} className="w-full py-2 rounded-full border border-outline text-primary text-sm font-medium hover:bg-primary-container/10 flex justify-center gap-2 items-center">
<Save size={16} /> {t('save_profile', lang)}
</button>
</div>
{/* WEIGHT TRACKER */}
<div className="bg-surface-container rounded-xl p-4 border border-outline-variant/20">
<button
onClick={() => setShowWeightTracker(!showWeightTracker)}
className="w-full flex justify-between items-center text-sm font-bold text-primary"
>
<span className="flex items-center gap-2"><Scale size={14} /> Weight Tracker</span>
{showWeightTracker ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
{showWeightTracker && (
<div className="mt-4 space-y-4">
<div className="flex gap-2 items-end">
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2 flex-1">
<label className="text-[10px] text-on-surface-variant font-medium">Today's Weight (kg)</label>
<input
type="number"
step="0.1"
value={todayWeight}
onChange={(e) => setTodayWeight(e.target.value)}
className="w-full bg-transparent text-on-surface focus:outline-none"
placeholder="Enter weight..."
/>
</div>
<button
onClick={handleLogWeight}
className="bg-primary text-on-primary px-4 py-3 rounded-lg font-medium text-sm mb-[1px]"
>
Log
</button>
</div>
<div className="space-y-2 max-h-60 overflow-y-auto">
<h4 className="text-xs font-medium text-on-surface-variant">History</h4>
{weightHistory.length === 0 ? (
<p className="text-xs text-on-surface-variant italic">No weight records yet.</p>
) : (
weightHistory.map(record => (
<div key={record.id} className="flex justify-between items-center p-3 bg-surface-container-high rounded-lg">
<span className="text-sm text-on-surface">{new Date(record.date).toLocaleDateString()}</span>
<span className="text-sm font-bold text-primary">{record.weight} kg</span>
</div>
))
)}
</div>
</div>
)}
</div>
{/* EXERCISE MANAGER */}
<div className="bg-surface-container rounded-xl p-4 border border-outline-variant/20">
<button
onClick={() => setShowExercises(!showExercises)}
className="w-full flex justify-between items-center text-sm font-bold text-primary"
>
<span className="flex items-center gap-2"><Dumbbell size={14} /> {t('manage_exercises', lang)}</span>
{showExercises ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
{showExercises && (
<div className="mt-4 space-y-4">
<button
onClick={() => setIsCreatingEx(true)}
className="w-full py-2 border border-outline border-dashed rounded-lg text-sm text-on-surface-variant hover:bg-surface-container-high flex items-center justify-center gap-2"
>
<Plus size={16} /> {t('create_exercise', lang)}
</button>
<FilledInput
label={t('filter_by_name', lang) || 'Filter by name'}
value={exerciseNameFilter}
onChange={(e: any) => setExerciseNameFilter(e.target.value)}
icon={<i className="hidden" />} // No icon needed or maybe use Search icon? Profile doesn't import Search. I'll omit icon if optional.
type="text"
autoFocus={false}
/>
<div className="flex items-center justify-end gap-2">
<label className="text-xs text-on-surface-variant">{t('show_archived', lang)}</label>
<input
type="checkbox"
checked={showArchived}
onChange={(e) => setShowArchived(e.target.checked)}
className="accent-primary"
/>
</div>
<div className="space-y-2">
{exercises
.filter(e => showArchived || !e.isArchived)
.filter(e => e.name.toLowerCase().includes(exerciseNameFilter.toLowerCase()))
.sort((a, b) => a.name.localeCompare(b.name))
.map(ex => (
<div key={ex.id} className={`p-3 rounded-lg flex justify-between items-center border border-outline-variant/20 ${ex.isArchived ? 'bg-surface-container-low opacity-60' : 'bg-surface-container-high'}`}>
<div className="overflow-hidden mr-2">
<div className="font-medium text-sm text-on-surface truncate">{ex.name}</div>
<div className="text-xs text-on-surface-variant">
{exerciseTypeLabels[ex.type]}
{ex.isUnilateral && `, ${t('unilateral', lang)}`}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => setEditingExercise(ex)} className="p-2 text-on-surface-variant hover:text-primary hover:bg-white/5 rounded-full">
<Pencil size={16} />
</button>
<button
onClick={() => handleArchiveExercise(ex, !ex.isArchived)}
className={`p-2 rounded-full hover:bg-white/5 ${ex.isArchived ? 'text-primary' : 'text-on-surface-variant'}`}
title={ex.isArchived ? t('unarchive', lang) : t('archive', lang)}
>
{ex.isArchived ? <ArchiveRestore size={16} /> : <Archive size={16} />}
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Change Password */}
<div className="bg-surface-container rounded-xl p-4 border border-outline-variant/20">
<h3 className="text-sm font-bold text-primary mb-4 flex items-center gap-2"><Lock size={14} /> {t('change_pass_btn', lang)}</h3>
<div className="flex gap-2">
<input
type="password"
placeholder={t('change_pass_new', lang)}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="flex-1 bg-surface-container-high border-b border-outline-variant px-3 py-2 text-on-surface focus:outline-none rounded-t-lg"
/>
<button onClick={handleChangePassword} className="bg-secondary-container text-on-secondary-container px-4 rounded-lg font-medium text-sm">OK</button>
</div>
{passMsg && <p className="text-xs text-primary mt-2">{passMsg}</p>}
</div>
{/* User Self Deletion (Not for Admin) */}
{user.role !== 'ADMIN' && (
<div className="bg-surface-container rounded-xl p-4 border border-error/30">
<h3 className="text-sm font-bold text-error mb-2 flex items-center gap-2"><Trash2 size={14} /> {t('delete_account', lang)}</h3>
{!showDeleteConfirm ? (
<button onClick={() => setShowDeleteConfirm(true)} className="text-error text-sm hover:underline">
{t('delete', lang)}
</button>
) : (
<div className="space-y-2">
<p className="text-xs text-error">{t('delete_account_confirm', lang)}</p>
<div className="flex gap-2">
<button onClick={() => setShowDeleteConfirm(false)} className="text-xs px-3 py-1 bg-surface-container-high rounded-full">{t('cancel', lang)}</button>
<button onClick={handleDeleteMyAccount} className="text-xs px-3 py-1 bg-error text-on-error rounded-full">{t('delete', lang)}</button>
</div>
</div>
)}
</div>
)}
{/* ADMIN AREA */}
{user.role === 'ADMIN' && (
<div className="bg-surface-container rounded-xl p-4 border border-primary/30 relative overflow-hidden">
<div className="absolute top-0 right-0 p-2 bg-primary/10 rounded-bl-xl">
<Shield size={16} className="text-primary" />
</div>
<h3 className="text-sm font-bold text-primary mb-4 flex items-center gap-2"><UserPlus size={14} /> {t('admin_area', lang)}</h3>
{/* Create User */}
<div className="space-y-3 mb-6">
<h4 className="text-xs font-medium text-on-surface-variant">{t('create_user', lang)}</h4>
<FilledInput
label="Email"
value={newUserEmail}
onChange={(e) => setNewUserEmail(e.target.value)}
type="email"
/>
<FilledInput
label={t('login_password', lang)}
value={newUserPass}
onChange={(e) => setNewUserPass(e.target.value)}
type="text"
/>
<button onClick={handleCreateUser} className="w-full py-2 bg-primary text-on-primary rounded-full text-sm font-medium">
{t('create_btn', lang)}
</button>
{createMsg && <p className="text-xs text-error text-center font-medium">{createMsg}</p>}
</div>
{/* User List */}
<div className="border-t border-outline-variant pt-4">
<button
onClick={() => setShowUserList(!showUserList)}
className="w-full flex justify-between items-center text-sm font-medium text-on-surface"
>
<span>{t('admin_users_list', lang)} ({allUsers.length})</span>
{showUserList ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
{showUserList && (
<div className="mt-4 space-y-4">
{allUsers.map(u => (
<div key={u.id} className="bg-surface-container-high p-3 rounded-lg space-y-3">
<div className="flex justify-between items-center">
<div className="overflow-hidden">
<div className="font-medium text-sm text-on-surface truncate">{u.email}</div>
<div className="text-xs text-on-surface-variant flex gap-2">
<span>{u.role}</span>
{u.isBlocked && <span className="text-error font-bold flex items-center gap-1"><Ban size={10} /> {t('block', lang)}</span>}
</div>
</div>
<div className="flex gap-1">
{u.role !== 'ADMIN' && (
<>
<button
onClick={() => handleAdminBlockUser(u.id, !u.isBlocked)}
className={`p-2 rounded-full ${u.isBlocked ? 'bg-primary/20 text-primary' : 'text-on-surface-variant hover:bg-white/10'}`}
title={u.isBlocked ? t('unblock', lang) : t('block', lang)}
>
<Ban size={16} />
</button>
<button
onClick={() => handleAdminDeleteUser(u.id)}
className="p-2 text-on-surface-variant hover:text-error hover:bg-error/10 rounded-full"
title={t('delete', lang)}
>
<Trash2 size={16} />
</button>
</>
)}
</div>
</div>
{u.role !== 'ADMIN' && (
<div className="flex gap-2 items-center">
<div className="flex-1 flex items-center bg-surface-container rounded px-2 border border-outline-variant/20">
<KeyRound size={12} className="text-on-surface-variant mr-2" />
<input
type="text"
placeholder={t('change_pass_new', lang)}
className="bg-transparent text-xs py-2 w-full focus:outline-none text-on-surface"
value={adminPassResetInput[u.id] || ''}
onChange={(e) => setAdminPassResetInput({ ...adminPassResetInput, [u.id]: e.target.value })}
/>
</div>
<button
onClick={() => handleAdminResetPass(u.id)}
className="text-xs bg-secondary-container text-on-secondary-container px-3 py-2 rounded font-medium"
>
{t('reset_pass', lang)}
</button>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Edit Exercise Modal */}
{editingExercise && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-surface-container w-full max-w-sm rounded-[28px] p-6 shadow-elevation-3">
<h3 className="text-xl font-normal text-on-surface mb-4">{t('edit', lang)}</h3>
<div className="space-y-4">
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
<label className="text-[10px] text-on-surface-variant font-medium">{t('ex_name', lang)}</label>
<input
value={editingExercise.name}
onChange={(e) => setEditingExercise({ ...editingExercise, name: e.target.value })}
className="w-full bg-transparent text-on-surface focus:outline-none"
/>
</div>
{editingExercise.type === ExerciseType.BODYWEIGHT && (
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
<label className="text-[10px] text-on-surface-variant font-medium">{t('body_weight_percent', lang)}</label>
<input
type="number"
value={editingExercise.bodyWeightPercentage || 100}
onChange={(e) => setEditingExercise({ ...editingExercise, bodyWeightPercentage: parseFloat(e.target.value) })}
className="w-full bg-transparent text-on-surface focus:outline-none"
/>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => setEditingExercise(null)} className="px-4 py-2 rounded-full text-primary font-medium hover:bg-white/5">{t('cancel', lang)}</button>
<button onClick={handleSaveExerciseEdit} className="px-4 py-2 rounded-full bg-primary text-on-primary font-medium">{t('save', lang)}</button>
</div>
</div>
</div>
</div>
)}
{/* Create Exercise Modal */}
{isCreatingEx && (
<ExerciseModal
isOpen={isCreatingEx}
onClose={() => setIsCreatingEx(false)}
onSave={handleCreateExercise}
lang={lang}
existingExercises={exercises}
/>
)}
</div>
<Snackbar
isOpen={snackbar.isOpen}
message={snackbar.message}
type={snackbar.type}
onClose={() => setSnackbar(prev => ({ ...prev, isOpen: false }))}
/>
</div>
);
};
export default Profile;

View File

@@ -0,0 +1,49 @@
import React, { useEffect } from 'react';
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react';
export type SnackbarType = 'success' | 'error' | 'info';
interface SnackbarProps {
message: string;
type?: SnackbarType;
isOpen: boolean;
onClose: () => void;
duration?: number;
}
const Snackbar: React.FC<SnackbarProps> = ({ message, type = 'info', isOpen, onClose, duration = 3000 }) => {
useEffect(() => {
if (isOpen && duration > 0) {
const timer = setTimeout(() => {
onClose();
}, duration);
return () => clearTimeout(timer);
}
}, [isOpen, duration, onClose]);
if (!isOpen) return null;
const bgColors = {
success: 'bg-primary-container text-on-primary-container',
error: 'bg-error-container text-on-error-container',
info: 'bg-secondary-container text-on-secondary-container'
};
const icons = {
success: <CheckCircle size={20} />,
error: <AlertCircle size={20} />,
info: <Info size={20} />
};
return (
<div className={`fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50 flex items-center gap-3 px-4 py-3 rounded-lg shadow-elevation-3 ${bgColors[type]} min-w-[300px] animate-in fade-in slide-in-from-bottom-4 duration-300`}>
<div className="shrink-0">{icons[type]}</div>
<p className="flex-1 text-sm font-medium">{message}</p>
<button onClick={onClose} className="p-1 hover:bg-black/10 rounded-full transition-colors">
<X size={16} />
</button>
</div>
);
};
export default Snackbar;

144
src/components/Stats.tsx Normal file
View File

@@ -0,0 +1,144 @@
import React, { useMemo, useState, useEffect } from 'react';
import { WorkoutSession, ExerciseType, Language, BodyWeightRecord } from '../types';
import { getWeightHistory } from '../services/weight';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts';
import { t } from '../services/i18n';
interface StatsProps {
sessions: WorkoutSession[];
lang: Language;
}
const Stats: React.FC<StatsProps> = ({ sessions, lang }) => {
const [weightRecords, setWeightRecords] = useState<BodyWeightRecord[]>([]);
useEffect(() => {
const fetchWeights = async () => {
const records = await getWeightHistory();
setWeightRecords(records);
};
fetchWeights();
}, []);
const volumeData = useMemo(() => {
const data = [...sessions].reverse().map(session => {
const sessionWeight = session.userBodyWeight || 70;
const work = session.sets.reduce((acc, set) => {
let setWork = 0;
const reps = set.reps || 0;
const weight = set.weight || 0;
if (set.type === ExerciseType.STRENGTH) {
setWork = weight * reps;
} else if (set.type === ExerciseType.BODYWEIGHT) {
const percentage = set.bodyWeightPercentage || 100;
const effectiveBw = sessionWeight * (percentage / 100);
setWork = (effectiveBw + weight) * reps;
} else if (set.type === ExerciseType.STATIC) {
setWork = 0;
}
return acc + Math.max(0, setWork);
}, 0);
return {
date: new Date(session.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'short' }),
work: Math.round(work)
};
}).filter(d => d.work > 0);
return data;
}, [sessions, lang]);
const setsData = useMemo(() => {
return [...sessions].reverse().map(session => ({
date: new Date(session.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'short' }),
sets: session.sets.length
}));
}, [sessions, lang]);
const weightData = useMemo(() => {
return [...weightRecords].reverse().map(record => ({
date: new Date(record.date).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'short' }),
weight: record.weight
}));
}, [weightRecords, lang]);
if (sessions.length < 2) {
return (
<div className="p-8 text-center text-on-surface-variant flex flex-col items-center justify-center h-full">
<p>{t('not_enough_data', lang)}</p>
</div>
)
}
return (
<div className="h-full overflow-y-auto p-4 space-y-6 pb-24 bg-surface">
<h2 className="text-3xl font-normal text-on-surface mb-2 pl-2">{t('progress', lang)}</h2>
{/* Volume Chart */}
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
<div className="flex justify-between items-end mb-6">
<div>
<h3 className="text-title-medium font-medium text-on-surface">{t('volume_title', lang)}</h3>
<p className="text-xs text-on-surface-variant mt-1">{t('volume_subtitle', lang)}</p>
</div>
</div>
<div className="h-64 min-h-64 w-full">
<ResponsiveContainer width="100%" height={256}>
<LineChart data={volumeData}>
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
<YAxis stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} tickFormatter={(val) => `${(val / 1000).toFixed(1)}k`} />
<Tooltip
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
itemStyle={{ color: '#D0BCFF' }}
formatter={(val: number) => [`${val.toLocaleString()} kg`, t('volume_title', lang)]}
/>
<Line type="monotone" dataKey="work" stroke="#D0BCFF" strokeWidth={3} dot={{ r: 4, fill: '#D0BCFF' }} activeDot={{ r: 6 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
{/* Sets Chart */}
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
<h3 className="text-title-medium font-medium text-on-surface mb-6">{t('sets_title', lang)}</h3>
<div className="h-64 min-h-64 w-full">
<ResponsiveContainer width="100%" height={256}>
<BarChart data={setsData}>
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
<YAxis stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
cursor={{ fill: 'rgba(255,255,255,0.05)' }}
/>
<Bar dataKey="sets" fill="#CCC2DC" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Body Weight Chart */}
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
<h3 className="text-title-medium font-medium text-on-surface mb-6">{t('weight_title', lang)}</h3>
<div className="h-64 min-h-64 w-full">
<ResponsiveContainer width="100%" height={256}>
<LineChart data={weightData}>
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
<YAxis domain={['auto', 'auto']} stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
itemStyle={{ color: '#6EE7B7' }}
formatter={(val: number) => [`${val} kg`, t('weight_kg', lang)]}
/>
<Line type="monotone" dataKey="weight" stroke="#6EE7B7" strokeWidth={3} dot={{ r: 4, fill: '#6EE7B7' }} activeDot={{ r: 6 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
};
export default Stats;

View File

@@ -0,0 +1,383 @@
import React from 'react';
import { MoreVertical, X, CheckSquare, ChevronUp, ChevronDown, Scale, Dumbbell, Plus, Activity, Timer as TimerIcon, ArrowRight, ArrowUp, CheckCircle, Edit, Trash2 } from 'lucide-react';
import { ExerciseType, Language, WorkoutSet } from '../../types';
import { t } from '../../services/i18n';
import FilledInput from '../FilledInput';
import ExerciseModal from '../ExerciseModal';
import { useTracker } from './useTracker';
import SetLogger from './SetLogger';
interface ActiveSessionViewProps {
tracker: ReturnType<typeof useTracker>;
activeSession: any; // Using any to avoid strict type issues with the complex session object for now, but ideally should be WorkoutSession
lang: Language;
onSessionEnd: () => void;
onSessionQuit: () => void;
onRemoveSet: (setId: string) => void;
}
const ActiveSessionView: React.FC<ActiveSessionViewProps> = ({ tracker, activeSession, lang, onSessionEnd, onSessionQuit, onRemoveSet }) => {
const {
elapsedTime,
showFinishConfirm,
setShowFinishConfirm,
showQuitConfirm,
setShowQuitConfirm,
showMenu,
setShowMenu,
activePlan, // This comes from useTracker props but we might need to pass it explicitly if not in hook return
currentStepIndex,
showPlanList,
setShowPlanList,
jumpToStep,
searchQuery,
setSearchQuery,
setShowSuggestions,
showSuggestions,
filteredExercises,
setSelectedExercise,
selectedExercise,
weight,
setWeight,
reps,
setReps,
duration,
setDuration,
distance,
setDistance,
height,
setHeight,
handleAddSet,
editingSetId,
editWeight,
setEditWeight,
editReps,
setEditReps,
editDuration,
setEditDuration,
editDistance,
setEditDistance,
editHeight,
setEditHeight,
handleCancelEdit,
handleSaveEdit,
handleEditSet,
isCreating,
setIsCreating,
handleCreateExercise,
exercises
} = tracker;
const isPlanFinished = activePlan && currentStepIndex >= activePlan.steps.length;
return (
<div className="flex flex-col h-full max-h-full overflow-hidden relative bg-surface">
<div className="px-4 py-3 bg-surface-container shadow-elevation-1 z-20 flex justify-between items-center">
<div className="flex flex-col">
<h2 className="text-title-medium text-on-surface flex items-center gap-2 font-medium">
<span className="w-2 h-2 rounded-full bg-error animate-pulse" />
{activePlan ? activePlan.name : t('free_workout', lang)}
</h2>
<span className="text-xs text-on-surface-variant font-mono mt-0.5 flex items-center gap-2">
<span className="bg-surface-container-high px-1.5 py-0.5 rounded text-on-surface font-bold">{elapsedTime}</span>
{activeSession.userBodyWeight ? `${activeSession.userBodyWeight}kg` : ''}
</span>
</div>
<div className="flex items-center gap-2 relative">
<button
onClick={() => setShowFinishConfirm(true)}
className="px-5 py-2 rounded-full bg-error-container text-on-error-container text-sm font-medium hover:opacity-90 transition-opacity"
>
{t('finish', lang)}
</button>
<button
onClick={() => setShowMenu(!showMenu)}
className="p-2 rounded-full bg-surface-container-high text-on-surface hover:bg-surface-container-highest transition-colors"
>
<MoreVertical size={20} />
</button>
{showMenu && (
<>
<div
className="fixed inset-0 z-30"
onClick={() => setShowMenu(false)}
/>
<div className="absolute right-0 top-full mt-2 bg-surface-container rounded-xl shadow-elevation-3 overflow-hidden z-40 min-w-[200px]">
<button
onClick={() => {
setShowMenu(false);
setShowQuitConfirm(true);
}}
className="w-full px-4 py-3 text-left text-error hover:bg-error-container/20 transition-colors flex items-center gap-2"
>
<X size={18} />
{t('quit_no_save', lang)}
</button>
</div>
</>
)}
</div>
</div>
{activePlan && (
<div className="bg-surface-container-low border-b border-outline-variant">
<button
onClick={() => setShowPlanList(!showPlanList)}
className="w-full px-4 py-3 flex justify-between items-center"
>
<div className="flex flex-col items-start">
{isPlanFinished ? (
<div className="flex items-center gap-2 text-primary">
<CheckSquare size={18} />
<span className="font-bold">{t('plan_completed', lang)}</span>
</div>
) : (
<>
<span className="text-[10px] text-primary font-medium tracking-wider">{t('step', lang)} {currentStepIndex + 1} {t('of', lang)} {activePlan.steps.length}</span>
<div className="font-medium text-on-surface flex items-center gap-2">
{activePlan.steps[currentStepIndex].exerciseName}
{activePlan.steps[currentStepIndex].isWeighted && <Scale size={12} className="text-primary" />}
</div>
</>
)}
</div>
{showPlanList ? <ChevronUp size={20} className="text-on-surface-variant" /> : <ChevronDown size={20} className="text-on-surface-variant" />}
</button>
{showPlanList && (
<div className="max-h-48 overflow-y-auto bg-surface-container-high p-2 space-y-1 animate-in slide-in-from-top-2">
{activePlan.steps.map((step, idx) => (
<button
key={step.id}
onClick={() => jumpToStep(idx)}
className={`w-full text-left px-4 py-3 rounded-full text-sm flex items-center justify-between transition-colors ${idx === currentStepIndex
? 'bg-primary-container text-on-primary-container font-medium'
: idx < currentStepIndex
? 'text-on-surface-variant opacity-50'
: 'text-on-surface hover:bg-white/5'
}`}
>
<span>{idx + 1}. {step.exerciseName}</span>
{step.isWeighted && <Scale size={14} />}
</button>
))}
</div>
)}
</div>
)}
<div className="flex-1 overflow-y-auto p-4 pb-32 space-y-6">
<SetLogger
tracker={tracker}
lang={lang}
onLogSet={handleAddSet}
/>
{activeSession.sets.length > 0 && (
<div className="pt-4">
<h3 className="text-sm text-primary font-medium px-2 mb-3 tracking-wide">{t('history_section', lang)}</h3>
<div className="flex flex-col gap-2">
{[...activeSession.sets].reverse().map((set: WorkoutSet, idx: number) => {
const setNumber = activeSession.sets.length - idx;
const isEditing = editingSetId === set.id;
return (
<div key={set.id} className="flex justify-between items-center p-4 bg-surface-container rounded-xl shadow-elevation-1 animate-in fade-in slide-in-from-bottom-2">
<div className="flex items-center gap-4 flex-1">
<div className="w-8 h-8 rounded-full bg-secondary-container text-on-secondary-container flex items-center justify-center text-xs font-bold">
{setNumber}
</div>
{isEditing ? (
<div className="flex-1">
<div className="text-base font-medium text-on-surface mb-2">{set.exerciseName}{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase() as any, lang)}</span>}</div>
<div className="grid grid-cols-2 gap-2">
{set.weight !== undefined && (
<input
type="number"
step="0.1"
value={editWeight}
onChange={(e) => setEditWeight(e.target.value)}
className="px-2 py-1 bg-surface-container-high rounded text-sm text-on-surface border border-outline-variant focus:border-primary focus:outline-none"
placeholder="Weight (kg)"
/>
)}
{set.reps !== undefined && (
<input
type="number"
value={editReps}
onChange={(e) => setEditReps(e.target.value)}
className="px-2 py-1 bg-surface-container-high rounded text-sm text-on-surface border border-outline-variant focus:border-primary focus:outline-none"
placeholder="Reps"
/>
)}
{set.durationSeconds !== undefined && (
<input
type="number"
value={editDuration}
onChange={(e) => setEditDuration(e.target.value)}
className="px-2 py-1 bg-surface-container-high rounded text-sm text-on-surface border border-outline-variant focus:border-primary focus:outline-none"
placeholder="Duration (s)"
/>
)}
{set.distanceMeters !== undefined && (
<input
type="number"
step="0.1"
value={editDistance}
onChange={(e) => setEditDistance(e.target.value)}
className="px-2 py-1 bg-surface-container-high rounded text-sm text-on-surface border border-outline-variant focus:border-primary focus:outline-none"
placeholder="Distance (m)"
/>
)}
{set.height !== undefined && (
<input
type="number"
step="0.1"
value={editHeight}
onChange={(e) => setEditHeight(e.target.value)}
className="px-2 py-1 bg-surface-container-high rounded text-sm text-on-surface border border-outline-variant focus:border-primary focus:outline-none"
placeholder="Height (cm)"
/>
)}
</div>
</div>
) : (
<div>
<div className="text-base font-medium text-on-surface">{set.exerciseName}{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase() as any, lang)}</span>}</div>
<div className="text-sm text-on-surface-variant">
{set.type === ExerciseType.STRENGTH &&
`${set.weight ? `${set.weight}kg` : ''} ${set.reps ? `x ${set.reps}` : ''}`.trim()
}
{set.type === ExerciseType.BODYWEIGHT &&
`${set.weight ? `${set.weight}kg` : ''} ${set.reps ? `x ${set.reps}` : ''}`.trim()
}
{set.type === ExerciseType.CARDIO &&
`${set.durationSeconds ? `${set.durationSeconds}s` : ''} ${set.distanceMeters ? `/ ${set.distanceMeters}m` : ''}`.trim()
}
{set.type === ExerciseType.STATIC &&
`${set.durationSeconds ? `${set.durationSeconds}s` : ''}`.trim()
}
{set.type === ExerciseType.HIGH_JUMP &&
`${set.height ? `${set.height}cm` : ''}`.trim()
}
{set.type === ExerciseType.LONG_JUMP &&
`${set.distanceMeters ? `${set.distanceMeters}m` : ''}`.trim()
}
{set.type === ExerciseType.PLYOMETRIC &&
`${set.reps ? `x ${set.reps}` : ''}`.trim()
}
</div>
</div>
)}
</div>
<div className="flex items-center gap-2">
{isEditing ? (
<>
<button
onClick={handleCancelEdit}
className="p-2 text-on-surface-variant hover:text-on-surface hover:bg-surface-container-high rounded-full transition-colors"
>
<X size={20} />
</button>
<button
onClick={() => handleSaveEdit(set)}
className="p-2 text-primary hover:bg-primary-container/20 rounded-full transition-colors"
>
<CheckCircle size={20} />
</button>
</>
) : (
<>
<button
onClick={() => handleEditSet(set)}
className="p-2 text-on-surface-variant hover:text-primary hover:bg-primary-container/20 rounded-full transition-colors"
>
<Edit size={20} />
</button>
<button
onClick={() => onRemoveSet(set.id)}
className="p-2 text-on-surface-variant hover:text-error hover:bg-error-container/10 rounded-full transition-colors"
>
<Trash2 size={20} />
</button>
</>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
{isCreating && (
<ExerciseModal
isOpen={isCreating}
onClose={() => setIsCreating(false)}
onSave={handleCreateExercise}
lang={lang}
existingExercises={exercises}
/>
)}
{/* Finish Confirmation Dialog */}
{showFinishConfirm && (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-6 backdrop-blur-sm">
<div className="w-full max-w-sm bg-surface-container rounded-[28px] p-6 shadow-elevation-3">
<h3 className="text-2xl font-normal text-on-surface mb-2">{t('finish_confirm_title', lang)}</h3>
<p className="text-on-surface-variant text-sm mb-8">{t('finish_confirm_msg', lang)}</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowFinishConfirm(false)}
className="px-6 py-2.5 rounded-full text-primary font-medium hover:bg-white/5"
>
{t('cancel', lang)}
</button>
<button
onClick={() => {
setShowFinishConfirm(false);
onSessionEnd();
}}
className="px-6 py-2.5 rounded-full bg-green-600 text-white font-medium hover:bg-green-700"
>
{t('confirm', lang)}
</button>
</div>
</div>
</div>
)}
{/* Quit Without Saving Confirmation Dialog */}
{showQuitConfirm && (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-6 backdrop-blur-sm">
<div className="w-full max-w-sm bg-surface-container rounded-[28px] p-6 shadow-elevation-3">
<h3 className="text-2xl font-normal text-error mb-2">{t('quit_confirm_title', lang)}</h3>
<p className="text-on-surface-variant text-sm mb-8">{t('quit_confirm_msg', lang)}</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowQuitConfirm(false)}
className="px-6 py-2.5 rounded-full text-primary font-medium hover:bg-white/5"
>
{t('cancel', lang)}
</button>
<button
onClick={() => {
setShowQuitConfirm(false);
onSessionQuit();
}}
className="px-6 py-2.5 rounded-full bg-green-600 text-white font-medium hover:bg-green-700"
>
{t('confirm', lang)}
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default ActiveSessionView;

View File

@@ -0,0 +1,115 @@
import React from 'react';
import { Dumbbell, User, PlayCircle, Plus, ArrowRight } from 'lucide-react';
import { Language } from '../../types';
import { t } from '../../services/i18n';
import { useTracker } from './useTracker';
interface IdleViewProps {
tracker: ReturnType<typeof useTracker>;
lang: Language;
}
const IdleView: React.FC<IdleViewProps> = ({ tracker, lang }) => {
const {
userBodyWeight,
setUserBodyWeight,
handleStart,
setIsSporadicMode,
plans,
showPlanPrep,
setShowPlanPrep,
confirmPlanStart
} = tracker;
return (
<div className="flex flex-col h-full p-4 md:p-8 overflow-y-auto relative">
<div className="flex-1 flex flex-col items-center justify-center space-y-12">
<div className="flex flex-col items-center gap-4">
<div className="w-24 h-24 rounded-full bg-surface-container-high flex items-center justify-center text-primary shadow-elevation-1">
<Dumbbell size={40} />
</div>
<div className="text-center">
<h1 className="text-3xl font-normal text-on-surface">{t('ready_title', lang)}</h1>
<p className="text-on-surface-variant text-sm">{t('ready_subtitle', lang)}</p>
</div>
</div>
<div className="w-full max-w-sm bg-surface-container rounded-2xl p-6 flex flex-col items-center gap-4 shadow-elevation-1">
<label className="text-xs text-on-surface-variant font-bold tracking-wide flex items-center gap-2">
<User size={14} />
{t('my_weight', lang)}
</label>
<div className="flex items-center gap-4">
<input
type="number"
step="0.1"
className="text-5xl font-normal text-on-surface tabular-nums bg-transparent text-center w-full focus:outline-none"
value={userBodyWeight}
onChange={(e) => setUserBodyWeight(e.target.value)}
/>
</div>
<p className="text-[10px] text-on-surface-variant">{t('change_in_profile', lang)}</p>
</div>
<div className="w-full max-w-xs space-y-3">
<button
onClick={() => handleStart()}
className="w-full h-16 rounded-full bg-primary text-on-primary font-medium text-lg shadow-elevation-2 hover:shadow-elevation-3 active:shadow-elevation-1 transition-all flex items-center justify-center gap-2"
>
<PlayCircle size={24} />
{t('free_workout', lang)}
</button>
<button
onClick={() => setIsSporadicMode(true)}
className="w-full h-12 rounded-full bg-surface-container-high text-on-surface font-medium text-base hover:bg-surface-container-highest transition-all flex items-center justify-center gap-2"
>
<Plus size={20} />
{t('quick_log', lang)}
</button>
</div>
{plans.length > 0 && (
<div className="w-full max-w-md mt-8">
<h3 className="text-sm text-on-surface-variant font-medium px-4 mb-3">{t('or_choose_plan', lang)}</h3>
<div className="grid grid-cols-1 gap-3">
{plans.map(plan => (
<button
key={plan.id}
onClick={() => handleStart(plan)}
className="flex items-center justify-between p-4 bg-surface-container rounded-xl hover:bg-surface-container-high transition-colors border border-outline-variant/20"
>
<div className="text-left">
<div className="text-base font-medium text-on-surface">{plan.name}</div>
<div className="text-xs text-on-surface-variant">{plan.steps.length} {t('exercises_count', lang)}</div>
</div>
<div className="w-10 h-10 rounded-full bg-secondary-container text-on-secondary-container flex items-center justify-center">
<ArrowRight size={20} />
</div>
</button>
))}
</div>
</div>
)}
</div>
{showPlanPrep && (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-6 backdrop-blur-sm">
<div className="w-full max-w-sm bg-surface-container rounded-[28px] p-6 shadow-elevation-3">
<h3 className="text-2xl font-normal text-on-surface mb-4">{showPlanPrep.name}</h3>
<div className="bg-surface-container-high p-4 rounded-xl text-on-surface-variant text-sm mb-8">
<div className="text-xs font-bold text-primary mb-2">{t('prep_title', lang)}</div>
{showPlanPrep.description || t('prep_no_instructions', lang)}
</div>
<div className="flex justify-end gap-2">
<button onClick={() => setShowPlanPrep(null)} className="px-6 py-2.5 rounded-full text-primary font-medium hover:bg-white/5">{t('cancel', lang)}</button>
<button onClick={confirmPlanStart} className="px-6 py-2.5 rounded-full bg-primary text-on-primary font-medium">{t('start', lang)}</button>
</div>
</div>
</div>
)}
</div>
);
};
export default IdleView;

View File

@@ -0,0 +1,181 @@
import React from 'react';
import { Dumbbell, Scale, Activity, Timer as TimerIcon, ArrowRight, ArrowUp, Plus, CheckCircle } from 'lucide-react';
import { ExerciseType, Language } from '../../types';
import { t } from '../../services/i18n';
import FilledInput from '../FilledInput';
import { useTracker } from './useTracker';
interface SetLoggerProps {
tracker: ReturnType<typeof useTracker>;
lang: Language;
onLogSet: () => void;
isSporadic?: boolean;
}
const SetLogger: React.FC<SetLoggerProps> = ({ tracker, lang, onLogSet, isSporadic = false }) => {
const {
searchQuery,
setSearchQuery,
setShowSuggestions,
showSuggestions,
filteredExercises,
setSelectedExercise,
selectedExercise,
weight,
setWeight,
reps,
setReps,
duration,
setDuration,
distance,
setDistance,
height,
setHeight,
setIsCreating,
sporadicSuccess,
activePlan,
currentStepIndex,
unilateralSide,
setUnilateralSide
} = tracker;
const isPlanFinished = activePlan && currentStepIndex >= activePlan.steps.length;
return (
<div className="space-y-6">
{/* Exercise Selection */}
<div className="relative">
<FilledInput
label={t('select_exercise', lang)}
value={searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
setShowSuggestions(true);
}}
onFocus={() => {
setSearchQuery('');
setShowSuggestions(true);
}}
onBlur={() => setTimeout(() => setShowSuggestions(false), 100)}
icon={<Dumbbell size={10} />}
autoComplete="off"
type="text"
rightElement={
<button
onClick={() => setIsCreating(true)}
className="p-2 text-primary hover:bg-primary-container/20 rounded-full"
>
<Plus size={24} />
</button>
}
/>
{showSuggestions && (
<div className="absolute top-full left-0 w-full bg-surface-container rounded-xl shadow-elevation-3 overflow-hidden z-20 mt-1 max-h-60 overflow-y-auto animate-in fade-in slide-in-from-top-2">
{filteredExercises.length > 0 ? (
filteredExercises.map(ex => (
<button
key={ex.id}
onMouseDown={(e) => {
e.preventDefault();
setSelectedExercise(ex);
setSearchQuery(ex.name);
setShowSuggestions(false);
}}
className="w-full text-left px-4 py-3 text-on-surface hover:bg-surface-container-high transition-colors text-lg"
>
{ex.name}
</button>
))
) : (
<div className="px-4 py-3 text-on-surface-variant text-lg">{t('no_exercises_found', lang)}</div>
)}
</div>
)}
</div>
{selectedExercise && (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-300 space-y-6">
{/* Unilateral Exercise Toggle */}
{selectedExercise.isUnilateral && (
<div className="flex items-center gap-2 bg-surface-container rounded-full p-1">
<button
onClick={() => setUnilateralSide('LEFT')}
className={`w-full text-center px-4 py-2 rounded-full text-sm font-medium transition-colors ${unilateralSide === 'LEFT' ? 'bg-primary-container text-on-primary-container' : 'text-on-surface-variant hover:bg-surface-container-high'
}`}
>
{t('left', lang)}
</button>
<button
onClick={() => setUnilateralSide('RIGHT')}
className={`w-full text-center px-4 py-2 rounded-full text-sm font-medium transition-colors ${unilateralSide === 'RIGHT' ? 'bg-secondary-container text-on-secondary-container' : 'text-on-surface-variant hover:bg-surface-container-high'
}`}
>
{t('right', lang)}
</button>
</div>
)}
{/* Input Forms */}
<div className="grid grid-cols-2 gap-4">
{(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.STATIC) && (
<FilledInput
label={selectedExercise.type === ExerciseType.BODYWEIGHT ? t('add_weight', lang) : t('weight_kg', lang)}
value={weight}
step="0.1"
onChange={(e: any) => setWeight(e.target.value)}
icon={<Scale size={10} />}
autoFocus={!isSporadic && activePlan && !isPlanFinished && activePlan.steps[currentStepIndex]?.isWeighted && (selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.STRENGTH)}
/>
)}
{(selectedExercise.type === ExerciseType.STRENGTH || selectedExercise.type === ExerciseType.BODYWEIGHT || selectedExercise.type === ExerciseType.PLYOMETRIC) && (
<FilledInput
label={t('reps', lang)}
value={reps}
onChange={(e: any) => setReps(e.target.value)}
icon={<Activity size={10} />}
type="number"
/>
)}
{(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.STATIC) && (
<FilledInput
label={t('time_sec', lang)}
value={duration}
onChange={(e: any) => setDuration(e.target.value)}
icon={<TimerIcon size={10} />}
/>
)}
{(selectedExercise.type === ExerciseType.CARDIO || selectedExercise.type === ExerciseType.LONG_JUMP) && (
<FilledInput
label={t('dist_m', lang)}
value={distance}
onChange={(e: any) => setDistance(e.target.value)}
icon={<ArrowRight size={10} />}
/>
)}
{(selectedExercise.type === ExerciseType.HIGH_JUMP) && (
<FilledInput
label={t('height_cm', lang)}
value={height}
onChange={(e: any) => setHeight(e.target.value)}
icon={<ArrowUp size={10} />}
/>
)}
</div>
<button
onClick={onLogSet}
className={`w-full h-14 font-medium text-lg rounded-full shadow-elevation-2 hover:shadow-elevation-3 active:scale-[0.98] transition-all flex items-center justify-center gap-2 ${isSporadic && sporadicSuccess
? 'bg-green-500 text-white'
: 'bg-primary-container text-on-primary-container'
}`}
>
{isSporadic && sporadicSuccess ? <CheckCircle size={24} /> : (isSporadic ? <Plus size={24} /> : <CheckCircle size={24} />)}
<span>{isSporadic && sporadicSuccess ? t('saved', lang) : t('log_set', lang)}</span>
</button>
</div>
)}
</div>
);
};
export default SetLogger;

View File

@@ -0,0 +1,286 @@
import React, { useState, useEffect } from 'react';
import { CheckCircle, Plus, Pencil, Trash2, X, Save } from 'lucide-react';
import { Language, WorkoutSet } from '../../types';
import { t } from '../../services/i18n';
import ExerciseModal from '../ExerciseModal';
import { useTracker } from './useTracker';
import SetLogger from './SetLogger';
interface SporadicViewProps {
tracker: ReturnType<typeof useTracker>;
lang: Language;
}
const SporadicView: React.FC<SporadicViewProps> = ({ tracker, lang }) => {
const {
handleLogSporadicSet,
setIsSporadicMode,
isCreating,
setIsCreating,
handleCreateExercise,
exercises,
resetForm,
quickLogSession,
selectedExercise,
loadQuickLogSession
} = tracker;
const [todaysSets, setTodaysSets] = useState<WorkoutSet[]>([]);
const [editingSetId, setEditingSetId] = useState<string | null>(null);
const [editingSet, setEditingSet] = useState<WorkoutSet | null>(null);
const [deletingSetId, setDeletingSetId] = useState<string | null>(null);
useEffect(() => {
if (quickLogSession && quickLogSession.sets) {
// Sets are already ordered by timestamp desc in the backend query, but let's ensure
setTodaysSets([...quickLogSession.sets].sort((a, b) => b.timestamp - a.timestamp));
} else {
setTodaysSets([]);
}
}, [quickLogSession]);
const renderSetMetrics = (set: WorkoutSet) => {
const metrics: string[] = [];
if (set.weight) metrics.push(`${set.weight} ${t('weight_kg', lang)}`);
if (set.reps) metrics.push(`${set.reps} ${t('reps', lang)}`);
if (set.durationSeconds) metrics.push(`${set.durationSeconds} ${t('time_sec', lang)}`);
if (set.distanceMeters) metrics.push(`${set.distanceMeters} ${t('dist_m', lang)}`);
if (set.height) metrics.push(`${set.height} ${t('height_cm', lang)}`);
return metrics.join(' / ');
};
return (
<div className="flex flex-col h-full max-h-full overflow-hidden relative bg-surface">
<div className="px-4 py-3 bg-surface-container shadow-elevation-1 z-20 flex justify-between items-center">
<button
onClick={() => {
resetForm();
setIsSporadicMode(false);
}}
className="text-error font-medium text-sm hover:opacity-80 transition-opacity"
>
{t('quit', lang)}
</button>
<div className="flex flex-col items-center">
<h2 className="text-title-medium text-on-surface flex items-center gap-2 font-medium">
<span className="w-2 h-2 rounded-full bg-primary animate-pulse" />
{t('quick_log', lang)}
</h2>
</div>
<button
onClick={handleLogSporadicSet}
className={`px-5 py-2 rounded-full text-sm font-medium transition-all ${selectedExercise
? 'bg-primary-container text-on-primary-container hover:opacity-90 shadow-elevation-1'
: 'bg-surface-container-high text-on-surface-variant opacity-50 cursor-not-allowed'
}`}
disabled={!selectedExercise}
>
{t('log_set', lang)}
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 pb-32 space-y-6">
<SetLogger
tracker={tracker}
lang={lang}
onLogSet={handleLogSporadicSet}
isSporadic={true}
/>
{/* History Section */}
{todaysSets.length > 0 && (
<div className="mt-6">
<h3 className="text-title-medium font-medium mb-3">{t('history_section', lang)}</h3>
<div className="space-y-2">
{todaysSets.map((set, idx) => (
<div key={set.id} className="bg-surface-container rounded-lg p-3 flex items-center justify-between shadow-elevation-1 animate-in fade-in">
<div className="flex items-center gap-4 flex-1">
<div className="w-8 h-8 rounded-full bg-secondary-container text-on-secondary-container flex items-center justify-center text-xs font-bold">
{todaysSets.length - idx}
</div>
<div>
<p className="font-medium text-on-surface">{set.exerciseName}{set.side && <span className="ml-2 text-xs font-medium text-on-surface-variant">{t(set.side.toLowerCase() as any, lang)}</span>}</p>
<p className="text-sm text-on-surface-variant">{renderSetMetrics(set)}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
setEditingSetId(set.id);
setEditingSet(JSON.parse(JSON.stringify(set)));
}}
className="p-2 text-on-surface-variant hover:text-primary hover:bg-surface-container-high rounded-full transition-colors"
>
<Pencil size={18} />
</button>
<button
onClick={() => setDeletingSetId(set.id)}
className="p-2 text-on-surface-variant hover:text-error hover:bg-surface-container-high rounded-full transition-colors"
>
<Trash2 size={18} />
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
{isCreating && (
<ExerciseModal
isOpen={isCreating}
onClose={() => setIsCreating(false)}
onSave={handleCreateExercise}
lang={lang}
existingExercises={exercises}
/>
)}
{/* Edit Set Modal */}
{editingSetId && editingSet && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-surface-container w-full max-w-md rounded-[28px] p-6 shadow-elevation-3 max-h-[80vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-xl font-normal text-on-surface">{t('edit', lang)}</h3>
<button
onClick={() => {
setEditingSetId(null);
setEditingSet(null);
}}
className="p-2 hover:bg-surface-container-high rounded-full transition-colors"
>
<X size={20} />
</button>
</div>
<div className="space-y-4">
{(editingSet.type === 'STRENGTH' || editingSet.type === 'BODYWEIGHT') && (
<>
<div>
<label className="text-sm text-on-surface-variant">{t('weight_kg', lang)}</label>
<input
type="number"
step="0.1"
value={editingSet.weight || ''}
onChange={(e) => setEditingSet({ ...editingSet, weight: parseFloat(e.target.value) || 0 })}
className="w-full mt-1 px-4 py-2 bg-surface-container-high text-on-surface rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label className="text-sm text-on-surface-variant">{t('reps', lang)}</label>
<input
type="number"
value={editingSet.reps || ''}
onChange={(e) => setEditingSet({ ...editingSet, reps: parseInt(e.target.value) || 0 })}
className="w-full mt-1 px-4 py-2 bg-surface-container-high text-on-surface rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</>
)}
{(editingSet.type === 'CARDIO' || editingSet.type === 'STATIC') && (
<div>
<label className="text-sm text-on-surface-variant">{t('time_sec', lang)}</label>
<input
type="number"
value={editingSet.durationSeconds || ''}
onChange={(e) => setEditingSet({ ...editingSet, durationSeconds: parseInt(e.target.value) || 0 })}
className="w-full mt-1 px-4 py-2 bg-surface-container-high text-on-surface rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
)}
{editingSet.type === 'CARDIO' && (
<div>
<label className="text-sm text-on-surface-variant">{t('dist_m', lang)}</label>
<input
type="number"
step="0.1"
value={editingSet.distanceMeters || ''}
onChange={(e) => setEditingSet({ ...editingSet, distanceMeters: parseFloat(e.target.value) || 0 })}
className="w-full mt-1 px-4 py-2 bg-surface-container-high text-on-surface rounded-lg focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
)}
</div>
<div className="flex justify-end gap-2 mt-6">
<button
onClick={() => {
setEditingSetId(null);
setEditingSet(null);
}}
className="px-4 py-2 rounded-full text-primary font-medium hover:bg-white/5"
>
{t('cancel', lang)}
</button>
<button
onClick={async () => {
try {
const response = await fetch(`/api/sessions/active/set/${editingSetId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify(editingSet)
});
if (response.ok) {
await loadQuickLogSession();
setEditingSetId(null);
setEditingSet(null);
}
} catch (error) {
console.error('Failed to update set:', error);
}
}}
className="px-4 py-2 rounded-full bg-primary text-on-primary font-medium flex items-center gap-2"
>
<Save size={18} />
{t('save', lang)}
</button>
</div>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{deletingSetId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-surface-container w-full max-w-xs rounded-[28px] p-6 shadow-elevation-3">
<h3 className="text-xl font-normal text-on-surface mb-2">{t('delete', lang)}</h3>
<p className="text-sm text-on-surface-variant mb-8">{t('delete_confirm', lang)}</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setDeletingSetId(null)}
className="px-4 py-2 rounded-full text-primary font-medium hover:bg-white/5"
>
{t('cancel', lang)}
</button>
<button
onClick={async () => {
try {
const response = await fetch(`/api/sessions/active/set/${deletingSetId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
await loadQuickLogSession();
setDeletingSetId(null);
}
} catch (error) {
console.error('Failed to delete set:', error);
}
}}
className="px-4 py-2 rounded-full bg-error-container text-on-error-container font-medium"
>
{t('delete', lang)}
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default SporadicView;

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { WorkoutSession, WorkoutSet, WorkoutPlan, Language } from '../../types';
import { useTracker } from './useTracker';
import IdleView from './IdleView';
import SporadicView from './SporadicView';
import ActiveSessionView from './ActiveSessionView';
interface TrackerProps {
userId: string;
userWeight?: number;
activeSession: WorkoutSession | null;
activePlan: WorkoutPlan | null;
onSessionStart: (plan?: WorkoutPlan, startWeight?: number) => void;
onSessionEnd: () => void;
onSessionQuit: () => void;
onSetAdded: (set: WorkoutSet) => void;
onRemoveSet: (setId: string) => void;
onUpdateSet: (set: WorkoutSet) => void;
onSporadicSetAdded?: () => void;
lang: Language;
}
const Tracker: React.FC<TrackerProps> = (props) => {
const tracker = useTracker(props);
const { isSporadicMode } = tracker;
const { activeSession, lang, onSessionEnd, onSessionQuit, onRemoveSet } = props;
if (activeSession) {
return (
<ActiveSessionView
tracker={tracker}
activeSession={activeSession}
lang={lang}
onSessionEnd={onSessionEnd}
onSessionQuit={onSessionQuit}
onRemoveSet={onRemoveSet}
/>
);
}
if (isSporadicMode) {
return <SporadicView tracker={tracker} lang={lang} />;
}
return <IdleView tracker={tracker} lang={lang} />;
};
export default Tracker;

View File

@@ -0,0 +1,491 @@
import { useState, useEffect } from 'react';
import { WorkoutSession, WorkoutSet, ExerciseDef, ExerciseType, WorkoutPlan, Language } from '../../types';
import { getExercises, getLastSetForExercise, saveExercise, getPlans } from '../../services/storage';
import { api } from '../../services/api';
interface UseTrackerProps {
userId: string;
userWeight?: number;
activeSession: WorkoutSession | null;
activePlan: WorkoutPlan | null;
onSessionStart: (plan?: WorkoutPlan, startWeight?: number) => void;
onSessionEnd: () => void;
onSessionQuit: () => void;
onSetAdded: (set: WorkoutSet) => void;
onRemoveSet: (setId: string) => void;
onUpdateSet: (set: WorkoutSet) => void;
onSporadicSetAdded?: () => void;
}
export const useTracker = ({
userId,
userWeight,
activeSession,
activePlan,
onSessionStart,
onSessionEnd,
onSessionQuit,
onSetAdded,
onRemoveSet,
onUpdateSet,
onSporadicSetAdded
}: UseTrackerProps) => {
const [exercises, setExercises] = useState<ExerciseDef[]>([]);
const [plans, setPlans] = useState<WorkoutPlan[]>([]);
const [selectedExercise, setSelectedExercise] = useState<ExerciseDef | null>(null);
const [lastSet, setLastSet] = useState<WorkoutSet | undefined>(undefined);
const [searchQuery, setSearchQuery] = useState<string>('');
const [showSuggestions, setShowSuggestions] = useState(false);
// Timer State
const [elapsedTime, setElapsedTime] = useState<string>('00:00:00');
// Form State
const [weight, setWeight] = useState<string>('');
const [reps, setReps] = useState<string>('');
const [duration, setDuration] = useState<string>('');
const [distance, setDistance] = useState<string>('');
const [height, setHeight] = useState<string>('');
const [bwPercentage, setBwPercentage] = useState<string>('100');
// User Weight State
const [userBodyWeight, setUserBodyWeight] = useState<string>(userWeight ? userWeight.toString() : '70');
// Create Exercise State
const [isCreating, setIsCreating] = useState(false);
// Plan Execution State
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [showPlanPrep, setShowPlanPrep] = useState<WorkoutPlan | null>(null);
const [showPlanList, setShowPlanList] = useState(false);
// Confirmation State
const [showFinishConfirm, setShowFinishConfirm] = useState(false);
const [showQuitConfirm, setShowQuitConfirm] = useState(false);
const [showMenu, setShowMenu] = useState(false);
// Edit Set State
const [editingSetId, setEditingSetId] = useState<string | null>(null);
const [editWeight, setEditWeight] = useState<string>('');
const [editReps, setEditReps] = useState<string>('');
const [editDuration, setEditDuration] = useState<string>('');
const [editDistance, setEditDistance] = useState<string>('');
const [editHeight, setEditHeight] = useState<string>('');
// Quick Log State
const [quickLogSession, setQuickLogSession] = useState<WorkoutSession | null>(null);
const [isSporadicMode, setIsSporadicMode] = useState(false);
const [sporadicSuccess, setSporadicSuccess] = useState(false);
// Unilateral Exercise State
const [unilateralSide, setUnilateralSide] = useState<'LEFT' | 'RIGHT'>('LEFT');
useEffect(() => {
const loadData = async () => {
const exList = await getExercises(userId);
exList.sort((a, b) => a.name.localeCompare(b.name));
setExercises(exList);
const planList = await getPlans(userId);
setPlans(planList);
if (activeSession?.userBodyWeight) {
setUserBodyWeight(activeSession.userBodyWeight.toString());
} else if (userWeight) {
setUserBodyWeight(userWeight.toString());
}
// Load Quick Log Session
try {
const response = await api.get('/sessions/quick-log');
if (response.success && response.session) {
setQuickLogSession(response.session);
}
} catch (error) {
console.error("Failed to load quick log session:", error);
}
};
loadData();
}, [activeSession, userId, userWeight, activePlan]);
// Function to reload Quick Log session
const loadQuickLogSession = async () => {
try {
const response = await api.get('/sessions/quick-log');
if (response.success && response.session) {
setQuickLogSession(response.session);
}
} catch (error) {
console.error("Failed to load quick log session:", error);
}
};
// Timer Logic
useEffect(() => {
let interval: number;
if (activeSession) {
const updateTimer = () => {
const diff = Math.floor((Date.now() - activeSession.startTime) / 1000);
const h = Math.floor(diff / 3600);
const m = Math.floor((diff % 3600) / 60);
const s = diff % 60;
setElapsedTime(`${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`);
};
updateTimer();
interval = window.setInterval(updateTimer, 1000);
}
return () => clearInterval(interval);
}, [activeSession]);
// Recalculate current step when sets change
useEffect(() => {
if (activeSession && activePlan) {
const performedCounts = new Map<string, number>();
for (const set of activeSession.sets) {
performedCounts.set(set.exerciseId, (performedCounts.get(set.exerciseId) || 0) + 1);
}
let nextStepIndex = activePlan.steps.length; // Default to finished
const plannedCounts = new Map<string, number>();
for (let i = 0; i < activePlan.steps.length; i++) {
const step = activePlan.steps[i];
const exerciseId = step.exerciseId;
plannedCounts.set(exerciseId, (plannedCounts.get(exerciseId) || 0) + 1);
const performedCount = performedCounts.get(exerciseId) || 0;
if (performedCount < plannedCounts.get(exerciseId)!) {
nextStepIndex = i;
break;
}
}
setCurrentStepIndex(nextStepIndex);
}
}, [activeSession, activePlan]);
useEffect(() => {
if (activeSession && activePlan && exercises.length > 0 && activePlan.steps.length > 0) {
if (currentStepIndex < activePlan.steps.length) {
const step = activePlan.steps[currentStepIndex];
if (step) {
const exDef = exercises.find(e => e.id === step.exerciseId);
if (exDef) {
setSelectedExercise(exDef);
}
}
}
}
}, [currentStepIndex, activePlan, exercises]);
useEffect(() => {
const updateSelection = async () => {
if (selectedExercise) {
setBwPercentage(selectedExercise.bodyWeightPercentage ? selectedExercise.bodyWeightPercentage.toString() : '100');
setSearchQuery(selectedExercise.name);
const set = await getLastSetForExercise(userId, selectedExercise.id);
setLastSet(set);
if (set) {
setWeight(set.weight?.toString() || '');
setReps(set.reps?.toString() || '');
setDuration(set.durationSeconds?.toString() || '');
setDistance(set.distanceMeters?.toString() || '');
setHeight(set.height?.toString() || '');
} else {
setWeight(''); setReps(''); setDuration(''); setDistance(''); setHeight('');
}
// Clear fields not relevant to the selected exercise type
if (selectedExercise.type !== ExerciseType.STRENGTH && selectedExercise.type !== ExerciseType.BODYWEIGHT) {
setWeight('');
}
if (selectedExercise.type !== ExerciseType.STRENGTH && selectedExercise.type !== ExerciseType.BODYWEIGHT && selectedExercise.type !== ExerciseType.PLYOMETRIC) {
setReps('');
}
if (selectedExercise.type !== ExerciseType.CARDIO && selectedExercise.type !== ExerciseType.STATIC) {
setDuration('');
}
if (selectedExercise.type !== ExerciseType.CARDIO && selectedExercise.type !== ExerciseType.LONG_JUMP) {
setDistance('');
}
if (selectedExercise.type !== ExerciseType.HIGH_JUMP) {
setHeight('');
}
} else {
setSearchQuery(''); // Clear search query if no exercise is selected
}
};
updateSelection();
}, [selectedExercise, userId]);
const filteredExercises = searchQuery === ''
? exercises
: exercises.filter(ex =>
ex.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleStart = (plan?: WorkoutPlan) => {
if (plan && plan.description) {
setShowPlanPrep(plan);
} else {
onSessionStart(plan, parseFloat(userBodyWeight));
}
};
const confirmPlanStart = () => {
if (showPlanPrep) {
onSessionStart(showPlanPrep, parseFloat(userBodyWeight));
setShowPlanPrep(null);
}
}
const handleAddSet = async () => {
if (!activeSession || !selectedExercise) return;
const setData: Partial<WorkoutSet> = {
exerciseId: selectedExercise.id,
};
if (selectedExercise.isUnilateral) {
setData.side = unilateralSide;
}
switch (selectedExercise.type) {
case ExerciseType.STRENGTH:
if (weight) setData.weight = parseFloat(weight);
if (reps) setData.reps = parseInt(reps);
break;
case ExerciseType.BODYWEIGHT:
if (weight) setData.weight = parseFloat(weight);
if (reps) setData.reps = parseInt(reps);
setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
break;
case ExerciseType.CARDIO:
if (duration) setData.durationSeconds = parseInt(duration);
if (distance) setData.distanceMeters = parseFloat(distance);
break;
case ExerciseType.STATIC:
if (duration) setData.durationSeconds = parseInt(duration);
setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
break;
case ExerciseType.HIGH_JUMP:
if (height) setData.height = parseFloat(height);
break;
case ExerciseType.LONG_JUMP:
if (distance) setData.distanceMeters = parseFloat(distance);
break;
case ExerciseType.PLYOMETRIC:
if (reps) setData.reps = parseInt(reps);
break;
}
try {
const response = await api.post('/sessions/active/log-set', setData);
if (response.success) {
const { newSet, activeExerciseId } = response;
onSetAdded(newSet);
if (activePlan && activeExerciseId) {
const nextStepIndex = activePlan.steps.findIndex(step => step.exerciseId === activeExerciseId);
if (nextStepIndex !== -1) {
setCurrentStepIndex(nextStepIndex);
}
} else if (activePlan && !activeExerciseId) {
// Plan is finished
setCurrentStepIndex(activePlan.steps.length);
}
}
} catch (error) {
console.error("Failed to log set:", error);
}
};
const handleLogSporadicSet = async () => {
if (!selectedExercise) return;
const setData: any = {
exerciseId: selectedExercise.id,
};
if (selectedExercise.isUnilateral) {
setData.side = unilateralSide;
}
switch (selectedExercise.type) {
case ExerciseType.STRENGTH:
if (weight) setData.weight = parseFloat(weight);
if (reps) setData.reps = parseInt(reps);
break;
case ExerciseType.BODYWEIGHT:
if (weight) setData.weight = parseFloat(weight);
if (reps) setData.reps = parseInt(reps);
setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
break;
case ExerciseType.CARDIO:
if (duration) setData.durationSeconds = parseInt(duration);
if (distance) setData.distanceMeters = parseFloat(distance);
break;
case ExerciseType.STATIC:
if (duration) setData.durationSeconds = parseInt(duration);
setData.bodyWeightPercentage = parseFloat(bwPercentage) || 100;
break;
case ExerciseType.HIGH_JUMP:
if (height) setData.height = parseFloat(height);
break;
case ExerciseType.LONG_JUMP:
if (distance) setData.distanceMeters = parseFloat(distance);
break;
case ExerciseType.PLYOMETRIC:
if (reps) setData.reps = parseInt(reps);
break;
}
try {
const response = await api.post('/sessions/quick-log/set', setData);
if (response.success) {
setSporadicSuccess(true);
setTimeout(() => setSporadicSuccess(false), 2000);
// Refresh quick log session
const sessionRes = await api.get('/sessions/quick-log');
if (sessionRes.success && sessionRes.session) {
setQuickLogSession(sessionRes.session);
}
// Reset form
setWeight('');
setReps('');
setDuration('');
setDistance('');
setHeight('');
if (onSporadicSetAdded) onSporadicSetAdded();
}
} catch (error) {
console.error("Failed to log quick log set:", error);
}
};
const handleCreateExercise = async (newEx: ExerciseDef) => {
await saveExercise(userId, newEx);
setExercises(prev => [...prev, newEx].sort((a, b) => a.name.localeCompare(b.name)));
setSelectedExercise(newEx);
setSearchQuery(newEx.name);
setIsCreating(false);
};
const handleEditSet = (set: WorkoutSet) => {
setEditingSetId(set.id);
setEditWeight(set.weight?.toString() || '');
setEditReps(set.reps?.toString() || '');
setEditDuration(set.durationSeconds?.toString() || '');
setEditDistance(set.distanceMeters?.toString() || '');
setEditHeight(set.height?.toString() || '');
};
const handleSaveEdit = (set: WorkoutSet) => {
const updatedSet: WorkoutSet = {
...set,
...(editWeight && { weight: parseFloat(editWeight) }),
...(editReps && { reps: parseInt(editReps) }),
...(editDuration && { durationSeconds: parseInt(editDuration) }),
...(editDistance && { distanceMeters: parseFloat(editDistance) }),
...(editHeight && { height: parseFloat(editHeight) })
};
onUpdateSet(updatedSet);
setEditingSetId(null);
};
const handleCancelEdit = () => {
setEditingSetId(null);
};
const jumpToStep = (index: number) => {
if (!activePlan) return;
setCurrentStepIndex(index);
setShowPlanList(false);
};
const resetForm = () => {
setWeight('');
setReps('');
setDuration('');
setDistance('');
setHeight('');
setSelectedExercise(null);
setSearchQuery('');
setSporadicSuccess(false);
};
return {
exercises,
plans,
activePlan,
selectedExercise,
setSelectedExercise,
lastSet,
searchQuery,
setSearchQuery,
showSuggestions,
setShowSuggestions,
elapsedTime,
weight,
setWeight,
reps,
setReps,
duration,
setDuration,
distance,
setDistance,
height,
setHeight,
bwPercentage,
setBwPercentage,
userBodyWeight,
setUserBodyWeight,
isCreating,
setIsCreating,
currentStepIndex,
showPlanPrep,
setShowPlanPrep,
showPlanList,
setShowPlanList,
showFinishConfirm,
setShowFinishConfirm,
showQuitConfirm,
setShowQuitConfirm,
showMenu,
setShowMenu,
editingSetId,
editWeight,
setEditWeight,
editReps,
setEditReps,
editDuration,
setEditDuration,
editDistance,
setEditDistance,
editHeight,
setEditHeight,
isSporadicMode,
setIsSporadicMode,
sporadicSuccess,
filteredExercises,
handleStart,
confirmPlanStart,
handleAddSet,
handleLogSporadicSet,
handleCreateExercise,
handleEditSet,
handleSaveEdit,
handleCancelEdit,
jumpToStep,
resetForm,
unilateralSide,
setUnilateralSide,
quickLogSession, // Export this
loadQuickLogSession, // Export reload function
};
};