2026-07-17 18:33:06 +02:00
|
|
|
package sync
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
2026-07-19 10:52:09 +02:00
|
|
|
"strings"
|
2026-07-17 18:33:06 +02:00
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"smartrun/backend/internal/classify"
|
|
|
|
|
"smartrun/backend/internal/garmin"
|
|
|
|
|
"smartrun/backend/internal/store"
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-19 10:52:09 +02:00
|
|
|
// 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")
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 18:33:06 +02:00
|
|
|
func toActivityRow(a garmin.Activity) store.Activity {
|
|
|
|
|
return store.Activity{
|
|
|
|
|
GarminActivityID: a.ActivityID,
|
2026-07-19 10:52:09 +02:00
|
|
|
EventTypeKey: a.EventType.TypeKey,
|
2026-07-19 11:55:13 +02:00
|
|
|
WorkoutID: a.WorkoutID,
|
2026-07-17 18:33:06 +02:00
|
|
|
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.
|
2026-07-19 11:55:13 +02:00
|
|
|
func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.WorkoutStep, profile store.Profile) []store.Lap {
|
2026-07-17 18:33:06 +02:00
|
|
|
rows := make([]store.Lap, 0, len(laps))
|
|
|
|
|
var elapsedStart float64
|
2026-07-19 11:55:13 +02:00
|
|
|
for i, l := range laps {
|
2026-07-17 18:33:06 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 11:55:13 +02:00
|
|
|
var paceLow, paceHigh, hrLow, hrHigh *float64
|
|
|
|
|
if i < len(targets) && targets[i] != nil {
|
|
|
|
|
paceLow, paceHigh = targetPaceRange(*targets[i])
|
|
|
|
|
hrLow, hrHigh = targetHRRange(*targets[i], profile)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 18:33:06 +02:00
|
|
|
rows = append(rows, store.Lap{
|
|
|
|
|
LapIndex: l.LapIndex,
|
|
|
|
|
AvgSpeedMps: nonZero(l.AverageSpeed),
|
|
|
|
|
IntensityType: l.IntensityType,
|
|
|
|
|
HRDriftBpmPerMin: driftPtr,
|
|
|
|
|
HRRecoveryBpmPerMin: recoveryPtr,
|
2026-07-19 11:55:13 +02:00
|
|
|
TargetPaceLowMps: paceLow,
|
|
|
|
|
TargetPaceHighMps: paceHigh,
|
|
|
|
|
TargetHRLowBpm: hrLow,
|
|
|
|
|
TargetHRHighBpm: hrHigh,
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
RawJSON: string(l.Raw),
|
2026-07-17 18:33:06 +02:00
|
|
|
})
|
|
|
|
|
elapsedStart = elapsedEnd
|
|
|
|
|
}
|
|
|
|
|
return rows
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 11:55:13 +02:00
|
|
|
// alignWorkoutTargets zips an activity's recorded laps against its
|
|
|
|
|
// structured workout's flattened steps, returning one *garmin.WorkoutStep
|
2026-07-19 18:01:06 +02:00
|
|
|
// 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.
|
2026-07-19 11:55:13 +02:00
|
|
|
func alignWorkoutTargets(laps []garmin.Lap, workout garmin.Workout) []*garmin.WorkoutStep {
|
|
|
|
|
steps := workout.FlattenSteps()
|
2026-07-19 18:01:06 +02:00
|
|
|
out := make([]*garmin.WorkoutStep, len(laps))
|
|
|
|
|
|
|
|
|
|
switch len(laps) - len(steps) {
|
|
|
|
|
case 0, 1:
|
|
|
|
|
for i := range steps {
|
|
|
|
|
s := steps[i]
|
|
|
|
|
out[i] = &s
|
|
|
|
|
}
|
2026-07-19 11:55:13 +02:00
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 18:33:06 +02:00
|
|
|
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 {
|
2026-07-19 10:52:09 +02:00
|
|
|
isRace := 0.0
|
|
|
|
|
if a.EventTypeKey == "race" {
|
|
|
|
|
isRace = 1.0
|
|
|
|
|
}
|
2026-07-17 18:33:06 +02:00
|
|
|
ctx := classify.MetricContext{
|
|
|
|
|
"duration_seconds": a.DurationSeconds,
|
|
|
|
|
"distance_meters": a.DistanceMeters,
|
2026-07-19 10:52:09 +02:00
|
|
|
"is_race": isRace,
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
|
|
|
|
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") }
|