Race is the 8th fixed workout kind, seeded with a real (not placeholder) rule since Garmin Connect's eventType.typeKey reports "race" for manually-tagged race activities. Non-running activity types (padel, cycling, strength training, ...) are now dropped at sync time instead of being stored. The Review Queue's type filter is now clickable exclusive pill buttons instead of a dropdown.
221 lines
7.0 KiB
Go
221 lines
7.0 KiB
Go
package sync
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"time"
|
|
|
|
"smartrun/backend/internal/classify"
|
|
"smartrun/backend/internal/garmin"
|
|
"smartrun/backend/internal/store"
|
|
)
|
|
|
|
// isRunningActivityType reports whether a Garmin activityType.typeKey
|
|
// represents a running activity (running, trail_running, treadmill_running,
|
|
// track_running, indoor_running, virtual_run, ...) as opposed to other
|
|
// sports (padel, cycling, strength training, ...) that also show up in
|
|
// get_activities().
|
|
func isRunningActivityType(typeKey string) bool {
|
|
return strings.Contains(strings.ToLower(typeKey), "run")
|
|
}
|
|
|
|
func toActivityRow(a garmin.Activity) store.Activity {
|
|
return store.Activity{
|
|
GarminActivityID: a.ActivityID,
|
|
ActivityName: a.ActivityName,
|
|
ActivityType: a.ActivityType.TypeKey,
|
|
EventTypeKey: a.EventType.TypeKey,
|
|
StartTimeUTC: a.StartTimeGMT,
|
|
BeginTimestampMs: a.BeginTimestamp,
|
|
DurationSeconds: a.Duration,
|
|
DistanceMeters: a.Distance,
|
|
AvgHR: nonZero(a.AverageHR),
|
|
MaxHR: nonZero(a.MaxHR),
|
|
AvgSpeedMps: nonZero(a.AverageSpeed),
|
|
MaxSpeedMps: nonZero(a.MaxSpeed),
|
|
ElevationGainM: a.ElevationGain,
|
|
ElevationLossM: a.ElevationLoss,
|
|
Calories: nonZero(a.Calories),
|
|
LapCount: a.LapCount,
|
|
AerobicTrainingEffect: nonZero(a.AerobicTrainingEffect),
|
|
AnaerobicTrainingEffect: nonZero(a.AnaerobicTrainingEffect),
|
|
TrainingEffectLabel: a.TrainingEffectLabel,
|
|
VO2MaxValue: a.VO2MaxValue,
|
|
HrTimeInZone1: nonZero(a.HrTimeInZone1),
|
|
HrTimeInZone2: nonZero(a.HrTimeInZone2),
|
|
HrTimeInZone3: nonZero(a.HrTimeInZone3),
|
|
HrTimeInZone4: nonZero(a.HrTimeInZone4),
|
|
HrTimeInZone5: nonZero(a.HrTimeInZone5),
|
|
RawJSON: string(a.Raw),
|
|
}
|
|
}
|
|
|
|
// nonZero returns nil for a zero value so store columns stay NULL instead of
|
|
// a misleading 0 when Garmin simply didn't report that field.
|
|
func nonZero(v float64) *float64 {
|
|
if v == 0 {
|
|
return nil
|
|
}
|
|
return &v
|
|
}
|
|
|
|
// toLapRows converts garmin lap DTOs into store rows, computing each lap's
|
|
// HR drift (active laps) or recovery rate (rest laps) from the samples that
|
|
// fall within that lap's time window. Lap boundaries are derived from
|
|
// cumulative elapsed duration rather than parsing StartTimeGMT, since laps
|
|
// are contiguous and this sidesteps timezone parsing entirely.
|
|
func toLapRows(laps []garmin.Lap, samples []garmin.Sample) []store.Lap {
|
|
rows := make([]store.Lap, 0, len(laps))
|
|
var elapsedStart float64
|
|
for _, l := range laps {
|
|
elapsedEnd := elapsedStart + l.ElapsedDuration
|
|
|
|
var driftPtr, recoveryPtr *float64
|
|
lapSamples := samplesInWindow(samples, elapsedStart, elapsedEnd)
|
|
switch l.IntensityType {
|
|
case "ACTIVE":
|
|
if v, ok := classify.HRDrift(lapSamples); ok {
|
|
driftPtr = &v
|
|
}
|
|
case "REST", "RECOVERY", "COOLDOWN", "WARMUP":
|
|
if v, ok := classify.HRRecovery(lapSamples); ok {
|
|
recoveryPtr = &v
|
|
}
|
|
}
|
|
|
|
raw, _ := json.Marshal(l)
|
|
rows = append(rows, store.Lap{
|
|
LapIndex: l.LapIndex,
|
|
StartTimeUTC: l.StartTimeGMT,
|
|
DurationSeconds: l.Duration,
|
|
DistanceMeters: l.Distance,
|
|
AvgHR: nonZero(l.AverageHR),
|
|
MaxHR: nonZero(l.MaxHR),
|
|
AvgSpeedMps: nonZero(l.AverageSpeed),
|
|
MaxSpeedMps: nonZero(l.MaxSpeed),
|
|
ElevationGainM: nonZero(l.ElevationGain),
|
|
ElevationLossM: nonZero(l.ElevationLoss),
|
|
IntensityType: l.IntensityType,
|
|
HRDriftBpmPerMin: driftPtr,
|
|
HRRecoveryBpmPerMin: recoveryPtr,
|
|
RawJSON: string(raw),
|
|
})
|
|
elapsedStart = elapsedEnd
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func samplesInWindow(samples []garmin.Sample, start, end float64) []classify.SampleInfo {
|
|
var out []classify.SampleInfo
|
|
for _, s := range samples {
|
|
if s.ElapsedSeconds < start || s.ElapsedSeconds >= end {
|
|
continue
|
|
}
|
|
out = append(out, classify.SampleInfo{ElapsedSeconds: s.ElapsedSeconds, HeartRate: s.HeartRate})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func toSampleRows(samples []garmin.Sample) []store.Sample {
|
|
rows := make([]store.Sample, len(samples))
|
|
for i, s := range samples {
|
|
rows[i] = store.Sample{
|
|
ElapsedSeconds: s.ElapsedSeconds,
|
|
TimestampMs: s.TimestampMS,
|
|
HeartRate: s.HeartRate,
|
|
SpeedMps: s.SpeedMps,
|
|
DistanceM: s.DistanceM,
|
|
ElevationM: s.ElevationM,
|
|
}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
// buildMetricContext computes the classify.MetricContext for one activity
|
|
// from its stored summary and laps, ready to evaluate against workout kind
|
|
// rules. maxHR is the user's configured max heart rate, used only to derive
|
|
// avg_hr_pct_max (Garmin's activity/lap summaries don't include it directly).
|
|
func buildMetricContext(a store.Activity, laps []store.Lap, maxHR float64) classify.MetricContext {
|
|
isRace := 0.0
|
|
if a.EventTypeKey == "race" {
|
|
isRace = 1.0
|
|
}
|
|
ctx := classify.MetricContext{
|
|
"duration_seconds": a.DurationSeconds,
|
|
"distance_meters": a.DistanceMeters,
|
|
"is_race": isRace,
|
|
}
|
|
if a.AvgSpeedMps != nil && *a.AvgSpeedMps > 0 {
|
|
ctx["avg_pace_sec_per_km"] = 1000 / *a.AvgSpeedMps
|
|
}
|
|
if a.AvgHR != nil {
|
|
ctx["avg_hr"] = *a.AvgHR
|
|
if maxHR > 0 {
|
|
ctx["avg_hr_pct_max"] = *a.AvgHR / maxHR
|
|
}
|
|
}
|
|
if a.MaxHR != nil {
|
|
ctx["max_hr"] = *a.MaxHR
|
|
}
|
|
if a.ElevationGainM != nil {
|
|
ctx["elevation_gain_m"] = *a.ElevationGainM
|
|
}
|
|
if a.AerobicTrainingEffect != nil {
|
|
ctx["aerobic_training_effect"] = *a.AerobicTrainingEffect
|
|
}
|
|
if a.AnaerobicTrainingEffect != nil {
|
|
ctx["anaerobic_training_effect"] = *a.AnaerobicTrainingEffect
|
|
}
|
|
if a.VO2MaxValue != nil {
|
|
ctx["vo2max_value"] = *a.VO2MaxValue
|
|
}
|
|
|
|
lapInfos := make([]classify.LapInfo, len(laps))
|
|
var paces []float64
|
|
var maxDrift, maxRecovery float64
|
|
haveDrift, haveRecovery := false, false
|
|
for i, l := range laps {
|
|
lapInfos[i] = classify.LapInfo{IntensityType: l.IntensityType}
|
|
if l.AvgSpeedMps != nil && *l.AvgSpeedMps > 0 {
|
|
paces = append(paces, 1000 / *l.AvgSpeedMps)
|
|
}
|
|
if l.HRDriftBpmPerMin != nil && (!haveDrift || *l.HRDriftBpmPerMin > maxDrift) {
|
|
maxDrift, haveDrift = *l.HRDriftBpmPerMin, true
|
|
}
|
|
if l.HRRecoveryBpmPerMin != nil && (!haveRecovery || *l.HRRecoveryBpmPerMin > maxRecovery) {
|
|
maxRecovery, haveRecovery = *l.HRRecoveryBpmPerMin, true
|
|
}
|
|
}
|
|
if len(laps) > 0 {
|
|
if classify.DetectIntervalPattern(lapInfos) {
|
|
ctx["lap_interval_pattern"] = 1
|
|
} else {
|
|
ctx["lap_interval_pattern"] = 0
|
|
}
|
|
}
|
|
if len(paces) > 0 {
|
|
ctx["lap_pace_stddev"] = classify.LapPaceStdDev(paces)
|
|
}
|
|
if haveDrift {
|
|
ctx["lap_hr_drift_bpm_per_min"] = maxDrift
|
|
}
|
|
if haveRecovery {
|
|
ctx["lap_hr_recovery_bpm_per_min"] = maxRecovery
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
func loadRuleKinds(kinds []store.WorkoutKind) ([]classify.RuleKind, error) {
|
|
rules := make([]classify.RuleKind, 0, len(kinds))
|
|
for _, k := range kinds {
|
|
var node classify.Node
|
|
if err := json.Unmarshal([]byte(k.RuleJSON), &node); err != nil {
|
|
return nil, err
|
|
}
|
|
rules = append(rules, classify.RuleKind{WorkoutKindID: k.ID, Name: k.Name, Rule: node})
|
|
}
|
|
return rules, nil
|
|
}
|
|
|
|
func dateStr(t time.Time) string { return t.Format("2006-01-02") }
|