This commit is contained in:
Christoph K.
2026-04-07 09:49:17 +02:00
parent 063aa67615
commit 4db170b467
37 changed files with 269 additions and 48 deletions

View File

@@ -16,6 +16,11 @@ import type {
} from '../types';
import { getActiveUserId } from '../stores/userStore';
/**
* Fehlerklasse für HTTP-Antworten mit Nicht-2xx-Statuscodes.
* Die `status`-Property enthält den HTTP-Statuscode für differenzierte
* Fehlerbehandlung im aufrufenden Code.
*/
export class ApiError extends Error {
constructor(
public status: number,
@@ -26,6 +31,11 @@ export class ApiError extends Error {
}
}
/**
* Generische Hilfsfunktion für alle API-Aufrufe.
* Setzt automatisch den `Content-Type`- und `X-User-ID`-Header
* und wirft bei Nicht-2xx-Antworten eine `ApiError`.
*/
async function request<T>(
url: string,
options?: RequestInit,
@@ -52,6 +62,7 @@ async function request<T>(
return data as T;
}
/** Typisiertes API-Objekt mit allen Backend-Endpunkten unter `/api/v1`. */
export const api = {
users: {
list(): Promise<User[]> {
@@ -105,6 +116,10 @@ export const api = {
return request<ExerciseImage[]>(`/api/v1/exercises/${id}/images`);
},
/**
* Lädt ein Bild für eine Übung hoch (Multipart-Upload).
* Verwendet keinen JSON-Header, da `FormData` den Content-Type selbst setzt.
*/
async uploadImage(id: number, file: File): Promise<ExerciseImage> {
const formData = new FormData();
formData.append('image', file);

View File

@@ -7,6 +7,10 @@ interface ExerciseCardProps {
onDelete: (exercise: Exercise) => void;
}
/**
* Zeigt eine einzelne Übung als Karte mit Muskelgruppen-Badge,
* Gewichtsschritt und Aktions-Buttons für Bearbeiten und Löschen.
*/
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';

View File

@@ -4,11 +4,17 @@ import { MUSCLE_GROUPS } from '../../types';
import { ImageGallery } from './ImageGallery';
interface ExerciseFormProps {
/** Vorhandene Übung beim Bearbeiten; `null`/`undefined` beim Erstellen. */
exercise?: Exercise | null;
onSubmit: (data: CreateExerciseRequest) => void;
onCancel: () => void;
}
/**
* Formular zum Erstellen und Bearbeiten einer Übung.
* Im Bearbeitungsmodus werden die vorhandenen Werte automatisch befüllt
* und die Bildergalerie der Übung angezeigt.
*/
export function ExerciseForm({ exercise, onSubmit, onCancel }: ExerciseFormProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');

View File

@@ -9,6 +9,11 @@ interface ExerciseListProps {
onDelete: (exercise: Exercise) => void;
}
/**
* Zeigt alle Übungen als gefilterte Liste.
* Filter nach Muskelgruppe und Freitext werden direkt im Store verwaltet
* und lösen automatisch einen Neuladevorgang aus.
*/
export function ExerciseList({ onEdit, onDelete }: ExerciseListProps) {
const { exercises, loading, filter, fetchExercises, setFilter } = useExerciseStore();
@@ -18,7 +23,6 @@ export function ExerciseList({ onEdit, onDelete }: ExerciseListProps) {
return (
<div className="space-y-4">
{/* Filter-Bar */}
<div className="flex flex-col sm:flex-row gap-2">
<select
value={filter.muscleGroup}
@@ -41,7 +45,6 @@ export function ExerciseList({ onEdit, onDelete }: ExerciseListProps) {
/>
</div>
{/* Liste */}
{loading ? (
<div className="text-center text-gray-500 py-8">Laden...</div>
) : exercises.length === 0 ? (

View File

@@ -7,6 +7,11 @@ interface ImageGalleryProps {
exerciseId: number;
}
/**
* Bildergalerie für eine Übung mit Upload- und Löschfunktion.
* Akzeptiert ausschließlich JPG-Dateien bis 5 MB.
* Ein Klick auf ein Bild öffnet eine Vollbildvorschau.
*/
export function ImageGallery({ exerciseId }: ImageGalleryProps) {
const [images, setImages] = useState<ExerciseImage[]>([]);
const [uploading, setUploading] = useState(false);
@@ -22,7 +27,7 @@ export function ImageGallery({ exerciseId }: ImageGalleryProps) {
const imgs = await api.exercises.listImages(exerciseId);
setImages(imgs || []);
} catch {
// Fehler ignorieren
// Fehler ignorieren Bilder sind optional
}
}

View File

@@ -12,6 +12,10 @@ import { api } from '../../api/client';
import { useExerciseStore } from '../../stores/exerciseStore';
import type { SessionLog } from '../../types';
/**
* Zeigt den Gewichtsverlauf einer ausgewählten Übung als Liniendiagramm.
* Pro Trainingstag wird das jeweils höchste verwendete Gewicht dargestellt.
*/
export function ExerciseChart() {
const { exercises, fetchExercises } = useExerciseStore();
const [selectedId, setSelectedId] = useState<number | null>(null);
@@ -32,7 +36,7 @@ export function ExerciseChart() {
api.exercises
.history(selectedId, 50)
.then((logs: SessionLog[]) => {
// Gruppiere nach Datum, nehme max Gewicht pro Tag
// Pro Datum das Maximalgewicht ermitteln
const byDate = new Map<string, number>();
for (const log of logs) {
const date = new Date(log.logged_at).toLocaleDateString('de-DE', {

View File

@@ -4,12 +4,16 @@ interface SessionDetailProps {
logs: SessionLog[];
}
/**
* Zeigt alle protokollierten Sätze einer Session gruppiert nach Übung.
* Innerhalb jeder Übung sind die Sätze aufsteigend nach Satznummer sortiert.
*/
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
// Sätze nach Übungs-ID gruppieren
const grouped = new Map<number, { name: string; logs: SessionLog[] }>();
for (const log of logs) {
if (!grouped.has(log.exercise_id)) {

View File

@@ -5,6 +5,11 @@ import type { Session } from '../../types';
import { api, ApiError } from '../../api/client';
import { useToastStore } from '../../stores/toastStore';
/**
* Liste abgeschlossener Trainings-Sessions als aufklappbares Accordion.
* Beim Aufklappen einer Session werden deren Sätze per API nachgeladen.
* Laufende Sessions (ohne `ended_at`) können nicht gelöscht werden.
*/
export function SessionList() {
const { sessions, loading, fetchSessions } = useHistoryStore();
const [expandedId, setExpandedId] = useState<number | null>(null);
@@ -30,6 +35,7 @@ export function SessionList() {
}
};
/** Formatiert ein ISO-Datum als lokalisiertes Datum mit Uhrzeit. */
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
@@ -40,6 +46,17 @@ export function SessionList() {
});
};
/** Berechnet Trainingsdauer als lesbaren String oder "laufend". */
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`;
};
const handleDelete = async (e: React.MouseEvent, id: number) => {
e.stopPropagation();
if (!confirm('Session wirklich löschen? Alle Sätze werden unwiderruflich entfernt.')) return;
@@ -48,7 +65,7 @@ export function SessionList() {
try {
await api.sessions.delete(id);
useToastStore.getState().addToast('success', 'Session erfolgreich gelöscht');
// Aufgeklappte Session schließen falls sie gerade gelöscht wurde
// Aufgeklappte Session schließen, falls sie gerade gelöscht wurde
if (expandedId === id) {
setExpandedId(null);
setExpandedSession(null);
@@ -65,16 +82,6 @@ export function SessionList() {
}
};
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>;
}

View File

@@ -50,6 +50,7 @@ const navItems = [
},
];
/** Mobile Bottom-Navigation mit Links zu allen Hauptseiten. */
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">
@@ -74,6 +75,7 @@ export function BottomNav() {
);
}
/** Desktop-Seitenleiste mit Navigation und aktivem Nutzer-Indikator. */
export function Sidebar() {
const { activeUser } = useUserStore();

View File

@@ -5,6 +5,10 @@ interface SetDetailProps {
trainingSet: TrainingSet;
}
/**
* Zeigt die Übungsliste eines Trainings-Sets mit Muskelgruppen-Badges.
* Wird in der Set-Übersicht als Detailansicht verwendet.
*/
export function SetDetail({ trainingSet }: SetDetailProps) {
return (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">

View File

@@ -4,11 +4,17 @@ import type { TrainingSet, MuscleGroup } from '../../types';
import { MUSCLE_GROUPS, MUSCLE_GROUP_LABELS } from '../../types';
interface SetFormProps {
/** Vorhandenes Set beim Bearbeiten; `null`/`undefined` beim Erstellen. */
trainingSet?: TrainingSet | null;
onSubmit: (name: string, exerciseIds: number[]) => void;
onCancel: () => void;
}
/**
* Formular zum Erstellen und Bearbeiten eines Trainings-Sets.
* Übungen können nach Muskelgruppe gefiltert und per Checkbox ausgewählt werden.
* Die Reihenfolge der ausgewählten Übungen ist per Pfeil-Buttons sortierbar.
*/
export function SetForm({ trainingSet, onSubmit, onCancel }: SetFormProps) {
const { exercises, fetchExercises } = useExerciseStore();
const [name, setName] = useState('');
@@ -16,7 +22,7 @@ export function SetForm({ trainingSet, onSubmit, onCancel }: SetFormProps) {
const [filterMg, setFilterMg] = useState<MuscleGroup | ''>('');
useEffect(() => {
// Lade alle Übungen ohne Filter
// Alle Übungen ohne aktiven Filter laden
useExerciseStore.getState().setFilter({ muscleGroup: '', query: '' });
fetchExercises();
}, [fetchExercises]);
@@ -66,6 +72,7 @@ export function SetForm({ trainingSet, onSubmit, onCancel }: SetFormProps) {
const isValid = name.trim().length > 0 && selectedIds.length > 0;
/** Map für effizienten Zugriff auf Übungsnamen in der Reihenfolge-Ansicht. */
const exerciseMap = new Map(exercises.map((e) => [e.id, e]));
return (
@@ -86,7 +93,6 @@ export function SetForm({ trainingSet, onSubmit, onCancel }: SetFormProps) {
/>
</div>
{/* Übungsauswahl */}
<div>
<label className="block text-sm text-gray-400 mb-1">Übungen auswählen *</label>
<select
@@ -127,7 +133,6 @@ export function SetForm({ trainingSet, onSubmit, onCancel }: SetFormProps) {
</div>
</div>
{/* Sortierbare ausgewählte Übungen */}
{selectedIds.length > 0 && (
<div>
<label className="block text-sm text-gray-400 mb-1">

View File

@@ -7,6 +7,10 @@ interface SetListProps {
onDelete: (set: TrainingSet) => void;
}
/**
* Listet alle Trainings-Sets mit ihren Übungen sowie Bearbeiten-
* und Löschen-Aktionen auf.
*/
export function SetList({ onEdit, onDelete }: SetListProps) {
const { sets, loading, fetchSets } = useSetStore();

View File

@@ -8,9 +8,15 @@ import { ExerciseSparkline } from './ExerciseSparkline';
import type { Exercise, SessionLog } from '../../types';
interface ActiveSessionProps {
/** Wird aufgerufen, nachdem das Training erfolgreich beendet wurde. */
onEnd: () => void;
}
/**
* Hauptansicht einer laufenden Trainings-Session.
* Zeigt alle Übungen des Sets als aufklappbares Accordion mit
* Fortschritts-Sparkline, letzten Trainingswerten und Satz-Eingabeformular.
*/
export function ActiveSession({ onEnd }: ActiveSessionProps) {
const { session, exercises, lastLogs, addLog, updateLog, deleteLog, endSession } =
useActiveSessionStore();
@@ -22,11 +28,13 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
const logs = session.logs || [];
/** Gibt alle protokollierten Sätze für eine Übung sortiert nach Satznummer zurück. */
const getExerciseLogs = (exerciseId: number) =>
logs
.filter((l) => l.exercise_id === exerciseId)
.sort((a, b) => a.set_number - b.set_number);
/** Berechnet die nächste Satznummer als Maximum der bisherigen + 1. */
const getNextSetNumber = (exerciseId: number) => {
const exLogs = getExerciseLogs(exerciseId);
return exLogs.length > 0 ? Math.max(...exLogs.map((l) => l.set_number)) + 1 : 1;
@@ -93,7 +101,6 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
<RestTimer />
{/* Übungen als Accordion */}
{exercises.map((exercise) => {
const exLogs = getExerciseLogs(exercise.id);
const isExpanded = expandedExercise === exercise.id;
@@ -104,7 +111,6 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
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"
@@ -131,20 +137,16 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
</svg>
</button>
{/* 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">
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 ? (
@@ -198,7 +200,11 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
</div>
))}
{/* Neuer Satz — Werte vom letzten Satz dieser Session, sonst vom letzten Training */}
{/*
Formular für neuen Satz: Vorausfüllung mit Werten des letzten Satzes
dieser Session, falls vorhanden, sonst mit dem letzten Trainingseintrag.
Der key-Wechsel erzwingt Reset des Formularzustands bei jedem neuen Satz.
*/}
<LogEntryForm
key={`new-${exercise.id}-${exLogs.length}`}
exercise={exercise}
@@ -213,7 +219,6 @@ export function ActiveSession({ onEnd }: ActiveSessionProps) {
);
})}
{/* 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]"

View File

@@ -18,12 +18,20 @@ interface DataPoint {
e1rm: number;
}
// Epley-Formel: e1RM = Gewicht × (1 + Wdh / 30)
/**
* Berechnet den geschätzten 1-Wiederholungs-Maximalwert (e1RM) nach der Epley-Formel:
* e1RM = Gewicht × (1 + Wiederholungen / 30)
*/
function calcE1RM(weight: number, reps: number): number {
if (reps <= 0 || weight <= 0) return 0;
return Math.round(weight * (1 + reps / 30) * 10) / 10;
}
/**
* Kompaktes Liniendiagramm (Sparkline) für den e1RM-Verlauf einer Übung.
* Pro Trainings-Session wird der beste e1RM-Wert dargestellt.
* Rendert nichts, wenn weniger als 2 Datenpunkte vorhanden sind.
*/
export function ExerciseSparkline({ exerciseId }: ExerciseSparklineProps) {
const [data, setData] = useState<DataPoint[]>([]);
@@ -31,7 +39,7 @@ export function ExerciseSparkline({ exerciseId }: ExerciseSparklineProps) {
api.exercises
.history(exerciseId, 100)
.then((logs: SessionLog[]) => {
// Gruppiere nach Session (= Trainingstag), nimm bestes e1RM pro Session
// Pro Session den besten e1RM ermitteln
const bySession = new Map<number, { date: string; e1rm: number }>();
for (const log of logs) {
const e1rm = calcE1RM(log.weight_kg, log.reps);
@@ -46,6 +54,7 @@ export function ExerciseSparkline({ exerciseId }: ExerciseSparklineProps) {
});
}
}
// API liefert neueste Einträge zuerst; umkehren für chronologische Darstellung
const points = Array.from(bySession.values()).reverse();
setData(points);
})

View File

@@ -7,9 +7,15 @@ interface LogEntryFormProps {
initialWeight?: number;
initialReps?: number;
onSubmit: (weight: number, reps: number, note: string) => void;
/** Beschriftung des Speichern-Buttons; Standard: "Satz speichern". */
submitLabel?: string;
}
/**
* Eingabeformular für einen einzelnen Trainingssatz.
* Gewicht wird in Schritten von `exercise.weight_step_kg` angepasst,
* Wiederholungen in Einerschritten. Nach dem Speichern wird die Notiz geleert.
*/
export function LogEntryForm({
exercise,
setNumber,
@@ -41,7 +47,6 @@ export function LogEntryForm({
<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">
@@ -84,7 +89,6 @@ export function LogEntryForm({
</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">
@@ -112,7 +116,6 @@ export function LogEntryForm({
</div>
</div>
{/* Notiz */}
<div>
<input
type="text"

View File

@@ -1,13 +1,20 @@
import { useActiveSessionStore } from '../../stores/activeSessionStore';
/** Voreingestellte Pausenzeiten in Sekunden. */
const PRESETS = [60, 90, 120, 180];
/**
* Pause-Timer für die aktive Trainings-Session.
* Zeigt bei laufendem Timer Countdown und Fortschrittsbalken an;
* andernfalls Schnellauswahl-Buttons für die vordefinierten Pausenzeiten.
*/
export function RestTimer() {
const { timerSeconds, timerTarget, startTimer, stopTimer } = useActiveSessionStore();
const isRunning = timerSeconds > 0;
const progress = timerTarget > 0 ? ((timerTarget - timerSeconds) / timerTarget) * 100 : 0;
/** Formatiert Sekunden als `m:ss`. */
const formatTime = (s: number) => {
const mins = Math.floor(s / 60);
const secs = s % 60;

View File

@@ -13,8 +13,11 @@ import { useToastStore } from './toastStore';
interface ActiveSessionState {
session: Session | null;
exercises: Exercise[];
/** Zuletzt protokollierte Werte pro Übungs-ID wird für Vorausfüllung genutzt. */
lastLogs: Map<number, LastLogResponse>;
/** Verbleibende Sekunden des laufenden Pause-Timers. */
timerSeconds: number;
/** Ursprünglich eingestellte Sekunden für Fortschrittsbalken-Berechnung. */
timerTarget: number;
timerInterval: ReturnType<typeof setInterval> | null;
@@ -32,6 +35,13 @@ interface ActiveSessionState {
clearSession: () => void;
}
/**
* Store für die laufende Trainings-Session.
*
* Verwaltet Session-Zustand, protokollierte Sätze, letzte Trainingswerte
* und den Pause-Timer. Der Timer-Interval wird manuell verwaltet;
* beim Stopp muss `stopTimer()` explizit aufgerufen werden.
*/
export const useActiveSessionStore = create<ActiveSessionState>((set, get) => ({
session: null,
exercises: [],
@@ -45,7 +55,7 @@ export const useActiveSessionStore = create<ActiveSessionState>((set, get) => ({
const session = await api.sessions.create({ set_id: setId });
set({ session, exercises });
// Lade letzte Logs für alle Übungen
// Letzte Logs für alle Übungen vorladen, um Formular-Vorausfüllung zu ermöglichen
for (const ex of exercises) {
await get().fetchLastLog(ex.id);
}
@@ -65,7 +75,6 @@ export const useActiveSessionStore = create<ActiveSessionState>((set, get) => ({
set({ session: result.session, exercises: result.exercises || [] });
// Lade letzte Logs für alle Übungen
for (const ex of (result.exercises || [])) {
await get().fetchLastLog(ex.id);
}
@@ -93,7 +102,7 @@ export const useActiveSessionStore = create<ActiveSessionState>((set, get) => ({
if (!session) return null;
try {
const log = await api.sessions.createLog(session.id, data);
// Reload session to get updated logs
// Session neu laden, damit die Logs-Liste aktuell ist
const updated = await api.sessions.get(session.id);
set({ session: updated });
return log;
@@ -161,7 +170,7 @@ export const useActiveSessionStore = create<ActiveSessionState>((set, get) => ({
});
return lastLog;
} catch {
// 404 = noch kein Log vorhanden
// 404 bedeutet: noch kein Trainingseintrag für diese Übung vorhanden
return null;
}
},

View File

@@ -3,6 +3,7 @@ import { api } from '../api/client';
import type { Exercise, MuscleGroup, CreateExerciseRequest } from '../types';
import { useToastStore } from './toastStore';
/** Filterkriterien für die Übungsliste. */
interface ExerciseFilter {
muscleGroup: MuscleGroup | '';
query: string;
@@ -20,6 +21,7 @@ interface ExerciseState {
setFilter: (filter: Partial<ExerciseFilter>) => void;
}
/** Store für Übungsverwaltung inkl. Filterung und CRUD-Operationen. */
export const useExerciseStore = create<ExerciseState>((set, get) => ({
exercises: [],
loading: false,

View File

@@ -12,6 +12,7 @@ interface HistoryState {
fetchStats: () => Promise<void>;
}
/** Store für Trainings-Historie und Übungsstatistiken. */
export const useHistoryStore = create<HistoryState>((set) => ({
sessions: [],
loading: false,

View File

@@ -13,6 +13,7 @@ interface SetState {
deleteSet: (id: number) => Promise<boolean>;
}
/** Store für Trainings-Sets inkl. CRUD-Operationen. */
export const useSetStore = create<SetState>((set, get) => ({
sets: [],
loading: false,

View File

@@ -1,5 +1,6 @@
import { create } from 'zustand';
/** Eine einzelne Toast-Benachrichtigung. */
export interface Toast {
id: string;
type: 'success' | 'error' | 'info';
@@ -12,6 +13,13 @@ interface ToastState {
removeToast: (id: string) => void;
}
/**
* Store für temporäre Benachrichtigungen.
*
* Toasts verschwinden nach 3 Sekunden automatisch.
* Wird aus anderen Stores via `useToastStore.getState().addToast()` aufgerufen,
* um Store-zu-Store-Abhängigkeiten zu vermeiden.
*/
export const useToastStore = create<ToastState>((set) => ({
toasts: [],

View File

@@ -11,6 +11,16 @@ interface UserState {
deleteUser: (id: number) => Promise<boolean>;
}
/**
* Store für Nutzerverwaltung.
*
* Der aktive Nutzer wird im localStorage persistiert (`activeUser`-Feld),
* damit die Auswahl nach einem Seitenneustart erhalten bleibt.
*
* Dieser Store verwendet bewusst direkte `fetch`-Aufrufe statt `api/client.ts`,
* da `api/client.ts` seinerseits `getActiveUserId()` aus diesem Store liest
* und ein zirkulärer Import vermieden werden muss.
*/
export const useUserStore = create<UserState>()(
persist(
(set, get) => ({
@@ -27,7 +37,7 @@ export const useUserStore = create<UserState>()(
const users: User[] = await res.json();
set({ users });
// Aktiven Nutzer aktualisieren falls er sich geändert hat
// Aktiven Nutzer aktualisieren, falls sich seine Daten geändert haben
const { activeUser } = get();
if (activeUser) {
const updated = users.find((u) => u.id === activeUser.id);
@@ -64,7 +74,10 @@ export const useUserStore = create<UserState>()(
),
);
// Gibt die aktive User-ID zurück — wird von api/client.ts verwendet.
/**
* Gibt die ID des aktiven Nutzers als String zurück.
* Wird von `api/client.ts` verwendet, um den `X-User-ID`-Header zu setzen.
*/
export function getActiveUserId(): string | null {
const { activeUser } = useUserStore.getState();
return activeUser ? String(activeUser.id) : null;

View File

@@ -1,3 +1,4 @@
/** Bezeichner für Muskelgruppen spiegeln die DB-Enum-Werte wider. */
export type MuscleGroup =
| 'brust'
| 'ruecken'
@@ -9,6 +10,7 @@ export type MuscleGroup =
| 'ganzkoerper'
| 'sonstiges';
/** Alle Muskelgruppen mit Anzeigebezeichnungen für Select-Felder. */
export const MUSCLE_GROUPS: { value: MuscleGroup; label: string }[] = [
{ value: 'brust', label: 'Brust' },
{ value: 'ruecken', label: 'Rücken' },
@@ -21,6 +23,7 @@ export const MUSCLE_GROUPS: { value: MuscleGroup; label: string }[] = [
{ value: 'sonstiges', label: 'Sonstiges' },
];
/** Anzeigebezeichnungen für Muskelgruppen, direkt per Schlüssel abrufbar. */
export const MUSCLE_GROUP_LABELS: Record<MuscleGroup, string> = {
brust: 'Brust',
ruecken: 'Rücken',
@@ -33,6 +36,7 @@ export const MUSCLE_GROUP_LABELS: Record<MuscleGroup, string> = {
sonstiges: 'Sonstiges',
};
/** Tailwind-Hintergrundfarben für Muskelgruppen-Badges. */
export const MUSCLE_GROUP_COLORS: Record<MuscleGroup, string> = {
brust: 'bg-red-600',
ruecken: 'bg-blue-600',
@@ -45,18 +49,21 @@ export const MUSCLE_GROUP_COLORS: Record<MuscleGroup, string> = {
sonstiges: 'bg-gray-600',
};
/** Eine einzelne Kraftübung aus der Datenbank. */
export interface Exercise {
id: number;
name: string;
description: string;
muscle_group: MuscleGroup;
weight_step_kg: number;
/** Optionale Nummerierung aus dem Übungskatalog (UF#). */
exercise_number?: number;
created_at: string;
updated_at: string;
deleted_at?: string;
}
/** Ein Trainings-Set: Sammlung von Übungen, die gemeinsam trainiert werden. */
export interface TrainingSet {
id: number;
name: string;
@@ -65,6 +72,11 @@ export interface TrainingSet {
deleted_at?: string;
}
/**
* Ein einzelner protokollierter Satz innerhalb einer Trainings-Session.
* Der Übungsname wird denormalisiert gespeichert, damit gelöschte Übungen
* historische Einträge nicht verwaisen lassen.
*/
export interface SessionLog {
id: number;
session_id: number;
@@ -77,6 +89,7 @@ export interface SessionLog {
logged_at: string;
}
/** Eine Trainings-Session, optional mit allen protokollierten Sätzen. */
export interface Session {
id: number;
set_id: number;
@@ -87,11 +100,13 @@ export interface Session {
logs?: SessionLog[];
}
/** Antwort des Endpunkts "letztes Training" für eine Übung. */
export interface LastLogResponse {
weight_kg: number;
reps: number;
}
/** Aggregierte Statistiken für eine einzelne Übung. */
export interface ExerciseStats {
exercise_id: number;
exercise_name: string;
@@ -101,6 +116,7 @@ export interface ExerciseStats {
last_trained: string;
}
/** Metadaten eines hochgeladenen Übungsbilds. */
export interface ExerciseImage {
id: number;
exercise_id: number;
@@ -109,6 +125,7 @@ export interface ExerciseImage {
created_at: string;
}
/** Request-Body zum Erstellen oder Aktualisieren einer Übung. */
export interface CreateExerciseRequest {
name: string;
description: string;
@@ -117,30 +134,36 @@ export interface CreateExerciseRequest {
exercise_number?: number;
}
/** Request-Body zum Erstellen eines Trainings-Sets. */
export interface CreateSetRequest {
name: string;
exercise_ids: number[];
}
/** Request-Body zum Aktualisieren eines Trainings-Sets. */
export interface UpdateSetRequest {
name: string;
exercise_ids: number[];
}
/** Request-Body zum Starten einer neuen Trainings-Session. */
export interface CreateSessionRequest {
set_id: number;
}
/** Anwendungsnutzer. */
export interface User {
id: number;
name: string;
created_at: string;
}
/** Request-Body zum Anlegen eines neuen Nutzers. */
export interface CreateUserRequest {
name: string;
}
/** Request-Body zum Protokollieren eines Satzes in einer laufenden Session. */
export interface CreateLogRequest {
exercise_id: number;
set_number: number;
@@ -149,6 +172,7 @@ export interface CreateLogRequest {
note: string;
}
/** Request-Body zum nachträglichen Bearbeiten eines protokollierten Satzes. */
export interface UpdateLogRequest {
weight_kg?: number;
reps?: number;