diff --git a/.gitignore b/.gitignore
index c66f178..4c7a59a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,6 @@ frontend/dist/
backend/mcpspike
backend/cmd/mcpspike/mcpspike
.superpowers/
+
+# Claude Code session-local runtime state
+.claude/*.lock
diff --git a/backend/.idea/vcs.xml b/backend/.idea/vcs.xml
new file mode 100644
index 0000000..6c0b863
--- /dev/null
+++ b/backend/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/cmd/seedsample/main.go b/backend/cmd/seedsample/main.go
index 23b5444..b134522 100644
--- a/backend/cmd/seedsample/main.go
+++ b/backend/cmd/seedsample/main.go
@@ -152,17 +152,17 @@ func seedActivity(ctx context.Context, db *store.DB, p seedParams) int64 {
anaerobic := p.anaerobicTE
id, err := db.UpsertActivity(ctx, store.Activity{
GarminActivityID: p.garminID,
- ActivityName: p.name,
- ActivityType: "running",
StartTimeUTC: p.start.Format("2006-01-02 15:04:05"),
- BeginTimestampMs: p.start.UnixMilli(),
DurationSeconds: p.duration,
DistanceMeters: p.distance,
AvgSpeedMps: &speed,
AvgHR: &hr,
AerobicTrainingEffect: &aerobic,
AnaerobicTrainingEffect: &anaerobic,
- RawJSON: "{}",
+ // ActivityName/ActivityType aren't stored columns anymore -- the API
+ // decodes them from RawJSON, so seeded activities need them here to
+ // still show a sensible name/type in the dev UI.
+ RawJSON: fmt.Sprintf(`{"activityName":%q,"activityType":{"typeKey":"running"}}`, p.name),
})
must(err)
return id
@@ -213,9 +213,14 @@ func seedIntervalLapsAndSamples(ctx context.Context, db *store.DB, activityID in
}
laps = append(laps, store.Lap{
- LapIndex: i + 1, DurationSeconds: lapDuration,
- DistanceMeters: 400, IntensityType: intensity, RawJSON: "{}",
- HRDriftBpmPerMin: drift, HRRecoveryBpmPerMin: recovery,
+ LapIndex: i + 1,
+ IntensityType: intensity,
+ // DurationSeconds/AvgHR aren't stored columns anymore -- the API
+ // decodes them from RawJSON, so seeded laps need them here to
+ // still show a sensible duration/HR on the dev chart.
+ RawJSON: fmt.Sprintf(`{"duration":%v,"distance":400,"averageHR":%v}`, lapDuration, baseHR),
+ HRDriftBpmPerMin: drift,
+ HRRecoveryBpmPerMin: recovery,
})
elapsed += lapDuration
}
diff --git a/backend/internal/api/activities.go b/backend/internal/api/activities.go
index 85ec924..d9cce47 100644
--- a/backend/internal/api/activities.go
+++ b/backend/internal/api/activities.go
@@ -13,7 +13,7 @@ import (
// current classification so the frontend can show what kind it's assigned
// to and whether that assignment is locked (see Locked's doc comment).
type activityListItem struct {
- store.Activity
+ activityResponse
WorkoutKindID *int64 `json:"workout_kind_id"`
WorkoutKindName *string `json:"workout_kind_name"`
AssignmentSource *string `json:"assignment_source"`
@@ -55,7 +55,7 @@ func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
resp := make([]activityListItem, 0, len(activities))
for _, a := range activities {
- item := activityListItem{Activity: a}
+ item := activityListItem{activityResponse: toActivityResponse(a)}
assignment, ok, err := s.DB.CurrentAssignment(r.Context(), a.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
@@ -107,7 +107,7 @@ func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
return
}
- resp := map[string]any{"activity": activity, "laps": laps}
+ resp := map[string]any{"activity": toActivityResponse(activity), "laps": toLapResponses(laps)}
if hasAssignment {
resp["assignment"] = assignment
}
diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go
index 6fcd172..dea5dd7 100644
--- a/backend/internal/api/api_test.go
+++ b/backend/internal/api/api_test.go
@@ -74,7 +74,7 @@ func TestWorkoutKindList_ReturnsEightSeededTypesWithPaceFields(t *testing.T) {
t.Fatalf("expected 8 seeded kinds, got %d", len(kinds))
}
for _, k := range kinds {
- if k.PaceMinSecPerKm != nil || k.PaceMaxSecPerKm != nil || k.ExpectedHRZone != nil {
+ if k.PaceMinSecPerKm != nil || k.PaceMaxSecPerKm != nil || k.HRMinPctHRR != nil || k.HRMaxPctHRR != nil {
t.Errorf("expected freshly-seeded kind %q to have nil pace fields, got %+v", k.Name, k)
}
}
@@ -89,13 +89,14 @@ func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) {
json.Unmarshal(rec.Body.Bytes(), &kinds)
target := kinds[0]
- minPace, maxPace, zone := 330.0, 420.0, 2
+ minPace, maxPace, hrMin, hrMax := 330.0, 420.0, 70.0, 80.0
body := map[string]any{
"name": target.Name,
"rule": json.RawMessage(`{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[270,300]}]}`),
"pace_min_sec_per_km": minPace,
"pace_max_sec_per_km": maxPace,
- "expected_hr_zone": zone,
+ "hr_min_pct_hrr": hrMin,
+ "hr_max_pct_hrr": hrMax,
}
rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(target.ID), body)
if rec.Code != http.StatusOK {
@@ -108,8 +109,11 @@ func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) {
if updated.PaceMinSecPerKm == nil || *updated.PaceMinSecPerKm != 330 {
t.Errorf("PaceMinSecPerKm = %v, want 330", updated.PaceMinSecPerKm)
}
- if updated.ExpectedHRZone == nil || *updated.ExpectedHRZone != 2 {
- t.Errorf("ExpectedHRZone = %v, want 2", updated.ExpectedHRZone)
+ if updated.HRMinPctHRR == nil || *updated.HRMinPctHRR != 70 {
+ t.Errorf("HRMinPctHRR = %v, want 70", updated.HRMinPctHRR)
+ }
+ if updated.HRMaxPctHRR == nil || *updated.HRMaxPctHRR != 80 {
+ t.Errorf("HRMaxPctHRR = %v, want 80", updated.HRMaxPctHRR)
}
}
@@ -130,7 +134,7 @@ func TestWorkoutKindUpdate_RejectsInvalidRule(t *testing.T) {
}
}
-func TestWorkoutKindUpdate_RejectsInvalidHRZone(t *testing.T) {
+func TestWorkoutKindUpdate_RejectsInvertedHRRange(t *testing.T) {
s, _ := newTestServer(t)
router := s.Router()
@@ -139,9 +143,29 @@ func TestWorkoutKindUpdate_RejectsInvalidHRZone(t *testing.T) {
json.Unmarshal(rec.Body.Bytes(), &kinds)
rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(kinds[0].ID), map[string]any{
- "name": kinds[0].Name,
- "rule": json.RawMessage(`{"match":"all","conditions":[]}`),
- "expected_hr_zone": 9,
+ "name": kinds[0].Name,
+ "rule": json.RawMessage(`{"match":"all","conditions":[]}`),
+ "hr_min_pct_hrr": 80,
+ "hr_max_pct_hrr": 70,
+ })
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestWorkoutKindUpdate_RejectsInvertedPaceRange(t *testing.T) {
+ s, _ := newTestServer(t)
+ router := s.Router()
+
+ rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
+ var kinds []workoutKindResponse
+ json.Unmarshal(rec.Body.Bytes(), &kinds)
+
+ rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(kinds[0].ID), map[string]any{
+ "name": kinds[0].Name,
+ "rule": json.RawMessage(`{"match":"all","conditions":[]}`),
+ "pace_min_sec_per_km": 420,
+ "pace_max_sec_per_km": 330,
})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
@@ -152,11 +176,11 @@ func TestReviewQueueResolve(t *testing.T) {
s, db := newTestServer(t)
ctx := newCtx()
- activityID, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
+ activityID, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
- kindID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Easy", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
+ kindID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
@@ -207,10 +231,73 @@ func TestReviewQueueResolve(t *testing.T) {
t.Fatalf("resolve status = %d, body = %s", rec.Code, rec.Body.String())
}
+ // The activity stays listed after resolving -- the Activities page shows
+ // every activity, locked or not, not just ones still needing review.
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
json.Unmarshal(rec.Body.Bytes(), &page)
- if len(page.Items) != 0 || page.Total != 0 {
- t.Fatalf("expected empty review queue after resolve, got %d items, total=%d", len(page.Items), page.Total)
+ if len(page.Items) != 1 || page.Total != 1 {
+ t.Fatalf("expected activity to remain listed after resolve, got %d items, total=%d", len(page.Items), page.Total)
+ }
+ if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual {
+ t.Fatalf("expected AssignmentSource=manual after resolve, got %v", page.Items[0]["AssignmentSource"])
+ }
+ if got := page.Items[0]["WorkoutKindID"]; got != float64(kindID) {
+ t.Fatalf("expected WorkoutKindID=%d after resolve, got %v", kindID, got)
+ }
+
+ // Unlocking reverts the source to rule_engine but keeps the same kind,
+ // so a later reclassify pass is free to change it again.
+ rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unlock", nil)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("unlock status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
+ json.Unmarshal(rec.Body.Bytes(), &page)
+ if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine {
+ t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"])
+ }
+ if got := page.Items[0]["WorkoutKindID"]; got != float64(kindID) {
+ t.Fatalf("expected WorkoutKindID=%d to survive unlock, got %v", kindID, got)
+ }
+
+ // Unlocking an already-unlocked activity is rejected.
+ rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unlock", nil)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400 unlocking a non-manual assignment, got %d", rec.Code)
+ }
+
+ // Manually unassigning clears the kind back to Unclassified and locks
+ // that decision (source=manual), same as resolving to a specific kind.
+ rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unassign", nil)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("unassign status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
+ json.Unmarshal(rec.Body.Bytes(), &page)
+ if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual {
+ t.Fatalf("expected AssignmentSource=manual after unassign, got %v", page.Items[0]["AssignmentSource"])
+ }
+ if got := page.Items[0]["WorkoutKindID"]; got != nil {
+ t.Fatalf("expected WorkoutKindID=nil after unassign, got %v", got)
+ }
+
+ // It also shows up under the Unclassified filter now.
+ rec = doJSON(t, router, http.MethodGet, "/api/review-queue/?unclassified=true", nil)
+ json.Unmarshal(rec.Body.Bytes(), &page)
+ if len(page.Items) != 1 || page.Total != 1 {
+ t.Fatalf("expected unassigned activity to show up as unclassified, got %d items, total=%d", len(page.Items), page.Total)
+ }
+
+ // Unassigning locks it, so unlocking works again (reverting to
+ // rule_engine sourcing with no kind, i.e. needs_review).
+ rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unlock", nil)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("unlock after unassign status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
+ json.Unmarshal(rec.Body.Bytes(), &page)
+ if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine {
+ t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"])
}
}
@@ -221,7 +308,7 @@ func TestReviewQueue_PaginatesByCursor(t *testing.T) {
// 5 activities, newest first once sorted: 2026-07-05 .. 2026-07-01.
for i := 1; i <= 5; i++ {
activityID, err := db.UpsertActivity(ctx, store.Activity{
- GarminActivityID: int64(i), ActivityType: "running",
+ GarminActivityID: int64(i),
StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", i), RawJSON: "{}",
})
if err != nil {
@@ -292,11 +379,98 @@ func TestReviewQueue_PaginatesByCursor(t *testing.T) {
}
}
+func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
+ s, db := newTestServer(t)
+ ctx := newCtx()
+
+ kinds, err := db.ListWorkoutKinds(ctx, true)
+ if err != nil || len(kinds) < 2 {
+ t.Fatalf("ListWorkoutKinds: %v (len=%d)", err, len(kinds))
+ }
+ kindA, kindB := kinds[0], kinds[1]
+
+ // 2 activities assigned to kindA, 1 to kindB, 1 unclassified.
+ makeActivity := func(n int64, kindID *int64) {
+ activityID, err := db.UpsertActivity(ctx, store.Activity{
+ GarminActivityID: n,
+ StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", n), RawJSON: "{}",
+ })
+ if err != nil {
+ t.Fatalf("UpsertActivity: %v", err)
+ }
+ status := store.AssignmentStatusAssigned
+ if kindID == nil {
+ status = store.AssignmentStatusNeedsReview
+ }
+ if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
+ ActivityID: activityID, WorkoutKindID: kindID, AssignmentSource: store.AssignmentSourceRuleEngine,
+ Status: status, CandidateKindsJSON: "[]",
+ }); err != nil {
+ t.Fatalf("InsertKindAssignment: %v", err)
+ }
+ }
+ makeActivity(1, &kindA.ID)
+ makeActivity(2, &kindA.ID)
+ makeActivity(3, &kindB.ID)
+ makeActivity(4, nil)
+
+ router := s.Router()
+
+ type page struct {
+ Items []map[string]any `json:"items"`
+ NextCursor *string `json:"next_cursor"`
+ Total int `json:"total"`
+ }
+ getPage := func(query string) page {
+ t.Helper()
+ rec := doJSON(t, router, http.MethodGet, "/api/review-queue/"+query, nil)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
+ }
+ var p page
+ if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ return p
+ }
+
+ // Filtering by kind_id, with a small limit, still paginates -- it doesn't
+ // have to load the whole matching backlog to serve one page.
+ kindAPage := getPage(fmt.Sprintf("?kind_id=%d&limit=1", kindA.ID))
+ if kindAPage.Total != 2 {
+ t.Fatalf("kindA total = %d, want 2", kindAPage.Total)
+ }
+ if len(kindAPage.Items) != 1 {
+ t.Fatalf("kindA page: expected 1 item (limit=1), got %d", len(kindAPage.Items))
+ }
+ if kindAPage.NextCursor == nil {
+ t.Fatal("expected a next_cursor on kindA's first (limited) page")
+ }
+
+ kindBPage := getPage(fmt.Sprintf("?kind_id=%d", kindB.ID))
+ if kindBPage.Total != 1 || len(kindBPage.Items) != 1 {
+ t.Fatalf("kindB page: total=%d items=%d, want 1/1", kindBPage.Total, len(kindBPage.Items))
+ }
+
+ unclassifiedPage := getPage("?unclassified=true")
+ if unclassifiedPage.Total != 1 || len(unclassifiedPage.Items) != 1 {
+ t.Fatalf("unclassified page: total=%d items=%d, want 1/1", unclassifiedPage.Total, len(unclassifiedPage.Items))
+ }
+ if unclassifiedPage.Items[0]["WorkoutKindID"] != nil {
+ t.Fatalf("expected nil WorkoutKindID in unclassified filter, got %v", unclassifiedPage.Items[0]["WorkoutKindID"])
+ }
+
+ all := getPage("")
+ if all.Total != 4 {
+ t.Fatalf("unfiltered total = %d, want 4", all.Total)
+ }
+}
+
func TestResolveReview_RejectsRaceKind(t *testing.T) {
s, db := newTestServer(t)
ctx := newCtx()
- activityID, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
+ activityID, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
@@ -324,9 +498,9 @@ func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
}
- ruleEngineActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
- manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, ActivityType: "running", StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
- raceActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 3, ActivityType: "running", EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
+ ruleEngineActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
+ manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
+ raceActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine,
@@ -391,9 +565,9 @@ func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) {
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
}
- ruleEngineActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
- manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, ActivityType: "running", StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
- raceActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 3, ActivityType: "running", EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
+ ruleEngineActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
+ manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
+ raceActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
@@ -433,7 +607,7 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
s, db := newTestServer(t)
ctx := newCtx()
- if _, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
+ if _, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
@@ -469,11 +643,11 @@ func TestProgression_ReturnsSortedTimeSeries(t *testing.T) {
s, db := newTestServer(t)
ctx := newCtx()
- kindID, _ := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Easy", RuleJSON: `{}`, IsActive: true})
+ kindID, _ := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{}`, IsActive: true})
speed := 3.0
- a1, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-01 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
- a2, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, ActivityType: "running", StartTimeUTC: "2026-07-05 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
+ a1, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
+ a2, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-05 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
for _, id := range []int64{a2, a1} { // insert out of order on purpose
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: id, WorkoutKindID: &kindID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
@@ -495,6 +669,25 @@ func TestProgression_ReturnsSortedTimeSeries(t *testing.T) {
}
}
+func TestMetricValue_EfficiencyFactor(t *testing.T) {
+ speed, hr := 3.0, 150.0
+ v, ok := metricValue("efficiency_factor", store.Activity{AvgSpeedMps: &speed, AvgHR: &hr})
+ if !ok {
+ t.Fatal("expected efficiency_factor to be computable")
+ }
+ if v != 20 {
+ t.Errorf("efficiency_factor = %v, want 20 (3.0/150*1000)", v)
+ }
+
+ if _, ok := metricValue("efficiency_factor", store.Activity{AvgSpeedMps: &speed}); ok {
+ t.Error("expected efficiency_factor to be unavailable without AvgHR")
+ }
+ zero := 0.0
+ if _, ok := metricValue("efficiency_factor", store.Activity{AvgSpeedMps: &speed, AvgHR: &zero}); ok {
+ t.Error("expected efficiency_factor to be unavailable with AvgHR=0")
+ }
+}
+
func itoa(v int64) string {
return strconv.FormatInt(v, 10)
}
diff --git a/backend/internal/api/display_fields.go b/backend/internal/api/display_fields.go
new file mode 100644
index 0000000..fe5e6c9
--- /dev/null
+++ b/backend/internal/api/display_fields.go
@@ -0,0 +1,75 @@
+package api
+
+import (
+ "encoding/json"
+
+ "smartrun/backend/internal/store"
+)
+
+// activityResponse adds back ActivityName/ActivityType as fields decoded
+// from RawJSON. They're no longer stored as their own columns (Phase 2 of
+// the 2026-07 duplication cleanup -- see store.Activity's doc comment):
+// Garmin's raw JSON is the only place they live now, decoded fresh for each
+// API response instead of kept as a redundant copy in the database.
+type activityResponse struct {
+ store.Activity
+ ActivityName string `json:"ActivityName"`
+ ActivityType string `json:"ActivityType"`
+}
+
+func toActivityResponse(a store.Activity) activityResponse {
+ name, typeKey := decodeActivityDisplayFields(a.RawJSON)
+ return activityResponse{Activity: a, ActivityName: name, ActivityType: typeKey}
+}
+
+type rawActivityDisplayFields struct {
+ ActivityName string `json:"activityName"`
+ ActivityType struct {
+ TypeKey string `json:"typeKey"`
+ } `json:"activityType"`
+}
+
+func decodeActivityDisplayFields(rawJSON string) (name, typeKey string) {
+ var f rawActivityDisplayFields
+ _ = json.Unmarshal([]byte(rawJSON), &f) // best-effort -- zero values are fine if rawJSON is empty/malformed
+ return f.ActivityName, f.ActivityType.TypeKey
+}
+
+// lapResponse adds back DurationSeconds/AvgHR as fields decoded from
+// RawJSON, same rationale as activityResponse -- the frontend chart still
+// needs them (x-axis timing, dashed average-HR line) even though nothing on
+// the backend does.
+type lapResponse struct {
+ store.Lap
+ DurationSeconds float64 `json:"DurationSeconds"`
+ AvgHR *float64 `json:"AvgHR"`
+}
+
+func toLapResponse(l store.Lap) lapResponse {
+ duration, avgHR := decodeLapDisplayFields(l.RawJSON)
+ return lapResponse{Lap: l, DurationSeconds: duration, AvgHR: avgHR}
+}
+
+func toLapResponses(laps []store.Lap) []lapResponse {
+ out := make([]lapResponse, len(laps))
+ for i, l := range laps {
+ out[i] = toLapResponse(l)
+ }
+ return out
+}
+
+type rawLapDisplayFields struct {
+ Duration float64 `json:"duration"`
+ AverageHR float64 `json:"averageHR"`
+}
+
+func decodeLapDisplayFields(rawJSON string) (durationSeconds float64, avgHR *float64) {
+ var f rawLapDisplayFields
+ _ = json.Unmarshal([]byte(rawJSON), &f)
+ durationSeconds = f.Duration
+ if f.AverageHR > 0 {
+ v := f.AverageHR
+ avgHR = &v
+ }
+ return
+}
diff --git a/backend/internal/api/kinds.go b/backend/internal/api/kinds.go
index c605ed1..70b9522 100644
--- a/backend/internal/api/kinds.go
+++ b/backend/internal/api/kinds.go
@@ -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
diff --git a/backend/internal/api/progression.go b/backend/internal/api/progression.go
index cdd9cb2..d98094a 100644
--- a/backend/internal/api/progression.go
+++ b/backend/internal/api/progression.go
@@ -43,6 +43,16 @@ func metricValue(metric string, a store.Activity) (float64, bool) {
return 0, false
}
return *a.AnaerobicTrainingEffect, true
+ case "efficiency_factor":
+ // Speed per heartbeat: how much ground each beat covers, on average.
+ // A rising trend at similar effort/HR over time is a classic sign of
+ // improving aerobic fitness, independent of pace/HR shown alone.
+ // Scaled by 1000 (m/s per bpm is otherwise a tiny fraction like
+ // 0.02, hard to read/compare at a glance).
+ if a.AvgSpeedMps == nil || a.AvgHR == nil || *a.AvgHR <= 0 {
+ return 0, false
+ }
+ return (*a.AvgSpeedMps / *a.AvgHR) * 1000, true
default:
return 0, false
}
diff --git a/backend/internal/api/review.go b/backend/internal/api/review.go
index 776ac7f..46913b2 100644
--- a/backend/internal/api/review.go
+++ b/backend/internal/api/review.go
@@ -17,18 +17,23 @@ const defaultReviewQueuePageSize = 10
type reviewQueueItem struct {
store.KindAssignment
- Activity store.Activity `json:"activity"`
- Laps []store.Lap `json:"laps"`
- Samples []store.Sample `json:"samples"`
+ Activity activityResponse `json:"activity"`
+ Laps []lapResponse `json:"laps"`
+ Samples []store.Sample `json:"samples"`
}
-// handleReviewQueue is cursor-paginated: fetching every needs_review
-// activity's laps and (especially) per-second samples is expensive once
-// there are many of them, so that work only happens for the requested page,
-// not the full backlog. Sorting/cursor filtering still needs each
-// activity's summary row (a cheap indexed lookup, no laps/samples), which
-// happens for the whole backlog -- only the heavy per-item fetches are
-// deferred to the page actually being returned.
+// handleReviewQueue backs the Activities page: every activity that has been
+// classified at least once, not just ones still needing review, so the page
+// can show each activity's current kind (or "Unclassified") and let the user
+// lock/unlock it. It's cursor-paginated: fetching every activity's laps and
+// (especially) per-second samples is expensive once there are many of them,
+// so that work only happens for the requested page, not the full backlog.
+// Sorting/cursor filtering still needs each activity's summary row (a cheap
+// indexed lookup, no laps/samples), which happens for the whole backlog --
+// only the heavy per-item fetches are deferred to the page actually being
+// returned. The optional kind_id/unclassified filters are applied before
+// that cursor slicing too, so a filtered view still only loads (and
+// chart-renders) one page at a time instead of the whole matching backlog.
func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
limit := defaultReviewQueuePageSize
if v := r.URL.Query().Get("limit"); v != "" {
@@ -38,7 +43,15 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
}
cursor := r.URL.Query().Get("before") // activity StartTimeUTC of the last item on the previous page
- queue, err := s.DB.ReviewQueue(r.Context())
+ var kindIDFilter *int64
+ if v := r.URL.Query().Get("kind_id"); v != "" {
+ if n, err := strconv.ParseInt(v, 10, 64); err == nil {
+ kindIDFilter = &n
+ }
+ }
+ unclassifiedOnly := r.URL.Query().Get("unclassified") == "true"
+
+ queue, err := s.DB.AllCurrentAssignments(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -50,6 +63,12 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
}
all := make([]withActivity, 0, len(queue))
for _, a := range queue {
+ if kindIDFilter != nil && (a.WorkoutKindID == nil || *a.WorkoutKindID != *kindIDFilter) {
+ continue
+ }
+ if unclassifiedOnly && a.WorkoutKindID != nil {
+ continue
+ }
activity, ok, err := s.DB.GetActivity(r.Context(), a.ActivityID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
@@ -96,7 +115,12 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
- items = append(items, reviewQueueItem{KindAssignment: wa.assignment, Activity: wa.activity, Laps: laps, Samples: samples})
+ items = append(items, reviewQueueItem{
+ KindAssignment: wa.assignment,
+ Activity: toActivityResponse(wa.activity),
+ Laps: toLapResponses(laps),
+ Samples: samples,
+ })
}
var nextCursor *string
@@ -157,3 +181,74 @@ func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"})
}
+
+// handleUnassignReview manually clears an activity's kind back to
+// Unclassified. Like handleResolveReview, it's a deliberate manual choice --
+// recorded as source=manual (with no kind) so the rule engine leaves it alone
+// on a later reclassify pass, exactly as it would for a manual kind
+// assignment. The frontend only offers this while the activity is unlocked
+// (locked activities must be unlocked first, same precondition as picking a
+// different kind), but the backend doesn't re-enforce that here, matching
+// handleResolveReview's own lack of a lock precondition check.
+func (s *Server) handleUnassignReview(w http.ResponseWriter, r *http.Request) {
+ activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "invalid activity id")
+ return
+ }
+
+ if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{
+ ActivityID: activityID,
+ WorkoutKindID: nil,
+ AssignmentSource: store.AssignmentSourceManual,
+ Status: store.AssignmentStatusNeedsReview,
+ CandidateKindsJSON: "[]",
+ }); err != nil {
+ writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"status": "unassigned"})
+}
+
+// handleUnlockReview reverts a manual assignment back to rule_engine
+// sourcing, keeping the same kind, so a later reclassify pass (which always
+// skips manual assignments) is free to change it again. It's the inverse of
+// handleResolveReview, not a delete: the kind stays visible as-is until
+// something actually reclassifies it.
+func (s *Server) handleUnlockReview(w http.ResponseWriter, r *http.Request) {
+ activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "invalid activity id")
+ return
+ }
+
+ current, ok, err := s.DB.CurrentAssignment(r.Context(), activityID)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ if !ok {
+ writeError(w, http.StatusNotFound, "activity has no assignment yet")
+ return
+ }
+ if current.AssignmentSource != store.AssignmentSourceManual {
+ writeError(w, http.StatusBadRequest, "activity is not manually locked")
+ return
+ }
+
+ status := store.AssignmentStatusAssigned
+ if current.WorkoutKindID == nil {
+ status = store.AssignmentStatusNeedsReview
+ }
+ if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{
+ ActivityID: activityID,
+ WorkoutKindID: current.WorkoutKindID,
+ AssignmentSource: store.AssignmentSourceRuleEngine,
+ Status: status,
+ CandidateKindsJSON: "[]",
+ }); err != nil {
+ writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"status": "unlocked"})
+}
diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go
index 22fe6c0..5977ee9 100644
--- a/backend/internal/api/server.go
+++ b/backend/internal/api/server.go
@@ -74,6 +74,8 @@ func (s *Server) Router() http.Handler {
r.Route("/review-queue", func(r chi.Router) {
r.Get("/", s.handleReviewQueue)
r.Post("/{activityID}/resolve", s.handleResolveReview)
+ r.Post("/{activityID}/unlock", s.handleUnlockReview)
+ r.Post("/{activityID}/unassign", s.handleUnassignReview)
})
r.Get("/progression/{kindID}", s.handleProgression)
diff --git a/backend/internal/api/sync.go b/backend/internal/api/sync.go
index 28b9975..63f26d0 100644
--- a/backend/internal/api/sync.go
+++ b/backend/internal/api/sync.go
@@ -15,16 +15,12 @@ const detailFillBatchSize = 50
// picked up automatically), then IncrementalSync (catches anything new since
// the latest known activity), then fills in details for whatever's still
// missing them. Activities already fully processed are left untouched --
-// see internal/sync.Service.FillPendingDetails.
+// see internal/sync.Service.FillPendingDetails. Recorded as a single
+// FullSync run so "last sync" reports the combined activity count, not just
+// whichever of Backfill/IncrementalSync happened to finish last.
func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
ok := s.backgroundSync(func(ctx context.Context) error {
- if err := s.Sync.Backfill(ctx); err != nil {
- return err
- }
- if err := s.Sync.IncrementalSync(ctx); err != nil {
- return err
- }
- return s.Sync.FillPendingDetails(ctx, detailFillBatchSize)
+ return s.Sync.FullSync(ctx, detailFillBatchSize)
})
if !ok {
writeError(w, http.StatusConflict, "a sync is already in progress")
diff --git a/backend/internal/garmin/client.go b/backend/internal/garmin/client.go
index 381a54d..f3bab3a 100644
--- a/backend/internal/garmin/client.go
+++ b/backend/internal/garmin/client.go
@@ -244,10 +244,23 @@ func (c *mcpClient) GetActivitySplits(ctx context.Context, activityID int64) (Ac
return ActivitySplits{}, err
}
- var splits ActivitySplits
- if err := json.Unmarshal([]byte(msg), &splits); err != nil {
+ var envelope struct {
+ ActivityID int64 `json:"activityId"`
+ Laps []json.RawMessage `json:"lapDTOs"`
+ }
+ if err := json.Unmarshal([]byte(msg), &envelope); err != nil {
return ActivitySplits{}, fmt.Errorf("get_activity_splits did not return valid JSON (%q): %w", truncate(msg, 200), err)
}
+
+ splits := ActivitySplits{ActivityID: envelope.ActivityID, Laps: make([]Lap, 0, len(envelope.Laps))}
+ for _, raw := range envelope.Laps {
+ var l Lap
+ if err := json.Unmarshal(raw, &l); err != nil {
+ return ActivitySplits{}, fmt.Errorf("parse lap: %w", err)
+ }
+ l.Raw = raw
+ splits.Laps = append(splits.Laps, l)
+ }
return splits, nil
}
@@ -269,6 +282,7 @@ func (c *mcpClient) GetActivityDetails(ctx context.Context, activityID int64) (A
if err := json.Unmarshal([]byte(msg), &details); err != nil {
return ActivityDetails{}, fmt.Errorf("get_activity_details did not return valid JSON (%q): %w", truncate(msg, 200), err)
}
+ details.Raw = json.RawMessage(msg)
return details, nil
}
@@ -290,6 +304,7 @@ func (c *mcpClient) GetWorkoutByID(ctx context.Context, workoutID int64) (Workou
if err := json.Unmarshal([]byte(msg), &workout); err != nil {
return Workout{}, fmt.Errorf("get_workout_by_id did not return valid JSON (%q): %w", truncate(msg, 200), err)
}
+ workout.Raw = json.RawMessage(msg)
return workout, nil
}
diff --git a/backend/internal/garmin/types.go b/backend/internal/garmin/types.go
index e805ae3..932001b 100644
--- a/backend/internal/garmin/types.go
+++ b/backend/internal/garmin/types.go
@@ -148,6 +148,10 @@ type Workout struct {
WorkoutID int64 `json:"workoutId"`
WorkoutName string `json:"workoutName"`
Segments []WorkoutSegment `json:"workoutSegments"`
+
+ // Raw holds the full original JSON object for this workout, same as
+ // Activity.Raw -- see GetWorkoutByID.
+ Raw json.RawMessage `json:"-"`
}
// FlattenSteps expands repeat groups (RepeatGroupDTO) into their repeated
@@ -195,6 +199,10 @@ type ActivityDetails struct {
ActivityID int64 `json:"activityId"`
MetricDescriptors []MetricDescriptor `json:"metricDescriptors"`
ActivityDetailMetrics []activityDetailMetricsRow `json:"activityDetailMetrics"`
+
+ // Raw holds the full original JSON object for this response, for fields
+ // not modeled above (or discovered later) without needing to re-fetch.
+ Raw json.RawMessage `json:"-"`
}
// Sample is one ~1-second telemetry reading extracted from ActivityDetails
diff --git a/backend/internal/store/activities.go b/backend/internal/store/activities.go
index a6fed4e..655a38b 100644
--- a/backend/internal/store/activities.go
+++ b/backend/internal/store/activities.go
@@ -9,40 +9,44 @@ import (
// Activity is smartrun's persisted view of a Garmin activity. Field mapping
// from the Garmin/MCP response happens in internal/sync, not here, so this
// package stays independent of internal/garmin.
+//
+// Deliberately NOT modeled here (though Garmin's response includes them):
+// ActivityName/ActivityType, plus every field removed in the 2026-07
+// dedup pass (BeginTimestampMs, MaxSpeedMps, ElevationLossM, Calories,
+// LapCount, TrainingEffectLabel, HrTimeInZone1-5). None of them are read by
+// any SQL query, the classify rule engine, or any other Go code -- they'd be
+// pure duplicates of RawJSON with no purpose beyond being slightly more
+// convenient to read than parsing JSON. ActivityName/ActivityType do have a
+// real frontend display need, so the API layer (internal/api) decodes them
+// from RawJSON at response time instead of storing a redundant copy -- see
+// decodeActivityDisplayFields.
type Activity struct {
ID int64
GarminActivityID int64
- ActivityName string
- ActivityType string
EventTypeKey string
WorkoutID *int64
StartTimeUTC string
- BeginTimestampMs int64
DurationSeconds float64
DistanceMeters float64
AvgHR *float64
MaxHR *float64
AvgSpeedMps *float64
- MaxSpeedMps *float64
ElevationGainM *float64
- ElevationLossM *float64
- Calories *float64
- LapCount int
AerobicTrainingEffect *float64
AnaerobicTrainingEffect *float64
- TrainingEffectLabel string
VO2MaxValue *float64
- HrTimeInZone1 *float64
- HrTimeInZone2 *float64
- HrTimeInZone3 *float64
- HrTimeInZone4 *float64
- HrTimeInZone5 *float64
RawJSON string
DetailsFetchedAt *string
DetailsRawJSON *string
SplitsFetchedAt *string
- CreatedAt string
- UpdatedAt string
+ // WorkoutRawJSON is the genuine raw get_workout_by_id() response for this
+ // activity's structured workout -- the source used to compute each lap's
+ // TargetPaceLowMps/HighMps and TargetHRLowBpm/HighBpm (see
+ // internal/sync/mapping.go's alignWorkoutTargets). Nil when the activity
+ // has no WorkoutID, or was synced before this column existed.
+ WorkoutRawJSON *string
+ CreatedAt string
+ UpdatedAt string
}
// UpsertActivity inserts a new activity or updates the existing row for the
@@ -51,49 +55,32 @@ type Activity struct {
func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) {
_, err := db.ExecContext(ctx, `
INSERT INTO activities (
- garmin_activity_id, activity_name, activity_type, event_type_key, workout_id, start_time_utc,
- begin_timestamp_ms, duration_seconds, distance_meters, avg_hr, max_hr,
- avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
- calories, lap_count, aerobic_training_effect, anaerobic_training_effect,
- training_effect_label, vo2max_value,
- hr_time_in_zone_1, hr_time_in_zone_2, hr_time_in_zone_3, hr_time_in_zone_4, hr_time_in_zone_5,
+ garmin_activity_id, event_type_key, workout_id, start_time_utc,
+ duration_seconds, distance_meters, avg_hr, max_hr,
+ avg_speed_mps, elevation_gain_m,
+ aerobic_training_effect, anaerobic_training_effect, vo2max_value,
raw_json, updated_at
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now'))
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now'))
ON CONFLICT(garmin_activity_id) DO UPDATE SET
- activity_name=excluded.activity_name,
- activity_type=excluded.activity_type,
event_type_key=excluded.event_type_key,
workout_id=excluded.workout_id,
start_time_utc=excluded.start_time_utc,
- begin_timestamp_ms=excluded.begin_timestamp_ms,
duration_seconds=excluded.duration_seconds,
distance_meters=excluded.distance_meters,
avg_hr=excluded.avg_hr,
max_hr=excluded.max_hr,
avg_speed_mps=excluded.avg_speed_mps,
- max_speed_mps=excluded.max_speed_mps,
elevation_gain_m=excluded.elevation_gain_m,
- elevation_loss_m=excluded.elevation_loss_m,
- calories=excluded.calories,
- lap_count=excluded.lap_count,
aerobic_training_effect=excluded.aerobic_training_effect,
anaerobic_training_effect=excluded.anaerobic_training_effect,
- training_effect_label=excluded.training_effect_label,
vo2max_value=excluded.vo2max_value,
- hr_time_in_zone_1=excluded.hr_time_in_zone_1,
- hr_time_in_zone_2=excluded.hr_time_in_zone_2,
- hr_time_in_zone_3=excluded.hr_time_in_zone_3,
- hr_time_in_zone_4=excluded.hr_time_in_zone_4,
- hr_time_in_zone_5=excluded.hr_time_in_zone_5,
raw_json=excluded.raw_json,
updated_at=datetime('now')
`,
- a.GarminActivityID, a.ActivityName, a.ActivityType, a.EventTypeKey, a.WorkoutID, a.StartTimeUTC,
- a.BeginTimestampMs, a.DurationSeconds, a.DistanceMeters, a.AvgHR, a.MaxHR,
- a.AvgSpeedMps, a.MaxSpeedMps, a.ElevationGainM, a.ElevationLossM,
- a.Calories, a.LapCount, a.AerobicTrainingEffect, a.AnaerobicTrainingEffect,
- a.TrainingEffectLabel, a.VO2MaxValue,
- a.HrTimeInZone1, a.HrTimeInZone2, a.HrTimeInZone3, a.HrTimeInZone4, a.HrTimeInZone5,
+ a.GarminActivityID, a.EventTypeKey, a.WorkoutID, a.StartTimeUTC,
+ a.DurationSeconds, a.DistanceMeters, a.AvgHR, a.MaxHR,
+ a.AvgSpeedMps, a.ElevationGainM,
+ a.AerobicTrainingEffect, a.AnaerobicTrainingEffect, a.VO2MaxValue,
a.RawJSON,
)
if err != nil {
@@ -110,26 +97,22 @@ func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) {
func scanActivity(row interface{ Scan(...any) error }) (Activity, error) {
var a Activity
err := row.Scan(
- &a.ID, &a.GarminActivityID, &a.ActivityName, &a.ActivityType, &a.EventTypeKey, &a.WorkoutID, &a.StartTimeUTC,
- &a.BeginTimestampMs, &a.DurationSeconds, &a.DistanceMeters, &a.AvgHR, &a.MaxHR,
- &a.AvgSpeedMps, &a.MaxSpeedMps, &a.ElevationGainM, &a.ElevationLossM,
- &a.Calories, &a.LapCount, &a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect,
- &a.TrainingEffectLabel, &a.VO2MaxValue,
- &a.HrTimeInZone1, &a.HrTimeInZone2, &a.HrTimeInZone3, &a.HrTimeInZone4, &a.HrTimeInZone5,
- &a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt,
+ &a.ID, &a.GarminActivityID, &a.EventTypeKey, &a.WorkoutID, &a.StartTimeUTC,
+ &a.DurationSeconds, &a.DistanceMeters, &a.AvgHR, &a.MaxHR,
+ &a.AvgSpeedMps, &a.ElevationGainM,
+ &a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect, &a.VO2MaxValue,
+ &a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt, &a.WorkoutRawJSON,
&a.CreatedAt, &a.UpdatedAt,
)
return a, err
}
const activityColumns = `
- id, garmin_activity_id, activity_name, activity_type, event_type_key, workout_id, start_time_utc,
- begin_timestamp_ms, duration_seconds, distance_meters, avg_hr, max_hr,
- avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
- calories, lap_count, aerobic_training_effect, anaerobic_training_effect,
- training_effect_label, vo2max_value,
- hr_time_in_zone_1, hr_time_in_zone_2, hr_time_in_zone_3, hr_time_in_zone_4, hr_time_in_zone_5,
- raw_json, details_fetched_at, details_raw_json, splits_fetched_at,
+ id, garmin_activity_id, event_type_key, workout_id, start_time_utc,
+ duration_seconds, distance_meters, avg_hr, max_hr,
+ avg_speed_mps, elevation_gain_m,
+ aerobic_training_effect, anaerobic_training_effect, vo2max_value,
+ raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json,
created_at, updated_at
`
@@ -189,6 +172,24 @@ func (db *DB) ListActivities(ctx context.Context, f ActivityFilter) ([]Activity,
return activities, rows.Err()
}
+// ActivityExists reports whether an activity with this garmin_activity_id is
+// already stored, checked before each upsert during a sync pass so the
+// reported "activities fetched" count reflects genuinely new activities, not
+// every activity Garmin's API happens to return for the queried date range
+// (which, thanks to the incremental overlap window and backfill's
+// already-covered history, is almost always a re-listing of known ones).
+func (db *DB) ActivityExists(ctx context.Context, garminActivityID int64) (bool, error) {
+ var id int64
+ err := db.QueryRowContext(ctx, `SELECT id FROM activities WHERE garmin_activity_id = ?`, garminActivityID).Scan(&id)
+ if err == sql.ErrNoRows {
+ return false, nil
+ }
+ if err != nil {
+ return false, fmt.Errorf("check activity %d exists: %w", garminActivityID, err)
+ }
+ return true, nil
+}
+
// LatestActivityStartTime returns the start_time_utc of the most recently
// started activity we have, used to compute the incremental sync window.
func (db *DB) LatestActivityStartTime(ctx context.Context) (string, bool, error) {
@@ -215,6 +216,18 @@ func (db *DB) SetActivityDetails(ctx context.Context, activityID int64, rawJSON
return nil
}
+// SetActivityWorkout stores the raw get_workout_by_id() response used to
+// compute this activity's laps' target pace/HR bands.
+func (db *DB) SetActivityWorkout(ctx context.Context, activityID int64, rawJSON string) error {
+ _, err := db.ExecContext(ctx, `
+ UPDATE activities SET workout_raw_json = ?, updated_at = datetime('now')
+ WHERE id = ?`, rawJSON, activityID)
+ if err != nil {
+ return fmt.Errorf("set activity %d workout: %w", activityID, err)
+ }
+ return nil
+}
+
// SetActivitySplitsFetched records that get_activity_splits has been fetched
// for this activity.
func (db *DB) SetActivitySplitsFetched(ctx context.Context, activityID int64) error {
diff --git a/backend/internal/store/laps.go b/backend/internal/store/laps.go
index effcab1..5d72942 100644
--- a/backend/internal/store/laps.go
+++ b/backend/internal/store/laps.go
@@ -7,19 +7,18 @@ import (
// Lap is one lap/split of an activity, from get_activity_splits, plus
// derived HR drift/recovery metrics computed from activity_samples.
+//
+// Deliberately NOT modeled here: StartTimeUTC, DistanceMeters, MaxHR,
+// MaxSpeedMps, ElevationGainM, ElevationLossM (removed in the 2026-07
+// dedup pass -- zero consumers anywhere, pure duplicates of RawJSON), plus
+// DurationSeconds and AvgHR, which DO have a real frontend chart need but no
+// backend one, so the API layer decodes them from RawJSON at response time
+// instead of storing a redundant copy -- see decodeLapDisplayFields.
type Lap struct {
ID int64
ActivityID int64
LapIndex int
- StartTimeUTC string
- DurationSeconds float64
- DistanceMeters float64
- AvgHR *float64
- MaxHR *float64
AvgSpeedMps *float64
- MaxSpeedMps *float64
- ElevationGainM *float64
- ElevationLossM *float64
IntensityType string
HRDriftBpmPerMin *float64
HRRecoveryBpmPerMin *float64
@@ -52,13 +51,11 @@ func (db *DB) ReplaceLaps(ctx context.Context, activityID int64, laps []Lap) err
for _, l := range laps {
_, err := tx.ExecContext(ctx, `
INSERT INTO laps (
- activity_id, lap_index, start_time_utc, duration_seconds, distance_meters,
- avg_hr, max_hr, avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
+ activity_id, lap_index, avg_speed_mps,
intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min,
target_pace_low_mps, target_pace_high_mps, target_hr_low_bpm, target_hr_high_bpm, raw_json
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
- activityID, l.LapIndex, l.StartTimeUTC, l.DurationSeconds, l.DistanceMeters,
- l.AvgHR, l.MaxHR, l.AvgSpeedMps, l.MaxSpeedMps, l.ElevationGainM, l.ElevationLossM,
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
+ activityID, l.LapIndex, l.AvgSpeedMps,
l.IntensityType, l.HRDriftBpmPerMin, l.HRRecoveryBpmPerMin,
l.TargetPaceLowMps, l.TargetPaceHighMps, l.TargetHRLowBpm, l.TargetHRHighBpm, l.RawJSON,
)
@@ -72,8 +69,7 @@ func (db *DB) ReplaceLaps(ctx context.Context, activityID int64, laps []Lap) err
// LapsForActivity returns all laps for an activity, ordered by lap_index.
func (db *DB) LapsForActivity(ctx context.Context, activityID int64) ([]Lap, error) {
rows, err := db.QueryContext(ctx, `
- SELECT id, activity_id, lap_index, start_time_utc, duration_seconds, distance_meters,
- avg_hr, max_hr, avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
+ SELECT id, activity_id, lap_index, avg_speed_mps,
intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min,
target_pace_low_mps, target_pace_high_mps, target_hr_low_bpm, target_hr_high_bpm, raw_json
FROM laps WHERE activity_id = ? ORDER BY lap_index`, activityID)
@@ -86,8 +82,7 @@ func (db *DB) LapsForActivity(ctx context.Context, activityID int64) ([]Lap, err
for rows.Next() {
var l Lap
if err := rows.Scan(
- &l.ID, &l.ActivityID, &l.LapIndex, &l.StartTimeUTC, &l.DurationSeconds, &l.DistanceMeters,
- &l.AvgHR, &l.MaxHR, &l.AvgSpeedMps, &l.MaxSpeedMps, &l.ElevationGainM, &l.ElevationLossM,
+ &l.ID, &l.ActivityID, &l.LapIndex, &l.AvgSpeedMps,
&l.IntensityType, &l.HRDriftBpmPerMin, &l.HRRecoveryBpmPerMin,
&l.TargetPaceLowMps, &l.TargetPaceHighMps, &l.TargetHRLowBpm, &l.TargetHRHighBpm, &l.RawJSON,
); err != nil {
diff --git a/backend/internal/store/migrations/0011_profile_name.sql b/backend/internal/store/migrations/0011_profile_name.sql
new file mode 100644
index 0000000..3efa4be
--- /dev/null
+++ b/backend/internal/store/migrations/0011_profile_name.sql
@@ -0,0 +1,3 @@
+-- Names the profile so a future multi-profile setup can show which one is
+-- active. Only one profile row exists today (id=1), so this is just a label.
+ALTER TABLE profile ADD COLUMN name TEXT NOT NULL DEFAULT 'Default';
diff --git a/backend/internal/store/migrations/0012_workout_type_hr_range.sql b/backend/internal/store/migrations/0012_workout_type_hr_range.sql
new file mode 100644
index 0000000..1bd39da
--- /dev/null
+++ b/backend/internal/store/migrations/0012_workout_type_hr_range.sql
@@ -0,0 +1,6 @@
+-- Replaces the named-zone-only expected_hr_zone with a custom %HRR range
+-- per training type (e.g. "easy runs at 70-80% heart rate reserve"),
+-- matching how pace range is already modeled. expected_hr_zone is left in
+-- place but unused going forward -- nothing reads or writes it anymore.
+ALTER TABLE workout_type_paces ADD COLUMN hr_min_pct_hrr REAL;
+ALTER TABLE workout_type_paces ADD COLUMN hr_max_pct_hrr REAL;
diff --git a/backend/internal/store/migrations/0013_dedup_activity_lap_columns.sql b/backend/internal/store/migrations/0013_dedup_activity_lap_columns.sql
new file mode 100644
index 0000000..f5137f2
--- /dev/null
+++ b/backend/internal/store/migrations/0013_dedup_activity_lap_columns.sql
@@ -0,0 +1,35 @@
+-- Phase 1 of the raw-JSON duplication cleanup: drops every Activity/Lap
+-- column that was a pure untransformed copy of a value already present in
+-- that row's own raw_json, with zero SQL/classify/functional consumer
+-- anywhere in the app (see the 2026-07 field-by-field duplication audit).
+-- Unlike this project's usual additive-only migrations, dropping these
+-- columns outright is the whole point of this one -- leaving them inert
+-- would keep the exact duplication being removed. activity_name/
+-- activity_type (Activity) and duration_seconds/avg_hr (laps) also go here
+-- even though the frontend still displays them: they're now decoded from
+-- raw_json at API-response time instead of stored separately (see
+-- internal/api's decodeActivityDisplayFields/decodeLapDisplayFields).
+DROP INDEX idx_activities_activity_type;
+
+ALTER TABLE activities DROP COLUMN activity_name;
+ALTER TABLE activities DROP COLUMN activity_type;
+ALTER TABLE activities DROP COLUMN begin_timestamp_ms;
+ALTER TABLE activities DROP COLUMN max_speed_mps;
+ALTER TABLE activities DROP COLUMN elevation_loss_m;
+ALTER TABLE activities DROP COLUMN calories;
+ALTER TABLE activities DROP COLUMN lap_count;
+ALTER TABLE activities DROP COLUMN training_effect_label;
+ALTER TABLE activities DROP COLUMN hr_time_in_zone_1;
+ALTER TABLE activities DROP COLUMN hr_time_in_zone_2;
+ALTER TABLE activities DROP COLUMN hr_time_in_zone_3;
+ALTER TABLE activities DROP COLUMN hr_time_in_zone_4;
+ALTER TABLE activities DROP COLUMN hr_time_in_zone_5;
+
+ALTER TABLE laps DROP COLUMN start_time_utc;
+ALTER TABLE laps DROP COLUMN duration_seconds;
+ALTER TABLE laps DROP COLUMN distance_meters;
+ALTER TABLE laps DROP COLUMN avg_hr;
+ALTER TABLE laps DROP COLUMN max_hr;
+ALTER TABLE laps DROP COLUMN max_speed_mps;
+ALTER TABLE laps DROP COLUMN elevation_gain_m;
+ALTER TABLE laps DROP COLUMN elevation_loss_m;
diff --git a/backend/internal/store/migrations/0014_chart_colors.sql b/backend/internal/store/migrations/0014_chart_colors.sql
new file mode 100644
index 0000000..6e9e2d2
--- /dev/null
+++ b/backend/internal/store/migrations/0014_chart_colors.sql
@@ -0,0 +1,10 @@
+-- User-configurable chart colors: 2 "main line" colors (one per metric) and
+-- 4 "effort kind" colors (one per workout phase), used together to derive
+-- the pace/HR chart's line, under-the-line fill, and phase-background fill
+-- colors (see frontend's ExpectedVsActualChart).
+ALTER TABLE profile ADD COLUMN pace_color TEXT NOT NULL DEFAULT '#3b82f6';
+ALTER TABLE profile ADD COLUMN heart_rate_color TEXT NOT NULL DEFAULT '#ef4444';
+ALTER TABLE profile ADD COLUMN warmup_color TEXT NOT NULL DEFAULT '#c2410c';
+ALTER TABLE profile ADD COLUMN effort_color TEXT NOT NULL DEFAULT '#7c3aed';
+ALTER TABLE profile ADD COLUMN recovery_color TEXT NOT NULL DEFAULT '#15803d';
+ALTER TABLE profile ADD COLUMN cooldown_color TEXT NOT NULL DEFAULT '#fb923c';
diff --git a/backend/internal/store/migrations/0015_training_type_names_and_order.sql b/backend/internal/store/migrations/0015_training_type_names_and_order.sql
new file mode 100644
index 0000000..58ff10f
--- /dev/null
+++ b/backend/internal/store/migrations/0015_training_type_names_and_order.sql
@@ -0,0 +1,18 @@
+-- Renames the default training-type taxonomy to a fixed display convention
+-- (drop the redundant "Run" suffix, numeral before "Threshold", "Intervals"
+-- not "Interval") and assigns explicit priorities so kinds always list in
+-- this exact order wherever they're shown (Activities filters, Progression's
+-- kind picker, Profile's Training types card) -- existing ORDER BY priority
+-- DESC, name already does the sorting, no query changes needed:
+-- Easy, Long, 60' Threshold, 30' Threshold, Tempo, Intervals, MAS Test, Race.
+--
+-- Each UPDATE matches on the original seeded name, so a kind the user has
+-- already renamed themselves (no longer matching) is left untouched.
+UPDATE workout_kinds SET name = 'Easy', priority = 80 WHERE name = 'Easy Run';
+UPDATE workout_kinds SET name = 'Long', priority = 70 WHERE name = 'Long Run';
+UPDATE workout_kinds SET name = '60'' Threshold', priority = 60 WHERE name = 'Threshold 60''';
+UPDATE workout_kinds SET name = '30'' Threshold', priority = 50 WHERE name = 'Threshold 30''';
+UPDATE workout_kinds SET priority = 40 WHERE name = 'Tempo';
+UPDATE workout_kinds SET name = 'Intervals', priority = 30 WHERE name = 'Interval';
+UPDATE workout_kinds SET priority = 20 WHERE name = 'MAS Test';
+UPDATE workout_kinds SET priority = 10 WHERE name = 'Race';
diff --git a/backend/internal/store/migrations/0016_chart_tint_intensity.sql b/backend/internal/store/migrations/0016_chart_tint_intensity.sql
new file mode 100644
index 0000000..c22c8ff
--- /dev/null
+++ b/backend/internal/store/migrations/0016_chart_tint_intensity.sql
@@ -0,0 +1,5 @@
+-- How strongly the main line color (blue/red) tints the effort-kind fill
+-- under the line, as a percentage (0-100) mixed in -- see frontend's
+-- ExpectedVsActualChart mixColor(). The phase background above the line is
+-- never tinted by the main line color regardless of this setting.
+ALTER TABLE profile ADD COLUMN main_line_tint_pct REAL NOT NULL DEFAULT 20;
diff --git a/backend/internal/store/migrations/0017_chart_background_darken.sql b/backend/internal/store/migrations/0017_chart_background_darken.sql
new file mode 100644
index 0000000..0a03607
--- /dev/null
+++ b/backend/internal/store/migrations/0017_chart_background_darken.sql
@@ -0,0 +1,5 @@
+-- How strongly (0-100) the effort-kind color is darkened for the phase
+-- background above the line -- see frontend's ExpectedVsActualChart
+-- darken(). Never mixed with the main line color, unlike the fill below the
+-- line (see main_line_tint_pct).
+ALTER TABLE profile ADD COLUMN background_darken_pct REAL NOT NULL DEFAULT 35;
diff --git a/backend/internal/store/migrations/0018_chart_target_brighten.sql b/backend/internal/store/migrations/0018_chart_target_brighten.sql
new file mode 100644
index 0000000..25caab9
--- /dev/null
+++ b/backend/internal/store/migrations/0018_chart_target_brighten.sql
@@ -0,0 +1,5 @@
+-- How strongly (0-100) a chart's main line color (and everything tinted
+-- from it) is brightened when that chart actually has a structured-workout
+-- target range to show -- a color-based cue that a target is present,
+-- replacing a text label -- see frontend's ExpectedVsActualChart brighten().
+ALTER TABLE profile ADD COLUMN target_brighten_pct REAL NOT NULL DEFAULT 20;
diff --git a/backend/internal/store/migrations/0019_activity_workout_raw_json.sql b/backend/internal/store/migrations/0019_activity_workout_raw_json.sql
new file mode 100644
index 0000000..df4c0df
--- /dev/null
+++ b/backend/internal/store/migrations/0019_activity_workout_raw_json.sql
@@ -0,0 +1,5 @@
+-- Genuine raw JSON of the activity's structured Garmin workout (get_workout_by_id),
+-- the source used to compute each lap's TargetPaceLowMps/HighMps and
+-- TargetHRLowBpm/HighBpm (see internal/sync/mapping.go's alignWorkoutTargets).
+-- Null for activities with no WorkoutID, or synced before this column existed.
+ALTER TABLE activities ADD COLUMN workout_raw_json TEXT;
diff --git a/backend/internal/store/migrations/0020_sync_runs_full_kind.sql b/backend/internal/store/migrations/0020_sync_runs_full_kind.sql
new file mode 100644
index 0000000..6ea2f47
--- /dev/null
+++ b/backend/internal/store/migrations/0020_sync_runs_full_kind.sql
@@ -0,0 +1,19 @@
+-- Widens sync_runs.kind's CHECK constraint to also allow 'full' (a manual
+-- "Sync now" pass recorded as one combined run instead of separate
+-- backfill/incremental rows -- see internal/sync.Service.FullSync). SQLite
+-- has no ALTER TABLE for CHECK constraints, so the table is rebuilt.
+CREATE TABLE sync_runs_new (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')),
+ started_at TEXT NOT NULL,
+ finished_at TEXT,
+ activities_fetched INTEGER NOT NULL DEFAULT 0,
+ status TEXT NOT NULL CHECK(status IN ('running','success','error')),
+ error_message TEXT
+);
+
+INSERT INTO sync_runs_new (id, kind, started_at, finished_at, activities_fetched, status, error_message)
+SELECT id, kind, started_at, finished_at, activities_fetched, status, error_message FROM sync_runs;
+
+DROP TABLE sync_runs;
+ALTER TABLE sync_runs_new RENAME TO sync_runs;
diff --git a/backend/internal/store/profile.go b/backend/internal/store/profile.go
index cec892a..c7dad75 100644
--- a/backend/internal/store/profile.go
+++ b/backend/internal/store/profile.go
@@ -8,6 +8,9 @@ import (
// Profile is the single active user's Garmin credentials plus every
// tunable analysis-engine parameter. Always exactly one row (id=1).
type Profile struct {
+ // Name labels this profile so a future multi-profile setup can show
+ // which one is active. Only one profile row exists today (id=1).
+ Name string
GarminEmail string
GarminPassword string
RollingWindowDays int
@@ -41,16 +44,40 @@ type Profile struct {
// right as recording starts, before the run itself begins).
MinRepresentativePaceSecPerKm, MinRepresentativeTimeSeconds float64
+ // Chart colors: PaceColor/HeartRateColor are the "main line" color for
+ // each metric's chart; Warmup/Effort/Recovery/CooldownColor are the
+ // "effort kind" colors. The frontend derives the under-the-line fill
+ // (effort color tinted by the main line color) and the phase background
+ // fill (effort color, darkened) from these six -- see
+ // frontend's ExpectedVsActualChart.
+ PaceColor, HeartRateColor string
+ WarmupColor, EffortColor, RecoveryColor, CooldownColor string
+ // MainLineTintPct (0-100) is how strongly the main line color mixes into
+ // the effort-kind fill under the line -- see frontend's mixColor(). Never
+ // affects the phase background above the line.
+ MainLineTintPct float64
+ // BackgroundDarkenPct (0-100) is how strongly the effort-kind color is
+ // darkened for the phase background above the line -- see frontend's
+ // darken(). Never mixed with the main line color.
+ BackgroundDarkenPct float64
+ // TargetBrightenPct (0-100) is how strongly a chart's main line color
+ // (and everything tinted from it) is brightened when that chart has a
+ // structured-workout target range to show -- a color cue for "this has a
+ // target" instead of a text label -- see frontend's brighten().
+ TargetBrightenPct float64
+
CreatedAt, UpdatedAt string
}
const profileColumns = `
- garmin_email, garmin_password, rolling_window_days, backfill_horizon_days, max_heart_rate, resting_heart_rate,
+ name, garmin_email, garmin_password, rolling_window_days, backfill_horizon_days, max_heart_rate, resting_heart_rate,
hr_zone1_min_pct, hr_zone1_max_pct, hr_zone2_min_pct, hr_zone2_max_pct,
hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct,
hr_zone5_min_pct, hr_zone5_max_pct,
warmup_minutes, cooldown_minutes,
min_representative_pace_sec_per_km, min_representative_time_seconds,
+ pace_color, heart_rate_color, warmup_color, effort_color, recovery_color, cooldown_color,
+ main_line_tint_pct, background_darken_pct, target_brighten_pct,
created_at, updated_at
`
@@ -58,12 +85,14 @@ const profileColumns = `
func (db *DB) GetProfile(ctx context.Context) (Profile, error) {
var p Profile
err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profile WHERE id = 1`).Scan(
- &p.GarminEmail, &p.GarminPassword, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate,
+ &p.Name, &p.GarminEmail, &p.GarminPassword, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate,
&p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct,
&p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct,
&p.HRZone5MinPct, &p.HRZone5MaxPct,
&p.WarmupMinutes, &p.CooldownMinutes,
&p.MinRepresentativePaceSecPerKm, &p.MinRepresentativeTimeSeconds,
+ &p.PaceColor, &p.HeartRateColor, &p.WarmupColor, &p.EffortColor, &p.RecoveryColor, &p.CooldownColor,
+ &p.MainLineTintPct, &p.BackgroundDarkenPct, &p.TargetBrightenPct,
&p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
@@ -78,20 +107,24 @@ func (db *DB) GetProfile(ctx context.Context) (Profile, error) {
func (db *DB) UpdateProfile(ctx context.Context, p Profile) error {
_, err := db.ExecContext(ctx, `
UPDATE profile SET
- garmin_email=?, garmin_password=?, rolling_window_days=?, backfill_horizon_days=?, max_heart_rate=?, resting_heart_rate=?,
+ name=?, garmin_email=?, garmin_password=?, rolling_window_days=?, backfill_horizon_days=?, max_heart_rate=?, resting_heart_rate=?,
hr_zone1_min_pct=?, hr_zone1_max_pct=?, hr_zone2_min_pct=?, hr_zone2_max_pct=?,
hr_zone3_min_pct=?, hr_zone3_max_pct=?, hr_zone4_min_pct=?, hr_zone4_max_pct=?,
hr_zone5_min_pct=?, hr_zone5_max_pct=?,
warmup_minutes=?, cooldown_minutes=?,
min_representative_pace_sec_per_km=?, min_representative_time_seconds=?,
+ pace_color=?, heart_rate_color=?, warmup_color=?, effort_color=?, recovery_color=?, cooldown_color=?,
+ main_line_tint_pct=?, background_darken_pct=?, target_brighten_pct=?,
updated_at=datetime('now')
WHERE id = 1`,
- p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, p.MaxHeartRate, p.RestingHeartRate,
+ p.Name, p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, p.MaxHeartRate, p.RestingHeartRate,
p.HRZone1MinPct, p.HRZone1MaxPct, p.HRZone2MinPct, p.HRZone2MaxPct,
p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct,
p.HRZone5MinPct, p.HRZone5MaxPct,
p.WarmupMinutes, p.CooldownMinutes,
p.MinRepresentativePaceSecPerKm, p.MinRepresentativeTimeSeconds,
+ p.PaceColor, p.HeartRateColor, p.WarmupColor, p.EffortColor, p.RecoveryColor, p.CooldownColor,
+ p.MainLineTintPct, p.BackgroundDarkenPct, p.TargetBrightenPct,
)
if err != nil {
return fmt.Errorf("update profile: %w", err)
diff --git a/backend/internal/store/profile_test.go b/backend/internal/store/profile_test.go
index 68e7628..f779319 100644
--- a/backend/internal/store/profile_test.go
+++ b/backend/internal/store/profile_test.go
@@ -28,8 +28,32 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
if p.MinRepresentativePaceSecPerKm != 720 || p.MinRepresentativeTimeSeconds != 3 {
t.Errorf("pace artifact filter defaults = %+v, want pace=720, time=3", p)
}
+ if p.Name != "Default" {
+ t.Errorf("Name = %q, want %q (migration default)", p.Name, "Default")
+ }
+ if p.PaceColor != "#3b82f6" || p.HeartRateColor != "#ef4444" {
+ t.Errorf("main line color defaults = %+v, want pace=#3b82f6, heartRate=#ef4444", p)
+ }
+ if p.WarmupColor != "#c2410c" || p.EffortColor != "#7c3aed" || p.RecoveryColor != "#15803d" || p.CooldownColor != "#fb923c" {
+ t.Errorf("effort color defaults = %+v", p)
+ }
+ if p.MainLineTintPct != 20 {
+ t.Errorf("MainLineTintPct = %v, want 20 (migration default)", p.MainLineTintPct)
+ }
+ if p.BackgroundDarkenPct != 35 {
+ t.Errorf("BackgroundDarkenPct = %v, want 35 (migration default)", p.BackgroundDarkenPct)
+ }
+ if p.TargetBrightenPct != 20 {
+ t.Errorf("TargetBrightenPct = %v, want 20 (migration default)", p.TargetBrightenPct)
+ }
maxHR, restingHR := 190.0, 50.0
+ p.Name = "Kriss"
+ p.PaceColor = "#111111"
+ p.EffortColor = "#222222"
+ p.MainLineTintPct = 45
+ p.BackgroundDarkenPct = 60
+ p.TargetBrightenPct = 50
p.GarminEmail = "runner@example.com"
p.GarminPassword = "hunter2"
p.RollingWindowDays = 120
@@ -51,6 +75,9 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
if got.GarminEmail != "runner@example.com" || got.RollingWindowDays != 120 {
t.Errorf("got = %+v, want updated email/window", got)
}
+ if got.Name != "Kriss" {
+ t.Errorf("Name = %q, want %q after update", got.Name, "Kriss")
+ }
if got.MaxHeartRate == nil || *got.MaxHeartRate != 190 {
t.Errorf("MaxHeartRate = %v, want 190", got.MaxHeartRate)
}
@@ -63,4 +90,16 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
if got.MinRepresentativePaceSecPerKm != 600 || got.MinRepresentativeTimeSeconds != 5 {
t.Errorf("pace artifact filter after update = %+v, want pace=600, time=5", got)
}
+ if got.PaceColor != "#111111" || got.EffortColor != "#222222" {
+ t.Errorf("chart colors after update = %+v, want pace=#111111, effort=#222222", got)
+ }
+ if got.MainLineTintPct != 45 {
+ t.Errorf("MainLineTintPct after update = %v, want 45", got.MainLineTintPct)
+ }
+ if got.BackgroundDarkenPct != 60 {
+ t.Errorf("BackgroundDarkenPct after update = %v, want 60", got.BackgroundDarkenPct)
+ }
+ if got.TargetBrightenPct != 50 {
+ t.Errorf("TargetBrightenPct after update = %v, want 50", got.TargetBrightenPct)
+ }
}
diff --git a/backend/internal/store/reset_test.go b/backend/internal/store/reset_test.go
index f257650..f53488a 100644
--- a/backend/internal/store/reset_test.go
+++ b/backend/internal/store/reset_test.go
@@ -9,7 +9,7 @@ func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *test
db := openTestDB(t)
ctx := context.Background()
- activityID, err := db.UpsertActivity(ctx, Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
+ activityID, err := db.UpsertActivity(ctx, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go
index b827df3..b1e77fd 100644
--- a/backend/internal/store/store_test.go
+++ b/backend/internal/store/store_test.go
@@ -40,14 +40,11 @@ func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
a := Activity{
GarminActivityID: 23554066504,
- ActivityName: "Auriol - W2-5-Base Endurance",
- ActivityType: "running",
StartTimeUTC: "2026-07-11 05:02:35",
- BeginTimestampMs: 1783746155000,
DurationSeconds: 1800,
DistanceMeters: 6858,
AvgHR: f(148),
- RawJSON: `{"activityId":23554066504}`,
+ RawJSON: `{"activityId":23554066504,"activityName":"Auriol - W2-5-Base Endurance","activityType":{"typeKey":"running"}}`,
}
id, err := db.UpsertActivity(ctx, a)
@@ -87,7 +84,6 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
activityID, err := db.UpsertActivity(ctx, Activity{
GarminActivityID: 1,
- ActivityType: "running",
StartTimeUTC: "2026-07-11 05:00:00",
RawJSON: "{}",
})
@@ -172,7 +168,6 @@ func TestReplaceLapsIsIdempotent(t *testing.T) {
activityID, err := db.UpsertActivity(ctx, Activity{
GarminActivityID: 2,
- ActivityType: "running",
StartTimeUTC: "2026-07-11 05:00:00",
RawJSON: "{}",
})
@@ -181,8 +176,8 @@ func TestReplaceLapsIsIdempotent(t *testing.T) {
}
laps := []Lap{
- {LapIndex: 1, StartTimeUTC: "2026-07-11 05:00:00", DurationSeconds: 300, DistanceMeters: 1000, AvgHR: f(140), RawJSON: "{}"},
- {LapIndex: 2, StartTimeUTC: "2026-07-11 05:05:00", DurationSeconds: 300, DistanceMeters: 1000, AvgHR: f(150), RawJSON: "{}"},
+ {LapIndex: 1, RawJSON: "{}"},
+ {LapIndex: 2, RawJSON: "{}"},
}
if err := db.ReplaceLaps(ctx, activityID, laps); err != nil {
t.Fatalf("ReplaceLaps (first): %v", err)
diff --git a/backend/internal/store/syncruns.go b/backend/internal/store/syncruns.go
index c716661..67ac013 100644
--- a/backend/internal/store/syncruns.go
+++ b/backend/internal/store/syncruns.go
@@ -9,6 +9,11 @@ import (
const (
SyncKindBackfill = "backfill"
SyncKindIncremental = "incremental"
+ // SyncKindFull is a manually-triggered "Sync now" pass: Backfill followed
+ // by IncrementalSync followed by FillPendingDetails, recorded as one run
+ // so the reported activity count covers the whole action instead of only
+ // whichever stage happened to finish last.
+ SyncKindFull = "full"
SyncStatusRunning = "running"
SyncStatusSuccess = "success"
diff --git a/backend/internal/store/workoutkinds_taxonomy_test.go b/backend/internal/store/workoutkinds_taxonomy_test.go
index fa70e31..bb50cb4 100644
--- a/backend/internal/store/workoutkinds_taxonomy_test.go
+++ b/backend/internal/store/workoutkinds_taxonomy_test.go
@@ -18,8 +18,8 @@ func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) {
}
wantNames := map[string]bool{
- "Easy Run": false, "Long Run": false, "Threshold 30'": false, "Threshold 60'": false,
- "Tempo": false, "Interval": false, "MAS Test": false, "Race": false,
+ "Easy": false, "Long": false, "60' Threshold": false, "30' Threshold": false,
+ "Tempo": false, "Intervals": false, "MAS Test": false, "Race": false,
}
for _, k := range kinds {
if _, ok := wantNames[k.Name]; !ok {
@@ -34,3 +34,31 @@ func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) {
}
}
}
+
+// Every list of training types (Activities filters, Progression's kind
+// picker, Profile's Training types card) relies on ListWorkoutKinds' own
+// ORDER BY priority DESC, name to already be in this exact fixed order --
+// none of them re-sort client-side.
+func TestWorkoutTaxonomy_ListedInFixedDisplayOrder(t *testing.T) {
+ db := openTestDB(t)
+ ctx := context.Background()
+
+ kinds, err := db.ListWorkoutKinds(ctx, true)
+ if err != nil {
+ t.Fatalf("ListWorkoutKinds: %v", err)
+ }
+
+ want := []string{"Easy", "Long", "60' Threshold", "30' Threshold", "Tempo", "Intervals", "MAS Test", "Race"}
+ if len(kinds) != len(want) {
+ t.Fatalf("expected %d kinds, got %d", len(want), len(kinds))
+ }
+ for i, name := range want {
+ if kinds[i].Name != name {
+ got := make([]string, len(kinds))
+ for j, k := range kinds {
+ got[j] = k.Name
+ }
+ t.Fatalf("position %d: got %q, want %q (full order: %v)", i, kinds[i].Name, name, got)
+ }
+ }
+}
diff --git a/backend/internal/store/workoutpaces.go b/backend/internal/store/workoutpaces.go
index b8e6bed..13ddfe7 100644
--- a/backend/internal/store/workoutpaces.go
+++ b/backend/internal/store/workoutpaces.go
@@ -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)
}
diff --git a/backend/internal/store/workoutpaces_test.go b/backend/internal/store/workoutpaces_test.go
index 62c783b..42f6c17 100644
--- a/backend/internal/store/workoutpaces_test.go
+++ b/backend/internal/store/workoutpaces_test.go
@@ -17,16 +17,17 @@ func TestWorkoutTypePaces_SeededOnePerKindThenUpdate(t *testing.T) {
t.Fatalf("expected 8 seeded pace rows (one per taxonomy kind), got %d", len(all))
}
for _, p := range all {
- if p.PaceMinSecPerKm != nil || p.PaceMaxSecPerKm != nil || p.ExpectedHRZone != nil {
+ if p.PaceMinSecPerKm != nil || p.PaceMaxSecPerKm != nil || p.HRMinPctHRR != nil || p.HRMaxPctHRR != nil {
t.Errorf("expected all-nil seeded pace row for kind %d, got %+v", p.WorkoutKindID, p)
}
}
target := all[0]
- minPace, maxPace, zone := 330.0, 420.0, 2
+ minPace, maxPace, hrMin, hrMax := 330.0, 420.0, 70.0, 80.0
target.PaceMinSecPerKm = &minPace
target.PaceMaxSecPerKm = &maxPace
- target.ExpectedHRZone = &zone
+ target.HRMinPctHRR = &hrMin
+ target.HRMaxPctHRR = &hrMax
if err := db.UpdateWorkoutTypePace(ctx, target); err != nil {
t.Fatalf("UpdateWorkoutTypePace: %v", err)
@@ -39,7 +40,10 @@ func TestWorkoutTypePaces_SeededOnePerKindThenUpdate(t *testing.T) {
if got.PaceMinSecPerKm == nil || *got.PaceMinSecPerKm != 330 {
t.Errorf("PaceMinSecPerKm = %v, want 330", got.PaceMinSecPerKm)
}
- if got.ExpectedHRZone == nil || *got.ExpectedHRZone != 2 {
- t.Errorf("ExpectedHRZone = %v, want 2", got.ExpectedHRZone)
+ if got.HRMinPctHRR == nil || *got.HRMinPctHRR != 70 {
+ t.Errorf("HRMinPctHRR = %v, want 70", got.HRMinPctHRR)
+ }
+ if got.HRMaxPctHRR == nil || *got.HRMaxPctHRR != 80 {
+ t.Errorf("HRMaxPctHRR = %v, want 80", got.HRMaxPctHRR)
}
}
diff --git a/backend/internal/sync/mapping.go b/backend/internal/sync/mapping.go
index ebd454c..3854777 100644
--- a/backend/internal/sync/mapping.go
+++ b/backend/internal/sync/mapping.go
@@ -22,31 +22,18 @@ func isRunningActivityType(typeKey string) bool {
func toActivityRow(a garmin.Activity) store.Activity {
return store.Activity{
GarminActivityID: a.ActivityID,
- ActivityName: a.ActivityName,
- ActivityType: a.ActivityType.TypeKey,
EventTypeKey: a.EventType.TypeKey,
WorkoutID: a.WorkoutID,
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),
}
}
@@ -90,18 +77,9 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.Wor
hrLow, hrHigh = targetHRRange(*targets[i], profile)
}
- 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,
@@ -109,7 +87,7 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.Wor
TargetPaceHighMps: paceHigh,
TargetHRLowBpm: hrLow,
TargetHRHighBpm: hrHigh,
- RawJSON: string(raw),
+ RawJSON: string(l.Raw),
})
elapsedStart = elapsedEnd
}
diff --git a/backend/internal/sync/service.go b/backend/internal/sync/service.go
index 55750fa..47eef5c 100644
--- a/backend/internal/sync/service.go
+++ b/backend/internal/sync/service.go
@@ -111,19 +111,30 @@ func (s *Service) Backfill(ctx context.Context) error {
return err
}
- profile, err := s.db.GetProfile(ctx)
+ total, err := s.backfillCore(ctx)
if err != nil {
msg := err.Error()
- s.db.FinishSyncRun(ctx, runID, 0, &msg)
- return fmt.Errorf("load profile: %w", err)
+ s.db.FinishSyncRun(ctx, runID, total, &msg)
+ return err
+ }
+ return s.db.FinishSyncRun(ctx, runID, total, nil)
+}
+
+// backfillCore holds Backfill's actual fetch logic, without the SyncRun
+// bookkeeping, so FullSync can run it as one step of a single combined run
+// instead of its own separately-recorded one. The returned count reflects
+// whatever was fetched even when an error is also returned, matching
+// Backfill's own partial-progress-on-error behavior.
+func (s *Service) backfillCore(ctx context.Context) (int, error) {
+ profile, err := s.db.GetProfile(ctx)
+ if err != nil {
+ return 0, fmt.Errorf("load profile: %w", err)
}
horizon := s.now().AddDate(0, 0, -profile.BackfillHorizonDays)
state, err := s.db.GetSyncState(ctx)
if err != nil {
- msg := err.Error()
- s.db.FinishSyncRun(ctx, runID, 0, &msg)
- return err
+ return 0, err
}
end := s.now()
@@ -132,7 +143,7 @@ func (s *Service) Backfill(ctx context.Context) error {
if state.BackfillComplete && !watermark.After(horizon) {
// Already backfilled at least as far back as the configured
// horizon -- nothing new to fetch from Garmin at all.
- return s.db.FinishSyncRun(ctx, runID, 0, nil)
+ return 0, nil
}
end = watermark.AddDate(0, 0, -1)
}
@@ -146,29 +157,23 @@ func (s *Service) Backfill(ctx context.Context) error {
start = horizon
}
- n, err := s.fetchAndStoreWindow(ctx, dateStr(start), dateStr(end))
+ rawCount, newCount, err := s.fetchAndStoreWindow(ctx, dateStr(start), dateStr(end))
if err != nil {
- msg := err.Error()
- s.db.FinishSyncRun(ctx, runID, total, &msg)
- return fmt.Errorf("backfill window %s..%s: %w", dateStr(start), dateStr(end), err)
+ return total, fmt.Errorf("backfill window %s..%s: %w", dateStr(start), dateStr(end), err)
}
- total += n
+ total += newCount
- if n == 0 {
+ if rawCount == 0 {
// Empty page: reached the start of this account's history,
// regardless of the configured horizon.
reachedStartOfHistory = true
if err := s.db.UpdateSyncState(ctx, dateStr(start), true); err != nil {
- msg := err.Error()
- s.db.FinishSyncRun(ctx, runID, total, &msg)
- return err
+ return total, err
}
break
}
if err := s.db.UpdateSyncState(ctx, dateStr(start), false); err != nil {
- msg := err.Error()
- s.db.FinishSyncRun(ctx, runID, total, &msg)
- return err
+ return total, err
}
end = start.AddDate(0, 0, -1)
}
@@ -177,13 +182,11 @@ func (s *Service) Backfill(ctx context.Context) error {
// Reached the configured horizon (not Garmin's actual history
// start) -- mark complete relative to that horizon.
if err := s.db.UpdateSyncState(ctx, dateStr(horizon), true); err != nil {
- msg := err.Error()
- s.db.FinishSyncRun(ctx, runID, total, &msg)
- return err
+ return total, err
}
}
- return s.db.FinishSyncRun(ctx, runID, total, nil)
+ return total, nil
}
// IncrementalSync fetches activities from just before the latest known
@@ -194,14 +197,7 @@ func (s *Service) IncrementalSync(ctx context.Context) error {
return err
}
- start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
- if latest, ok, err := s.db.LatestActivityStartTime(ctx); err == nil && ok {
- if t, err := time.Parse("2006-01-02 15:04:05", latest); err == nil {
- start = t.AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
- }
- }
-
- n, err := s.fetchAndStoreWindow(ctx, dateStr(start), dateStr(s.now()))
+ n, err := s.incrementalSyncCore(ctx)
if err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, n, &msg)
@@ -210,6 +206,58 @@ func (s *Service) IncrementalSync(ctx context.Context) error {
return s.db.FinishSyncRun(ctx, runID, n, nil)
}
+// incrementalSyncCore holds IncrementalSync's actual fetch logic, without
+// the SyncRun bookkeeping -- see backfillCore.
+func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
+ start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
+ if latest, ok, err := s.db.LatestActivityStartTime(ctx); err == nil && ok {
+ if t, err := time.Parse("2006-01-02 15:04:05", latest); err == nil {
+ start = t.AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
+ }
+ }
+ _, newCount, err := s.fetchAndStoreWindow(ctx, dateStr(start), dateStr(s.now()))
+ return newCount, err
+}
+
+// FullSync performs a complete manual "Sync now" pass -- Backfill (resumes
+// from the watermark), then IncrementalSync (catches anything new since the
+// latest known activity), then FillPendingDetails -- recorded as a single
+// SyncRun. Backfill and IncrementalSync each record their own SyncRun when
+// called on their own (used by the periodic background loop), but a manual
+// sync runs both back to back, and FillPendingDetails records no run at all;
+// showing the user only the most recently *recorded* run (IncrementalSync's)
+// would silently hide however many activities Backfill fetched. Recording
+// one combined run makes the reported count match the whole action.
+func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
+ runID, err := s.db.StartSyncRun(ctx, store.SyncKindFull)
+ if err != nil {
+ return err
+ }
+
+ backfillCount, err := s.backfillCore(ctx)
+ if err != nil {
+ msg := err.Error()
+ s.db.FinishSyncRun(ctx, runID, backfillCount, &msg)
+ return err
+ }
+
+ incrementalCount, err := s.incrementalSyncCore(ctx)
+ total := backfillCount + incrementalCount
+ if err != nil {
+ msg := err.Error()
+ s.db.FinishSyncRun(ctx, runID, total, &msg)
+ return err
+ }
+
+ if err := s.FillPendingDetails(ctx, detailFillLimit); err != nil {
+ msg := err.Error()
+ s.db.FinishSyncRun(ctx, runID, total, &msg)
+ return err
+ }
+
+ return s.db.FinishSyncRun(ctx, runID, total, nil)
+}
+
// ResetAll deletes every synced activity (and its laps/samples/kind
// assignments) and rewinds the backfill watermark, so the next Backfill
// call performs a genuinely fresh pull from Garmin instead of resuming from
@@ -218,26 +266,42 @@ func (s *Service) ResetAll(ctx context.Context) error {
return s.db.ResetAllSyncedData(ctx)
}
-func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate string) (int, error) {
+// fetchAndStoreWindow returns two counts: rawCount is every activity Garmin's
+// API returned for this date range, regardless of sport or whether it was
+// already known -- Backfill's "reached start of history" check needs this
+// exact unfiltered count, since a page containing only non-running
+// activities must not look like an empty page. newCount is how many running
+// activities were genuinely new (not already stored), which is what actually
+// belongs in the user-facing "activities fetched" report: the incremental
+// overlap window and backfill's already-covered history mean Garmin almost
+// always re-returns activities we already have, and reporting rawCount there
+// produced a confusing, meaningless number (e.g. "2 activities" on a sync
+// that found nothing new, just because 2 already-known activities happened
+// to fall inside the queried window).
+func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate string) (rawCount, newCount int, err error) {
activities, err := s.garmin.GetActivities(ctx, startDate, endDate, 500)
if err != nil {
- return 0, fmt.Errorf("get_activities(%s, %s): %w", startDate, endDate, err)
+ return 0, 0, fmt.Errorf("get_activities(%s, %s): %w", startDate, endDate, err)
}
for _, a := range activities {
// Only running activities are of interest here; other sports (padel,
// cycling, strength training, ...) also come back from
- // get_activities() but are dropped rather than stored. len(activities)
- // below stays the unfiltered count, since it drives the backfill
- // watermark's "reached start of history" check -- a page containing
- // only non-running activities must not look like an empty page.
+ // get_activities() but are dropped rather than stored.
if !isRunningActivityType(a.ActivityType.TypeKey) {
continue
}
+ exists, err := s.db.ActivityExists(ctx, a.ActivityID)
+ if err != nil {
+ return 0, 0, err
+ }
if _, err := s.db.UpsertActivity(ctx, toActivityRow(a)); err != nil {
- return 0, fmt.Errorf("store activity %d: %w", a.ActivityID, err)
+ return 0, 0, fmt.Errorf("store activity %d: %w", a.ActivityID, err)
+ }
+ if !exists {
+ newCount++
}
}
- return len(activities), nil
+ return len(activities), newCount, nil
}
// FillPendingDetails fetches get_activity_splits/get_activity_details for up
@@ -293,6 +357,9 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, pro
log.Printf("sync: get_workout_by_id(%d) for activity %d failed, continuing without target zones: %v", *a.WorkoutID, a.GarminActivityID, err)
} else {
targets = alignWorkoutTargets(splits.Laps, workout)
+ if err := s.db.SetActivityWorkout(ctx, a.ID, string(workout.Raw)); err != nil {
+ return err
+ }
}
}
@@ -303,8 +370,7 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, pro
if err := s.db.ReplaceLaps(ctx, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil {
return err
}
- detailsRaw, _ := json.Marshal(details)
- if err := s.db.SetActivityDetails(ctx, a.ID, string(detailsRaw)); err != nil {
+ if err := s.db.SetActivityDetails(ctx, a.ID, string(details.Raw)); err != nil {
return err
}
return s.db.SetActivitySplitsFetched(ctx, a.ID)
diff --git a/backend/internal/sync/service_test.go b/backend/internal/sync/service_test.go
index b128c31..c6d661b 100644
--- a/backend/internal/sync/service_test.go
+++ b/backend/internal/sync/service_test.go
@@ -555,6 +555,66 @@ func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
}
}
+func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
+ db := openTestDB(t)
+ ctx := context.Background()
+
+ m := &mock.Client{
+ Activities: []garmin.Activity{
+ {ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
+ {ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-08 06:00:00", Distance: 5000, Duration: 1500},
+ },
+ Splits: map[int64]garmin.ActivitySplits{
+ 1: {ActivityID: 1}, 2: {ActivityID: 2},
+ },
+ Details: map[int64]garmin.ActivityDetails{
+ 1: {ActivityID: 1}, 2: {ActivityID: 2},
+ },
+ }
+ svc := NewService(m, db, Config{BackfillWindowDays: 10},
+ fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
+ setBackfillHorizon(t, db, 10)
+
+ if err := svc.FullSync(ctx, 10); err != nil {
+ t.Fatalf("FullSync: %v", err)
+ }
+
+ runs, err := db.ListSyncRuns(ctx, 10)
+ if err != nil {
+ t.Fatalf("ListSyncRuns: %v", err)
+ }
+ if len(runs) != 1 {
+ t.Fatalf("expected exactly 1 sync run recorded by FullSync (not one per stage), got %d: %+v", len(runs), runs)
+ }
+ run := runs[0]
+ if run.Kind != store.SyncKindFull {
+ t.Errorf("Kind = %q, want %q", run.Kind, store.SyncKindFull)
+ }
+ if run.Status != store.SyncStatusSuccess {
+ t.Errorf("Status = %q, want success", run.Status)
+ }
+ // Backfill (one window covering the whole horizon) stores both of the
+ // mock's activities as genuinely new (2). IncrementalSync then runs its
+ // own separate fetch and -- since the fake client ignores the date range
+ // it's called with -- sees the exact same 2 activities again, but they're
+ // already stored by then, so it contributes 0 new ones. The combined
+ // run's count (2) must reflect that dedup, not naively sum each stage's
+ // raw fetch count (which would double-count to 4) or report only
+ // whichever stage happened to run last (which would silently drop
+ // Backfill's count) -- both are bugs this test guards against.
+ if run.ActivitiesFetched != 2 {
+ t.Errorf("ActivitiesFetched = %d, want 2 (genuinely new activities, deduped across stages)", run.ActivitiesFetched)
+ }
+
+ activities, err := db.ListActivities(ctx, store.ActivityFilter{})
+ if err != nil {
+ t.Fatalf("ListActivities: %v", err)
+ }
+ if len(activities) != 2 {
+ t.Fatalf("expected 2 stored activities (upserted, not duplicated), got %d", len(activities))
+ }
+}
+
func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
diff --git a/frontend/index.html b/frontend/index.html
index 0fca6f0..4a4b1c6 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,7 +4,7 @@
-
frontend
+ geniusrun
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg
index 6893eb1..0680c9f 100644
--- a/frontend/public/favicon.svg
+++ b/frontend/public/favicon.svg
@@ -1 +1,3 @@
-
\ No newline at end of file
+
+ 🧞♀️
+
diff --git a/frontend/src/App.css b/frontend/src/App.css
index 4f88311..b1cafab 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -10,7 +10,7 @@ body {
}
.app {
- max-width: 960px;
+ max-width: 1100px;
margin: 0 auto;
padding: 0 1.5rem 3rem;
}
@@ -42,18 +42,57 @@ body {
cursor: pointer;
}
+.tab-icon {
+ margin-right: 0.4rem;
+}
+
.tab.active {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
+/* Pill-shaped and icon-led, deliberately unlike the rectangular .tab
+ buttons: this opens account/profile settings, not an application view
+ alongside Activities/Progression/Training plan, so it reads more like an
+ account chip (à la Slack/GitHub's corner avatar) than another nav tab. */
+.profile-name {
+ margin-left: auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ background: #1a1d24;
+ border: 1px solid #2a2d35;
+ color: #9aa0ab;
+ padding: 0.4rem 0.9rem 0.4rem 0.7rem;
+ border-radius: 999px;
+ cursor: pointer;
+ font-weight: 600;
+ font-size: 0.85rem;
+}
+
+.profile-name::before {
+ content: "⚙️";
+ font-size: 0.8rem;
+}
+
+.profile-name:hover:not(.active) {
+ border-color: #3b82f6;
+ color: #e6e6e6;
+}
+
+.profile-name.active {
+ background: #3b82f6;
+ border-color: #3b82f6;
+ color: white;
+}
+
.garmin-connection {
display: flex;
flex-direction: column;
gap: 0.5rem;
- padding: 0.75rem 0;
- border-bottom: 1px solid #2a2d35;
+ padding-top: 0.75rem;
+ border-top: 1px solid #2a2d35;
}
.garmin-connection-row {
@@ -62,6 +101,22 @@ body {
gap: 0.75rem;
}
+.garmin-connection-main {
+ justify-content: space-between;
+}
+
+.garmin-connection-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.garmin-connection-status {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
.garmin-connection-message {
margin: 0;
font-size: 0.8rem;
@@ -128,6 +183,13 @@ button {
cursor: pointer;
}
+input[type="color"] {
+ padding: 0.2rem;
+ width: 3.5rem;
+ height: 2rem;
+ cursor: pointer;
+}
+
button:hover:not(:disabled) {
border-color: #3b82f6;
}
@@ -151,11 +213,15 @@ button:disabled {
.filter-pills {
display: flex;
gap: 0.5rem;
- flex-wrap: wrap;
+ flex-wrap: nowrap;
+ overflow-x: auto;
margin-bottom: 1rem;
+ padding-bottom: 0.25rem;
}
.filter-pill {
+ flex-shrink: 0;
+ white-space: nowrap;
border-radius: 999px;
padding: 0.35rem 0.9rem;
font-size: 0.85rem;
@@ -197,14 +263,30 @@ button:disabled {
.review-item-header {
display: flex;
justify-content: space-between;
+ align-items: flex-start;
font-size: 1rem;
}
+.review-item-title {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ flex-wrap: wrap;
+}
+
.review-item-header span {
color: #9aa0ab;
font-size: 0.85rem;
}
+.review-item-datetime {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ flex-shrink: 0;
+ line-height: 1.3;
+}
+
.review-item-stats {
display: flex;
gap: 1rem;
@@ -219,11 +301,49 @@ button:disabled {
margin: 0.5rem 0;
}
-.review-item-actions {
+.classify-control {
+ position: relative;
display: flex;
- gap: 0.5rem;
- flex-wrap: wrap;
- margin-top: 0.5rem;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.classify-label {
+ font-size: 0.85rem;
+}
+
+.classify-lock {
+ padding: 0.25rem 0.5rem;
+ font-size: 0.85rem;
+ line-height: 1;
+}
+
+.classify-dropdown {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ z-index: 10;
+ margin-top: 0.25rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ padding: 0.4rem;
+ background: #14161c;
+ border: 1px solid #2a2d35;
+ border-radius: 6px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
+}
+
+.classify-dropdown button {
+ text-align: left;
+ white-space: nowrap;
+}
+
+.classify-dropdown-unassign {
+ color: #9aa0ab;
+ padding-bottom: 0.4rem;
+ margin-bottom: 0.15rem;
+ border-bottom: 1px solid #2a2d35;
}
.expected-actual-charts {
@@ -239,14 +359,15 @@ button:disabled {
.mini-chart-label {
display: block;
+ text-align: center;
font-size: 0.75rem;
color: #9aa0ab;
margin-bottom: 0.15rem;
}
.phase-legend {
- flex-basis: 100%;
display: flex;
+ justify-content: center;
gap: 0.75rem;
flex-wrap: wrap;
}
@@ -292,8 +413,8 @@ button:disabled {
background: #14161c;
border: 1px solid #2a2d35;
border-radius: 8px;
- width: min(720px, 90vw);
- max-height: 80vh;
+ width: min(1100px, 94vw);
+ max-height: 88vh;
display: flex;
flex-direction: column;
}
@@ -307,42 +428,75 @@ button:disabled {
font-weight: 600;
}
+.modal-header-actions {
+ display: flex;
+ gap: 0.5rem;
+ font-weight: 400;
+}
+
+.modal-header-actions button {
+ font-size: 0.75rem;
+ padding: 0.25rem 0.6rem;
+}
+
.modal-json {
margin: 0;
padding: 1rem;
overflow: auto;
font-size: 0.8rem;
- line-height: 1.4;
+ line-height: 1.5;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+
+.json-row {
white-space: pre-wrap;
word-break: break-word;
}
-.kinds-table {
- width: 100%;
- border-collapse: collapse;
- margin-bottom: 1rem;
-}
-
-.lock-badge {
+.json-toggle {
display: inline-block;
- border-radius: 999px;
- padding: 0.15rem 0.6rem;
- font-size: 0.75rem;
+ width: 1rem;
+ padding: 0;
+ border: none;
+ background: none;
color: #9aa0ab;
- border: 1px solid #2a2d35;
- cursor: help;
+ cursor: pointer;
+ font-size: 0.7rem;
+ font-family: inherit;
}
-.kinds-table th,
-.kinds-table td {
- text-align: left;
- padding: 0.5rem;
- border-bottom: 1px solid #2a2d35;
+.json-toggle:hover {
+ color: #e6e6e6;
}
-.kinds-table-actions {
- display: flex;
- gap: 0.5rem;
+.json-key {
+ color: #9aa0ab;
+}
+
+.json-bracket {
+ color: #6b7280;
+}
+
+.json-summary {
+ color: #6b7280;
+ font-style: italic;
+}
+
+.json-string {
+ color: #6fcf97;
+}
+
+.json-number {
+ color: #6cb6ff;
+}
+
+.json-boolean {
+ color: #f5a742;
+}
+
+.json-null {
+ color: #6b7280;
+ font-style: italic;
}
.kind-editor {
@@ -352,7 +506,51 @@ button:disabled {
border: 1px solid #2a2d35;
border-radius: 8px;
padding: 1rem;
- max-width: 480px;
+ max-width: 580px;
+}
+
+.profile-page {
+ display: flex;
+ flex-direction: column;
+ gap: 1.75rem;
+}
+
+.training-type-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.training-type-item {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ border: 1px solid #2a2d35;
+ border-radius: 6px;
+ padding: 0.75rem;
+}
+
+.training-type-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.training-type-description {
+ margin: 0;
+ font-size: 0.85rem;
+ color: #9aa0ab;
+ white-space: pre-wrap;
+}
+
+.training-type-meta {
+ margin: 0;
+ font-size: 0.8rem;
+ color: #6b7280;
}
.kind-editor textarea {
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index e1f023c..991bd57 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,24 +1,31 @@
-import { useState } from "react";
+import { useEffect, useState } from "react";
+import { api } from "./api/client";
import "./App.css";
-import { GarminConnection } from "./components/GarminConnection";
-import { Activities } from "./pages/Activities";
import { Dashboard } from "./pages/Dashboard";
+import { Plan } from "./pages/Plan";
import { Profile } from "./pages/Profile";
import { ReviewQueue } from "./pages/ReviewQueue";
-import { WorkoutKinds } from "./pages/WorkoutKinds";
const TABS = [
- { key: "dashboard", label: "Progression", Component: Dashboard },
- { key: "review", label: "Review Queue", Component: ReviewQueue },
- { key: "activities", label: "Activities", Component: Activities },
- { key: "kinds", label: "Workout Kinds", Component: WorkoutKinds },
- { key: "profile", label: "Profile", Component: Profile },
+ { key: "activities", label: "Activities", icon: "☰", Component: ReviewQueue },
+ { key: "dashboard", label: "Progression", icon: "↗", Component: Dashboard },
+ { key: "plan", label: "Training plan", icon: "✎", Component: Plan },
] as const;
type TabKey = (typeof TABS)[number]["key"];
function App() {
- const [tab, setTab] = useState("dashboard");
+ const [tab, setTab] = useState("activities");
+ // Profile isn't a tab: it's reached via the profile name in the top-right
+ // corner instead, since (for now, single-profile) it's account settings,
+ // not a content view alongside Activities/Progression/Training plan.
+ const [showProfile, setShowProfile] = useState(false);
+ const [profileName, setProfileName] = useState(null);
+
+ useEffect(() => {
+ api.getProfile().then((p) => setProfileName(p.Name)).catch(() => {});
+ }, []);
+
const Active = TABS.find((t) => t.key === tab)!.Component;
return (
@@ -29,18 +36,26 @@ function App() {
{TABS.map((t) => (
setTab(t.key)}
+ className={!showProfile && t.key === tab ? "tab active" : "tab"}
+ onClick={() => {
+ setShowProfile(false);
+ setTab(t.key);
+ }}
>
+ {t.icon}
{t.label}
))}
+ setShowProfile(true)}
+ >
+ {profileName ?? "Profile"}
+
-
-
-
-
+ {showProfile ? setProfileName(p.Name)} /> : }
);
}
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index bb0dab8..e7feb57 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -55,7 +55,7 @@ export const api = {
getActivity: (id: number) =>
request<{ activity: Activity; laps: unknown[]; assignment?: KindAssignment }>(`/api/activities/${id}`),
- // Workout kinds -- fixed taxonomy, no create/delete
+ // Training types -- fixed taxonomy, no create/delete
listWorkoutKinds: (includeInactive = false) =>
request(`/api/workout-kinds/${includeInactive ? "?include_inactive=true" : ""}`),
updateWorkoutKind: (
@@ -69,7 +69,8 @@ export const api = {
is_active?: boolean;
pace_min_sec_per_km?: number | null;
pace_max_sec_per_km?: number | null;
- expected_hr_zone?: number | null;
+ hr_min_pct_hrr?: number | null;
+ hr_max_pct_hrr?: number | null;
},
) => request(`/api/workout-kinds/${id}`, { method: "PUT", body: JSON.stringify(body) }),
@@ -85,11 +86,15 @@ export const api = {
// Review queue -- cursor-paginated: pass the previous page's next_cursor
// as `before` to fetch the next one. Fetching every activity's laps and
// per-second samples up front gets expensive once there are many, so the
- // frontend loads it incrementally (infinite scroll) instead of all at once.
- reviewQueue: (params?: { limit?: number; before?: string }) => {
+ // frontend loads it incrementally (infinite scroll) instead of all at once
+ // -- kindId/unclassified filter server-side so a filtered view stays
+ // paginated too, instead of having to load the whole matching backlog.
+ reviewQueue: (params?: { limit?: number; before?: string; kindId?: number; unclassified?: boolean }) => {
const q = new URLSearchParams();
if (params?.limit) q.set("limit", String(params.limit));
if (params?.before) q.set("before", params.before);
+ if (params?.kindId != null) q.set("kind_id", String(params.kindId));
+ if (params?.unclassified) q.set("unclassified", "true");
const qs = q.toString();
return request(`/api/review-queue/${qs ? `?${qs}` : ""}`);
},
@@ -98,6 +103,14 @@ export const api = {
method: "POST",
body: JSON.stringify({ workout_kind_id: workoutKindId }),
}),
+ // Reverts a manual assignment back to rule_engine sourcing (same kind),
+ // so a later reclassify pass is free to change it again.
+ unlockReview: (activityId: number) =>
+ request<{ status: string }>(`/api/review-queue/${activityId}/unlock`, { method: "POST" }),
+ // Manually clears an activity's kind back to Unclassified (locks that
+ // decision, same as resolveReview locks a specific kind).
+ unassignReview: (activityId: number) =>
+ request<{ status: string }>(`/api/review-queue/${activityId}/unassign`, { method: "POST" }),
// Progression
progression: (kindId: number, metric: ProgressionMetric = "pace", from?: string, to?: string) => {
diff --git a/frontend/src/components/ColorField.tsx b/frontend/src/components/ColorField.tsx
new file mode 100644
index 0000000..6b752d7
--- /dev/null
+++ b/frontend/src/components/ColorField.tsx
@@ -0,0 +1,16 @@
+export function ColorField({
+ label,
+ value,
+ onChange,
+}: {
+ label: string;
+ value: string;
+ onChange: (v: string) => void;
+}) {
+ return (
+
+ {label}
+ onChange(e.target.value)} />
+
+ );
+}
diff --git a/frontend/src/components/GarminConnection.tsx b/frontend/src/components/GarminConnection.tsx
index 3e68689..053fb41 100644
--- a/frontend/src/components/GarminConnection.tsx
+++ b/frontend/src/components/GarminConnection.tsx
@@ -20,6 +20,10 @@ export function GarminConnection() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const [syncStatus, setSyncStatus] = useState(null);
+ // There's no real Garmin logout -- the backend session just sits idle.
+ // "Disconnect" only hides that and shows Connect again locally; a real
+ // login (connect()) clears it.
+ const [disconnected, setDisconnected] = useState(false);
function refreshStatus() {
api.authStatus().then(setAuth).catch((e) => setError(String(e)));
@@ -47,6 +51,7 @@ export function GarminConnection() {
setError(null);
try {
setAuth(await api.login());
+ setDisconnected(false);
} catch (e) {
setError(String(e));
} finally {
@@ -54,6 +59,10 @@ export function GarminConnection() {
}
}
+ function disconnect() {
+ setDisconnected(true);
+ }
+
async function submitMFA() {
if (!code.trim()) return;
setBusy(true);
@@ -90,7 +99,7 @@ export function GarminConnection() {
try {
await api.resetSync();
// Reset runs as a background sync job; wait for it to actually finish
- // before reloading, otherwise other pages (e.g. Review Queue) would
+ // before reloading, otherwise other pages (e.g. Activities) would
// still show the just-deleted activities from their own stale state.
let status = await api.syncStatus();
while (status.in_progress) {
@@ -104,45 +113,48 @@ export function GarminConnection() {
}
}
- const status = auth?.status ?? "unknown";
+ const status = disconnected ? "unknown" : auth?.status ?? "unknown";
return (
-
-
-
- {status === "authenticated" && "Connected to Garmin"}
- {status === "mfa_required" && "MFA code required"}
- {status === "failed" && "Connection failed"}
- {status === "unknown" && "Not connected to Garmin"}
-
+
+
+ {status !== "authenticated" && status !== "mfa_required" && (
+
+ Connect to Garmin
+
+ )}
- {status !== "authenticated" && status !== "mfa_required" && (
-
- Connect to Garmin
+ {status === "authenticated" && (
+ <>
+
+ Sync now
+
+
+ Disconnect
+
+ >
+ )}
+
+ {/* Reset all only wipes local DB state (see handleSyncReset) -- it
+ doesn't touch the Garmin session, so it stays available even
+ while disconnected/not-yet-connected. */}
+
+ Reset all
- )}
+
- {status === "authenticated" && (
- <>
-
- Sync now
-
-
- Reset all
-
- {syncStatus?.in_progress && (
-
{syncProgressLabel(syncStatus)}
- )}
- {syncStatus?.last_run && !syncStatus.in_progress && (
-
- last sync: {syncStatus.last_run.Status} ({syncStatus.last_run.ActivitiesFetched} activities)
- {syncStatus.activities_pending_details > 0 &&
- ` — ${syncStatus.activities_pending_details} still need details, click Sync now again`}
-
- )}
- >
- )}
+
+
+ {/* Connected/not-connected is already conveyed by the
+ Connect/Disconnect button itself -- only MFA/failed need a
+ label, since no button distinguishes those from "not connected". */}
+ {(status === "mfa_required" || status === "failed") && (
+
+ {status === "mfa_required" ? "MFA code required" : "Connection failed"}
+
+ )}
+
{status === "mfa_required" && (
@@ -159,7 +171,21 @@ export function GarminConnection() {
)}
- {auth?.message &&
{auth.message}
}
+ {!disconnected && auth?.message &&
{auth.message}
}
+
+ {/* Full-width so a long "last sync"/pending-details message has room
+ to breathe, instead of being squeezed next to the status dot. */}
+ {status === "authenticated" && syncStatus?.in_progress && (
+
{syncProgressLabel(syncStatus)}
+ )}
+ {status === "authenticated" && syncStatus?.last_run && !syncStatus.in_progress && (
+
+ last sync: {syncStatus.last_run.Status} ({syncStatus.last_run.ActivitiesFetched} activities)
+ {syncStatus.activities_pending_details > 0 &&
+ ` — ${syncStatus.activities_pending_details} still need details, click Sync now again`}
+
+ )}
+
{error &&
{error}
}
);
diff --git a/frontend/src/components/NullableNumberField.tsx b/frontend/src/components/NullableNumberField.tsx
new file mode 100644
index 0000000..9996839
--- /dev/null
+++ b/frontend/src/components/NullableNumberField.tsx
@@ -0,0 +1,20 @@
+export function NullableNumberField({
+ label,
+ value,
+ onChange,
+}: {
+ label: string;
+ value: number | null;
+ onChange: (v: number | null) => void;
+}) {
+ return (
+
+ {label}
+ onChange(e.target.value === "" ? null : Number(e.target.value))}
+ />
+
+ );
+}
diff --git a/frontend/src/components/PaceField.tsx b/frontend/src/components/PaceField.tsx
new file mode 100644
index 0000000..8e5575c
--- /dev/null
+++ b/frontend/src/components/PaceField.tsx
@@ -0,0 +1,66 @@
+import { useEffect, useState } from "react";
+
+// Pace is entered/displayed as "m:ss" but stored as whole seconds. An empty
+// input means "not set" (null), not zero.
+export function parsePace(text: string): number | null {
+ const trimmed = text.trim();
+ if (trimmed === "") return null;
+ const match = /^(\d+):([0-5]?\d)$/.exec(trimmed);
+ if (!match) return null;
+ return Number(match[1]) * 60 + Number(match[2]);
+}
+
+// Rounds the total seconds first, then splits into minutes/seconds -- doing
+// it the other way around (floor the minutes, round the leftover seconds
+// separately) can round e.g. 479.6s up to "7:60" instead of carrying over to
+// "8:00".
+export function formatMinutesSeconds(totalSeconds: number): string {
+ const total = Math.round(totalSeconds);
+ const m = Math.floor(total / 60);
+ const s = total % 60;
+ return `${m}:${s.toString().padStart(2, "0")}`;
+}
+
+export function formatPace(seconds: number | null): string {
+ if (seconds == null) return "";
+ return formatMinutesSeconds(seconds);
+}
+
+export function PaceField({
+ label,
+ value,
+ onChange,
+}: {
+ label: string;
+ value: number | null;
+ onChange: (v: number | null) => void;
+}) {
+ const [text, setText] = useState(formatPace(value));
+ useEffect(() => setText(formatPace(value)), [value]);
+
+ function commit(raw: string) {
+ if (raw.trim() === "") {
+ onChange(null);
+ return;
+ }
+ const parsed = parsePace(raw);
+ if (parsed != null) {
+ onChange(parsed);
+ } else {
+ setText(formatPace(value)); // invalid input -- revert to the last valid value
+ }
+ }
+
+ return (
+
+ {label}
+ setText(e.target.value)}
+ onBlur={(e) => commit(e.target.value)}
+ placeholder="12:00"
+ />
+
+ );
+}
diff --git a/frontend/src/components/RawDataModal.tsx b/frontend/src/components/RawDataModal.tsx
index 26fd2ea..91913e6 100644
--- a/frontend/src/components/RawDataModal.tsx
+++ b/frontend/src/components/RawDataModal.tsx
@@ -1,9 +1,13 @@
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
// Fields whose value is itself a JSON string (Garmin's raw payloads, kept
-// verbatim in the DB). Parsed back into objects so the modal shows one
-// readable nested tree instead of an escaped string blob.
-const RAW_JSON_STRING_FIELDS = new Set(["RawJSON", "DetailsRawJSON"]);
+// verbatim in the DB). Parsed back into objects so the tree shows a real
+// nested structure instead of an escaped string blob. Every other field is
+// shown as-is, even ones that duplicate something inside these blobs --
+// the backend's own duplication cleanup (2026-07) already removed every
+// field that had no good reason to exist alongside RawJSON, so anything
+// left here is worth seeing in full.
+const RAW_JSON_STRING_FIELDS = new Set(["RawJSON", "DetailsRawJSON", "WorkoutRawJSON"]);
function parseEmbeddedJSON(value: unknown): unknown {
if (Array.isArray(value)) return value.map(parseEmbeddedJSON);
@@ -26,21 +30,147 @@ function parseEmbeddedJSON(value: unknown): unknown {
return value;
}
+// Indentation per nesting level, in px. Array levels use a smaller step than
+// object levels -- a numeric index carries far less structure than a named
+// key, and Garmin payloads have long, deeply-repeated arrays (samples, laps,
+// workout steps) where full per-level indentation quickly eats the width
+// available for the values themselves.
+const OBJECT_INDENT = 12;
+const ARRAY_INDENT = 6;
+
+type ValueKind = "object" | "array" | "string" | "number" | "boolean" | "null";
+
+function kindOf(v: unknown): ValueKind {
+ if (v === null) return "null";
+ if (Array.isArray(v)) return "array";
+ if (typeof v === "object") return "object";
+ if (typeof v === "number") return "number";
+ if (typeof v === "boolean") return "boolean";
+ return "string";
+}
+
+// Bumped by the Expand/Collapse all buttons to force every node's local
+// fold state, overriding whatever the user clicked individually before.
+type ExpandSignal = { gen: number; expand: boolean } | null;
+
+function Primitive({ value }: { value: unknown }) {
+ const kind = kindOf(value);
+ if (kind === "string") return {`"${value as string}"`} ;
+ if (kind === "null") return null ;
+ return {String(value)} ;
+}
+
+function JsonNode({
+ name,
+ value,
+ depth,
+ indentPx,
+ signal,
+}: {
+ name: string | null;
+ value: unknown;
+ depth: number;
+ indentPx: number;
+ signal: ExpandSignal;
+}) {
+ const kind = kindOf(value);
+ const isContainer = kind === "object" || kind === "array";
+ const entries: Array<[string, unknown]> = !isContainer
+ ? []
+ : kind === "array"
+ ? (value as unknown[]).map((v, i) => [String(i), v])
+ : Object.entries(value as Record);
+
+ // Deeply nested or very large containers (raw Garmin payloads, per-second
+ // sample arrays) start folded so opening the modal doesn't render
+ // thousands of rows up front; shallow, modestly sized ones start open.
+ const [expanded, setExpanded] = useState(depth < 2 && entries.length <= 50);
+ useEffect(() => {
+ if (signal) setExpanded(signal.expand);
+ }, [signal]);
+
+ const indent = { paddingLeft: indentPx };
+ const childIndentPx = indentPx + (kind === "array" ? ARRAY_INDENT : OBJECT_INDENT);
+
+ if (!isContainer) {
+ return (
+
+ {name != null &&
{name}: }
+
+
+ );
+ }
+
+ const [open, close] = kind === "array" ? ["[", "]"] : ["{", "}"];
+ return (
+
+
setExpanded((e) => !e)}
+ aria-label={expanded ? "Collapse" : "Expand"}
+ >
+ {expanded ? "▾" : "▸"}
+
+ {name != null &&
{name}: }
+ {expanded ? (
+ <>
+
{open}
+ {entries.map(([k, v]) => (
+
+ ))}
+
+ {close}
+
+ >
+ ) : (
+
+ {open}
+ {entries.length} {kind === "array" ? "items" : "keys"}
+ {close}
+
+ )}
+
+ );
+}
+
export function RawDataModal({ data, onClose }: { data: unknown; onClose: () => void }) {
+ const [signal, setSignal] = useState(null);
+
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose]);
+ const parsed = parseEmbeddedJSON(data);
+
return (
e.stopPropagation()}>
Raw data
-
Close
+
+ setSignal((s) => ({ gen: (s?.gen ?? 0) + 1, expand: true }))}
+ >
+ Expand all
+
+ setSignal((s) => ({ gen: (s?.gen ?? 0) + 1, expand: false }))}
+ >
+ Collapse all
+
+
+ Close
+
+
+
+
+
-
{JSON.stringify(parseEmbeddedJSON(data), null, 2)}
);
diff --git a/frontend/src/components/TrainingTypesCard.tsx b/frontend/src/components/TrainingTypesCard.tsx
new file mode 100644
index 0000000..f5d2d51
--- /dev/null
+++ b/frontend/src/components/TrainingTypesCard.tsx
@@ -0,0 +1,127 @@
+import { useEffect, useState } from "react";
+import { api } from "../api/client";
+import { NullableNumberField } from "./NullableNumberField";
+import { PaceField, formatPace } from "./PaceField";
+import type { WorkoutKind } from "../types/api";
+
+// RuleJSON still exists on WorkoutKind and still drives the rule engine for
+// kinds configured earlier, but it's not edited here: for now a training
+// type is described in free text (used later to assist an AI-based
+// auto-sort) plus a pace/HR range for reference, and whatever rule a kind
+// already has is carried through untouched on save.
+
+function paceRangeText(min: number | null, max: number | null): string | null {
+ if (min == null && max == null) return null;
+ if (min != null && max != null) return `${formatPace(min)}–${formatPace(max)}/km`;
+ return min != null ? `from ${formatPace(min)}/km` : `up to ${formatPace(max)}/km`;
+}
+
+function hrRangeText(min: number | null, max: number | null): string | null {
+ if (min == null && max == null) return null;
+ if (min != null && max != null) return `${min}–${max}% HRR`;
+ return min != null ? `from ${min}% HRR` : `up to ${max}% HRR`;
+}
+
+export function TrainingTypesCard() {
+ const [kinds, setKinds] = useState([]);
+ const [editing, setEditing] = useState(null);
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [paceMin, setPaceMin] = useState(null);
+ const [paceMax, setPaceMax] = useState(null);
+ const [hrMin, setHrMin] = useState(null);
+ const [hrMax, setHrMax] = useState(null);
+ const [error, setError] = useState(null);
+
+ function reload() {
+ api.listWorkoutKinds(true).then(setKinds).catch((e) => setError(String(e)));
+ }
+
+ useEffect(reload, []);
+
+ function startEdit(k: WorkoutKind) {
+ setEditing(k);
+ setName(k.Name);
+ setDescription(k.Description);
+ setPaceMin(k.pace_min_sec_per_km);
+ setPaceMax(k.pace_max_sec_per_km);
+ setHrMin(k.hr_min_pct_hrr);
+ setHrMax(k.hr_max_pct_hrr);
+ setError(null);
+ }
+
+ async function save() {
+ if (!editing) return;
+ setError(null);
+ try {
+ await api.updateWorkoutKind(editing.ID, {
+ name,
+ description,
+ rule: JSON.parse(editing.RuleJSON || "{}"),
+ pace_min_sec_per_km: paceMin,
+ pace_max_sec_per_km: paceMax,
+ hr_min_pct_hrr: hrMin,
+ hr_max_pct_hrr: hrMax,
+ });
+ setEditing(null);
+ reload();
+ } catch (e) {
+ setError(String(e));
+ }
+ }
+
+ return (
+
+ Training types
+
+ {error && {error}
}
+
+
+ {kinds.map((k) => (
+
+ {editing?.ID === k.ID ? (
+ <>
+
+ Name
+ setName(e.target.value)} />
+
+
+ Description
+
+
+
+
+
+
+
+ Save
+ setEditing(null)}>Cancel
+
+ >
+ ) : (
+ <>
+
+ {k.Name}
+ startEdit(k)}>Edit
+
+ {k.Description || "No description yet."}
+
+ {[
+ paceRangeText(k.pace_min_sec_per_km, k.pace_max_sec_per_km),
+ hrRangeText(k.hr_min_pct_hrr, k.hr_max_pct_hrr),
+ ]
+ .filter(Boolean)
+ .join(" · ") || "No target pace or heart rate set."}
+
+ >
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/components/charts/ExpectedVsActualChart.tsx b/frontend/src/components/charts/ExpectedVsActualChart.tsx
index fb8e9a9..1ebfef0 100644
--- a/frontend/src/components/charts/ExpectedVsActualChart.tsx
+++ b/frontend/src/components/charts/ExpectedVsActualChart.tsx
@@ -1,4 +1,5 @@
import { Area, AreaChart, Line, ReferenceArea, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
+import { formatMinutesSeconds } from "../PaceField";
import type { Lap, Sample } from "../../types/api";
function paceSecPerKm(mps: number | null): number | null {
@@ -43,17 +44,6 @@ function filterPaceArtifacts(
return result;
}
-// Rounds the total seconds first, then splits into minutes/seconds -- doing
-// it the other way around (floor the minutes, round the leftover seconds
-// separately) can round 59.6s up to "60", displaying e.g. "6:60" instead of
-// carrying over to "7:00".
-function formatMinutesSeconds(totalSeconds: number): string {
- const total = Math.round(totalSeconds);
- const m = Math.floor(total / 60);
- const s = total % 60;
- return `${m}:${s.toString().padStart(2, "0")}`;
-}
-
function formatPaceShort(secPerKm: number): string {
return formatMinutesSeconds(secPerKm);
}
@@ -122,27 +112,48 @@ function hrTickStep(domain: [number, number]): number {
return 5;
}
-// Pace and HR each keep one dedicated accent color across every chart, so
-// the two metrics stay visually distinct from each other and from the phase
-// bands below.
-const PACE_COLOR = "#3b82f6"; // blue
-const HR_COLOR = "#ef4444"; // red
+// Color policy (all 6 colors configurable in Profile > Chart colors):
+// - Each chart's main line (the actual pace/HR trace and its dashed phase
+// average) is always its metric's own color -- blue for pace, red for HR
+// -- regardless of effort kind.
+// - The 4 effort-kind colors (warm-up/effort/recovery/cool-down) drive two
+// derived fills: the area *under* the line is the effort color with a
+// subtle tint of the main line color mixed in (mixColor); the background
+// *above* the line (the full-height band behind everything) is the same
+// effort color, darkened, with no main-line tint (darken).
+function hexToRgb(hex: string): [number, number, number] {
+ const clean = hex.replace("#", "");
+ const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
+ const n = parseInt(full, 16) || 0;
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
+}
+
+function rgbToHex([r, g, b]: [number, number, number]): string {
+ const toHex = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0");
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
+}
+
+// Mixes `weight` (0-1) of `tint` into `base` -- e.g. mixColor(effortColor,
+// paceColor, 0.2) reads as "effort color with a subtle taint of pace blue".
+function mixColor(base: string, tint: string, weight: number): string {
+ const b = hexToRgb(base);
+ const t = hexToRgb(tint);
+ return rgbToHex([0, 1, 2].map((i) => b[i] + (t[i] - b[i]) * weight) as [number, number, number]);
+}
+
+// Darkens `hex` by mixing in `amount` (0-1) of black -- no main-line tint,
+// just a deeper shade of the same effort color.
+function darken(hex: string, amount: number): string {
+ return mixColor(hex, "#000000", amount);
+}
+
+// Brightens `hex` by mixing in `amount` (0-1) of white -- the color cue that
+// a chart has a structured-workout target range to show, replacing a text
+// label (it's otherwise not visually obvious a target exists).
+function brighten(hex: string, amount: number): string {
+ return mixColor(hex, "#ffffff", amount);
+}
-// Phase a lap represents, by Garmin's own per-lap IntensityType tagging.
-// Colors distinguish warm-up / effort / recovery / cool-down bands behind
-// the pace/HR trace so a run's structure reads at a glance -- deliberately
-// avoiding blue/red, since those are already spoken for by PACE_COLOR/HR_COLOR.
-// ACTIVE and INTERVAL both mean "this is a work/effort segment" -- Garmin
-// uses ACTIVE for structured-workout steps and INTERVAL for manually-lapped
-// or auto-detected effort segments, but they're the same concept here.
-const PHASE_COLORS: Record = {
- WARMUP: "#f59e0b", // amber
- ACTIVE: "#ec4899", // pink
- INTERVAL: "#ec4899", // pink
- REST: "#14b8a6", // teal
- RECOVERY: "#14b8a6", // teal
- COOLDOWN: "#8b5cf6", // violet
-};
const PHASE_LABELS: Record = {
WARMUP: "Warm-up",
ACTIVE: "Effort",
@@ -173,7 +184,7 @@ interface Point {
phaseAvgHR: number | null;
}
-function buildLapWindows(laps: Lap[]): LapWindow[] {
+function buildLapWindows(laps: Lap[], phaseColors: Record): LapWindow[] {
const windows: LapWindow[] = [];
let elapsedMin = 0;
laps.forEach((l, i) => {
@@ -203,7 +214,7 @@ function buildLapWindows(laps: Lap[]): LapWindow[] {
targetHRRange: !isContinuation && l.TargetHRLowBpm != null && l.TargetHRHighBpm != null ? [l.TargetHRLowBpm, l.TargetHRHighBpm] : null,
avgPace: paceSecPerKm(l.AvgSpeedMps),
avgHR: l.AvgHR,
- color: PHASE_COLORS[l.IntensityType],
+ color: phaseColors[l.IntensityType],
});
});
return windows;
@@ -297,19 +308,56 @@ export function ExpectedVsActualChart({
samples,
minRepresentativePaceSecPerKm,
minRepresentativeTimeSeconds,
+ paceColor,
+ heartRateColor,
+ warmupColor,
+ effortColor,
+ recoveryColor,
+ cooldownColor,
+ mainLineTintPct,
+ backgroundDarkenPct,
+ targetBrightenPct,
}: {
laps: Lap[];
samples: Sample[];
minRepresentativePaceSecPerKm: number;
minRepresentativeTimeSeconds: number;
+ paceColor: string;
+ heartRateColor: string;
+ warmupColor: string;
+ effortColor: string;
+ recoveryColor: string;
+ cooldownColor: string;
+ mainLineTintPct: number;
+ backgroundDarkenPct: number;
+ targetBrightenPct: number;
}) {
if (laps.length === 0) return null;
+ // ACTIVE and INTERVAL both mean "this is a work/effort segment" -- Garmin
+ // uses ACTIVE for structured-workout steps and INTERVAL for manually-lapped
+ // or auto-detected effort segments, but they're the same concept here.
+ // Likewise REST/RECOVERY are both "recovery".
+ const phaseColors: Record = {
+ WARMUP: warmupColor,
+ ACTIVE: effortColor,
+ INTERVAL: effortColor,
+ REST: recoveryColor,
+ RECOVERY: recoveryColor,
+ COOLDOWN: cooldownColor,
+ };
+
const hasPaceTarget = laps.some((l) => l.TargetPaceLowMps != null && l.TargetPaceHighMps != null);
const hasHRTarget = laps.some((l) => l.TargetHRLowBpm != null && l.TargetHRHighBpm != null);
+ // Brightened main line color is the cue that this chart has a target to
+ // show, replacing a "vs target" text label -- it's otherwise not visually
+ // obvious a target range is present versus just an unusually flat effort.
+ const brightenWeight = Math.max(0, Math.min(100, targetBrightenPct)) / 100;
+ const paceMainColor = hasPaceTarget ? brighten(paceColor, brightenWeight) : paceColor;
+ const hrMainColor = hasHRTarget ? brighten(heartRateColor, brightenWeight) : heartRateColor;
const singleEffortType = new Set(laps.map((l) => l.IntensityType)).size === 1;
- const lapWindows = buildLapWindows(laps);
+ const lapWindows = buildLapWindows(laps, phaseColors);
const lapTotalMin = lapWindows[lapWindows.length - 1].end;
const phaseSegments = buildPhaseSegments(lapWindows);
@@ -372,11 +420,38 @@ export function ExpectedVsActualChart({
}
const phaseBands = lapWindows.filter((w) => w.color).map((w) => ({ x1: w.start, x2: w.end, color: w.color! }));
- const phasesPresent = [...new Set(laps.map((l) => l.IntensityType).filter((t) => PHASE_COLORS[t]))];
- // A light vertical line at every point the effort type changes (warm-up
- // -> effort -> recovery -> ...), regardless of whether that type has a
- // background color, so the workout's structure reads at a glance.
- const phaseBoundaries = lapWindows.slice(1).filter((w, i) => w.intensityType !== lapWindows[i].intensityType).map((w) => w.start);
+ const phasesPresent = [...new Set(laps.map((l) => l.IntensityType).filter((t) => phaseColors[t]))];
+ // A light vertical line at every lap boundary -- not just where the effort
+ // kind changes (warm-up -> effort -> recovery -> ...), but also between two
+ // consecutive laps of the *same* kind, e.g. the workout's own cool-down lap
+ // followed by an extra cool-down lap logged after the recording continued
+ // past the prescribed target (see buildLapWindows' isContinuation). Those
+ // two laps get merged into one shaded phase segment (same color, same
+ // fill), so without this line they'd otherwise read as a single lap.
+ const phaseBoundaries = lapWindows.slice(1).map((w) => w.start);
+
+ // One Area per phase segment instead of one for the whole chart, so the
+ // fill under the line can vary by effort kind (each segment gets its own
+ // color) while the stroke stays the metric's single main color throughout.
+ // The last segment is extended to elapsedMin (not just its own end) so a
+ // sample slightly past the laps' own total duration is still covered --
+ // otherwise the last sliver of the trace would be left unfilled.
+ const tintWeight = Math.max(0, Math.min(100, mainLineTintPct)) / 100;
+ const darkenWeight = Math.max(0, Math.min(100, backgroundDarkenPct)) / 100;
+ function segmentsWithPoints(mainColor: string) {
+ return phaseSegments
+ .map((seg, i) => {
+ const color = phaseColors[seg.intensityType];
+ if (!color) return null;
+ const isLast = i === phaseSegments.length - 1;
+ const segPoints = points.filter((p) => p.t >= seg.start && (isLast || p.t <= seg.end));
+ if (segPoints.length === 0) return null;
+ return { key: `${seg.intensityType}-${seg.start}`, points: segPoints, fill: mixColor(color, mainColor, tintWeight) };
+ })
+ .filter((s): s is { key: string; points: Point[]; fill: string } => s != null);
+ }
+ const paceSegments = segmentsWithPoints(paceMainColor);
+ const hrSegments = segmentsWithPoints(hrMainColor);
const paceDomain = robustDomain(
points.map((p) => p.actualPace),
@@ -396,22 +471,12 @@ export function ExpectedVsActualChart({
return (
- {phasesPresent.length > 0 && (
-
- {phasesPresent.map((phase) => (
-
-
- {PHASE_LABELS[phase] ?? phase}
-
- ))}
-
- )}
-
Pace{hasPaceTarget ? " vs target" : ""}
+
Pace
{phaseBands.map((b, i) => (
-
+
))}
{phaseBoundaries.map((x, i) => (
@@ -422,17 +487,39 @@ export function ExpectedVsActualChart({
{hasPaceTarget && (
)}
-
-
+ {paceSegments.map((seg) => (
+
+ ))}
+
-
HR{hasHRTarget ? " vs target" : ""}
+
Heart Rate
{phaseBands.map((b, i) => (
-
+
))}
{phaseBoundaries.map((x, i) => (
@@ -443,11 +530,36 @@ export function ExpectedVsActualChart({
{hasHRTarget && (
)}
-
-
+ {hrSegments.map((seg) => (
+
+ ))}
+
+ {phasesPresent.length > 0 && (
+
+ {phasesPresent.map((phase) => (
+
+
+ {PHASE_LABELS[phase] ?? phase}
+
+ ))}
+
+ )}
);
}
diff --git a/frontend/src/components/charts/ProgressionChart.tsx b/frontend/src/components/charts/ProgressionChart.tsx
index 87b5b59..5ef4c99 100644
--- a/frontend/src/components/charts/ProgressionChart.tsx
+++ b/frontend/src/components/charts/ProgressionChart.tsx
@@ -1,20 +1,68 @@
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
+import { formatMinutesSeconds } from "../PaceField";
import type { ProgressionPoint } from "../../types/api";
const METRIC_LABELS: Record = {
- pace: "Pace (sec/km)",
+ pace: "Pace (m:ss/km)",
hr: "Avg HR (bpm)",
vo2max: "VO2max",
aerobic_te: "Aerobic Training Effect",
anaerobic_te: "Anaerobic Training Effect",
+ // Speed per heartbeat, scaled by 1000 for readability (the raw m/s-per-bpm
+ // ratio is a tiny fraction like 0.02, too small to compare at a glance).
+ efficiency_factor: "Efficiency Factor (×1000)",
};
+function formatPaceShort(secPerKm: number): string {
+ return formatMinutesSeconds(secPerKm);
+}
+
+function formatMetricValue(metric: string, value: number): string {
+ if (metric === "pace") return `${formatPaceShort(value)}/km`;
+ // Efficiency factor only really matters at whole-number granularity (11
+ // vs. 14) -- a fractional value like 12.34 implies precision the ×1000
+ // scaling doesn't actually carry.
+ if (metric === "efficiency_factor") return String(Math.round(value));
+ return value.toFixed(1);
+}
+
+// Axis ticks specifically (not the tooltip): with a tight, non-round domain
+// (see computeDomain) Recharts interpolates tick values directly between the
+// domain's exact bounds instead of snapping to round numbers, so unrounded
+// ticks come out as e.g. "15.138246239532403" -- technically correct but
+// unreadable, and easy to misread as a much bigger number than it is.
+function formatAxisTick(metric: string, value: number): string {
+ if (metric === "pace") return formatPaceShort(value);
+ if (metric === "efficiency_factor") return String(Math.round(value));
+ return value.toFixed(1);
+}
+
+// Recharts' YAxis defaults to a [0, auto] domain, which for a metric like HR
+// or pace makes every real activity's variation collapse into a sliver near
+// the top of the chart (starting a heart-rate axis at 0bpm, for example).
+// Padding tightly around the actual data range instead makes real
+// progression visible. Progression series are small (one point per
+// activity/week), so plain min/max is enough -- no need for the percentile
+// trimming the per-second chart in ExpectedVsActualChart.tsx uses.
+function computeDomain(values: number[]): [number, number] {
+ if (values.length === 0) return [0, 1];
+ const lo = Math.min(...values);
+ const hi = Math.max(...values);
+ if (lo === hi) {
+ const pad = Math.abs(lo) * 0.1 || 1;
+ return [lo - pad, hi + pad];
+ }
+ const pad = (hi - lo) * 0.15;
+ return [lo - pad, hi + pad];
+}
+
export function ProgressionChart({ points, metric }: { points: ProgressionPoint[]; metric: string }) {
if (points.length === 0) {
return No data yet for this metric.
;
}
const data = points.map((p) => ({ date: p.date.slice(0, 10), value: p.value }));
+ const domain = computeDomain(data.map((d) => d.value));
return (
@@ -23,11 +71,17 @@ export function ProgressionChart({ points, metric }: { points: ProgressionPoint[
formatAxisTick(metric, Number(v))}
label={{ value: METRIC_LABELS[metric] ?? metric, angle: -90, position: "insideLeft", fontSize: 12 }}
/>
[Number(value).toFixed(1), METRIC_LABELS[metric] ?? metric]}
+ formatter={(value) => [formatMetricValue(metric, Number(value)), METRIC_LABELS[metric] ?? metric]}
contentStyle={{ background: "#1a1d24", border: "1px solid #2a2d35", borderRadius: 6 }}
labelStyle={{ color: "#9aa0ab" }}
itemStyle={{ color: "#e6e6e6" }}
diff --git a/frontend/src/pages/Activities.tsx b/frontend/src/pages/Activities.tsx
deleted file mode 100644
index ad44b88..0000000
--- a/frontend/src/pages/Activities.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import { useEffect, useState } from "react";
-import { api } from "../api/client";
-import type { ActivityListItem } from "../types/api";
-
-function formatPace(avgSpeedMps: number | null): string | null {
- if (avgSpeedMps == null || avgSpeedMps <= 0) return null;
- const secPerKm = 1000 / avgSpeedMps;
- const m = Math.floor(secPerKm / 60);
- const s = Math.round(secPerKm % 60);
- return `${m}:${s.toString().padStart(2, "0")}/km`;
-}
-
-function lockReason(item: ActivityListItem): string {
- if (item.assignment_source === "manual") return "Manually assigned -- kept as-is by reclassify";
- if (item.workout_kind_name === "Race") return "Race, from Garmin metadata -- kept as-is by reclassify";
- return "";
-}
-
-export function Activities() {
- const [items, setItems] = useState([]);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- api.listActivities().then(setItems).catch((e) => setError(String(e)));
- }, []);
-
- return (
-
-
Activities
- {error &&
{error}
}
-
- {items.length === 0 ? (
-
No activities synced yet.
- ) : (
-
-
-
- Date
- Name
- Distance
- Pace
- Kind
- Source
-
-
-
-
- {items.map((item) => {
- const pace = formatPace(item.AvgSpeedMps);
- return (
-
- {item.StartTimeUTC}
- {item.ActivityName || item.ActivityType}
- {(item.DistanceMeters / 1000).toFixed(2)} km
- {pace ?? "—"}
- {item.workout_kind_name ?? "—"}
- {item.assignment_source ?? "—"}
-
- {item.locked && (
-
- Locked
-
- )}
-
-
- );
- })}
-
-
- )}
-
- );
-}
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
index d07066e..be94d2b 100644
--- a/frontend/src/pages/Dashboard.tsx
+++ b/frontend/src/pages/Dashboard.tsx
@@ -9,6 +9,7 @@ const METRICS: { key: ProgressionMetric; label: string }[] = [
{ key: "vo2max", label: "VO2max" },
{ key: "aerobic_te", label: "Aerobic Training Effect" },
{ key: "anaerobic_te", label: "Anaerobic Training Effect" },
+ { key: "efficiency_factor", label: "Efficiency Factor" },
];
export function Dashboard() {
@@ -42,17 +43,16 @@ export function Dashboard() {
return (
-
Progression
-
{kinds.length === 0 ? (
- No workout kinds defined yet. Create one on the Workout Kinds tab to start seeing progression here.
+ No training types defined yet. See the Training types card on the Profile page to start seeing progression
+ here.
) : (
<>
- Workout kind
+ Training types
setSelectedKindId(Number(e.target.value))}
diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx
new file mode 100644
index 0000000..7a77b8b
--- /dev/null
+++ b/frontend/src/pages/Plan.tsx
@@ -0,0 +1,3 @@
+export function Plan() {
+ return
;
+}
diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx
index 02ef3ae..8d9e33e 100644
--- a/frontend/src/pages/Profile.tsx
+++ b/frontend/src/pages/Profile.tsx
@@ -1,5 +1,10 @@
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { api } from "../api/client";
+import { ColorField } from "../components/ColorField";
+import { GarminConnection } from "../components/GarminConnection";
+import { NullableNumberField } from "../components/NullableNumberField";
+import { PaceField } from "../components/PaceField";
+import { TrainingTypesCard } from "../components/TrainingTypesCard";
import type { Profile as ProfileType } from "../types/api";
function NumberField({
@@ -26,121 +31,86 @@ function NumberField({
);
}
-// Pace is entered/displayed as "m:ss" but stored as whole seconds.
-function parsePace(text: string): number | null {
- const trimmed = text.trim();
- if (trimmed === "") return null;
- const match = /^(\d+):([0-5]?\d)$/.exec(trimmed);
- if (!match) return null;
- return Number(match[1]) * 60 + Number(match[2]);
-}
+// How long to wait after the last edit before actually saving, so typing a
+// name or a multi-digit number doesn't fire one request per keystroke --
+// only the last edit in a burst triggers a save, covering every field
+// touched during that burst (not just the one that triggered the timer).
+const AUTO_SAVE_DELAY_MS = 600;
-function formatPace(seconds: number): string {
- const m = Math.floor(seconds / 60);
- const s = Math.round(seconds % 60);
- return `${m}:${s.toString().padStart(2, "0")}`;
-}
-
-function PaceField({
- label,
- value,
- onChange,
-}: {
- label: string;
- value: number;
- onChange: (v: number) => void;
-}) {
- const [text, setText] = useState(formatPace(value));
- useEffect(() => setText(formatPace(value)), [value]);
-
- function commit(raw: string) {
- const parsed = parsePace(raw);
- if (parsed != null) {
- onChange(parsed);
- } else {
- setText(formatPace(value)); // invalid input -- revert to the last valid value
- }
- }
-
- return (
-
- {label}
- setText(e.target.value)}
- onBlur={(e) => commit(e.target.value)}
- placeholder="12:00"
- />
-
- );
-}
-
-function NullableNumberField({
- label,
- value,
- onChange,
-}: {
- label: string;
- value: number | null;
- onChange: (v: number | null) => void;
-}) {
- return (
-
- {label}
- onChange(e.target.value === "" ? null : Number(e.target.value))}
- />
-
- );
-}
-
-export function Profile() {
+export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) {
const [profile, setProfile] = useState(null);
const [error, setError] = useState(null);
const [saved, setSaved] = useState(false);
+ // Mirrors `profile` synchronously (state updates don't apply until the
+ // next render), so set() always debounces from the latest edit rather
+ // than a stale snapshot from this render's closure.
+ const profileRef = useRef(null);
+ profileRef.current = profile;
+ const saveTimeoutRef = useRef | null>(null);
useEffect(() => {
api.getProfile().then(setProfile).catch((e) => setError(String(e)));
}, []);
- function set(key: K, value: ProfileType[K]) {
- setProfile((p) => (p ? { ...p, [key]: value } : p));
- setSaved(false);
- }
+ // Flush a still-pending debounced save on unmount (e.g. the user edits a
+ // field then immediately switches away from the Profile view) rather than
+ // silently dropping it -- there's no state left to update for, so errors
+ // are swallowed.
+ useEffect(() => {
+ return () => {
+ if (saveTimeoutRef.current) {
+ clearTimeout(saveTimeoutRef.current);
+ if (profileRef.current) api.updateProfile(profileRef.current).catch(() => {});
+ }
+ };
+ }, []);
- async function save() {
- if (!profile) return;
- setError(null);
+ async function persist(next: ProfileType) {
try {
- const updated = await api.updateProfile(profile);
+ const updated = await api.updateProfile(next);
+ profileRef.current = updated;
setProfile(updated);
+ setError(null);
setSaved(true);
+ onSaved?.(updated);
} catch (e) {
setError(String(e));
- setSaved(false);
}
}
+ function set(key: K, value: ProfileType[K]) {
+ if (!profileRef.current) return;
+ const next = { ...profileRef.current, [key]: value };
+ profileRef.current = next;
+ setProfile(next);
+ setSaved(false);
+ if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
+ saveTimeoutRef.current = setTimeout(() => persist(next), AUTO_SAVE_DELAY_MS);
+ }
+
if (!profile) {
return (
-
Profile
{error ?
{error}
:
Loading...
}
);
}
return (
-
-
Profile
+
{error &&
{error}
}
{saved &&
Saved.
}
- Garmin account
+ Profile
+
+ Name
+ set("Name", e.target.value)} />
+
+
+
+
+ Garmin
Email
set("GarminPassword", e.target.value)}
/>
+ set("BackfillHorizonDays", v)}
+ />
+
+ {/* TODO: these settings currently have no explanation in the UI --
+ add tooltips once we settle on a pattern for that. */}
- Classification
+ Activity analysis
set("RollingWindowDays", v)}
/>
-
-
-
- Sync
set("BackfillHorizonDays", v)}
+ label="Warm-up (minutes)"
+ value={profile.WarmupMinutes}
+ onChange={(v) => set("WarmupMinutes", v)}
+ />
+ set("CooldownMinutes", v)}
+ />
+ set("MinRepresentativePaceSecPerKm", v ?? 0)}
+ />
+ set("MinRepresentativeTimeSeconds", v)}
/>
-
- How far back "Sync now" reaches when walking backward from today. Not the same as the classification rolling
- window above.
-
@@ -210,46 +195,39 @@ export function Profile() {
- Phase detection
+ Chart colors
- set("WarmupMinutes", v)}
- />
- set("CooldownMinutes", v)}
+ set("PaceColor", v)} />
+ set("HeartRateColor", v)}
/>
-
- Applies to every workout type. Interval workouts detect warm-up/cool-down from lap data directly and don't use this setting.
-
-
-
-
- Pace chart artifacts
-
set("MinRepresentativePaceSecPerKm", v)}
- />
- set("MinRepresentativeTimeSeconds", v)}
- />
+ set("WarmupColor", v)} />
+ set("EffortColor", v)} />
+ set("RecoveryColor", v)} />
+ set("CooldownColor", v)} />
-
- A stretch of samples slower than this pace is hidden from the Review Queue's pace chart (and doesn't stretch
- its scale) unless it lasts at least this long -- e.g. a brief GPS blip right as recording starts gets
- dropped, but a real walk break or stop is kept.
-
+ set("MainLineTintPct", v)}
+ />
+ set("BackgroundDarkenPct", v)}
+ />
+ set("TargetBrightenPct", v)}
+ />
-
Save
+
);
}
diff --git a/frontend/src/pages/ReviewQueue.tsx b/frontend/src/pages/ReviewQueue.tsx
index 10f92ac..f8aaf1e 100644
--- a/frontend/src/pages/ReviewQueue.tsx
+++ b/frontend/src/pages/ReviewQueue.tsx
@@ -1,10 +1,11 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../api/client";
import { ExpectedVsActualChart } from "../components/charts/ExpectedVsActualChart";
+import { formatMinutesSeconds } from "../components/PaceField";
import { RawDataModal } from "../components/RawDataModal";
import type { Profile, ReviewQueueItem, ScoredKind, WorkoutKind } from "../types/api";
-const UNSORTED = "__unsorted__";
+const UNCLASSIFIED = "__unclassified__";
const PAGE_SIZE = 10;
function candidates(item: ReviewQueueItem): ScoredKind[] {
@@ -18,14 +19,145 @@ function candidates(item: ReviewQueueItem): ScoredKind[] {
function formatPace(avgSpeedMps: number | null): string | null {
if (avgSpeedMps == null || avgSpeedMps <= 0) return null;
const secPerKm = 1000 / avgSpeedMps;
- const m = Math.floor(secPerKm / 60);
- const s = Math.round(secPerKm % 60);
- return `${m}:${s.toString().padStart(2, "0")}/km`;
+ return `${formatMinutesSeconds(secPerKm)}/km`;
+}
+
+// StartTimeUTC is "YYYY-MM-DD HH:MM:SS" in UTC with no offset marker, so it
+// must be parsed as UTC explicitly (a bare space-separated string like this
+// is otherwise ambiguous/inconsistent across browsers) before converting to
+// the viewer's own timezone for display.
+function formatActivityDateTime(startTimeUTC: string): { date: string; time: string } {
+ const d = new Date(startTimeUTC.replace(" ", "T") + "Z");
+ const day = d.getDate().toString().padStart(2, "0");
+ // Forced to English regardless of the viewer's own locale, to match the
+ // rest of this app's English-only UI (an auto-locale abbreviation like
+ // French "juil." would look inconsistent here).
+ const month = d.toLocaleDateString("en-US", { month: "short" });
+ const date = `${day}-${month}-${d.getFullYear()}`;
+ const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", hour12: false });
+ return { date, time };
+}
+
+// Grey for unclassified, otherwise grouped by training-load family rather
+// than an exact name match, so "60' Threshold"/"30' Threshold" both read as
+// green, etc. (Threshold uses includes(), not startsWith(), since the
+// numeral now comes first.) Falls back to grey for any kind name outside
+// this taxonomy.
+function kindColor(name: string | undefined): string {
+ if (!name) return "#6b7280"; // grey -- unclassified
+ if (name.startsWith("Easy") || name.startsWith("Long")) return "#3b82f6"; // blue
+ if (name.includes("Threshold")) return "#22c55e"; // green
+ if (name.startsWith("Tempo")) return "#eab308"; // yellow
+ if (name.startsWith("Interval")) return "#f97316"; // orange
+ if (name.startsWith("MAS")) return "#ef4444"; // red
+ if (name.startsWith("Race")) return "#a855f7"; // purple
+ return "#6b7280";
+}
+
+// One combined control per activity: the label shows the current kind (or
+// "Unclassified") and opens a picker when clicked, unless locked -- either
+// because the user picked it by hand, or because it's Race, a hard fact from
+// Garmin's own metadata rather than a retunable rule. The small lock icon is
+// the only way back to unlocked, which is what lets a later "Reclassify all"
+// touch this activity again -- it's only actionable while locked, since
+// there's nothing to lock/unlock about an activity that's still unclassified.
+function ClassifyControl({
+ item,
+ kinds,
+ busy,
+ onAssign,
+ onUnassign,
+ onUnlock,
+}: {
+ item: ReviewQueueItem;
+ kinds: WorkoutKind[];
+ busy: boolean;
+ onAssign: (activityId: number, kindId: number) => void;
+ onUnassign: (activityId: number) => void;
+ onUnlock: (activityId: number) => void;
+}) {
+ const [open, setOpen] = useState(false);
+ const kindName = item.WorkoutKindID != null ? kinds.find((k) => k.ID === item.WorkoutKindID)?.Name : undefined;
+ const isRace = kindName === "Race";
+ const locked = item.AssignmentSource === "manual" || isRace;
+ const assignableKinds = kinds.filter((k) => k.Name !== "Race");
+ const color = kindColor(kindName);
+
+ return (
+
+
setOpen((o) => !o)}
+ >
+ {kindName ?? "Unclassified"}
+
+
onUnlock(item.ActivityID)}
+ >
+ {locked ? "🔒" : "🔓"}
+
+ {open && !locked && (
+
+ {kindName != null && (
+ {
+ onUnassign(item.ActivityID);
+ setOpen(false);
+ }}
+ >
+ Unclassified
+
+ )}
+ {assignableKinds.map((k) => (
+ {
+ onAssign(item.ActivityID, k.ID);
+ setOpen(false);
+ }}
+ >
+ {k.Name}
+
+ ))}
+
+ )}
+
+ );
+}
+
+// Builds the kind_id/unclassified query params for a filter pill value, so
+// filtering happens server-side -- a filtered view stays paginated (one page
+// of laps/samples/charts at a time) instead of needing the whole matching
+// backlog loaded and rendered up front just to check membership client-side.
+function filterParams(filter: string): { kindId?: number; unclassified?: boolean } {
+ if (filter === UNCLASSIFIED) return { unclassified: true };
+ if (filter !== "") return { kindId: Number(filter) };
+ return {};
+}
+
+function matchesFilter(item: ReviewQueueItem, filter: string): boolean {
+ if (filter === "") return true;
+ if (filter === UNCLASSIFIED) return item.WorkoutKindID == null;
+ return item.WorkoutKindID === Number(filter);
}
export function ReviewQueue() {
const [items, setItems] = useState
([]);
- const [total, setTotal] = useState(0);
+ const [grandTotal, setGrandTotal] = useState(0); // unfiltered count, for the "All" pill
const [nextCursor, setNextCursor] = useState(null);
const [initialLoading, setInitialLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
@@ -41,33 +173,45 @@ export function ReviewQueue() {
// scroll) instead of all at once -- see the paginated GET /api/review-queue/.
const allLoaded = !initialLoading && nextCursor === null;
// A single in-flight request is shared by every concurrent caller (the
- // scroll observer, a filter switch's loadAll, ...): each gets the same
- // promise back and genuinely awaits its completion, rather than a boolean
- // guard that would let a second caller's loop spin against an
- // already-in-progress fetch with nothing to await.
+ // scroll observer, a filter switch, ...): each gets the same promise back
+ // and genuinely awaits its completion, rather than a boolean guard that
+ // would let a second caller spin against an already-in-progress fetch with
+ // nothing to await.
const inFlightRef = useRef | null>(null);
// The authoritative cursor for control flow, updated synchronously the
// instant a response arrives -- NOT derived from the nextCursor state in
- // the render body. A ref written that way only picks up a new value once
- // React actually re-renders, which isn't guaranteed to happen between two
- // iterations of loadAll's tight loop; it was reading a stale cursor and
- // re-fetching the same page twice. nextCursor (state) still exists
- // separately, purely to drive rendering (e.g. allLoaded).
+ // the render body, since a ref written that way only picks up a new value
+ // once React actually re-renders.
const nextCursorRef = useRef(null);
+ // Bumped on every filter switch so a response from a since-superseded
+ // filter (e.g. the user clicked two pills in quick succession) is detected
+ // and discarded instead of clobbering the current filter's results.
+ const generationRef = useRef(0);
+ // loadMore reads the *current* filter via this ref rather than closing
+ // over filterKindId, so the memoized callback identity (and therefore the
+ // IntersectionObserver effect below) doesn't need to be recreated on every
+ // filter change.
+ const filterRef = useRef(filterKindId);
+ filterRef.current = filterKindId;
const loadMore = useCallback((): Promise => {
if (inFlightRef.current) return inFlightRef.current;
if (nextCursorRef.current === null) return Promise.resolve();
+ const gen = generationRef.current;
setLoadingMore(true);
const promise = (async () => {
try {
- const page = await api.reviewQueue({ limit: PAGE_SIZE, before: nextCursorRef.current ?? undefined });
+ const page = await api.reviewQueue({
+ limit: PAGE_SIZE,
+ before: nextCursorRef.current ?? undefined,
+ ...filterParams(filterRef.current),
+ });
+ if (gen !== generationRef.current) return; // a filter switch superseded this request
nextCursorRef.current = page.next_cursor;
setItems((prev) => [...prev, ...page.items]);
setNextCursor(page.next_cursor);
- setTotal(page.total);
} catch (e) {
- setError(String(e));
+ if (gen === generationRef.current) setError(String(e));
} finally {
setLoadingMore(false);
inFlightRef.current = null;
@@ -77,40 +221,46 @@ export function ReviewQueue() {
return promise;
}, []);
- // Loads every remaining page in sequence. Used when a specific-kind filter
- // is selected: filtering only the items loaded so far would hide matches
- // that simply haven't scrolled into view yet, so switching to a filter
- // (other than "All") loads the whole backlog once, up front.
- const loadAll = useCallback(async () => {
- while (nextCursorRef.current !== null) {
- await loadMore();
- }
- }, [loadMore]);
-
- function reload() {
+ // Resets the list and loads the first page for `filter`. Used both for the
+ // initial mount (filter "") and every filter-pill switch.
+ function loadFirstPage(filter: string) {
+ generationRef.current += 1;
+ const gen = generationRef.current;
+ inFlightRef.current = null; // release loadMore's guard for any stale in-flight request
+ nextCursorRef.current = null;
setItems([]);
setInitialLoading(true);
- Promise.all([api.reviewQueue({ limit: PAGE_SIZE }), api.listWorkoutKinds(), api.getProfile()])
- .then(([page, workoutKinds, userProfile]) => {
+ api
+ .reviewQueue({ limit: PAGE_SIZE, ...filterParams(filter) })
+ .then((page) => {
+ if (gen !== generationRef.current) return;
nextCursorRef.current = page.next_cursor;
setItems(page.items);
setNextCursor(page.next_cursor);
- setTotal(page.total);
- setKinds(workoutKinds);
- setProfile(userProfile);
+ if (filter === "") setGrandTotal(page.total);
})
- .catch((e) => setError(String(e)))
- .finally(() => setInitialLoading(false));
+ .catch((e) => {
+ if (gen === generationRef.current) setError(String(e));
+ })
+ .finally(() => {
+ if (gen === generationRef.current) setInitialLoading(false);
+ });
}
- useEffect(reload, []);
+ useEffect(() => {
+ loadFirstPage("");
+ api.listWorkoutKinds().then(setKinds).catch((e) => setError(String(e)));
+ api.getProfile().then(setProfile).catch((e) => setError(String(e)));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
// Infinite scroll: load the next page once the sentinel at the bottom of
- // the list becomes visible.
+ // the list becomes visible. Works the same whether a filter is active or
+ // not, since filtering now happens server-side.
const sentinelRef = useRef(null);
useEffect(() => {
const node = sentinelRef.current;
- if (!node || filterKindId !== "") return;
+ if (!node) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) loadMore();
@@ -119,14 +269,25 @@ export function ReviewQueue() {
);
observer.observe(node);
return () => observer.disconnect();
- }, [loadMore, filterKindId, items.length]);
+ }, [loadMore, items.length]);
async function resolve(activityId: number, kindId: number) {
setResolvingId(activityId);
try {
await api.resolveReview(activityId, kindId);
- setItems((prev) => prev.filter((i) => i.ActivityID !== activityId));
- setTotal((prev) => Math.max(0, prev - 1));
+ setItems((prev) =>
+ prev
+ .map((i) =>
+ i.ActivityID === activityId
+ ? { ...i, WorkoutKindID: kindId, AssignmentSource: "manual" as const, Status: "assigned" as const }
+ : i,
+ )
+ // If a filter (kind or Unclassified) is active and this reassignment
+ // moved the activity out of it, drop it from the visible list --
+ // otherwise e.g. reassigning an item away from "Easy" while filtered
+ // to Easy would leave it sitting there under the wrong filter.
+ .filter((i) => matchesFilter(i, filterKindId)),
+ );
} catch (e) {
setError(String(e));
} finally {
@@ -134,45 +295,69 @@ export function ReviewQueue() {
}
}
- const filteredItems = useMemo(() => {
- if (filterKindId === "") return items;
- if (filterKindId === UNSORTED) {
- return items.filter((item) => candidates(item).length === 0);
+ async function unassign(activityId: number) {
+ setResolvingId(activityId);
+ try {
+ await api.unassignReview(activityId);
+ setItems((prev) =>
+ prev
+ .map((i) =>
+ i.ActivityID === activityId
+ ? { ...i, WorkoutKindID: null, AssignmentSource: "manual" as const, Status: "needs_review" as const }
+ : i,
+ )
+ // Same as resolve() -- an active kind filter no longer matches an
+ // activity just cleared back to Unclassified, so drop it.
+ .filter((i) => matchesFilter(i, filterKindId)),
+ );
+ } catch (e) {
+ setError(String(e));
+ } finally {
+ setResolvingId(null);
}
- const id = Number(filterKindId);
- return items.filter((item) => candidates(item).some((c) => c.workout_kind_id === id));
- }, [items, filterKindId]);
+ }
+
+ async function unlock(activityId: number) {
+ setResolvingId(activityId);
+ try {
+ await api.unlockReview(activityId);
+ setItems((prev) =>
+ prev
+ .map((i) => (i.ActivityID === activityId ? { ...i, AssignmentSource: "rule_engine" as const } : i))
+ .filter((i) => matchesFilter(i, filterKindId)),
+ );
+ } catch (e) {
+ setError(String(e));
+ } finally {
+ setResolvingId(null);
+ }
+ }
function toggleFilter(id: string) {
const next = filterKindId === id ? "" : id;
setFilterKindId(next);
- if (next !== "") loadAll();
+ loadFirstPage(next);
}
- // Race is assigned automatically from Garmin metadata (eventType.typeKey
- // == "race"), never by hand -- not offered as a manual-assign option.
- const manuallyAssignableKinds = kinds.filter((k) => k.Name !== "Race");
-
return (
-
Review Queue
{error &&
{error}
}
- {total > 0 && (
+ {grandTotal > 0 && (
toggleFilter("")}
>
- All ({total})
+ All ({grandTotal})
toggleFilter(UNSORTED)}
+ className={`filter-pill${filterKindId === UNCLASSIFIED ? " active" : ""}`}
+ onClick={() => toggleFilter(UNCLASSIFIED)}
>
- Unsorted (no candidates)
+ Unclassified
{kinds.map((k) => (
Loading…
- ) : total === 0 ? (
- Nothing needs review right now.
- ) : filterKindId !== "" && !allLoaded ? (
- Loading the rest of the queue to filter accurately…
- ) : filteredItems.length === 0 ? (
- No runs match this filter.
+ ) : items.length === 0 ? (
+
+ {filterKindId === "" ? "No activities synced yet." : "No runs match this filter."}
+
) : (
- {filteredItems.map((item) => {
+ {items.map((item) => {
const scored = candidates(item);
const pace = formatPace(item.activity.AvgSpeedMps);
+ const { date, time } = formatActivityDateTime(item.activity.StartTimeUTC);
return (
-
{item.activity.ActivityName || item.activity.ActivityType}
-
{item.activity.StartTimeUTC}
+
+ {item.activity.ActivityName || item.activity.ActivityType}
+
+
+
+ {date}
+ {time}
+
- {(item.activity.DistanceMeters / 1000).toFixed(2)} km
- {Math.round(item.activity.DurationSeconds / 60)} min
- {pace && {pace} }
- {item.activity.AvgHR != null && {Math.round(item.activity.AvgHR)} bpm avg }
+ 📏 {(item.activity.DistanceMeters / 1000).toFixed(2)} km
+ ⏱️ {Math.round(item.activity.DurationSeconds / 60)} min
+ {pace && ⚡ {pace} }
+ {item.activity.AvgHR != null && ❤️ {Math.round(item.activity.AvgHR)} bpm avg }
{scored.length > 0 && (
@@ -224,20 +421,17 @@ export function ReviewQueue() {
samples={item.samples}
minRepresentativePaceSecPerKm={profile?.MinRepresentativePaceSecPerKm ?? 0}
minRepresentativeTimeSeconds={profile?.MinRepresentativeTimeSeconds ?? 0}
+ paceColor={profile?.PaceColor ?? "#3b82f6"}
+ heartRateColor={profile?.HeartRateColor ?? "#ef4444"}
+ warmupColor={profile?.WarmupColor ?? "#c2410c"}
+ effortColor={profile?.EffortColor ?? "#7c3aed"}
+ recoveryColor={profile?.RecoveryColor ?? "#15803d"}
+ cooldownColor={profile?.CooldownColor ?? "#fb923c"}
+ mainLineTintPct={profile?.MainLineTintPct ?? 20}
+ backgroundDarkenPct={profile?.BackgroundDarkenPct ?? 35}
+ targetBrightenPct={profile?.TargetBrightenPct ?? 20}
/>
-
- {manuallyAssignableKinds.map((k) => (
- resolve(item.ActivityID, k.ID)}
- >
- {k.Name}
-
- ))}
-
-
setRawDataItem(item)}>
Raw data
@@ -249,7 +443,7 @@ export function ReviewQueue() {
)}
- {filterKindId === "" && !allLoaded && (
+ {!allLoaded && (
{loadingMore &&
Loading more…
}
diff --git a/frontend/src/pages/WorkoutKinds.tsx b/frontend/src/pages/WorkoutKinds.tsx
deleted file mode 100644
index aa9d0ec..0000000
--- a/frontend/src/pages/WorkoutKinds.tsx
+++ /dev/null
@@ -1,198 +0,0 @@
-import { useEffect, useState } from "react";
-import { api } from "../api/client";
-import type { WorkoutKind } from "../types/api";
-
-const EXAMPLE_RULE = `{
- "match": "all",
- "conditions": [
- { "metric": "avg_pace_sec_per_km", "op": "between", "value": [330, 420] },
- { "metric": "avg_hr_pct_max", "op": "<=", "value": 0.75 }
- ]
-}`;
-
-// Pace is entered/displayed as "m:ss" but sent to the API as whole seconds.
-function parsePace(text: string): number | null {
- const trimmed = text.trim();
- if (trimmed === "") return null;
- const match = /^(\d+):([0-5]?\d)$/.exec(trimmed);
- if (!match) return null;
- return Number(match[1]) * 60 + Number(match[2]);
-}
-
-function formatPace(seconds: number | null): string {
- if (seconds == null) return "";
- const m = Math.floor(seconds / 60);
- const s = Math.round(seconds % 60);
- return `${m}:${s.toString().padStart(2, "0")}`;
-}
-
-export function WorkoutKinds() {
- const [kinds, setKinds] = useState([]);
- const [editingId, setEditingId] = useState(null);
- const [name, setName] = useState("");
- const [description, setDescription] = useState("");
- const [ruleText, setRuleText] = useState(EXAMPLE_RULE);
- const [paceMinText, setPaceMinText] = useState("");
- const [paceMaxText, setPaceMaxText] = useState("");
- const [expectedZone, setExpectedZone] = useState(null);
- const [error, setError] = useState(null);
- const [reclassifying, setReclassifying] = useState(false);
-
- function reload() {
- api.listWorkoutKinds(true).then(setKinds).catch((e) => setError(String(e)));
- }
-
- useEffect(reload, []);
-
- function startEdit(k: WorkoutKind) {
- setEditingId(k.ID);
- setName(k.Name);
- setDescription(k.Description);
- setRuleText(JSON.stringify(JSON.parse(k.RuleJSON || "{}"), null, 2));
- setPaceMinText(formatPace(k.pace_min_sec_per_km));
- setPaceMaxText(formatPace(k.pace_max_sec_per_km));
- setExpectedZone(k.expected_hr_zone);
- setError(null);
- }
-
- async function save() {
- if (editingId === null) return;
-
- let rule: unknown;
- try {
- rule = JSON.parse(ruleText);
- } catch {
- setError("Rule is not valid JSON");
- return;
- }
-
- const paceMin = parsePace(paceMinText);
- const paceMax = parsePace(paceMaxText);
- if (paceMinText.trim() !== "" && paceMin === null) {
- setError("Min pace must look like m:ss, e.g. 4:30");
- return;
- }
- if (paceMaxText.trim() !== "" && paceMax === null) {
- setError("Max pace must look like m:ss, e.g. 4:30");
- return;
- }
-
- try {
- await api.updateWorkoutKind(editingId, {
- name,
- description,
- rule,
- pace_min_sec_per_km: paceMin,
- pace_max_sec_per_km: paceMax,
- expected_hr_zone: expectedZone,
- });
- setEditingId(null);
- reload();
- } catch (e) {
- setError(String(e));
- }
- }
-
- async function reclassifyAll() {
- setReclassifying(true);
- try {
- const res = await api.reclassifyAll();
- setError(`Reclassified ${res.reclassified} activities.`);
- } catch (e) {
- setError(String(e));
- } finally {
- setReclassifying(false);
- }
- }
-
- return (
-
-
Workout Kinds
- {error &&
{error}
}
-
-
-
- {reclassifying ? "Reclassifying…" : "Reclassify all"}
-
-
-
- Re-runs the rule engine on every activity, except manual assignments (the user's definitive word) and Race
- assignments (a fact from Garmin, not a retunable rule).
-
-
-
-
-
- Name
- Description
- Pace range
- HR zone
-
-
-
-
- {kinds.map((k) => (
-
- {k.Name}
- {k.Description}
-
- {k.pace_min_sec_per_km != null && k.pace_max_sec_per_km != null
- ? `${formatPace(k.pace_min_sec_per_km)}–${formatPace(k.pace_max_sec_per_km)}/km`
- : "—"}
-
- {k.expected_hr_zone ?? "—"}
-
- startEdit(k)}>Edit
-
-
- ))}
-
-
-
- {editingId !== null && (
-
- )}
-
- );
-}
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index 13a45f3..23cddb0 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -5,33 +5,33 @@
export interface Activity {
ID: number;
GarminActivityID: number;
+ // ActivityName/ActivityType aren't stored columns -- the backend decodes
+ // them from RawJSON at response time (see internal/api/display_fields.go).
ActivityName: string;
ActivityType: string;
EventTypeKey: string;
WorkoutID: number | null;
StartTimeUTC: string;
- BeginTimestampMs: number;
DurationSeconds: number;
DistanceMeters: number;
AvgHR: number | null;
MaxHR: number | null;
AvgSpeedMps: number | null;
- MaxSpeedMps: number | null;
ElevationGainM: number | null;
- ElevationLossM: number | null;
- Calories: number | null;
- LapCount: number;
AerobicTrainingEffect: number | null;
AnaerobicTrainingEffect: number | null;
- TrainingEffectLabel: string;
VO2MaxValue: number | null;
// Raw JSON strings from Garmin, kept for fields not modeled above. Only
- // needed by the Review Queue's raw-data viewer, so left as strings here
+ // needed by the Activities page's raw-data viewer, so left as strings here
// rather than typed -- the viewer parses them for display.
RawJSON: string;
DetailsFetchedAt: string | null;
DetailsRawJSON: string | null;
SplitsFetchedAt: string | null;
+ // Genuine raw get_workout_by_id() response for this activity's structured
+ // workout. Null when the activity has no WorkoutID, or was synced before
+ // this column existed.
+ WorkoutRawJSON: string | null;
CreatedAt: string;
UpdatedAt: string;
}
@@ -40,15 +40,11 @@ export interface Lap {
ID: number;
ActivityID: number;
LapIndex: number;
- StartTimeUTC: string;
+ // DurationSeconds/AvgHR aren't stored columns -- the backend decodes them
+ // from RawJSON at response time (see internal/api/display_fields.go).
DurationSeconds: number;
- DistanceMeters: number;
AvgHR: number | null;
- MaxHR: number | null;
AvgSpeedMps: number | null;
- MaxSpeedMps: number | null;
- ElevationGainM: number | null;
- ElevationLossM: number | null;
IntensityType: string;
HRDriftBpmPerMin: number | null;
HRRecoveryBpmPerMin: number | null;
@@ -91,10 +87,12 @@ export interface WorkoutKind {
UpdatedAt: string;
pace_min_sec_per_km: number | null;
pace_max_sec_per_km: number | null;
- expected_hr_zone: number | null;
+ hr_min_pct_hrr: number | null;
+ hr_max_pct_hrr: number | null;
}
export interface Profile {
+ Name: string;
GarminEmail: string;
GarminPassword: string;
RollingWindowDays: number;
@@ -115,6 +113,25 @@ export interface Profile {
CooldownMinutes: number;
MinRepresentativePaceSecPerKm: number;
MinRepresentativeTimeSeconds: number;
+ // Chart colors: PaceColor/HeartRateColor are each chart's "main line"
+ // color; Warmup/Effort/Recovery/CooldownColor are the "effort kind"
+ // colors used to derive the under-the-line fill and phase background
+ // (see ExpectedVsActualChart).
+ PaceColor: string;
+ HeartRateColor: string;
+ WarmupColor: string;
+ EffortColor: string;
+ RecoveryColor: string;
+ CooldownColor: string;
+ // How strongly (0-100) the main line color mixes into the effort-kind
+ // fill under the line. Never affects the phase background above the line.
+ MainLineTintPct: number;
+ // How strongly (0-100) the effort-kind color is darkened for the phase
+ // background above the line. Never mixed with the main line color.
+ BackgroundDarkenPct: number;
+ // How strongly (0-100) a chart's main line color is brightened when that
+ // chart has a structured-workout target range to show.
+ TargetBrightenPct: number;
CreatedAt: string;
UpdatedAt: string;
}
@@ -181,4 +198,4 @@ export interface SyncStatus {
last_run?: SyncRun;
}
-export type ProgressionMetric = "pace" | "hr" | "vo2max" | "aerobic_te" | "anaerobic_te";
+export type ProgressionMetric = "pace" | "hr" | "vo2max" | "aerobic_te" | "anaerobic_te" | "efficiency_factor";