diff --git a/backend/.claude/agent-memory/tester/MEMORY.md b/backend/.claude/agent-memory/tester/MEMORY.md new file mode 100644 index 0000000..af56ee0 --- /dev/null +++ b/backend/.claude/agent-memory/tester/MEMORY.md @@ -0,0 +1,3 @@ +# Tester Agent Memory + +- [Test database setup pattern](feedback_test_db_setup.md) — store.New appends query params; use os.CreateTemp instead of ":memory:" URIs diff --git a/backend/.claude/agent-memory/tester/feedback_test_db_setup.md b/backend/.claude/agent-memory/tester/feedback_test_db_setup.md new file mode 100644 index 0000000..6070c70 --- /dev/null +++ b/backend/.claude/agent-memory/tester/feedback_test_db_setup.md @@ -0,0 +1,18 @@ +--- +name: Test database setup pattern +description: How to create isolated test databases for store and handler tests in this project +type: feedback +--- + +Use `os.CreateTemp` to create a temporary SQLite file for each test. Pass that file path to `store.New`. + +**Why:** `store.New` always appends `?_journal_mode=WAL&_foreign_keys=ON` to the path argument. If you pass a URI like `file:name?mode=memory&cache=shared`, the result is a malformed DSN (`...?mode=memory&cache=shared?_journal_mode=WAL&...`) that SQLite rejects with "no such cache mode: shared". + +**How to apply:** In every `newTestStore` / `newHandlerWithStore` helper, do: +```go +f, _ := os.CreateTemp("", "krafttrainer-test-*.db") +f.Close() +dbPath := f.Name() +s, err := store.New(dbPath) +t.Cleanup(func() { s.Close(); os.Remove(dbPath) }) +``` diff --git a/backend/internal/handler/exercise.go b/backend/internal/handler/exercise.go index 0cced07..ee27cbf 100755 --- a/backend/internal/handler/exercise.go +++ b/backend/internal/handler/exercise.go @@ -6,6 +6,8 @@ import ( "net/http" ) +// handleListExercises behandelt GET /api/v1/exercises. +// Unterstützt optionale Query-Parameter: muscle_group und q (Namenssuche). func (h *Handler) handleListExercises(w http.ResponseWriter, r *http.Request) { uid, err := userID(r) if err != nil { @@ -23,6 +25,7 @@ func (h *Handler) handleListExercises(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, exercises) } +// handleCreateExercise behandelt POST /api/v1/exercises. func (h *Handler) handleCreateExercise(w http.ResponseWriter, r *http.Request) { uid, err := userID(r) if err != nil { @@ -48,6 +51,7 @@ func (h *Handler) handleCreateExercise(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, exercise) } +// handleUpdateExercise behandelt PUT /api/v1/exercises/{id}. func (h *Handler) handleUpdateExercise(w http.ResponseWriter, r *http.Request) { uid, err := userID(r) if err != nil { @@ -82,6 +86,8 @@ func (h *Handler) handleUpdateExercise(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, exercise) } +// handleDeleteExercise behandelt DELETE /api/v1/exercises/{id}. +// Führt einen Soft-Delete durch (setzt deleted_at). func (h *Handler) handleDeleteExercise(w http.ResponseWriter, r *http.Request) { uid, err := userID(r) if err != nil { diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 1dfe3b5..6bc514b 100755 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -61,26 +61,32 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // --- Hilfsfunktionen --- +// writeJSON schreibt data als JSON mit dem angegebenen HTTP-Statuscode. func writeJSON(w http.ResponseWriter, status int, data any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(data) } +// writeError schreibt eine Fehlerantwort im Format {"error": "..."}. func writeError(w http.ResponseWriter, status int, message string) { writeJSON(w, status, map[string]string{"error": message}) } +// decodeJSON dekodiert den Request-Body als JSON in dst. +// Unbekannte Felder werden als Fehler behandelt. func decodeJSON(r *http.Request, dst any) error { dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() return dec.Decode(dst) } +// pathID liest einen Integer-Pfadparameter aus dem Request. func pathID(r *http.Request, name string) (int64, error) { return strconv.ParseInt(r.PathValue(name), 10, 64) } +// queryInt liest einen Integer-Query-Parameter. Gibt defaultVal zurück wenn der Parameter fehlt oder ungültig ist. func queryInt(r *http.Request, name string, defaultVal int) int { v := r.URL.Query().Get(name) if v == "" { diff --git a/backend/internal/handler/image_handler.go b/backend/internal/handler/image_handler.go index 55665ef..f4a9e70 100644 --- a/backend/internal/handler/image_handler.go +++ b/backend/internal/handler/image_handler.go @@ -1,8 +1,8 @@ package handler import ( - "fmt" "io" + "log" "net/http" "os" "path/filepath" @@ -15,6 +15,7 @@ const ( uploadDir = "uploads" ) +// handleListImages behandelt GET /api/v1/exercises/{id}/images. func (h *Handler) handleListImages(w http.ResponseWriter, r *http.Request) { exerciseID, err := pathID(r, "id") if err != nil { @@ -30,6 +31,9 @@ func (h *Handler) handleListImages(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, images) } +// handleUploadImage behandelt POST /api/v1/exercises/{id}/images. +// Erwartet multipart/form-data mit Feld "image" (nur JPEG, max 5 MB). +// Die Datei wird unter uploads/.jpg gespeichert. func (h *Handler) handleUploadImage(w http.ResponseWriter, r *http.Request) { exerciseID, err := pathID(r, "id") if err != nil { @@ -99,6 +103,8 @@ func (h *Handler) handleUploadImage(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, img) } +// handleDeleteImage behandelt DELETE /api/v1/exercises/{id}/images/{imageId}. +// Löscht den Datenbankeintrag und die zugehörige Datei vom Dateisystem. func (h *Handler) handleDeleteImage(w http.ResponseWriter, r *http.Request) { imageID, err := pathID(r, "imageId") if err != nil { @@ -128,8 +134,8 @@ func (h *Handler) RegisterImageRoutes(mux *http.ServeMux) { mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadDir)))) - // uploads-Verzeichnis sicherstellen - os.MkdirAll(uploadDir, 0755) - - fmt.Println("Bild-Upload konfiguriert:", uploadDir) + // uploads-Verzeichnis beim Start sicherstellen + if err := os.MkdirAll(uploadDir, 0755); err != nil { + log.Printf("Warnung: Upload-Verzeichnis konnte nicht erstellt werden: %v", err) + } } diff --git a/backend/internal/handler/session.go b/backend/internal/handler/session.go index ff45ef5..b3a42e2 100755 --- a/backend/internal/handler/session.go +++ b/backend/internal/handler/session.go @@ -7,6 +7,9 @@ import ( "strings" ) +// handleGetActiveSession behandelt GET /api/v1/sessions/active. +// Gibt die offene Session des Nutzers mit den Übungen des zugehörigen Sets zurück, +// oder null wenn keine Session aktiv ist. func (h *Handler) handleGetActiveSession(w http.ResponseWriter, r *http.Request) { uid, err := userID(r) if err != nil { @@ -37,6 +40,8 @@ func (h *Handler) handleGetActiveSession(w http.ResponseWriter, r *http.Request) }) } +// handleCreateSession behandelt POST /api/v1/sessions. +// Gibt 409 zurück wenn bereits eine offene Session existiert. func (h *Handler) handleCreateSession(w http.ResponseWriter, r *http.Request) { uid, err := userID(r) if err != nil { @@ -70,6 +75,8 @@ func (h *Handler) handleCreateSession(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, session) } +// handleListSessions behandelt GET /api/v1/sessions. +// Unterstützt Query-Parameter: limit (Standard: 20) und offset (Standard: 0). func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) { uid, err := userID(r) if err != nil { @@ -87,6 +94,7 @@ func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, sessions) } +// handleGetSession behandelt GET /api/v1/sessions/{id}. func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) { id, err := pathID(r, "id") if err != nil { @@ -106,6 +114,8 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, session) } +// handleEndSession behandelt PUT /api/v1/sessions/{id}/end. +// Beendet eine offene Session und speichert optional eine Notiz. func (h *Handler) handleEndSession(w http.ResponseWriter, r *http.Request) { uid, err := userID(r) if err != nil { @@ -167,6 +177,9 @@ func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// handleCreateLog behandelt POST /api/v1/sessions/{id}/logs. +// Fügt einen Satz zu einer offenen Session hinzu. +// Gibt 409 zurück bei doppelter (exercise_id, set_number)-Kombination. func (h *Handler) handleCreateLog(w http.ResponseWriter, r *http.Request) { sessionID, err := pathID(r, "id") if err != nil { @@ -204,6 +217,8 @@ func (h *Handler) handleCreateLog(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, log) } +// handleUpdateLog behandelt PUT /api/v1/sessions/{id}/logs/{logId}. +// Alle Felder im Request sind optional (Partial Update). func (h *Handler) handleUpdateLog(w http.ResponseWriter, r *http.Request) { sessionID, err := pathID(r, "id") if err != nil { @@ -242,6 +257,7 @@ func (h *Handler) handleUpdateLog(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, log) } +// handleDeleteLog behandelt DELETE /api/v1/sessions/{id}/logs/{logId}. func (h *Handler) handleDeleteLog(w http.ResponseWriter, r *http.Request) { sessionID, err := pathID(r, "id") if err != nil { diff --git a/backend/internal/handler/stats.go b/backend/internal/handler/stats.go index 74a1052..a61de0a 100755 --- a/backend/internal/handler/stats.go +++ b/backend/internal/handler/stats.go @@ -2,6 +2,8 @@ package handler import "net/http" +// handleGetLastLog behandelt GET /api/v1/exercises/{id}/last-log. +// Gibt den zuletzt geloggten Satz der Übung zurück, oder 404 wenn noch kein Satz existiert. func (h *Handler) handleGetLastLog(w http.ResponseWriter, r *http.Request) { uid, err := userID(r) if err != nil { @@ -26,6 +28,8 @@ func (h *Handler) handleGetLastLog(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, lastLog) } +// handleGetExerciseHistory behandelt GET /api/v1/exercises/{id}/history. +// Unterstützt Query-Parameter: limit (Standard: 30). func (h *Handler) handleGetExerciseHistory(w http.ResponseWriter, r *http.Request) { uid, err := userID(r) if err != nil { @@ -47,6 +51,7 @@ func (h *Handler) handleGetExerciseHistory(w http.ResponseWriter, r *http.Reques writeJSON(w, http.StatusOK, logs) } +// handleGetStatsOverview behandelt GET /api/v1/stats/overview. func (h *Handler) handleGetStatsOverview(w http.ResponseWriter, r *http.Request) { uid, err := userID(r) if err != nil { diff --git a/backend/internal/handler/training_set.go b/backend/internal/handler/training_set.go index 7fc3d08..a40f0f3 100755 --- a/backend/internal/handler/training_set.go +++ b/backend/internal/handler/training_set.go @@ -7,6 +7,7 @@ import ( "strings" ) +// handleListSets behandelt GET /api/v1/sets. func (h *Handler) handleListSets(w http.ResponseWriter, r *http.Request) { uid, err := userID(r) if err != nil { @@ -22,6 +23,7 @@ func (h *Handler) handleListSets(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, sets) } +// handleCreateSet behandelt POST /api/v1/sets. func (h *Handler) handleCreateSet(w http.ResponseWriter, r *http.Request) { uid, err := userID(r) if err != nil { @@ -51,6 +53,8 @@ func (h *Handler) handleCreateSet(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, set) } +// handleUpdateSet behandelt PUT /api/v1/sets/{id}. +// Ersetzt Name und Übungszuordnungen vollständig. func (h *Handler) handleUpdateSet(w http.ResponseWriter, r *http.Request) { uid, err := userID(r) if err != nil { @@ -89,6 +93,8 @@ func (h *Handler) handleUpdateSet(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, set) } +// handleDeleteSet behandelt DELETE /api/v1/sets/{id}. +// Führt einen Soft-Delete durch (setzt deleted_at). func (h *Handler) handleDeleteSet(w http.ResponseWriter, r *http.Request) { uid, err := userID(r) if err != nil { diff --git a/backend/internal/handler/user.go b/backend/internal/handler/user.go index 1d7df49..d30196f 100644 --- a/backend/internal/handler/user.go +++ b/backend/internal/handler/user.go @@ -7,6 +7,7 @@ import ( "strings" ) +// handleListUsers behandelt GET /api/v1/users. func (h *Handler) handleListUsers(w http.ResponseWriter, r *http.Request) { users, err := h.store.ListUsers() if err != nil { @@ -16,6 +17,7 @@ func (h *Handler) handleListUsers(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, users) } +// handleCreateUser behandelt POST /api/v1/users. func (h *Handler) handleCreateUser(w http.ResponseWriter, r *http.Request) { var req model.CreateUserRequest if err := decodeJSON(r, &req); err != nil { @@ -35,6 +37,8 @@ func (h *Handler) handleCreateUser(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, user) } +// handleDeleteUser behandelt DELETE /api/v1/users/{id}. +// Gibt 409 zurück wenn es sich um den letzten Nutzer handelt. func (h *Handler) handleDeleteUser(w http.ResponseWriter, r *http.Request) { id, err := pathID(r, "id") if err != nil { diff --git a/backend/internal/store/session_store.go b/backend/internal/store/session_store.go index af9361d..ab74017 100755 --- a/backend/internal/store/session_store.go +++ b/backend/internal/store/session_store.go @@ -157,6 +157,8 @@ func (s *Store) CreateLog(sessionID int64, req *model.CreateLogRequest) (*model. return nil, err } + // exercise_name wird denormalisiert gespeichert, damit historische Logs + // erhalten bleiben wenn die Übung später gelöscht wird. var exerciseName string err := s.db.QueryRow(`SELECT name FROM exercises WHERE id = ? AND deleted_at IS NULL`, req.ExerciseID).Scan(&exerciseName) if err == sql.ErrNoRows { @@ -197,6 +199,7 @@ func (s *Store) UpdateLog(sessionID, logID int64, req *model.UpdateLogRequest) ( return nil, nil } + // Dynamisches UPDATE: nur explizit übergebene Felder werden geändert (Partial Update). updates := []string{} args := []any{} if req.WeightKg != nil { @@ -293,7 +296,8 @@ func (s *Store) DeleteSession(id, userID int64) error { return nil } -// checkSessionOpen prüft ob eine Session offen ist. +// checkSessionOpen prüft ob eine Session existiert und noch nicht beendet wurde. +// Gibt einen Fehler mit "SESSION_CLOSED" zurück wenn ended_at bereits gesetzt ist. func (s *Store) checkSessionOpen(sessionID int64) error { var endedAt *string err := s.db.QueryRow(`SELECT ended_at FROM sessions WHERE id = ?`, sessionID).Scan(&endedAt) @@ -309,7 +313,7 @@ func (s *Store) checkSessionOpen(sessionID int64) error { return nil } -// getLog gibt einen einzelnen Log-Eintrag zurück. +// getLog lädt einen einzelnen Session-Log-Eintrag anhand seiner ID. func (s *Store) getLog(id int64) (*model.SessionLog, error) { var log model.SessionLog err := s.db.QueryRow(` @@ -325,7 +329,7 @@ func (s *Store) getLog(id int64) (*model.SessionLog, error) { return &log, nil } -// getSessionLogs gibt alle Logs einer Session zurück. +// getSessionLogs lädt alle Logs einer Session, sortiert nach Übung und Satznummer. func (s *Store) getSessionLogs(sessionID int64) ([]model.SessionLog, error) { rows, err := s.db.Query(` SELECT id, session_id, exercise_id, exercise_name, set_number, weight_kg, reps, note, logged_at diff --git a/backend/internal/store/set_store.go b/backend/internal/store/set_store.go index 94509d3..0ade15d 100755 --- a/backend/internal/store/set_store.go +++ b/backend/internal/store/set_store.go @@ -168,7 +168,8 @@ func (s *Store) SoftDeleteSet(id, userID int64) error { return nil } -// getSetExercises lädt die Übungen eines Sets sortiert nach Position. +// getSetExercises lädt die nicht-gelöschten Übungen eines Sets, sortiert nach Position. +// Soft-gelöschte Übungen werden nicht zurückgegeben. func (s *Store) getSetExercises(setID int64) ([]model.Exercise, error) { rows, err := s.db.Query(` SELECT e.id, e.name, e.description, e.muscle_group, e.weight_step_kg, e.created_at, e.updated_at diff --git a/backend/internal/store/stats_store.go b/backend/internal/store/stats_store.go index 6f300d2..ca6dbff 100755 --- a/backend/internal/store/stats_store.go +++ b/backend/internal/store/stats_store.go @@ -5,7 +5,7 @@ import ( "krafttrainer/internal/model" ) -// StatsOverview enthält die Gesamtübersicht. +// StatsOverview enthält aggregierte Trainingsdaten eines Nutzers. type StatsOverview struct { TotalSessions int `json:"total_sessions"` TotalVolumeKg float64 `json:"total_volume_kg"` diff --git a/backend/internal/store/user_store.go b/backend/internal/store/user_store.go index dd04e8b..b19ffd7 100644 --- a/backend/internal/store/user_store.go +++ b/backend/internal/store/user_store.go @@ -59,6 +59,7 @@ func (s *Store) DeleteUser(id int64) error { return nil } +// getUser lädt einen Nutzer anhand seiner ID. func (s *Store) getUser(id int64) (*model.User, error) { var u model.User err := s.db.QueryRow(`SELECT id, name, created_at FROM users WHERE id = ?`, id). diff --git a/backend/server b/backend/server index e30d47d..ae5e5c6 100755 Binary files a/backend/server and b/backend/server differ diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 797dfa5..83ec502 100755 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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( url: string, options?: RequestInit, @@ -52,6 +62,7 @@ async function request( return data as T; } +/** Typisiertes API-Objekt mit allen Backend-Endpunkten unter `/api/v1`. */ export const api = { users: { list(): Promise { @@ -105,6 +116,10 @@ export const api = { return request(`/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 { const formData = new FormData(); formData.append('image', file); diff --git a/frontend/src/components/exercises/ExerciseCard.tsx b/frontend/src/components/exercises/ExerciseCard.tsx index fe3f6aa..de3272a 100755 --- a/frontend/src/components/exercises/ExerciseCard.tsx +++ b/frontend/src/components/exercises/ExerciseCard.tsx @@ -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'; diff --git a/frontend/src/components/exercises/ExerciseForm.tsx b/frontend/src/components/exercises/ExerciseForm.tsx index 75d112c..5c49141 100755 --- a/frontend/src/components/exercises/ExerciseForm.tsx +++ b/frontend/src/components/exercises/ExerciseForm.tsx @@ -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(''); diff --git a/frontend/src/components/exercises/ExerciseList.tsx b/frontend/src/components/exercises/ExerciseList.tsx index facb83d..1883ec4 100755 --- a/frontend/src/components/exercises/ExerciseList.tsx +++ b/frontend/src/components/exercises/ExerciseList.tsx @@ -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 (
- {/* Filter-Bar */}
- {/* Sortierbare ausgewählte Übungen */} {selectedIds.length > 0 && (