Initial commit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christoph K.
2026-03-21 15:03:55 +01:00
commit dfd66e43c6
78 changed files with 6219 additions and 0 deletions

View File

@@ -0,0 +1,86 @@
package handler
import (
"database/sql"
"krafttrainer/internal/model"
"net/http"
)
func (h *Handler) handleListExercises(w http.ResponseWriter, r *http.Request) {
muscleGroup := r.URL.Query().Get("muscle_group")
query := r.URL.Query().Get("q")
exercises, err := h.store.ListExercises(muscleGroup, query)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Übungen")
return
}
writeJSON(w, http.StatusOK, exercises)
}
func (h *Handler) handleCreateExercise(w http.ResponseWriter, r *http.Request) {
var req model.CreateExerciseRequest
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
}
exercise, err := h.store.CreateExercise(&req)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Erstellen der Übung")
return
}
writeJSON(w, http.StatusCreated, exercise)
}
func (h *Handler) handleUpdateExercise(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige ID")
return
}
var req model.CreateExerciseRequest
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
}
exercise, err := h.store.UpdateExercise(id, &req)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Aktualisieren der Übung")
return
}
if exercise == nil {
writeError(w, http.StatusNotFound, "Übung nicht gefunden")
return
}
writeJSON(w, http.StatusOK, exercise)
}
func (h *Handler) handleDeleteExercise(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.SoftDeleteExercise(id)
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "Übung nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Löschen der Übung")
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,83 @@
package handler
import (
"encoding/json"
"krafttrainer/internal/store"
"net/http"
"strconv"
)
// Handler bündelt alle HTTP-Handler und hält eine Referenz auf den Store.
type Handler struct {
store *store.Store
}
// New erstellt einen neuen Handler.
func New(store *store.Store) *Handler {
return &Handler{store: store}
}
// RegisterRoutes registriert alle API-Routen am ServeMux.
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Exercises
mux.HandleFunc("GET /api/v1/exercises", h.handleListExercises)
mux.HandleFunc("POST /api/v1/exercises", h.handleCreateExercise)
mux.HandleFunc("PUT /api/v1/exercises/{id}", h.handleUpdateExercise)
mux.HandleFunc("DELETE /api/v1/exercises/{id}", h.handleDeleteExercise)
// Training Sets
mux.HandleFunc("GET /api/v1/sets", h.handleListSets)
mux.HandleFunc("POST /api/v1/sets", h.handleCreateSet)
mux.HandleFunc("PUT /api/v1/sets/{id}", h.handleUpdateSet)
mux.HandleFunc("DELETE /api/v1/sets/{id}", h.handleDeleteSet)
// Sessions
mux.HandleFunc("POST /api/v1/sessions", h.handleCreateSession)
mux.HandleFunc("GET /api/v1/sessions", h.handleListSessions)
mux.HandleFunc("GET /api/v1/sessions/{id}", h.handleGetSession)
mux.HandleFunc("PUT /api/v1/sessions/{id}/end", h.handleEndSession)
// Session Logs
mux.HandleFunc("POST /api/v1/sessions/{id}/logs", h.handleCreateLog)
mux.HandleFunc("PUT /api/v1/sessions/{id}/logs/{logId}", h.handleUpdateLog)
mux.HandleFunc("DELETE /api/v1/sessions/{id}/logs/{logId}", h.handleDeleteLog)
// Stats
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)
}
// --- Hilfsfunktionen ---
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func decodeJSON(r *http.Request, dst any) error {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
return dec.Decode(dst)
}
func pathID(r *http.Request, name string) (int64, error) {
return strconv.ParseInt(r.PathValue(name), 10, 64)
}
func queryInt(r *http.Request, name string, defaultVal int) int {
v := r.URL.Query().Get(name)
if v == "" {
return defaultVal
}
n, err := strconv.Atoi(v)
if err != nil {
return defaultVal
}
return n
}

View File

