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