Files
geniusrun/backend/internal/api/progression.go
Christophe Vila eaca8e602b api: scope profile, workout-kind, Garmin auth, and progression handlers to userID
Pulled from userIDFromContext (never a URL/body parameter) and threaded
into every store call plus the per-user garmin.Client accessor.
2026-07-25 18:16:06 +02:00

108 lines
2.8 KiB
Go

package api
import (
"net/http"
"sort"
"strconv"
"github.com/go-chi/chi/v5"
"geniusrun/backend/internal/store"
)
type progressionPoint struct {
Date string `json:"date"`
ActivityID int64 `json:"activity_id"`
Value float64 `json:"value"`
}
func metricValue(metric string, a store.Activity) (float64, bool) {
switch metric {
case "pace":
if a.AvgSpeedMps == nil || *a.AvgSpeedMps <= 0 {
return 0, false
}
return 1000 / *a.AvgSpeedMps, true
case "hr":
if a.AvgHR == nil {
return 0, false
}
return *a.AvgHR, true
case "vo2max":
if a.VO2MaxValue == nil {
return 0, false
}
return *a.VO2MaxValue, true
case "aerobic_te":
if a.AerobicTrainingEffect == nil {
return 0, false
}
return *a.AerobicTrainingEffect, true
case "anaerobic_te":
if a.AnaerobicTrainingEffect == nil {
return 0, false
}
return *a.AnaerobicTrainingEffect, true
case "efficiency_factor":
// Speed per heartbeat: how much ground each beat covers, on average.
// A rising trend at similar effort/HR over time is a classic sign of
// improving aerobic fitness, independent of pace/HR shown alone.
// Scaled by 1000 (m/s per bpm is otherwise a tiny fraction like
// 0.02, hard to read/compare at a glance).
if a.AvgSpeedMps == nil || a.AvgHR == nil || *a.AvgHR <= 0 {
return 0, false
}
return (*a.AvgSpeedMps / *a.AvgHR) * 1000, true
default:
return 0, false
}
}
// handleProgression returns a time series of the requested metric for every
// activity currently assigned to a workout kind, for progression charts.
func (s *Server) handleProgression(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
kindID, err := strconv.ParseInt(chi.URLParam(r, "kindID"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid workout kind id")
return
}
metric := r.URL.Query().Get("metric")
if metric == "" {
metric = "pace"
}
from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
assignments, err := s.DB.AssignmentsForKind(r.Context(), userID, kindID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
points := []progressionPoint{}
for _, a := range assignments {
activity, ok, err := s.DB.GetActivity(r.Context(), userID, a.ActivityID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !ok {
continue
}
if from != "" && activity.StartTimeUTC < from {
continue
}
if to != "" && activity.StartTimeUTC > to+" 23:59:59" {
continue
}
value, ok := metricValue(metric, activity)
if !ok {
continue
}
points = append(points, progressionPoint{Date: activity.StartTimeUTC, ActivityID: activity.ID, Value: value})
}
sort.Slice(points, func(i, j int) bool { return points[i].Date < points[j].Date })
writeJSON(w, http.StatusOK, points)
}