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, EventTypeKey: a.EventType.TypeKey, WorkoutID: a.WorkoutID, StartTimeUTC: a.StartTimeGMT, DurationSeconds: a.Duration, DistanceMeters: a.Distance, AvgHR: nonZero(a.AverageHR), MaxHR: nonZero(a.MaxHR), AvgSpeedMps: nonZero(a.AverageSpeed), ElevationGainM: a.ElevationGain, AerobicTrainingEffect: nonZero(a.AerobicTrainingEffect), AnaerobicTrainingEffect: nonZero(a.AnaerobicTrainingEffect), VO2MaxValue: a.VO2MaxValue, 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, targets []*garmin.WorkoutStep, profile store.Profile) []store.Lap { rows := make([]store.Lap, 0, len(laps)) var elapsedStart float64 for i, 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 } } var paceLow, paceHigh, hrLow, hrHigh *float64 if i < len(targets) && targets[i] != nil { paceLow, paceHigh = targetPaceRange(*targets[i]) hrLow, hrHigh = targetHRRange(*targets[i], profile) } rows = append(rows, store.Lap{ LapIndex: l.LapIndex, AvgSpeedMps: nonZero(l.AverageSpeed), IntensityType: l.IntensityType, HRDriftBpmPerMin: driftPtr, HRRecoveryBpmPerMin: recoveryPtr, TargetPaceLowMps: paceLow, TargetPaceHighMps: paceHigh, TargetHRLowBpm: hrLow, TargetHRHighBpm: hrHigh, RawJSON: string(l.Raw), }) elapsedStart = elapsedEnd } return rows } // alignWorkoutTargets zips an activity's recorded laps against its // structured workout's flattened steps, returning one *garmin.WorkoutStep // per lap (nil where unavailable). // // Confirmed against a real activity (via Garmin Connect's own workout view) // that recording sometimes continues one lap past the end of the workout's // last step -- e.g. a 5-minute prescribed cool-down followed by another // 6:46 the athlete just kept running, logged as a further lap Garmin never // defined a target for. That shows up here as exactly one more recorded lap // than the workout has steps, so that specific case zips the steps that do // exist and leaves the trailing extra lap unmapped, rather than discarding // every other lap's real target along with it. // // Any other mismatch (extra manual laps, auto-lap-by-distance also firing, // etc.) can't be trusted at all, so every entry comes back nil rather than // risk showing a target against the wrong lap. func alignWorkoutTargets(laps []garmin.Lap, workout garmin.Workout) []*garmin.WorkoutStep { steps := workout.FlattenSteps() out := make([]*garmin.WorkoutStep, len(laps)) switch len(laps) - len(steps) { case 0, 1: for i := range steps { s := steps[i] out[i] = &s } } return out } // targetPaceRange returns the (low, high) m/s bounds of a workout step's // pace-zone target, or (nil, nil) if it doesn't target pace. func targetPaceRange(step garmin.WorkoutStep) (*float64, *float64) { if step.TargetType.TypeKey != "pace.zone" || step.TargetValueOne == nil || step.TargetValueTwo == nil { return nil, nil } lo, hi := *step.TargetValueOne, *step.TargetValueTwo if lo > hi { lo, hi = hi, lo } return &lo, &hi } // targetHRRange returns the (low, high) bpm bounds of a workout step's // heart-rate-zone target, or (nil, nil) if it doesn't target heart rate. // Steps that target a named zone (ZoneNumber) rather than a custom bpm // range are resolved via the user's Karvonen profile. func targetHRRange(step garmin.WorkoutStep, profile store.Profile) (*float64, *float64) { if step.TargetType.TypeKey != "heart.rate.zone" { return nil, nil } if step.TargetValueOne != nil && step.TargetValueTwo != nil { lo, hi := *step.TargetValueOne, *step.TargetValueTwo if lo > hi { lo, hi = hi, lo } return &lo, &hi } if step.ZoneNumber != nil { if lo, hi, ok := karvonenBounds(profile, *step.ZoneNumber); ok { return &lo, &hi } } return nil, nil } // karvonenBounds resolves a named HR zone (1-5) to bpm bounds using the // user's max/resting heart rate and the zone's %HRR range, both from // Profile. ok is false when max/resting heart rate aren't configured, or // the zone number is out of range. func karvonenBounds(p store.Profile, zone int) (lowBpm, highBpm float64, ok bool) { if p.MaxHeartRate == nil || p.RestingHeartRate == nil { return 0, 0, false } maxHR, restHR := *p.MaxHeartRate, *p.RestingHeartRate var minPct, maxPct float64 switch zone { case 1: minPct, maxPct = p.HRZone1MinPct, p.HRZone1MaxPct case 2: minPct, maxPct = p.HRZone2MinPct, p.HRZone2MaxPct case 3: minPct, maxPct = p.HRZone3MinPct, p.HRZone3MaxPct case 4: minPct, maxPct = p.HRZone4MinPct, p.HRZone4MaxPct case 5: minPct, maxPct = p.HRZone5MinPct, p.HRZone5MaxPct default: return 0, 0, false } return restHR + (minPct/100)*(maxHR-restHR), restHR + (maxPct/100)*(maxHR-restHR), true } 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") }