Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields (activity name/type, lap duration/HR, structured workout raw JSON) from RawJSON at API-response time instead of storing redundant columns - Add a fully configurable chart color system (pace/HR main-line colors, 4 effort-kind colors, tint/darken/brighten intensity knobs) under Profile > Chart colors - Rename training types and fix their display order (Easy, Long, 60'/30' Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed - Add an Efficiency Factor progression metric; fix Progression chart axes to use tight non-zero-based domains, m:ss/km pace formatting, and rounded ticks instead of raw floating-point labels - Expose the raw get_workout_by_id() payload in the raw-data viewer alongside activity/lap/detail JSON; enlarge the modal and shrink array indentation for readability - Fix "last sync" reporting a meaningless activity count: record one combined sync run per manual "Sync now" and count genuinely new activities instead of re-listing whatever Garmin returned for the queried window - Let a Review Queue activity be manually cleared back to Unclassified, and make "Reset all" available even while disconnected from Garmin Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
75
backend/internal/api/display_fields.go
Normal file
75
backend/internal/api/display_fields.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
3
backend/internal/store/migrations/0011_profile_name.sql
Normal file
3
backend/internal/store/migrations/0011_profile_name.sql
Normal file
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
10
backend/internal/store/migrations/0014_chart_colors.sql
Normal file
10
backend/internal/store/migrations/0014_chart_colors.sql
Normal file
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user