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) => ( ))} + - -
- -
+
{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 ( + + ); +} 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" && ( + + )} - {status !== "authenticated" && status !== "mfa_required" && ( - + + + )} + + {/* 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. */} + - )} +
- {status === "authenticated" && ( - <> - - - {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 ( + + ); +} 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 ( + + ); +} 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 ( +
+ + {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 - +
+ + + +
+
+
+
-
{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 ? ( + <> + +