297 lines
9.4 KiB
Go
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
|
|
}
|