feat: extend equipment and workout models with service tracking
This commit is contained in:
94
internal/activity/fit_parser.go
Normal file
94
internal/activity/fit_parser.go
Normal 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
|
||||
}
|
||||
251
internal/activity/gpx_parser.go
Normal file
251
internal/activity/gpx_parser.go
Normal 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
|
||||
}
|
||||
103
internal/activity/handler.go
Normal file
103
internal/activity/handler.go
Normal 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)
|
||||
}
|
||||
18
internal/activity/parser.go
Normal file
18
internal/activity/parser.go
Normal 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
|
||||
}
|
||||
126
internal/activity/service.go
Normal file
126
internal/activity/service.go
Normal 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
|
||||
}
|
||||
181
internal/activity/tcx_parser.go
Normal file
181
internal/activity/tcx_parser.go
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user