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

@@ -0,0 +1,94 @@
package activity
import (
"bytes"
"fmt"
"github.com/muktihari/fit/decoder"
"github.com/muktihari/fit/profile/basetype"
"github.com/muktihari/fit/profile/mesgdef"
"github.com/muktihari/fit/profile/typedef"
)
// ParseFIT decodes a FIT activity file and extracts session-level metrics.
func ParseFIT(data []byte) (*ParsedActivity, error) {
dec := decoder.New(bytes.NewReader(data))
if !dec.Next() {
return nil, fmt.Errorf("empty or invalid FIT file")
}
fit, err := dec.Decode()
if err != nil {
return nil, fmt.Errorf("failed to decode FIT file: %w", err)
}
result := &ParsedActivity{}
foundSession := false
for i := range fit.Messages {
if fit.Messages[i].Num != typedef.MesgNumSession {
continue
}
foundSession = true
session := mesgdef.NewSession(&fit.Messages[i])
// total_elapsed_time: FIT stores as uint32 with scale 1000 (ms -> seconds)
if session.TotalElapsedTime != basetype.Uint32Invalid {
result.Duration = int(session.TotalElapsedTime / 1000)
}
// total_distance: FIT stores as uint32 with scale 100 (centimeters -> meters)
// Convert to km for our model
if session.TotalDistance != basetype.Uint32Invalid {
result.Distance = float64(session.TotalDistance) / 100.0 / 1000.0
}
// Power (no scale)
if session.AvgPower != basetype.Uint16Invalid {
result.AvgPower = int(session.AvgPower)
}
if session.MaxPower != basetype.Uint16Invalid {
result.MaxPower = int(session.MaxPower)
}
// Heart rate (no scale)
if session.AvgHeartRate != basetype.Uint8Invalid {
result.AvgHR = int(session.AvgHeartRate)
}
if session.MaxHeartRate != basetype.Uint8Invalid {
result.MaxHR = int(session.MaxHeartRate)
}
// Calories (no scale)
if session.TotalCalories != basetype.Uint16Invalid {
result.CaloriesBurned = int(session.TotalCalories)
}
// Elevation gain (no scale, meters)
if session.TotalAscent != basetype.Uint16Invalid {
result.ElevGain = int(session.TotalAscent)
}
// Cadence (no scale)
if session.AvgCadence != basetype.Uint8Invalid {
result.AvgCadence = int(session.AvgCadence)
}
// Start time
if session.StartTime.IsZero() {
result.StartTime = session.Timestamp
} else {
result.StartTime = session.StartTime
}
break // use first session
}
if !foundSession {
return nil, fmt.Errorf("no session data found in FIT file")
}
return result, nil
}

View File

