Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes

- Remove duplicated Garmin fields from storage; decode display-only fields
  (activity name/type, lap duration/HR, structured workout raw JSON) from
  RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
  effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
  Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
  Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
  use tight non-zero-based domains, m:ss/km pace formatting, and rounded
  ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
  alongside activity/lap/detail JSON; enlarge the modal and shrink array
  indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
  combined sync run per manual "Sync now" and count genuinely new
  activities instead of re-listing whatever Garmin returned for the queried
  window
- Let a Review Queue activity be manually cleared back to Unclassified, and
  make "Reset all" available even while disconnected from Garmin

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 06:33:47 +02:00
parent 9818d35910
commit 518bccf5ab
56 changed files with 2334 additions and 890 deletions

View File

@@ -13,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
}

View File

@@ -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)
}

View 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
}

View File

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

View File

@@ -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
}

View File

@@ -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"})
}

View File

@@ -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)

View File

@@ -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")