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

22
frontend/src/App.tsx Executable file
View File

@@ -0,0 +1,22 @@
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { PageShell } from './components/layout/PageShell';
import { ExercisesPage } from './pages/ExercisesPage';
import { SetsPage } from './pages/SetsPage';
import { TrainingPage } from './pages/TrainingPage';
import { HistoryPage } from './pages/HistoryPage';
const router = createBrowserRouter([
{
element: <PageShell />,
children: [
{ path: '/', element: <ExercisesPage /> },
{ path: '/sets', element: <SetsPage /> },
{ path: '/training', element: <TrainingPage /> },
{ path: '/history', element: <HistoryPage /> },
],
},
]);
export function App() {
return <RouterProvider router={router} />;
}

178
frontend/src/api/client.ts Executable file
View File

@@ -0,0 +1,178 @@
import type {
Exercise,
TrainingSet,
Session,
SessionLog,
LastLogResponse,
ExerciseStats,
CreateExerciseRequest,
CreateSetRequest,
UpdateSetRequest,
CreateSessionRequest,
CreateLogRequest,
UpdateLogRequest,
} from '../types';
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = 'ApiError';
}
}
async function request<T>(
url: string,
options?: RequestInit,
): Promise<T> {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...options,
});
if (res.status === 204) {
return undefined as T;
}
const data = await res.json();
if (!res.ok) {
throw new ApiError(res.status, data.error || 'Unbekannter Fehler');
}
return data as T;
}
export const api = {
exercises: {
list(muscleGroup?: string, q?: string): Promise<Exercise[]> {
const params = new URLSearchParams();
if (muscleGroup) params.set('muscle_group', muscleGroup);
if (q) params.set('q', q);
const qs = params.toString();
return request<Exercise[]>(`/api/v1/exercises${qs ? '?' + qs : ''}`);
},
create(data: CreateExerciseRequest): Promise<Exercise> {
return request<Exercise>('/api/v1/exercises', {
method: 'POST',
body: JSON.stringify(data),
});
},
update(id: number, data: CreateExerciseRequest): Promise<Exercise> {
return request<Exercise>(`/api/v1/exercises/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
},
delete(id: number): Promise<void> {
return request<void>(`/api/v1/exercises/${id}`, {
method: 'DELETE',
});
},
lastLog(id: number): Promise<LastLogResponse> {
return request<LastLogResponse>(`/api/v1/exercises/${id}/last-log`);
},
history(id: number, limit?: number): Promise<SessionLog[]> {
const params = new URLSearchParams();
if (limit) params.set('limit', String(limit));
const qs = params.toString();
return request<SessionLog[]>(
`/api/v1/exercises/${id}/history${qs ? '?' + qs : ''}`,
);
},
},
sets: {
list(): Promise<TrainingSet[]> {
return request<TrainingSet[]>('/api/v1/sets');
},
create(data: CreateSetRequest): Promise<TrainingSet> {
return request<TrainingSet>('/api/v1/sets', {
method: 'POST',
body: JSON.stringify(data),
});
},
update(id: number, data: UpdateSetRequest): Promise<TrainingSet> {
return request<TrainingSet>(`/api/v1/sets/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
},
delete(id: number): Promise<void> {
return request<void>(`/api/v1/sets/${id}`, {
method: 'DELETE',
});
},
},
sessions: {
create(data: CreateSessionRequest): Promise<Session> {
return request<Session>('/api/v1/sessions', {
method: 'POST',
body: JSON.stringify(data),
});
},
list(limit?: number, offset?: number): Promise<Session[]> {
const params = new URLSearchParams();
if (limit) params.set('limit', String(limit));
if (offset) params.set('offset', String(offset));
const qs = params.toString();
return request<Session[]>(`/api/v1/sessions${qs ? '?' + qs : ''}`);
},
get(id: number): Promise<Session> {
return request<Session>(`/api/v1/sessions/${id}`);
},
end(id: number, note?: string): Promise<Session> {
return request<Session>(`/api/v1/sessions/${id}/end`, {
method: 'PUT',
body: JSON.stringify({ note: note || '' }),
});
},
createLog(sessionId: number, data: CreateLogRequest): Promise<SessionLog> {
return request<SessionLog>(`/api/v1/sessions/${sessionId}/logs`, {
method: 'POST',
body: JSON.stringify(data),
});
},
updateLog(
sessionId: number,
logId: number,
data: UpdateLogRequest,
): Promise<SessionLog> {
return request<SessionLog>(
`/api/v1/sessions/${sessionId}/logs/${logId}`,
{
method: 'PUT',
body: JSON.stringify(data),
},
);
},
deleteLog(sessionId: number, logId: number): Promise<void> {
return request<void>(`/api/v1/sessions/${sessionId}/logs/${logId}`, {
method: 'DELETE',
});
},
},
stats: {
overview(): Promise<ExerciseStats[]> {
return request<ExerciseStats[]>('/api/v1/stats/overview');
},
},
};

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>
);
}