@@ -0,0 +1,251 @@
package activity
import (
"encoding/xml"
"fmt"
"math"
"strconv"
"strings"
"time"
)
// GPX XML structures
type gpxFile struct {
Metadata gpxMetadata `xml:"metadata"`
Tracks []gpxTrack `xml:"trk"`
}
type gpxMetadata struct {
Name string `xml:"name"`
Time string `xml:"time"`
}
type gpxTrack struct {
Name string `xml:"name"`
Type string `xml:"type"`
Segments []gpxTrackSeg `xml:"trkseg"`
}
type gpxTrackSeg struct {
Points []gpxTrackPoint `xml:"trkpt"`
}
type gpxTrackPoint struct {
Lat float64 `xml:"lat,attr"`
Lon float64 `xml:"lon,attr"`
Elevation float64 `xml:"ele"`
Time string `xml:"time"`
Extensions gpxTPExtensions `xml:"extensions"`
}
// gpxTPExtensions captures the raw inner XML of extensions for flexible parsing.
type gpxTPExtensions struct {
InnerXML string `xml:",innerxml"`
}
// ParseGPX parses a GPX activity file, computing metrics from trackpoints.
func ParseGPX(data []byte) (*ParsedActivity, error) {
var gpx gpxFile
if err := xml.Unmarshal(data, &gpx); err != nil {
return nil, fmt.Errorf("failed to parse GPX file: %w", err)
}
if len(gpx.Tracks) == 0 {
return nil, fmt.Errorf("no tracks found in GPX file")
}
result := &ParsedActivity{}
// Set title from track or metadata
if gpx.Tracks[0].Name != "" {
result.Title = gpx.Tracks[0].Name
} else if gpx.Metadata.Name != "" {
result.Title = gpx.Metadata.Name
}
// Collect all points across tracks and segments
var allPoints []gpxTrackPoint
for _, trk := range gpx.Tracks {
for _, seg := range trk.Segments {
allPoints = append(allPoints, seg.Points...)
}
}
if len(allPoints) == 0 {
return nil, fmt.Errorf("no trackpoints found in GPX file")
}
// Calculate distance using Haversine formula
var totalDistance float64
for i := 1; i < len(allPoints); i++ {
d := haversine(allPoints[i-1].Lat, allPoints[i-1].Lon, allPoints[i].Lat, allPoints[i].Lon)
totalDistance += d
}
result.Distance = totalDistance / 1000.0 // meters to km
// Calculate duration from timestamps
var firstTime, lastTime time.Time
for _, pt := range allPoints {
if pt.Time == "" {
continue
}
t, err := time.Parse(time.RFC3339, pt.Time)
if err != nil {
continue
}
if firstTime.IsZero() {
firstTime = t
}
lastTime = t
}
if !firstTime.IsZero() && !lastTime.IsZero() {
result.Duration = int(lastTime.Sub(firstTime).Seconds())
result.StartTime = firstTime
}
// Calculate elevation gain (sum of positive altitude deltas)
var elevGain float64
var prevEle float64
firstEle := true
for _, pt := range allPoints {
if pt.Elevation == 0 {
continue
}
if firstEle {
prevEle = pt.Elevation
firstEle = false
continue
}
delta := pt.Elevation - prevEle
if delta > 0 {
elevGain += delta
}
prevEle = pt.Elevation
}
result.ElevGain = int(math.Round(elevGain))
// Extract HR, power, cadence from extensions
var hrSum, powerSum, cadSum int
var hrCount, powerCount, cadCount int
var maxHR, maxPower int
for _, pt := range allPoints {
hr, power, cad := parseGPXExtensions(pt.Extensions.InnerXML)
if hr > 0 {
hrSum += hr
hrCount++
if hr > maxHR {
maxHR = hr
}
}
if power > 0 {
powerSum += power
powerCount++
if power > maxPower {
maxPower = power
}
}
if cad > 0 {
cadSum += cad
cadCount++
}
}
if hrCount > 0 {
result.AvgHR = hrSum / hrCount
}
result.MaxHR = maxHR
if powerCount > 0 {
result.AvgPower = powerSum / powerCount
}
result.MaxPower = maxPower
if cadCount > 0 {
result.AvgCadence = cadSum / cadCount
}
return result, nil
}
// parseGPXExtensions extracts HR, power, and cadence from extension XML.
// Handles common namespaces: Garmin TrackPointExtension, ClueTrust, generic.
func parseGPXExtensions(innerXML string) (hr, power, cadence int) {
if innerXML == "" {
return
}
// Parse the inner XML as a generic tree
type anyElement struct {
XMLName xml.Name
Value string `xml:",chardata"`
Children []anyElement `xml:",any"`
}
var elements []anyElement
wrapped := "<ext>" + innerXML + "</ext>"
var wrapper struct {
Children []anyElement `xml:",any"`
}
if err := xml.Unmarshal([]byte(wrapped), &wrapper); err != nil {
return
}
elements = wrapper.Children
// Walk the element tree looking for known field names
var walk func(elems []anyElement)
walk = func(elems []anyElement) {
for _, el := range elems {
local := strings.ToLower(el.XMLName.Local)
switch local {
case "hr":
if v, err := strconv.Atoi(strings.TrimSpace(el.Value)); err == nil && v > 0 {
hr = v
}
// HR might be nested: <hr><Value>145</Value></hr>
for _, child := range el.Children {
if strings.ToLower(child.XMLName.Local) == "value" {
if v, err := strconv.Atoi(strings.TrimSpace(child.Value)); err == nil && v > 0 {
hr = v
}
}
}
case "power", "watts":
if v, err := strconv.Atoi(strings.TrimSpace(el.Value)); err == nil && v > 0 {
power = v
}
case "cad":
if v, err := strconv.Atoi(strings.TrimSpace(el.Value)); err == nil && v > 0 {
cadence = v
}
}
// Recurse into children (e.g., TrackPointExtension wrapper)
if len(el.Children) > 0 {
walk(el.Children)
}
}
}
walk(elements)
return
}
// haversine calculates the distance in meters between two lat/lon points.
func haversine(lat1, lon1, lat2, lon2 float64) float64 {
const earthRadius = 6371000.0 // meters
dLat := degToRad(lat2 - lat1)
dLon := degToRad(lon2 - lon1)
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(degToRad(lat1))*math.Cos(degToRad(lat2))*
math.Sin(dLon/2)*math.Sin(dLon/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return earthRadius * c
}
func degToRad(deg float64) float64 {
return deg * math.Pi / 180.0
}

View File

@@ -0,0 +1,103 @@
package activity
import (
"encoding/json"
"log"
"net/http"
"strconv"
"rideaware/internal/config"
"rideaware/internal/equipment"
"rideaware/internal/middleware"
)
type Handler struct {
service *Service
equipmentSvc *equipment.Service
}
func NewHandler() *Handler {
return &Handler{
service: NewService(),
equipmentSvc: equipment.NewService(),
}
}
// ImportActivity POST /api/protected/workouts/import
// Accepts multipart form with:
// - file: activity file (FIT/TCX/GPX) - required
// - workout_id: existing workout ID to update (optional)
// - title: custom title (optional)
// - equipment_id: equipment to associate (optional)
// - notes: optional notes
func (h *Handler) ImportActivity(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
}
if err := r.ParseMultipartForm(20 << 20); err != nil { // 20MB max
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "file too large or invalid form data"})
return
}
file, handler, err := r.FormFile("file")
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "no file provided"})
return
}
defer file.Close()
fileData := make([]byte, handler.Size)
if _, err := file.Read(fileData); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "failed to read file"})
return
}
opts := ImportOptions{
Title: r.FormValue("title"),
Notes: r.FormValue("notes"),
}
if idStr := r.FormValue("workout_id"); idStr != "" {
if id, err := strconv.ParseUint(idStr, 10, 32); err == nil {
opts.WorkoutID = uint(id)
}
}
if eqIDStr := r.FormValue("equipment_id"); eqIDStr != "" {
if eqID, err := strconv.ParseUint(eqIDStr, 10, 32); err == nil {
id := uint(eqID)
opts.EquipmentID = &id
}
}
workout, err := h.service.ImportActivity(claims.UserID, fileData, handler.Filename, opts)
if err != nil {
log.Printf("Activity import error: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
// Auto-update equipment mileage if equipment is assigned
if workout.EquipmentID != nil && workout.Status == "completed" {
if err := h.equipmentSvc.IncrementMileage(*workout.EquipmentID, claims.UserID, workout.Distance, workout.Duration); err != nil {
log.Printf("Equipment mileage update error: %v", err)
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(workout)
}

View File

@@ -0,0 +1,18 @@
package activity
import "time"
// ParsedActivity holds the extracted metrics from an activity file (FIT/TCX/GPX).
type ParsedActivity struct {
Title string
Duration int // seconds
Distance float64 // kilometers
ElevGain int // meters
AvgPower int // watts
MaxPower int // watts
AvgHR int // bpm
MaxHR int // bpm
CaloriesBurned int // kcal
AvgCadence int // rpm
StartTime time.Time // activity start time
}

View File

@@ -0,0 +1,126 @@
package activity
import (
"fmt"
"path/filepath"
"strings"
"time"
"rideaware/internal/workout"
)
type Service struct {
workoutRepo *workout.Repository
}
func NewService() *Service {
return &Service{
workoutRepo: workout.NewRepository(),
}
}
// ImportActivity parses an activity file and creates or updates a workout with the metrics.
func (s *Service) ImportActivity(userID uint, fileData []byte, filename string, opts ImportOptions) (*workout.Workout, error) {
ext := strings.ToLower(filepath.Ext(filename))
var parsed *ParsedActivity
var err error
switch ext {
case ".fit":
parsed, err = ParseFIT(fileData)
case ".tcx":
parsed, err = ParseTCX(fileData)
case ".gpx":
parsed, err = ParseGPX(fileData)
default:
return nil, fmt.Errorf("unsupported file type: %s (supported: .fit, .tcx, .gpx)", ext)
}
if err != nil {
return nil, fmt.Errorf("failed to parse %s file: %w", ext, err)
}
// If updating an existing workout, apply metrics to it
if opts.WorkoutID > 0 {
return s.updateExistingWorkout(userID, opts.WorkoutID, parsed, opts)
}
// Create a new workout from parsed data
return s.createNewWorkout(userID, parsed, ext, opts)
}
func (s *Service) updateExistingWorkout(userID uint, workoutID uint, parsed *ParsedActivity, opts ImportOptions) (*workout.Workout, error) {
w, err := s.workoutRepo.GetWorkoutByID(workoutID, userID)
if err != nil {
return nil, err
}
w.Status = "completed"
w.Duration = parsed.Duration
w.Distance = parsed.Distance
w.ElevGain = parsed.ElevGain
w.AvgPower = parsed.AvgPower
w.MaxPower = parsed.MaxPower
w.AvgHR = parsed.AvgHR
w.MaxHR = parsed.MaxHR
w.CaloriesBurned = parsed.CaloriesBurned
if opts.EquipmentID != nil {
w.EquipmentID = opts.EquipmentID
}
if err := s.workoutRepo.UpdateWorkout(w); err != nil {
return nil, err
}
return w, nil
}
func (s *Service) createNewWorkout(userID uint, parsed *ParsedActivity, fileExt string, opts ImportOptions) (*workout.Workout, error) {
title := opts.Title
if title == "" && parsed.Title != "" {
title = parsed.Title
}
if title == "" {
title = "Imported Ride"
}
scheduledDate := parsed.StartTime
if scheduledDate.IsZero() {
scheduledDate = time.Now()
}
w := &workout.Workout{
UserID: userID,
Title: title,
Type: "ride",
Status: "completed",
ScheduledDate: scheduledDate,
Duration: parsed.Duration,
Distance: parsed.Distance,
ElevGain: parsed.ElevGain,
AvgPower: parsed.AvgPower,
MaxPower: parsed.MaxPower,
AvgHR: parsed.AvgHR,
MaxHR: parsed.MaxHR,
CaloriesBurned: parsed.CaloriesBurned,
FileType: strings.TrimPrefix(fileExt, "."),
EquipmentID: opts.EquipmentID,
Notes: opts.Notes,
}
if err := s.workoutRepo.CreateWorkout(w); err != nil {
return nil, err
}
return w, nil
}
// ImportOptions holds optional parameters for activity import.
type ImportOptions struct {
WorkoutID uint // If set, update existing workout instead of creating new
Title string // Custom title override
EquipmentID *uint // Equipment to associate
Notes string // Optional notes
}

View File

@@ -0,0 +1,181 @@
package activity
import (
"encoding/xml"
"fmt"
"math"
"time"
)
// TCX XML structures
type tcxDatabase struct {
Activities struct {
Activity []tcxActivity `xml:"Activity"`
} `xml:"Activities"`
}
type tcxActivity struct {
Sport string `xml:"Sport,attr"`
ID string `xml:"Id"`
Laps []tcxLap `xml:"Lap"`
}
type tcxLap struct {
StartTime string `xml:"StartTime,attr"`
TotalTimeSeconds float64 `xml:"TotalTimeSeconds"`
DistanceMeters float64 `xml:"DistanceMeters"`
Calories int `xml:"Calories"`
AvgHR tcxHeartRate `xml:"AverageHeartRateBpm"`
MaxHR tcxHeartRate `xml:"MaximumHeartRateBpm"`
Cadence int `xml:"Cadence"`
Extensions tcxExtensions `xml:"Extensions"`
Track tcxTrack `xml:"Track"`
}
type tcxHeartRate struct {
Value int `xml:"Value"`
}
type tcxExtensions struct {
LX tcxLapExtension `xml:"LX"`
}
type tcxLapExtension struct {
AvgWatts int `xml:"AvgWatts"`
MaxWatts int `xml:"MaxWatts"`
}
type tcxTrack struct {
Trackpoints []tcxTrackpoint `xml:"Trackpoint"`
}
type tcxTrackpoint struct {
Time string `xml:"Time"`
AltitudeMeters float64 `xml:"AltitudeMeters"`
DistanceMeters float64 `xml:"DistanceMeters"`
HeartRateBpm tcxHeartRate `xml:"HeartRateBpm"`
Cadence int `xml:"Cadence"`
HasAltitude bool
}
// ParseTCX parses a TCX activity file and extracts aggregated metrics from laps.
func ParseTCX(data []byte) (*ParsedActivity, error) {
var db tcxDatabase
if err := xml.Unmarshal(data, &db); err != nil {
return nil, fmt.Errorf("failed to parse TCX file: %w", err)
}
if len(db.Activities.Activity) == 0 {
return nil, fmt.Errorf("no activities found in TCX file")
}
act := db.Activities.Activity[0]
if len(act.Laps) == 0 {
return nil, fmt.Errorf("no laps found in TCX activity")
}
result := &ParsedActivity{}
var totalDuration float64
var totalDistance float64
var totalCalories int
var maxHR int
var maxPower int
// Weighted sums for averages
var hrDurationSum float64
var powerDurationSum float64
var cadDurationSum float64
var hrDuration float64
var powerDuration float64
var cadDuration float64
for _, lap := range act.Laps {
totalDuration += lap.TotalTimeSeconds
totalDistance += lap.DistanceMeters
totalCalories += lap.Calories
if lap.AvgHR.Value > 0 {
hrDurationSum += float64(lap.AvgHR.Value) * lap.TotalTimeSeconds
hrDuration += lap.TotalTimeSeconds
}
if lap.MaxHR.Value > maxHR {
maxHR = lap.MaxHR.Value
}
if lap.Extensions.LX.AvgWatts > 0 {
powerDurationSum += float64(lap.Extensions.LX.AvgWatts) * lap.TotalTimeSeconds
powerDuration += lap.TotalTimeSeconds
}
if lap.Extensions.LX.MaxWatts > maxPower {
maxPower = lap.Extensions.LX.MaxWatts
}
if lap.Cadence > 0 {
cadDurationSum += float64(lap.Cadence) * lap.TotalTimeSeconds
cadDuration += lap.TotalTimeSeconds
}
}
result.Duration = int(math.Round(totalDuration))
result.Distance = totalDistance / 1000.0 // meters to km
result.CaloriesBurned = totalCalories
result.MaxHR = maxHR
result.MaxPower = maxPower
if hrDuration > 0 {
result.AvgHR = int(math.Round(hrDurationSum / hrDuration))
}
if powerDuration > 0 {
result.AvgPower = int(math.Round(powerDurationSum / powerDuration))
}
if cadDuration > 0 {
result.AvgCadence = int(math.Round(cadDurationSum / cadDuration))
}
// Calculate elevation gain from trackpoints
result.ElevGain = calculateTCXElevGain(act.Laps)
// Parse start time from activity ID or first lap
if act.ID != "" {
if t, err := time.Parse(time.RFC3339, act.ID); err == nil {
result.StartTime = t
}
}
if result.StartTime.IsZero() && len(act.Laps) > 0 && act.Laps[0].StartTime != "" {
if t, err := time.Parse(time.RFC3339, act.Laps[0].StartTime); err == nil {
result.StartTime = t
}
}
return result, nil
}
// calculateTCXElevGain computes total ascent from trackpoint altitude data.
func calculateTCXElevGain(laps []tcxLap) int {
var elevGain float64
var prevAlt float64
first := true
for _, lap := range laps {
for _, tp := range lap.Track.Trackpoints {
if tp.AltitudeMeters == 0 {
continue
}
if first {
prevAlt = tp.AltitudeMeters
first = false
continue
}
delta := tp.AltitudeMeters - prevAlt
if delta > 0 {
elevGain += delta
}
prevAlt = tp.AltitudeMeters
}
}
return int(math.Round(elevGain))
}