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

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

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

View File

@@ -8,9 +8,19 @@ import (
)
// CreateSession startet eine neue Trainingseinheit für einen Nutzer.
// Gibt einen Fehler zurück wenn noch eine offene Session existiert.
func (s *Store) CreateSession(userID, setID int64) (*model.Session, error) {
// Prüfe ob bereits eine offene Session existiert
active, err := s.GetActiveSession(userID)
if err != nil {
return nil, fmt.Errorf("Aktive Session prüfen: %w", err)
}
if active != nil {
return nil, fmt.Errorf("SESSION_OPEN: Es läuft bereits ein Training (%s)", active.SetName)
}
var setName string
err := s.db.QueryRow(`SELECT name FROM training_sets WHERE id = ? AND user_id = ? AND deleted_at IS NULL`, setID, userID).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)
}
@@ -96,6 +106,51 @@ func (s *Store) ListSessions(userID int64, limit, offset int) ([]model.Session,
return sessions, rows.Err()
}
// GetActiveSession gibt die offene Session eines Nutzers zurück (falls vorhanden).
func (s *Store) GetActiveSession(userID int64) (*model.Session, error) {
var sessionID int64
err := s.db.QueryRow(`
SELECT id FROM sessions
WHERE user_id = ? AND ended_at IS NULL
ORDER BY started_at DESC LIMIT 1`, userID,
).Scan(&sessionID)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("Aktive Session suchen: %w", err)
}
return s.GetSession(sessionID)
}
// GetSetExercises gibt die Übungen eines Training-Sets zurück.
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.exercise_number, e.created_at, e.updated_at
FROM exercises e
JOIN set_exercises se ON se.exercise_id = e.id
WHERE se.set_id = ? AND e.deleted_at IS NULL
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.ExerciseNumber, &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()
}
// CreateLog fügt einen Satz zu einer offenen Session hinzu.
func (s *Store) CreateLog(sessionID int64, req *model.CreateLogRequest) (*model.SessionLog, error) {
if err := s.checkSessionOpen(sessionID); err != nil {