@@ -0,0 +1,52 @@
package handler
import (
"log"
"net/http"
"time"
)
// Chain wendet Middlewares in der angegebenen Reihenfolge an.
func Chain(h http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
h = middlewares[i](h)
}
return h
}
// CORS erlaubt Cross-Origin-Requests vom Vite Dev-Server.
func CORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "http://*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// RequestLogger loggt eingehende Requests mit Dauer.
func RequestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
})
}
// Recoverer fängt Panics in Handlern ab und gibt 500 zurück.
func Recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("PANIC: %v", err)
writeError(w, http.StatusInternalServerError, "Interner Serverfehler")
}
}()
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,190 @@
package handler
import (
"database/sql"
"krafttrainer/internal/model"
"net/http"
"strings"
)
func (h *Handler) handleCreateSession(w http.ResponseWriter, r *http.Request) {
var req model.CreateSessionRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
return
}
if req.SetID == 0 {
writeError(w, http.StatusBadRequest, "set_id ist erforderlich")
return
}
session, err := h.store.CreateSession(req.SetID)
if err != nil {
if strings.Contains(err.Error(), "existiert nicht") {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeError(w, http.StatusInternalServerError, "Fehler beim Starten der Session")
return
}
writeJSON(w, http.StatusCreated, session)
}
func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) {
limit := queryInt(r, "limit", 20)
offset := queryInt(r, "offset", 0)
sessions, err := h.store.ListSessions(limit, offset)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Sessions")
return
}
writeJSON(w, http.StatusOK, sessions)
}
func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige ID")
return
}
session, err := h.store.GetSession(id)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Session")
return
}
if session == nil {
writeError(w, http.StatusNotFound, "Session nicht gefunden")
return
}
writeJSON(w, http.StatusOK, session)
}
func (h *Handler) handleEndSession(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige ID")
return
}
var body struct {
Note string `json:"note"`
}
// Body ist optional
decodeJSON(r, &body)
session, err := h.store.EndSession(id, body.Note)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Beenden der Session")
return
}
if session == nil {
writeError(w, http.StatusNotFound, "Session nicht gefunden oder bereits beendet")
return
}
writeJSON(w, http.StatusOK, session)
}
func (h *Handler) handleCreateLog(w http.ResponseWriter, r *http.Request) {
sessionID, err := pathID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Session-ID")
return
}
var req model.CreateLogRequest
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
}
log, err := h.store.CreateLog(sessionID, &req)
if err != nil {
if strings.Contains(err.Error(), "SESSION_CLOSED") {
writeError(w, http.StatusBadRequest, "Session ist bereits beendet")
return
}
if strings.Contains(err.Error(), "UNIQUE_VIOLATION") {
writeError(w, http.StatusConflict, err.Error())
return
}
if strings.Contains(err.Error(), "existiert nicht") {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeError(w, http.StatusInternalServerError, "Fehler beim Loggen des Satzes")
return
}
writeJSON(w, http.StatusCreated, log)
}
func (h *Handler) handleUpdateLog(w http.ResponseWriter, r *http.Request) {
sessionID, err := pathID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Session-ID")
return
}
logID, err := pathID(r, "logId")
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Log-ID")
return
}
var req model.UpdateLogRequest
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
}
log, err := h.store.UpdateLog(sessionID, logID, &req)
if err != nil {
if strings.Contains(err.Error(), "SESSION_CLOSED") {
writeError(w, http.StatusBadRequest, "Session ist bereits beendet")
return
}
writeError(w, http.StatusInternalServerError, "Fehler beim Aktualisieren des Satzes")
return
}
if log == nil {
writeError(w, http.StatusNotFound, "Log nicht gefunden")
return
}
writeJSON(w, http.StatusOK, log)
}
func (h *Handler) handleDeleteLog(w http.ResponseWriter, r *http.Request) {
sessionID, err := pathID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Session-ID")
return
}
logID, err := pathID(r, "logId")
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Log-ID")
return
}
err = h.store.DeleteLog(sessionID, logID)
if err != nil {
if strings.Contains(err.Error(), "SESSION_CLOSED") {
writeError(w, http.StatusBadRequest, "Session ist bereits beendet")
return
}
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "Log nicht gefunden")
return
}
writeError(w, http.StatusInternalServerError, "Fehler beim Löschen des Satzes")
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,47 @@
package handler
import "net/http"
func (h *Handler) handleGetLastLog(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige ID")
return
}
lastLog, err := h.store.GetLastLog(id)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Laden des letzten Logs")
return
}
if lastLog == nil {
writeError(w, http.StatusNotFound, "Noch kein Log für diese Übung")
return
}
writeJSON(w, http.StatusOK, lastLog)
}
func (h *Handler) handleGetExerciseHistory(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige ID")
return
}
limit := queryInt(r, "limit", 30)
logs, err := h.store.GetExerciseHistory(id, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Übungshistorie")
return
}
writeJSON(w, http.StatusOK, logs)
}
func (h *Handler) handleGetStatsOverview(w http.ResponseWriter, r *http.Request) {
overview, err := h.store.GetStatsOverview()
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Statistiken")
return
}
writeJSON(w, http.StatusOK, overview)
}

