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

@@ -6,23 +6,25 @@ import (
"fmt"
)
// WorkoutTypePace is a workout kind's user-declared target pace range and
// expected HR zone. Informational only -- never read by the classification
// rule engine. No history: fields are overwritten in place.
// WorkoutTypePace is a workout kind's user-declared target pace range and HR
// range (percent of heart rate reserve). Informational only -- never read by
// the classification rule engine. No history: fields are overwritten in
// place.
type WorkoutTypePace struct {
WorkoutKindID int64
PaceMinSecPerKm *float64
PaceMaxSecPerKm *float64
ExpectedHRZone *int
HRMinPctHRR *float64
HRMaxPctHRR *float64
}
func scanWorkoutTypePace(row interface{ Scan(...any) error }) (WorkoutTypePace, error) {
var p WorkoutTypePace
err := row.Scan(&p.WorkoutKindID, &p.PaceMinSecPerKm, &p.PaceMaxSecPerKm, &p.ExpectedHRZone)
err := row.Scan(&p.WorkoutKindID, &p.PaceMinSecPerKm, &p.PaceMaxSecPerKm, &p.HRMinPctHRR, &p.HRMaxPctHRR)
return p, err
}
const workoutTypePaceColumns = `workout_kind_id, pace_min_sec_per_km, pace_max_sec_per_km, expected_hr_zone`
const workoutTypePaceColumns = `workout_kind_id, pace_min_sec_per_km, pace_max_sec_per_km, hr_min_pct_hrr, hr_max_pct_hrr`
// GetWorkoutTypePace fetches the pace/zone row for one workout kind.
func (db *DB) GetWorkoutTypePace(ctx context.Context, workoutKindID int64) (WorkoutTypePace, error) {
@@ -40,9 +42,9 @@ func (db *DB) GetWorkoutTypePace(ctx context.Context, workoutKindID int64) (Work
// UpdateWorkoutTypePace overwrites the pace/zone row for one workout kind.
func (db *DB) UpdateWorkoutTypePace(ctx context.Context, p WorkoutTypePace) error {
_, err := db.ExecContext(ctx, `
UPDATE workout_type_paces SET pace_min_sec_per_km=?, pace_max_sec_per_km=?, expected_hr_zone=?
UPDATE workout_type_paces SET pace_min_sec_per_km=?, pace_max_sec_per_km=?, hr_min_pct_hrr=?, hr_max_pct_hrr=?
WHERE workout_kind_id=?`,
p.PaceMinSecPerKm, p.PaceMaxSecPerKm, p.ExpectedHRZone, p.WorkoutKindID)
p.PaceMinSecPerKm, p.PaceMaxSecPerKm, p.HRMinPctHRR, p.HRMaxPctHRR, p.WorkoutKindID)
if err != nil {
return fmt.Errorf("update workout type pace for kind %d: %w", p.WorkoutKindID, err)
}