Add exercise numbers, image uploads, version display, session resume, and training sparklines

- Exercise number (UF#): optional field on exercises, displayed in cards, training, and sets
- Import training plan numbers via migration 005 (UPDATE by name)
- Exercise images: JPG upload with multi-image support per exercise (migration 006)
- Version endpoint (GET /api/v1/version) with ldflags injection in Makefile and Dockerfile
- Version displayed on settings page
- Session resume: GET /api/v1/sessions/active endpoint, auto-resume on training page load
- Block new session while one is active (409 Conflict)
- e1RM sparkline chart per exercise during training (Epley formula)
- Fix CORS: add X-User-ID to allowed headers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christoph K.
2026-03-27 08:37:29 +01:00
parent 833ad04a6f
commit 063aa67615
32 changed files with 1457 additions and 32 deletions

View File

@@ -15,7 +15,12 @@ export function ExerciseCard({ exercise, onEdit, onDelete }: ExerciseCardProps)
<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>
<h3 className="font-semibold text-gray-100 truncate">
{exercise.exercise_number != null && (
<span className="text-blue-400 mr-1.5">#{exercise.exercise_number}</span>
)}
{exercise.name}
</h3>
{exercise.description && (
<p className="text-sm text-gray-400 mt-1">{exercise.description}</p>
)}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import type { Exercise, CreateExerciseRequest, MuscleGroup } from '../../types';
import { MUSCLE_GROUPS } from '../../types';
import { ImageGallery } from './ImageGallery';
interface ExerciseFormProps {
exercise?: Exercise | null;
@@ -13,6 +14,7 @@ export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps
const [description, setDescription] = useState('');
const [muscleGroup, setMuscleGroup] = useState<MuscleGroup>('brust');
const [weightStep, setWeightStep] = useState(2.5);
const [exerciseNumber, setExerciseNumber] = useState<string>('');
useEffect(() => {
if (exercise) {
@@ -20,21 +22,25 @@ export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps
setDescription(exercise.description);
setMuscleGroup(exercise.muscle_group);
setWeightStep(exercise.weight_step_kg);
setExerciseNumber(exercise.exercise_number != null ? String(exercise.exercise_number) : '');
} else {
setName('');
setDescription('');
setMuscleGroup('brust');
setWeightStep(2.5);
setExerciseNumber('');
}
}, [exercise]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const num = exerciseNumber.trim() ? parseInt(exerciseNumber.trim(), 10) : undefined;
onSubmit({
name: name.trim(),
description: description.trim(),
muscle_group: muscleGroup,
weight_step_kg: weightStep,
exercise_number: Number.isFinite(num) ? num : undefined,
});
};
@@ -58,6 +64,18 @@ export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Übungsnummer (UF#)</label>
<input
type="number"
value={exerciseNumber}
onChange={(e) => setExerciseNumber(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="Optional"
min={1}
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Beschreibung</label>
<textarea
@@ -95,6 +113,10 @@ export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps
/>
</div>
{exercise && (
<ImageGallery exerciseId={exercise.id} />
)}
<div className="flex gap-3 justify-end pt-2">
<button
type="button"

View File

@@ -0,0 +1,127 @@
import { useState, useEffect, useRef } from 'react';
import { api } from '../../api/client';
import { useToastStore } from '../../stores/toastStore';
import type { ExerciseImage } from '../../types';
interface ImageGalleryProps {
exerciseId: number;
}
export function ImageGallery({ exerciseId }: ImageGalleryProps) {
const [images, setImages] = useState<ExerciseImage[]>([]);
const [uploading, setUploading] = useState(false);
const [viewImage, setViewImage] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
loadImages();
}, [exerciseId]);
async function loadImages() {
try {
const imgs = await api.exercises.listImages(exerciseId);
setImages(imgs || []);
} catch {
// Fehler ignorieren
}
}
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
if (file.type !== 'image/jpeg') {
useToastStore.getState().addToast('error', 'Nur JPG-Bilder erlaubt');
return;
}
if (file.size > 5 * 1024 * 1024) {
useToastStore.getState().addToast('error', 'Datei zu groß (max 5 MB)');
return;
}
setUploading(true);
try {
await api.exercises.uploadImage(exerciseId, file);
useToastStore.getState().addToast('success', 'Bild hochgeladen');
await loadImages();
} catch (err) {
const message = err instanceof Error ? err.message : 'Upload fehlgeschlagen';
useToastStore.getState().addToast('error', message);
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = '';
}
}
async function handleDelete(imageId: number) {
try {
await api.exercises.deleteImage(exerciseId, imageId);
useToastStore.getState().addToast('success', 'Bild gelöscht');
setImages((prev) => prev.filter((img) => img.id !== imageId));
} catch (err) {
const message = err instanceof Error ? err.message : 'Löschen fehlgeschlagen';
useToastStore.getState().addToast('error', message);
}
}
return (
<div className="space-y-3">
<label className="block text-sm text-gray-400">Bilder</label>
{images.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{images.map((img) => (
<div key={img.id} className="relative group">
<img
src={`/uploads/${img.filename}`}
alt="Übungsbild"
className="w-full h-24 object-cover rounded-lg cursor-pointer"
onClick={() => setViewImage(img.filename)}
/>
<button
type="button"
onClick={() => handleDelete(img.id)}
className="absolute top-1 right-1 w-6 h-6 bg-red-600 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
title="Löschen"
>
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
)}
<input
ref={fileRef}
type="file"
accept="image/jpeg"
onChange={handleUpload}
className="hidden"
/>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="w-full py-2 border border-dashed border-gray-600 rounded-lg text-sm text-gray-400 hover:text-gray-200 hover:border-gray-400 transition-colors min-h-[44px] disabled:opacity-50"
>
{uploading ? 'Wird hochgeladen...' : '+ Bild hinzufügen (JPG)'}
</button>
{viewImage && (
<div
className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4"
onClick={() => setViewImage(null)}
>
<img
src={`/uploads/${viewImage}`}
alt="Übungsbild"
className="max-w-full max-h-full rounded-lg"
/>
</div>
)}
</div>
);
}

View File

@@ -19,7 +19,12 @@ export function SetDetail({ trainingSet }: SetDetailProps) {
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="flex-1 text-gray-200">
{ex.exercise_number != null && (
<span className="text-blue-400 mr-1.5">#{ex.exercise_number}</span>
)}
{ex.name}
</span>
<span className={`${color} text-white text-xs px-2 py-1 rounded-full`}>
{label}
</span>

View File

@@ -4,6 +4,7 @@ import { useConfirm } from '../../hooks/useConfirm';
import { ConfirmDialog } from '../layout/ConfirmDialog';
import { LogEntryForm } from './LogEntryForm';
import { RestTimer } from './RestTimer';
import { ExerciseSparkline } from './ExerciseSparkline';
import type { Exercise, SessionLog } from '../../types';
interface ActiveSessionProps {
@@ -109,7 +110,12 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
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="font-semibold text-gray-100">
{exercise.exercise_number != null && (
<span className="text-blue-400 mr-1.5">#{exercise.exercise_number}</span>
)}
{exercise.name}
</span>
<span className="ml-2 text-sm text-gray-500">
({exLogs.length} {exLogs.length === 1 ? 'Satz' : 'Sätze'})
</span>
@@ -128,6 +134,9 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
{/* Expanded content */}
{isExpanded && (
<div className="px-4 pb-4 space-y-3">
{/* Fortschritts-Sparkline */}
<ExerciseSparkline exerciseId={exercise.id} />
{/* Vorherige Werte */}
{lastLog && (
<div className="text-sm text-gray-500 bg-gray-800 rounded-lg px-3 py-2">

View File

@@ -0,0 +1,95 @@
import { useState, useEffect } from 'react';
import {
LineChart,
Line,
YAxis,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { api } from '../../api/client';
import type { SessionLog } from '../../types';
interface ExerciseSparklineProps {
exerciseId: number;
}
interface DataPoint {
date: string;
e1rm: number;
}
// Epley-Formel: e1RM = Gewicht × (1 + Wdh / 30)
function calcE1RM(weight: number, reps: number): number {
if (reps <= 0 || weight <= 0) return 0;
return Math.round(weight * (1 + reps / 30) * 10) / 10;
}
export function ExerciseSparkline({ exerciseId }: ExerciseSparklineProps) {
const [data, setData] = useState<DataPoint[]>([]);
useEffect(() => {
api.exercises
.history(exerciseId, 100)
.then((logs: SessionLog[]) => {
// Gruppiere nach Session (= Trainingstag), nimm bestes e1RM pro Session
const bySession = new Map<number, { date: string; e1rm: number }>();
for (const log of logs) {
const e1rm = calcE1RM(log.weight_kg, log.reps);
const current = bySession.get(log.session_id);
if (!current || e1rm > current.e1rm) {
bySession.set(log.session_id, {
date: new Date(log.logged_at).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
}),
e1rm,
});
}
}
const points = Array.from(bySession.values()).reverse();
setData(points);
})
.catch(() => setData([]));
}, [exerciseId]);
if (data.length < 2) return null;
const trend = data[data.length - 1].e1rm - data[0].e1rm;
const trendColor = trend >= 0 ? '#22C55E' : '#EF4444';
return (
<div className="bg-gray-800 rounded-lg px-3 py-2">
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-gray-500">Fortschritt (e1RM)</span>
<span className="text-xs font-medium" style={{ color: trendColor }}>
{trend >= 0 ? '+' : ''}{trend.toFixed(1)} kg
</span>
</div>
<div className="h-16">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<YAxis hide domain={['dataMin - 2', 'dataMax + 2']} />
<Tooltip
contentStyle={{
backgroundColor: '#1F2937',
border: '1px solid #374151',
borderRadius: '8px',
color: '#F3F4F6',
fontSize: '12px',
}}
formatter={(value) => [`${value} kg`, 'e1RM']}
/>
<Line
type="monotone"
dataKey="e1rm"
stroke="#3B82F6"
strokeWidth={1.5}
dot={false}
activeDot={{ r: 3, fill: '#3B82F6' }}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
}