View File

@@ -0,0 +1,92 @@
package handler
import (
"database/sql"
"krafttrainer/internal/model"
"net/http"
"strings"
)
func (h *Handler) handleListSets(w http.ResponseWriter, r *http.Request) {
sets, err := h.store.ListSets()
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Laden der Sets")
return
}
writeJSON(w, http.StatusOK, sets)
}
func (h *Handler) handleCreateSet(w http.ResponseWriter, r *http.Request) {
var req model.CreateSetRequest
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
}
set, err := h.store.CreateSet(&req)
if err != nil {
if strings.Contains(err.Error(), "existiert nicht") {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeError(w, http.StatusInternalServerError, "Fehler beim Erstellen des Sets")
return
}
writeJSON(w, http.StatusCreated, set)
}
func (h *Handler) handleUpdateSet(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültige ID")
return
}
var req model.UpdateSetRequest
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
}
set, err := h.store.UpdateSet(id, &req)
if err != nil {
if strings.Contains(err.Error(), "existiert nicht") {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeError(w, http.StatusInternalServerError, "Fehler beim Aktualisieren des Sets")
return
}
if set == nil {
writeError(w, http.StatusNotFound, "Set nicht gefunden")
return
}
writeJSON(w, http.StatusOK, set)
}
func (h *Handler) handleDeleteSet(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.SoftDeleteSet(id)
if err == sql.ErrNoRows {
writeError(w, http.StatusNotFound, "Set nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Löschen des Sets")
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,36 @@
package migrate
import (
"database/sql"
"errors"
"fmt"
"io/fs"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
)
// Run führt alle ausstehenden Migrationen aus.
// migrationsFS muss die Migrations-Dateien enthalten.
func Run(db *sql.DB, migrationsFS fs.FS) error {
driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
if err != nil {
return fmt.Errorf("Migrations-Treiber erstellen: %w", err)
}
source, err := iofs.New(migrationsFS, ".")
if err != nil {
return fmt.Errorf("Migrations-Quelle erstellen: %w", err)
}
m, err := migrate.NewWithInstance("iofs", source, "sqlite3", driver)
if err != nil {
return fmt.Errorf("Migrator erstellen: %w", err)
}
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("Migrationen ausführen: %w", err)
}
return nil
}

View File

@@ -0,0 +1,47 @@
package model
import (
"errors"
"strings"
"time"
)
// Exercise repräsentiert eine Kraftübung.
type Exercise struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
MuscleGroup string `json:"muscle_group"`
WeightStepKg float64 `json:"weight_step_kg"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
// CreateExerciseRequest enthält die Felder zum Anlegen einer Übung.
type CreateExerciseRequest struct {
Name string `json:"name"`
Description string `json:"description"`
MuscleGroup string `json:"muscle_group"`
WeightStepKg *float64 `json:"weight_step_kg"`
}
// Validate prüft und normalisiert den Request. Setzt Default für WeightStepKg.
func (r *CreateExerciseRequest) Validate() error {
r.Name = strings.TrimSpace(r.Name)
if len(r.Name) == 0 || len(r.Name) > 100 {
return errors.New("Name muss 1100 Zeichen lang sein")
}
if !ValidMuscleGroup(r.MuscleGroup) {
return errors.New("Ungültige Muskelgruppe")
}
if r.WeightStepKg != nil {
if *r.WeightStepKg <= 0 {
return errors.New("Gewichtsschritt muss > 0 sein")
}
} else {
def := 2.5
r.WeightStepKg = &def
}
return nil
}

View File

@@ -0,0 +1,19 @@
package model
import "time"
// Session repräsentiert eine Trainingseinheit.
type Session struct {
ID int64 `json:"id"`
SetID int64 `json:"set_id"`
SetName string `json:"set_name"`
StartedAt time.Time `json:"started_at"`
EndedAt *time.Time `json:"ended_at,omitempty"`
Note string `json:"note"`
Logs []SessionLog `json:"logs,omitempty"`
}
// CreateSessionRequest enthält die Felder zum Starten einer Session.
type CreateSessionRequest struct {
SetID int64 `json:"set_id"`
}

View File

@@ -0,0 +1,76 @@
package model
import (
"errors"
"time"
)
// SessionLog repräsentiert einen einzelnen Satz innerhalb einer Session.
type SessionLog struct {
ID int64 `json:"id"`
SessionID int64 `json:"session_id"`
ExerciseID int64 `json:"exercise_id"`
ExerciseName string `json:"exercise_name"`
SetNumber int `json:"set_number"`
WeightKg float64 `json:"weight_kg"`
Reps int `json:"reps"`
Note string `json:"note"`
LoggedAt time.Time `json:"logged_at"`
}
// CreateLogRequest enthält die Felder zum Loggen eines Satzes.
type CreateLogRequest struct {
ExerciseID int64 `json:"exercise_id"`
SetNumber int `json:"set_number"`
WeightKg float64 `json:"weight_kg"`
Reps int `json:"reps"`
Note string `json:"note"`
}
// Validate prüft den Request.
func (r *CreateLogRequest) Validate() error {
if r.SetNumber < 1 {
return errors.New("Satznummer muss ≥ 1 sein")
}
if r.WeightKg < 0 || r.WeightKg > 999 {
return errors.New("Gewicht muss zwischen 0 und 999 kg liegen")
}
if r.Reps < 0 || r.Reps > 999 {
return errors.New("Wiederholungen müssen zwischen 0 und 999 liegen")
}
return nil
}
// UpdateLogRequest enthält die Felder zum Korrigieren eines Satzes.
type UpdateLogRequest struct {
WeightKg *float64 `json:"weight_kg"`
Reps *int `json:"reps"`
Note *string `json:"note"`
}
// Validate prüft den Request.
func (r *UpdateLogRequest) Validate() error {
if r.WeightKg != nil && (*r.WeightKg < 0 || *r.WeightKg > 999) {
return errors.New("Gewicht muss zwischen 0 und 999 kg liegen")
}
if r.Reps != nil && (*r.Reps < 0 || *r.Reps > 999) {
return errors.New("Wiederholungen müssen zwischen 0 und 999 liegen")
}
return nil
}
// LastLogResponse enthält die letzten Werte einer Übung.
type LastLogResponse struct {
WeightKg float64 `json:"weight_kg"`
Reps int `json:"reps"`
}
// ExerciseStats enthält aggregierte Statistiken einer Übung.
type ExerciseStats struct {
ExerciseID int64 `json:"exercise_id"`
ExerciseName string `json:"exercise_name"`
MaxWeightKg float64 `json:"max_weight_kg"`
TotalVolumeKg float64 `json:"total_volume_kg"`
TotalSets int `json:"total_sets"`
LastTrained string `json:"last_trained"`
}

View File

@@ -0,0 +1,52 @@
package model
import (
"errors"
"strings"
"time"
)
// TrainingSet ist eine benannte Zusammenstellung von Übungen.
type TrainingSet struct {
ID int64 `json:"id"`
Name string `json:"name"`
Exercises []Exercise `json:"exercises"`
CreatedAt time.Time `json:"created_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
// CreateSetRequest enthält die Felder zum Anlegen eines Sets.
type CreateSetRequest struct {
Name string `json:"name"`
ExerciseIDs []int64 `json:"exercise_ids"`
}
// Validate prüft und normalisiert den Request.
func (r *CreateSetRequest) Validate() error {
r.Name = strings.TrimSpace(r.Name)
if len(r.Name) == 0 || len(r.Name) > 100 {
return errors.New("Name muss 1100 Zeichen lang sein")
}
if len(r.ExerciseIDs) == 0 {
return errors.New("Mindestens eine Übung erforderlich")
}
return nil
}
// UpdateSetRequest enthält die Felder zum Aktualisieren eines Sets.
type UpdateSetRequest struct {
Name string `json:"name"`
ExerciseIDs []int64 `json:"exercise_ids"`
}
// Validate prüft und normalisiert den Request.
func (r *UpdateSetRequest) Validate() error {
r.Name = strings.TrimSpace(r.Name)
if len(r.Name) == 0 || len(r.Name) > 100 {
return errors.New("Name muss 1100 Zeichen lang sein")
}
if len(r.ExerciseIDs) == 0 {
return errors.New("Mindestens eine Übung erforderlich")
}
return nil
}

View File

@@ -0,0 +1,28 @@
package model
// Gültige Muskelgruppen für Übungen.
var muscleGroups = map[string]bool{
"brust": true,
"ruecken": true,
"schultern": true,
"bizeps": true,
"trizeps": true,
"beine": true,
"bauch": true,
"ganzkoerper": true,
"sonstiges": true,
}
// ValidMuscleGroup prüft ob die übergebene Muskelgruppe gültig ist.
func ValidMuscleGroup(mg string) bool {
return muscleGroups[mg]
}
// MuscleGroups gibt alle gültigen Muskelgruppen zurück.
func MuscleGroups() []string {
groups := make([]string, 0, len(muscleGroups))
for g := range muscleGroups {
groups = append(groups, g)
}
return groups
}

View File

@@ -0,0 +1,105 @@
package store
import (
"database/sql"
"fmt"
"krafttrainer/internal/model"
)
// ListExercises gibt alle nicht-gelöschten Übungen zurück, optional gefiltert.
func (s *Store) ListExercises(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 (muscle_group = ? OR ? = '')
AND (name LIKE '%' || ? || '%' OR ? = '')
ORDER BY name`,
muscleGroup, muscleGroup, query, query,
)
if err != nil {
return nil, fmt.Errorf("Übungen abfragen: %w", err)
}
defer rows.Close()
var exercises []model.Exercise
for rows.Next() {
var e model.Exercise
if err := rows.Scan(&e.ID, &e.Name, &e.Description, &e.MuscleGroup, &e.WeightStepKg, &e.CreatedAt, &e.UpdatedAt); err != nil {
return nil, fmt.Errorf("Übung scannen: %w", err)
}
exercises = append(exercises, e)
}
if exercises == nil {
exercises = []model.Exercise{}
}
return exercises, rows.Err()
}
// GetExercise gibt eine einzelne Übung zurück.
func (s *Store) GetExercise(id int64) (*model.Exercise, error) {
var e model.Exercise
err := s.db.QueryRow(`
SELECT id, name, description, muscle_group, weight_step_kg, created_at, updated_at, deleted_at
FROM exercises WHERE id = ?`, id,
).Scan(&e.ID, &e.Name, &e.Description, &e.MuscleGroup, &e.WeightStepKg, &e.CreatedAt, &e.UpdatedAt, &e.DeletedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("Übung abfragen: %w", err)
}
return &e, nil
}
// CreateExercise legt eine neue Übung an und gibt sie zurück.
func (s *Store) CreateExercise(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,
)
if err != nil {
return nil, fmt.Errorf("Übung erstellen: %w", err)
}
id, _ := result.LastInsertId()
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) {
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,
)
if err != nil {
return nil, fmt.Errorf("Übung aktualisieren: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return nil, nil
}
return s.GetExercise(id)
}
// SoftDeleteExercise markiert eine Übung als gelöscht.
func (s *Store) SoftDeleteExercise(id 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,
)
if err != nil {
return fmt.Errorf("Übung löschen: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return sql.ErrNoRows
}
return nil
}

View File

@@ -0,0 +1,269 @@
package store
import (
"database/sql"
"fmt"
"krafttrainer/internal/model"
"strings"
)
// CreateSession startet eine neue Trainingseinheit.
func (s *Store) CreateSession(setID int64) (*model.Session, error) {
// Set prüfen
var setName string
err := s.db.QueryRow(`SELECT name FROM training_sets WHERE id = ? AND deleted_at IS NULL`, setID).Scan(&setName)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("Set %d existiert nicht", setID)
}
if err != nil {
return nil, fmt.Errorf("Set prüfen: %w", err)
}
result, err := s.db.Exec(`INSERT INTO sessions (set_id) VALUES (?)`, setID)
if err != nil {
return nil, fmt.Errorf("Session erstellen: %w", err)
}
id, _ := result.LastInsertId()
return s.GetSession(id)
}
// GetSession gibt eine Session mit allen Logs zurück.
func (s *Store) GetSession(id int64) (*model.Session, error) {
var sess model.Session
err := s.db.QueryRow(`
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.id = ?`, id,
).Scan(&sess.ID, &sess.SetID, &sess.SetName, &sess.StartedAt, &sess.EndedAt, &sess.Note)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("Session abfragen: %w", err)
}
logs, err := s.getSessionLogs(id)
if err != nil {
return nil, err
}
sess.Logs = logs
return &sess, nil
}
// EndSession beendet eine Session.
func (s *Store) EndSession(id 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,
)
if err != nil {
return nil, fmt.Errorf("Session beenden: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return nil, nil
}
return s.GetSession(id)
}
// ListSessions gibt paginierte Sessions zurück (neueste zuerst).
func (s *Store) ListSessions(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
ORDER BY s.started_at DESC
LIMIT ? OFFSET ?`, limit, offset,
)
if err != nil {
return nil, fmt.Errorf("Sessions abfragen: %w", err)
}
defer rows.Close()
var sessions []model.Session
for rows.Next() {
var sess model.Session
if err := rows.Scan(&sess.ID, &sess.SetID, &sess.SetName, &sess.StartedAt, &sess.EndedAt, &sess.Note); err != nil {
return nil, fmt.Errorf("Session scannen: %w", err)
}
sessions = append(sessions, sess)
}
if sessions == nil {
sessions = []model.Session{}
}
return sessions, rows.Err()
}
// 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 {
return nil, fmt.Errorf("Übung %d existiert nicht", req.ExerciseID)
}
if err != nil {
return nil, fmt.Errorf("Übung abfragen: %w", err)
}
result, err := s.db.Exec(`
INSERT INTO session_logs (session_id, exercise_id, exercise_name, set_number, weight_kg, reps, note)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
sessionID, req.ExerciseID, exerciseName, req.SetNumber, req.WeightKg, req.Reps, req.Note,
)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint") {
return nil, fmt.Errorf("UNIQUE_VIOLATION: Satz %d für diese Übung existiert bereits", req.SetNumber)
}
return nil, fmt.Errorf("Log erstellen: %w", err)
}
id, _ := result.LastInsertId()
return s.getLog(id)
}
// UpdateLog korrigiert einen Satz in einer offenen Session.
func (s *Store) UpdateLog(sessionID, logID int64, req *model.UpdateLogRequest) (*model.SessionLog, error) {
if err := s.checkSessionOpen(sessionID); err != nil {
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 {
return nil, fmt.Errorf("Log prüfen: %w", err)
}
if !exists {
return nil, nil
}
// Partielle Updates
updates := []string{}
args := []any{}
if req.WeightKg != nil {
updates = append(updates, "weight_kg = ?")
args = append(args, *req.WeightKg)
}
if req.Reps != nil {
updates = append(updates, "reps = ?")
args = append(args, *req.Reps)
}
if req.Note != nil {
updates = append(updates, "note = ?")
args = append(args, *req.Note)
}
if len(updates) == 0 {
return s.getLog(logID)
}
args = append(args, logID)
_, err = s.db.Exec(
fmt.Sprintf("UPDATE session_logs SET %s WHERE id = ?", strings.Join(updates, ", ")),
args...,
)
if err != nil {
return nil, fmt.Errorf("Log aktualisieren: %w", err)
}
return s.getLog(logID)
}
// DeleteLog löscht einen Satz aus einer offenen Session.
func (s *Store) DeleteLog(sessionID, logID int64) error {
if err := s.checkSessionOpen(sessionID); err != nil {
return err
}
result, err := s.db.Exec(`DELETE FROM session_logs WHERE id = ? AND session_id = ?`, logID, sessionID)
if err != nil {
return fmt.Errorf("Log löschen: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return sql.ErrNoRows
}
return nil
}
// GetLastLog gibt die letzten Werte einer Übung zurück.
func (s *Store) GetLastLog(exerciseID 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,
).Scan(&resp.WeightKg, &resp.Reps)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("Letzten Log abfragen: %w", err)
}
return &resp, nil
}
// checkSessionOpen prüft ob eine Session offen 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)
if err == sql.ErrNoRows {
return fmt.Errorf("Session %d existiert nicht", sessionID)
}
if err != nil {
return fmt.Errorf("Session prüfen: %w", err)
}
if endedAt != nil {
return fmt.Errorf("SESSION_CLOSED: Session ist bereits beendet")
}
return nil
}
// getLog gibt einen einzelnen Log-Eintrag zurück.
func (s *Store) getLog(id int64) (*model.SessionLog, error) {
var log model.SessionLog
err := s.db.QueryRow(`
SELECT id, session_id, exercise_id, exercise_name, set_number, weight_kg, reps, note, logged_at
FROM session_logs WHERE id = ?`, id,
).Scan(&log.ID, &log.SessionID, &log.ExerciseID, &log.ExerciseName, &log.SetNumber, &log.WeightKg, &log.Reps, &log.Note, &log.LoggedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("Log abfragen: %w", err)
}
return &log, nil
}
// getSessionLogs gibt alle Logs einer Session zurück.
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
FROM session_logs
WHERE session_id = ?
ORDER BY exercise_id, set_number`, sessionID,
)
if err != nil {
return nil, fmt.Errorf("Logs abfragen: %w", err)
}
defer rows.Close()
var logs []model.SessionLog
for rows.Next() {
var log model.SessionLog
if err := rows.Scan(&log.ID, &log.SessionID, &log.ExerciseID, &log.ExerciseName, &log.SetNumber, &log.WeightKg, &log.Reps, &log.Note, &log.LoggedAt); err != nil {
return nil, fmt.Errorf("Log scannen: %w", err)
}
logs = append(logs, log)
}
if logs == nil {
logs = []model.SessionLog{}
}
return logs, rows.Err()
}

