From a954f2c59d7d9e4fce1ccc70a3c77cdc2d84bb41 Mon Sep 17 00:00:00 2001 From: "Christoph K." Date: Sat, 21 Mar 2026 23:55:51 +0100 Subject: [PATCH] Add multi-user support with export feature - New users table (migration 004) with user_id on exercises, training_sets, sessions - User CRUD endpoints (GET/POST /api/v1/users, DELETE /api/v1/users/{id}) - All existing endpoints scoped to X-User-ID header - CSV export endpoint (GET /api/v1/export) for completed sessions - UserGate in PageShell: blocks app until a user is selected - Settings page for managing users (create, switch, delete) - BottomNav/Sidebar updated with settings navigation - Fix: nil pointer panic in handleDeleteUser on success path - Fix: export download now uses fetch with X-User-ID header instead of window.location.href Co-Authored-By: Claude Sonnet 4.6 --- backend/internal/handler/exercise.go | 29 ++++- backend/internal/handler/export.go | 42 +++++++ backend/internal/handler/handler.go | 22 ++++ backend/internal/handler/session.go | 23 +++- backend/internal/handler/stats.go | 22 +++- backend/internal/handler/training_set.go | 30 ++++- backend/internal/handler/user.go | 59 ++++++++++ backend/internal/model/user.go | 31 ++++++ backend/internal/store/exercise_store.go | 31 +++--- backend/internal/store/export_store.go | 40 +++++++ backend/internal/store/session_store.go | 40 ++++--- backend/internal/store/set_store.go | 31 +++--- backend/internal/store/stats_store.go | 31 +++--- backend/internal/store/user_store.go | 73 ++++++++++++ backend/migrations/004_add_users.down.sql | 4 + backend/migrations/004_add_users.up.sql | 12 ++ frontend/src/App.tsx | 2 + frontend/src/api/client.ts | 23 +++- frontend/src/components/layout/BottomNav.tsx | 23 +++- frontend/src/components/layout/PageShell.tsx | 110 +++++++++++++++++-- frontend/src/pages/HistoryPage.tsx | 34 +++++- frontend/src/pages/SettingsPage.tsx | 95 ++++++++++++++++ frontend/src/stores/userStore.ts | 71 ++++++++++++ frontend/src/types/index.ts | 10 ++ 24 files changed, 793 insertions(+), 95 deletions(-) create mode 100644 backend/internal/handler/export.go create mode 100644 backend/internal/handler/user.go create mode 100644 backend/internal/model/user.go create mode 100644 backend/internal/store/export_store.go create mode 100644 backend/internal/store/user_store.go create mode 100644 backend/migrations/004_add_users.down.sql create mode 100644 backend/migrations/004_add_users.up.sql create mode 100644 frontend/src/pages/SettingsPage.tsx create mode 100644 frontend/src/stores/userStore.ts diff --git a/backend/internal/handler/exercise.go b/backend/internal/handler/exercise.go index ad45263..0cced07 100755 --- a/backend/internal/handler/exercise.go +++ b/backend/internal/handler/exercise.go @@ -7,10 +7,15 @@ import ( ) func (h *Handler) handleListExercises(w http.ResponseWriter, r *http.Request) { + uid, err := userID(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } muscleGroup := r.URL.Query().Get("muscle_group") query := r.URL.Query().Get("q") - exercises, err := h.store.ListExercises(muscleGroup, query) + exercises, err := h.store.ListExercises(uid, muscleGroup, query) if err != nil { writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Übungen") return @@ -19,6 +24,12 @@ func (h *Handler) handleListExercises(w http.ResponseWriter, r *http.Request) { } func (h *Handler) handleCreateExercise(w http.ResponseWriter, r *http.Request) { + uid, err := userID(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + var req model.CreateExerciseRequest if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiger Request-Body") @@ -29,7 +40,7 @@ func (h *Handler) handleCreateExercise(w http.ResponseWriter, r *http.Request) { return } - exercise, err := h.store.CreateExercise(&req) + exercise, err := h.store.CreateExercise(uid, &req) if err != nil { writeError(w, http.StatusInternalServerError, "Fehler beim Erstellen der Übung") return @@ -38,6 +49,11 @@ func (h *Handler) handleCreateExercise(w http.ResponseWriter, r *http.Request) { } func (h *Handler) handleUpdateExercise(w http.ResponseWriter, r *http.Request) { + uid, err := userID(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } id, err := pathID(r, "id") if err != nil { writeError(w, http.StatusBadRequest, "Ungültige ID") @@ -54,7 +70,7 @@ func (h *Handler) handleUpdateExercise(w http.ResponseWriter, r *http.Request) { return } - exercise, err := h.store.UpdateExercise(id, &req) + exercise, err := h.store.UpdateExercise(id, uid, &req) if err != nil { writeError(w, http.StatusInternalServerError, "Fehler beim Aktualisieren der Übung") return @@ -67,13 +83,18 @@ func (h *Handler) handleUpdateExercise(w http.ResponseWriter, r *http.Request) { } func (h *Handler) handleDeleteExercise(w http.ResponseWriter, r *http.Request) { + uid, err := userID(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } id, err := pathID(r, "id") if err != nil { writeError(w, http.StatusBadRequest, "Ungültige ID") return } - err = h.store.SoftDeleteExercise(id) + err = h.store.SoftDeleteExercise(id, uid) if err == sql.ErrNoRows { writeError(w, http.StatusNotFound, "Übung nicht gefunden") return diff --git a/backend/internal/handler/export.go b/backend/internal/handler/export.go new file mode 100644 index 0000000..6ebb4e8 --- /dev/null +++ b/backend/internal/handler/export.go @@ -0,0 +1,42 @@ +package handler + +import ( + "encoding/csv" + "net/http" + "strconv" + "time" +) + +// handleExport gibt alle Trainingsdaten eines Nutzers als CSV zurück. +func (h *Handler) handleExport(w http.ResponseWriter, r *http.Request) { + uid, err := userID(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + rows, err := h.store.ExportLogs(uid) + if err != nil { + writeError(w, http.StatusInternalServerError, "Export fehlgeschlagen") + return + } + + filename := "training-export-" + time.Now().Format("2006-01-02") + ".csv" + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"") + + cw := csv.NewWriter(w) + cw.Write([]string{"datum", "uebung", "satz", "gewicht_kg", "wiederholungen", "notiz"}) + + for _, row := range rows { + cw.Write([]string{ + row.SessionStarted.Format("2006-01-02 15:04"), + row.ExerciseName, + strconv.Itoa(row.SetNumber), + strconv.FormatFloat(row.WeightKg, 'f', 2, 64), + strconv.Itoa(row.Reps), + row.Note, + }) + } + cw.Flush() +} diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 8724325..6ff0428 100755 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -2,6 +2,7 @@ package handler import ( "encoding/json" + "errors" "krafttrainer/internal/store" "net/http" "strconv" @@ -19,6 +20,11 @@ func New(store *store.Store) *Handler { // RegisterRoutes registriert alle API-Routen am ServeMux. func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + // Users (kein X-User-ID Header nötig) + mux.HandleFunc("GET /api/v1/users", h.handleListUsers) + mux.HandleFunc("POST /api/v1/users", h.handleCreateUser) + mux.HandleFunc("DELETE /api/v1/users/{id}", h.handleDeleteUser) + // Exercises mux.HandleFunc("GET /api/v1/exercises", h.handleListExercises) mux.HandleFunc("POST /api/v1/exercises", h.handleCreateExercise) @@ -46,6 +52,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/v1/exercises/{id}/last-log", h.handleGetLastLog) mux.HandleFunc("GET /api/v1/exercises/{id}/history", h.handleGetExerciseHistory) mux.HandleFunc("GET /api/v1/stats/overview", h.handleGetStatsOverview) + + // Export + mux.HandleFunc("GET /api/v1/export", h.handleExport) } // --- Hilfsfunktionen --- @@ -81,3 +90,16 @@ func queryInt(r *http.Request, name string, defaultVal int) int { } return n } + +// userID liest die X-User-ID aus dem Request-Header. +func userID(r *http.Request) (int64, error) { + v := r.Header.Get("X-User-ID") + if v == "" { + return 0, errors.New("X-User-ID Header fehlt") + } + id, err := strconv.ParseInt(v, 10, 64) + if err != nil || id <= 0 { + return 0, errors.New("ungültige User-ID") + } + return id, nil +} diff --git a/backend/internal/handler/session.go b/backend/internal/handler/session.go index 4b83cd2..9007f67 100755 --- a/backend/internal/handler/session.go +++ b/backend/internal/handler/session.go @@ -8,6 +8,12 @@ import ( ) func (h *Handler) handleCreateSession(w http.ResponseWriter, r *http.Request) { + uid, err := userID(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + var req model.CreateSessionRequest if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiger Request-Body") @@ -18,7 +24,7 @@ func (h *Handler) handleCreateSession(w http.ResponseWriter, r *http.Request) { return } - session, err := h.store.CreateSession(req.SetID) + session, err := h.store.CreateSession(uid, req.SetID) if err != nil { if strings.Contains(err.Error(), "existiert nicht") { writeError(w, http.StatusBadRequest, err.Error()) @@ -31,10 +37,15 @@ func (h *Handler) handleCreateSession(w http.ResponseWriter, r *http.Request) { } func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) { + uid, err := userID(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } limit := queryInt(r, "limit", 20) offset := queryInt(r, "offset", 0) - sessions, err := h.store.ListSessions(limit, offset) + sessions, err := h.store.ListSessions(uid, limit, offset) if err != nil { writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Sessions") return @@ -62,6 +73,11 @@ func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) { } func (h *Handler) handleEndSession(w http.ResponseWriter, r *http.Request) { + uid, err := userID(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } id, err := pathID(r, "id") if err != nil { writeError(w, http.StatusBadRequest, "Ungültige ID") @@ -71,10 +87,9 @@ func (h *Handler) handleEndSession(w http.ResponseWriter, r *http.Request) { var body struct { Note string `json:"note"` } - // Body ist optional decodeJSON(r, &body) - session, err := h.store.EndSession(id, body.Note) + session, err := h.store.EndSession(id, uid, body.Note) if err != nil { writeError(w, http.StatusInternalServerError, "Fehler beim Beenden der Session") return diff --git a/backend/internal/handler/stats.go b/backend/internal/handler/stats.go index e391b4b..74a1052 100755 --- a/backend/internal/handler/stats.go +++ b/backend/internal/handler/stats.go @@ -3,13 +3,18 @@ package handler import "net/http" func (h *Handler) handleGetLastLog(w http.ResponseWriter, r *http.Request) { + uid, err := userID(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } id, err := pathID(r, "id") if err != nil { writeError(w, http.StatusBadRequest, "Ungültige ID") return } - lastLog, err := h.store.GetLastLog(id) + lastLog, err := h.store.GetLastLog(id, uid) if err != nil { writeError(w, http.StatusInternalServerError, "Fehler beim Laden des letzten Logs") return @@ -22,6 +27,11 @@ func (h *Handler) handleGetLastLog(w http.ResponseWriter, r *http.Request) { } func (h *Handler) handleGetExerciseHistory(w http.ResponseWriter, r *http.Request) { + uid, err := userID(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } id, err := pathID(r, "id") if err != nil { writeError(w, http.StatusBadRequest, "Ungültige ID") @@ -29,7 +39,7 @@ func (h *Handler) handleGetExerciseHistory(w http.ResponseWriter, r *http.Reques } limit := queryInt(r, "limit", 30) - logs, err := h.store.GetExerciseHistory(id, limit) + logs, err := h.store.GetExerciseHistory(id, uid, limit) if err != nil { writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Übungshistorie") return @@ -38,7 +48,13 @@ func (h *Handler) handleGetExerciseHistory(w http.ResponseWriter, r *http.Reques } func (h *Handler) handleGetStatsOverview(w http.ResponseWriter, r *http.Request) { - overview, err := h.store.GetStatsOverview() + uid, err := userID(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + overview, err := h.store.GetStatsOverview(uid) if err != nil { writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Statistiken") return diff --git a/backend/internal/handler/training_set.go b/backend/internal/handler/training_set.go index 822dc6d..7fc3d08 100755 --- a/backend/internal/handler/training_set.go +++ b/backend/internal/handler/training_set.go @@ -8,7 +8,13 @@ import ( ) func (h *Handler) handleListSets(w http.ResponseWriter, r *http.Request) { - sets, err := h.store.ListSets() + uid, err := userID(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + sets, err := h.store.ListSets(uid) if err != nil { writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Sets") return @@ -17,6 +23,12 @@ func (h *Handler) handleListSets(w http.ResponseWriter, r *http.Request) { } func (h *Handler) handleCreateSet(w http.ResponseWriter, r *http.Request) { + uid, err := userID(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + var req model.CreateSetRequest if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiger Request-Body") @@ -27,7 +39,7 @@ func (h *Handler) handleCreateSet(w http.ResponseWriter, r *http.Request) { return } - set, err := h.store.CreateSet(&req) + set, err := h.store.CreateSet(uid, &req) if err != nil { if strings.Contains(err.Error(), "existiert nicht") { writeError(w, http.StatusBadRequest, err.Error()) @@ -40,6 +52,11 @@ func (h *Handler) handleCreateSet(w http.ResponseWriter, r *http.Request) { } func (h *Handler) handleUpdateSet(w http.ResponseWriter, r *http.Request) { + uid, err := userID(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } id, err := pathID(r, "id") if err != nil { writeError(w, http.StatusBadRequest, "Ungültige ID") @@ -56,7 +73,7 @@ func (h *Handler) handleUpdateSet(w http.ResponseWriter, r *http.Request) { return } - set, err := h.store.UpdateSet(id, &req) + set, err := h.store.UpdateSet(id, uid, &req) if err != nil { if strings.Contains(err.Error(), "existiert nicht") { writeError(w, http.StatusBadRequest, err.Error()) @@ -73,13 +90,18 @@ func (h *Handler) handleUpdateSet(w http.ResponseWriter, r *http.Request) { } func (h *Handler) handleDeleteSet(w http.ResponseWriter, r *http.Request) { + uid, err := userID(r) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } id, err := pathID(r, "id") if err != nil { writeError(w, http.StatusBadRequest, "Ungültige ID") return } - err = h.store.SoftDeleteSet(id) + err = h.store.SoftDeleteSet(id, uid) if err == sql.ErrNoRows { writeError(w, http.StatusNotFound, "Set nicht gefunden") return diff --git a/backend/internal/handler/user.go b/backend/internal/handler/user.go new file mode 100644 index 0000000..1d7df49 --- /dev/null +++ b/backend/internal/handler/user.go @@ -0,0 +1,59 @@ +package handler + +import ( + "database/sql" + "krafttrainer/internal/model" + "net/http" + "strings" +) + +func (h *Handler) handleListUsers(w http.ResponseWriter, r *http.Request) { + users, err := h.store.ListUsers() + if err != nil { + writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Nutzer") + return + } + writeJSON(w, http.StatusOK, users) +} + +func (h *Handler) handleCreateUser(w http.ResponseWriter, r *http.Request) { + var req model.CreateUserRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "Ungültiger Request-Body") + return + } + if err := req.Validate(); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + user, err := h.store.CreateUser(req.Name) + if err != nil { + writeError(w, http.StatusInternalServerError, "Fehler beim Erstellen des Nutzers") + return + } + writeJSON(w, http.StatusCreated, user) +} + +func (h *Handler) handleDeleteUser(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r, "id") + if err != nil { + writeError(w, http.StatusBadRequest, "Ungültige ID") + return + } + + err = h.store.DeleteUser(id) + if err != nil { + if err == sql.ErrNoRows { + writeError(w, http.StatusNotFound, "Nutzer nicht gefunden") + return + } + if strings.Contains(err.Error(), "LAST_USER") { + writeError(w, http.StatusConflict, "Der letzte Nutzer kann nicht gelöscht werden") + return + } + writeError(w, http.StatusInternalServerError, "Fehler beim Löschen des Nutzers") + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go new file mode 100644 index 0000000..51d524d --- /dev/null +++ b/backend/internal/model/user.go @@ -0,0 +1,31 @@ +package model + +import ( + "errors" + "strings" + "time" +) + +// User repräsentiert einen Nutzer der Applikation. +type User struct { + ID int64 `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` +} + +// CreateUserRequest enthält die Felder zum Anlegen eines Nutzers. +type CreateUserRequest struct { + Name string `json:"name"` +} + +// Validate prüft den Request. +func (r *CreateUserRequest) Validate() error { + r.Name = strings.TrimSpace(r.Name) + if len(r.Name) == 0 { + return errors.New("Name darf nicht leer sein") + } + if len(r.Name) > 50 { + return errors.New("Name darf maximal 50 Zeichen lang sein") + } + return nil +} diff --git a/backend/internal/store/exercise_store.go b/backend/internal/store/exercise_store.go index d259dfc..b03712a 100755 --- a/backend/internal/store/exercise_store.go +++ b/backend/internal/store/exercise_store.go @@ -6,16 +6,17 @@ import ( "krafttrainer/internal/model" ) -// ListExercises gibt alle nicht-gelöschten Übungen zurück, optional gefiltert. -func (s *Store) ListExercises(muscleGroup, query string) ([]model.Exercise, error) { +// ListExercises gibt alle nicht-gelöschten Übungen eines Nutzers zurück, optional gefiltert. +func (s *Store) ListExercises(userID int64, muscleGroup, query string) ([]model.Exercise, error) { rows, err := s.db.Query(` SELECT id, name, description, muscle_group, weight_step_kg, created_at, updated_at FROM exercises WHERE deleted_at IS NULL + AND user_id = ? AND (muscle_group = ? OR ? = '') AND (name LIKE '%' || ? || '%' OR ? = '') ORDER BY name`, - muscleGroup, muscleGroup, query, query, + userID, muscleGroup, muscleGroup, query, query, ) if err != nil { return nil, fmt.Errorf("Übungen abfragen: %w", err) @@ -36,7 +37,7 @@ func (s *Store) ListExercises(muscleGroup, query string) ([]model.Exercise, erro return exercises, rows.Err() } -// GetExercise gibt eine einzelne Übung zurück. +// GetExercise gibt eine einzelne Übung zurück (intern, ohne User-Scope). func (s *Store) GetExercise(id int64) (*model.Exercise, error) { var e model.Exercise err := s.db.QueryRow(` @@ -53,11 +54,11 @@ func (s *Store) GetExercise(id int64) (*model.Exercise, error) { } // CreateExercise legt eine neue Übung an und gibt sie zurück. -func (s *Store) CreateExercise(req *model.CreateExerciseRequest) (*model.Exercise, error) { +func (s *Store) CreateExercise(userID int64, req *model.CreateExerciseRequest) (*model.Exercise, error) { result, err := s.db.Exec(` - INSERT INTO exercises (name, description, muscle_group, weight_step_kg) - VALUES (?, ?, ?, ?)`, - req.Name, req.Description, req.MuscleGroup, *req.WeightStepKg, + INSERT INTO exercises (name, description, muscle_group, weight_step_kg, user_id) + VALUES (?, ?, ?, ?, ?)`, + req.Name, req.Description, req.MuscleGroup, *req.WeightStepKg, userID, ) if err != nil { return nil, fmt.Errorf("Übung erstellen: %w", err) @@ -67,14 +68,14 @@ func (s *Store) CreateExercise(req *model.CreateExerciseRequest) (*model.Exercis return s.GetExercise(id) } -// UpdateExercise aktualisiert eine Übung und gibt sie zurück. -func (s *Store) UpdateExercise(id int64, req *model.CreateExerciseRequest) (*model.Exercise, error) { +// UpdateExercise aktualisiert eine Übung eines Nutzers und gibt sie zurück. +func (s *Store) UpdateExercise(id, userID int64, req *model.CreateExerciseRequest) (*model.Exercise, error) { result, err := s.db.Exec(` UPDATE exercises SET name = ?, description = ?, muscle_group = ?, weight_step_kg = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? AND deleted_at IS NULL`, - req.Name, req.Description, req.MuscleGroup, *req.WeightStepKg, id, + WHERE id = ? AND user_id = ? AND deleted_at IS NULL`, + req.Name, req.Description, req.MuscleGroup, *req.WeightStepKg, id, userID, ) if err != nil { return nil, fmt.Errorf("Übung aktualisieren: %w", err) @@ -87,11 +88,11 @@ func (s *Store) UpdateExercise(id int64, req *model.CreateExerciseRequest) (*mod return s.GetExercise(id) } -// SoftDeleteExercise markiert eine Übung als gelöscht. -func (s *Store) SoftDeleteExercise(id int64) error { +// SoftDeleteExercise markiert eine Übung eines Nutzers als gelöscht. +func (s *Store) SoftDeleteExercise(id, userID int64) error { result, err := s.db.Exec(` UPDATE exercises SET deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP - WHERE id = ? AND deleted_at IS NULL`, id, + WHERE id = ? AND user_id = ? AND deleted_at IS NULL`, id, userID, ) if err != nil { return fmt.Errorf("Übung löschen: %w", err) diff --git a/backend/internal/store/export_store.go b/backend/internal/store/export_store.go new file mode 100644 index 0000000..aa887da --- /dev/null +++ b/backend/internal/store/export_store.go @@ -0,0 +1,40 @@ +package store + +import ( + "fmt" + "time" +) + +// ExportRow repräsentiert eine Zeile im Trainingsexport. +type ExportRow struct { + SessionStarted time.Time + ExerciseName string + SetNumber int + WeightKg float64 + Reps int + Note string +} + +// ExportLogs gibt alle Logs abgeschlossener Sessions eines Nutzers zurück (für AI-Export). +func (s *Store) ExportLogs(userID int64) ([]ExportRow, error) { + rows, err := s.db.Query(` + SELECT s.started_at, sl.exercise_name, sl.set_number, sl.weight_kg, sl.reps, sl.note + FROM session_logs sl + JOIN sessions s ON s.id = sl.session_id + WHERE s.ended_at IS NOT NULL AND s.user_id = ? + ORDER BY s.started_at, sl.exercise_name, sl.set_number`, userID) + if err != nil { + return nil, fmt.Errorf("Export abfragen: %w", err) + } + defer rows.Close() + + var result []ExportRow + for rows.Next() { + var row ExportRow + if err := rows.Scan(&row.SessionStarted, &row.ExerciseName, &row.SetNumber, &row.WeightKg, &row.Reps, &row.Note); err != nil { + return nil, fmt.Errorf("Export scannen: %w", err) + } + result = append(result, row) + } + return result, rows.Err() +} diff --git a/backend/internal/store/session_store.go b/backend/internal/store/session_store.go index 8b9f665..02e1878 100755 --- a/backend/internal/store/session_store.go +++ b/backend/internal/store/session_store.go @@ -7,11 +7,10 @@ import ( "strings" ) -// CreateSession startet eine neue Trainingseinheit. -func (s *Store) CreateSession(setID int64) (*model.Session, error) { - // Set prüfen +// CreateSession startet eine neue Trainingseinheit für einen Nutzer. +func (s *Store) CreateSession(userID, setID int64) (*model.Session, error) { var setName string - err := s.db.QueryRow(`SELECT name FROM training_sets WHERE id = ? AND deleted_at IS NULL`, setID).Scan(&setName) + err := s.db.QueryRow(`SELECT name FROM training_sets WHERE id = ? AND user_id = ? AND deleted_at IS NULL`, setID, userID).Scan(&setName) if err == sql.ErrNoRows { return nil, fmt.Errorf("Set %d existiert nicht", setID) } @@ -19,7 +18,7 @@ func (s *Store) CreateSession(setID int64) (*model.Session, error) { return nil, fmt.Errorf("Set prüfen: %w", err) } - result, err := s.db.Exec(`INSERT INTO sessions (set_id) VALUES (?)`, setID) + result, err := s.db.Exec(`INSERT INTO sessions (set_id, user_id) VALUES (?, ?)`, setID, userID) if err != nil { return nil, fmt.Errorf("Session erstellen: %w", err) } @@ -28,7 +27,7 @@ func (s *Store) CreateSession(setID int64) (*model.Session, error) { return s.GetSession(id) } -// GetSession gibt eine Session mit allen Logs zurück. +// GetSession gibt eine Session mit allen Logs zurück (intern, ohne User-Scope). func (s *Store) GetSession(id int64) (*model.Session, error) { var sess model.Session err := s.db.QueryRow(` @@ -52,11 +51,11 @@ func (s *Store) GetSession(id int64) (*model.Session, error) { return &sess, nil } -// EndSession beendet eine Session. -func (s *Store) EndSession(id int64, note string) (*model.Session, error) { +// EndSession beendet eine Session eines Nutzers. +func (s *Store) EndSession(id, userID int64, note string) (*model.Session, error) { result, err := s.db.Exec(` UPDATE sessions SET ended_at = CURRENT_TIMESTAMP, note = ? - WHERE id = ? AND ended_at IS NULL`, note, id, + WHERE id = ? AND user_id = ? AND ended_at IS NULL`, note, id, userID, ) if err != nil { return nil, fmt.Errorf("Session beenden: %w", err) @@ -68,14 +67,15 @@ func (s *Store) EndSession(id int64, note string) (*model.Session, error) { return s.GetSession(id) } -// ListSessions gibt paginierte Sessions zurück (neueste zuerst). -func (s *Store) ListSessions(limit, offset int) ([]model.Session, error) { +// ListSessions gibt paginierte Sessions eines Nutzers zurück (neueste zuerst). +func (s *Store) ListSessions(userID int64, limit, offset int) ([]model.Session, error) { rows, err := s.db.Query(` SELECT s.id, s.set_id, ts.name, s.started_at, s.ended_at, s.note FROM sessions s JOIN training_sets ts ON ts.id = s.set_id + WHERE s.user_id = ? ORDER BY s.started_at DESC - LIMIT ? OFFSET ?`, limit, offset, + LIMIT ? OFFSET ?`, userID, limit, offset, ) if err != nil { return nil, fmt.Errorf("Sessions abfragen: %w", err) @@ -98,12 +98,10 @@ func (s *Store) ListSessions(limit, offset int) ([]model.Session, error) { // CreateLog fügt einen Satz zu einer offenen Session hinzu. func (s *Store) CreateLog(sessionID int64, req *model.CreateLogRequest) (*model.SessionLog, error) { - // Session offen? if err := s.checkSessionOpen(sessionID); err != nil { return nil, err } - // Übungsname denormalisiert speichern 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 { @@ -135,7 +133,6 @@ func (s *Store) UpdateLog(sessionID, logID int64, req *model.UpdateLogRequest) ( return nil, err } - // Log gehört zur Session? var exists bool err := s.db.QueryRow(`SELECT EXISTS(SELECT 1 FROM session_logs WHERE id = ? AND session_id = ?)`, logID, sessionID).Scan(&exists) if err != nil { @@ -145,7 +142,6 @@ func (s *Store) UpdateLog(sessionID, logID int64, req *model.UpdateLogRequest) ( return nil, nil } - // Partielle Updates updates := []string{} args := []any{} if req.WeightKg != nil { @@ -192,13 +188,15 @@ func (s *Store) DeleteLog(sessionID, logID int64) error { return nil } -// GetLastLog gibt die letzten Werte einer Übung zurück. -func (s *Store) GetLastLog(exerciseID int64) (*model.LastLogResponse, error) { +// GetLastLog gibt die letzten Werte einer Übung für einen Nutzer zurück. +func (s *Store) GetLastLog(exerciseID, userID int64) (*model.LastLogResponse, error) { var resp model.LastLogResponse err := s.db.QueryRow(` - SELECT weight_kg, reps FROM session_logs - WHERE exercise_id = ? - ORDER BY logged_at DESC LIMIT 1`, exerciseID, + SELECT sl.weight_kg, sl.reps + FROM session_logs sl + JOIN sessions s ON s.id = sl.session_id + WHERE sl.exercise_id = ? AND s.user_id = ? + ORDER BY sl.logged_at DESC LIMIT 1`, exerciseID, userID, ).Scan(&resp.WeightKg, &resp.Reps) if err == sql.ErrNoRows { return nil, nil diff --git a/backend/internal/store/set_store.go b/backend/internal/store/set_store.go index ffe2ecf..94509d3 100755 --- a/backend/internal/store/set_store.go +++ b/backend/internal/store/set_store.go @@ -6,11 +6,11 @@ import ( "krafttrainer/internal/model" ) -// ListSets gibt alle nicht-gelöschten Sets mit ihren Übungen zurück. -func (s *Store) ListSets() ([]model.TrainingSet, error) { +// ListSets gibt alle nicht-gelöschten Sets eines Nutzers mit ihren Übungen zurück. +func (s *Store) ListSets(userID int64) ([]model.TrainingSet, error) { rows, err := s.db.Query(` SELECT id, name, created_at FROM training_sets - WHERE deleted_at IS NULL ORDER BY name`) + WHERE deleted_at IS NULL AND user_id = ? ORDER BY name`, userID) if err != nil { return nil, fmt.Errorf("Sets abfragen: %w", err) } @@ -41,7 +41,7 @@ func (s *Store) ListSets() ([]model.TrainingSet, error) { return sets, nil } -// GetSet gibt ein einzelnes Set mit Übungen zurück. +// GetSet gibt ein einzelnes Set mit Übungen zurück (intern, ohne User-Scope). func (s *Store) GetSet(id int64) (*model.TrainingSet, error) { var ts model.TrainingSet err := s.db.QueryRow(` @@ -63,17 +63,16 @@ func (s *Store) GetSet(id int64) (*model.TrainingSet, error) { } // CreateSet legt ein neues Set an (in einer Transaktion). -func (s *Store) CreateSet(req *model.CreateSetRequest) (*model.TrainingSet, error) { +func (s *Store) CreateSet(userID int64, req *model.CreateSetRequest) (*model.TrainingSet, error) { tx, err := s.db.Begin() if err != nil { return nil, fmt.Errorf("Transaktion starten: %w", err) } defer tx.Rollback() - // Prüfen ob alle Übungen existieren for _, eid := range req.ExerciseIDs { var exists bool - err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM exercises WHERE id = ? AND deleted_at IS NULL)`, eid).Scan(&exists) + err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM exercises WHERE id = ? AND user_id = ? AND deleted_at IS NULL)`, eid, userID).Scan(&exists) if err != nil { return nil, fmt.Errorf("Übung prüfen: %w", err) } @@ -82,7 +81,7 @@ func (s *Store) CreateSet(req *model.CreateSetRequest) (*model.TrainingSet, erro } } - result, err := tx.Exec(`INSERT INTO training_sets (name) VALUES (?)`, req.Name) + result, err := tx.Exec(`INSERT INTO training_sets (name, user_id) VALUES (?, ?)`, req.Name, userID) if err != nil { return nil, fmt.Errorf("Set erstellen: %w", err) } @@ -102,17 +101,16 @@ func (s *Store) CreateSet(req *model.CreateSetRequest) (*model.TrainingSet, erro return s.GetSet(id) } -// UpdateSet aktualisiert ein Set (Name + Übungszuordnungen). -func (s *Store) UpdateSet(id int64, req *model.UpdateSetRequest) (*model.TrainingSet, error) { +// UpdateSet aktualisiert ein Set eines Nutzers (Name + Übungszuordnungen). +func (s *Store) UpdateSet(id, userID int64, req *model.UpdateSetRequest) (*model.TrainingSet, error) { tx, err := s.db.Begin() if err != nil { return nil, fmt.Errorf("Transaktion starten: %w", err) } defer tx.Rollback() - // Prüfen ob Set existiert var exists bool - err = tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM training_sets WHERE id = ? AND deleted_at IS NULL)`, id).Scan(&exists) + err = tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM training_sets WHERE id = ? AND user_id = ? AND deleted_at IS NULL)`, id, userID).Scan(&exists) if err != nil { return nil, fmt.Errorf("Set prüfen: %w", err) } @@ -120,10 +118,9 @@ func (s *Store) UpdateSet(id int64, req *model.UpdateSetRequest) (*model.Trainin return nil, nil } - // Prüfen ob alle Übungen existieren for _, eid := range req.ExerciseIDs { var eExists bool - err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM exercises WHERE id = ? AND deleted_at IS NULL)`, eid).Scan(&eExists) + err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM exercises WHERE id = ? AND user_id = ? AND deleted_at IS NULL)`, eid, userID).Scan(&eExists) if err != nil { return nil, fmt.Errorf("Übung prüfen: %w", err) } @@ -155,11 +152,11 @@ func (s *Store) UpdateSet(id int64, req *model.UpdateSetRequest) (*model.Trainin return s.GetSet(id) } -// SoftDeleteSet markiert ein Set als gelöscht. -func (s *Store) SoftDeleteSet(id int64) error { +// SoftDeleteSet markiert ein Set eines Nutzers als gelöscht. +func (s *Store) SoftDeleteSet(id, userID int64) error { result, err := s.db.Exec(` UPDATE training_sets SET deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP - WHERE id = ? AND deleted_at IS NULL`, id, + WHERE id = ? AND user_id = ? AND deleted_at IS NULL`, id, userID, ) if err != nil { return fmt.Errorf("Set löschen: %w", err) diff --git a/backend/internal/store/stats_store.go b/backend/internal/store/stats_store.go index 9a14baa..6f300d2 100755 --- a/backend/internal/store/stats_store.go +++ b/backend/internal/store/stats_store.go @@ -13,16 +13,16 @@ type StatsOverview struct { Exercises []model.ExerciseStats `json:"exercises"` } -// GetStatsOverview gibt die Gesamtstatistik zurück. -func (s *Store) GetStatsOverview() (*StatsOverview, error) { +// GetStatsOverview gibt die Gesamtstatistik eines Nutzers zurück. +func (s *Store) GetStatsOverview(userID int64) (*StatsOverview, error) { var overview StatsOverview err := s.db.QueryRow(` SELECT - (SELECT COUNT(*) FROM sessions WHERE ended_at IS NOT NULL), - (SELECT COALESCE(SUM(weight_kg * reps), 0) FROM session_logs), - (SELECT COUNT(*) FROM sessions WHERE ended_at IS NOT NULL AND started_at >= date('now', '-7 days')) - `).Scan(&overview.TotalSessions, &overview.TotalVolumeKg, &overview.SessionsThisWeek) + (SELECT COUNT(*) FROM sessions WHERE user_id = ? AND ended_at IS NOT NULL), + (SELECT COALESCE(SUM(sl.weight_kg * sl.reps), 0) FROM session_logs sl JOIN sessions s ON s.id = sl.session_id WHERE s.user_id = ?), + (SELECT COUNT(*) FROM sessions WHERE user_id = ? AND ended_at IS NOT NULL AND started_at >= date('now', '-7 days')) + `, userID, userID, userID).Scan(&overview.TotalSessions, &overview.TotalVolumeKg, &overview.SessionsThisWeek) if err != nil { return nil, fmt.Errorf("Übersicht abfragen: %w", err) } @@ -36,8 +36,10 @@ func (s *Store) GetStatsOverview() (*StatsOverview, error) { COUNT(*) as total_sets, MAX(sl.logged_at) as last_trained FROM session_logs sl + JOIN sessions s ON s.id = sl.session_id + WHERE s.user_id = ? GROUP BY sl.exercise_id - ORDER BY last_trained DESC`) + ORDER BY last_trained DESC`, userID) if err != nil { return nil, fmt.Errorf("Übungs-Stats abfragen: %w", err) } @@ -56,14 +58,15 @@ func (s *Store) GetStatsOverview() (*StatsOverview, error) { return &overview, rows.Err() } -// GetExerciseHistory gibt die letzten N Logs einer Übung zurück. -func (s *Store) GetExerciseHistory(exerciseID int64, limit int) ([]model.SessionLog, error) { +// GetExerciseHistory gibt die letzten N Logs einer Übung für einen Nutzer zurück. +func (s *Store) GetExerciseHistory(exerciseID, userID int64, limit int) ([]model.SessionLog, error) { rows, err := s.db.Query(` - SELECT id, session_id, exercise_id, exercise_name, set_number, weight_kg, reps, note, logged_at - FROM session_logs - WHERE exercise_id = ? - ORDER BY logged_at DESC - LIMIT ?`, exerciseID, limit, + SELECT sl.id, sl.session_id, sl.exercise_id, sl.exercise_name, sl.set_number, sl.weight_kg, sl.reps, sl.note, sl.logged_at + FROM session_logs sl + JOIN sessions s ON s.id = sl.session_id + WHERE sl.exercise_id = ? AND s.user_id = ? + ORDER BY sl.logged_at DESC + LIMIT ?`, exerciseID, userID, limit, ) if err != nil { return nil, fmt.Errorf("Übungshistorie abfragen: %w", err) diff --git a/backend/internal/store/user_store.go b/backend/internal/store/user_store.go new file mode 100644 index 0000000..dd04e8b --- /dev/null +++ b/backend/internal/store/user_store.go @@ -0,0 +1,73 @@ +package store + +import ( + "database/sql" + "fmt" + "krafttrainer/internal/model" +) + +// ListUsers gibt alle Nutzer zurück. +func (s *Store) ListUsers() ([]model.User, error) { + rows, err := s.db.Query(`SELECT id, name, created_at FROM users ORDER BY created_at`) + if err != nil { + return nil, fmt.Errorf("Nutzer abfragen: %w", err) + } + defer rows.Close() + + var users []model.User + for rows.Next() { + var u model.User + if err := rows.Scan(&u.ID, &u.Name, &u.CreatedAt); err != nil { + return nil, fmt.Errorf("Nutzer scannen: %w", err) + } + users = append(users, u) + } + if users == nil { + users = []model.User{} + } + return users, rows.Err() +} + +// CreateUser legt einen neuen Nutzer an. +func (s *Store) CreateUser(name string) (*model.User, error) { + result, err := s.db.Exec(`INSERT INTO users (name) VALUES (?)`, name) + if err != nil { + return nil, fmt.Errorf("Nutzer erstellen: %w", err) + } + id, _ := result.LastInsertId() + return s.getUser(id) +} + +// DeleteUser löscht einen Nutzer, sofern noch mindestens ein weiterer existiert. +func (s *Store) DeleteUser(id int64) error { + var count int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil { + return fmt.Errorf("Nutzeranzahl prüfen: %w", err) + } + if count <= 1 { + return fmt.Errorf("LAST_USER: letzter Nutzer kann nicht gelöscht werden") + } + + result, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("Nutzer löschen: %w", err) + } + rows, _ := result.RowsAffected() + if rows == 0 { + return sql.ErrNoRows + } + return nil +} + +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). + Scan(&u.ID, &u.Name, &u.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("Nutzer abfragen: %w", err) + } + return &u, nil +} diff --git a/backend/migrations/004_add_users.down.sql b/backend/migrations/004_add_users.down.sql new file mode 100644 index 0000000..81a2899 --- /dev/null +++ b/backend/migrations/004_add_users.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE sessions DROP COLUMN user_id; +ALTER TABLE training_sets DROP COLUMN user_id; +ALTER TABLE exercises DROP COLUMN user_id; +DROP TABLE IF EXISTS users; diff --git a/backend/migrations/004_add_users.up.sql b/backend/migrations/004_add_users.up.sql new file mode 100644 index 0000000..79c6e74 --- /dev/null +++ b/backend/migrations/004_add_users.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL CHECK(length(name) >= 1 AND length(name) <= 50), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Standardnutzer für bestehende Daten +INSERT INTO users (name) VALUES ('Standard'); + +ALTER TABLE exercises ADD COLUMN user_id INTEGER NOT NULL DEFAULT 1; +ALTER TABLE training_sets ADD COLUMN user_id INTEGER NOT NULL DEFAULT 1; +ALTER TABLE sessions ADD COLUMN user_id INTEGER NOT NULL DEFAULT 1; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 266af0b..0505a0e 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { ExercisesPage } from './pages/ExercisesPage'; import { SetsPage } from './pages/SetsPage'; import { TrainingPage } from './pages/TrainingPage'; import { HistoryPage } from './pages/HistoryPage'; +import { SettingsPage } from './pages/SettingsPage'; const router = createBrowserRouter([ { @@ -13,6 +14,7 @@ const router = createBrowserRouter([ { path: '/sets', element: }, { path: '/training', element: }, { path: '/history', element: }, + { path: '/settings', element: }, ], }, ]); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d976430..f9bb89c 100755 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -5,6 +5,7 @@ import type { SessionLog, LastLogResponse, ExerciseStats, + User, CreateExerciseRequest, CreateSetRequest, UpdateSetRequest, @@ -12,6 +13,7 @@ import type { CreateLogRequest, UpdateLogRequest, } from '../types'; +import { getActiveUserId } from '../stores/userStore'; export class ApiError extends Error { constructor( @@ -27,8 +29,12 @@ async function request( url: string, options?: RequestInit, ): Promise { + const headers: Record = { 'Content-Type': 'application/json' }; + const uid = getActiveUserId(); + if (uid) headers['X-User-ID'] = uid; + const res = await fetch(url, { - headers: { 'Content-Type': 'application/json' }, + headers, ...options, }); @@ -46,6 +52,21 @@ async function request( } export const api = { + users: { + list(): Promise { + return request('/api/v1/users'); + }, + create(name: string): Promise { + return request('/api/v1/users', { + method: 'POST', + body: JSON.stringify({ name }), + }); + }, + delete(id: number): Promise { + return request(`/api/v1/users/${id}`, { method: 'DELETE' }); + }, + }, + exercises: { list(muscleGroup?: string, q?: string): Promise { const params = new URLSearchParams(); diff --git a/frontend/src/components/layout/BottomNav.tsx b/frontend/src/components/layout/BottomNav.tsx index ebf2b44..57437cb 100755 --- a/frontend/src/components/layout/BottomNav.tsx +++ b/frontend/src/components/layout/BottomNav.tsx @@ -1,4 +1,5 @@ import { NavLink } from 'react-router-dom'; +import { useUserStore } from '../../stores/userStore'; const navItems = [ { @@ -37,6 +38,16 @@ const navItems = [ ), }, + { + to: '/settings', + label: 'Einstellungen', + icon: ( + + + + + ), + }, ]; export function BottomNav() { @@ -64,10 +75,12 @@ export function BottomNav() { } export function Sidebar() { + const { activeUser } = useUserStore(); + return ( ); } diff --git a/frontend/src/components/layout/PageShell.tsx b/frontend/src/components/layout/PageShell.tsx index 0aac542..4997026 100755 --- a/frontend/src/components/layout/PageShell.tsx +++ b/frontend/src/components/layout/PageShell.tsx @@ -1,18 +1,108 @@ +import { useEffect, useState } from 'react'; import { Outlet } from 'react-router-dom'; import { BottomNav, Sidebar } from './BottomNav'; import { ToastContainer } from './Toast'; +import { useUserStore } from '../../stores/userStore'; + +function UserGate({ children }: { children: React.ReactNode }) { + const { users, activeUser, setActiveUser, fetchUsers, createUser } = useUserStore(); + const [newName, setNewName] = useState(''); + const [loading, setLoading] = useState(false); + const [fetched, setFetched] = useState(false); + + useEffect(() => { + fetchUsers().then(() => setFetched(true)); + }, [fetchUsers]); + + if (!fetched) { + return ( +
+
Laden…
+
+ ); + } + + if (!activeUser) { + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + const name = newName.trim(); + if (!name) return; + setLoading(true); + const user = await createUser(name); + setLoading(false); + if (user) setActiveUser(user); + } + + return ( +
+
+
+

Krafttrainer

+

Wer trainiert heute?

+
+ + {users.length > 0 && ( +
    + {users.map((user) => ( +
  • + +
  • + ))} +
+ )} + +
+

+ {users.length === 0 ? 'Ersten Nutzer anlegen:' : 'Oder neuen Nutzer anlegen:'} +

+
+ setNewName(e.target.value)} + placeholder="Name" + maxLength={50} + autoFocus + className="flex-1 bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 placeholder-gray-500 focus:outline-none focus:border-blue-500 min-h-[44px]" + /> + +
+
+
+
+ ); + } + + return <>{children}; +} export function PageShell() { return ( -
- -
-
- -
-
- - -
+ +
+ +
+
+ +
+
+ + +
+
); } diff --git a/frontend/src/pages/HistoryPage.tsx b/frontend/src/pages/HistoryPage.tsx index 3b0b4a3..29c988f 100755 --- a/frontend/src/pages/HistoryPage.tsx +++ b/frontend/src/pages/HistoryPage.tsx @@ -1,15 +1,47 @@ import { useState } from 'react'; import { SessionList } from '../components/history/SessionList'; import { ExerciseChart } from '../components/history/ExerciseChart'; +import { getActiveUserId } from '../stores/userStore'; type Tab = 'history' | 'stats'; +async function downloadExport() { + const uid = getActiveUserId(); + const headers: Record = {}; + if (uid) headers['X-User-ID'] = uid; + + const res = await fetch('/api/v1/export', { headers }); + if (!res.ok) return; + + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `training-export-${new Date().toISOString().slice(0, 10)}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + export function HistoryPage() { const [activeTab, setActiveTab] = useState('history'); return (
-

Historie

+
+

Historie

+ +
{/* Tab Toggle */}
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..c26b499 --- /dev/null +++ b/frontend/src/pages/SettingsPage.tsx @@ -0,0 +1,95 @@ +import { useEffect, useState } from 'react'; +import { useUserStore } from '../stores/userStore'; + +export function SettingsPage() { + const { users, activeUser, setActiveUser, fetchUsers, createUser, deleteUser } = + useUserStore(); + const [newName, setNewName] = useState(''); + const [loading, setLoading] = useState(false); + + useEffect(() => { + fetchUsers(); + }, [fetchUsers]); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + const name = newName.trim(); + if (!name) return; + setLoading(true); + const user = await createUser(name); + setLoading(false); + if (user) setNewName(''); + } + + async function handleDelete(id: number) { + await deleteUser(id); + } + + return ( +
+

Einstellungen

+ +
+

Nutzer

+ +
    + {users.map((user) => ( +
  • + + + {users.length > 1 && ( + + )} +
  • + ))} +
+ +
+ setNewName(e.target.value)} + placeholder="Neuer Nutzername" + maxLength={50} + className="flex-1 bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-gray-100 placeholder-gray-500 focus:outline-none focus:border-blue-500 min-h-[44px]" + /> + +
+
+
+ ); +} diff --git a/frontend/src/stores/userStore.ts b/frontend/src/stores/userStore.ts new file mode 100644 index 0000000..21c6932 --- /dev/null +++ b/frontend/src/stores/userStore.ts @@ -0,0 +1,71 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { User } from '../types'; + +interface UserState { + users: User[]; + activeUser: User | null; + setActiveUser: (user: User) => void; + fetchUsers: () => Promise; + createUser: (name: string) => Promise; + deleteUser: (id: number) => Promise; +} + +export const useUserStore = create()( + persist( + (set, get) => ({ + users: [], + activeUser: null, + + setActiveUser: (user) => set({ activeUser: user }), + + fetchUsers: async () => { + const res = await fetch('/api/v1/users', { + headers: { 'Content-Type': 'application/json' }, + }); + if (!res.ok) return; + const users: User[] = await res.json(); + set({ users }); + + // Aktiven Nutzer aktualisieren falls er sich geändert hat + const { activeUser } = get(); + if (activeUser) { + const updated = users.find((u) => u.id === activeUser.id); + if (updated) set({ activeUser: updated }); + } + }, + + createUser: async (name) => { + const res = await fetch('/api/v1/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + if (!res.ok) return null; + const user: User = await res.json(); + set((s) => ({ users: [...s.users, user] })); + return user; + }, + + deleteUser: async (id) => { + const res = await fetch(`/api/v1/users/${id}`, { method: 'DELETE' }); + if (!res.ok) return false; + set((s) => ({ + users: s.users.filter((u) => u.id !== id), + activeUser: s.activeUser?.id === id ? null : s.activeUser, + })); + return true; + }, + }), + { + name: 'user-store', + partialize: (state) => ({ activeUser: state.activeUser }), + }, + ), +); + +// Gibt die aktive User-ID zurück — wird von api/client.ts verwendet. +export function getActiveUserId(): string | null { + const { activeUser } = useUserStore.getState(); + return activeUser ? String(activeUser.id) : null; +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index fd424b8..b5073d1 100755 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -121,6 +121,16 @@ export interface CreateSessionRequest { set_id: number; } +export interface User { + id: number; + name: string; + created_at: string; +} + +export interface CreateUserRequest { + name: string; +} + export interface CreateLogRequest { exercise_id: number; set_number: number;