View File

@@ -0,0 +1,43 @@
import { useState, useCallback, useRef } from 'react';
interface ConfirmState {
isOpen: boolean;
title: string;
message: string;
}
export function useConfirm() {
const [state, setState] = useState<ConfirmState>({
isOpen: false,
title: '',
message: '',
});
const resolveRef = useRef<((value: boolean) => void) | null>(null);
const confirm = useCallback((title: string, message: string): Promise<boolean> => {
setState({ isOpen: true, title, message });
return new Promise<boolean>((resolve) => {
resolveRef.current = resolve;
});
}, []);
const handleConfirm = useCallback(() => {
setState({ isOpen: false, title: '', message: '' });
resolveRef.current?.(true);
resolveRef.current = null;
}, []);
const handleCancel = useCallback(() => {
setState({ isOpen: false, title: '', message: '' });
resolveRef.current?.(false);
resolveRef.current = null;
}, []);
return {
...state,
confirm,
handleConfirm,
handleCancel,
};
}

View File

@@ -0,0 +1,26 @@
import { useEffect } from 'react';
import { useBlocker } from 'react-router-dom';
import { useActiveSessionStore } from '../stores/activeSessionStore';
export function useNavigationGuard() {
const session = useActiveSessionStore((s) => s.session);
const hasActiveSession = session !== null && !session.ended_at;
const blocker = useBlocker(
({ currentLocation, nextLocation }) =>
hasActiveSession && currentLocation.pathname !== nextLocation.pathname,
);
useEffect(() => {
if (!hasActiveSession) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [hasActiveSession]);
return blocker;
}

1
frontend/src/index.css Executable file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

10
frontend/src/main.tsx Executable file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,87 @@
import { useState } from 'react';
import { ExerciseList } from '../components/exercises/ExerciseList';
import { ExerciseForm } from '../components/exercises/ExerciseForm';
import { ConfirmDialog } from '../components/layout/ConfirmDialog';
import { useExerciseStore } from '../stores/exerciseStore';
import { useConfirm } from '../hooks/useConfirm';
import type { Exercise, CreateExerciseRequest } from '../types';
export function ExercisesPage() {
const { createExercise, updateExercise, deleteExercise } = useExerciseStore();
const [showForm, setShowForm] = useState(false);
const [editingExercise, setEditingExercise] = useState<Exercise | null>(null);
const { isOpen, title, message, confirm, handleConfirm, handleCancel } = useConfirm();
const handleCreate = async (data: CreateExerciseRequest) => {
const result = await createExercise(data);
if (result) {
setShowForm(false);
}
};
const handleUpdate = async (data: CreateExerciseRequest) => {
if (!editingExercise) return;
const result = await updateExercise(editingExercise.id, data);
if (result) {
setEditingExercise(null);
setShowForm(false);
}
};
const handleEdit = (exercise: Exercise) => {
setEditingExercise(exercise);
setShowForm(true);
};
const handleDelete = async (exercise: Exercise) => {
const ok = await confirm(
'Übung löschen',
`Möchtest du "${exercise.name}" wirklich löschen?`,
);
if (ok) {
await deleteExercise(exercise.id);
}
};
const handleCancelForm = () => {
setShowForm(false);
setEditingExercise(null);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-100">Übungen</h1>
{!showForm && (
<button
onClick={() => {
setEditingExercise(null);
setShowForm(true);
}}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg min-h-[44px] font-medium"
>
+ Neue Übung
</button>
)}
</div>
{showForm && (
<ExerciseForm
exercise={editingExercise}
onSubmit={editingExercise ? handleUpdate : handleCreate}
onCancel={handleCancelForm}
/>
)}
<ExerciseList onEdit={handleEdit} onDelete={handleDelete} />
<ConfirmDialog
isOpen={isOpen}
title={title}
message={message}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { useState } from 'react';
import { SessionList } from '../components/history/SessionList';
import { ExerciseChart } from '../components/history/ExerciseChart';
type Tab = 'history' | 'stats';
export function HistoryPage() {
const [activeTab, setActiveTab] = useState<Tab>('history');
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold text-gray-100">Historie</h1>
{/* Tab Toggle */}
<div className="flex bg-gray-900 rounded-lg p-1">
<button
onClick={() => setActiveTab('history')}
className={`flex-1 py-2 rounded-md text-sm font-medium min-h-[44px] transition-colors ${
activeTab === 'history'
? 'bg-blue-600 text-white'
: 'text-gray-400 hover:text-gray-200'
}`}
>
Trainings
</button>
<button
onClick={() => setActiveTab('stats')}
className={`flex-1 py-2 rounded-md text-sm font-medium min-h-[44px] transition-colors ${
activeTab === 'stats'
? 'bg-blue-600 text-white'
: 'text-gray-400 hover:text-gray-200'
}`}
>
Statistiken
</button>
</div>
{activeTab === 'history' ? <SessionList /> : <ExerciseChart />}
</div>
);
}

90
frontend/src/pages/SetsPage.tsx Executable file
View File

@@ -0,0 +1,90 @@
import { useState } from 'react';
import { SetList } from '../components/sets/SetList';
import { SetForm } from '../components/sets/SetForm';
import { ConfirmDialog } from '../components/layout/ConfirmDialog';
import { useSetStore } from '../stores/setStore';
import { useConfirm } from '../hooks/useConfirm';
import type { TrainingSet } from '../types';
export function SetsPage() {
const { createSet, updateSet, deleteSet } = useSetStore();
const [showForm, setShowForm] = useState(false);
const [editingSet, setEditingSet] = useState<TrainingSet | null>(null);
const { isOpen, title, message, confirm, handleConfirm, handleCancel } = useConfirm();
const handleCreate = async (name: string, exerciseIds: number[]) => {
const result = await createSet({ name, exercise_ids: exerciseIds });
if (result) {
setShowForm(false);
}
};
const handleUpdate = async (name: string, exerciseIds: number[]) => {
if (!editingSet) return;
const result = await updateSet(editingSet.id, {
name,
exercise_ids: exerciseIds,
});
if (result) {
setEditingSet(null);
setShowForm(false);
}
};
const handleEdit = (set: TrainingSet) => {
setEditingSet(set);
setShowForm(true);
};
const handleDelete = async (set: TrainingSet) => {
const ok = await confirm(
'Set löschen',
`Möchtest du "${set.name}" wirklich löschen?`,
);
if (ok) {
await deleteSet(set.id);
}
};
const handleCancelForm = () => {
setShowForm(false);
setEditingSet(null);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-100">Training-Sets</h1>
{!showForm && (
<button
onClick={() => {
setEditingSet(null);
setShowForm(true);
}}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg min-h-[44px] font-medium"
>
+ Neues Set
</button>
)}
</div>
{showForm && (
<SetForm
trainingSet={editingSet}
onSubmit={editingSet ? handleUpdate : handleCreate}
onCancel={handleCancelForm}
/>
)}
<SetList onEdit={handleEdit} onDelete={handleDelete} />
<ConfirmDialog
isOpen={isOpen}
title={title}
message={message}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
</div>
);
}

View File

@@ -0,0 +1,107 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSetStore } from '../stores/setStore';
import { useActiveSessionStore } from '../stores/activeSessionStore';
import { useNavigationGuard } from '../hooks/useNavigationGuard';
import { ActiveSession } from '../components/training/ActiveSession';
import { ConfirmDialog } from '../components/layout/ConfirmDialog';
export function TrainingPage() {
const navigate = useNavigate();
const { sets, fetchSets, loading } = useSetStore();
const { session, startSession } = useActiveSessionStore();
const [starting, setStarting] = useState(false);
const blocker = useNavigationGuard();
useEffect(() => {
fetchSets();
}, [fetchSets]);
const handleStart = async (setId: number) => {
const trainingSet = sets.find((s) => s.id === setId);
if (!trainingSet) return;
setStarting(true);
const success = await startSession(setId, trainingSet.exercises || []);
setStarting(false);
if (!success) return;
};
const handleEnd = () => {
navigate('/history');
};
// Aktive Session anzeigen
if (session) {
return (
<div className="space-y-4">
<ActiveSession onEnd={handleEnd} />
{/* Navigation Blocker Dialog */}
{blocker.state === 'blocked' && (
<ConfirmDialog
isOpen={true}
title="Training verlassen?"
message="Du hast ein aktives Training. Wenn du die Seite verlässt, kannst du später fortfahren. Möchtest du trotzdem navigieren?"
onConfirm={() => blocker.proceed?.()}
onCancel={() => blocker.reset?.()}
/>
)}
</div>
);
}
// Set-Auswahl
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold text-gray-100">Training starten</h1>
{loading ? (
<div className="text-center text-gray-500 py-8">Laden...</div>
) : sets.length === 0 ? (
<div className="text-center text-gray-500 py-12">
<p className="text-lg">Keine Sets vorhanden</p>
<p className="text-sm mt-1">Erstelle zuerst ein Set unter "Sets".</p>
</div>
) : (
<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-center justify-between">
<div>
<h3 className="font-semibold text-gray-100">{set.name}</h3>
<p className="text-sm text-gray-400 mt-0.5">
{set.exercises?.length || 0} Übungen
</p>
</div>
<button
onClick={() => handleStart(set.id)}
disabled={starting}
className="px-4 py-2 bg-green-600 hover:bg-green-500 text-white rounded-lg min-h-[44px] font-medium disabled:opacity-50"
>
Starten
</button>
</div>
{set.exercises && set.exercises.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{set.exercises.map((ex) => (
<span
key={ex.id}
className="text-xs bg-gray-800 text-gray-400 px-2 py-1 rounded"
>
{ex.name}
</span>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,181 @@
import { create } from 'zustand';
import { api } from '../api/client';
import type {
Session,
Exercise,
SessionLog,
LastLogResponse,
CreateLogRequest,
UpdateLogRequest,
} from '../types';
import { useToastStore } from './toastStore';
interface ActiveSessionState {
session: Session | null;
exercises: Exercise[];
lastLogs: Map<number, LastLogResponse>;
timerSeconds: number;
timerTarget: number;
timerInterval: ReturnType<typeof setInterval> | null;
startSession: (setId: number, exercises: Exercise[]) => Promise<boolean>;
loadSession: (sessionId: number) => Promise<boolean>;
addLog: (data: CreateLogRequest) => Promise<SessionLog | null>;
updateLog: (logId: number, data: UpdateLogRequest) => Promise<SessionLog | null>;
deleteLog: (logId: number) => Promise<boolean>;
endSession: (note?: string) => Promise<boolean>;
fetchLastLog: (exerciseId: number) => Promise<LastLogResponse | null>;
copyLastValues: (exerciseId: number) => LastLogResponse | null;
startTimer: (seconds: number) => void;
stopTimer: () => void;
clearSession: () => void;
}
export const useActiveSessionStore = create<ActiveSessionState>((set, get) => ({
session: null,
exercises: [],
lastLogs: new Map(),
timerSeconds: 0,
timerTarget: 0,
timerInterval: null,
startSession: async (setId, exercises) => {
try {
const session = await api.sessions.create({ set_id: setId });
set({ session, exercises });
// Lade letzte Logs für alle Übungen
for (const ex of exercises) {
await get().fetchLastLog(ex.id);
}
return true;
} catch (err) {
const message = err instanceof Error ? err.message : 'Fehler beim Starten';
useToastStore.getState().addToast('error', message);
return false;
}
},
loadSession: async (sessionId) => {
try {
const session = await api.sessions.get(sessionId);
set({ session });
return true;
} catch (err) {
const message = err instanceof Error ? err.message : 'Session nicht gefunden';
useToastStore.getState().addToast('error', message);
return false;
}
},
addLog: async (data) => {
const { session } = get();
if (!session) return null;
try {
const log = await api.sessions.createLog(session.id, data);
// Reload session to get updated logs
const updated = await api.sessions.get(session.id);
set({ session: updated });
return log;
} catch (err) {
const message = err instanceof Error ? err.message : 'Fehler beim Speichern';
useToastStore.getState().addToast('error', message);
return null;
}
},
updateLog: async (logId, data) => {
const { session } = get();
if (!session) return null;
try {
const log = await api.sessions.updateLog(session.id, logId, data);
const updated = await api.sessions.get(session.id);
set({ session: updated });
return log;
} catch (err) {
const message = err instanceof Error ? err.message : 'Fehler beim Aktualisieren';
useToastStore.getState().addToast('error', message);
return null;
}
},
deleteLog: async (logId) => {
const { session } = get();
if (!session) return false;
try {
await api.sessions.deleteLog(session.id, logId);
const updated = await api.sessions.get(session.id);
set({ session: updated });
useToastStore.getState().addToast('success', 'Satz gelöscht');
return true;
} catch (err) {
const message = err instanceof Error ? err.message : 'Fehler beim Löschen';
useToastStore.getState().addToast('error', message);
return false;
}
},
endSession: async (note) => {
const { session } = get();
if (!session) return false;
try {
await api.sessions.end(session.id, note);
get().stopTimer();
set({ session: null, exercises: [], lastLogs: new Map() });
useToastStore.getState().addToast('success', 'Training beendet');
return true;
} catch (err) {
const message = err instanceof Error ? err.message : 'Fehler beim Beenden';
useToastStore.getState().addToast('error', message);
return false;
}
},
fetchLastLog: async (exerciseId) => {
try {
const lastLog = await api.exercises.lastLog(exerciseId);
set((state) => {
const newMap = new Map(state.lastLogs);
newMap.set(exerciseId, lastLog);
return { lastLogs: newMap };
});
return lastLog;
} catch {
// 404 = noch kein Log vorhanden
return null;
}
},
copyLastValues: (exerciseId) => {
return get().lastLogs.get(exerciseId) || null;
},
startTimer: (seconds) => {
const { timerInterval } = get();
if (timerInterval) clearInterval(timerInterval);
set({ timerTarget: seconds, timerSeconds: seconds });
const interval = setInterval(() => {
const current = get().timerSeconds;
if (current <= 1) {
clearInterval(interval);
set({ timerSeconds: 0, timerInterval: null });
} else {
set({ timerSeconds: current - 1 });
}
}, 1000);
set({ timerInterval: interval });
},
stopTimer: () => {
const { timerInterval } = get();
if (timerInterval) clearInterval(timerInterval);
set({ timerSeconds: 0, timerTarget: 0, timerInterval: null });
},
clearSession: () => {
get().stopTimer();
set({ session: null, exercises: [], lastLogs: new Map() });
},
}));

View File

@@ -0,0 +1,85 @@
import { create } from 'zustand';
import { api } from '../api/client';
import type { Exercise, MuscleGroup, CreateExerciseRequest } from '../types';
import { useToastStore } from './toastStore';
interface ExerciseFilter {
muscleGroup: MuscleGroup | '';
query: string;
}
interface ExerciseState {
exercises: Exercise[];
loading: boolean;
error: string | null;
filter: ExerciseFilter;
fetchExercises: () => Promise<void>;
createExercise: (data: CreateExerciseRequest) => Promise<Exercise | null>;
updateExercise: (id: number, data: CreateExerciseRequest) => Promise<Exercise | null>;
deleteExercise: (id: number) => Promise<boolean>;
setFilter: (filter: Partial<ExerciseFilter>) => void;
}
export const useExerciseStore = create<ExerciseState>((set, get) => ({
exercises: [],
loading: false,
error: null,
filter: { muscleGroup: '', query: '' },
fetchExercises: async () => {
set({ loading: true, error: null });
try {
const { muscleGroup, query } = get().filter;
const exercises = await api.exercises.list(muscleGroup || undefined, query || undefined);
set({ exercises: exercises || [], loading: false });
} catch (err) {
const message = err instanceof Error ? err.message : 'Fehler beim Laden';
set({ error: message, loading: false });
}
},
createExercise: async (data) => {
try {
const exercise = await api.exercises.create(data);
useToastStore.getState().addToast('success', 'Übung erstellt');
await get().fetchExercises();
return exercise;
} catch (err) {
const message = err instanceof Error ? err.message : 'Fehler beim Erstellen';
useToastStore.getState().addToast('error', message);
return null;
}
},
updateExercise: async (id, data) => {
try {
const exercise = await api.exercises.update(id, data);
useToastStore.getState().addToast('success', 'Übung aktualisiert');
await get().fetchExercises();
return exercise;
} catch (err) {
const message = err instanceof Error ? err.message : 'Fehler beim Aktualisieren';
useToastStore.getState().addToast('error', message);
return null;
}
},
deleteExercise: async (id) => {
try {
await api.exercises.delete(id);
useToastStore.getState().addToast('success', 'Übung gelöscht');
await get().fetchExercises();
return true;
} catch (err) {
const message = err instanceof Error ? err.message : 'Fehler beim Löschen';
useToastStore.getState().addToast('error', message);
return false;
}
},
setFilter: (filter) => {
set((state) => ({
filter: { ...state.filter, ...filter },
}));
},
}));

View File

@@ -0,0 +1,51 @@
import { create } from 'zustand';
import { api } from '../api/client';
import type { Session, ExerciseStats } from '../types';
import { useToastStore } from './toastStore';
interface HistoryState {
sessions: Session[];
loading: boolean;
stats: ExerciseStats[];
fetchSessions: (limit?: number, offset?: number) => Promise<void>;
fetchSession: (id: number) => Promise<Session | null>;
fetchStats: () => Promise<void>;
}
export const useHistoryStore = create<HistoryState>((set) => ({
sessions: [],
loading: false,
stats: [],
fetchSessions: async (limit, offset) => {
set({ loading: true });
try {
const sessions = await api.sessions.list(limit, offset);
set({ sessions: sessions || [], loading: false });
} catch (err) {
const message = err instanceof Error ? err.message : 'Fehler beim Laden';
useToastStore.getState().addToast('error', message);
set({ loading: false });
}
},
fetchSession: async (id) => {
try {
return await api.sessions.get(id);
} catch (err) {
const message = err instanceof Error ? err.message : 'Session nicht gefunden';
useToastStore.getState().addToast('error', message);
return null;
}
},
fetchStats: async () => {
try {
const stats = await api.stats.overview();
set({ stats: stats || [] });
} catch (err) {
const message = err instanceof Error ? err.message : 'Fehler beim Laden';
useToastStore.getState().addToast('error', message);
}
},
}));

70
frontend/src/stores/setStore.ts Executable file
View File

@@ -0,0 +1,70 @@
import { create } from 'zustand';
import { api } from '../api/client';
import type { TrainingSet, CreateSetRequest, UpdateSetRequest } from '../types';
import { useToastStore } from './toastStore';
interface SetState {
sets: TrainingSet[];
loading: boolean;
error: string | null;
fetchSets: () => Promise<void>;
createSet: (data: CreateSetRequest) => Promise<TrainingSet | null>;
updateSet: (id: number, data: UpdateSetRequest) => Promise<TrainingSet | null>;
deleteSet: (id: number) => Promise<boolean>;
}
export const useSetStore = create<SetState>((set, get) => ({
sets: [],
loading: false,
error: null,
fetchSets: async () => {
set({ loading: true, error: null });
try {
const sets = await api.sets.list();
set({ sets: sets || [], loading: false });
} catch (err) {
const message = err instanceof Error ? err.message : 'Fehler beim Laden';
set({ error: message, loading: false });
}
},
createSet: async (data) => {
try {
const newSet = await api.sets.create(data);
useToastStore.getState().addToast('success', 'Set erstellt');
await get().fetchSets();
return newSet;
} catch (err) {
const message = err instanceof Error ? err.message : 'Fehler beim Erstellen';
useToastStore.getState().addToast('error', message);
return null;
}
},
updateSet: async (id, data) => {
try {
const updated = await api.sets.update(id, data);
useToastStore.getState().addToast('success', 'Set aktualisiert');
await get().fetchSets();
return updated;
} catch (err) {
const message = err instanceof Error ? err.message : 'Fehler beim Aktualisieren';
useToastStore.getState().addToast('error', message);
return null;
}
},
deleteSet: async (id) => {
try {
await api.sets.delete(id);
useToastStore.getState().addToast('success', 'Set gelöscht');
await get().fetchSets();
return true;
} catch (err) {
const message = err instanceof Error ? err.message : 'Fehler beim Löschen';
useToastStore.getState().addToast('error', message);
return false;
}
},
}));

View File

@@ -0,0 +1,35 @@
import { create } from 'zustand';
export interface Toast {
id: string;
type: 'success' | 'error' | 'info';
message: string;
}
interface ToastState {
toasts: Toast[];
addToast: (type: Toast['type'], message: string) => void;
removeToast: (id: string) => void;
}
export const useToastStore = create<ToastState>((set) => ({
toasts: [],
addToast: (type, message) => {
const id = crypto.randomUUID();
set((state) => ({
toasts: [...state.toasts, { id, type, message }],
}));
setTimeout(() => {
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
}));
}, 3000);
},
removeToast: (id) => {
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
}));
},
}));

136
frontend/src/types/index.ts Executable file
View File

@@ -0,0 +1,136 @@
export type MuscleGroup =
| 'brust'
| 'ruecken'
| 'schultern'
| 'bizeps'
| 'trizeps'
| 'beine'
| 'bauch'
| 'ganzkoerper'
| 'sonstiges';
export const MUSCLE_GROUPS: { value: MuscleGroup; label: string }[] = [
{ value: 'brust', label: 'Brust' },
{ value: 'ruecken', label: 'Rücken' },
{ value: 'schultern', label: 'Schultern' },
{ value: 'bizeps', label: 'Bizeps' },
{ value: 'trizeps', label: 'Trizeps' },
{ value: 'beine', label: 'Beine' },
{ value: 'bauch', label: 'Bauch' },
{ value: 'ganzkoerper', label: 'Ganzkörper' },
{ value: 'sonstiges', label: 'Sonstiges' },
];
export const MUSCLE_GROUP_LABELS: Record<MuscleGroup, string> = {
brust: 'Brust',
ruecken: 'Rücken',
schultern: 'Schultern',
bizeps: 'Bizeps',
trizeps: 'Trizeps',
beine: 'Beine',
bauch: 'Bauch',
ganzkoerper: 'Ganzkörper',
sonstiges: 'Sonstiges',
};
export const MUSCLE_GROUP_COLORS: Record<MuscleGroup, string> = {
brust: 'bg-red-600',
ruecken: 'bg-blue-600',
schultern: 'bg-yellow-600',
bizeps: 'bg-purple-600',
trizeps: 'bg-pink-600',
beine: 'bg-green-600',
bauch: 'bg-orange-600',
ganzkoerper: 'bg-teal-600',
sonstiges: 'bg-gray-600',
};
export interface Exercise {
id: number;
name: string;
description: string;
muscle_group: MuscleGroup;
weight_step_kg: number;
created_at: string;
updated_at: string;
deleted_at?: string;
}
export interface TrainingSet {
id: number;
name: string;
exercises: Exercise[];
created_at: string;
deleted_at?: string;
}
export interface SessionLog {
id: number;
session_id: number;
exercise_id: number;
exercise_name: string;
set_number: number;
weight_kg: number;
reps: number;
note: string;
logged_at: string;
}
export interface Session {
id: number;
set_id: number;
set_name: string;
started_at: string;
ended_at?: string;
note: string;
logs?: SessionLog[];
}
export interface LastLogResponse {
weight_kg: number;
reps: number;
}
export interface ExerciseStats {
exercise_id: number;
exercise_name: string;
max_weight_kg: number;
total_volume_kg: number;
total_sets: number;
last_trained: string;
}
export interface CreateExerciseRequest {
name: string;
description: string;
muscle_group: MuscleGroup;
weight_step_kg?: number;
}
export interface CreateSetRequest {
name: string;
exercise_ids: number[];
}
export interface UpdateSetRequest {
name: string;
exercise_ids: number[];
}
export interface CreateSessionRequest {
set_id: number;
}
export interface CreateLogRequest {
exercise_id: number;
set_number: number;
weight_kg: number;
reps: number;
note: string;
}
export interface UpdateLogRequest {
weight_kg?: number;
reps?: number;
note?: string;
}