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>
This commit is contained in:
2026-07-20 06:33:47 +02:00
parent 9818d35910
commit 518bccf5ab
56 changed files with 2334 additions and 890 deletions

View File

@@ -13,13 +13,14 @@ import (
)
// workoutKindResponse combines a workout kind's rule/metadata with its pace
// range and expected HR zone (stored separately in workout_type_paces),
// since the frontend always edits and displays them together.
// and HR range (stored separately in workout_type_paces), since the frontend
// always edits and displays them together.
type workoutKindResponse struct {
store.WorkoutKind
PaceMinSecPerKm *float64 `json:"pace_min_sec_per_km"`
PaceMaxSecPerKm *float64 `json:"pace_max_sec_per_km"`
ExpectedHRZone *int `json:"expected_hr_zone"`
HRMinPctHRR *float64 `json:"hr_min_pct_hrr"`
HRMaxPctHRR *float64 `json:"hr_max_pct_hrr"`
}
func (s *Server) toWorkoutKindResponse(r *http.Request, k store.WorkoutKind) (workoutKindResponse, error) {
@@ -31,7 +32,8 @@ func (s *Server) toWorkoutKindResponse(r *http.Request, k store.WorkoutKind) (wo
WorkoutKind: k,
PaceMinSecPerKm: pace.PaceMinSecPerKm,
PaceMaxSecPerKm: pace.PaceMaxSecPerKm,
ExpectedHRZone: pace.ExpectedHRZone,
HRMinPctHRR: pace.HRMinPctHRR,
HRMaxPctHRR: pace.HRMaxPctHRR,
}, nil
}
@@ -44,7 +46,8 @@ type workoutKindRequest struct {
IsActive *bool `json:"is_active"`
PaceMinSecPerKm *float64 `json:"pace_min_sec_per_km"`
PaceMaxSecPerKm *float64 `json:"pace_max_sec_per_km"`
ExpectedHRZone *int `json:"expected_hr_zone"`
HRMinPctHRR *float64 `json:"hr_min_pct_hrr"`
HRMaxPctHRR *float64 `json:"hr_max_pct_hrr"`
}
func (req workoutKindRequest) validate() (classify.Node, error) {
@@ -58,8 +61,11 @@ func (req workoutKindRequest) validate() (classify.Node, error) {
if err := node.Validate(); err != nil {
return node, errors.New("invalid rule: " + err.Error())
}
if req.ExpectedHRZone != nil && (*req.ExpectedHRZone < 1 || *req.ExpectedHRZone > 5) {
return node, errors.New("expected_hr_zone must be between 1 and 5")
if req.PaceMinSecPerKm != nil && req.PaceMaxSecPerKm != nil && *req.PaceMinSecPerKm >= *req.PaceMaxSecPerKm {
return node, errors.New("pace_min_sec_per_km must be less than pace_max_sec_per_km")
}
if req.HRMinPctHRR != nil && req.HRMaxPctHRR != nil && *req.HRMinPctHRR >= *req.HRMaxPctHRR {
return node, errors.New("hr_min_pct_hrr must be less than hr_max_pct_hrr")
}
return node, nil
}
@@ -148,7 +154,8 @@ func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request)
WorkoutKindID: id,
PaceMinSecPerKm: req.PaceMinSecPerKm,
PaceMaxSecPerKm: req.PaceMaxSecPerKm,
ExpectedHRZone: req.ExpectedHRZone,
HRMinPctHRR: req.HRMinPctHRR,
HRMaxPctHRR: req.HRMaxPctHRR,
}); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return