Files
rideaware-api/internal/stats/repository.go
2026-05-17 20:39:47 -05:00

297 lines
9.4 KiB
Go

package stats
import (
"rideaware/internal/workout"
"rideaware/pkg/database"
"time"
)
type Repository struct{}
func NewRepository() *Repository {
return &Repository{}
}
// Summary holds overall aggregated stats for a user.
type Summary struct {
TotalRides int `json:"total_rides"`
TotalDistance float64 `json:"total_distance"`
TotalDuration int `json:"total_duration"`
TotalElevGain int `json:"total_elev_gain"`
TotalCalories int `json:"total_calories"`
AvgPower float64 `json:"avg_power"`
AvgHR float64 `json:"avg_hr"`
AvgDistance float64 `json:"avg_distance"`
AvgDuration float64 `json:"avg_duration"`
LongestRide int `json:"longest_ride"`
FarthestRide float64 `json:"farthest_ride"`
MaxPower int `json:"max_power"`
MaxHR int `json:"max_hr"`
MostElevGain int `json:"most_elev_gain"`
}
// PeriodStats holds aggregated stats for a time period.
type PeriodStats struct {
Period string `json:"period"`
Year int `json:"year"`
Rides int `json:"rides"`
Distance float64 `json:"distance"`
Duration int `json:"duration"`
ElevGain int `json:"elev_gain"`
Calories int `json:"calories"`
AvgPower float64 `json:"avg_power"`
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
err := database.DB.Model(&workout.Workout{}).
Select(`
COUNT(*) as total_rides,
COALESCE(SUM(distance), 0) as total_distance,
COALESCE(SUM(duration), 0) as total_duration,
COALESCE(SUM(elev_gain), 0) as total_elev_gain,
COALESCE(SUM(calories_burned), 0) as total_calories,
COALESCE(AVG(NULLIF(avg_power, 0)), 0) as avg_power,
COALESCE(AVG(NULLIF(avg_hr, 0)), 0) as avg_hr,
COALESCE(AVG(distance), 0) as avg_distance,
COALESCE(AVG(duration), 0) as avg_duration,
COALESCE(MAX(duration), 0) as longest_ride,
COALESCE(MAX(distance), 0) as farthest_ride,
COALESCE(MAX(max_power), 0) as max_power,
COALESCE(MAX(max_hr), 0) as max_hr,
COALESCE(MAX(elev_gain), 0) as most_elev_gain
`).
Where("user_id = ? AND status = ?", userID, "completed").
Scan(&summary).Error
return &summary, err
}
// GetWeeklyStats returns weekly aggregated stats for the last N weeks.
func (r *Repository) GetWeeklyStats(userID uint, weeks int) ([]PeriodStats, error) {
cutoff := time.Now().AddDate(0, 0, -weeks*7)
var stats []PeriodStats
err := database.DB.Model(&workout.Workout{}).
Select(`
TO_CHAR(scheduled_date, 'IYYY-IW') as period,
EXTRACT(ISOYEAR FROM scheduled_date)::int as year,
COUNT(*) as rides,
COALESCE(SUM(distance), 0) as distance,
COALESCE(SUM(duration), 0) as duration,
COALESCE(SUM(elev_gain), 0) as elev_gain,
COALESCE(SUM(calories_burned), 0) as calories,
COALESCE(AVG(NULLIF(avg_power, 0)), 0) as avg_power,
COALESCE(AVG(NULLIF(avg_hr, 0)), 0) as avg_hr
`).
Where("user_id = ? AND status = ? AND scheduled_date >= ?", userID, "completed", cutoff).
Group("period, year").
Order("year ASC, period ASC").
Scan(&stats).Error
return stats, err
}
// GetMonthlyStats returns monthly aggregated stats for the last N months.
func (r *Repository) GetMonthlyStats(userID uint, months int) ([]PeriodStats, error) {
cutoff := time.Now().AddDate(0, -months, 0)
var stats []PeriodStats
err := database.DB.Model(&workout.Workout{}).
Select(`
TO_CHAR(scheduled_date, 'YYYY-MM') as period,
EXTRACT(YEAR FROM scheduled_date)::int as year,
COUNT(*) as rides,
COALESCE(SUM(distance), 0) as distance,
COALESCE(SUM(duration), 0) as duration,
COALESCE(SUM(elev_gain), 0) as elev_gain,
COALESCE(SUM(calories_burned), 0) as calories,
COALESCE(AVG(NULLIF(avg_power, 0)), 0) as avg_power,
COALESCE(AVG(NULLIF(avg_hr, 0)), 0) as avg_hr
`).
Where("user_id = ? AND status = ? AND scheduled_date >= ?", userID, "completed", cutoff).
Group("period, year").
Order("year ASC, period ASC").
Scan(&stats).Error
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"`
Value interface{} `json:"value"`
Unit string `json:"unit"`
WorkoutID uint `json:"workout_id"`
Date time.Time `json:"date"`
Title string `json:"title"`
}
// GetPersonalBests returns personal best records across key metrics.
func (r *Repository) GetPersonalBests(userID uint) ([]PersonalBest, error) {
var pbs []PersonalBest
type pbRow struct {
ID uint
Title string
ScheduledDate time.Time
MaxPower int
MaxHR int
Distance float64
Duration int
ElevGain int
AvgPower int
}
// Max Power
var maxPower pbRow
if err := database.DB.Model(&workout.Workout{}).
Where("user_id = ? AND status = ? AND max_power > 0", userID, "completed").
Order("max_power DESC").Limit(1).
Scan(&maxPower).Error; err == nil && maxPower.ID != 0 {
pbs = append(pbs, PersonalBest{
Category: "max_power", Value: maxPower.MaxPower, Unit: "watts",
WorkoutID: maxPower.ID, Date: maxPower.ScheduledDate, Title: maxPower.Title,
})
}
// Max HR
var maxHR pbRow
if err := database.DB.Model(&workout.Workout{}).
Where("user_id = ? AND status = ? AND max_hr > 0", userID, "completed").
Order("max_hr DESC").Limit(1).
Scan(&maxHR).Error; err == nil && maxHR.ID != 0 {
pbs = append(pbs, PersonalBest{
Category: "max_hr", Value: maxHR.MaxHR, Unit: "bpm",
WorkoutID: maxHR.ID, Date: maxHR.ScheduledDate, Title: maxHR.Title,
})
}
// Longest Ride (duration)
var longest pbRow
if err := database.DB.Model(&workout.Workout{}).
Where("user_id = ? AND status = ? AND duration > 0", userID, "completed").
Order("duration DESC").Limit(1).
Scan(&longest).Error; err == nil && longest.ID != 0 {
pbs = append(pbs, PersonalBest{
Category: "longest_ride", Value: longest.Duration, Unit: "seconds",
WorkoutID: longest.ID, Date: longest.ScheduledDate, Title: longest.Title,
})
}
// Farthest Ride (distance)
var farthest pbRow
if err := database.DB.Model(&workout.Workout{}).
Where("user_id = ? AND status = ? AND distance > 0", userID, "completed").
Order("distance DESC").Limit(1).
Scan(&farthest).Error; err == nil && farthest.ID != 0 {
pbs = append(pbs, PersonalBest{
Category: "farthest_ride", Value: farthest.Distance, Unit: "km",
WorkoutID: farthest.ID, Date: farthest.ScheduledDate, Title: farthest.Title,
})
}
// Most Elevation
var mostElev pbRow
if err := database.DB.Model(&workout.Workout{}).
Where("user_id = ? AND status = ? AND elev_gain > 0", userID, "completed").
Order("elev_gain DESC").Limit(1).
Scan(&mostElev).Error; err == nil && mostElev.ID != 0 {
pbs = append(pbs, PersonalBest{
Category: "most_elevation", Value: mostElev.ElevGain, Unit: "meters",
WorkoutID: mostElev.ID, Date: mostElev.ScheduledDate, Title: mostElev.Title,
})
}
// Best Avg Power
var bestAvgPower pbRow
if err := database.DB.Model(&workout.Workout{}).
Where("user_id = ? AND status = ? AND avg_power > 0", userID, "completed").
Order("avg_power DESC").Limit(1).
Scan(&bestAvgPower).Error; err == nil && bestAvgPower.ID != 0 {
pbs = append(pbs, PersonalBest{
Category: "best_avg_power", Value: bestAvgPower.AvgPower, Unit: "watts",
WorkoutID: bestAvgPower.ID, Date: bestAvgPower.ScheduledDate, Title: bestAvgPower.Title,
})
}
return pbs, nil
}