547 lines
28 KiB
TypeScript
547 lines
28 KiB
TypeScript
|
|
import React, { useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Trash2, Calendar, Clock, ChevronDown, ChevronUp, History as HistoryIcon, Dumbbell, Ruler, Timer, Weight, Edit2, Gauge, Pencil, Save, MoreVertical, ClipboardList, Download } from 'lucide-react';
|
|
import { TopBar } from './ui/TopBar';
|
|
import { WorkoutSession, ExerciseType, WorkoutSet, Language } from '../types';
|
|
import { t } from '../services/i18n';
|
|
import { formatSetMetrics } from '../utils/setFormatting';
|
|
import { useSession } from '../context/SessionContext';
|
|
import { generateCsv, downloadCsv } from '../utils/csvExport';
|
|
import { useAuth } from '../context/AuthContext';
|
|
import { getExercises } from '../services/storage';
|
|
import { Button } from './ui/Button';
|
|
import { Ripple } from './ui/Ripple';
|
|
import { Card } from './ui/Card';
|
|
import { Modal } from './ui/Modal';
|
|
import { SideSheet } from './ui/SideSheet';
|
|
import EditSetModal from './EditSetModal';
|
|
import FilledInput from './FilledInput';
|
|
|
|
interface HistoryProps {
|
|
lang: Language;
|
|
}
|
|
|
|
const History: React.FC<HistoryProps> = ({ lang }) => {
|
|
const { sessions, updateSession, deleteSession } = useSession();
|
|
const { currentUser } = useAuth();
|
|
const userId = currentUser?.id || '';
|
|
const navigate = useNavigate();
|
|
const [exercises, setExercises] = useState<import('../types').ExerciseDef[]>([]);
|
|
|
|
const [editingSession, setEditingSession] = useState<WorkoutSession | null>(null);
|
|
const [menuState, setMenuState] = useState<{ id: string, x: number, y: number } | null>(null);
|
|
|
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
const [deletingSetInfo, setDeletingSetInfo] = useState<{ sessionId: string, setId: string } | null>(null);
|
|
const [editingSetInfo, setEditingSetInfo] = useState<{ sessionId: string, set: WorkoutSet } | null>(null);
|
|
|
|
const handleSaveSingleSet = async (updatedSet: WorkoutSet) => {
|
|
if (!editingSetInfo) return;
|
|
const session = sessions.find(s => s.id === editingSetInfo.sessionId);
|
|
if (session) {
|
|
const updatedSets = session.sets.map(s => s.id === updatedSet.id ? updatedSet : s);
|
|
const updatedSession = { ...session, sets: updatedSets };
|
|
try {
|
|
await updateSession(updatedSession);
|
|
setEditingSetInfo(null);
|
|
} catch (e) {
|
|
console.error("Failed to update set", e);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleSaveSetFromModal = async (updatedSet: WorkoutSet) => {
|
|
if (!editingSetInfo) return;
|
|
|
|
// If editing within the session edit modal, update local state only
|
|
if (editingSession && editingSession.id === editingSetInfo.sessionId) {
|
|
const updatedSets = editingSession.sets.map(s =>
|
|
s.id === updatedSet.id ? updatedSet : s
|
|
);
|
|
setEditingSession({ ...editingSession, sets: updatedSets });
|
|
setEditingSetInfo(null);
|
|
} else {
|
|
// Otherwise save to backend (Quick Log flow)
|
|
await handleSaveSingleSet(updatedSet);
|
|
}
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
if (!userId) return;
|
|
getExercises(userId).then(exs => setExercises(exs));
|
|
}, [userId]);
|
|
|
|
|
|
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 = async () => {
|
|
if (editingSession) {
|
|
try {
|
|
await updateSession(editingSession);
|
|
setEditingSession(null);
|
|
} catch (e) {
|
|
console.error("Failed to update session", e);
|
|
}
|
|
}
|
|
};
|
|
|
|
|
|
|
|
const handleDeleteSet = (setId: string) => {
|
|
if (!editingSession) return;
|
|
setEditingSession({
|
|
...editingSession,
|
|
sets: editingSession.sets.filter(s => s.id !== setId)
|
|
});
|
|
};
|
|
|
|
const handleConfirmDelete = async () => {
|
|
if (deletingId) {
|
|
try {
|
|
await deleteSession(deletingId);
|
|
setDeletingId(null);
|
|
} catch (e) {
|
|
console.error("Failed to delete session", e);
|
|
}
|
|
} else if (deletingSetInfo) {
|
|
// 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)
|
|
};
|
|
try {
|
|
await updateSession(updatedSession);
|
|
} catch (e) {
|
|
console.error("Failed to update session after set delete", e);
|
|
}
|
|
}
|
|
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">
|
|
<TopBar
|
|
title={t('tab_history', lang)}
|
|
icon={HistoryIcon}
|
|
actions={
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => {
|
|
const csvContent = generateCsv(sessions, exercises);
|
|
downloadCsv(csvContent);
|
|
}}
|
|
title={t('export_csv', lang)}
|
|
aria-label={t('export_csv', lang)}
|
|
>
|
|
<Download size={24} className="text-on-surface-variant hover:text-primary transition-colors" />
|
|
</Button>
|
|
}
|
|
/>
|
|
<div className="flex-1 overflow-y-auto p-4 pb-20">
|
|
<div className="max-w-2xl mx-auto space-y-4">
|
|
{/* Regular Workout Sessions */}
|
|
{sessions.filter(s => s.type === 'STANDARD').map((session) => {
|
|
const totalWork = calculateSessionWork(session);
|
|
|
|
return (
|
|
<Card
|
|
key={session.id}
|
|
className="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="relative">
|
|
<Button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
setMenuState({
|
|
id: session.id,
|
|
x: rect.right + window.scrollX,
|
|
y: rect.bottom + window.scrollY
|
|
});
|
|
}}
|
|
variant="ghost"
|
|
size="icon"
|
|
aria-label="Session Actions"
|
|
className="text-on-surface-variant hover:text-primary"
|
|
>
|
|
<MoreVertical size={24} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
)
|
|
})}
|
|
|
|
{/* 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<Record<string, WorkoutSession[]>>((groups, session) => {
|
|
const date = new Date(session.startTime).toISOString().split('T')[0];
|
|
if (!groups[date]) groups[date] = [];
|
|
groups[date].push(session);
|
|
return groups;
|
|
}, {})
|
|
) as [string, WorkoutSession[]][])
|
|
.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
|
|
.reduce<WorkoutSet[]>((acc, session) => acc.concat(session.sets), [])
|
|
.map((set, idx) => (
|
|
<Card
|
|
key={set.id}
|
|
className="bg-surface-container-low 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">
|
|
{formatSetMetrics(set, lang)}
|
|
</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) {
|
|
setEditingSetInfo({ sessionId: parentSession.id, set: set });
|
|
}
|
|
}}
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-on-surface-variant hover:text-primary"
|
|
>
|
|
<Pencil size={24} />
|
|
</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 });
|
|
}
|
|
}}
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-error hover:text-error"
|
|
>
|
|
<Trash2 size={24} />
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* MENU PORTAL */}
|
|
{menuState && typeof document !== 'undefined' && createPortal(
|
|
<>
|
|
<div
|
|
className="fixed inset-0 z-50"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setMenuState(null);
|
|
}}
|
|
/>
|
|
<div
|
|
className="absolute bg-surface-container-high rounded-xl shadow-elevation-2 z-50 min-w-[160px] py-1 flex flex-col overflow-hidden animate-menu-enter origin-top-right"
|
|
style={{
|
|
top: menuState.y,
|
|
left: menuState.x,
|
|
}}
|
|
>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
const session = sessions.find(s => s.id === menuState.id);
|
|
if (session) {
|
|
navigate(`/plans?createFromSessionId=${session.id}`);
|
|
}
|
|
setMenuState(null);
|
|
}}
|
|
className="w-full relative overflow-hidden text-left px-4 py-3 hover:bg-on-surface/10 text-on-surface flex items-center gap-3 transition-colors text-sm font-medium"
|
|
>
|
|
<Ripple />
|
|
<ClipboardList size={18} />
|
|
{t('create_plan', lang) || 'Create Plan'}
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
const session = sessions.find(s => s.id === menuState.id);
|
|
if (session) {
|
|
setEditingSession(JSON.parse(JSON.stringify(session)));
|
|
}
|
|
setMenuState(null);
|
|
}}
|
|
className="w-full relative overflow-hidden text-left px-4 py-3 hover:bg-on-surface/10 text-on-surface flex items-center gap-3 transition-colors text-sm font-medium"
|
|
>
|
|
<Ripple />
|
|
<Pencil size={18} />
|
|
{t('edit', lang)}
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setDeletingId(menuState.id);
|
|
setMenuState(null);
|
|
}}
|
|
className="w-full relative overflow-hidden text-left px-4 py-3 hover:bg-error-container/10 text-error flex items-center gap-3 transition-colors text-sm font-medium"
|
|
>
|
|
<Ripple color="rgba(242, 184, 181, 0.2)" />
|
|
<Trash2 size={18} />
|
|
{t('delete', lang)}
|
|
</button>
|
|
</div>
|
|
</>,
|
|
document.body
|
|
)}
|
|
|
|
{/* DELETE CONFIRMATION MODAL */}
|
|
<Modal
|
|
isOpen={!!(deletingId || deletingSetInfo)}
|
|
onClose={() => {
|
|
setDeletingId(null);
|
|
setDeletingSetInfo(null);
|
|
}}
|
|
title={deletingId ? t('delete_workout', lang) : t('delete_set', lang) || 'Delete Set'}
|
|
maxWidth="sm"
|
|
>
|
|
<div className="space-y-6">
|
|
<p className="text-sm text-on-surface-variant">
|
|
{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);
|
|
}}
|
|
variant="ghost"
|
|
size="sm"
|
|
>
|
|
{t('cancel', lang)}
|
|
</Button>
|
|
<Button
|
|
onClick={handleConfirmDelete}
|
|
variant="destructive"
|
|
size="sm"
|
|
>
|
|
{t('delete', lang)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* EDIT SESSION MODAL */}
|
|
{editingSession && (
|
|
<SideSheet
|
|
isOpen={!!editingSession}
|
|
onClose={() => setEditingSession(null)}
|
|
title={t('edit', lang)}
|
|
width="lg"
|
|
footer={
|
|
<div className="flex justify-end">
|
|
<Button onClick={handleSaveEdit}>
|
|
<Save size={16} className="mr-2" />
|
|
{t('save', lang)}
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<div className="space-y-6">
|
|
{/* Meta Info */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<FilledInput
|
|
label={t('start_time', lang)}
|
|
type="datetime-local"
|
|
value={formatDateForInput(editingSession.startTime)}
|
|
onChange={(e: any) => setEditingSession({ ...editingSession, startTime: parseDateFromInput(e.target.value) })}
|
|
/>
|
|
<FilledInput
|
|
label={t('end_time', lang)}
|
|
type="datetime-local"
|
|
value={editingSession.endTime ? formatDateForInput(editingSession.endTime) : ''}
|
|
onChange={(e: any) => setEditingSession({ ...editingSession, endTime: parseDateFromInput(e.target.value) })}
|
|
/>
|
|
</div>
|
|
<FilledInput
|
|
label={t('weight_kg', lang)}
|
|
type="number"
|
|
value={editingSession.userBodyWeight || ''}
|
|
onChange={(e: any) => setEditingSession({ ...editingSession, userBodyWeight: parseFloat(e.target.value) })}
|
|
/>
|
|
|
|
<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-low p-3 rounded-xl border border-outline-variant/20 flex items-center justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="w-5 h-5 rounded-full bg-secondary-container text-on-secondary-container text-[10px] font-bold flex items-center justify-center">{idx + 1}</span>
|
|
<span className="font-medium text-on-surface text-sm">{set.exerciseName}</span>
|
|
{set.side && <span className="text-xs text-on-surface-variant font-medium bg-surface-container px-1.5 py-0.5 rounded">{t(set.side.toLowerCase() as any, lang)}</span>}
|
|
</div>
|
|
<div className="text-sm text-on-surface-variant pl-7">
|
|
{formatSetMetrics(set, lang)}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-1">
|
|
<Button
|
|
onClick={() => setEditingSetInfo({ sessionId: editingSession.id, set: set })}
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-on-surface-variant hover:text-primary"
|
|
title={t('edit', lang)}
|
|
>
|
|
<Pencil size={18} />
|
|
</Button>
|
|
<Button
|
|
onClick={() => handleDeleteSet(set.id)}
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-on-surface-variant hover:text-error hover:bg-error-container/10"
|
|
title={t('delete', lang)}
|
|
>
|
|
<Trash2 size={18} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
|
|
|
|
</div>
|
|
</SideSheet>
|
|
)
|
|
}
|
|
{editingSetInfo && (
|
|
<EditSetModal
|
|
isOpen={!!editingSetInfo}
|
|
onClose={() => setEditingSetInfo(null)}
|
|
set={editingSetInfo.set}
|
|
exerciseDef={exercises.find(e => e.id === editingSetInfo.set.exerciseId)}
|
|
onSave={handleSaveSetFromModal}
|
|
lang={lang}
|
|
/>
|
|
)}
|
|
</div >
|
|
);
|
|
};
|
|
|
|
export default History;
|