lots of stuff, don't truly remember

This commit is contained in:
Blake Ridgway
2026-05-17 20:39:47 -05:00
parent 178ffb3425
commit dc4fe558b7
35 changed files with 3501 additions and 112 deletions

View File

@@ -110,6 +110,73 @@ func (h *Handler) GetMonthlyStats(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(stats)
}
// GetTrainingLoad GET /api/protected/stats/training-load?days=180
func (h *Handler) GetTrainingLoad(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
if claims == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
return
}
days := 180
if dStr := r.URL.Query().Get("days"); dStr != "" {
if parsed, err := strconv.Atoi(dStr); err == nil {
days = parsed
}
}
data, ftp, err := h.service.GetTrainingLoad(claims.UserID, days)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": "failed to fetch training load"})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"ftp": ftp,
"daily_tss": data,
})
}
// GetPowerHistory GET /api/protected/stats/power-history?days=365
func (h *Handler) GetPowerHistory(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
if claims == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
return
}
days := 365
if dStr := r.URL.Query().Get("days"); dStr != "" {
if parsed, err := strconv.Atoi(dStr); err == nil {
days = parsed
}
}
data, err := h.service.GetPowerHistory(claims.UserID, days)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": "failed to fetch power history"})
return
}
if data == nil {
data = []PowerPoint{}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(data)
}
// GetPersonalBests GET /api/protected/stats/personal-bests
func (h *Handler) GetPersonalBests(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)

View File

@@ -43,6 +43,21 @@ type PeriodStats struct {
AvgHR float64 `json:"avg_hr"`
}
// DailyTSS holds the computed TSS for a single day.
type DailyTSS struct {
Date string `json:"date"`
TSS float64 `json:"tss"`
}
// PowerPoint holds power data for a single completed workout.
type PowerPoint struct {
Date string `json:"date"`
AvgPower int `json:"avg_power"`
MaxPower int `json:"max_power"`
Duration int `json:"duration"`
Title string `json:"title"`
}
// GetSummary returns overall ride statistics for completed workouts.
func (r *Repository) GetSummary(userID uint) (*Summary, error) {
var summary Summary
@@ -120,6 +135,65 @@ func (r *Repository) GetMonthlyStats(userID uint, months int) ([]PeriodStats, er
return stats, err
}
// GetDailyTSS returns daily TSS values for the last N days.
// TSS = (duration_seconds * (avg_power / FTP)^2 / 3600) * 100
// Simplified: (duration * avg_power^2) / (FTP^2 * 36)
func (r *Repository) GetDailyTSS(userID uint, ftp int, days int) ([]DailyTSS, error) {
if ftp <= 0 {
return []DailyTSS{}, nil
}
cutoff := time.Now().AddDate(0, 0, -days)
ftpFloat := float64(ftp)
var results []DailyTSS
err := database.DB.Model(&workout.Workout{}).
Select(`
TO_CHAR(scheduled_date, 'YYYY-MM-DD') as date,
COALESCE(SUM(
CASE WHEN avg_power > 0 AND duration > 0
THEN (duration::float8 * avg_power::float8 * avg_power::float8) / (? * ? * 36.0)
ELSE 0
END
), 0) as tss
`, ftpFloat, ftpFloat).
Where("user_id = ? AND status = ? AND scheduled_date >= ?", userID, "completed", cutoff).
Group("date").
Order("date ASC").
Scan(&results).Error
if results == nil {
results = []DailyTSS{}
}
return results, err
}
// GetPowerHistory returns power data points for completed workouts in the last N days.
func (r *Repository) GetPowerHistory(userID uint, days int) ([]PowerPoint, error) {
cutoff := time.Now().AddDate(0, 0, -days)
var results []PowerPoint
err := database.DB.Model(&workout.Workout{}).
Select(`
TO_CHAR(scheduled_date, 'YYYY-MM-DD') as date,
avg_power,
max_power,
duration,
title
`).
Where("user_id = ? AND status = ? AND avg_power > 0 AND scheduled_date >= ?",
userID, "completed", cutoff).
Order("scheduled_date ASC").
Scan(&results).Error
if results == nil {
results = []PowerPoint{}
}
return results, err
}
// PersonalBest holds a single personal best record.
type PersonalBest struct {
Category string `json:"category"`

View File

@@ -1,5 +1,9 @@
package stats
import (
"rideaware/pkg/database"
)
type Service struct {
repo *Repository
}
@@ -31,3 +35,37 @@ func (s *Service) GetMonthlyStats(userID uint, months int) ([]PeriodStats, error
func (s *Service) GetPersonalBests(userID uint) ([]PersonalBest, error) {
return s.repo.GetPersonalBests(userID)
}
// getUserFTP retrieves the user's FTP from the user_profiles table.
func (s *Service) getUserFTP(userID uint) int {
var ftp int
database.DB.Table("user_profiles").
Where("user_id = ?", userID).
Select("ftp").
Scan(&ftp)
return ftp
}
// GetTrainingLoad returns daily TSS data and the user's FTP.
func (s *Service) GetTrainingLoad(userID uint, days int) ([]DailyTSS, int, error) {
if days <= 0 || days > 730 {
days = 180
}
ftp := s.getUserFTP(userID)
data, err := s.repo.GetDailyTSS(userID, ftp, days)
if err != nil {
return nil, 0, err
}
return data, ftp, nil
}
// GetPowerHistory returns power data points for completed workouts.
func (s *Service) GetPowerHistory(userID uint, days int) ([]PowerPoint, error) {
if days <= 0 || days > 730 {
days = 365
}
return s.repo.GetPowerHistory(userID, days)
}