View File

@@ -0,0 +1,200 @@
package store
import (
"database/sql"
"fmt"
"krafttrainer/internal/model"
)
// ListSets gibt alle nicht-gelöschten Sets mit ihren Übungen zurück.
func (s *Store) ListSets() ([]model.TrainingSet, error) {
rows, err := s.db.Query(`
SELECT id, name, created_at FROM training_sets
WHERE deleted_at IS NULL ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("Sets abfragen: %w", err)
}
defer rows.Close()
var sets []model.TrainingSet
for rows.Next() {
var ts model.TrainingSet
if err := rows.Scan(&ts.ID, &ts.Name, &ts.CreatedAt); err != nil {
return nil, fmt.Errorf("Set scannen: %w", err)
}
sets = append(sets, ts)
}
if err := rows.Err(); err != nil {
return nil, err
}
if sets == nil {
sets = []model.TrainingSet{}
}
for i := range sets {
exercises, err := s.getSetExercises(sets[i].ID)
if err != nil {
return nil, err
}
sets[i].Exercises = exercises
}
return sets, nil
}
// GetSet gibt ein einzelnes Set mit Übungen zurück.
func (s *Store) GetSet(id int64) (*model.TrainingSet, error) {
var ts model.TrainingSet
err := s.db.QueryRow(`
SELECT id, name, created_at, deleted_at FROM training_sets WHERE id = ?`, id,
).Scan(&ts.ID, &ts.Name, &ts.CreatedAt, &ts.DeletedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("Set abfragen: %w", err)
}
exercises, err := s.getSetExercises(id)
if err != nil {
return nil, err
}
ts.Exercises = exercises
return &ts, nil
}
// CreateSet legt ein neues Set an (in einer Transaktion).
func (s *Store) CreateSet(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)
if err != nil {
return nil, fmt.Errorf("Übung prüfen: %w", err)
}
if !exists {
return nil, fmt.Errorf("Übung %d existiert nicht", eid)
}
}
result, err := tx.Exec(`INSERT INTO training_sets (name) VALUES (?)`, req.Name)
if err != nil {
return nil, fmt.Errorf("Set erstellen: %w", err)
}
id, _ := result.LastInsertId()
for pos, eid := range req.ExerciseIDs {
_, err := tx.Exec(`INSERT INTO set_exercises (set_id, exercise_id, position) VALUES (?, ?, ?)`, id, eid, pos)
if err != nil {
return nil, fmt.Errorf("Set-Übung zuordnen: %w", err)
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("Transaktion committen: %w", err)
}
return s.GetSet(id)
}
// UpdateSet aktualisiert ein Set (Name + Übungszuordnungen).
func (s *Store) UpdateSet(id 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)
if err != nil {
return nil, fmt.Errorf("Set prüfen: %w", err)
}
if !exists {
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)
if err != nil {
return nil, fmt.Errorf("Übung prüfen: %w", err)
}
if !eExists {
return nil, fmt.Errorf("Übung %d existiert nicht", eid)
}
}
_, err = tx.Exec(`UPDATE training_sets SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, req.Name, id)
if err != nil {
return nil, fmt.Errorf("Set aktualisieren: %w", err)
}
_, err = tx.Exec(`DELETE FROM set_exercises WHERE set_id = ?`, id)
if err != nil {
return nil, fmt.Errorf("Set-Übungen löschen: %w", err)
}
for pos, eid := range req.ExerciseIDs {
_, err := tx.Exec(`INSERT INTO set_exercises (set_id, exercise_id, position) VALUES (?, ?, ?)`, id, eid, pos)
if err != nil {
return nil, fmt.Errorf("Set-Übung zuordnen: %w", err)
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("Transaktion committen: %w", err)
}
return s.GetSet(id)
}
// SoftDeleteSet markiert ein Set als gelöscht.
func (s *Store) SoftDeleteSet(id 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,
)
if err != nil {
return fmt.Errorf("Set löschen: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return sql.ErrNoRows
}
return nil
}
// getSetExercises lädt die Übungen eines Sets sortiert nach Position.
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
FROM exercises e
JOIN set_exercises se ON se.exercise_id = e.id
WHERE se.set_id = ?
ORDER BY se.position`, setID,
)
if err != nil {
return nil, fmt.Errorf("Set-Übungen abfragen: %w", err)
}
defer rows.Close()
var exercises []model.Exercise
for rows.Next() {
var e model.Exercise
if err := rows.Scan(&e.ID, &e.Name, &e.Description, &e.MuscleGroup, &e.WeightStepKg, &e.CreatedAt, &e.UpdatedAt); err != nil {
return nil, fmt.Errorf("Übung scannen: %w", err)
}
exercises = append(exercises, e)
}
if exercises == nil {
exercises = []model.Exercise{}
}
return exercises, rows.Err()
}

View File

@@ -0,0 +1,85 @@
package store
import (
"fmt"
"krafttrainer/internal/model"
)
// StatsOverview enthält die Gesamtübersicht.
type StatsOverview struct {
TotalSessions int `json:"total_sessions"`
TotalVolumeKg float64 `json:"total_volume_kg"`
SessionsThisWeek int `json:"sessions_this_week"`
Exercises []model.ExerciseStats `json:"exercises"`
}
// GetStatsOverview gibt die Gesamtstatistik zurück.
func (s *Store) GetStatsOverview() (*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)
if err != nil {
return nil, fmt.Errorf("Übersicht abfragen: %w", err)
}
rows, err := s.db.Query(`
SELECT
sl.exercise_id,
sl.exercise_name,
MAX(sl.weight_kg) as max_weight_kg,
SUM(sl.weight_kg * sl.reps) as total_volume_kg,
COUNT(*) as total_sets,
MAX(sl.logged_at) as last_trained
FROM session_logs sl
GROUP BY sl.exercise_id
ORDER BY last_trained DESC`)
if err != nil {
return nil, fmt.Errorf("Übungs-Stats abfragen: %w", err)
}
defer rows.Close()
for rows.Next() {
var es model.ExerciseStats
if err := rows.Scan(&es.ExerciseID, &es.ExerciseName, &es.MaxWeightKg, &es.TotalVolumeKg, &es.TotalSets, &es.LastTrained); err != nil {
return nil, fmt.Errorf("Übungs-Stats scannen: %w", err)
}
overview.Exercises = append(overview.Exercises, es)
}
if overview.Exercises == nil {
overview.Exercises = []model.ExerciseStats{}
}
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) {
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,
)
if err != nil {
return nil, fmt.Errorf("Übungshistorie abfragen: %w", err)
}
defer rows.Close()
var logs []model.SessionLog
for rows.Next() {
var log model.SessionLog
if err := rows.Scan(&log.ID, &log.SessionID, &log.ExerciseID, &log.ExerciseName, &log.SetNumber, &log.WeightKg, &log.Reps, &log.Note, &log.LoggedAt); err != nil {
return nil, fmt.Errorf("Log scannen: %w", err)
}
logs = append(logs, log)
}
if logs == nil {
logs = []model.SessionLog{}
}
return logs, rows.Err()
}

37
backend/internal/store/store.go Executable file
View File

@@ -0,0 +1,37 @@
package store
import (
"database/sql"
"fmt"
_ "github.com/mattn/go-sqlite3"
)
// Store kapselt den Datenbankzugriff.
type Store struct {
db *sql.DB
}
// New erstellt einen neuen Store und konfiguriert SQLite.
func New(dbPath string) (*Store, error) {
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=ON")
if err != nil {
return nil, fmt.Errorf("Datenbank öffnen: %w", err)
}
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("Datenbank-Verbindung prüfen: %w", err)
}
return &Store{db: db}, nil
}
// DB gibt die zugrundeliegende Datenbankverbindung zurück (für Migrations).
func (s *Store) DB() *sql.DB {
return s.db
}
// Close schliesst die Datenbankverbindung.
func (s *Store) Close() error {
return s.db.Close()
}