2026-08-04 16:04:18 +02:00
|
|
|
package garmin
|
2026-07-17 18:33:06 +02:00
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
2026-07-19 10:52:09 +02:00
|
|
|
"strings"
|
2026-07-17 18:33:06 +02:00
|
|
|
"time"
|
|
|
|
|
|
2026-07-24 21:08:07 +02:00
|
|
|
"geniusrun/backend/internal/classify"
|
|
|
|
|
"geniusrun/backend/internal/store"
|
2026-07-17 18:33:06 +02:00
|
|
|
)
|
|
|
|
|
|
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-08-04 16:04:18 +02:00
|
|
|
func toActivityRow(a Activity) store.Activity {
|
2026-07-17 18:33:06 +02:00
|
|
|
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-08-04 16:04:18 +02:00
|
|
|
func toLapRows(laps []Lap, samples []Sample, targets []*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
|
|
|
|
|
}
|
|
|
|
|
|
feat(sync): split FillPendingDetails into activities/workouts phases
Progress becomes phase-aware ({Phase, Done, Total} instead of a flat
{Done, Total}), with FullSync reporting an indeterminate "discovering"
phase during backfill/incremental sync (there's no meaningful total
before those calls have already happened), then FillPendingDetails
reporting a real Done/Total for its activities pass, then its new,
independent workouts pass.
alignWorkoutTargets now takes a lap count instead of a []garmin.Lap
slice, since it never used lap content, only length -- this lets the
new workouts pass call it against laps read back from the DB rather
than needing the original Garmin lap data again.
Splitting the passes also fixes a latent bug: an activity whose
details were fetched successfully but whose workout fetch failed in
that same run previously had no way to ever retry the workout fetch,
since ActivitiesMissingDetails stops returning it once
details_fetched_at/splits_fetched_at are set. ActivitiesMissingWorkout
queries workout_id/workout_raw_json independently, so it keeps
surfacing that activity until its workout is actually fetched.
2026-07-27 06:55:23 +02:00
|
|
|
// alignWorkoutTargets zips an activity's lap count against its structured
|
|
|
|
|
// workout's flattened steps, returning one *garmin.WorkoutStep per lap (nil
|
|
|
|
|
// where unavailable).
|
2026-07-19 18:01:06 +02:00
|
|
|
//
|
|
|
|
|
// 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-08-04 16:04:18 +02:00
|
|
|
func alignWorkoutTargets(lapCount int, workout Workout) []*WorkoutStep {
|
2026-07-19 11:55:13 +02:00
|
|
|
steps := workout.FlattenSteps()
|
2026-08-04 16:04:18 +02:00
|
|
|
out := make([]*WorkoutStep, lapCount)
|
2026-07-19 18:01:06 +02:00
|
|
|
|
feat(sync): split FillPendingDetails into activities/workouts phases
Progress becomes phase-aware ({Phase, Done, Total} instead of a flat
{Done, Total}), with FullSync reporting an indeterminate "discovering"
phase during backfill/incremental sync (there's no meaningful total
before those calls have already happened), then FillPendingDetails
reporting a real Done/Total for its activities pass, then its new,
independent workouts pass.
alignWorkoutTargets now takes a lap count instead of a []garmin.Lap
slice, since it never used lap content, only length -- this lets the
new workouts pass call it against laps read back from the DB rather
than needing the original Garmin lap data again.
Splitting the passes also fixes a latent bug: an activity whose
details were fetched successfully but whose workout fetch failed in
that same run previously had no way to ever retry the workout fetch,
since ActivitiesMissingDetails stops returning it once
details_fetched_at/splits_fetched_at are set. ActivitiesMissingWorkout
queries workout_id/workout_raw_json independently, so it keeps
surfacing that activity until its workout is actually fetched.
2026-07-27 06:55:23 +02:00
|
|
|
switch lapCount - len(steps) {
|
2026-07-19 18:01:06 +02:00
|
|
|
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.
|
2026-08-04 16:04:18 +02:00
|
|
|
func targetPaceRange(step WorkoutStep) (*float64, *float64) {
|
2026-07-19 11:55:13 +02:00
|
|
|
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.
|
2026-08-04 16:04:18 +02:00
|
|
|
func targetHRRange(step WorkoutStep, profile store.Profile) (*float64, *float64) {
|
2026-07-19 11:55:13 +02:00
|
|
|
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-08-04 16:04:18 +02:00
|
|
|
func samplesInWindow(samples []Sample, start, end float64) []classify.SampleInfo {
|
2026-07-17 18:33:06 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 16:04:18 +02:00
|
|
|
func toSampleRows(samples []Sample) []store.Sample {
|
2026-07-17 18:33:06 +02:00
|
|
|
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") }
|