feat: extend equipment and workout models with service tracking
This commit is contained in:
138
internal/stats/handler.go
Normal file
138
internal/stats/handler.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"rideaware/internal/config"
|
||||
"rideaware/internal/middleware"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler() *Handler {
|
||||
return &Handler{
|
||||
service: NewService(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetSummary GET /api/protected/stats/summary
|
||||
func (h *Handler) GetSummary(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
|
||||
}
|
||||
|
||||
summary, err := h.service.GetSummary(claims.UserID)
|
||||
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 stats"})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(summary)
|
||||
}
|
||||
|
||||
// GetWeeklyStats GET /api/protected/stats/weekly?weeks=12
|
||||
func (h *Handler) GetWeeklyStats(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
|
||||
}
|
||||
|
||||
weeks := 12
|
||||
if w_str := r.URL.Query().Get("weeks"); w_str != "" {
|
||||
if parsed, err := strconv.Atoi(w_str); err == nil {
|
||||
weeks = parsed
|
||||
}
|
||||
}
|
||||
|
||||
stats, err := h.service.GetWeeklyStats(claims.UserID, weeks)
|
||||
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 weekly stats"})
|
||||
return
|
||||
}
|
||||
|
||||
if stats == nil {
|
||||
stats = []PeriodStats{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(stats)
|
||||
}
|
||||
|
||||
// GetMonthlyStats GET /api/protected/stats/monthly?months=12
|
||||
func (h *Handler) GetMonthlyStats(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
|
||||
}
|
||||
|
||||
months := 12
|
||||
if m_str := r.URL.Query().Get("months"); m_str != "" {
|
||||
if parsed, err := strconv.Atoi(m_str); err == nil {
|
||||
months = parsed
|
||||
}
|
||||
}
|
||||
|
||||
stats, err := h.service.GetMonthlyStats(claims.UserID, months)
|
||||
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 monthly stats"})
|
||||
return
|
||||
}
|
||||
|
||||
if stats == nil {
|
||||
stats = []PeriodStats{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(stats)
|
||||
}
|
||||
|
||||
// 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)
|
||||
if claims == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
pbs, err := h.service.GetPersonalBests(claims.UserID)
|
||||
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 personal bests"})
|
||||
return
|
||||
}
|
||||
|
||||
if pbs == nil {
|
||||
pbs = []PersonalBest{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(pbs)
|
||||
}
|
||||
222
internal/stats/repository.go
Normal file
222
internal/stats/repository.go
Normal file
@@ -0,0 +1,222 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
33
internal/stats/service.go
Normal file
33
internal/stats/service.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package stats
|
||||
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{
|
||||
repo: NewRepository(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) GetSummary(userID uint) (*Summary, error) {
|
||||
return s.repo.GetSummary(userID)
|
||||
}
|
||||
|
||||
func (s *Service) GetWeeklyStats(userID uint, weeks int) ([]PeriodStats, error) {
|
||||
if weeks <= 0 || weeks > 52 {
|
||||
weeks = 12
|
||||
}
|
||||
return s.repo.GetWeeklyStats(userID, weeks)
|
||||
}
|
||||
|
||||
func (s *Service) GetMonthlyStats(userID uint, months int) ([]PeriodStats, error) {
|
||||
if months <= 0 || months > 24 {
|
||||
months = 12
|
||||
}
|
||||
return s.repo.GetMonthlyStats(userID, months)
|
||||
}
|
||||
|
||||
func (s *Service) GetPersonalBests(userID uint) ([]PersonalBest, error) {
|
||||
return s.repo.GetPersonalBests(userID)
|
||||
}
|
||||
Reference in New Issue
Block a user