feat: extend equipment and workout models with service tracking

This commit is contained in:
Blake Ridgway
2026-02-12 10:09:50 -06:00
parent eb9ac1b67a
commit 178ffb3425
37 changed files with 4005 additions and 40 deletions

View File

@@ -136,6 +136,56 @@ func (h *Handler) DeleteEquipment(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// RecordService POST /api/protected/equipment/service?id=X
func (h *Handler) RecordService(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
idStr := r.URL.Query().Get("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "invalid equipment id"})
return
}
eq, err := h.service.RecordService(uint(id), claims.UserID)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(eq)
}
// GetServiceStatus GET /api/protected/equipment/service-status?id=X
func (h *Handler) GetServiceStatus(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
idStr := r.URL.Query().Get("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "invalid equipment id"})
return
}
status, err := h.service.GetServiceStatus(uint(id), claims.UserID)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}
// GetTrainingZones GET /api/zones
func (h *Handler) GetTrainingZones(w http.ResponseWriter, r *http.Request) {
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)

View File

@@ -3,17 +3,58 @@ package equipment
import "time"
type Equipment struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
Name string `gorm:"not null" json:"name"`
Type string `gorm:"not null" json:"type"` // "bike", "shoes", "helmet", etc.
Brand string `gorm:"default:''" json:"brand"`
Model string `gorm:"default:''" json:"model"`
Weight float64 `gorm:"default:0" json:"weight"` // grams
Notes string `gorm:"default:''" json:"notes"`
Active bool `gorm:"default:true" json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"not null;index" json:"user_id"`
Name string `gorm:"not null" json:"name"`
Type string `gorm:"not null" json:"type"` // "bike", "shoes", "helmet", etc.
Brand string `gorm:"default:''" json:"brand"`
Model string `gorm:"default:''" json:"model"`
Weight float64 `gorm:"default:0" json:"weight"` // grams
Notes string `gorm:"default:''" json:"notes"`
Active bool `gorm:"default:true" json:"active"`
TotalDistance float64 `gorm:"default:0" json:"total_distance"` // km
TotalDuration int `gorm:"default:0" json:"total_duration"` // seconds
TotalRides int `gorm:"default:0" json:"total_rides"`
ServiceIntervalDistance float64 `gorm:"default:0" json:"service_interval_distance"` // km, 0 = no reminder
ServiceIntervalDuration int `gorm:"default:0" json:"service_interval_duration"` // hours, 0 = no reminder
LastServiceDate *time.Time `json:"last_service_date"`
DistanceSinceService float64 `gorm:"default:0" json:"distance_since_service"` // km since last service
DurationSinceService int `gorm:"default:0" json:"duration_since_service"` // seconds since last service
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ServiceStatus indicates whether equipment needs servicing.
type ServiceStatus struct {
NeedsService bool `json:"needs_service"`
DistanceDue bool `json:"distance_due"`
DurationDue bool `json:"duration_due"`
DistanceSinceService float64 `json:"distance_since_service"` // km
DurationSinceService int `json:"duration_since_service"` // hours
ServiceIntervalDist float64 `json:"service_interval_distance"`
ServiceIntervalDur int `json:"service_interval_duration"`
}
// GetServiceStatus checks if the equipment is due for service.
func (e *Equipment) GetServiceStatus() ServiceStatus {
status := ServiceStatus{
DistanceSinceService: e.DistanceSinceService,
DurationSinceService: e.DurationSinceService / 3600,
ServiceIntervalDist: e.ServiceIntervalDistance,
ServiceIntervalDur: e.ServiceIntervalDuration,
}
if e.ServiceIntervalDistance > 0 && e.DistanceSinceService >= e.ServiceIntervalDistance {
status.DistanceDue = true
status.NeedsService = true
}
if e.ServiceIntervalDuration > 0 && e.DurationSinceService >= e.ServiceIntervalDuration*3600 {
status.DurationDue = true
status.NeedsService = true
}
return status
}
type TrainingZone struct {

View File

@@ -3,6 +3,8 @@ package equipment
import (
"errors"
"rideaware/pkg/database"
"time"
"gorm.io/gorm"
)
@@ -53,4 +55,29 @@ func (r *Repository) UpdateEquipment(equipment *Equipment) error {
func (r *Repository) DeleteEquipment(id, userID uint) error {
return database.DB.Where("id = ? AND user_id = ?", id, userID).
Delete(&Equipment{}).Error
}
// IncrementMileage atomically adds distance and duration to equipment totals.
func (r *Repository) IncrementMileage(id, userID uint, distance float64, duration int) error {
return database.DB.Model(&Equipment{}).
Where("id = ? AND user_id = ?", id, userID).
Updates(map[string]interface{}{
"total_distance": gorm.Expr("total_distance + ?", distance),
"total_duration": gorm.Expr("total_duration + ?", duration),
"total_rides": gorm.Expr("total_rides + 1"),
"distance_since_service": gorm.Expr("distance_since_service + ?", distance),
"duration_since_service": gorm.Expr("duration_since_service + ?", duration),
}).Error
}
// ResetServiceCounters resets the service tracking counters after servicing.
func (r *Repository) ResetServiceCounters(id, userID uint) error {
now := time.Now()
return database.DB.Model(&Equipment{}).
Where("id = ? AND user_id = ?", id, userID).
Updates(map[string]interface{}{
"distance_since_service": 0,
"duration_since_service": 0,
"last_service_date": now,
}).Error
}

View File

@@ -71,6 +71,12 @@ func (s *Service) UpdateEquipment(id, userID uint, updates map[string]interface{
if active, ok := updates["active"].(bool); ok {
equipment.Active = active
}
if v, ok := updates["service_interval_distance"].(float64); ok {
equipment.ServiceIntervalDistance = v
}
if v, ok := updates["service_interval_duration"].(float64); ok {
equipment.ServiceIntervalDuration = int(v)
}
if err := s.repo.UpdateEquipment(equipment); err != nil {
return nil, err
@@ -83,6 +89,29 @@ func (s *Service) DeleteEquipment(id, userID uint) error {
return s.repo.DeleteEquipment(id, userID)
}
// IncrementMileage adds distance (km) and duration (seconds) to equipment totals.
func (s *Service) IncrementMileage(equipmentID, userID uint, distance float64, duration int) error {
return s.repo.IncrementMileage(equipmentID, userID, distance, duration)
}
// RecordService resets the service counters and records the service date.
func (s *Service) RecordService(equipmentID, userID uint) (*Equipment, error) {
if err := s.repo.ResetServiceCounters(equipmentID, userID); err != nil {
return nil, err
}
return s.repo.GetEquipmentByID(equipmentID, userID)
}
// GetServiceStatus returns the service status for a piece of equipment.
func (s *Service) GetServiceStatus(equipmentID, userID uint) (*ServiceStatus, error) {
eq, err := s.repo.GetEquipmentByID(equipmentID, userID)
if err != nil {
return nil, err
}
status := eq.GetServiceStatus()
return &status, nil
}
// Training Zones calculation
func (s *Service) CalculateHRZones(maxHR, restingHR int) *HRZones {
if maxHR <= 0 {