Initial commit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christoph K.
2026-03-21 15:03:55 +01:00
commit dfd66e43c6
78 changed files with 6219 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
import type { Exercise } from '../../types';
import { MUSCLE_GROUP_LABELS, MUSCLE_GROUP_COLORS } from '../../types';
interface ExerciseCardProps {
exercise: Exercise;
onEdit: (exercise: Exercise) => void;
onDelete: (exercise: Exercise) => void;
}
export function ExerciseCard({ exercise, onEdit, onDelete }: ExerciseCardProps) {
const label = MUSCLE_GROUP_LABELS[exercise.muscle_group] || exercise.muscle_group;
const color = MUSCLE_GROUP_COLORS[exercise.muscle_group] || 'bg-gray-600';
return (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-100 truncate">{exercise.name}</h3>
{exercise.description && (
<p className="text-sm text-gray-400 mt-1">{exercise.description}</p>
)}
<div className="flex items-center gap-2 mt-2">
<span className={`${color} text-white text-xs px-2 py-1 rounded-full`}>
{label}
</span>
<span className="text-xs text-gray-500">
Schritt: {exercise.weight_step_kg} kg
</span>
</div>
</div>
<div className="flex gap-1">
<button
onClick={() => onEdit(exercise)}
className="min-h-[44px] min-w-[44px] flex items-center justify-center text-gray-400 hover:text-blue-400 rounded-lg hover:bg-gray-800"
title="Bearbeiten"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => onDelete(exercise)}
className="min-h-[44px] min-w-[44px] flex items-center justify-center text-gray-400 hover:text-red-400 rounded-lg hover:bg-gray-800"
title="Löschen"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,116 @@
import { useState, useEffect } from 'react';
import type { Exercise, CreateExerciseRequest, MuscleGroup } from '../../types';
import { MUSCLE_GROUPS } from '../../types';
interface ExerciseFormProps {
exercise?: Exercise | null;
onSubmit: (data: CreateExerciseRequest) => void;
onCancel: () => void;
}
export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [muscleGroup, setMuscleGroup] = useState<MuscleGroup>('brust');
const [weightStep, setWeightStep] = useState(2.5);
useEffect(() => {
if (exercise) {
setName(exercise.name);
setDescription(exercise.description);
setMuscleGroup(exercise.muscle_group);
setWeightStep(exercise.weight_step_kg);
} else {
setName('');
setDescription('');
setMuscleGroup('brust');
setWeightStep(2.5);
}
}, [exercise]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({
name: name.trim(),
description: description.trim(),
muscle_group: muscleGroup,
weight_step_kg: weightStep,
});
};
const isValid = name.trim().length > 0;
return (
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-4">
<h3 className="text-lg font-semibold">
{exercise ? 'Übung bearbeiten' : 'Neue Übung'}
</h3>
<div>
<label className="block text-sm text-gray-400 mb-1">Name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[44px]"
placeholder="Übungsname"
maxLength={100}
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Beschreibung</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[80px] resize-y"
placeholder="Optionale Beschreibung"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Muskelgruppe *</label>
<select
value={muscleGroup}
onChange={(e) => setMuscleGroup(e.target.value as MuscleGroup)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[44px]"
>
{MUSCLE_GROUPS.map((mg) => (
<option key={mg.value} value={mg.value}>
{mg.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Gewichtsschritt (kg)</label>
<input
type="number"
value={weightStep}
onChange={(e) => setWeightStep(parseFloat(e.target.value) || 0)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[44px]"
min={0.25}
step={0.25}
/>
</div>
<div className="flex gap-3 justify-end pt-2">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-gray-200 min-h-[44px]"
>
Abbrechen
</button>
<button
type="submit"
disabled={!isValid}
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white min-h-[44px] disabled:opacity-50 disabled:cursor-not-allowed"
>
{exercise ? 'Speichern' : 'Erstellen'}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,66 @@
import { useEffect } from 'react';
import { useExerciseStore } from '../../stores/exerciseStore';
import { ExerciseCard } from './ExerciseCard';
import { MUSCLE_GROUPS } from '../../types';
import type { Exercise, MuscleGroup } from '../../types';
interface ExerciseListProps {
onEdit: (exercise: Exercise) => void;
onDelete: (exercise: Exercise) => void;
}
export function ExerciseList({ onEdit, onDelete }: ExerciseListProps) {
const { exercises, loading, filter, fetchExercises, setFilter } = useExerciseStore();
useEffect(() => {
fetchExercises();
}, [fetchExercises, filter.muscleGroup, filter.query]);
return (
<div className="space-y-4">
{/* Filter-Bar */}
<div className="flex flex-col sm:flex-row gap-2">
<select
value={filter.muscleGroup}
onChange={(e) => setFilter({ muscleGroup: e.target.value as MuscleGroup | '' })}
className="bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[44px]"
>
<option value="">Alle Muskelgruppen</option>
{MUSCLE_GROUPS.map((mg) => (
<option key={mg.value} value={mg.value}>
{mg.label}
</option>
))}
</select>
<input
type="text"
value={filter.query}
onChange={(e) => setFilter({ query: e.target.value })}
placeholder="Suche..."
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[44px]"
/>
</div>
{/* Liste */}
{loading ? (
<div className="text-center text-gray-500 py-8">Laden...</div>
) : exercises.length === 0 ? (
<div className="text-center text-gray-500 py-12">
<p className="text-lg">Noch keine Übungen angelegt</p>
<p className="text-sm mt-1">Erstelle deine erste Übung mit dem Button oben.</p>
</div>
) : (
<div className="grid gap-3">
{exercises.map((exercise) => (
<ExerciseCard
key={exercise.id}
exercise={exercise}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,113 @@
import { useState, useEffect } from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { api } from '../../api/client';
import { useExerciseStore } from '../../stores/exerciseStore';
import type { SessionLog } from '../../types';
export function ExerciseChart() {
const { exercises, fetchExercises } = useExerciseStore();
const [selectedId, setSelectedId] = useState<number | null>(null);
const [chartData, setChartData] = useState<{ date: string; weight: number }[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
fetchExercises();
}, [fetchExercises]);
useEffect(() => {
if (!selectedId) {
setChartData([]);
return;
}
setLoading(true);
api.exercises
.history(selectedId, 50)
.then((logs: SessionLog[]) => {
// Gruppiere nach Datum, nehme max Gewicht pro Tag
const byDate = new Map<string, number>();
for (const log of logs) {
const date = new Date(log.logged_at).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
});
const current = byDate.get(date) || 0;
if (log.weight_kg > current) {
byDate.set(date, log.weight_kg);
}
}
const data = Array.from(byDate.entries())
.map(([date, weight]) => ({ date, weight }))
.reverse();
setChartData(data);
})
.catch(() => setChartData([]))
.finally(() => setLoading(false));
}, [selectedId]);
return (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
<h3 className="text-lg font-semibold text-gray-100 mb-3">Gewichtsverlauf</h3>
<select
value={selectedId ?? ''}
onChange={(e) =>
setSelectedId(e.target.value ? parseInt(e.target.value) : null)
}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[44px] mb-4"
>
<option value="">Übung auswählen...</option>
{exercises.map((ex) => (
<option key={ex.id} value={ex.id}>
{ex.name}
</option>
))}
</select>
{loading ? (
<div className="text-center text-gray-500 py-8">Laden...</div>
) : chartData.length === 0 ? (
<div className="text-center text-gray-500 py-8">
{selectedId
? 'Keine Daten vorhanden'
: 'Wähle eine Übung aus'}
</div>
) : (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="date" stroke="#9CA3AF" fontSize={12} />
<YAxis stroke="#9CA3AF" fontSize={12} unit=" kg" />
<Tooltip
contentStyle={{
backgroundColor: '#1F2937',
border: '1px solid #374151',
borderRadius: '8px',
color: '#F3F4F6',
}}
formatter={(value) => [`${value} kg`, 'Max. Gewicht']}
/>
<Line
type="monotone"
dataKey="weight"
stroke="#3B82F6"
strokeWidth={2}
dot={{ fill: '#3B82F6', r: 4 }}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,48 @@
import type { SessionLog } from '../../types';
interface SessionDetailProps {
logs: SessionLog[];
}
export function SessionDetail({ logs }: SessionDetailProps) {
if (!logs || logs.length === 0) {
return <p className="text-sm text-gray-500 py-2">Keine Sätze aufgezeichnet.</p>;
}
// Gruppiere nach Übung
const grouped = new Map<number, { name: string; logs: SessionLog[] }>();
for (const log of logs) {
if (!grouped.has(log.exercise_id)) {
grouped.set(log.exercise_id, { name: log.exercise_name, logs: [] });
}
grouped.get(log.exercise_id)!.logs.push(log);
}
return (
<div className="space-y-3">
{Array.from(grouped.entries()).map(([exerciseId, { name, logs: exLogs }]) => (
<div key={exerciseId}>
<h4 className="text-sm font-semibold text-gray-300 mb-1">{name}</h4>
<div className="space-y-1">
{exLogs
.sort((a, b) => a.set_number - b.set_number)
.map((log) => (
<div
key={log.id}
className="flex items-center gap-2 text-sm text-gray-400 bg-gray-800 rounded px-3 py-1.5"
>
<span className="text-gray-500 w-14">Satz {log.set_number}</span>
<span className="font-medium text-gray-200">
{log.weight_kg} kg x {log.reps}
</span>
{log.note && (
<span className="text-gray-500 text-xs ml-auto">({log.note})</span>
)}
</div>
))}
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,107 @@
import { useEffect, useState } from 'react';
import { useHistoryStore } from '../../stores/historyStore';
import { SessionDetail } from './SessionDetail';
import type { Session } from '../../types';
import { api } from '../../api/client';
export function SessionList() {
const { sessions, loading, fetchSessions } = useHistoryStore();
const [expandedId, setExpandedId] = useState<number | null>(null);
const [expandedSession, setExpandedSession] = useState<Session | null>(null);
useEffect(() => {
fetchSessions(50);
}, [fetchSessions]);
const toggleSession = async (id: number) => {
if (expandedId === id) {
setExpandedId(null);
setExpandedSession(null);
return;
}
setExpandedId(id);
try {
const session = await api.sessions.get(id);
setExpandedSession(session);
} catch {
setExpandedSession(null);
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const formatDuration = (start: string, end?: string) => {
if (!end) return 'laufend';
const ms = new Date(end).getTime() - new Date(start).getTime();
const mins = Math.round(ms / 60000);
if (mins < 60) return `${mins} Min.`;
const h = Math.floor(mins / 60);
const m = mins % 60;
return `${h}h ${m}m`;
};
if (loading) {
return <div className="text-center text-gray-500 py-8">Laden...</div>;
}
if (sessions.length === 0) {
return (
<div className="text-center text-gray-500 py-12">
<p className="text-lg">Noch keine Trainings absolviert</p>
<p className="text-sm mt-1">Starte dein erstes Training im Training-Tab.</p>
</div>
);
}
return (
<div className="space-y-3">
{sessions.map((session) => (
<div
key={session.id}
className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"
>
<button
onClick={() => toggleSession(session.id)}
className="w-full px-4 py-3 min-h-[44px] text-left hover:bg-gray-800/50"
>
<div className="flex items-center justify-between">
<div>
<div className="font-semibold text-gray-100">{session.set_name}</div>
<div className="text-sm text-gray-400 mt-0.5">
{formatDate(session.started_at)}
</div>
</div>
<div className="text-right">
<div className="text-sm text-gray-400">
{formatDuration(session.started_at, session.ended_at)}
</div>
<svg
className={`w-5 h-5 text-gray-400 transition-transform ml-auto ${expandedId === session.id ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</button>
{expandedId === session.id && expandedSession && (
<div className="px-4 pb-4 border-t border-gray-800 pt-3">
<SessionDetail logs={expandedSession.logs || []} />
</div>
)}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,91 @@
import { NavLink } from 'react-router-dom';
const navItems = [
{
to: '/',
label: 'Übungen',
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h7" />
</svg>
),
},
{
to: '/sets',
label: 'Sets',
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
},
{
to: '/training',
label: 'Training',
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
),
},
{
to: '/history',
label: 'Historie',
icon: (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
];
export function BottomNav() {
return (
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-gray-900 border-t border-gray-800 z-40">
<div className="flex justify-around">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
`flex flex-col items-center py-2 px-3 min-h-[44px] min-w-[44px] text-xs transition-colors ${
isActive ? 'text-blue-500' : 'text-gray-400 hover:text-gray-200'
}`
}
>
{item.icon}
<span className="mt-1">{item.label}</span>
</NavLink>
))}
</div>
</nav>
);
}
export function Sidebar() {
return (
<nav className="hidden md:flex flex-col w-56 bg-gray-900 border-r border-gray-800 min-h-screen p-4">
<h1 className="text-xl font-bold text-blue-500 mb-8">Krafttrainer</h1>
<div className="flex flex-col gap-1">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-3 rounded-lg min-h-[44px] transition-colors ${
isActive
? 'bg-blue-500/20 text-blue-400'
: 'text-gray-400 hover:text-gray-200 hover:bg-gray-800'
}`
}
>
{item.icon}
<span>{item.label}</span>
</NavLink>
))}
</div>
</nav>
);
}

View File

@@ -0,0 +1,40 @@
interface ConfirmDialogProps {
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmDialog({
isOpen,
title,
message,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 max-w-sm w-full mx-4 shadow-xl">
<h3 className="text-lg font-semibold text-gray-100 mb-2">{title}</h3>
<p className="text-gray-300 mb-6">{message}</p>
<div className="flex gap-3 justify-end">
<button
onClick={onCancel}
className="px-4 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-gray-200 min-h-[44px] min-w-[44px]"
>
Abbrechen
</button>
<button
onClick={onConfirm}
className="px-4 py-2 rounded-lg bg-red-600 hover:bg-red-500 text-white min-h-[44px] min-w-[44px]"
>
Bestätigen
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { Outlet } from 'react-router-dom';
import { BottomNav, Sidebar } from './BottomNav';
import { ToastContainer } from './Toast';
export function PageShell() {
return (
<div className="flex min-h-screen">
<Sidebar />
<main className="flex-1 pb-20 md:pb-0">
<div className="max-w-2xl mx-auto px-4 py-6">
<Outlet />
</div>
</main>
<BottomNav />
<ToastContainer />
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { useToastStore } from '../../stores/toastStore';
const colorMap = {
success: 'bg-green-700 border-green-500',
error: 'bg-red-700 border-red-500',
info: 'bg-blue-700 border-blue-500',
};
export function ToastContainer() {
const toasts = useToastStore((s) => s.toasts);
const removeToast = useToastStore((s) => s.removeToast);
if (toasts.length === 0) return null;
return (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
{toasts.map((toast) => (
<div
key={toast.id}
className={`${colorMap[toast.type]} border rounded-lg px-4 py-3 text-white shadow-lg flex items-center justify-between gap-2`}
>
<span className="text-sm">{toast.message}</span>
<button
onClick={() => removeToast(toast.id)}
className="text-white/70 hover:text-white min-h-[44px] min-w-[44px] flex items-center justify-center"
>
&times;
</button>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import type { TrainingSet } from '../../types';
import { MUSCLE_GROUP_LABELS, MUSCLE_GROUP_COLORS } from '../../types';
interface SetDetailProps {
trainingSet: TrainingSet;
}
export function SetDetail({ trainingSet }: SetDetailProps) {
return (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
<h3 className="font-semibold text-gray-100 text-lg mb-3">{trainingSet.name}</h3>
{(!trainingSet.exercises || trainingSet.exercises.length === 0) ? (
<p className="text-gray-500 text-sm">Keine Übungen in diesem Set.</p>
) : (
<div className="space-y-2">
{trainingSet.exercises.map((ex, index) => {
const label = MUSCLE_GROUP_LABELS[ex.muscle_group] || ex.muscle_group;
const color = MUSCLE_GROUP_COLORS[ex.muscle_group] || 'bg-gray-600';
return (
<div key={ex.id} className="flex items-center gap-3 bg-gray-800 rounded-lg px-3 py-2">
<span className="text-gray-500 text-sm w-6">{index + 1}.</span>
<span className="flex-1 text-gray-200">{ex.name}</span>
<span className={`${color} text-white text-xs px-2 py-1 rounded-full`}>
{label}
</span>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,193 @@
import { useState, useEffect } from 'react';
import { useExerciseStore } from '../../stores/exerciseStore';
import type { TrainingSet, MuscleGroup } from '../../types';
import { MUSCLE_GROUPS, MUSCLE_GROUP_LABELS } from '../../types';
interface SetFormProps {
trainingSet?: TrainingSet | null;
onSubmit: (name: string, exerciseIds: number[]) => void;
onCancel: () => void;
}
export function SetForm({ trainingSet, onSubmit, onCancel }: SetFormProps) {
const { exercises, fetchExercises } = useExerciseStore();
const [name, setName] = useState('');
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [filterMg, setFilterMg] = useState<MuscleGroup | ''>('');
useEffect(() => {
// Lade alle Übungen ohne Filter
useExerciseStore.getState().setFilter({ muscleGroup: '', query: '' });
fetchExercises();
}, [fetchExercises]);
useEffect(() => {
if (trainingSet) {
setName(trainingSet.name);
setSelectedIds(trainingSet.exercises?.map((e) => e.id) || []);
} else {
setName('');
setSelectedIds([]);
}
}, [trainingSet]);
const toggleExercise = (id: number) => {
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
const moveUp = (index: number) => {
if (index === 0) return;
setSelectedIds((prev) => {
const next = [...prev];
[next[index - 1], next[index]] = [next[index], next[index - 1]];
return next;
});
};
const moveDown = (index: number) => {
if (index >= selectedIds.length - 1) return;
setSelectedIds((prev) => {
const next = [...prev];
[next[index], next[index + 1]] = [next[index + 1], next[index]];
return next;
});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(name.trim(), selectedIds);
};
const filteredExercises = filterMg
? exercises.filter((e) => e.muscle_group === filterMg)
: exercises;
const isValid = name.trim().length > 0 && selectedIds.length > 0;
const exerciseMap = new Map(exercises.map((e) => [e.id, e]));
return (
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-4">
<h3 className="text-lg font-semibold">
{trainingSet ? 'Set bearbeiten' : 'Neues Set'}
</h3>
<div>
<label className="block text-sm text-gray-400 mb-1">Name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[44px]"
placeholder="Set-Name"
maxLength={100}
/>
</div>
{/* Übungsauswahl */}
<div>
<label className="block text-sm text-gray-400 mb-1">Übungen auswählen *</label>
<select
value={filterMg}
onChange={(e) => setFilterMg(e.target.value as MuscleGroup | '')}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 min-h-[44px] mb-2"
>
<option value="">Alle Muskelgruppen</option>
{MUSCLE_GROUPS.map((mg) => (
<option key={mg.value} value={mg.value}>
{mg.label}
</option>
))}
</select>
<div className="max-h-48 overflow-y-auto space-y-1 border border-gray-700 rounded-lg p-2">
{filteredExercises.length === 0 ? (
<p className="text-sm text-gray-500 py-2 text-center">Keine Übungen gefunden</p>
) : (
filteredExercises.map((ex) => (
<label
key={ex.id}
className="flex items-center gap-2 px-2 py-2 rounded hover:bg-gray-800 cursor-pointer min-h-[44px]"
>
<input
type="checkbox"
checked={selectedIds.includes(ex.id)}
onChange={() => toggleExercise(ex.id)}
className="w-5 h-5 accent-blue-500"
/>
<span className="flex-1 text-gray-200">{ex.name}</span>
<span className="text-xs text-gray-500">
{MUSCLE_GROUP_LABELS[ex.muscle_group]}
</span>
</label>
))
)}
</div>
</div>
{/* Sortierbare ausgewählte Übungen */}
{selectedIds.length > 0 && (
<div>
<label className="block text-sm text-gray-400 mb-1">
Reihenfolge ({selectedIds.length} ausgewählt)
</label>
<div className="space-y-1 border border-gray-700 rounded-lg p-2">
{selectedIds.map((id, index) => {
const ex = exerciseMap.get(id);
return (
<div
key={id}
className="flex items-center gap-2 bg-gray-800 rounded-lg px-3 py-2"
>
<span className="text-gray-500 text-sm w-6">{index + 1}.</span>
<span className="flex-1 text-gray-200 text-sm">
{ex?.name || `Übung #${id}`}
</span>
<button
type="button"
onClick={() => moveUp(index)}
disabled={index === 0}
className="min-h-[36px] min-w-[36px] flex items-center justify-center text-gray-400 hover:text-gray-200 disabled:opacity-30"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
</svg>
</button>
<button
type="button"
onClick={() => moveDown(index)}
disabled={index === selectedIds.length - 1}
className="min-h-[36px] min-w-[36px] flex items-center justify-center text-gray-400 hover:text-gray-200 disabled:opacity-30"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
);
})}
</div>
</div>
)}
<div className="flex gap-3 justify-end pt-2">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-gray-200 min-h-[44px]"
>
Abbrechen
</button>
<button
type="submit"
disabled={!isValid}
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white min-h-[44px] disabled:opacity-50 disabled:cursor-not-allowed"
>
{trainingSet ? 'Speichern' : 'Erstellen'}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,81 @@
import { useEffect } from 'react';
import { useSetStore } from '../../stores/setStore';
import type { TrainingSet } from '../../types';
interface SetListProps {
onEdit: (set: TrainingSet) => void;
onDelete: (set: TrainingSet) => void;
}
export function SetList({ onEdit, onDelete }: SetListProps) {
const { sets, loading, fetchSets } = useSetStore();
useEffect(() => {
fetchSets();
}, [fetchSets]);
if (loading) {
return <div className="text-center text-gray-500 py-8">Laden...</div>;
}
if (sets.length === 0) {
return (
<div className="text-center text-gray-500 py-12">
<p className="text-lg">Noch keine Sets angelegt</p>
<p className="text-sm mt-1">Erstelle dein erstes Set mit dem Button oben.</p>
</div>
);
}
return (
<div className="grid gap-3">
{sets.map((set) => (
<div
key={set.id}
className="bg-gray-900 border border-gray-800 rounded-xl p-4"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-100">{set.name}</h3>
<p className="text-sm text-gray-400 mt-1">
{set.exercises?.length || 0} Übungen
</p>
{set.exercises && set.exercises.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{set.exercises.map((ex) => (
<span
key={ex.id}
className="text-xs bg-gray-800 text-gray-300 px-2 py-1 rounded"
>
{ex.name}
</span>
))}
</div>
)}
</div>
<div className="flex gap-1">
<button
onClick={() => onEdit(set)}
className="min-h-[44px] min-w-[44px] flex items-center justify-center text-gray-400 hover:text-blue-400 rounded-lg hover:bg-gray-800"
title="Bearbeiten"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => onDelete(set)}
className="min-h-[44px] min-w-[44px] flex items-center justify-center text-gray-400 hover:text-red-400 rounded-lg hover:bg-gray-800"
title="Löschen"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,224 @@
import { useState } from 'react';
import { useActiveSessionStore } from '../../stores/activeSessionStore';
import { useConfirm } from '../../hooks/useConfirm';
import { ConfirmDialog } from '../layout/ConfirmDialog';
import { LogEntryForm } from './LogEntryForm';
import { RestTimer } from './RestTimer';
import type { Exercise, SessionLog } from '../../types';
interface ActiveSessionProps {
onEnd: () => void;
}
export function ActiveSession({ onEnd }: ActiveSessionProps) {
const { session, exercises, lastLogs, addLog, updateLog, deleteLog, endSession } =
useActiveSessionStore();
const [expandedExercise, setExpandedExercise] = useState<number | null>(null);
const [editingLog, setEditingLog] = useState<SessionLog | null>(null);
const { isOpen, title, message, confirm, handleConfirm, handleCancel } = useConfirm();
if (!session) return null;
const logs = session.logs || [];
const getExerciseLogs = (exerciseId: number) =>
logs
.filter((l) => l.exercise_id === exerciseId)
.sort((a, b) => a.set_number - b.set_number);
const getNextSetNumber = (exerciseId: number) => {
const exLogs = getExerciseLogs(exerciseId);
return exLogs.length > 0 ? Math.max(...exLogs.map((l) => l.set_number)) + 1 : 1;
};
const handleAddLog = async (
exercise: Exercise,
weight: number,
reps: number,
note: string,
) => {
await addLog({
exercise_id: exercise.id,
set_number: getNextSetNumber(exercise.id),
weight_kg: weight,
reps,
note,
});
};
const handleUpdateLog = async (
log: SessionLog,
weight: number,
reps: number,
note: string,
) => {
await updateLog(log.id, { weight_kg: weight, reps, note });
setEditingLog(null);
};
const handleDeleteLog = async (log: SessionLog) => {
const ok = await confirm('Satz löschen', 'Möchtest du diesen Satz wirklich löschen?');
if (ok) {
await deleteLog(log.id);
}
};
const handleEndSession = async () => {
const ok = await confirm(
'Training beenden',
'Möchtest du das Training wirklich beenden?',
);
if (ok) {
const success = await endSession();
if (success) onEnd();
}
};
const toggleExercise = (id: number) => {
setExpandedExercise((prev) => (prev === id ? null : id));
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-100">{session.set_name}</h2>
<span className="text-sm text-gray-400">
{new Date(session.started_at).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
<RestTimer />
{/* Übungen als Accordion */}
{exercises.map((exercise) => {
const exLogs = getExerciseLogs(exercise.id);
const isExpanded = expandedExercise === exercise.id;
const lastLog = lastLogs.get(exercise.id);
return (
<div
key={exercise.id}
className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"
>
{/* Header */}
<button
onClick={() => toggleExercise(exercise.id)}
className="w-full flex items-center justify-between px-4 py-3 min-h-[44px] text-left hover:bg-gray-800/50"
>
<div>
<span className="font-semibold text-gray-100">{exercise.name}</span>
<span className="ml-2 text-sm text-gray-500">
({exLogs.length} {exLogs.length === 1 ? 'Satz' : 'Sätze'})
</span>
</div>
<svg
className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Expanded content */}
{isExpanded && (
<div className="px-4 pb-4 space-y-3">
{/* Vorherige Werte */}
{lastLog && (
<div className="text-sm text-gray-500 bg-gray-800 rounded-lg px-3 py-2">
Letztes Training: {lastLog.weight_kg} kg x {lastLog.reps} Wdh.
</div>
)}
{/* Bisherige Sätze */}
{exLogs.map((log) => (
<div key={log.id}>
{editingLog?.id === log.id ? (
<LogEntryForm
exercise={exercise}
setNumber={log.set_number}
initialWeight={log.weight_kg}
initialReps={log.reps}
onSubmit={(w, r, n) => handleUpdateLog(log, w, r, n)}
submitLabel="Aktualisieren"
/>
) : (
<div className="flex items-center justify-between bg-gray-800 rounded-lg px-3 py-2">
<div>
<span className="text-gray-500 text-sm mr-2">
Satz {log.set_number}:
</span>
<span className="font-semibold text-gray-100">
{log.weight_kg} kg
</span>
<span className="text-gray-400 mx-1">x</span>
<span className="font-semibold text-gray-100">
{log.reps} Wdh.
</span>
{log.note && (
<span className="text-xs text-gray-500 ml-2">
({log.note})
</span>
)}
</div>
<div className="flex gap-1">
<button
onClick={() => setEditingLog(log)}
className="min-h-[36px] min-w-[36px] flex items-center justify-center text-gray-400 hover:text-blue-400"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDeleteLog(log)}
className="min-h-[36px] min-w-[36px] flex items-center justify-center text-gray-400 hover:text-red-400"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
)}
</div>
))}
{/* Neuer Satz — Werte vom letzten Satz dieser Session, sonst vom letzten Training */}
<LogEntryForm
key={`new-${exercise.id}-${exLogs.length}`}
exercise={exercise}
setNumber={getNextSetNumber(exercise.id)}
initialWeight={exLogs.length > 0 ? exLogs[exLogs.length - 1].weight_kg : (lastLog?.weight_kg ?? 0)}
initialReps={exLogs.length > 0 ? exLogs[exLogs.length - 1].reps : (lastLog?.reps ?? 0)}
onSubmit={(w, r, n) => handleAddLog(exercise, w, r, n)}
/>
</div>
)}
</div>
);
})}
{/* Training beenden */}
<button
onClick={handleEndSession}
className="w-full bg-red-600 hover:bg-red-500 text-white font-semibold rounded-xl py-4 min-h-[44px]"
>
Training beenden
</button>
<ConfirmDialog
isOpen={isOpen}
title={title}
message={message}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
</div>
);
}

View File

@@ -0,0 +1,135 @@
import { useState } from 'react';
import type { Exercise } from '../../types';
interface LogEntryFormProps {
exercise: Exercise;
setNumber: number;
initialWeight?: number;
initialReps?: number;
onSubmit: (weight: number, reps: number, note: string) => void;
submitLabel?: string;
}
export function LogEntryForm({
exercise,
setNumber,
initialWeight = 0,
initialReps = 0,
onSubmit,
submitLabel = 'Satz speichern',
}: LogEntryFormProps) {
const [weight, setWeight] = useState(initialWeight);
const [reps, setReps] = useState(initialReps);
const [note, setNote] = useState('');
const step = exercise.weight_step_kg;
const adjustWeight = (delta: number) => {
setWeight((w) => Math.max(0, Math.round((w + delta) * 100) / 100));
};
const adjustReps = (delta: number) => {
setReps((r) => Math.max(0, r + delta));
};
const handleSubmit = () => {
onSubmit(weight, reps, note);
setNote('');
};
return (
<div className="space-y-4 bg-gray-800 rounded-lg p-4">
<div className="text-sm text-gray-400">Satz {setNumber}</div>
{/* Gewicht */}
<div>
<label className="block text-sm text-gray-400 mb-2">Gewicht (kg)</label>
<div className="flex items-center gap-2 justify-center">
<button
type="button"
onClick={() => adjustWeight(-2 * step)}
className="min-h-[44px] min-w-[44px] bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-200 font-bold"
>
-{2 * step}
</button>
<button
type="button"
onClick={() => adjustWeight(-step)}
className="min-h-[44px] min-w-[44px] bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-200 font-bold"
>
-{step}
</button>
<input
type="number"
value={weight}
onChange={(e) => setWeight(parseFloat(e.target.value) || 0)}
className="w-28 text-center text-3xl font-bold bg-gray-900 border border-gray-600 rounded-lg py-2 text-gray-100 focus:outline-none focus:border-blue-500"
min={0}
step={step}
/>
<button
type="button"
onClick={() => adjustWeight(step)}
className="min-h-[44px] min-w-[44px] bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-200 font-bold"
>
+{step}
</button>
<button
type="button"
onClick={() => adjustWeight(2 * step)}
className="min-h-[44px] min-w-[44px] bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-200 font-bold"
>
+{2 * step}
</button>
</div>
</div>
{/* Wiederholungen */}
<div>
<label className="block text-sm text-gray-400 mb-2">Wiederholungen</label>
<div className="flex items-center gap-2 justify-center">
<button
type="button"
onClick={() => adjustReps(-1)}
className="min-h-[44px] min-w-[44px] bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-200 font-bold text-xl"
>
-1
</button>
<input
type="number"
value={reps}
onChange={(e) => setReps(parseInt(e.target.value) || 0)}
className="w-28 text-center text-3xl font-bold bg-gray-900 border border-gray-600 rounded-lg py-2 text-gray-100 focus:outline-none focus:border-blue-500"
min={0}
/>
<button
type="button"
onClick={() => adjustReps(1)}
className="min-h-[44px] min-w-[44px] bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-200 font-bold text-xl"
>
+1
</button>
</div>
</div>
{/* Notiz */}
<div>
<input
type="text"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Notiz (optional)"
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 text-sm focus:outline-none focus:border-blue-500 min-h-[44px]"
/>
</div>
<button
type="button"
onClick={handleSubmit}
className="w-full bg-blue-600 hover:bg-blue-500 text-white font-semibold rounded-lg py-3 min-h-[44px]"
>
{submitLabel}
</button>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { useActiveSessionStore } from '../../stores/activeSessionStore';
const PRESETS = [60, 90, 120, 180];
export function RestTimer() {
const { timerSeconds, timerTarget, startTimer, stopTimer } = useActiveSessionStore();
const isRunning = timerSeconds > 0;
const progress = timerTarget > 0 ? ((timerTarget - timerSeconds) / timerTarget) * 100 : 0;
const formatTime = (s: number) => {
const mins = Math.floor(s / 60);
const secs = s % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
return (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
<h3 className="text-sm font-semibold text-gray-400 mb-3">Pause-Timer</h3>
{isRunning ? (
<div className="text-center space-y-3">
<div className="text-4xl font-bold text-blue-400 font-mono">
{formatTime(timerSeconds)}
</div>
<div className="w-full bg-gray-800 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all duration-1000"
style={{ width: `${progress}%` }}
/>
</div>
<button
onClick={stopTimer}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-200 min-h-[44px]"
>
Stopp
</button>
</div>
) : (
<div className="flex flex-wrap gap-2 justify-center">
{PRESETS.map((s) => (
<button
key={s}
onClick={() => startTimer(s)}
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-gray-200 min-h-[44px] min-w-[44px] font-mono"
>
{formatTime(s)}
</button>
))}
</div>
)}
</div>
);
}