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)) }