Compare commits
10 Commits
a6efcec33b
...
518bccf5ab
| Author | SHA1 | Date | |
|---|---|---|---|
| 518bccf5ab | |||
| 9818d35910 | |||
| 513418123f | |||
| 619277b0ee | |||
| 1fb00f5bc6 | |||
| 2c2d4966b2 | |||
| 82f08c0c1e | |||
| db4c76b558 | |||
| 9ec721b9fc | |||
| 02ee9fa76e |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -20,3 +20,6 @@ frontend/dist/
|
|||||||
backend/mcpspike
|
backend/mcpspike
|
||||||
backend/cmd/mcpspike/mcpspike
|
backend/cmd/mcpspike/mcpspike
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
|
# Claude Code session-local runtime state
|
||||||
|
.claude/*.lock
|
||||||
|
|||||||
6
backend/.idea/vcs.xml
generated
Normal file
6
backend/.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -152,17 +152,17 @@ func seedActivity(ctx context.Context, db *store.DB, p seedParams) int64 {
|
|||||||
anaerobic := p.anaerobicTE
|
anaerobic := p.anaerobicTE
|
||||||
id, err := db.UpsertActivity(ctx, store.Activity{
|
id, err := db.UpsertActivity(ctx, store.Activity{
|
||||||
GarminActivityID: p.garminID,
|
GarminActivityID: p.garminID,
|
||||||
ActivityName: p.name,
|
|
||||||
ActivityType: "running",
|
|
||||||
StartTimeUTC: p.start.Format("2006-01-02 15:04:05"),
|
StartTimeUTC: p.start.Format("2006-01-02 15:04:05"),
|
||||||
BeginTimestampMs: p.start.UnixMilli(),
|
|
||||||
DurationSeconds: p.duration,
|
DurationSeconds: p.duration,
|
||||||
DistanceMeters: p.distance,
|
DistanceMeters: p.distance,
|
||||||
AvgSpeedMps: &speed,
|
AvgSpeedMps: &speed,
|
||||||
AvgHR: &hr,
|
AvgHR: &hr,
|
||||||
AerobicTrainingEffect: &aerobic,
|
AerobicTrainingEffect: &aerobic,
|
||||||
AnaerobicTrainingEffect: &anaerobic,
|
AnaerobicTrainingEffect: &anaerobic,
|
||||||
RawJSON: "{}",
|
// ActivityName/ActivityType aren't stored columns anymore -- the API
|
||||||
|
// decodes them from RawJSON, so seeded activities need them here to
|
||||||
|
// still show a sensible name/type in the dev UI.
|
||||||
|
RawJSON: fmt.Sprintf(`{"activityName":%q,"activityType":{"typeKey":"running"}}`, p.name),
|
||||||
})
|
})
|
||||||
must(err)
|
must(err)
|
||||||
return id
|
return id
|
||||||
@@ -213,9 +213,14 @@ func seedIntervalLapsAndSamples(ctx context.Context, db *store.DB, activityID in
|
|||||||
}
|
}
|
||||||
|
|
||||||
laps = append(laps, store.Lap{
|
laps = append(laps, store.Lap{
|
||||||
LapIndex: i + 1, DurationSeconds: lapDuration,
|
LapIndex: i + 1,
|
||||||
DistanceMeters: 400, IntensityType: intensity, RawJSON: "{}",
|
IntensityType: intensity,
|
||||||
HRDriftBpmPerMin: drift, HRRecoveryBpmPerMin: recovery,
|
// DurationSeconds/AvgHR aren't stored columns anymore -- the API
|
||||||
|
// decodes them from RawJSON, so seeded laps need them here to
|
||||||
|
// still show a sensible duration/HR on the dev chart.
|
||||||
|
RawJSON: fmt.Sprintf(`{"duration":%v,"distance":400,"averageHR":%v}`, lapDuration, baseHR),
|
||||||
|
HRDriftBpmPerMin: drift,
|
||||||
|
HRRecoveryBpmPerMin: recovery,
|
||||||
})
|
})
|
||||||
elapsed += lapDuration
|
elapsed += lapDuration
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
// current classification so the frontend can show what kind it's assigned
|
// current classification so the frontend can show what kind it's assigned
|
||||||
// to and whether that assignment is locked (see Locked's doc comment).
|
// to and whether that assignment is locked (see Locked's doc comment).
|
||||||
type activityListItem struct {
|
type activityListItem struct {
|
||||||
store.Activity
|
activityResponse
|
||||||
WorkoutKindID *int64 `json:"workout_kind_id"`
|
WorkoutKindID *int64 `json:"workout_kind_id"`
|
||||||
WorkoutKindName *string `json:"workout_kind_name"`
|
WorkoutKindName *string `json:"workout_kind_name"`
|
||||||
AssignmentSource *string `json:"assignment_source"`
|
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))
|
resp := make([]activityListItem, 0, len(activities))
|
||||||
for _, a := range activities {
|
for _, a := range activities {
|
||||||
item := activityListItem{Activity: a}
|
item := activityListItem{activityResponse: toActivityResponse(a)}
|
||||||
assignment, ok, err := s.DB.CurrentAssignment(r.Context(), a.ID)
|
assignment, ok, err := s.DB.CurrentAssignment(r.Context(), a.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
@@ -107,7 +107,7 @@ func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := map[string]any{"activity": activity, "laps": laps}
|
resp := map[string]any{"activity": toActivityResponse(activity), "laps": toLapResponses(laps)}
|
||||||
if hasAssignment {
|
if hasAssignment {
|
||||||
resp["assignment"] = assignment
|
resp["assignment"] = assignment
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -72,7 +74,7 @@ func TestWorkoutKindList_ReturnsEightSeededTypesWithPaceFields(t *testing.T) {
|
|||||||
t.Fatalf("expected 8 seeded kinds, got %d", len(kinds))
|
t.Fatalf("expected 8 seeded kinds, got %d", len(kinds))
|
||||||
}
|
}
|
||||||
for _, k := range 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)
|
t.Errorf("expected freshly-seeded kind %q to have nil pace fields, got %+v", k.Name, k)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,13 +89,14 @@ func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) {
|
|||||||
json.Unmarshal(rec.Body.Bytes(), &kinds)
|
json.Unmarshal(rec.Body.Bytes(), &kinds)
|
||||||
target := kinds[0]
|
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{
|
body := map[string]any{
|
||||||
"name": target.Name,
|
"name": target.Name,
|
||||||
"rule": json.RawMessage(`{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[270,300]}]}`),
|
"rule": json.RawMessage(`{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[270,300]}]}`),
|
||||||
"pace_min_sec_per_km": minPace,
|
"pace_min_sec_per_km": minPace,
|
||||||
"pace_max_sec_per_km": maxPace,
|
"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)
|
rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(target.ID), body)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
@@ -106,8 +109,11 @@ func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) {
|
|||||||
if updated.PaceMinSecPerKm == nil || *updated.PaceMinSecPerKm != 330 {
|
if updated.PaceMinSecPerKm == nil || *updated.PaceMinSecPerKm != 330 {
|
||||||
t.Errorf("PaceMinSecPerKm = %v, want 330", updated.PaceMinSecPerKm)
|
t.Errorf("PaceMinSecPerKm = %v, want 330", updated.PaceMinSecPerKm)
|
||||||
}
|
}
|
||||||
if updated.ExpectedHRZone == nil || *updated.ExpectedHRZone != 2 {
|
if updated.HRMinPctHRR == nil || *updated.HRMinPctHRR != 70 {
|
||||||
t.Errorf("ExpectedHRZone = %v, want 2", updated.ExpectedHRZone)
|
t.Errorf("HRMinPctHRR = %v, want 70", updated.HRMinPctHRR)
|
||||||
|
}
|
||||||
|
if updated.HRMaxPctHRR == nil || *updated.HRMaxPctHRR != 80 {
|
||||||
|
t.Errorf("HRMaxPctHRR = %v, want 80", updated.HRMaxPctHRR)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +134,7 @@ func TestWorkoutKindUpdate_RejectsInvalidRule(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWorkoutKindUpdate_RejectsInvalidHRZone(t *testing.T) {
|
func TestWorkoutKindUpdate_RejectsInvertedHRRange(t *testing.T) {
|
||||||
s, _ := newTestServer(t)
|
s, _ := newTestServer(t)
|
||||||
router := s.Router()
|
router := s.Router()
|
||||||
|
|
||||||
@@ -139,7 +145,27 @@ func TestWorkoutKindUpdate_RejectsInvalidHRZone(t *testing.T) {
|
|||||||
rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(kinds[0].ID), map[string]any{
|
rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(kinds[0].ID), map[string]any{
|
||||||
"name": kinds[0].Name,
|
"name": kinds[0].Name,
|
||||||
"rule": json.RawMessage(`{"match":"all","conditions":[]}`),
|
"rule": json.RawMessage(`{"match":"all","conditions":[]}`),
|
||||||
"expected_hr_zone": 9,
|
"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 {
|
if rec.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
||||||
@@ -150,11 +176,11 @@ func TestReviewQueueResolve(t *testing.T) {
|
|||||||
s, db := newTestServer(t)
|
s, db := newTestServer(t)
|
||||||
ctx := newCtx()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("UpsertActivity: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("CreateWorkoutKind: %v", err)
|
t.Fatalf("CreateWorkoutKind: %v", err)
|
||||||
}
|
}
|
||||||
@@ -182,16 +208,20 @@ func TestReviewQueueResolve(t *testing.T) {
|
|||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("review queue status = %d", rec.Code)
|
t.Fatalf("review queue status = %d", rec.Code)
|
||||||
}
|
}
|
||||||
var queue []map[string]any
|
var page struct {
|
||||||
json.Unmarshal(rec.Body.Bytes(), &queue)
|
Items []map[string]any `json:"items"`
|
||||||
if len(queue) != 1 {
|
NextCursor *string `json:"next_cursor"`
|
||||||
t.Fatalf("expected 1 item in review queue, got %d: %s", len(queue), rec.Body.String())
|
Total int `json:"total"`
|
||||||
}
|
}
|
||||||
laps, _ := queue[0]["laps"].([]any)
|
json.Unmarshal(rec.Body.Bytes(), &page)
|
||||||
|
if len(page.Items) != 1 || page.Total != 1 {
|
||||||
|
t.Fatalf("expected 1 item in review queue (total=1), got %d items, total=%d: %s", len(page.Items), page.Total, rec.Body.String())
|
||||||
|
}
|
||||||
|
laps, _ := page.Items[0]["laps"].([]any)
|
||||||
if len(laps) != 1 {
|
if len(laps) != 1 {
|
||||||
t.Fatalf("expected 1 lap in review queue item, got %d: %s", len(laps), rec.Body.String())
|
t.Fatalf("expected 1 lap in review queue item, got %d: %s", len(laps), rec.Body.String())
|
||||||
}
|
}
|
||||||
samples, _ := queue[0]["samples"].([]any)
|
samples, _ := page.Items[0]["samples"].([]any)
|
||||||
if len(samples) != 1 {
|
if len(samples) != 1 {
|
||||||
t.Fatalf("expected 1 sample in review queue item, got %d: %s", len(samples), rec.Body.String())
|
t.Fatalf("expected 1 sample in review queue item, got %d: %s", len(samples), rec.Body.String())
|
||||||
}
|
}
|
||||||
@@ -201,10 +231,238 @@ func TestReviewQueueResolve(t *testing.T) {
|
|||||||
t.Fatalf("resolve status = %d, body = %s", rec.Code, rec.Body.String())
|
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)
|
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
||||||
json.Unmarshal(rec.Body.Bytes(), &queue)
|
json.Unmarshal(rec.Body.Bytes(), &page)
|
||||||
if len(queue) != 0 {
|
if len(page.Items) != 1 || page.Total != 1 {
|
||||||
t.Fatalf("expected empty review queue after resolve, got %d", len(queue))
|
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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReviewQueue_PaginatesByCursor(t *testing.T) {
|
||||||
|
s, db := newTestServer(t)
|
||||||
|
ctx := newCtx()
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
StartTimeUTC: fmt.Sprintf("2026-07-0%d 06:00:00", i), RawJSON: "{}",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
|
||||||
|
ActivityID: activityID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview,
|
||||||
|
CandidateKindsJSON: "[]",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("InsertKindAssignment: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
startTime := func(item map[string]any) string {
|
||||||
|
return item["activity"].(map[string]any)["StartTimeUTC"].(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
first := getPage("?limit=2")
|
||||||
|
if len(first.Items) != 2 || first.Total != 5 {
|
||||||
|
t.Fatalf("first page: expected 2 items (total=5), got %d items, total=%d", len(first.Items), first.Total)
|
||||||
|
}
|
||||||
|
if startTime(first.Items[0]) != "2026-07-05 06:00:00" || startTime(first.Items[1]) != "2026-07-04 06:00:00" {
|
||||||
|
t.Fatalf("first page not newest-first: %v", first.Items)
|
||||||
|
}
|
||||||
|
if first.NextCursor == nil {
|
||||||
|
t.Fatal("expected a next_cursor on the first page")
|
||||||
|
}
|
||||||
|
|
||||||
|
second := getPage("?limit=2&before=" + url.QueryEscape(*first.NextCursor))
|
||||||
|
if len(second.Items) != 2 {
|
||||||
|
t.Fatalf("second page: expected 2 items, got %d", len(second.Items))
|
||||||
|
}
|
||||||
|
if startTime(second.Items[0]) != "2026-07-03 06:00:00" || startTime(second.Items[1]) != "2026-07-02 06:00:00" {
|
||||||
|
t.Fatalf("second page not continuing newest-first: %v", second.Items)
|
||||||
|
}
|
||||||
|
if second.NextCursor == nil {
|
||||||
|
t.Fatal("expected a next_cursor on the second page")
|
||||||
|
}
|
||||||
|
|
||||||
|
third := getPage("?limit=2&before=" + url.QueryEscape(*second.NextCursor))
|
||||||
|
if len(third.Items) != 1 {
|
||||||
|
t.Fatalf("third page: expected 1 remaining item, got %d", len(third.Items))
|
||||||
|
}
|
||||||
|
if startTime(third.Items[0]) != "2026-07-01 06:00:00" {
|
||||||
|
t.Fatalf("third page wrong item: %v", third.Items)
|
||||||
|
}
|
||||||
|
if third.NextCursor != nil {
|
||||||
|
t.Fatalf("expected no next_cursor on the last page, got %v", *third.NextCursor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +470,7 @@ func TestResolveReview_RejectsRaceKind(t *testing.T) {
|
|||||||
s, db := newTestServer(t)
|
s, db := newTestServer(t)
|
||||||
ctx := newCtx()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("UpsertActivity: %v", err)
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
}
|
}
|
||||||
@@ -240,9 +498,9 @@ func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
|
|||||||
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
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: "{}"})
|
ruleEngineActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, 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: "{}"})
|
manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, 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: "{}"})
|
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{
|
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
|
||||||
ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine,
|
ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine,
|
||||||
@@ -307,9 +565,9 @@ func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) {
|
|||||||
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
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: "{}"})
|
ruleEngineActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, 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: "{}"})
|
manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, 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: "{}"})
|
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: 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: "[]"})
|
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
||||||
@@ -349,7 +607,7 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
|||||||
s, db := newTestServer(t)
|
s, db := newTestServer(t)
|
||||||
ctx := newCtx()
|
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)
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,11 +643,11 @@ func TestProgression_ReturnsSortedTimeSeries(t *testing.T) {
|
|||||||
s, db := newTestServer(t)
|
s, db := newTestServer(t)
|
||||||
ctx := newCtx()
|
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
|
speed := 3.0
|
||||||
a1, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-01 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, ActivityType: "running", StartTimeUTC: "2026-07-05 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
|
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: "[]"})
|
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: id, WorkoutKindID: &kindID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
||||||
@@ -411,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 {
|
func itoa(v int64) string {
|
||||||
return strconv.FormatInt(v, 10)
|
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
|
// workoutKindResponse combines a workout kind's rule/metadata with its pace
|
||||||
// range and expected HR zone (stored separately in workout_type_paces),
|
// and HR range (stored separately in workout_type_paces), since the frontend
|
||||||
// since the frontend always edits and displays them together.
|
// always edits and displays them together.
|
||||||
type workoutKindResponse struct {
|
type workoutKindResponse struct {
|
||||||
store.WorkoutKind
|
store.WorkoutKind
|
||||||
PaceMinSecPerKm *float64 `json:"pace_min_sec_per_km"`
|
PaceMinSecPerKm *float64 `json:"pace_min_sec_per_km"`
|
||||||
PaceMaxSecPerKm *float64 `json:"pace_max_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) {
|
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,
|
WorkoutKind: k,
|
||||||
PaceMinSecPerKm: pace.PaceMinSecPerKm,
|
PaceMinSecPerKm: pace.PaceMinSecPerKm,
|
||||||
PaceMaxSecPerKm: pace.PaceMaxSecPerKm,
|
PaceMaxSecPerKm: pace.PaceMaxSecPerKm,
|
||||||
ExpectedHRZone: pace.ExpectedHRZone,
|
HRMinPctHRR: pace.HRMinPctHRR,
|
||||||
|
HRMaxPctHRR: pace.HRMaxPctHRR,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +46,8 @@ type workoutKindRequest struct {
|
|||||||
IsActive *bool `json:"is_active"`
|
IsActive *bool `json:"is_active"`
|
||||||
PaceMinSecPerKm *float64 `json:"pace_min_sec_per_km"`
|
PaceMinSecPerKm *float64 `json:"pace_min_sec_per_km"`
|
||||||
PaceMaxSecPerKm *float64 `json:"pace_max_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) {
|
func (req workoutKindRequest) validate() (classify.Node, error) {
|
||||||
@@ -58,8 +61,11 @@ func (req workoutKindRequest) validate() (classify.Node, error) {
|
|||||||
if err := node.Validate(); err != nil {
|
if err := node.Validate(); err != nil {
|
||||||
return node, errors.New("invalid rule: " + err.Error())
|
return node, errors.New("invalid rule: " + err.Error())
|
||||||
}
|
}
|
||||||
if req.ExpectedHRZone != nil && (*req.ExpectedHRZone < 1 || *req.ExpectedHRZone > 5) {
|
if req.PaceMinSecPerKm != nil && req.PaceMaxSecPerKm != nil && *req.PaceMinSecPerKm >= *req.PaceMaxSecPerKm {
|
||||||
return node, errors.New("expected_hr_zone must be between 1 and 5")
|
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
|
return node, nil
|
||||||
}
|
}
|
||||||
@@ -148,7 +154,8 @@ func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request)
|
|||||||
WorkoutKindID: id,
|
WorkoutKindID: id,
|
||||||
PaceMinSecPerKm: req.PaceMinSecPerKm,
|
PaceMinSecPerKm: req.PaceMinSecPerKm,
|
||||||
PaceMaxSecPerKm: req.PaceMaxSecPerKm,
|
PaceMaxSecPerKm: req.PaceMaxSecPerKm,
|
||||||
ExpectedHRZone: req.ExpectedHRZone,
|
HRMinPctHRR: req.HRMinPctHRR,
|
||||||
|
HRMaxPctHRR: req.HRMaxPctHRR,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -43,6 +43,16 @@ func metricValue(metric string, a store.Activity) (float64, bool) {
|
|||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
return *a.AnaerobicTrainingEffect, true
|
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:
|
default:
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,21 +11,64 @@ import (
|
|||||||
"smartrun/backend/internal/store"
|
"smartrun/backend/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// defaultReviewQueuePageSize matches the frontend's initial/incremental
|
||||||
|
// page size for its infinite-scroll list.
|
||||||
|
const defaultReviewQueuePageSize = 10
|
||||||
|
|
||||||
|
type reviewQueueItem struct {
|
||||||
|
store.KindAssignment
|
||||||
|
Activity activityResponse `json:"activity"`
|
||||||
|
Laps []lapResponse `json:"laps"`
|
||||||
|
Samples []store.Sample `json:"samples"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
||||||
queue, err := s.DB.ReviewQueue(r.Context())
|
limit := defaultReviewQueuePageSize
|
||||||
|
if v := r.URL.Query().Get("limit"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor := r.URL.Query().Get("before") // activity StartTimeUTC of the last item on the previous page
|
||||||
|
|
||||||
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
type item struct {
|
type withActivity struct {
|
||||||
store.KindAssignment
|
assignment store.KindAssignment
|
||||||
Activity store.Activity `json:"activity"`
|
activity store.Activity
|
||||||
Laps []store.Lap `json:"laps"`
|
|
||||||
Samples []store.Sample `json:"samples"`
|
|
||||||
}
|
}
|
||||||
items := make([]item, 0, len(queue))
|
all := make([]withActivity, 0, len(queue))
|
||||||
for _, a := range 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)
|
activity, ok, err := s.DB.GetActivity(r.Context(), a.ActivityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
@@ -34,7 +77,32 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
laps, err := s.DB.LapsForActivity(r.Context(), a.ActivityID)
|
all = append(all, withActivity{assignment: a, activity: activity})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most recent run first, by when the activity actually happened (not
|
||||||
|
// when the rule engine flagged it), so the queue reads like a log.
|
||||||
|
sort.Slice(all, func(i, j int) bool {
|
||||||
|
return all[i].activity.StartTimeUTC > all[j].activity.StartTimeUTC
|
||||||
|
})
|
||||||
|
|
||||||
|
total := len(all)
|
||||||
|
|
||||||
|
if cursor != "" {
|
||||||
|
idx := 0
|
||||||
|
for idx < len(all) && all[idx].activity.StartTimeUTC >= cursor {
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
all = all[idx:]
|
||||||
|
}
|
||||||
|
hasMore := len(all) > limit
|
||||||
|
if len(all) > limit {
|
||||||
|
all = all[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]reviewQueueItem, 0, len(all))
|
||||||
|
for _, wa := range all {
|
||||||
|
laps, err := s.DB.LapsForActivity(r.Context(), wa.assignment.ActivityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
@@ -42,21 +110,30 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Per-second telemetry, not just per-lap averages, so the chart can
|
// Per-second telemetry, not just per-lap averages, so the chart can
|
||||||
// show real within-lap variation instead of one flat segment per lap
|
// show real within-lap variation instead of one flat segment per lap
|
||||||
// (most activities only have a handful of laps).
|
// (most activities only have a handful of laps).
|
||||||
samples, err := s.DB.SamplesForActivity(r.Context(), a.ActivityID)
|
samples, err := s.DB.SamplesForActivity(r.Context(), wa.assignment.ActivityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items = append(items, item{KindAssignment: a, Activity: activity, Laps: laps, Samples: samples})
|
items = append(items, reviewQueueItem{
|
||||||
|
KindAssignment: wa.assignment,
|
||||||
|
Activity: toActivityResponse(wa.activity),
|
||||||
|
Laps: toLapResponses(laps),
|
||||||
|
Samples: samples,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Most recent run first, by when the activity actually happened (not
|
var nextCursor *string
|
||||||
// when the rule engine flagged it), so the queue reads like a log.
|
if hasMore && len(items) > 0 {
|
||||||
sort.Slice(items, func(i, j int) bool {
|
c := items[len(items)-1].Activity.StartTimeUTC
|
||||||
return items[i].Activity.StartTimeUTC > items[j].Activity.StartTimeUTC
|
nextCursor = &c
|
||||||
})
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, items)
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"items": items,
|
||||||
|
"next_cursor": nextCursor,
|
||||||
|
"total": total,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -104,3 +181,74 @@ func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"})
|
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.Route("/review-queue", func(r chi.Router) {
|
||||||
r.Get("/", s.handleReviewQueue)
|
r.Get("/", s.handleReviewQueue)
|
||||||
r.Post("/{activityID}/resolve", s.handleResolveReview)
|
r.Post("/{activityID}/resolve", s.handleResolveReview)
|
||||||
|
r.Post("/{activityID}/unlock", s.handleUnlockReview)
|
||||||
|
r.Post("/{activityID}/unassign", s.handleUnassignReview)
|
||||||
})
|
})
|
||||||
|
|
||||||
r.Get("/progression/{kindID}", s.handleProgression)
|
r.Get("/progression/{kindID}", s.handleProgression)
|
||||||
|
|||||||
@@ -15,16 +15,12 @@ const detailFillBatchSize = 50
|
|||||||
// picked up automatically), then IncrementalSync (catches anything new since
|
// picked up automatically), then IncrementalSync (catches anything new since
|
||||||
// the latest known activity), then fills in details for whatever's still
|
// the latest known activity), then fills in details for whatever's still
|
||||||
// missing them. Activities already fully processed are left untouched --
|
// 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) {
|
func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
|
||||||
ok := s.backgroundSync(func(ctx context.Context) error {
|
ok := s.backgroundSync(func(ctx context.Context) error {
|
||||||
if err := s.Sync.Backfill(ctx); err != nil {
|
return s.Sync.FullSync(ctx, detailFillBatchSize)
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := s.Sync.IncrementalSync(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.Sync.FillPendingDetails(ctx, detailFillBatchSize)
|
|
||||||
})
|
})
|
||||||
if !ok {
|
if !ok {
|
||||||
writeError(w, http.StatusConflict, "a sync is already in progress")
|
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
|
return ActivitySplits{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var splits ActivitySplits
|
var envelope struct {
|
||||||
if err := json.Unmarshal([]byte(msg), &splits); err != nil {
|
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)
|
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
|
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 {
|
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)
|
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
|
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 {
|
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)
|
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
|
return workout, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,10 @@ type Workout struct {
|
|||||||
WorkoutID int64 `json:"workoutId"`
|
WorkoutID int64 `json:"workoutId"`
|
||||||
WorkoutName string `json:"workoutName"`
|
WorkoutName string `json:"workoutName"`
|
||||||
Segments []WorkoutSegment `json:"workoutSegments"`
|
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
|
// FlattenSteps expands repeat groups (RepeatGroupDTO) into their repeated
|
||||||
@@ -195,6 +199,10 @@ type ActivityDetails struct {
|
|||||||
ActivityID int64 `json:"activityId"`
|
ActivityID int64 `json:"activityId"`
|
||||||
MetricDescriptors []MetricDescriptor `json:"metricDescriptors"`
|
MetricDescriptors []MetricDescriptor `json:"metricDescriptors"`
|
||||||
ActivityDetailMetrics []activityDetailMetricsRow `json:"activityDetailMetrics"`
|
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
|
// Sample is one ~1-second telemetry reading extracted from ActivityDetails
|
||||||
|
|||||||
@@ -9,38 +9,42 @@ import (
|
|||||||
// Activity is smartrun's persisted view of a Garmin activity. Field mapping
|
// 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
|
// from the Garmin/MCP response happens in internal/sync, not here, so this
|
||||||
// package stays independent of internal/garmin.
|
// 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 {
|
type Activity struct {
|
||||||
ID int64
|
ID int64
|
||||||
GarminActivityID int64
|
GarminActivityID int64
|
||||||
ActivityName string
|
|
||||||
ActivityType string
|
|
||||||
EventTypeKey string
|
EventTypeKey string
|
||||||
WorkoutID *int64
|
WorkoutID *int64
|
||||||
StartTimeUTC string
|
StartTimeUTC string
|
||||||
BeginTimestampMs int64
|
|
||||||
DurationSeconds float64
|
DurationSeconds float64
|
||||||
DistanceMeters float64
|
DistanceMeters float64
|
||||||
AvgHR *float64
|
AvgHR *float64
|
||||||
MaxHR *float64
|
MaxHR *float64
|
||||||
AvgSpeedMps *float64
|
AvgSpeedMps *float64
|
||||||
MaxSpeedMps *float64
|
|
||||||
ElevationGainM *float64
|
ElevationGainM *float64
|
||||||
ElevationLossM *float64
|
|
||||||
Calories *float64
|
|
||||||
LapCount int
|
|
||||||
AerobicTrainingEffect *float64
|
AerobicTrainingEffect *float64
|
||||||
AnaerobicTrainingEffect *float64
|
AnaerobicTrainingEffect *float64
|
||||||
TrainingEffectLabel string
|
|
||||||
VO2MaxValue *float64
|
VO2MaxValue *float64
|
||||||
HrTimeInZone1 *float64
|
|
||||||
HrTimeInZone2 *float64
|
|
||||||
HrTimeInZone3 *float64
|
|
||||||
HrTimeInZone4 *float64
|
|
||||||
HrTimeInZone5 *float64
|
|
||||||
RawJSON string
|
RawJSON string
|
||||||
DetailsFetchedAt *string
|
DetailsFetchedAt *string
|
||||||
DetailsRawJSON *string
|
DetailsRawJSON *string
|
||||||
SplitsFetchedAt *string
|
SplitsFetchedAt *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
|
CreatedAt string
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
}
|
}
|
||||||
@@ -51,49 +55,32 @@ type Activity struct {
|
|||||||
func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) {
|
func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) {
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
INSERT INTO activities (
|
INSERT INTO activities (
|
||||||
garmin_activity_id, activity_name, activity_type, event_type_key, workout_id, start_time_utc,
|
garmin_activity_id, event_type_key, workout_id, start_time_utc,
|
||||||
begin_timestamp_ms, duration_seconds, distance_meters, avg_hr, max_hr,
|
duration_seconds, distance_meters, avg_hr, max_hr,
|
||||||
avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
|
avg_speed_mps, elevation_gain_m,
|
||||||
calories, lap_count, aerobic_training_effect, anaerobic_training_effect,
|
aerobic_training_effect, anaerobic_training_effect, vo2max_value,
|
||||||
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, updated_at
|
raw_json, updated_at
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now'))
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now'))
|
||||||
ON CONFLICT(garmin_activity_id) DO UPDATE SET
|
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,
|
event_type_key=excluded.event_type_key,
|
||||||
workout_id=excluded.workout_id,
|
workout_id=excluded.workout_id,
|
||||||
start_time_utc=excluded.start_time_utc,
|
start_time_utc=excluded.start_time_utc,
|
||||||
begin_timestamp_ms=excluded.begin_timestamp_ms,
|
|
||||||
duration_seconds=excluded.duration_seconds,
|
duration_seconds=excluded.duration_seconds,
|
||||||
distance_meters=excluded.distance_meters,
|
distance_meters=excluded.distance_meters,
|
||||||
avg_hr=excluded.avg_hr,
|
avg_hr=excluded.avg_hr,
|
||||||
max_hr=excluded.max_hr,
|
max_hr=excluded.max_hr,
|
||||||
avg_speed_mps=excluded.avg_speed_mps,
|
avg_speed_mps=excluded.avg_speed_mps,
|
||||||
max_speed_mps=excluded.max_speed_mps,
|
|
||||||
elevation_gain_m=excluded.elevation_gain_m,
|
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,
|
aerobic_training_effect=excluded.aerobic_training_effect,
|
||||||
anaerobic_training_effect=excluded.anaerobic_training_effect,
|
anaerobic_training_effect=excluded.anaerobic_training_effect,
|
||||||
training_effect_label=excluded.training_effect_label,
|
|
||||||
vo2max_value=excluded.vo2max_value,
|
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,
|
raw_json=excluded.raw_json,
|
||||||
updated_at=datetime('now')
|
updated_at=datetime('now')
|
||||||
`,
|
`,
|
||||||
a.GarminActivityID, a.ActivityName, a.ActivityType, a.EventTypeKey, a.WorkoutID, a.StartTimeUTC,
|
a.GarminActivityID, a.EventTypeKey, a.WorkoutID, a.StartTimeUTC,
|
||||||
a.BeginTimestampMs, a.DurationSeconds, a.DistanceMeters, a.AvgHR, a.MaxHR,
|
a.DurationSeconds, a.DistanceMeters, a.AvgHR, a.MaxHR,
|
||||||
a.AvgSpeedMps, a.MaxSpeedMps, a.ElevationGainM, a.ElevationLossM,
|
a.AvgSpeedMps, a.ElevationGainM,
|
||||||
a.Calories, a.LapCount, a.AerobicTrainingEffect, a.AnaerobicTrainingEffect,
|
a.AerobicTrainingEffect, a.AnaerobicTrainingEffect, a.VO2MaxValue,
|
||||||
a.TrainingEffectLabel, a.VO2MaxValue,
|
|
||||||
a.HrTimeInZone1, a.HrTimeInZone2, a.HrTimeInZone3, a.HrTimeInZone4, a.HrTimeInZone5,
|
|
||||||
a.RawJSON,
|
a.RawJSON,
|
||||||
)
|
)
|
||||||
if err != nil {
|
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) {
|
func scanActivity(row interface{ Scan(...any) error }) (Activity, error) {
|
||||||
var a Activity
|
var a Activity
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&a.ID, &a.GarminActivityID, &a.ActivityName, &a.ActivityType, &a.EventTypeKey, &a.WorkoutID, &a.StartTimeUTC,
|
&a.ID, &a.GarminActivityID, &a.EventTypeKey, &a.WorkoutID, &a.StartTimeUTC,
|
||||||
&a.BeginTimestampMs, &a.DurationSeconds, &a.DistanceMeters, &a.AvgHR, &a.MaxHR,
|
&a.DurationSeconds, &a.DistanceMeters, &a.AvgHR, &a.MaxHR,
|
||||||
&a.AvgSpeedMps, &a.MaxSpeedMps, &a.ElevationGainM, &a.ElevationLossM,
|
&a.AvgSpeedMps, &a.ElevationGainM,
|
||||||
&a.Calories, &a.LapCount, &a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect,
|
&a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect, &a.VO2MaxValue,
|
||||||
&a.TrainingEffectLabel, &a.VO2MaxValue,
|
&a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt, &a.WorkoutRawJSON,
|
||||||
&a.HrTimeInZone1, &a.HrTimeInZone2, &a.HrTimeInZone3, &a.HrTimeInZone4, &a.HrTimeInZone5,
|
|
||||||
&a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt,
|
|
||||||
&a.CreatedAt, &a.UpdatedAt,
|
&a.CreatedAt, &a.UpdatedAt,
|
||||||
)
|
)
|
||||||
return a, err
|
return a, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const activityColumns = `
|
const activityColumns = `
|
||||||
id, garmin_activity_id, activity_name, activity_type, event_type_key, workout_id, start_time_utc,
|
id, garmin_activity_id, event_type_key, workout_id, start_time_utc,
|
||||||
begin_timestamp_ms, duration_seconds, distance_meters, avg_hr, max_hr,
|
duration_seconds, distance_meters, avg_hr, max_hr,
|
||||||
avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
|
avg_speed_mps, elevation_gain_m,
|
||||||
calories, lap_count, aerobic_training_effect, anaerobic_training_effect,
|
aerobic_training_effect, anaerobic_training_effect, vo2max_value,
|
||||||
training_effect_label, vo2max_value,
|
raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json,
|
||||||
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,
|
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -189,6 +172,24 @@ func (db *DB) ListActivities(ctx context.Context, f ActivityFilter) ([]Activity,
|
|||||||
return activities, rows.Err()
|
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
|
// LatestActivityStartTime returns the start_time_utc of the most recently
|
||||||
// started activity we have, used to compute the incremental sync window.
|
// started activity we have, used to compute the incremental sync window.
|
||||||
func (db *DB) LatestActivityStartTime(ctx context.Context) (string, bool, error) {
|
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
|
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
|
// SetActivitySplitsFetched records that get_activity_splits has been fetched
|
||||||
// for this activity.
|
// for this activity.
|
||||||
func (db *DB) SetActivitySplitsFetched(ctx context.Context, activityID int64) error {
|
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
|
// Lap is one lap/split of an activity, from get_activity_splits, plus
|
||||||
// derived HR drift/recovery metrics computed from activity_samples.
|
// 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 {
|
type Lap struct {
|
||||||
ID int64
|
ID int64
|
||||||
ActivityID int64
|
ActivityID int64
|
||||||
LapIndex int
|
LapIndex int
|
||||||
StartTimeUTC string
|
|
||||||
DurationSeconds float64
|
|
||||||
DistanceMeters float64
|
|
||||||
AvgHR *float64
|
|
||||||
MaxHR *float64
|
|
||||||
AvgSpeedMps *float64
|
AvgSpeedMps *float64
|
||||||
MaxSpeedMps *float64
|
|
||||||
ElevationGainM *float64
|
|
||||||
ElevationLossM *float64
|
|
||||||
IntensityType string
|
IntensityType string
|
||||||
HRDriftBpmPerMin *float64
|
HRDriftBpmPerMin *float64
|
||||||
HRRecoveryBpmPerMin *float64
|
HRRecoveryBpmPerMin *float64
|
||||||
@@ -52,13 +51,11 @@ func (db *DB) ReplaceLaps(ctx context.Context, activityID int64, laps []Lap) err
|
|||||||
for _, l := range laps {
|
for _, l := range laps {
|
||||||
_, err := tx.ExecContext(ctx, `
|
_, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO laps (
|
INSERT INTO laps (
|
||||||
activity_id, lap_index, start_time_utc, duration_seconds, distance_meters,
|
activity_id, lap_index, avg_speed_mps,
|
||||||
avg_hr, max_hr, avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
|
|
||||||
intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min,
|
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
|
target_pace_low_mps, target_pace_high_mps, target_hr_low_bpm, target_hr_high_bpm, raw_json
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
activityID, l.LapIndex, l.StartTimeUTC, l.DurationSeconds, l.DistanceMeters,
|
activityID, l.LapIndex, l.AvgSpeedMps,
|
||||||
l.AvgHR, l.MaxHR, l.AvgSpeedMps, l.MaxSpeedMps, l.ElevationGainM, l.ElevationLossM,
|
|
||||||
l.IntensityType, l.HRDriftBpmPerMin, l.HRRecoveryBpmPerMin,
|
l.IntensityType, l.HRDriftBpmPerMin, l.HRRecoveryBpmPerMin,
|
||||||
l.TargetPaceLowMps, l.TargetPaceHighMps, l.TargetHRLowBpm, l.TargetHRHighBpm, l.RawJSON,
|
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.
|
// LapsForActivity returns all laps for an activity, ordered by lap_index.
|
||||||
func (db *DB) LapsForActivity(ctx context.Context, activityID int64) ([]Lap, error) {
|
func (db *DB) LapsForActivity(ctx context.Context, activityID int64) ([]Lap, error) {
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT id, activity_id, lap_index, start_time_utc, duration_seconds, distance_meters,
|
SELECT id, activity_id, lap_index, avg_speed_mps,
|
||||||
avg_hr, max_hr, avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
|
|
||||||
intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min,
|
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
|
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)
|
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() {
|
for rows.Next() {
|
||||||
var l Lap
|
var l Lap
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&l.ID, &l.ActivityID, &l.LapIndex, &l.StartTimeUTC, &l.DurationSeconds, &l.DistanceMeters,
|
&l.ID, &l.ActivityID, &l.LapIndex, &l.AvgSpeedMps,
|
||||||
&l.AvgHR, &l.MaxHR, &l.AvgSpeedMps, &l.MaxSpeedMps, &l.ElevationGainM, &l.ElevationLossM,
|
|
||||||
&l.IntensityType, &l.HRDriftBpmPerMin, &l.HRRecoveryBpmPerMin,
|
&l.IntensityType, &l.HRDriftBpmPerMin, &l.HRRecoveryBpmPerMin,
|
||||||
&l.TargetPaceLowMps, &l.TargetPaceHighMps, &l.TargetHRLowBpm, &l.TargetHRHighBpm, &l.RawJSON,
|
&l.TargetPaceLowMps, &l.TargetPaceHighMps, &l.TargetHRLowBpm, &l.TargetHRHighBpm, &l.RawJSON,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Filters brief pace "artifacts" (e.g. GPS/motion still settling right as
|
||||||
|
-- recording starts, before the run itself begins) out of the Review
|
||||||
|
-- Queue's pace chart: a stretch of samples slower than
|
||||||
|
-- min_representative_pace_sec_per_km is dropped unless it persists for at
|
||||||
|
-- least min_representative_time_seconds, in which case it's treated as a
|
||||||
|
-- real stop or walk break, not noise. Defaults match the values used to
|
||||||
|
-- design this feature (12:00/km, 3 seconds).
|
||||||
|
ALTER TABLE profile ADD COLUMN min_representative_pace_sec_per_km REAL NOT NULL DEFAULT 720;
|
||||||
|
ALTER TABLE profile ADD COLUMN min_representative_time_seconds REAL NOT NULL DEFAULT 3;
|
||||||
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
|
// Profile is the single active user's Garmin credentials plus every
|
||||||
// tunable analysis-engine parameter. Always exactly one row (id=1).
|
// tunable analysis-engine parameter. Always exactly one row (id=1).
|
||||||
type Profile struct {
|
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
|
GarminEmail string
|
||||||
GarminPassword string
|
GarminPassword string
|
||||||
RollingWindowDays int
|
RollingWindowDays int
|
||||||
@@ -32,15 +35,49 @@ type Profile struct {
|
|||||||
// don't use these.
|
// don't use these.
|
||||||
WarmupMinutes, CooldownMinutes float64
|
WarmupMinutes, CooldownMinutes float64
|
||||||
|
|
||||||
|
// MinRepresentativePaceSecPerKm/MinRepresentativeTimeSeconds drive the
|
||||||
|
// Review Queue pace chart's artifact filter: a stretch of samples slower
|
||||||
|
// than MinRepresentativePaceSecPerKm is dropped from the chart (and its
|
||||||
|
// Y-axis scale) unless it persists for at least
|
||||||
|
// MinRepresentativeTimeSeconds, in which case it's treated as a real
|
||||||
|
// stop or walk break rather than noise (e.g. GPS/motion still settling
|
||||||
|
// 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
|
CreatedAt, UpdatedAt string
|
||||||
}
|
}
|
||||||
|
|
||||||
const profileColumns = `
|
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_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_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct,
|
||||||
hr_zone5_min_pct, hr_zone5_max_pct,
|
hr_zone5_min_pct, hr_zone5_max_pct,
|
||||||
warmup_minutes, cooldown_minutes,
|
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
|
created_at, updated_at
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -48,11 +85,14 @@ const profileColumns = `
|
|||||||
func (db *DB) GetProfile(ctx context.Context) (Profile, error) {
|
func (db *DB) GetProfile(ctx context.Context) (Profile, error) {
|
||||||
var p Profile
|
var p Profile
|
||||||
err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profile WHERE id = 1`).Scan(
|
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.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct,
|
||||||
&p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct,
|
&p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct,
|
||||||
&p.HRZone5MinPct, &p.HRZone5MaxPct,
|
&p.HRZone5MinPct, &p.HRZone5MaxPct,
|
||||||
&p.WarmupMinutes, &p.CooldownMinutes,
|
&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,
|
&p.CreatedAt, &p.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -67,18 +107,24 @@ func (db *DB) GetProfile(ctx context.Context) (Profile, error) {
|
|||||||
func (db *DB) UpdateProfile(ctx context.Context, p Profile) error {
|
func (db *DB) UpdateProfile(ctx context.Context, p Profile) error {
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
UPDATE profile SET
|
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_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_zone3_min_pct=?, hr_zone3_max_pct=?, hr_zone4_min_pct=?, hr_zone4_max_pct=?,
|
||||||
hr_zone5_min_pct=?, hr_zone5_max_pct=?,
|
hr_zone5_min_pct=?, hr_zone5_max_pct=?,
|
||||||
warmup_minutes=?, cooldown_minutes=?,
|
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')
|
updated_at=datetime('now')
|
||||||
WHERE id = 1`,
|
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.HRZone1MinPct, p.HRZone1MaxPct, p.HRZone2MinPct, p.HRZone2MaxPct,
|
||||||
p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct,
|
p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct,
|
||||||
p.HRZone5MinPct, p.HRZone5MaxPct,
|
p.HRZone5MinPct, p.HRZone5MaxPct,
|
||||||
p.WarmupMinutes, p.CooldownMinutes,
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("update profile: %w", err)
|
return fmt.Errorf("update profile: %w", err)
|
||||||
|
|||||||
@@ -25,8 +25,35 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
|||||||
if p.BackfillHorizonDays != 1095 {
|
if p.BackfillHorizonDays != 1095 {
|
||||||
t.Errorf("BackfillHorizonDays = %d, want 1095 (migration default)", p.BackfillHorizonDays)
|
t.Errorf("BackfillHorizonDays = %d, want 1095 (migration default)", p.BackfillHorizonDays)
|
||||||
}
|
}
|
||||||
|
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
|
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.GarminEmail = "runner@example.com"
|
||||||
p.GarminPassword = "hunter2"
|
p.GarminPassword = "hunter2"
|
||||||
p.RollingWindowDays = 120
|
p.RollingWindowDays = 120
|
||||||
@@ -34,6 +61,8 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
|||||||
p.RestingHeartRate = &restingHR
|
p.RestingHeartRate = &restingHR
|
||||||
p.WarmupMinutes = 8
|
p.WarmupMinutes = 8
|
||||||
p.BackfillHorizonDays = 14
|
p.BackfillHorizonDays = 14
|
||||||
|
p.MinRepresentativePaceSecPerKm = 600
|
||||||
|
p.MinRepresentativeTimeSeconds = 5
|
||||||
|
|
||||||
if err := db.UpdateProfile(ctx, p); err != nil {
|
if err := db.UpdateProfile(ctx, p); err != nil {
|
||||||
t.Fatalf("UpdateProfile: %v", err)
|
t.Fatalf("UpdateProfile: %v", err)
|
||||||
@@ -46,6 +75,9 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
|||||||
if got.GarminEmail != "runner@example.com" || got.RollingWindowDays != 120 {
|
if got.GarminEmail != "runner@example.com" || got.RollingWindowDays != 120 {
|
||||||
t.Errorf("got = %+v, want updated email/window", got)
|
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 {
|
if got.MaxHeartRate == nil || *got.MaxHeartRate != 190 {
|
||||||
t.Errorf("MaxHeartRate = %v, want 190", got.MaxHeartRate)
|
t.Errorf("MaxHeartRate = %v, want 190", got.MaxHeartRate)
|
||||||
}
|
}
|
||||||
@@ -55,4 +87,19 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
|||||||
if got.BackfillHorizonDays != 14 {
|
if got.BackfillHorizonDays != 14 {
|
||||||
t.Errorf("BackfillHorizonDays = %v, want 14", got.BackfillHorizonDays)
|
t.Errorf("BackfillHorizonDays = %v, want 14", got.BackfillHorizonDays)
|
||||||
}
|
}
|
||||||
|
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)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("UpsertActivity: %v", err)
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,14 +40,11 @@ func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
|
|||||||
|
|
||||||
a := Activity{
|
a := Activity{
|
||||||
GarminActivityID: 23554066504,
|
GarminActivityID: 23554066504,
|
||||||
ActivityName: "Auriol - W2-5-Base Endurance",
|
|
||||||
ActivityType: "running",
|
|
||||||
StartTimeUTC: "2026-07-11 05:02:35",
|
StartTimeUTC: "2026-07-11 05:02:35",
|
||||||
BeginTimestampMs: 1783746155000,
|
|
||||||
DurationSeconds: 1800,
|
DurationSeconds: 1800,
|
||||||
DistanceMeters: 6858,
|
DistanceMeters: 6858,
|
||||||
AvgHR: f(148),
|
AvgHR: f(148),
|
||||||
RawJSON: `{"activityId":23554066504}`,
|
RawJSON: `{"activityId":23554066504,"activityName":"Auriol - W2-5-Base Endurance","activityType":{"typeKey":"running"}}`,
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := db.UpsertActivity(ctx, a)
|
id, err := db.UpsertActivity(ctx, a)
|
||||||
@@ -87,7 +84,6 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
|||||||
|
|
||||||
activityID, err := db.UpsertActivity(ctx, Activity{
|
activityID, err := db.UpsertActivity(ctx, Activity{
|
||||||
GarminActivityID: 1,
|
GarminActivityID: 1,
|
||||||
ActivityType: "running",
|
|
||||||
StartTimeUTC: "2026-07-11 05:00:00",
|
StartTimeUTC: "2026-07-11 05:00:00",
|
||||||
RawJSON: "{}",
|
RawJSON: "{}",
|
||||||
})
|
})
|
||||||
@@ -172,7 +168,6 @@ func TestReplaceLapsIsIdempotent(t *testing.T) {
|
|||||||
|
|
||||||
activityID, err := db.UpsertActivity(ctx, Activity{
|
activityID, err := db.UpsertActivity(ctx, Activity{
|
||||||
GarminActivityID: 2,
|
GarminActivityID: 2,
|
||||||
ActivityType: "running",
|
|
||||||
StartTimeUTC: "2026-07-11 05:00:00",
|
StartTimeUTC: "2026-07-11 05:00:00",
|
||||||
RawJSON: "{}",
|
RawJSON: "{}",
|
||||||
})
|
})
|
||||||
@@ -181,8 +176,8 @@ func TestReplaceLapsIsIdempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
laps := []Lap{
|
laps := []Lap{
|
||||||
{LapIndex: 1, StartTimeUTC: "2026-07-11 05:00:00", DurationSeconds: 300, DistanceMeters: 1000, AvgHR: f(140), RawJSON: "{}"},
|
{LapIndex: 1, RawJSON: "{}"},
|
||||||
{LapIndex: 2, StartTimeUTC: "2026-07-11 05:05:00", DurationSeconds: 300, DistanceMeters: 1000, AvgHR: f(150), RawJSON: "{}"},
|
{LapIndex: 2, RawJSON: "{}"},
|
||||||
}
|
}
|
||||||
if err := db.ReplaceLaps(ctx, activityID, laps); err != nil {
|
if err := db.ReplaceLaps(ctx, activityID, laps); err != nil {
|
||||||
t.Fatalf("ReplaceLaps (first): %v", err)
|
t.Fatalf("ReplaceLaps (first): %v", err)
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import (
|
|||||||
const (
|
const (
|
||||||
SyncKindBackfill = "backfill"
|
SyncKindBackfill = "backfill"
|
||||||
SyncKindIncremental = "incremental"
|
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"
|
SyncStatusRunning = "running"
|
||||||
SyncStatusSuccess = "success"
|
SyncStatusSuccess = "success"
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
wantNames := map[string]bool{
|
wantNames := map[string]bool{
|
||||||
"Easy Run": false, "Long Run": false, "Threshold 30'": false, "Threshold 60'": false,
|
"Easy": false, "Long": false, "60' Threshold": false, "30' Threshold": false,
|
||||||
"Tempo": false, "Interval": false, "MAS Test": false, "Race": false,
|
"Tempo": false, "Intervals": false, "MAS Test": false, "Race": false,
|
||||||
}
|
}
|
||||||
for _, k := range kinds {
|
for _, k := range kinds {
|
||||||
if _, ok := wantNames[k.Name]; !ok {
|
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"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WorkoutTypePace is a workout kind's user-declared target pace range and
|
// WorkoutTypePace is a workout kind's user-declared target pace range and HR
|
||||||
// expected HR zone. Informational only -- never read by the classification
|
// range (percent of heart rate reserve). Informational only -- never read by
|
||||||
// rule engine. No history: fields are overwritten in place.
|
// the classification rule engine. No history: fields are overwritten in
|
||||||
|
// place.
|
||||||
type WorkoutTypePace struct {
|
type WorkoutTypePace struct {
|
||||||
WorkoutKindID int64
|
WorkoutKindID int64
|
||||||
PaceMinSecPerKm *float64
|
PaceMinSecPerKm *float64
|
||||||
PaceMaxSecPerKm *float64
|
PaceMaxSecPerKm *float64
|
||||||
ExpectedHRZone *int
|
HRMinPctHRR *float64
|
||||||
|
HRMaxPctHRR *float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanWorkoutTypePace(row interface{ Scan(...any) error }) (WorkoutTypePace, error) {
|
func scanWorkoutTypePace(row interface{ Scan(...any) error }) (WorkoutTypePace, error) {
|
||||||
var p WorkoutTypePace
|
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
|
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.
|
// GetWorkoutTypePace fetches the pace/zone row for one workout kind.
|
||||||
func (db *DB) GetWorkoutTypePace(ctx context.Context, workoutKindID int64) (WorkoutTypePace, error) {
|
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.
|
// UpdateWorkoutTypePace overwrites the pace/zone row for one workout kind.
|
||||||
func (db *DB) UpdateWorkoutTypePace(ctx context.Context, p WorkoutTypePace) error {
|
func (db *DB) UpdateWorkoutTypePace(ctx context.Context, p WorkoutTypePace) error {
|
||||||
_, err := db.ExecContext(ctx, `
|
_, 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=?`,
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("update workout type pace for kind %d: %w", p.WorkoutKindID, err)
|
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))
|
t.Fatalf("expected 8 seeded pace rows (one per taxonomy kind), got %d", len(all))
|
||||||
}
|
}
|
||||||
for _, p := range 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)
|
t.Errorf("expected all-nil seeded pace row for kind %d, got %+v", p.WorkoutKindID, p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
target := all[0]
|
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.PaceMinSecPerKm = &minPace
|
||||||
target.PaceMaxSecPerKm = &maxPace
|
target.PaceMaxSecPerKm = &maxPace
|
||||||
target.ExpectedHRZone = &zone
|
target.HRMinPctHRR = &hrMin
|
||||||
|
target.HRMaxPctHRR = &hrMax
|
||||||
|
|
||||||
if err := db.UpdateWorkoutTypePace(ctx, target); err != nil {
|
if err := db.UpdateWorkoutTypePace(ctx, target); err != nil {
|
||||||
t.Fatalf("UpdateWorkoutTypePace: %v", err)
|
t.Fatalf("UpdateWorkoutTypePace: %v", err)
|
||||||
@@ -39,7 +40,10 @@ func TestWorkoutTypePaces_SeededOnePerKindThenUpdate(t *testing.T) {
|
|||||||
if got.PaceMinSecPerKm == nil || *got.PaceMinSecPerKm != 330 {
|
if got.PaceMinSecPerKm == nil || *got.PaceMinSecPerKm != 330 {
|
||||||
t.Errorf("PaceMinSecPerKm = %v, want 330", got.PaceMinSecPerKm)
|
t.Errorf("PaceMinSecPerKm = %v, want 330", got.PaceMinSecPerKm)
|
||||||
}
|
}
|
||||||
if got.ExpectedHRZone == nil || *got.ExpectedHRZone != 2 {
|
if got.HRMinPctHRR == nil || *got.HRMinPctHRR != 70 {
|
||||||
t.Errorf("ExpectedHRZone = %v, want 2", got.ExpectedHRZone)
|
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 {
|
func toActivityRow(a garmin.Activity) store.Activity {
|
||||||
return store.Activity{
|
return store.Activity{
|
||||||
GarminActivityID: a.ActivityID,
|
GarminActivityID: a.ActivityID,
|
||||||
ActivityName: a.ActivityName,
|
|
||||||
ActivityType: a.ActivityType.TypeKey,
|
|
||||||
EventTypeKey: a.EventType.TypeKey,
|
EventTypeKey: a.EventType.TypeKey,
|
||||||
WorkoutID: a.WorkoutID,
|
WorkoutID: a.WorkoutID,
|
||||||
StartTimeUTC: a.StartTimeGMT,
|
StartTimeUTC: a.StartTimeGMT,
|
||||||
BeginTimestampMs: a.BeginTimestamp,
|
|
||||||
DurationSeconds: a.Duration,
|
DurationSeconds: a.Duration,
|
||||||
DistanceMeters: a.Distance,
|
DistanceMeters: a.Distance,
|
||||||
AvgHR: nonZero(a.AverageHR),
|
AvgHR: nonZero(a.AverageHR),
|
||||||
MaxHR: nonZero(a.MaxHR),
|
MaxHR: nonZero(a.MaxHR),
|
||||||
AvgSpeedMps: nonZero(a.AverageSpeed),
|
AvgSpeedMps: nonZero(a.AverageSpeed),
|
||||||
MaxSpeedMps: nonZero(a.MaxSpeed),
|
|
||||||
ElevationGainM: a.ElevationGain,
|
ElevationGainM: a.ElevationGain,
|
||||||
ElevationLossM: a.ElevationLoss,
|
|
||||||
Calories: nonZero(a.Calories),
|
|
||||||
LapCount: a.LapCount,
|
|
||||||
AerobicTrainingEffect: nonZero(a.AerobicTrainingEffect),
|
AerobicTrainingEffect: nonZero(a.AerobicTrainingEffect),
|
||||||
AnaerobicTrainingEffect: nonZero(a.AnaerobicTrainingEffect),
|
AnaerobicTrainingEffect: nonZero(a.AnaerobicTrainingEffect),
|
||||||
TrainingEffectLabel: a.TrainingEffectLabel,
|
|
||||||
VO2MaxValue: a.VO2MaxValue,
|
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),
|
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)
|
hrLow, hrHigh = targetHRRange(*targets[i], profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, _ := json.Marshal(l)
|
|
||||||
rows = append(rows, store.Lap{
|
rows = append(rows, store.Lap{
|
||||||
LapIndex: l.LapIndex,
|
LapIndex: l.LapIndex,
|
||||||
StartTimeUTC: l.StartTimeGMT,
|
|
||||||
DurationSeconds: l.Duration,
|
|
||||||
DistanceMeters: l.Distance,
|
|
||||||
AvgHR: nonZero(l.AverageHR),
|
|
||||||
MaxHR: nonZero(l.MaxHR),
|
|
||||||
AvgSpeedMps: nonZero(l.AverageSpeed),
|
AvgSpeedMps: nonZero(l.AverageSpeed),
|
||||||
MaxSpeedMps: nonZero(l.MaxSpeed),
|
|
||||||
ElevationGainM: nonZero(l.ElevationGain),
|
|
||||||
ElevationLossM: nonZero(l.ElevationLoss),
|
|
||||||
IntensityType: l.IntensityType,
|
IntensityType: l.IntensityType,
|
||||||
HRDriftBpmPerMin: driftPtr,
|
HRDriftBpmPerMin: driftPtr,
|
||||||
HRRecoveryBpmPerMin: recoveryPtr,
|
HRRecoveryBpmPerMin: recoveryPtr,
|
||||||
@@ -109,7 +87,7 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.Wor
|
|||||||
TargetPaceHighMps: paceHigh,
|
TargetPaceHighMps: paceHigh,
|
||||||
TargetHRLowBpm: hrLow,
|
TargetHRLowBpm: hrLow,
|
||||||
TargetHRHighBpm: hrHigh,
|
TargetHRHighBpm: hrHigh,
|
||||||
RawJSON: string(raw),
|
RawJSON: string(l.Raw),
|
||||||
})
|
})
|
||||||
elapsedStart = elapsedEnd
|
elapsedStart = elapsedEnd
|
||||||
}
|
}
|
||||||
@@ -118,20 +96,31 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.Wor
|
|||||||
|
|
||||||
// alignWorkoutTargets zips an activity's recorded laps against its
|
// alignWorkoutTargets zips an activity's recorded laps against its
|
||||||
// structured workout's flattened steps, returning one *garmin.WorkoutStep
|
// structured workout's flattened steps, returning one *garmin.WorkoutStep
|
||||||
// per lap (nil where unavailable). Zipping only happens when the counts
|
// per lap (nil where unavailable).
|
||||||
// match exactly -- a mismatch (extra manual laps, auto-lap-by-distance also
|
//
|
||||||
// firing, etc.) means we can't trust the alignment, so every entry comes
|
// Confirmed against a real activity (via Garmin Connect's own workout view)
|
||||||
// back nil rather than risk showing a target against the wrong lap.
|
// that recording sometimes continues one lap past the end of the workout's
|
||||||
|
// last step -- e.g. a 5-minute prescribed cool-down followed by another
|
||||||
|
// 6:46 the athlete just kept running, logged as a further lap Garmin never
|
||||||
|
// defined a target for. That shows up here as exactly one more recorded lap
|
||||||
|
// than the workout has steps, so that specific case zips the steps that do
|
||||||
|
// exist and leaves the trailing extra lap unmapped, rather than discarding
|
||||||
|
// every other lap's real target along with it.
|
||||||
|
//
|
||||||
|
// Any other mismatch (extra manual laps, auto-lap-by-distance also firing,
|
||||||
|
// etc.) can't be trusted at all, so every entry comes back nil rather than
|
||||||
|
// risk showing a target against the wrong lap.
|
||||||
func alignWorkoutTargets(laps []garmin.Lap, workout garmin.Workout) []*garmin.WorkoutStep {
|
func alignWorkoutTargets(laps []garmin.Lap, workout garmin.Workout) []*garmin.WorkoutStep {
|
||||||
steps := workout.FlattenSteps()
|
steps := workout.FlattenSteps()
|
||||||
if len(steps) != len(laps) {
|
out := make([]*garmin.WorkoutStep, len(laps))
|
||||||
return make([]*garmin.WorkoutStep, len(laps))
|
|
||||||
}
|
switch len(laps) - len(steps) {
|
||||||
out := make([]*garmin.WorkoutStep, len(steps))
|
case 0, 1:
|
||||||
for i := range steps {
|
for i := range steps {
|
||||||
s := steps[i]
|
s := steps[i]
|
||||||
out[i] = &s
|
out[i] = &s
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,19 +111,30 @@ func (s *Service) Backfill(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
profile, err := s.db.GetProfile(ctx)
|
total, err := s.backfillCore(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := err.Error()
|
msg := err.Error()
|
||||||
s.db.FinishSyncRun(ctx, runID, 0, &msg)
|
s.db.FinishSyncRun(ctx, runID, total, &msg)
|
||||||
return fmt.Errorf("load profile: %w", err)
|
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)
|
horizon := s.now().AddDate(0, 0, -profile.BackfillHorizonDays)
|
||||||
|
|
||||||
state, err := s.db.GetSyncState(ctx)
|
state, err := s.db.GetSyncState(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := err.Error()
|
return 0, err
|
||||||
s.db.FinishSyncRun(ctx, runID, 0, &msg)
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
end := s.now()
|
end := s.now()
|
||||||
@@ -132,7 +143,7 @@ func (s *Service) Backfill(ctx context.Context) error {
|
|||||||
if state.BackfillComplete && !watermark.After(horizon) {
|
if state.BackfillComplete && !watermark.After(horizon) {
|
||||||
// Already backfilled at least as far back as the configured
|
// Already backfilled at least as far back as the configured
|
||||||
// horizon -- nothing new to fetch from Garmin at all.
|
// 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)
|
end = watermark.AddDate(0, 0, -1)
|
||||||
}
|
}
|
||||||
@@ -146,29 +157,23 @@ func (s *Service) Backfill(ctx context.Context) error {
|
|||||||
start = horizon
|
start = horizon
|
||||||
}
|
}
|
||||||
|
|
||||||
n, err := s.fetchAndStoreWindow(ctx, dateStr(start), dateStr(end))
|
rawCount, newCount, err := s.fetchAndStoreWindow(ctx, dateStr(start), dateStr(end))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := err.Error()
|
return total, fmt.Errorf("backfill window %s..%s: %w", dateStr(start), dateStr(end), err)
|
||||||
s.db.FinishSyncRun(ctx, runID, total, &msg)
|
|
||||||
return 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,
|
// Empty page: reached the start of this account's history,
|
||||||
// regardless of the configured horizon.
|
// regardless of the configured horizon.
|
||||||
reachedStartOfHistory = true
|
reachedStartOfHistory = true
|
||||||
if err := s.db.UpdateSyncState(ctx, dateStr(start), true); err != nil {
|
if err := s.db.UpdateSyncState(ctx, dateStr(start), true); err != nil {
|
||||||
msg := err.Error()
|
return total, err
|
||||||
s.db.FinishSyncRun(ctx, runID, total, &msg)
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if err := s.db.UpdateSyncState(ctx, dateStr(start), false); err != nil {
|
if err := s.db.UpdateSyncState(ctx, dateStr(start), false); err != nil {
|
||||||
msg := err.Error()
|
return total, err
|
||||||
s.db.FinishSyncRun(ctx, runID, total, &msg)
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
end = start.AddDate(0, 0, -1)
|
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
|
// Reached the configured horizon (not Garmin's actual history
|
||||||
// start) -- mark complete relative to that horizon.
|
// start) -- mark complete relative to that horizon.
|
||||||
if err := s.db.UpdateSyncState(ctx, dateStr(horizon), true); err != nil {
|
if err := s.db.UpdateSyncState(ctx, dateStr(horizon), true); err != nil {
|
||||||
msg := err.Error()
|
return total, err
|
||||||
s.db.FinishSyncRun(ctx, runID, total, &msg)
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.db.FinishSyncRun(ctx, runID, total, nil)
|
return total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IncrementalSync fetches activities from just before the latest known
|
// IncrementalSync fetches activities from just before the latest known
|
||||||
@@ -194,14 +197,7 @@ func (s *Service) IncrementalSync(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
|
n, err := s.incrementalSyncCore(ctx)
|
||||||
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()))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := err.Error()
|
msg := err.Error()
|
||||||
s.db.FinishSyncRun(ctx, runID, n, &msg)
|
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)
|
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
|
// ResetAll deletes every synced activity (and its laps/samples/kind
|
||||||
// assignments) and rewinds the backfill watermark, so the next Backfill
|
// assignments) and rewinds the backfill watermark, so the next Backfill
|
||||||
// call performs a genuinely fresh pull from Garmin instead of resuming from
|
// 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)
|
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)
|
activities, err := s.garmin.GetActivities(ctx, startDate, endDate, 500)
|
||||||
if err != nil {
|
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 {
|
for _, a := range activities {
|
||||||
// Only running activities are of interest here; other sports (padel,
|
// Only running activities are of interest here; other sports (padel,
|
||||||
// cycling, strength training, ...) also come back from
|
// cycling, strength training, ...) also come back from
|
||||||
// get_activities() but are dropped rather than stored. len(activities)
|
// get_activities() but are dropped rather than stored.
|
||||||
// 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.
|
|
||||||
if !isRunningActivityType(a.ActivityType.TypeKey) {
|
if !isRunningActivityType(a.ActivityType.TypeKey) {
|
||||||
continue
|
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 {
|
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
|
// 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)
|
log.Printf("sync: get_workout_by_id(%d) for activity %d failed, continuing without target zones: %v", *a.WorkoutID, a.GarminActivityID, err)
|
||||||
} else {
|
} else {
|
||||||
targets = alignWorkoutTargets(splits.Laps, workout)
|
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 {
|
if err := s.db.ReplaceLaps(ctx, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
detailsRaw, _ := json.Marshal(details)
|
if err := s.db.SetActivityDetails(ctx, a.ID, string(details.Raw)); err != nil {
|
||||||
if err := s.db.SetActivityDetails(ctx, a.ID, string(detailsRaw)); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.db.SetActivitySplitsFetched(ctx, a.ID)
|
return s.db.SetActivitySplitsFetched(ctx, a.ID)
|
||||||
|
|||||||
@@ -98,6 +98,35 @@ func TestAlignWorkoutTargets_MatchesLapsToFlattenedSteps(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAlignWorkoutTargets_OneExtraTrailingLapKeepsOtherTargets(t *testing.T) {
|
||||||
|
// Confirmed via Garmin Connect against a real activity: recording
|
||||||
|
// sometimes continues one lap past the workout's last step (e.g. a
|
||||||
|
// 5-minute prescribed cool-down followed by another 6:46 the athlete
|
||||||
|
// just kept running). The extra lap should have no target, but every
|
||||||
|
// other lap's real target must still come through.
|
||||||
|
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
|
||||||
|
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
|
||||||
|
{Steps: []garmin.WorkoutStep{
|
||||||
|
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)},
|
||||||
|
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(2), TargetValueTwo: f(2.5)},
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
|
||||||
|
targets := alignWorkoutTargets(laps, workout)
|
||||||
|
if len(targets) != 3 {
|
||||||
|
t.Fatalf("expected 3 slots (one per lap), got %d", len(targets))
|
||||||
|
}
|
||||||
|
if targets[0] == nil || targets[0].TargetType.TypeKey != "pace.zone" {
|
||||||
|
t.Errorf("targets[0] = %+v, want the first step's pace.zone target", targets[0])
|
||||||
|
}
|
||||||
|
if targets[1] == nil || targets[1].TargetType.TypeKey != "pace.zone" {
|
||||||
|
t.Errorf("targets[1] = %+v, want the second step's pace.zone target", targets[1])
|
||||||
|
}
|
||||||
|
if targets[2] != nil {
|
||||||
|
t.Errorf("targets[2] = %+v, want nil for the trailing unplanned continuation lap", targets[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAlignWorkoutTargets_MismatchedCountYieldsAllNil(t *testing.T) {
|
func TestAlignWorkoutTargets_MismatchedCountYieldsAllNil(t *testing.T) {
|
||||||
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
|
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
|
||||||
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
|
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
|
||||||
@@ -309,7 +338,7 @@ func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFillPendingDetails_MismatchedLapCountLeavesTargetsNil(t *testing.T) {
|
func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
@@ -329,7 +358,10 @@ func TestFillPendingDetails_MismatchedLapCountLeavesTargetsNil(t *testing.T) {
|
|||||||
},
|
},
|
||||||
Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
|
Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
|
||||||
Workouts: map[int64]garmin.Workout{
|
Workouts: map[int64]garmin.Workout{
|
||||||
// Only one step for two recorded laps -- counts don't match.
|
// One step for two recorded laps -- the second lap is an
|
||||||
|
// unplanned continuation past the workout's end (confirmed via
|
||||||
|
// Garmin Connect against a real activity), not a genuine
|
||||||
|
// mismatch, so the first lap should still get a real target.
|
||||||
workoutID: {Segments: []garmin.WorkoutSegment{
|
workoutID: {Segments: []garmin.WorkoutSegment{
|
||||||
{Steps: []garmin.WorkoutStep{
|
{Steps: []garmin.WorkoutStep{
|
||||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
||||||
@@ -351,10 +383,11 @@ func TestFillPendingDetails_MismatchedLapCountLeavesTargetsNil(t *testing.T) {
|
|||||||
if err != nil || len(laps) != 2 {
|
if err != nil || len(laps) != 2 {
|
||||||
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
||||||
}
|
}
|
||||||
for i, l := range laps {
|
if laps[0].TargetPaceLowMps == nil || *laps[0].TargetPaceLowMps != 3.0 {
|
||||||
if l.TargetPaceLowMps != nil || l.TargetPaceHighMps != nil {
|
t.Errorf("laps[0].TargetPaceLowMps = %v, want 3.0 (the one defined step's target)", laps[0].TargetPaceLowMps)
|
||||||
t.Errorf("laps[%d] target should be nil on lap/step count mismatch, got low=%v high=%v", i, l.TargetPaceLowMps, l.TargetPaceHighMps)
|
|
||||||
}
|
}
|
||||||
|
if laps[1].TargetPaceLowMps != nil || laps[1].TargetPaceHighMps != nil {
|
||||||
|
t.Errorf("laps[1] target should be nil (the trailing unplanned continuation lap), got low=%v high=%v", laps[1].TargetPaceLowMps, laps[1].TargetPaceHighMps)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,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) {
|
func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
|
||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>frontend</title>
|
<title>geniusrun</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 177 B |
@@ -10,7 +10,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
max-width: 960px;
|
max-width: 1100px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 1.5rem 3rem;
|
padding: 0 1.5rem 3rem;
|
||||||
}
|
}
|
||||||
@@ -42,18 +42,57 @@ body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tab-icon {
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
.tab.active {
|
.tab.active {
|
||||||
background: #3b82f6;
|
background: #3b82f6;
|
||||||
border-color: #3b82f6;
|
border-color: #3b82f6;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pill-shaped and icon-led, deliberately unlike the rectangular .tab
|
||||||
|
buttons: this opens account/profile settings, not an application view
|
||||||
|
alongside Activities/Progression/Training plan, so it reads more like an
|
||||||
|
account chip (à la Slack/GitHub's corner avatar) than another nav tab. */
|
||||||
|
.profile-name {
|
||||||
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
background: #1a1d24;
|
||||||
|
border: 1px solid #2a2d35;
|
||||||
|
color: #9aa0ab;
|
||||||
|
padding: 0.4rem 0.9rem 0.4rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name::before {
|
||||||
|
content: "⚙️";
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name:hover:not(.active) {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name.active {
|
||||||
|
background: #3b82f6;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
.garmin-connection {
|
.garmin-connection {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem 0;
|
padding-top: 0.75rem;
|
||||||
border-bottom: 1px solid #2a2d35;
|
border-top: 1px solid #2a2d35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.garmin-connection-row {
|
.garmin-connection-row {
|
||||||
@@ -62,6 +101,22 @@ body {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.garmin-connection-main {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.garmin-connection-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.garmin-connection-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.garmin-connection-message {
|
.garmin-connection-message {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@@ -128,6 +183,13 @@ button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[type="color"] {
|
||||||
|
padding: 0.2rem;
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
button:hover:not(:disabled) {
|
button:hover:not(:disabled) {
|
||||||
border-color: #3b82f6;
|
border-color: #3b82f6;
|
||||||
}
|
}
|
||||||
@@ -151,11 +213,15 @@ button:disabled {
|
|||||||
.filter-pills {
|
.filter-pills {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-pill {
|
.filter-pill {
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.35rem 0.9rem;
|
padding: 0.35rem 0.9rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -183,6 +249,11 @@ button:disabled {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.review-list-sentinel {
|
||||||
|
padding: 1rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.review-item {
|
.review-item {
|
||||||
border: 1px solid #2a2d35;
|
border: 1px solid #2a2d35;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -192,14 +263,30 @@ button:disabled {
|
|||||||
.review-item-header {
|
.review-item-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.review-item-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.review-item-header span {
|
.review-item-header span {
|
||||||
color: #9aa0ab;
|
color: #9aa0ab;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.review-item-datetime {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
.review-item-stats {
|
.review-item-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -214,35 +301,73 @@ button:disabled {
|
|||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.review-item-actions {
|
.classify-control {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
gap: 0.4rem;
|
||||||
margin-top: 0.5rem;
|
}
|
||||||
|
|
||||||
|
.classify-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classify-lock {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classify-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
z-index: 10;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.4rem;
|
||||||
|
background: #14161c;
|
||||||
|
border: 1px solid #2a2d35;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.classify-dropdown button {
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.classify-dropdown-unassign {
|
||||||
|
color: #9aa0ab;
|
||||||
|
padding-bottom: 0.4rem;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
border-bottom: 1px solid #2a2d35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.expected-actual-charts {
|
.expected-actual-charts {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
flex-wrap: wrap;
|
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-chart {
|
.mini-chart {
|
||||||
flex: 1 1 220px;
|
width: 100%;
|
||||||
min-width: 180px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-chart-label {
|
.mini-chart-label {
|
||||||
display: block;
|
display: block;
|
||||||
|
text-align: center;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #9aa0ab;
|
color: #9aa0ab;
|
||||||
margin-bottom: 0.15rem;
|
margin-bottom: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.phase-legend {
|
.phase-legend {
|
||||||
flex-basis: 100%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
@@ -288,8 +413,8 @@ button:disabled {
|
|||||||
background: #14161c;
|
background: #14161c;
|
||||||
border: 1px solid #2a2d35;
|
border: 1px solid #2a2d35;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
width: min(720px, 90vw);
|
width: min(1100px, 94vw);
|
||||||
max-height: 80vh;
|
max-height: 88vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -303,42 +428,75 @@ button:disabled {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header-actions button {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-json {
|
.modal-json {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
line-height: 1.4;
|
line-height: 1.5;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-row {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kinds-table {
|
.json-toggle {
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lock-badge {
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
border-radius: 999px;
|
width: 1rem;
|
||||||
padding: 0.15rem 0.6rem;
|
padding: 0;
|
||||||
font-size: 0.75rem;
|
border: none;
|
||||||
|
background: none;
|
||||||
color: #9aa0ab;
|
color: #9aa0ab;
|
||||||
border: 1px solid #2a2d35;
|
cursor: pointer;
|
||||||
cursor: help;
|
font-size: 0.7rem;
|
||||||
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kinds-table th,
|
.json-toggle:hover {
|
||||||
.kinds-table td {
|
color: #e6e6e6;
|
||||||
text-align: left;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-bottom: 1px solid #2a2d35;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.kinds-table-actions {
|
.json-key {
|
||||||
display: flex;
|
color: #9aa0ab;
|
||||||
gap: 0.5rem;
|
}
|
||||||
|
|
||||||
|
.json-bracket {
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-summary {
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-string {
|
||||||
|
color: #6fcf97;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-number {
|
||||||
|
color: #6cb6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-boolean {
|
||||||
|
color: #f5a742;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-null {
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kind-editor {
|
.kind-editor {
|
||||||
@@ -348,7 +506,51 @@ button:disabled {
|
|||||||
border: 1px solid #2a2d35;
|
border: 1px solid #2a2d35;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
max-width: 480px;
|
max-width: 580px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.training-type-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.training-type-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border: 1px solid #2a2d35;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.training-type-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.training-type-description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #9aa0ab;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.training-type-meta {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kind-editor textarea {
|
.kind-editor textarea {
|
||||||
|
|||||||
@@ -1,46 +1,61 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "./api/client";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import { GarminConnection } from "./components/GarminConnection";
|
|
||||||
import { Activities } from "./pages/Activities";
|
|
||||||
import { Dashboard } from "./pages/Dashboard";
|
import { Dashboard } from "./pages/Dashboard";
|
||||||
|
import { Plan } from "./pages/Plan";
|
||||||
import { Profile } from "./pages/Profile";
|
import { Profile } from "./pages/Profile";
|
||||||
import { ReviewQueue } from "./pages/ReviewQueue";
|
import { ReviewQueue } from "./pages/ReviewQueue";
|
||||||
import { WorkoutKinds } from "./pages/WorkoutKinds";
|
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: "dashboard", label: "Progression", Component: Dashboard },
|
{ key: "activities", label: "Activities", icon: "☰", Component: ReviewQueue },
|
||||||
{ key: "review", label: "Review Queue", Component: ReviewQueue },
|
{ key: "dashboard", label: "Progression", icon: "↗", Component: Dashboard },
|
||||||
{ key: "activities", label: "Activities", Component: Activities },
|
{ key: "plan", label: "Training plan", icon: "✎", Component: Plan },
|
||||||
{ key: "kinds", label: "Workout Kinds", Component: WorkoutKinds },
|
|
||||||
{ key: "profile", label: "Profile", Component: Profile },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type TabKey = (typeof TABS)[number]["key"];
|
type TabKey = (typeof TABS)[number]["key"];
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [tab, setTab] = useState<TabKey>("dashboard");
|
const [tab, setTab] = useState<TabKey>("activities");
|
||||||
|
// Profile isn't a tab: it's reached via the profile name in the top-right
|
||||||
|
// corner instead, since (for now, single-profile) it's account settings,
|
||||||
|
// not a content view alongside Activities/Progression/Training plan.
|
||||||
|
const [showProfile, setShowProfile] = useState(false);
|
||||||
|
const [profileName, setProfileName] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getProfile().then((p) => setProfileName(p.Name)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const Active = TABS.find((t) => t.key === tab)!.Component;
|
const Active = TABS.find((t) => t.key === tab)!.Component;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<h1>smartrun</h1>
|
<h1>🧞♀️ geniusrun</h1>
|
||||||
<nav className="tabs">
|
<nav className="tabs">
|
||||||
{TABS.map((t) => (
|
{TABS.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.key}
|
key={t.key}
|
||||||
className={t.key === tab ? "tab active" : "tab"}
|
className={!showProfile && t.key === tab ? "tab active" : "tab"}
|
||||||
onClick={() => setTab(t.key)}
|
onClick={() => {
|
||||||
|
setShowProfile(false);
|
||||||
|
setTab(t.key);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
<span className="tab-icon">{t.icon}</span>
|
||||||
{t.label}
|
{t.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={showProfile ? "profile-name active" : "profile-name"}
|
||||||
|
onClick={() => setShowProfile(true)}
|
||||||
|
>
|
||||||
|
{profileName ?? "Profile"}
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<GarminConnection />
|
<main>{showProfile ? <Profile onSaved={(p) => setProfileName(p.Name)} /> : <Active />}</main>
|
||||||
<main>
|
|
||||||
<Active />
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
Profile,
|
Profile,
|
||||||
ProgressionMetric,
|
ProgressionMetric,
|
||||||
ProgressionPoint,
|
ProgressionPoint,
|
||||||
ReviewQueueItem,
|
ReviewQueuePage,
|
||||||
SyncRun,
|
SyncRun,
|
||||||
SyncStatus,
|
SyncStatus,
|
||||||
WorkoutKind,
|
WorkoutKind,
|
||||||
@@ -55,7 +55,7 @@ export const api = {
|
|||||||
getActivity: (id: number) =>
|
getActivity: (id: number) =>
|
||||||
request<{ activity: Activity; laps: unknown[]; assignment?: KindAssignment }>(`/api/activities/${id}`),
|
request<{ activity: Activity; laps: unknown[]; assignment?: KindAssignment }>(`/api/activities/${id}`),
|
||||||
|
|
||||||
// Workout kinds -- fixed taxonomy, no create/delete
|
// Training types -- fixed taxonomy, no create/delete
|
||||||
listWorkoutKinds: (includeInactive = false) =>
|
listWorkoutKinds: (includeInactive = false) =>
|
||||||
request<WorkoutKind[]>(`/api/workout-kinds/${includeInactive ? "?include_inactive=true" : ""}`),
|
request<WorkoutKind[]>(`/api/workout-kinds/${includeInactive ? "?include_inactive=true" : ""}`),
|
||||||
updateWorkoutKind: (
|
updateWorkoutKind: (
|
||||||
@@ -69,7 +69,8 @@ export const api = {
|
|||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
pace_min_sec_per_km?: number | null;
|
pace_min_sec_per_km?: number | null;
|
||||||
pace_max_sec_per_km?: number | null;
|
pace_max_sec_per_km?: number | null;
|
||||||
expected_hr_zone?: number | null;
|
hr_min_pct_hrr?: number | null;
|
||||||
|
hr_max_pct_hrr?: number | null;
|
||||||
},
|
},
|
||||||
) => request<WorkoutKind>(`/api/workout-kinds/${id}`, { method: "PUT", body: JSON.stringify(body) }),
|
) => request<WorkoutKind>(`/api/workout-kinds/${id}`, { method: "PUT", body: JSON.stringify(body) }),
|
||||||
|
|
||||||
@@ -82,13 +83,34 @@ export const api = {
|
|||||||
updateProfile: (profile: Profile) =>
|
updateProfile: (profile: Profile) =>
|
||||||
request<Profile>("/api/profile", { method: "PUT", body: JSON.stringify(profile) }),
|
request<Profile>("/api/profile", { method: "PUT", body: JSON.stringify(profile) }),
|
||||||
|
|
||||||
// Review queue
|
// Review queue -- cursor-paginated: pass the previous page's next_cursor
|
||||||
reviewQueue: () => request<ReviewQueueItem[]>("/api/review-queue/"),
|
// as `before` to fetch the next one. Fetching every activity's laps and
|
||||||
|
// per-second samples up front gets expensive once there are many, so the
|
||||||
|
// frontend loads it incrementally (infinite scroll) instead of all at once
|
||||||
|
// -- kindId/unclassified filter server-side so a filtered view stays
|
||||||
|
// paginated too, instead of having to load the whole matching backlog.
|
||||||
|
reviewQueue: (params?: { limit?: number; before?: string; kindId?: number; unclassified?: boolean }) => {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params?.limit) q.set("limit", String(params.limit));
|
||||||
|
if (params?.before) q.set("before", params.before);
|
||||||
|
if (params?.kindId != null) q.set("kind_id", String(params.kindId));
|
||||||
|
if (params?.unclassified) q.set("unclassified", "true");
|
||||||
|
const qs = q.toString();
|
||||||
|
return request<ReviewQueuePage>(`/api/review-queue/${qs ? `?${qs}` : ""}`);
|
||||||
|
},
|
||||||
resolveReview: (activityId: number, workoutKindId: number) =>
|
resolveReview: (activityId: number, workoutKindId: number) =>
|
||||||
request<{ status: string }>(`/api/review-queue/${activityId}/resolve`, {
|
request<{ status: string }>(`/api/review-queue/${activityId}/resolve`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ workout_kind_id: workoutKindId }),
|
body: JSON.stringify({ workout_kind_id: workoutKindId }),
|
||||||
}),
|
}),
|
||||||
|
// Reverts a manual assignment back to rule_engine sourcing (same kind),
|
||||||
|
// so a later reclassify pass is free to change it again.
|
||||||
|
unlockReview: (activityId: number) =>
|
||||||
|
request<{ status: string }>(`/api/review-queue/${activityId}/unlock`, { method: "POST" }),
|
||||||
|
// Manually clears an activity's kind back to Unclassified (locks that
|
||||||
|
// decision, same as resolveReview locks a specific kind).
|
||||||
|
unassignReview: (activityId: number) =>
|
||||||
|
request<{ status: string }>(`/api/review-queue/${activityId}/unassign`, { method: "POST" }),
|
||||||
|
|
||||||
// Progression
|
// Progression
|
||||||
progression: (kindId: number, metric: ProgressionMetric = "pace", from?: string, to?: string) => {
|
progression: (kindId: number, metric: ProgressionMetric = "pace", from?: string, to?: string) => {
|
||||||
|
|||||||
16
frontend/src/components/ColorField.tsx
Normal file
16
frontend/src/components/ColorField.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export function ColorField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label>
|
||||||
|
{label}
|
||||||
|
<input type="color" value={value} onChange={(e) => onChange(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,10 @@ export function GarminConnection() {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
|
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
|
||||||
|
// There's no real Garmin logout -- the backend session just sits idle.
|
||||||
|
// "Disconnect" only hides that and shows Connect again locally; a real
|
||||||
|
// login (connect()) clears it.
|
||||||
|
const [disconnected, setDisconnected] = useState(false);
|
||||||
|
|
||||||
function refreshStatus() {
|
function refreshStatus() {
|
||||||
api.authStatus().then(setAuth).catch((e) => setError(String(e)));
|
api.authStatus().then(setAuth).catch((e) => setError(String(e)));
|
||||||
@@ -47,6 +51,7 @@ export function GarminConnection() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
setAuth(await api.login());
|
setAuth(await api.login());
|
||||||
|
setDisconnected(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -54,6 +59,10 @@ export function GarminConnection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
setDisconnected(true);
|
||||||
|
}
|
||||||
|
|
||||||
async function submitMFA() {
|
async function submitMFA() {
|
||||||
if (!code.trim()) return;
|
if (!code.trim()) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -90,7 +99,7 @@ export function GarminConnection() {
|
|||||||
try {
|
try {
|
||||||
await api.resetSync();
|
await api.resetSync();
|
||||||
// Reset runs as a background sync job; wait for it to actually finish
|
// Reset runs as a background sync job; wait for it to actually finish
|
||||||
// before reloading, otherwise other pages (e.g. Review Queue) would
|
// before reloading, otherwise other pages (e.g. Activities) would
|
||||||
// still show the just-deleted activities from their own stale state.
|
// still show the just-deleted activities from their own stale state.
|
||||||
let status = await api.syncStatus();
|
let status = await api.syncStatus();
|
||||||
while (status.in_progress) {
|
while (status.in_progress) {
|
||||||
@@ -104,19 +113,12 @@ export function GarminConnection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = auth?.status ?? "unknown";
|
const status = disconnected ? "unknown" : auth?.status ?? "unknown";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="garmin-connection">
|
<div className="garmin-connection">
|
||||||
<div className="garmin-connection-row">
|
<div className="garmin-connection-row garmin-connection-main">
|
||||||
<span className={`status-dot status-${status}`} />
|
<div className="garmin-connection-actions">
|
||||||
<span className="status-label">
|
|
||||||
{status === "authenticated" && "Connected to Garmin"}
|
|
||||||
{status === "mfa_required" && "MFA code required"}
|
|
||||||
{status === "failed" && "Connection failed"}
|
|
||||||
{status === "unknown" && "Not connected to Garmin"}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{status !== "authenticated" && status !== "mfa_required" && (
|
{status !== "authenticated" && status !== "mfa_required" && (
|
||||||
<button disabled={busy} onClick={connect}>
|
<button disabled={busy} onClick={connect}>
|
||||||
Connect to Garmin
|
Connect to Garmin
|
||||||
@@ -128,21 +130,31 @@ export function GarminConnection() {
|
|||||||
<button disabled={busy} onClick={sync}>
|
<button disabled={busy} onClick={sync}>
|
||||||
Sync now
|
Sync now
|
||||||
</button>
|
</button>
|
||||||
|
<button disabled={busy} onClick={disconnect}>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reset all only wipes local DB state (see handleSyncReset) -- it
|
||||||
|
doesn't touch the Garmin session, so it stays available even
|
||||||
|
while disconnected/not-yet-connected. */}
|
||||||
<button className="button-danger" disabled={busy} onClick={resetAll}>
|
<button className="button-danger" disabled={busy} onClick={resetAll}>
|
||||||
Reset all
|
Reset all
|
||||||
</button>
|
</button>
|
||||||
{syncStatus?.in_progress && (
|
</div>
|
||||||
<span className="status-label">{syncProgressLabel(syncStatus)}</span>
|
|
||||||
)}
|
<div className="garmin-connection-status">
|
||||||
{syncStatus?.last_run && !syncStatus.in_progress && (
|
<span className={`status-dot status-${status}`} />
|
||||||
|
{/* Connected/not-connected is already conveyed by the
|
||||||
|
Connect/Disconnect button itself -- only MFA/failed need a
|
||||||
|
label, since no button distinguishes those from "not connected". */}
|
||||||
|
{(status === "mfa_required" || status === "failed") && (
|
||||||
<span className="status-label">
|
<span className="status-label">
|
||||||
last sync: {syncStatus.last_run.Status} ({syncStatus.last_run.ActivitiesFetched} activities)
|
{status === "mfa_required" ? "MFA code required" : "Connection failed"}
|
||||||
{syncStatus.activities_pending_details > 0 &&
|
|
||||||
` — ${syncStatus.activities_pending_details} still need details, click Sync now again`}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status === "mfa_required" && (
|
{status === "mfa_required" && (
|
||||||
@@ -159,7 +171,21 @@ export function GarminConnection() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{auth?.message && <p className="garmin-connection-message">{auth.message}</p>}
|
{!disconnected && auth?.message && <p className="garmin-connection-message">{auth.message}</p>}
|
||||||
|
|
||||||
|
{/* Full-width so a long "last sync"/pending-details message has room
|
||||||
|
to breathe, instead of being squeezed next to the status dot. */}
|
||||||
|
{status === "authenticated" && syncStatus?.in_progress && (
|
||||||
|
<p className="garmin-connection-message">{syncProgressLabel(syncStatus)}</p>
|
||||||
|
)}
|
||||||
|
{status === "authenticated" && syncStatus?.last_run && !syncStatus.in_progress && (
|
||||||
|
<p className="garmin-connection-message">
|
||||||
|
last sync: {syncStatus.last_run.Status} ({syncStatus.last_run.ActivitiesFetched} activities)
|
||||||
|
{syncStatus.activities_pending_details > 0 &&
|
||||||
|
` — ${syncStatus.activities_pending_details} still need details, click Sync now again`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
20
frontend/src/components/NullableNumberField.tsx
Normal file
20
frontend/src/components/NullableNumberField.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export function NullableNumberField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number | null;
|
||||||
|
onChange: (v: number | null) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label>
|
||||||
|
{label}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value ?? ""}
|
||||||
|
onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
frontend/src/components/PaceField.tsx
Normal file
66
frontend/src/components/PaceField.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
// Pace is entered/displayed as "m:ss" but stored as whole seconds. An empty
|
||||||
|
// input means "not set" (null), not zero.
|
||||||
|
export function parsePace(text: string): number | null {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed === "") return null;
|
||||||
|
const match = /^(\d+):([0-5]?\d)$/.exec(trimmed);
|
||||||
|
if (!match) return null;
|
||||||
|
return Number(match[1]) * 60 + Number(match[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rounds the total seconds first, then splits into minutes/seconds -- doing
|
||||||
|
// it the other way around (floor the minutes, round the leftover seconds
|
||||||
|
// separately) can round e.g. 479.6s up to "7:60" instead of carrying over to
|
||||||
|
// "8:00".
|
||||||
|
export function formatMinutesSeconds(totalSeconds: number): string {
|
||||||
|
const total = Math.round(totalSeconds);
|
||||||
|
const m = Math.floor(total / 60);
|
||||||
|
const s = total % 60;
|
||||||
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPace(seconds: number | null): string {
|
||||||
|
if (seconds == null) return "";
|
||||||
|
return formatMinutesSeconds(seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaceField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number | null;
|
||||||
|
onChange: (v: number | null) => void;
|
||||||
|
}) {
|
||||||
|
const [text, setText] = useState(formatPace(value));
|
||||||
|
useEffect(() => setText(formatPace(value)), [value]);
|
||||||
|
|
||||||
|
function commit(raw: string) {
|
||||||
|
if (raw.trim() === "") {
|
||||||
|
onChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = parsePace(raw);
|
||||||
|
if (parsed != null) {
|
||||||
|
onChange(parsed);
|
||||||
|
} else {
|
||||||
|
setText(formatPace(value)); // invalid input -- revert to the last valid value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label>
|
||||||
|
{label}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onBlur={(e) => commit(e.target.value)}
|
||||||
|
placeholder="12:00"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
// Fields whose value is itself a JSON string (Garmin's raw payloads, kept
|
// Fields whose value is itself a JSON string (Garmin's raw payloads, kept
|
||||||
// verbatim in the DB). Parsed back into objects so the modal shows one
|
// verbatim in the DB). Parsed back into objects so the tree shows a real
|
||||||
// readable nested tree instead of an escaped string blob.
|
// nested structure instead of an escaped string blob. Every other field is
|
||||||
const RAW_JSON_STRING_FIELDS = new Set(["RawJSON", "DetailsRawJSON"]);
|
// shown as-is, even ones that duplicate something inside these blobs --
|
||||||
|
// the backend's own duplication cleanup (2026-07) already removed every
|
||||||
|
// field that had no good reason to exist alongside RawJSON, so anything
|
||||||
|
// left here is worth seeing in full.
|
||||||
|
const RAW_JSON_STRING_FIELDS = new Set(["RawJSON", "DetailsRawJSON", "WorkoutRawJSON"]);
|
||||||
|
|
||||||
function parseEmbeddedJSON(value: unknown): unknown {
|
function parseEmbeddedJSON(value: unknown): unknown {
|
||||||
if (Array.isArray(value)) return value.map(parseEmbeddedJSON);
|
if (Array.isArray(value)) return value.map(parseEmbeddedJSON);
|
||||||
@@ -26,21 +30,147 @@ function parseEmbeddedJSON(value: unknown): unknown {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Indentation per nesting level, in px. Array levels use a smaller step than
|
||||||
|
// object levels -- a numeric index carries far less structure than a named
|
||||||
|
// key, and Garmin payloads have long, deeply-repeated arrays (samples, laps,
|
||||||
|
// workout steps) where full per-level indentation quickly eats the width
|
||||||
|
// available for the values themselves.
|
||||||
|
const OBJECT_INDENT = 12;
|
||||||
|
const ARRAY_INDENT = 6;
|
||||||
|
|
||||||
|
type ValueKind = "object" | "array" | "string" | "number" | "boolean" | "null";
|
||||||
|
|
||||||
|
function kindOf(v: unknown): ValueKind {
|
||||||
|
if (v === null) return "null";
|
||||||
|
if (Array.isArray(v)) return "array";
|
||||||
|
if (typeof v === "object") return "object";
|
||||||
|
if (typeof v === "number") return "number";
|
||||||
|
if (typeof v === "boolean") return "boolean";
|
||||||
|
return "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bumped by the Expand/Collapse all buttons to force every node's local
|
||||||
|
// fold state, overriding whatever the user clicked individually before.
|
||||||
|
type ExpandSignal = { gen: number; expand: boolean } | null;
|
||||||
|
|
||||||
|
function Primitive({ value }: { value: unknown }) {
|
||||||
|
const kind = kindOf(value);
|
||||||
|
if (kind === "string") return <span className="json-string">{`"${value as string}"`}</span>;
|
||||||
|
if (kind === "null") return <span className="json-null">null</span>;
|
||||||
|
return <span className={`json-${kind}`}>{String(value)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function JsonNode({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
depth,
|
||||||
|
indentPx,
|
||||||
|
signal,
|
||||||
|
}: {
|
||||||
|
name: string | null;
|
||||||
|
value: unknown;
|
||||||
|
depth: number;
|
||||||
|
indentPx: number;
|
||||||
|
signal: ExpandSignal;
|
||||||
|
}) {
|
||||||
|
const kind = kindOf(value);
|
||||||
|
const isContainer = kind === "object" || kind === "array";
|
||||||
|
const entries: Array<[string, unknown]> = !isContainer
|
||||||
|
? []
|
||||||
|
: kind === "array"
|
||||||
|
? (value as unknown[]).map((v, i) => [String(i), v])
|
||||||
|
: Object.entries(value as Record<string, unknown>);
|
||||||
|
|
||||||
|
// Deeply nested or very large containers (raw Garmin payloads, per-second
|
||||||
|
// sample arrays) start folded so opening the modal doesn't render
|
||||||
|
// thousands of rows up front; shallow, modestly sized ones start open.
|
||||||
|
const [expanded, setExpanded] = useState(depth < 2 && entries.length <= 50);
|
||||||
|
useEffect(() => {
|
||||||
|
if (signal) setExpanded(signal.expand);
|
||||||
|
}, [signal]);
|
||||||
|
|
||||||
|
const indent = { paddingLeft: indentPx };
|
||||||
|
const childIndentPx = indentPx + (kind === "array" ? ARRAY_INDENT : OBJECT_INDENT);
|
||||||
|
|
||||||
|
if (!isContainer) {
|
||||||
|
return (
|
||||||
|
<div className="json-row" style={indent}>
|
||||||
|
{name != null && <span className="json-key">{name}: </span>}
|
||||||
|
<Primitive value={value} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [open, close] = kind === "array" ? ["[", "]"] : ["{", "}"];
|
||||||
|
return (
|
||||||
|
<div className="json-row" style={indent}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="json-toggle"
|
||||||
|
onClick={() => setExpanded((e) => !e)}
|
||||||
|
aria-label={expanded ? "Collapse" : "Expand"}
|
||||||
|
>
|
||||||
|
{expanded ? "▾" : "▸"}
|
||||||
|
</button>
|
||||||
|
{name != null && <span className="json-key">{name}: </span>}
|
||||||
|
{expanded ? (
|
||||||
|
<>
|
||||||
|
<span className="json-bracket">{open}</span>
|
||||||
|
{entries.map(([k, v]) => (
|
||||||
|
<JsonNode key={k} name={k} value={v} depth={depth + 1} indentPx={childIndentPx} signal={signal} />
|
||||||
|
))}
|
||||||
|
<div className="json-bracket" style={indent}>
|
||||||
|
{close}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="json-summary">
|
||||||
|
{open}
|
||||||
|
{entries.length} {kind === "array" ? "items" : "keys"}
|
||||||
|
{close}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function RawDataModal({ data, onClose }: { data: unknown; onClose: () => void }) {
|
export function RawDataModal({ data, onClose }: { data: unknown; onClose: () => void }) {
|
||||||
|
const [signal, setSignal] = useState<ExpandSignal>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
const onKeyDown = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||||
window.addEventListener("keydown", onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
|
const parsed = parseEmbeddedJSON(data);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-backdrop" onClick={onClose}>
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<span>Raw data</span>
|
<span>Raw data</span>
|
||||||
<button onClick={onClose}>Close</button>
|
<div className="modal-header-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSignal((s) => ({ gen: (s?.gen ?? 0) + 1, expand: true }))}
|
||||||
|
>
|
||||||
|
Expand all
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSignal((s) => ({ gen: (s?.gen ?? 0) + 1, expand: false }))}
|
||||||
|
>
|
||||||
|
Collapse all
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-json">
|
||||||
|
<JsonNode name={null} value={parsed} depth={0} indentPx={0} signal={signal} />
|
||||||
</div>
|
</div>
|
||||||
<pre className="modal-json">{JSON.stringify(parseEmbeddedJSON(data), null, 2)}</pre>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
127
frontend/src/components/TrainingTypesCard.tsx
Normal file
127
frontend/src/components/TrainingTypesCard.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import { NullableNumberField } from "./NullableNumberField";
|
||||||
|
import { PaceField, formatPace } from "./PaceField";
|
||||||
|
import type { WorkoutKind } from "../types/api";
|
||||||
|
|
||||||
|
// RuleJSON still exists on WorkoutKind and still drives the rule engine for
|
||||||
|
// kinds configured earlier, but it's not edited here: for now a training
|
||||||
|
// type is described in free text (used later to assist an AI-based
|
||||||
|
// auto-sort) plus a pace/HR range for reference, and whatever rule a kind
|
||||||
|
// already has is carried through untouched on save.
|
||||||
|
|
||||||
|
function paceRangeText(min: number | null, max: number | null): string | null {
|
||||||
|
if (min == null && max == null) return null;
|
||||||
|
if (min != null && max != null) return `${formatPace(min)}–${formatPace(max)}/km`;
|
||||||
|
return min != null ? `from ${formatPace(min)}/km` : `up to ${formatPace(max)}/km`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hrRangeText(min: number | null, max: number | null): string | null {
|
||||||
|
if (min == null && max == null) return null;
|
||||||
|
if (min != null && max != null) return `${min}–${max}% HRR`;
|
||||||
|
return min != null ? `from ${min}% HRR` : `up to ${max}% HRR`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrainingTypesCard() {
|
||||||
|
const [kinds, setKinds] = useState<WorkoutKind[]>([]);
|
||||||
|
const [editing, setEditing] = useState<WorkoutKind | null>(null);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [paceMin, setPaceMin] = useState<number | null>(null);
|
||||||
|
const [paceMax, setPaceMax] = useState<number | null>(null);
|
||||||
|
const [hrMin, setHrMin] = useState<number | null>(null);
|
||||||
|
const [hrMax, setHrMax] = useState<number | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
api.listWorkoutKinds(true).then(setKinds).catch((e) => setError(String(e)));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(reload, []);
|
||||||
|
|
||||||
|
function startEdit(k: WorkoutKind) {
|
||||||
|
setEditing(k);
|
||||||
|
setName(k.Name);
|
||||||
|
setDescription(k.Description);
|
||||||
|
setPaceMin(k.pace_min_sec_per_km);
|
||||||
|
setPaceMax(k.pace_max_sec_per_km);
|
||||||
|
setHrMin(k.hr_min_pct_hrr);
|
||||||
|
setHrMax(k.hr_max_pct_hrr);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!editing) return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.updateWorkoutKind(editing.ID, {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
rule: JSON.parse(editing.RuleJSON || "{}"),
|
||||||
|
pace_min_sec_per_km: paceMin,
|
||||||
|
pace_max_sec_per_km: paceMax,
|
||||||
|
hr_min_pct_hrr: hrMin,
|
||||||
|
hr_max_pct_hrr: hrMax,
|
||||||
|
});
|
||||||
|
setEditing(null);
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset className="kind-editor">
|
||||||
|
<legend>Training types</legend>
|
||||||
|
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
|
<ul className="training-type-list">
|
||||||
|
{kinds.map((k) => (
|
||||||
|
<li key={k.ID} className="training-type-item">
|
||||||
|
{editing?.ID === k.ID ? (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Description
|
||||||
|
<textarea rows={4} value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="controls">
|
||||||
|
<PaceField label="Min pace (m:ss/km)" value={paceMin} onChange={setPaceMin} />
|
||||||
|
<PaceField label="Max pace (m:ss/km)" value={paceMax} onChange={setPaceMax} />
|
||||||
|
</div>
|
||||||
|
<div className="controls">
|
||||||
|
<NullableNumberField label="Min heart rate (% HRR)" value={hrMin} onChange={setHrMin} />
|
||||||
|
<NullableNumberField label="Max heart rate (% HRR)" value={hrMax} onChange={setHrMax} />
|
||||||
|
</div>
|
||||||
|
<div className="kind-editor-actions">
|
||||||
|
<button onClick={save}>Save</button>
|
||||||
|
<button onClick={() => setEditing(null)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="training-type-header">
|
||||||
|
<strong>{k.Name}</strong>
|
||||||
|
<button onClick={() => startEdit(k)}>Edit</button>
|
||||||
|
</div>
|
||||||
|
<p className="training-type-description">{k.Description || "No description yet."}</p>
|
||||||
|
<p className="training-type-meta">
|
||||||
|
{[
|
||||||
|
paceRangeText(k.pace_min_sec_per_km, k.pace_max_sec_per_km),
|
||||||
|
hrRangeText(k.hr_min_pct_hrr, k.hr_max_pct_hrr),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ") || "No target pace or heart rate set."}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +1,51 @@
|
|||||||
import { Area, AreaChart, Line, ReferenceArea, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
import { Area, AreaChart, Line, ReferenceArea, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
|
import { formatMinutesSeconds } from "../PaceField";
|
||||||
import type { Lap, Sample } from "../../types/api";
|
import type { Lap, Sample } from "../../types/api";
|
||||||
|
|
||||||
// Speeds below this read as an implausibly slow "pace" (20:00/km is well
|
|
||||||
// past even a very slow walk) -- in practice these come from GPS/motion
|
|
||||||
// still settling right as recording starts, before the run itself begins,
|
|
||||||
// not a real pace. Treating them as unknown keeps that artifact out of both
|
|
||||||
// the trace and the axis scale it would otherwise blow out.
|
|
||||||
const MAX_PLAUSIBLE_PACE_SEC_PER_KM = 1200;
|
|
||||||
|
|
||||||
function paceSecPerKm(mps: number | null): number | null {
|
function paceSecPerKm(mps: number | null): number | null {
|
||||||
if (mps == null || mps <= 0) return null;
|
if (mps == null || mps <= 0) return null;
|
||||||
const pace = 1000 / mps;
|
return 1000 / mps;
|
||||||
return pace <= MAX_PLAUSIBLE_PACE_SEC_PER_KM ? pace : null;
|
}
|
||||||
|
|
||||||
|
// Drops brief pace "artifacts" -- e.g. GPS/motion still settling right as
|
||||||
|
// recording starts, before the run itself begins -- without hiding a real
|
||||||
|
// stop or walk break. A stretch of consecutive points slower than
|
||||||
|
// minRepresentativePaceSecPerKm (including points with no pace at all,
|
||||||
|
// i.e. truly stationary) is nulled out unless it lasts at least
|
||||||
|
// minRepresentativeTimeSeconds, in which case it's kept as-is: long enough
|
||||||
|
// to be a real part of the run, not noise. A pace threshold of 0 (or below)
|
||||||
|
// disables filtering entirely.
|
||||||
|
function filterPaceArtifacts(
|
||||||
|
points: Array<{ t: number; pace: number | null }>,
|
||||||
|
minRepresentativePaceSecPerKm: number,
|
||||||
|
minRepresentativeTimeSeconds: number,
|
||||||
|
): Array<number | null> {
|
||||||
|
const result: Array<number | null> = points.map((p) => p.pace);
|
||||||
|
if (minRepresentativePaceSecPerKm <= 0) return result;
|
||||||
|
|
||||||
|
const isSlowOrStopped = (pace: number | null) => pace == null || pace > minRepresentativePaceSecPerKm;
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
while (i < points.length) {
|
||||||
|
if (!isSlowOrStopped(points[i].pace)) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let j = i;
|
||||||
|
while (j < points.length && isSlowOrStopped(points[j].pace)) j++;
|
||||||
|
// "Not lasting more than" the threshold means a run exactly at the
|
||||||
|
// threshold still counts as an artifact, hence <= rather than <.
|
||||||
|
const durationSeconds = (points[j - 1].t - points[i].t) * 60;
|
||||||
|
if (durationSeconds <= minRepresentativeTimeSeconds) {
|
||||||
|
for (let k = i; k < j; k++) result[k] = null;
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPaceShort(secPerKm: number): string {
|
function formatPaceShort(secPerKm: number): string {
|
||||||
const m = Math.floor(secPerKm / 60);
|
return formatMinutesSeconds(secPerKm);
|
||||||
const s = Math.round(secPerKm % 60);
|
|
||||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPace(secPerKm: number): string {
|
function formatPace(secPerKm: number): string {
|
||||||
@@ -25,9 +53,7 @@ function formatPace(secPerKm: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatElapsed(minutes: number): string {
|
function formatElapsed(minutes: number): string {
|
||||||
const m = Math.floor(minutes);
|
return formatMinutesSeconds(minutes * 60);
|
||||||
const s = Math.round((minutes - m) * 60);
|
|
||||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function percentile(sortedAsc: number[], p: number): number {
|
function percentile(sortedAsc: number[], p: number): number {
|
||||||
@@ -67,7 +93,7 @@ function robustDomain(actualValues: Array<number | null | undefined>, targetValu
|
|||||||
// Picks a small number of evenly-spaced ticks across the domain, each
|
// Picks a small number of evenly-spaced ticks across the domain, each
|
||||||
// snapped to a round increment (30s for pace, 5-20bpm for HR depending on
|
// snapped to a round increment (30s for pace, 5-20bpm for HR depending on
|
||||||
// range). A fixed, small count -- rather than every increment in range --
|
// range). A fixed, small count -- rather than every increment in range --
|
||||||
// keeps labels legible and non-overlapping in a ~100px-tall mini chart.
|
// keeps labels legible and non-overlapping in a ~200px-tall mini chart.
|
||||||
// Recharts' own collision-avoidance would otherwise silently drop most of a
|
// Recharts' own collision-avoidance would otherwise silently drop most of a
|
||||||
// denser tick set anyway.
|
// denser tick set anyway.
|
||||||
function niceTicks([lo, hi]: [number, number], step: number, count = 3): number[] {
|
function niceTicks([lo, hi]: [number, number], step: number, count = 3): number[] {
|
||||||
@@ -86,27 +112,48 @@ function hrTickStep(domain: [number, number]): number {
|
|||||||
return 5;
|
return 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pace and HR each keep one dedicated accent color across every chart, so
|
// Color policy (all 6 colors configurable in Profile > Chart colors):
|
||||||
// the two metrics stay visually distinct from each other and from the phase
|
// - Each chart's main line (the actual pace/HR trace and its dashed phase
|
||||||
// bands below.
|
// average) is always its metric's own color -- blue for pace, red for HR
|
||||||
const PACE_COLOR = "#3b82f6"; // blue
|
// -- regardless of effort kind.
|
||||||
const HR_COLOR = "#ef4444"; // red
|
// - The 4 effort-kind colors (warm-up/effort/recovery/cool-down) drive two
|
||||||
|
// derived fills: the area *under* the line is the effort color with a
|
||||||
|
// subtle tint of the main line color mixed in (mixColor); the background
|
||||||
|
// *above* the line (the full-height band behind everything) is the same
|
||||||
|
// effort color, darkened, with no main-line tint (darken).
|
||||||
|
function hexToRgb(hex: string): [number, number, number] {
|
||||||
|
const clean = hex.replace("#", "");
|
||||||
|
const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
|
||||||
|
const n = parseInt(full, 16) || 0;
|
||||||
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToHex([r, g, b]: [number, number, number]): string {
|
||||||
|
const toHex = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0");
|
||||||
|
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mixes `weight` (0-1) of `tint` into `base` -- e.g. mixColor(effortColor,
|
||||||
|
// paceColor, 0.2) reads as "effort color with a subtle taint of pace blue".
|
||||||
|
function mixColor(base: string, tint: string, weight: number): string {
|
||||||
|
const b = hexToRgb(base);
|
||||||
|
const t = hexToRgb(tint);
|
||||||
|
return rgbToHex([0, 1, 2].map((i) => b[i] + (t[i] - b[i]) * weight) as [number, number, number]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Darkens `hex` by mixing in `amount` (0-1) of black -- no main-line tint,
|
||||||
|
// just a deeper shade of the same effort color.
|
||||||
|
function darken(hex: string, amount: number): string {
|
||||||
|
return mixColor(hex, "#000000", amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brightens `hex` by mixing in `amount` (0-1) of white -- the color cue that
|
||||||
|
// a chart has a structured-workout target range to show, replacing a text
|
||||||
|
// label (it's otherwise not visually obvious a target exists).
|
||||||
|
function brighten(hex: string, amount: number): string {
|
||||||
|
return mixColor(hex, "#ffffff", amount);
|
||||||
|
}
|
||||||
|
|
||||||
// Phase a lap represents, by Garmin's own per-lap IntensityType tagging.
|
|
||||||
// Colors distinguish warm-up / effort / recovery / cool-down bands behind
|
|
||||||
// the pace/HR trace so a run's structure reads at a glance -- deliberately
|
|
||||||
// avoiding blue/red, since those are already spoken for by PACE_COLOR/HR_COLOR.
|
|
||||||
// ACTIVE and INTERVAL both mean "this is a work/effort segment" -- Garmin
|
|
||||||
// uses ACTIVE for structured-workout steps and INTERVAL for manually-lapped
|
|
||||||
// or auto-detected effort segments, but they're the same concept here.
|
|
||||||
const PHASE_COLORS: Record<string, string> = {
|
|
||||||
WARMUP: "#f59e0b", // amber
|
|
||||||
ACTIVE: "#ec4899", // pink
|
|
||||||
INTERVAL: "#ec4899", // pink
|
|
||||||
REST: "#14b8a6", // teal
|
|
||||||
RECOVERY: "#14b8a6", // teal
|
|
||||||
COOLDOWN: "#8b5cf6", // violet
|
|
||||||
};
|
|
||||||
const PHASE_LABELS: Record<string, string> = {
|
const PHASE_LABELS: Record<string, string> = {
|
||||||
WARMUP: "Warm-up",
|
WARMUP: "Warm-up",
|
||||||
ACTIVE: "Effort",
|
ACTIVE: "Effort",
|
||||||
@@ -131,19 +178,29 @@ interface Point {
|
|||||||
t: number;
|
t: number;
|
||||||
actualPace: number | null;
|
actualPace: number | null;
|
||||||
targetPaceRange: [number, number] | null;
|
targetPaceRange: [number, number] | null;
|
||||||
warmupCooldownAvgPace: number | null;
|
phaseAvgPace: number | null;
|
||||||
actualHR: number | null;
|
actualHR: number | null;
|
||||||
targetHRRange: [number, number] | null;
|
targetHRRange: [number, number] | null;
|
||||||
warmupCooldownAvgHR: number | null;
|
phaseAvgHR: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLapWindows(laps: Lap[]): LapWindow[] {
|
function buildLapWindows(laps: Lap[], phaseColors: Record<string, string>): LapWindow[] {
|
||||||
const windows: LapWindow[] = [];
|
const windows: LapWindow[] = [];
|
||||||
let elapsedMin = 0;
|
let elapsedMin = 0;
|
||||||
for (const l of laps) {
|
laps.forEach((l, i) => {
|
||||||
const start = elapsedMin;
|
const start = elapsedMin;
|
||||||
elapsedMin += l.DurationSeconds / 60;
|
elapsedMin += l.DurationSeconds / 60;
|
||||||
|
|
||||||
|
// A lap whose type merely repeats the previous lap's (e.g. a second
|
||||||
|
// "cool-down" lap right after the first) means the recording continued
|
||||||
|
// past the end of that step -- confirmed against Garmin Connect's own
|
||||||
|
// workout view for a real activity where this happened: the run kept
|
||||||
|
// going well past the prescribed cool-down, and Garmin logged the extra
|
||||||
|
// time as a further same-type lap that was never actually part of the
|
||||||
|
// workout. Only the first lap of such a run carries a real prescribed
|
||||||
|
// target; the continuation's target/HR fields are suppressed.
|
||||||
|
const isContinuation = i > 0 && laps[i - 1].IntensityType === l.IntensityType;
|
||||||
|
|
||||||
// Pace (sec/km) is inverted vs speed (m/s): the *faster* (higher) speed
|
// Pace (sec/km) is inverted vs speed (m/s): the *faster* (higher) speed
|
||||||
// bound is the *lower* (faster) pace bound.
|
// bound is the *lower* (faster) pace bound.
|
||||||
const paceLow = l.TargetPaceHighMps != null ? paceSecPerKm(l.TargetPaceHighMps) : null;
|
const paceLow = l.TargetPaceHighMps != null ? paceSecPerKm(l.TargetPaceHighMps) : null;
|
||||||
@@ -153,13 +210,13 @@ function buildLapWindows(laps: Lap[]): LapWindow[] {
|
|||||||
start,
|
start,
|
||||||
end: elapsedMin,
|
end: elapsedMin,
|
||||||
intensityType: l.IntensityType,
|
intensityType: l.IntensityType,
|
||||||
targetPaceRange: paceLow != null && paceHigh != null ? [paceLow, paceHigh] : null,
|
targetPaceRange: !isContinuation && paceLow != null && paceHigh != null ? [paceLow, paceHigh] : null,
|
||||||
targetHRRange: l.TargetHRLowBpm != null && l.TargetHRHighBpm != null ? [l.TargetHRLowBpm, l.TargetHRHighBpm] : null,
|
targetHRRange: !isContinuation && l.TargetHRLowBpm != null && l.TargetHRHighBpm != null ? [l.TargetHRLowBpm, l.TargetHRHighBpm] : null,
|
||||||
avgPace: paceSecPerKm(l.AvgSpeedMps),
|
avgPace: paceSecPerKm(l.AvgSpeedMps),
|
||||||
avgHR: l.AvgHR,
|
avgHR: l.AvgHR,
|
||||||
color: PHASE_COLORS[l.IntensityType],
|
color: phaseColors[l.IntensityType],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
|
||||||
return windows;
|
return windows;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +227,45 @@ function lapWindowAt(t: number, windows: LapWindow[]): LapWindow | undefined {
|
|||||||
return windows.find((w) => t >= w.start && t < w.end) ?? windows[windows.length - 1];
|
return windows.find((w) => t >= w.start && t < w.end) ?? windows[windows.length - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PhaseSegment {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
intensityType: string;
|
||||||
|
avgPace: number | null;
|
||||||
|
avgHR: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merges consecutive laps of the same IntensityType into one segment (e.g. a
|
||||||
|
// two-lap cool-down becomes one), with a single duration-weighted average
|
||||||
|
// across all of its laps -- otherwise each lap would draw its own average
|
||||||
|
// line, showing e.g. two different "cool-down averages" back to back where
|
||||||
|
// there's really just one cool-down.
|
||||||
|
function buildPhaseSegments(windows: LapWindow[]): PhaseSegment[] {
|
||||||
|
const segments: PhaseSegment[] = [];
|
||||||
|
let group: LapWindow[] = [];
|
||||||
|
function flush() {
|
||||||
|
if (group.length === 0) return;
|
||||||
|
segments.push({
|
||||||
|
start: group[0].start,
|
||||||
|
end: group[group.length - 1].end,
|
||||||
|
intensityType: group[0].intensityType,
|
||||||
|
avgPace: weightedMean(group.map((w) => [w.avgPace, w.end - w.start])),
|
||||||
|
avgHR: weightedMean(group.map((w) => [w.avgHR, w.end - w.start])),
|
||||||
|
});
|
||||||
|
group = [];
|
||||||
|
}
|
||||||
|
for (const w of windows) {
|
||||||
|
if (group.length > 0 && group[group.length - 1].intensityType !== w.intensityType) flush();
|
||||||
|
group.push(w);
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function phaseSegmentAt(t: number, segments: PhaseSegment[]): PhaseSegment | undefined {
|
||||||
|
return segments.find((s) => t >= s.start && t < s.end) ?? segments[segments.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
function rangeTooltipFormatter(rangeLabel: string, formatValue: (v: number) => string) {
|
function rangeTooltipFormatter(rangeLabel: string, formatValue: (v: number) => string) {
|
||||||
return (value: unknown, name: unknown): [string, string] => {
|
return (value: unknown, name: unknown): [string, string] => {
|
||||||
if (Array.isArray(value)) return [`${formatValue(value[0])}–${formatValue(value[1])}`, rangeLabel];
|
if (Array.isArray(value)) return [`${formatValue(value[0])}–${formatValue(value[1])}`, rangeLabel];
|
||||||
@@ -177,6 +273,17 @@ function rangeTooltipFormatter(rangeLabel: string, formatValue: (v: number) => s
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function weightedMean(pairs: Array<[number | null, number]>): number | null {
|
||||||
|
let weightedSum = 0;
|
||||||
|
let totalWeight = 0;
|
||||||
|
for (const [value, weight] of pairs) {
|
||||||
|
if (value == null) continue;
|
||||||
|
weightedSum += value * weight;
|
||||||
|
totalWeight += weight;
|
||||||
|
}
|
||||||
|
return totalWeight > 0 ? weightedSum / totalWeight : null;
|
||||||
|
}
|
||||||
|
|
||||||
// Shows actual pace/HR over elapsed time for every activity that has laps,
|
// Shows actual pace/HR over elapsed time for every activity that has laps,
|
||||||
// with the workout's warm-up/effort/recovery/cool-down structure shaded
|
// with the workout's warm-up/effort/recovery/cool-down structure shaded
|
||||||
// behind it (a light vertical line marks each phase change) and the
|
// behind it (a light vertical line marks each phase change) and the
|
||||||
@@ -186,24 +293,78 @@ function rangeTooltipFormatter(rangeLabel: string, formatValue: (v: number) => s
|
|||||||
// band is the common case, not an error. Warm-up and cool-down segments,
|
// band is the common case, not an error. Warm-up and cool-down segments,
|
||||||
// which rarely have a target, instead get a dashed line at their own
|
// which rarely have a target, instead get a dashed line at their own
|
||||||
// average pace/HR so there's still a reference point to judge them against.
|
// average pace/HR so there's still a reference point to judge them against.
|
||||||
|
// A workout with only one kind of effort throughout (no warm-up/cool-down
|
||||||
|
// segmentation at all) gets that same dashed line too, spanning the whole
|
||||||
|
// activity at its overall average -- with no structure to compare against,
|
||||||
|
// the average is the only reference point available.
|
||||||
//
|
//
|
||||||
// The actual trace is built from per-second samples when available, not lap
|
// The actual trace is built from per-second samples when available, not lap
|
||||||
// averages: a lap can span many minutes, so plotting one flat value per lap
|
// averages: a lap can span many minutes, so plotting one flat value per lap
|
||||||
// would hide real within-lap variation (a single-lap hill repeat, for
|
// would hide real within-lap variation (a single-lap hill repeat, for
|
||||||
// example, would otherwise render as a dead-flat line despite the pace/HR
|
// example, would otherwise render as a dead-flat line despite the pace/HR
|
||||||
// swinging throughout it).
|
// swinging throughout it).
|
||||||
export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples: Sample[] }) {
|
export function ExpectedVsActualChart({
|
||||||
|
laps,
|
||||||
|
samples,
|
||||||
|
minRepresentativePaceSecPerKm,
|
||||||
|
minRepresentativeTimeSeconds,
|
||||||
|
paceColor,
|
||||||
|
heartRateColor,
|
||||||
|
warmupColor,
|
||||||
|
effortColor,
|
||||||
|
recoveryColor,
|
||||||
|
cooldownColor,
|
||||||
|
mainLineTintPct,
|
||||||
|
backgroundDarkenPct,
|
||||||
|
targetBrightenPct,
|
||||||
|
}: {
|
||||||
|
laps: Lap[];
|
||||||
|
samples: Sample[];
|
||||||
|
minRepresentativePaceSecPerKm: number;
|
||||||
|
minRepresentativeTimeSeconds: number;
|
||||||
|
paceColor: string;
|
||||||
|
heartRateColor: string;
|
||||||
|
warmupColor: string;
|
||||||
|
effortColor: string;
|
||||||
|
recoveryColor: string;
|
||||||
|
cooldownColor: string;
|
||||||
|
mainLineTintPct: number;
|
||||||
|
backgroundDarkenPct: number;
|
||||||
|
targetBrightenPct: number;
|
||||||
|
}) {
|
||||||
if (laps.length === 0) return null;
|
if (laps.length === 0) return null;
|
||||||
|
|
||||||
|
// ACTIVE and INTERVAL both mean "this is a work/effort segment" -- Garmin
|
||||||
|
// uses ACTIVE for structured-workout steps and INTERVAL for manually-lapped
|
||||||
|
// or auto-detected effort segments, but they're the same concept here.
|
||||||
|
// Likewise REST/RECOVERY are both "recovery".
|
||||||
|
const phaseColors: Record<string, string> = {
|
||||||
|
WARMUP: warmupColor,
|
||||||
|
ACTIVE: effortColor,
|
||||||
|
INTERVAL: effortColor,
|
||||||
|
REST: recoveryColor,
|
||||||
|
RECOVERY: recoveryColor,
|
||||||
|
COOLDOWN: cooldownColor,
|
||||||
|
};
|
||||||
|
|
||||||
const hasPaceTarget = laps.some((l) => l.TargetPaceLowMps != null && l.TargetPaceHighMps != null);
|
const hasPaceTarget = laps.some((l) => l.TargetPaceLowMps != null && l.TargetPaceHighMps != null);
|
||||||
const hasHRTarget = laps.some((l) => l.TargetHRLowBpm != null && l.TargetHRHighBpm != null);
|
const hasHRTarget = laps.some((l) => l.TargetHRLowBpm != null && l.TargetHRHighBpm != null);
|
||||||
|
// Brightened main line color is the cue that this chart has a target to
|
||||||
|
// show, replacing a "vs target" text label -- it's otherwise not visually
|
||||||
|
// obvious a target range is present versus just an unusually flat effort.
|
||||||
|
const brightenWeight = Math.max(0, Math.min(100, targetBrightenPct)) / 100;
|
||||||
|
const paceMainColor = hasPaceTarget ? brighten(paceColor, brightenWeight) : paceColor;
|
||||||
|
const hrMainColor = hasHRTarget ? brighten(heartRateColor, brightenWeight) : heartRateColor;
|
||||||
|
const singleEffortType = new Set(laps.map((l) => l.IntensityType)).size === 1;
|
||||||
|
|
||||||
const lapWindows = buildLapWindows(laps);
|
const lapWindows = buildLapWindows(laps, phaseColors);
|
||||||
const lapTotalMin = lapWindows[lapWindows.length - 1].end;
|
const lapTotalMin = lapWindows[lapWindows.length - 1].end;
|
||||||
|
const phaseSegments = buildPhaseSegments(lapWindows);
|
||||||
|
|
||||||
function warmupCooldownAvg(w: LapWindow | undefined, metric: "avgPace" | "avgHR"): number | null {
|
function phaseAvg(t: number, metric: "avgPace" | "avgHR"): number | null {
|
||||||
if (!w || (w.intensityType !== "WARMUP" && w.intensityType !== "COOLDOWN")) return null;
|
const seg = phaseSegmentAt(t, phaseSegments);
|
||||||
return w[metric];
|
if (!seg || (seg.intensityType !== "WARMUP" && seg.intensityType !== "COOLDOWN")) return null;
|
||||||
|
return seg[metric];
|
||||||
}
|
}
|
||||||
|
|
||||||
let points: Point[];
|
let points: Point[];
|
||||||
@@ -216,10 +377,10 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples:
|
|||||||
t,
|
t,
|
||||||
actualPace: paceSecPerKm(s.SpeedMps),
|
actualPace: paceSecPerKm(s.SpeedMps),
|
||||||
targetPaceRange: w?.targetPaceRange ?? null,
|
targetPaceRange: w?.targetPaceRange ?? null,
|
||||||
warmupCooldownAvgPace: warmupCooldownAvg(w, "avgPace"),
|
phaseAvgPace: phaseAvg(t, "avgPace"),
|
||||||
actualHR: s.HeartRate,
|
actualHR: s.HeartRate,
|
||||||
targetHRRange: w?.targetHRRange ?? null,
|
targetHRRange: w?.targetHRRange ?? null,
|
||||||
warmupCooldownAvgHR: warmupCooldownAvg(w, "avgHR"),
|
phaseAvgHR: phaseAvg(t, "avgHR"),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
elapsedMin = Math.max(lapTotalMin, points[points.length - 1].t);
|
elapsedMin = Math.max(lapTotalMin, points[points.length - 1].t);
|
||||||
@@ -230,21 +391,67 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples:
|
|||||||
t: w.start,
|
t: w.start,
|
||||||
actualPace: null,
|
actualPace: null,
|
||||||
targetPaceRange: w.targetPaceRange,
|
targetPaceRange: w.targetPaceRange,
|
||||||
warmupCooldownAvgPace: warmupCooldownAvg(w, "avgPace"),
|
phaseAvgPace: phaseAvg(w.start, "avgPace"),
|
||||||
actualHR: null,
|
actualHR: null,
|
||||||
targetHRRange: w.targetHRRange,
|
targetHRRange: w.targetHRRange,
|
||||||
warmupCooldownAvgHR: warmupCooldownAvg(w, "avgHR"),
|
phaseAvgHR: phaseAvg(w.start, "avgHR"),
|
||||||
}));
|
}));
|
||||||
points.push({ ...points[points.length - 1], t: lapTotalMin });
|
points.push({ ...points[points.length - 1], t: lapTotalMin });
|
||||||
elapsedMin = lapTotalMin;
|
elapsedMin = lapTotalMin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filteredActualPace = filterPaceArtifacts(
|
||||||
|
points.map((p) => ({ t: p.t, pace: p.actualPace })),
|
||||||
|
minRepresentativePaceSecPerKm,
|
||||||
|
minRepresentativeTimeSeconds,
|
||||||
|
);
|
||||||
|
points = points.map((p, i) => ({ ...p, actualPace: filteredActualPace[i] }));
|
||||||
|
|
||||||
|
if (singleEffortType) {
|
||||||
|
// The whole workout is one undifferentiated effort -- draw one flat
|
||||||
|
// average line across the entire span instead of per-lap segments.
|
||||||
|
// Duration-weighted across laps (not just their own avg fields' plain
|
||||||
|
// mean) so a long lap counts more than a short one, and computed from
|
||||||
|
// lap data rather than samples so it still works without per-second
|
||||||
|
// telemetry.
|
||||||
|
const overallAvgPace = weightedMean(lapWindows.map((w) => [w.avgPace, w.end - w.start]));
|
||||||
|
const overallAvgHR = weightedMean(lapWindows.map((w) => [w.avgHR, w.end - w.start]));
|
||||||
|
points = points.map((p) => ({ ...p, phaseAvgPace: overallAvgPace, phaseAvgHR: overallAvgHR }));
|
||||||
|
}
|
||||||
|
|
||||||
const phaseBands = lapWindows.filter((w) => w.color).map((w) => ({ x1: w.start, x2: w.end, color: w.color! }));
|
const phaseBands = lapWindows.filter((w) => w.color).map((w) => ({ x1: w.start, x2: w.end, color: w.color! }));
|
||||||
const phasesPresent = [...new Set(laps.map((l) => l.IntensityType).filter((t) => PHASE_COLORS[t]))];
|
const phasesPresent = [...new Set(laps.map((l) => l.IntensityType).filter((t) => phaseColors[t]))];
|
||||||
// A light vertical line at every point the effort type changes (warm-up
|
// A light vertical line at every lap boundary -- not just where the effort
|
||||||
// -> effort -> recovery -> ...), regardless of whether that type has a
|
// kind changes (warm-up -> effort -> recovery -> ...), but also between two
|
||||||
// background color, so the workout's structure reads at a glance.
|
// consecutive laps of the *same* kind, e.g. the workout's own cool-down lap
|
||||||
const phaseBoundaries = lapWindows.slice(1).filter((w, i) => w.intensityType !== lapWindows[i].intensityType).map((w) => w.start);
|
// followed by an extra cool-down lap logged after the recording continued
|
||||||
|
// past the prescribed target (see buildLapWindows' isContinuation). Those
|
||||||
|
// two laps get merged into one shaded phase segment (same color, same
|
||||||
|
// fill), so without this line they'd otherwise read as a single lap.
|
||||||
|
const phaseBoundaries = lapWindows.slice(1).map((w) => w.start);
|
||||||
|
|
||||||
|
// One Area per phase segment instead of one for the whole chart, so the
|
||||||
|
// fill under the line can vary by effort kind (each segment gets its own
|
||||||
|
// color) while the stroke stays the metric's single main color throughout.
|
||||||
|
// The last segment is extended to elapsedMin (not just its own end) so a
|
||||||
|
// sample slightly past the laps' own total duration is still covered --
|
||||||
|
// otherwise the last sliver of the trace would be left unfilled.
|
||||||
|
const tintWeight = Math.max(0, Math.min(100, mainLineTintPct)) / 100;
|
||||||
|
const darkenWeight = Math.max(0, Math.min(100, backgroundDarkenPct)) / 100;
|
||||||
|
function segmentsWithPoints(mainColor: string) {
|
||||||
|
return phaseSegments
|
||||||
|
.map((seg, i) => {
|
||||||
|
const color = phaseColors[seg.intensityType];
|
||||||
|
if (!color) return null;
|
||||||
|
const isLast = i === phaseSegments.length - 1;
|
||||||
|
const segPoints = points.filter((p) => p.t >= seg.start && (isLast || p.t <= seg.end));
|
||||||
|
if (segPoints.length === 0) return null;
|
||||||
|
return { key: `${seg.intensityType}-${seg.start}`, points: segPoints, fill: mixColor(color, mainColor, tintWeight) };
|
||||||
|
})
|
||||||
|
.filter((s): s is { key: string; points: Point[]; fill: string } => s != null);
|
||||||
|
}
|
||||||
|
const paceSegments = segmentsWithPoints(paceMainColor);
|
||||||
|
const hrSegments = segmentsWithPoints(hrMainColor);
|
||||||
|
|
||||||
const paceDomain = robustDomain(
|
const paceDomain = robustDomain(
|
||||||
points.map((p) => p.actualPace),
|
points.map((p) => p.actualPace),
|
||||||
@@ -264,58 +471,95 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="expected-actual-charts">
|
<div className="expected-actual-charts">
|
||||||
{phasesPresent.length > 0 && (
|
|
||||||
<div className="phase-legend">
|
|
||||||
{phasesPresent.map((phase) => (
|
|
||||||
<span key={phase} className="phase-legend-item">
|
|
||||||
<span className="phase-legend-swatch" style={{ background: PHASE_COLORS[phase] }} />
|
|
||||||
{PHASE_LABELS[phase] ?? phase}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="mini-chart">
|
<div className="mini-chart">
|
||||||
<span className="mini-chart-label">Pace{hasPaceTarget ? " vs target" : ""}</span>
|
<span className="mini-chart-label">Pace</span>
|
||||||
<ResponsiveContainer width="100%" height={100}>
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
<AreaChart data={points} margin={{ top: 4, right: 4, bottom: 0, left: 4 }}>
|
<AreaChart data={points} margin={{ top: 4, right: 4, bottom: 0, left: 4 }}>
|
||||||
{phaseBands.map((b, i) => (
|
{phaseBands.map((b, i) => (
|
||||||
<ReferenceArea key={i} x1={b.x1} x2={b.x2} fill={b.color} fillOpacity={0.18} strokeOpacity={0} />
|
<ReferenceArea key={i} x1={b.x1} x2={b.x2} fill={darken(b.color, darkenWeight)} fillOpacity={0.55} strokeOpacity={0} />
|
||||||
))}
|
))}
|
||||||
{phaseBoundaries.map((x, i) => (
|
{phaseBoundaries.map((x, i) => (
|
||||||
<ReferenceLine key={i} x={x} stroke="#ffffff" strokeOpacity={0.15} />
|
<ReferenceLine key={i} x={x} stroke="#ffffff" strokeOpacity={0.15} />
|
||||||
))}
|
))}
|
||||||
<XAxis dataKey="t" type="number" domain={[0, elapsedMin]} tick={{ fontSize: 10 }} tickFormatter={(v) => `${Math.round(Number(v))}m`} />
|
<XAxis dataKey="t" type="number" domain={[0, elapsedMin]} tick={{ fontSize: 10 }} tickFormatter={(v) => `${Math.round(Number(v))}m`} />
|
||||||
<YAxis reversed domain={paceDomain} ticks={paceTicks} interval={0} tick={{ fontSize: 10 }} width={34} tickFormatter={(v) => formatPaceShort(Number(v))} />
|
<YAxis reversed domain={paceDomain} ticks={paceTicks} interval={0} tick={{ fontSize: 10 }} width={34} tickFormatter={(v) => formatPaceShort(Number(v))} />
|
||||||
<Tooltip formatter={paceTooltipFormatter} labelFormatter={(t) => `${formatElapsed(Number(t))} elapsed`} contentStyle={{ fontSize: 12 }} />
|
<Tooltip formatter={paceTooltipFormatter} labelFormatter={(t) => `${formatElapsed(Number(t))} elapsed`} contentStyle={{ fontSize: 12, background: "#1a1d24", border: "1px solid #2a2d35", borderRadius: 6 }} labelStyle={{ color: "#9aa0ab" }} itemStyle={{ color: "#e6e6e6" }} />
|
||||||
{hasPaceTarget && (
|
{hasPaceTarget && (
|
||||||
<Area type="stepAfter" dataKey="targetPaceRange" stroke="none" fill="#9aa0ab" fillOpacity={0.25} isAnimationActive={false} name="Target range" connectNulls />
|
<Area type="stepAfter" dataKey="targetPaceRange" stroke="none" fill="#9aa0ab" fillOpacity={0.25} isAnimationActive={false} name="Target range" connectNulls />
|
||||||
)}
|
)}
|
||||||
<Area type="monotone" dataKey="actualPace" stroke={PACE_COLOR} strokeWidth={2} fill={PACE_COLOR} fillOpacity={0.3} dot={false} isAnimationActive={false} name="Actual" connectNulls />
|
{paceSegments.map((seg) => (
|
||||||
<Line type="linear" dataKey="warmupCooldownAvgPace" stroke={PACE_COLOR} strokeDasharray="4 2" strokeWidth={1.5} dot={false} isAnimationActive={false} name="Warm-up/cool-down avg" connectNulls={false} />
|
<Area
|
||||||
|
key={seg.key}
|
||||||
|
type="monotone"
|
||||||
|
data={seg.points}
|
||||||
|
dataKey="actualPace"
|
||||||
|
// The pace axis is reversed (faster/lower values drawn higher),
|
||||||
|
// which flips Recharts' default fill-to-dataMin behavior --
|
||||||
|
// without this, the fill paints above the line instead of
|
||||||
|
// below it, and the phase background ends up showing through
|
||||||
|
// underneath instead of above. dataMax is whatever renders at
|
||||||
|
// the bottom of a reversed axis, restoring "fill below the line".
|
||||||
|
baseValue="dataMax"
|
||||||
|
stroke={paceMainColor}
|
||||||
|
strokeWidth={2}
|
||||||
|
fill={seg.fill}
|
||||||
|
fillOpacity={0.85}
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
name="Actual"
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Line type="linear" dataKey="phaseAvgPace" stroke={paceMainColor} strokeDasharray="4 2" strokeWidth={1.5} dot={false} isAnimationActive={false} name="Average" connectNulls={false} />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
<div className="mini-chart">
|
<div className="mini-chart">
|
||||||
<span className="mini-chart-label">HR{hasHRTarget ? " vs target" : ""}</span>
|
<span className="mini-chart-label">Heart Rate</span>
|
||||||
<ResponsiveContainer width="100%" height={100}>
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
<AreaChart data={points} margin={{ top: 4, right: 4, bottom: 0, left: 4 }}>
|
<AreaChart data={points} margin={{ top: 4, right: 4, bottom: 0, left: 4 }}>
|
||||||
{phaseBands.map((b, i) => (
|
{phaseBands.map((b, i) => (
|
||||||
<ReferenceArea key={i} x1={b.x1} x2={b.x2} fill={b.color} fillOpacity={0.18} strokeOpacity={0} />
|
<ReferenceArea key={i} x1={b.x1} x2={b.x2} fill={darken(b.color, darkenWeight)} fillOpacity={0.55} strokeOpacity={0} />
|
||||||
))}
|
))}
|
||||||
{phaseBoundaries.map((x, i) => (
|
{phaseBoundaries.map((x, i) => (
|
||||||
<ReferenceLine key={i} x={x} stroke="#ffffff" strokeOpacity={0.15} />
|
<ReferenceLine key={i} x={x} stroke="#ffffff" strokeOpacity={0.15} />
|
||||||
))}
|
))}
|
||||||
<XAxis dataKey="t" type="number" domain={[0, elapsedMin]} tick={{ fontSize: 10 }} tickFormatter={(v) => `${Math.round(Number(v))}m`} />
|
<XAxis dataKey="t" type="number" domain={[0, elapsedMin]} tick={{ fontSize: 10 }} tickFormatter={(v) => `${Math.round(Number(v))}m`} />
|
||||||
<YAxis domain={hrDomain} ticks={hrTicks} interval={0} tick={{ fontSize: 10 }} width={30} tickFormatter={(v) => String(Math.round(Number(v)))} />
|
<YAxis domain={hrDomain} ticks={hrTicks} interval={0} tick={{ fontSize: 10 }} width={30} tickFormatter={(v) => String(Math.round(Number(v)))} />
|
||||||
<Tooltip formatter={hrTooltipFormatter} labelFormatter={(t) => `${formatElapsed(Number(t))} elapsed`} contentStyle={{ fontSize: 12 }} />
|
<Tooltip formatter={hrTooltipFormatter} labelFormatter={(t) => `${formatElapsed(Number(t))} elapsed`} contentStyle={{ fontSize: 12, background: "#1a1d24", border: "1px solid #2a2d35", borderRadius: 6 }} labelStyle={{ color: "#9aa0ab" }} itemStyle={{ color: "#e6e6e6" }} />
|
||||||
{hasHRTarget && (
|
{hasHRTarget && (
|
||||||
<Area type="stepAfter" dataKey="targetHRRange" stroke="none" fill="#9aa0ab" fillOpacity={0.25} isAnimationActive={false} name="Target range" connectNulls />
|
<Area type="stepAfter" dataKey="targetHRRange" stroke="none" fill="#9aa0ab" fillOpacity={0.25} isAnimationActive={false} name="Target range" connectNulls />
|
||||||
)}
|
)}
|
||||||
<Area type="monotone" dataKey="actualHR" stroke={HR_COLOR} strokeWidth={2} fill={HR_COLOR} fillOpacity={0.3} dot={false} isAnimationActive={false} name="Actual" connectNulls />
|
{hrSegments.map((seg) => (
|
||||||
<Line type="linear" dataKey="warmupCooldownAvgHR" stroke={HR_COLOR} strokeDasharray="4 2" strokeWidth={1.5} dot={false} isAnimationActive={false} name="Warm-up/cool-down avg" connectNulls={false} />
|
<Area
|
||||||
|
key={seg.key}
|
||||||
|
type="monotone"
|
||||||
|
data={seg.points}
|
||||||
|
dataKey="actualHR"
|
||||||
|
stroke={hrMainColor}
|
||||||
|
strokeWidth={2}
|
||||||
|
fill={seg.fill}
|
||||||
|
fillOpacity={0.85}
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
name="Actual"
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Line type="linear" dataKey="phaseAvgHR" stroke={hrMainColor} strokeDasharray="4 2" strokeWidth={1.5} dot={false} isAnimationActive={false} name="Average" connectNulls={false} />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
{phasesPresent.length > 0 && (
|
||||||
|
<div className="phase-legend">
|
||||||
|
{phasesPresent.map((phase) => (
|
||||||
|
<span key={phase} className="phase-legend-item">
|
||||||
|
<span className="phase-legend-swatch" style={{ background: phaseColors[phase] }} />
|
||||||
|
{PHASE_LABELS[phase] ?? phase}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,68 @@
|
|||||||
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
|
import { formatMinutesSeconds } from "../PaceField";
|
||||||
import type { ProgressionPoint } from "../../types/api";
|
import type { ProgressionPoint } from "../../types/api";
|
||||||
|
|
||||||
const METRIC_LABELS: Record<string, string> = {
|
const METRIC_LABELS: Record<string, string> = {
|
||||||
pace: "Pace (sec/km)",
|
pace: "Pace (m:ss/km)",
|
||||||
hr: "Avg HR (bpm)",
|
hr: "Avg HR (bpm)",
|
||||||
vo2max: "VO2max",
|
vo2max: "VO2max",
|
||||||
aerobic_te: "Aerobic Training Effect",
|
aerobic_te: "Aerobic Training Effect",
|
||||||
anaerobic_te: "Anaerobic Training Effect",
|
anaerobic_te: "Anaerobic Training Effect",
|
||||||
|
// Speed per heartbeat, scaled by 1000 for readability (the raw m/s-per-bpm
|
||||||
|
// ratio is a tiny fraction like 0.02, too small to compare at a glance).
|
||||||
|
efficiency_factor: "Efficiency Factor (×1000)",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function formatPaceShort(secPerKm: number): string {
|
||||||
|
return formatMinutesSeconds(secPerKm);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMetricValue(metric: string, value: number): string {
|
||||||
|
if (metric === "pace") return `${formatPaceShort(value)}/km`;
|
||||||
|
// Efficiency factor only really matters at whole-number granularity (11
|
||||||
|
// vs. 14) -- a fractional value like 12.34 implies precision the ×1000
|
||||||
|
// scaling doesn't actually carry.
|
||||||
|
if (metric === "efficiency_factor") return String(Math.round(value));
|
||||||
|
return value.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Axis ticks specifically (not the tooltip): with a tight, non-round domain
|
||||||
|
// (see computeDomain) Recharts interpolates tick values directly between the
|
||||||
|
// domain's exact bounds instead of snapping to round numbers, so unrounded
|
||||||
|
// ticks come out as e.g. "15.138246239532403" -- technically correct but
|
||||||
|
// unreadable, and easy to misread as a much bigger number than it is.
|
||||||
|
function formatAxisTick(metric: string, value: number): string {
|
||||||
|
if (metric === "pace") return formatPaceShort(value);
|
||||||
|
if (metric === "efficiency_factor") return String(Math.round(value));
|
||||||
|
return value.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recharts' YAxis defaults to a [0, auto] domain, which for a metric like HR
|
||||||
|
// or pace makes every real activity's variation collapse into a sliver near
|
||||||
|
// the top of the chart (starting a heart-rate axis at 0bpm, for example).
|
||||||
|
// Padding tightly around the actual data range instead makes real
|
||||||
|
// progression visible. Progression series are small (one point per
|
||||||
|
// activity/week), so plain min/max is enough -- no need for the percentile
|
||||||
|
// trimming the per-second chart in ExpectedVsActualChart.tsx uses.
|
||||||
|
function computeDomain(values: number[]): [number, number] {
|
||||||
|
if (values.length === 0) return [0, 1];
|
||||||
|
const lo = Math.min(...values);
|
||||||
|
const hi = Math.max(...values);
|
||||||
|
if (lo === hi) {
|
||||||
|
const pad = Math.abs(lo) * 0.1 || 1;
|
||||||
|
return [lo - pad, hi + pad];
|
||||||
|
}
|
||||||
|
const pad = (hi - lo) * 0.15;
|
||||||
|
return [lo - pad, hi + pad];
|
||||||
|
}
|
||||||
|
|
||||||
export function ProgressionChart({ points, metric }: { points: ProgressionPoint[]; metric: string }) {
|
export function ProgressionChart({ points, metric }: { points: ProgressionPoint[]; metric: string }) {
|
||||||
if (points.length === 0) {
|
if (points.length === 0) {
|
||||||
return <p className="empty-state">No data yet for this metric.</p>;
|
return <p className="empty-state">No data yet for this metric.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = points.map((p) => ({ date: p.date.slice(0, 10), value: p.value }));
|
const data = points.map((p) => ({ date: p.date.slice(0, 10), value: p.value }));
|
||||||
|
const domain = computeDomain(data.map((d) => d.value));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={320}>
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
@@ -23,11 +71,20 @@ export function ProgressionChart({ points, metric }: { points: ProgressionPoint[
|
|||||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
|
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: 12 }}
|
tick={{ fontSize: 12 }}
|
||||||
|
// Reversed so a *faster* (numerically lower) pace still reads as
|
||||||
|
// "better, plots higher" -- the same up-is-better convention every
|
||||||
|
// other metric here already has for free (higher HR/VO2max/etc.
|
||||||
|
// just happens to mean "more", not "worse", but pace is inverted).
|
||||||
reversed={metric === "pace"}
|
reversed={metric === "pace"}
|
||||||
|
domain={domain}
|
||||||
|
tickFormatter={(v) => formatAxisTick(metric, Number(v))}
|
||||||
label={{ value: METRIC_LABELS[metric] ?? metric, angle: -90, position: "insideLeft", fontSize: 12 }}
|
label={{ value: METRIC_LABELS[metric] ?? metric, angle: -90, position: "insideLeft", fontSize: 12 }}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value) => [Number(value).toFixed(1), METRIC_LABELS[metric] ?? metric]}
|
formatter={(value) => [formatMetricValue(metric, Number(value)), METRIC_LABELS[metric] ?? metric]}
|
||||||
|
contentStyle={{ background: "#1a1d24", border: "1px solid #2a2d35", borderRadius: 6 }}
|
||||||
|
labelStyle={{ color: "#9aa0ab" }}
|
||||||
|
itemStyle={{ color: "#e6e6e6" }}
|
||||||
/>
|
/>
|
||||||
<Line type="monotone" dataKey="value" stroke="#3b82f6" strokeWidth={2} dot={{ r: 3 }} />
|
<Line type="monotone" dataKey="value" stroke="#3b82f6" strokeWidth={2} dot={{ r: 3 }} />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { api } from "../api/client";
|
|
||||||
import type { ActivityListItem } from "../types/api";
|
|
||||||
|
|
||||||
function formatPace(avgSpeedMps: number | null): string | null {
|
|
||||||
if (avgSpeedMps == null || avgSpeedMps <= 0) return null;
|
|
||||||
const secPerKm = 1000 / avgSpeedMps;
|
|
||||||
const m = Math.floor(secPerKm / 60);
|
|
||||||
const s = Math.round(secPerKm % 60);
|
|
||||||
return `${m}:${s.toString().padStart(2, "0")}/km`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function lockReason(item: ActivityListItem): string {
|
|
||||||
if (item.assignment_source === "manual") return "Manually assigned -- kept as-is by reclassify";
|
|
||||||
if (item.workout_kind_name === "Race") return "Race, from Garmin metadata -- kept as-is by reclassify";
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Activities() {
|
|
||||||
const [items, setItems] = useState<ActivityListItem[]>([]);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.listActivities().then(setItems).catch((e) => setError(String(e)));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page">
|
|
||||||
<h2>Activities</h2>
|
|
||||||
{error && <p className="error">{error}</p>}
|
|
||||||
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<p className="empty-state">No activities synced yet.</p>
|
|
||||||
) : (
|
|
||||||
<table className="kinds-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Distance</th>
|
|
||||||
<th>Pace</th>
|
|
||||||
<th>Kind</th>
|
|
||||||
<th>Source</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{items.map((item) => {
|
|
||||||
const pace = formatPace(item.AvgSpeedMps);
|
|
||||||
return (
|
|
||||||
<tr key={item.ID}>
|
|
||||||
<td>{item.StartTimeUTC}</td>
|
|
||||||
<td>{item.ActivityName || item.ActivityType}</td>
|
|
||||||
<td>{(item.DistanceMeters / 1000).toFixed(2)} km</td>
|
|
||||||
<td>{pace ?? "—"}</td>
|
|
||||||
<td>{item.workout_kind_name ?? "—"}</td>
|
|
||||||
<td>{item.assignment_source ?? "—"}</td>
|
|
||||||
<td>
|
|
||||||
{item.locked && (
|
|
||||||
<span className="lock-badge" title={lockReason(item)}>
|
|
||||||
Locked
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,7 @@ const METRICS: { key: ProgressionMetric; label: string }[] = [
|
|||||||
{ key: "vo2max", label: "VO2max" },
|
{ key: "vo2max", label: "VO2max" },
|
||||||
{ key: "aerobic_te", label: "Aerobic Training Effect" },
|
{ key: "aerobic_te", label: "Aerobic Training Effect" },
|
||||||
{ key: "anaerobic_te", label: "Anaerobic Training Effect" },
|
{ key: "anaerobic_te", label: "Anaerobic Training Effect" },
|
||||||
|
{ key: "efficiency_factor", label: "Efficiency Factor" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
@@ -42,17 +43,16 @@ export function Dashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<h2>Progression</h2>
|
|
||||||
|
|
||||||
{kinds.length === 0 ? (
|
{kinds.length === 0 ? (
|
||||||
<p className="empty-state">
|
<p className="empty-state">
|
||||||
No workout kinds defined yet. Create one on the Workout Kinds tab to start seeing progression here.
|
No training types defined yet. See the Training types card on the Profile page to start seeing progression
|
||||||
|
here.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="controls">
|
<div className="controls">
|
||||||
<label>
|
<label>
|
||||||
Workout kind
|
Training types
|
||||||
<select
|
<select
|
||||||
value={selectedKindId ?? ""}
|
value={selectedKindId ?? ""}
|
||||||
onChange={(e) => setSelectedKindId(Number(e.target.value))}
|
onChange={(e) => setSelectedKindId(Number(e.target.value))}
|
||||||
|
|||||||
3
frontend/src/pages/Plan.tsx
Normal file
3
frontend/src/pages/Plan.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function Plan() {
|
||||||
|
return <div className="page" />;
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
|
import { ColorField } from "../components/ColorField";
|
||||||
|
import { GarminConnection } from "../components/GarminConnection";
|
||||||
|
import { NullableNumberField } from "../components/NullableNumberField";
|
||||||
|
import { PaceField } from "../components/PaceField";
|
||||||
|
import { TrainingTypesCard } from "../components/TrainingTypesCard";
|
||||||
import type { Profile as ProfileType } from "../types/api";
|
import type { Profile as ProfileType } from "../types/api";
|
||||||
|
|
||||||
function NumberField({
|
function NumberField({
|
||||||
@@ -26,71 +31,86 @@ function NumberField({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NullableNumberField({
|
// How long to wait after the last edit before actually saving, so typing a
|
||||||
label,
|
// name or a multi-digit number doesn't fire one request per keystroke --
|
||||||
value,
|
// only the last edit in a burst triggers a save, covering every field
|
||||||
onChange,
|
// touched during that burst (not just the one that triggered the timer).
|
||||||
}: {
|
const AUTO_SAVE_DELAY_MS = 600;
|
||||||
label: string;
|
|
||||||
value: number | null;
|
|
||||||
onChange: (v: number | null) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<label>
|
|
||||||
{label}
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={value ?? ""}
|
|
||||||
onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Profile() {
|
export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) {
|
||||||
const [profile, setProfile] = useState<ProfileType | null>(null);
|
const [profile, setProfile] = useState<ProfileType | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
|
// Mirrors `profile` synchronously (state updates don't apply until the
|
||||||
|
// next render), so set() always debounces from the latest edit rather
|
||||||
|
// than a stale snapshot from this render's closure.
|
||||||
|
const profileRef = useRef<ProfileType | null>(null);
|
||||||
|
profileRef.current = profile;
|
||||||
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.getProfile().then(setProfile).catch((e) => setError(String(e)));
|
api.getProfile().then(setProfile).catch((e) => setError(String(e)));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function set<K extends keyof ProfileType>(key: K, value: ProfileType[K]) {
|
// Flush a still-pending debounced save on unmount (e.g. the user edits a
|
||||||
setProfile((p) => (p ? { ...p, [key]: value } : p));
|
// field then immediately switches away from the Profile view) rather than
|
||||||
setSaved(false);
|
// silently dropping it -- there's no state left to update for, so errors
|
||||||
|
// are swallowed.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveTimeoutRef.current) {
|
||||||
|
clearTimeout(saveTimeoutRef.current);
|
||||||
|
if (profileRef.current) api.updateProfile(profileRef.current).catch(() => {});
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function save() {
|
async function persist(next: ProfileType) {
|
||||||
if (!profile) return;
|
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
const updated = await api.updateProfile(profile);
|
const updated = await api.updateProfile(next);
|
||||||
|
profileRef.current = updated;
|
||||||
setProfile(updated);
|
setProfile(updated);
|
||||||
|
setError(null);
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
|
onSaved?.(updated);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
setSaved(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function set<K extends keyof ProfileType>(key: K, value: ProfileType[K]) {
|
||||||
|
if (!profileRef.current) return;
|
||||||
|
const next = { ...profileRef.current, [key]: value };
|
||||||
|
profileRef.current = next;
|
||||||
|
setProfile(next);
|
||||||
|
setSaved(false);
|
||||||
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||||
|
saveTimeoutRef.current = setTimeout(() => persist(next), AUTO_SAVE_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<h2>Profile</h2>
|
|
||||||
{error ? <p className="error">{error}</p> : <p>Loading...</p>}
|
{error ? <p className="error">{error}</p> : <p>Loading...</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page profile-page">
|
||||||
<h2>Profile</h2>
|
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
{saved && <p className="garmin-connection-message">Saved.</p>}
|
{saved && <p className="garmin-connection-message">Saved.</p>}
|
||||||
|
|
||||||
<fieldset className="kind-editor">
|
<fieldset className="kind-editor">
|
||||||
<legend>Garmin account</legend>
|
<legend>Profile</legend>
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input type="text" value={profile.Name} onChange={(e) => set("Name", e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="kind-editor">
|
||||||
|
<legend>Garmin</legend>
|
||||||
<label>
|
<label>
|
||||||
Email
|
Email
|
||||||
<input
|
<input
|
||||||
@@ -107,28 +127,43 @@ export function Profile() {
|
|||||||
onChange={(e) => set("GarminPassword", e.target.value)}
|
onChange={(e) => set("GarminPassword", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<NumberField
|
||||||
|
label="Past sync (days)"
|
||||||
|
value={profile.BackfillHorizonDays}
|
||||||
|
onChange={(v) => set("BackfillHorizonDays", v)}
|
||||||
|
/>
|
||||||
|
<GarminConnection />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
{/* TODO: these settings currently have no explanation in the UI --
|
||||||
|
add tooltips once we settle on a pattern for that. */}
|
||||||
<fieldset className="kind-editor">
|
<fieldset className="kind-editor">
|
||||||
<legend>Classification</legend>
|
<legend>Activity analysis</legend>
|
||||||
<NumberField
|
<NumberField
|
||||||
label="Rolling window (days)"
|
label="Rolling window (days)"
|
||||||
value={profile.RollingWindowDays}
|
value={profile.RollingWindowDays}
|
||||||
onChange={(v) => set("RollingWindowDays", v)}
|
onChange={(v) => set("RollingWindowDays", v)}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset className="kind-editor">
|
|
||||||
<legend>Sync</legend>
|
|
||||||
<NumberField
|
<NumberField
|
||||||
label="Backfill horizon (days)"
|
label="Warm-up (minutes)"
|
||||||
value={profile.BackfillHorizonDays}
|
value={profile.WarmupMinutes}
|
||||||
onChange={(v) => set("BackfillHorizonDays", v)}
|
onChange={(v) => set("WarmupMinutes", v)}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="Cool-down (minutes)"
|
||||||
|
value={profile.CooldownMinutes}
|
||||||
|
onChange={(v) => set("CooldownMinutes", v)}
|
||||||
|
/>
|
||||||
|
<PaceField
|
||||||
|
label="Minimum representative pace (m:ss/km)"
|
||||||
|
value={profile.MinRepresentativePaceSecPerKm}
|
||||||
|
onChange={(v) => set("MinRepresentativePaceSecPerKm", v ?? 0)}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="Minimum representative time (seconds)"
|
||||||
|
value={profile.MinRepresentativeTimeSeconds}
|
||||||
|
onChange={(v) => set("MinRepresentativeTimeSeconds", v)}
|
||||||
/>
|
/>
|
||||||
<p className="empty-state">
|
|
||||||
How far back "Sync now" reaches when walking backward from today. Not the same as the classification rolling
|
|
||||||
window above.
|
|
||||||
</p>
|
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset className="kind-editor">
|
<fieldset className="kind-editor">
|
||||||
@@ -160,25 +195,39 @@ export function Profile() {
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset className="kind-editor">
|
<fieldset className="kind-editor">
|
||||||
<legend>Phase detection</legend>
|
<legend>Chart colors</legend>
|
||||||
<div className="controls">
|
<div className="controls">
|
||||||
<NumberField
|
<ColorField label="Pace (main line)" value={profile.PaceColor} onChange={(v) => set("PaceColor", v)} />
|
||||||
label="Warm-up (minutes)"
|
<ColorField
|
||||||
value={profile.WarmupMinutes}
|
label="Heart rate (main line)"
|
||||||
onChange={(v) => set("WarmupMinutes", v)}
|
value={profile.HeartRateColor}
|
||||||
/>
|
onChange={(v) => set("HeartRateColor", v)}
|
||||||
<NumberField
|
|
||||||
label="Cool-down (minutes)"
|
|
||||||
value={profile.CooldownMinutes}
|
|
||||||
onChange={(v) => set("CooldownMinutes", v)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="empty-state">
|
<div className="controls">
|
||||||
Applies to every workout type. Interval workouts detect warm-up/cool-down from lap data directly and don't use this setting.
|
<ColorField label="Warm-up" value={profile.WarmupColor} onChange={(v) => set("WarmupColor", v)} />
|
||||||
</p>
|
<ColorField label="Effort" value={profile.EffortColor} onChange={(v) => set("EffortColor", v)} />
|
||||||
|
<ColorField label="Recovery" value={profile.RecoveryColor} onChange={(v) => set("RecoveryColor", v)} />
|
||||||
|
<ColorField label="Cool-down" value={profile.CooldownColor} onChange={(v) => set("CooldownColor", v)} />
|
||||||
|
</div>
|
||||||
|
<NumberField
|
||||||
|
label="Main line tint on fill (%)"
|
||||||
|
value={profile.MainLineTintPct}
|
||||||
|
onChange={(v) => set("MainLineTintPct", v)}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="Background darkening (%)"
|
||||||
|
value={profile.BackgroundDarkenPct}
|
||||||
|
onChange={(v) => set("BackgroundDarkenPct", v)}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label="Brightening when a target is present (%)"
|
||||||
|
value={profile.TargetBrightenPct}
|
||||||
|
onChange={(v) => set("TargetBrightenPct", v)}
|
||||||
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<button onClick={save}>Save</button>
|
<TrainingTypesCard />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import { ExpectedVsActualChart } from "../components/charts/ExpectedVsActualChart";
|
import { ExpectedVsActualChart } from "../components/charts/ExpectedVsActualChart";
|
||||||
|
import { formatMinutesSeconds } from "../components/PaceField";
|
||||||
import { RawDataModal } from "../components/RawDataModal";
|
import { RawDataModal } from "../components/RawDataModal";
|
||||||
import type { ReviewQueueItem, ScoredKind, WorkoutKind } from "../types/api";
|
import type { Profile, ReviewQueueItem, ScoredKind, WorkoutKind } from "../types/api";
|
||||||
|
|
||||||
const UNSORTED = "__unsorted__";
|
const UNCLASSIFIED = "__unclassified__";
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
function candidates(item: ReviewQueueItem): ScoredKind[] {
|
function candidates(item: ReviewQueueItem): ScoredKind[] {
|
||||||
try {
|
try {
|
||||||
@@ -17,35 +19,275 @@ function candidates(item: ReviewQueueItem): ScoredKind[] {
|
|||||||
function formatPace(avgSpeedMps: number | null): string | null {
|
function formatPace(avgSpeedMps: number | null): string | null {
|
||||||
if (avgSpeedMps == null || avgSpeedMps <= 0) return null;
|
if (avgSpeedMps == null || avgSpeedMps <= 0) return null;
|
||||||
const secPerKm = 1000 / avgSpeedMps;
|
const secPerKm = 1000 / avgSpeedMps;
|
||||||
const m = Math.floor(secPerKm / 60);
|
return `${formatMinutesSeconds(secPerKm)}/km`;
|
||||||
const s = Math.round(secPerKm % 60);
|
}
|
||||||
return `${m}:${s.toString().padStart(2, "0")}/km`;
|
|
||||||
|
// StartTimeUTC is "YYYY-MM-DD HH:MM:SS" in UTC with no offset marker, so it
|
||||||
|
// must be parsed as UTC explicitly (a bare space-separated string like this
|
||||||
|
// is otherwise ambiguous/inconsistent across browsers) before converting to
|
||||||
|
// the viewer's own timezone for display.
|
||||||
|
function formatActivityDateTime(startTimeUTC: string): { date: string; time: string } {
|
||||||
|
const d = new Date(startTimeUTC.replace(" ", "T") + "Z");
|
||||||
|
const day = d.getDate().toString().padStart(2, "0");
|
||||||
|
// Forced to English regardless of the viewer's own locale, to match the
|
||||||
|
// rest of this app's English-only UI (an auto-locale abbreviation like
|
||||||
|
// French "juil." would look inconsistent here).
|
||||||
|
const month = d.toLocaleDateString("en-US", { month: "short" });
|
||||||
|
const date = `${day}-${month}-${d.getFullYear()}`;
|
||||||
|
const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||||
|
return { date, time };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grey for unclassified, otherwise grouped by training-load family rather
|
||||||
|
// than an exact name match, so "60' Threshold"/"30' Threshold" both read as
|
||||||
|
// green, etc. (Threshold uses includes(), not startsWith(), since the
|
||||||
|
// numeral now comes first.) Falls back to grey for any kind name outside
|
||||||
|
// this taxonomy.
|
||||||
|
function kindColor(name: string | undefined): string {
|
||||||
|
if (!name) return "#6b7280"; // grey -- unclassified
|
||||||
|
if (name.startsWith("Easy") || name.startsWith("Long")) return "#3b82f6"; // blue
|
||||||
|
if (name.includes("Threshold")) return "#22c55e"; // green
|
||||||
|
if (name.startsWith("Tempo")) return "#eab308"; // yellow
|
||||||
|
if (name.startsWith("Interval")) return "#f97316"; // orange
|
||||||
|
if (name.startsWith("MAS")) return "#ef4444"; // red
|
||||||
|
if (name.startsWith("Race")) return "#a855f7"; // purple
|
||||||
|
return "#6b7280";
|
||||||
|
}
|
||||||
|
|
||||||
|
// One combined control per activity: the label shows the current kind (or
|
||||||
|
// "Unclassified") and opens a picker when clicked, unless locked -- either
|
||||||
|
// because the user picked it by hand, or because it's Race, a hard fact from
|
||||||
|
// Garmin's own metadata rather than a retunable rule. The small lock icon is
|
||||||
|
// the only way back to unlocked, which is what lets a later "Reclassify all"
|
||||||
|
// touch this activity again -- it's only actionable while locked, since
|
||||||
|
// there's nothing to lock/unlock about an activity that's still unclassified.
|
||||||
|
function ClassifyControl({
|
||||||
|
item,
|
||||||
|
kinds,
|
||||||
|
busy,
|
||||||
|
onAssign,
|
||||||
|
onUnassign,
|
||||||
|
onUnlock,
|
||||||
|
}: {
|
||||||
|
item: ReviewQueueItem;
|
||||||
|
kinds: WorkoutKind[];
|
||||||
|
busy: boolean;
|
||||||
|
onAssign: (activityId: number, kindId: number) => void;
|
||||||
|
onUnassign: (activityId: number) => void;
|
||||||
|
onUnlock: (activityId: number) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const kindName = item.WorkoutKindID != null ? kinds.find((k) => k.ID === item.WorkoutKindID)?.Name : undefined;
|
||||||
|
const isRace = kindName === "Race";
|
||||||
|
const locked = item.AssignmentSource === "manual" || isRace;
|
||||||
|
const assignableKinds = kinds.filter((k) => k.Name !== "Race");
|
||||||
|
const color = kindColor(kindName);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="classify-control">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="classify-label"
|
||||||
|
style={{ borderColor: color, color }}
|
||||||
|
disabled={locked || busy}
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
>
|
||||||
|
{kindName ?? "Unclassified"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="classify-lock"
|
||||||
|
disabled={!locked || busy}
|
||||||
|
title={
|
||||||
|
locked
|
||||||
|
? "Locked to this kind -- click to let auto-sort change it again"
|
||||||
|
: "Unlocked -- auto-sort may still change this"
|
||||||
|
}
|
||||||
|
onClick={() => onUnlock(item.ActivityID)}
|
||||||
|
>
|
||||||
|
{locked ? "🔒" : "🔓"}
|
||||||
|
</button>
|
||||||
|
{open && !locked && (
|
||||||
|
<div className="classify-dropdown">
|
||||||
|
{kindName != null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="classify-dropdown-unassign"
|
||||||
|
onClick={() => {
|
||||||
|
onUnassign(item.ActivityID);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Unclassified
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{assignableKinds.map((k) => (
|
||||||
|
<button
|
||||||
|
key={k.ID}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onAssign(item.ActivityID, k.ID);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{k.Name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds the kind_id/unclassified query params for a filter pill value, so
|
||||||
|
// filtering happens server-side -- a filtered view stays paginated (one page
|
||||||
|
// of laps/samples/charts at a time) instead of needing the whole matching
|
||||||
|
// backlog loaded and rendered up front just to check membership client-side.
|
||||||
|
function filterParams(filter: string): { kindId?: number; unclassified?: boolean } {
|
||||||
|
if (filter === UNCLASSIFIED) return { unclassified: true };
|
||||||
|
if (filter !== "") return { kindId: Number(filter) };
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFilter(item: ReviewQueueItem, filter: string): boolean {
|
||||||
|
if (filter === "") return true;
|
||||||
|
if (filter === UNCLASSIFIED) return item.WorkoutKindID == null;
|
||||||
|
return item.WorkoutKindID === Number(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReviewQueue() {
|
export function ReviewQueue() {
|
||||||
const [items, setItems] = useState<ReviewQueueItem[]>([]);
|
const [items, setItems] = useState<ReviewQueueItem[]>([]);
|
||||||
|
const [grandTotal, setGrandTotal] = useState(0); // unfiltered count, for the "All" pill
|
||||||
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||||
|
const [initialLoading, setInitialLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [kinds, setKinds] = useState<WorkoutKind[]>([]);
|
const [kinds, setKinds] = useState<WorkoutKind[]>([]);
|
||||||
|
const [profile, setProfile] = useState<Profile | null>(null);
|
||||||
const [filterKindId, setFilterKindId] = useState<string>("");
|
const [filterKindId, setFilterKindId] = useState<string>("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [resolvingId, setResolvingId] = useState<number | null>(null);
|
const [resolvingId, setResolvingId] = useState<number | null>(null);
|
||||||
const [rawDataItem, setRawDataItem] = useState<ReviewQueueItem | null>(null);
|
const [rawDataItem, setRawDataItem] = useState<ReviewQueueItem | null>(null);
|
||||||
|
|
||||||
function reload() {
|
// Fetching every activity's laps and per-second samples is expensive once
|
||||||
Promise.all([api.reviewQueue(), api.listWorkoutKinds()])
|
// there are many activities, so the list loads incrementally (infinite
|
||||||
.then(([reviewItems, workoutKinds]) => {
|
// scroll) instead of all at once -- see the paginated GET /api/review-queue/.
|
||||||
setItems(reviewItems);
|
const allLoaded = !initialLoading && nextCursor === null;
|
||||||
setKinds(workoutKinds);
|
// A single in-flight request is shared by every concurrent caller (the
|
||||||
|
// scroll observer, a filter switch, ...): each gets the same promise back
|
||||||
|
// and genuinely awaits its completion, rather than a boolean guard that
|
||||||
|
// would let a second caller spin against an already-in-progress fetch with
|
||||||
|
// nothing to await.
|
||||||
|
const inFlightRef = useRef<Promise<void> | null>(null);
|
||||||
|
// The authoritative cursor for control flow, updated synchronously the
|
||||||
|
// instant a response arrives -- NOT derived from the nextCursor state in
|
||||||
|
// the render body, since a ref written that way only picks up a new value
|
||||||
|
// once React actually re-renders.
|
||||||
|
const nextCursorRef = useRef<string | null>(null);
|
||||||
|
// Bumped on every filter switch so a response from a since-superseded
|
||||||
|
// filter (e.g. the user clicked two pills in quick succession) is detected
|
||||||
|
// and discarded instead of clobbering the current filter's results.
|
||||||
|
const generationRef = useRef(0);
|
||||||
|
// loadMore reads the *current* filter via this ref rather than closing
|
||||||
|
// over filterKindId, so the memoized callback identity (and therefore the
|
||||||
|
// IntersectionObserver effect below) doesn't need to be recreated on every
|
||||||
|
// filter change.
|
||||||
|
const filterRef = useRef(filterKindId);
|
||||||
|
filterRef.current = filterKindId;
|
||||||
|
|
||||||
|
const loadMore = useCallback((): Promise<void> => {
|
||||||
|
if (inFlightRef.current) return inFlightRef.current;
|
||||||
|
if (nextCursorRef.current === null) return Promise.resolve();
|
||||||
|
const gen = generationRef.current;
|
||||||
|
setLoadingMore(true);
|
||||||
|
const promise = (async () => {
|
||||||
|
try {
|
||||||
|
const page = await api.reviewQueue({
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
before: nextCursorRef.current ?? undefined,
|
||||||
|
...filterParams(filterRef.current),
|
||||||
|
});
|
||||||
|
if (gen !== generationRef.current) return; // a filter switch superseded this request
|
||||||
|
nextCursorRef.current = page.next_cursor;
|
||||||
|
setItems((prev) => [...prev, ...page.items]);
|
||||||
|
setNextCursor(page.next_cursor);
|
||||||
|
} catch (e) {
|
||||||
|
if (gen === generationRef.current) setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
inFlightRef.current = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
inFlightRef.current = promise;
|
||||||
|
return promise;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Resets the list and loads the first page for `filter`. Used both for the
|
||||||
|
// initial mount (filter "") and every filter-pill switch.
|
||||||
|
function loadFirstPage(filter: string) {
|
||||||
|
generationRef.current += 1;
|
||||||
|
const gen = generationRef.current;
|
||||||
|
inFlightRef.current = null; // release loadMore's guard for any stale in-flight request
|
||||||
|
nextCursorRef.current = null;
|
||||||
|
setItems([]);
|
||||||
|
setInitialLoading(true);
|
||||||
|
api
|
||||||
|
.reviewQueue({ limit: PAGE_SIZE, ...filterParams(filter) })
|
||||||
|
.then((page) => {
|
||||||
|
if (gen !== generationRef.current) return;
|
||||||
|
nextCursorRef.current = page.next_cursor;
|
||||||
|
setItems(page.items);
|
||||||
|
setNextCursor(page.next_cursor);
|
||||||
|
if (filter === "") setGrandTotal(page.total);
|
||||||
})
|
})
|
||||||
.catch((e) => setError(String(e)));
|
.catch((e) => {
|
||||||
|
if (gen === generationRef.current) setError(String(e));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (gen === generationRef.current) setInitialLoading(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(reload, []);
|
useEffect(() => {
|
||||||
|
loadFirstPage("");
|
||||||
|
api.listWorkoutKinds().then(setKinds).catch((e) => setError(String(e)));
|
||||||
|
api.getProfile().then(setProfile).catch((e) => setError(String(e)));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Infinite scroll: load the next page once the sentinel at the bottom of
|
||||||
|
// the list becomes visible. Works the same whether a filter is active or
|
||||||
|
// not, since filtering now happens server-side.
|
||||||
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const node = sentinelRef.current;
|
||||||
|
if (!node) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0]?.isIntersecting) loadMore();
|
||||||
|
},
|
||||||
|
{ rootMargin: "200px" },
|
||||||
|
);
|
||||||
|
observer.observe(node);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [loadMore, items.length]);
|
||||||
|
|
||||||
async function resolve(activityId: number, kindId: number) {
|
async function resolve(activityId: number, kindId: number) {
|
||||||
setResolvingId(activityId);
|
setResolvingId(activityId);
|
||||||
try {
|
try {
|
||||||
await api.resolveReview(activityId, kindId);
|
await api.resolveReview(activityId, kindId);
|
||||||
setItems((prev) => prev.filter((i) => i.ActivityID !== activityId));
|
setItems((prev) =>
|
||||||
|
prev
|
||||||
|
.map((i) =>
|
||||||
|
i.ActivityID === activityId
|
||||||
|
? { ...i, WorkoutKindID: kindId, AssignmentSource: "manual" as const, Status: "assigned" as const }
|
||||||
|
: i,
|
||||||
|
)
|
||||||
|
// If a filter (kind or Unclassified) is active and this reassignment
|
||||||
|
// moved the activity out of it, drop it from the visible list --
|
||||||
|
// otherwise e.g. reassigning an item away from "Easy" while filtered
|
||||||
|
// to Easy would leave it sitting there under the wrong filter.
|
||||||
|
.filter((i) => matchesFilter(i, filterKindId)),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -53,43 +295,69 @@ export function ReviewQueue() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
async function unassign(activityId: number) {
|
||||||
if (filterKindId === "") return items;
|
setResolvingId(activityId);
|
||||||
if (filterKindId === UNSORTED) {
|
try {
|
||||||
return items.filter((item) => candidates(item).length === 0);
|
await api.unassignReview(activityId);
|
||||||
|
setItems((prev) =>
|
||||||
|
prev
|
||||||
|
.map((i) =>
|
||||||
|
i.ActivityID === activityId
|
||||||
|
? { ...i, WorkoutKindID: null, AssignmentSource: "manual" as const, Status: "needs_review" as const }
|
||||||
|
: i,
|
||||||
|
)
|
||||||
|
// Same as resolve() -- an active kind filter no longer matches an
|
||||||
|
// activity just cleared back to Unclassified, so drop it.
|
||||||
|
.filter((i) => matchesFilter(i, filterKindId)),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setResolvingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlock(activityId: number) {
|
||||||
|
setResolvingId(activityId);
|
||||||
|
try {
|
||||||
|
await api.unlockReview(activityId);
|
||||||
|
setItems((prev) =>
|
||||||
|
prev
|
||||||
|
.map((i) => (i.ActivityID === activityId ? { ...i, AssignmentSource: "rule_engine" as const } : i))
|
||||||
|
.filter((i) => matchesFilter(i, filterKindId)),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setResolvingId(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const id = Number(filterKindId);
|
|
||||||
return items.filter((item) => candidates(item).some((c) => c.workout_kind_id === id));
|
|
||||||
}, [items, filterKindId]);
|
|
||||||
|
|
||||||
function toggleFilter(id: string) {
|
function toggleFilter(id: string) {
|
||||||
setFilterKindId((prev) => (prev === id ? "" : id));
|
const next = filterKindId === id ? "" : id;
|
||||||
|
setFilterKindId(next);
|
||||||
|
loadFirstPage(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Race is assigned automatically from Garmin metadata (eventType.typeKey
|
|
||||||
// == "race"), never by hand -- not offered as a manual-assign option.
|
|
||||||
const manuallyAssignableKinds = kinds.filter((k) => k.Name !== "Race");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<h2>Review Queue</h2>
|
|
||||||
{error && <p className="error">{error}</p>}
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
{items.length > 0 && (
|
{grandTotal > 0 && (
|
||||||
<div className="filter-pills">
|
<div className="filter-pills">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`filter-pill${filterKindId === "" ? " active" : ""}`}
|
className={`filter-pill${filterKindId === "" ? " active" : ""}`}
|
||||||
onClick={() => toggleFilter("")}
|
onClick={() => toggleFilter("")}
|
||||||
>
|
>
|
||||||
All ({items.length})
|
All ({grandTotal})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`filter-pill${filterKindId === UNSORTED ? " active" : ""}`}
|
className={`filter-pill${filterKindId === UNCLASSIFIED ? " active" : ""}`}
|
||||||
onClick={() => toggleFilter(UNSORTED)}
|
onClick={() => toggleFilter(UNCLASSIFIED)}
|
||||||
>
|
>
|
||||||
Unsorted (no candidates)
|
Unclassified
|
||||||
</button>
|
</button>
|
||||||
{kinds.map((k) => (
|
{kinds.map((k) => (
|
||||||
<button
|
<button
|
||||||
@@ -104,26 +372,42 @@ export function ReviewQueue() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{items.length === 0 ? (
|
{initialLoading ? (
|
||||||
<p className="empty-state">Nothing needs review right now.</p>
|
<p className="empty-state">Loading…</p>
|
||||||
) : filteredItems.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<p className="empty-state">No runs match this filter.</p>
|
<p className="empty-state">
|
||||||
|
{filterKindId === "" ? "No activities synced yet." : "No runs match this filter."}
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="review-list">
|
<ul className="review-list">
|
||||||
{filteredItems.map((item) => {
|
{items.map((item) => {
|
||||||
const scored = candidates(item);
|
const scored = candidates(item);
|
||||||
const pace = formatPace(item.activity.AvgSpeedMps);
|
const pace = formatPace(item.activity.AvgSpeedMps);
|
||||||
|
const { date, time } = formatActivityDateTime(item.activity.StartTimeUTC);
|
||||||
return (
|
return (
|
||||||
<li key={item.ActivityID} className="review-item">
|
<li key={item.ActivityID} className="review-item">
|
||||||
<div className="review-item-header">
|
<div className="review-item-header">
|
||||||
|
<div className="review-item-title">
|
||||||
<strong>{item.activity.ActivityName || item.activity.ActivityType}</strong>
|
<strong>{item.activity.ActivityName || item.activity.ActivityType}</strong>
|
||||||
<span>{item.activity.StartTimeUTC}</span>
|
<ClassifyControl
|
||||||
|
item={item}
|
||||||
|
kinds={kinds}
|
||||||
|
busy={resolvingId === item.ActivityID}
|
||||||
|
onAssign={resolve}
|
||||||
|
onUnassign={unassign}
|
||||||
|
onUnlock={unlock}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="review-item-datetime">
|
||||||
|
<span>{date}</span>
|
||||||
|
<span>{time}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="review-item-stats">
|
<div className="review-item-stats">
|
||||||
<span>{(item.activity.DistanceMeters / 1000).toFixed(2)} km</span>
|
<span>📏 {(item.activity.DistanceMeters / 1000).toFixed(2)} km</span>
|
||||||
<span>{Math.round(item.activity.DurationSeconds / 60)} min</span>
|
<span>⏱️ {Math.round(item.activity.DurationSeconds / 60)} min</span>
|
||||||
{pace && <span>{pace}</span>}
|
{pace && <span>⚡ {pace}</span>}
|
||||||
{item.activity.AvgHR != null && <span>{Math.round(item.activity.AvgHR)} bpm avg</span>}
|
{item.activity.AvgHR != null && <span>❤️ {Math.round(item.activity.AvgHR)} bpm avg</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{scored.length > 0 && (
|
{scored.length > 0 && (
|
||||||
@@ -132,19 +416,21 @@ export function ReviewQueue() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ExpectedVsActualChart laps={item.laps} samples={item.samples} />
|
<ExpectedVsActualChart
|
||||||
|
laps={item.laps}
|
||||||
<div className="review-item-actions">
|
samples={item.samples}
|
||||||
{manuallyAssignableKinds.map((k) => (
|
minRepresentativePaceSecPerKm={profile?.MinRepresentativePaceSecPerKm ?? 0}
|
||||||
<button
|
minRepresentativeTimeSeconds={profile?.MinRepresentativeTimeSeconds ?? 0}
|
||||||
key={k.ID}
|
paceColor={profile?.PaceColor ?? "#3b82f6"}
|
||||||
disabled={resolvingId === item.ActivityID}
|
heartRateColor={profile?.HeartRateColor ?? "#ef4444"}
|
||||||
onClick={() => resolve(item.ActivityID, k.ID)}
|
warmupColor={profile?.WarmupColor ?? "#c2410c"}
|
||||||
>
|
effortColor={profile?.EffortColor ?? "#7c3aed"}
|
||||||
{k.Name}
|
recoveryColor={profile?.RecoveryColor ?? "#15803d"}
|
||||||
</button>
|
cooldownColor={profile?.CooldownColor ?? "#fb923c"}
|
||||||
))}
|
mainLineTintPct={profile?.MainLineTintPct ?? 20}
|
||||||
</div>
|
backgroundDarkenPct={profile?.BackgroundDarkenPct ?? 35}
|
||||||
|
targetBrightenPct={profile?.TargetBrightenPct ?? 20}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="review-item-footer">
|
<div className="review-item-footer">
|
||||||
<button type="button" className="raw-data-button" onClick={() => setRawDataItem(item)}>
|
<button type="button" className="raw-data-button" onClick={() => setRawDataItem(item)}>
|
||||||
@@ -157,6 +443,12 @@ export function ReviewQueue() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!allLoaded && (
|
||||||
|
<div ref={sentinelRef} className="review-list-sentinel">
|
||||||
|
{loadingMore && <p className="empty-state">Loading more…</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{rawDataItem && <RawDataModal data={rawDataItem} onClose={() => setRawDataItem(null)} />}
|
{rawDataItem && <RawDataModal data={rawDataItem} onClose={() => setRawDataItem(null)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,198 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { api } from "../api/client";
|
|
||||||
import type { WorkoutKind } from "../types/api";
|
|
||||||
|
|
||||||
const EXAMPLE_RULE = `{
|
|
||||||
"match": "all",
|
|
||||||
"conditions": [
|
|
||||||
{ "metric": "avg_pace_sec_per_km", "op": "between", "value": [330, 420] },
|
|
||||||
{ "metric": "avg_hr_pct_max", "op": "<=", "value": 0.75 }
|
|
||||||
]
|
|
||||||
}`;
|
|
||||||
|
|
||||||
// Pace is entered/displayed as "m:ss" but sent to the API as whole seconds.
|
|
||||||
function parsePace(text: string): number | null {
|
|
||||||
const trimmed = text.trim();
|
|
||||||
if (trimmed === "") return null;
|
|
||||||
const match = /^(\d+):([0-5]?\d)$/.exec(trimmed);
|
|
||||||
if (!match) return null;
|
|
||||||
return Number(match[1]) * 60 + Number(match[2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPace(seconds: number | null): string {
|
|
||||||
if (seconds == null) return "";
|
|
||||||
const m = Math.floor(seconds / 60);
|
|
||||||
const s = Math.round(seconds % 60);
|
|
||||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorkoutKinds() {
|
|
||||||
const [kinds, setKinds] = useState<WorkoutKind[]>([]);
|
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [ruleText, setRuleText] = useState(EXAMPLE_RULE);
|
|
||||||
const [paceMinText, setPaceMinText] = useState("");
|
|
||||||
const [paceMaxText, setPaceMaxText] = useState("");
|
|
||||||
const [expectedZone, setExpectedZone] = useState<number | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [reclassifying, setReclassifying] = useState(false);
|
|
||||||
|
|
||||||
function reload() {
|
|
||||||
api.listWorkoutKinds(true).then(setKinds).catch((e) => setError(String(e)));
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(reload, []);
|
|
||||||
|
|
||||||
function startEdit(k: WorkoutKind) {
|
|
||||||
setEditingId(k.ID);
|
|
||||||
setName(k.Name);
|
|
||||||
setDescription(k.Description);
|
|
||||||
setRuleText(JSON.stringify(JSON.parse(k.RuleJSON || "{}"), null, 2));
|
|
||||||
setPaceMinText(formatPace(k.pace_min_sec_per_km));
|
|
||||||
setPaceMaxText(formatPace(k.pace_max_sec_per_km));
|
|
||||||
setExpectedZone(k.expected_hr_zone);
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
if (editingId === null) return;
|
|
||||||
|
|
||||||
let rule: unknown;
|
|
||||||
try {
|
|
||||||
rule = JSON.parse(ruleText);
|
|
||||||
} catch {
|
|
||||||
setError("Rule is not valid JSON");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const paceMin = parsePace(paceMinText);
|
|
||||||
const paceMax = parsePace(paceMaxText);
|
|
||||||
if (paceMinText.trim() !== "" && paceMin === null) {
|
|
||||||
setError("Min pace must look like m:ss, e.g. 4:30");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (paceMaxText.trim() !== "" && paceMax === null) {
|
|
||||||
setError("Max pace must look like m:ss, e.g. 4:30");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.updateWorkoutKind(editingId, {
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
rule,
|
|
||||||
pace_min_sec_per_km: paceMin,
|
|
||||||
pace_max_sec_per_km: paceMax,
|
|
||||||
expected_hr_zone: expectedZone,
|
|
||||||
});
|
|
||||||
setEditingId(null);
|
|
||||||
reload();
|
|
||||||
} catch (e) {
|
|
||||||
setError(String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reclassifyAll() {
|
|
||||||
setReclassifying(true);
|
|
||||||
try {
|
|
||||||
const res = await api.reclassifyAll();
|
|
||||||
setError(`Reclassified ${res.reclassified} activities.`);
|
|
||||||
} catch (e) {
|
|
||||||
setError(String(e));
|
|
||||||
} finally {
|
|
||||||
setReclassifying(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page">
|
|
||||||
<h2>Workout Kinds</h2>
|
|
||||||
{error && <p className="error">{error}</p>}
|
|
||||||
|
|
||||||
<div className="controls">
|
|
||||||
<button disabled={reclassifying} onClick={reclassifyAll}>
|
|
||||||
{reclassifying ? "Reclassifying…" : "Reclassify all"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="empty-state">
|
|
||||||
Re-runs the rule engine on every activity, except manual assignments (the user's definitive word) and Race
|
|
||||||
assignments (a fact from Garmin, not a retunable rule).
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<table className="kinds-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Description</th>
|
|
||||||
<th>Pace range</th>
|
|
||||||
<th>HR zone</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{kinds.map((k) => (
|
|
||||||
<tr key={k.ID}>
|
|
||||||
<td>{k.Name}</td>
|
|
||||||
<td>{k.Description}</td>
|
|
||||||
<td>
|
|
||||||
{k.pace_min_sec_per_km != null && k.pace_max_sec_per_km != null
|
|
||||||
? `${formatPace(k.pace_min_sec_per_km)}–${formatPace(k.pace_max_sec_per_km)}/km`
|
|
||||||
: "—"}
|
|
||||||
</td>
|
|
||||||
<td>{k.expected_hr_zone ?? "—"}</td>
|
|
||||||
<td className="kinds-table-actions">
|
|
||||||
<button onClick={() => startEdit(k)}>Edit</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{editingId !== null && (
|
|
||||||
<div className="kind-editor">
|
|
||||||
<label>
|
|
||||||
Name
|
|
||||||
<input value={name} onChange={(e) => setName(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Description
|
|
||||||
<input value={description} onChange={(e) => setDescription(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
<div className="controls">
|
|
||||||
<label>
|
|
||||||
Min pace (m:ss/km)
|
|
||||||
<input value={paceMinText} onChange={(e) => setPaceMinText(e.target.value)} placeholder="4:30" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Max pace (m:ss/km)
|
|
||||||
<input value={paceMaxText} onChange={(e) => setPaceMaxText(e.target.value)} placeholder="4:45" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Expected HR zone
|
|
||||||
<select
|
|
||||||
value={expectedZone ?? ""}
|
|
||||||
onChange={(e) => setExpectedZone(e.target.value === "" ? null : Number(e.target.value))}
|
|
||||||
>
|
|
||||||
<option value="">—</option>
|
|
||||||
{[1, 2, 3, 4, 5].map((z) => (
|
|
||||||
<option key={z} value={z}>
|
|
||||||
Zone {z}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label>
|
|
||||||
Rule (JSON condition tree)
|
|
||||||
<textarea rows={12} value={ruleText} onChange={(e) => setRuleText(e.target.value)} />
|
|
||||||
</label>
|
|
||||||
<div className="kind-editor-actions">
|
|
||||||
<button onClick={save}>Save</button>
|
|
||||||
<button onClick={() => setEditingId(null)}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,33 +5,33 @@
|
|||||||
export interface Activity {
|
export interface Activity {
|
||||||
ID: number;
|
ID: number;
|
||||||
GarminActivityID: number;
|
GarminActivityID: number;
|
||||||
|
// ActivityName/ActivityType aren't stored columns -- the backend decodes
|
||||||
|
// them from RawJSON at response time (see internal/api/display_fields.go).
|
||||||
ActivityName: string;
|
ActivityName: string;
|
||||||
ActivityType: string;
|
ActivityType: string;
|
||||||
EventTypeKey: string;
|
EventTypeKey: string;
|
||||||
WorkoutID: number | null;
|
WorkoutID: number | null;
|
||||||
StartTimeUTC: string;
|
StartTimeUTC: string;
|
||||||
BeginTimestampMs: number;
|
|
||||||
DurationSeconds: number;
|
DurationSeconds: number;
|
||||||
DistanceMeters: number;
|
DistanceMeters: number;
|
||||||
AvgHR: number | null;
|
AvgHR: number | null;
|
||||||
MaxHR: number | null;
|
MaxHR: number | null;
|
||||||
AvgSpeedMps: number | null;
|
AvgSpeedMps: number | null;
|
||||||
MaxSpeedMps: number | null;
|
|
||||||
ElevationGainM: number | null;
|
ElevationGainM: number | null;
|
||||||
ElevationLossM: number | null;
|
|
||||||
Calories: number | null;
|
|
||||||
LapCount: number;
|
|
||||||
AerobicTrainingEffect: number | null;
|
AerobicTrainingEffect: number | null;
|
||||||
AnaerobicTrainingEffect: number | null;
|
AnaerobicTrainingEffect: number | null;
|
||||||
TrainingEffectLabel: string;
|
|
||||||
VO2MaxValue: number | null;
|
VO2MaxValue: number | null;
|
||||||
// Raw JSON strings from Garmin, kept for fields not modeled above. Only
|
// Raw JSON strings from Garmin, kept for fields not modeled above. Only
|
||||||
// needed by the Review Queue's raw-data viewer, so left as strings here
|
// needed by the Activities page's raw-data viewer, so left as strings here
|
||||||
// rather than typed -- the viewer parses them for display.
|
// rather than typed -- the viewer parses them for display.
|
||||||
RawJSON: string;
|
RawJSON: string;
|
||||||
DetailsFetchedAt: string | null;
|
DetailsFetchedAt: string | null;
|
||||||
DetailsRawJSON: string | null;
|
DetailsRawJSON: string | null;
|
||||||
SplitsFetchedAt: string | null;
|
SplitsFetchedAt: string | null;
|
||||||
|
// Genuine raw get_workout_by_id() response for this activity's structured
|
||||||
|
// workout. Null when the activity has no WorkoutID, or was synced before
|
||||||
|
// this column existed.
|
||||||
|
WorkoutRawJSON: string | null;
|
||||||
CreatedAt: string;
|
CreatedAt: string;
|
||||||
UpdatedAt: string;
|
UpdatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -40,15 +40,11 @@ export interface Lap {
|
|||||||
ID: number;
|
ID: number;
|
||||||
ActivityID: number;
|
ActivityID: number;
|
||||||
LapIndex: number;
|
LapIndex: number;
|
||||||
StartTimeUTC: string;
|
// DurationSeconds/AvgHR aren't stored columns -- the backend decodes them
|
||||||
|
// from RawJSON at response time (see internal/api/display_fields.go).
|
||||||
DurationSeconds: number;
|
DurationSeconds: number;
|
||||||
DistanceMeters: number;
|
|
||||||
AvgHR: number | null;
|
AvgHR: number | null;
|
||||||
MaxHR: number | null;
|
|
||||||
AvgSpeedMps: number | null;
|
AvgSpeedMps: number | null;
|
||||||
MaxSpeedMps: number | null;
|
|
||||||
ElevationGainM: number | null;
|
|
||||||
ElevationLossM: number | null;
|
|
||||||
IntensityType: string;
|
IntensityType: string;
|
||||||
HRDriftBpmPerMin: number | null;
|
HRDriftBpmPerMin: number | null;
|
||||||
HRRecoveryBpmPerMin: number | null;
|
HRRecoveryBpmPerMin: number | null;
|
||||||
@@ -91,10 +87,12 @@ export interface WorkoutKind {
|
|||||||
UpdatedAt: string;
|
UpdatedAt: string;
|
||||||
pace_min_sec_per_km: number | null;
|
pace_min_sec_per_km: number | null;
|
||||||
pace_max_sec_per_km: number | null;
|
pace_max_sec_per_km: number | null;
|
||||||
expected_hr_zone: number | null;
|
hr_min_pct_hrr: number | null;
|
||||||
|
hr_max_pct_hrr: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Profile {
|
export interface Profile {
|
||||||
|
Name: string;
|
||||||
GarminEmail: string;
|
GarminEmail: string;
|
||||||
GarminPassword: string;
|
GarminPassword: string;
|
||||||
RollingWindowDays: number;
|
RollingWindowDays: number;
|
||||||
@@ -113,6 +111,27 @@ export interface Profile {
|
|||||||
HRZone5MaxPct: number;
|
HRZone5MaxPct: number;
|
||||||
WarmupMinutes: number;
|
WarmupMinutes: number;
|
||||||
CooldownMinutes: number;
|
CooldownMinutes: number;
|
||||||
|
MinRepresentativePaceSecPerKm: number;
|
||||||
|
MinRepresentativeTimeSeconds: number;
|
||||||
|
// Chart colors: PaceColor/HeartRateColor are each chart's "main line"
|
||||||
|
// color; Warmup/Effort/Recovery/CooldownColor are the "effort kind"
|
||||||
|
// colors used to derive the under-the-line fill and phase background
|
||||||
|
// (see ExpectedVsActualChart).
|
||||||
|
PaceColor: string;
|
||||||
|
HeartRateColor: string;
|
||||||
|
WarmupColor: string;
|
||||||
|
EffortColor: string;
|
||||||
|
RecoveryColor: string;
|
||||||
|
CooldownColor: string;
|
||||||
|
// How strongly (0-100) the main line color mixes into the effort-kind
|
||||||
|
// fill under the line. Never affects the phase background above the line.
|
||||||
|
MainLineTintPct: number;
|
||||||
|
// How strongly (0-100) the effort-kind color is darkened for the phase
|
||||||
|
// background above the line. Never mixed with the main line color.
|
||||||
|
BackgroundDarkenPct: number;
|
||||||
|
// How strongly (0-100) a chart's main line color is brightened when that
|
||||||
|
// chart has a structured-workout target range to show.
|
||||||
|
TargetBrightenPct: number;
|
||||||
CreatedAt: string;
|
CreatedAt: string;
|
||||||
UpdatedAt: string;
|
UpdatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -140,6 +159,12 @@ export interface ReviewQueueItem extends KindAssignment {
|
|||||||
samples: Sample[];
|
samples: Sample[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReviewQueuePage {
|
||||||
|
items: ReviewQueueItem[];
|
||||||
|
next_cursor: string | null;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProgressionPoint {
|
export interface ProgressionPoint {
|
||||||
date: string;
|
date: string;
|
||||||
activity_id: number;
|
activity_id: number;
|
||||||
@@ -173,4 +198,4 @@ export interface SyncStatus {
|
|||||||
last_run?: SyncRun;
|
last_run?: SyncRun;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProgressionMetric = "pace" | "hr" | "vo2max" | "aerobic_te" | "anaerobic_te";
|
export type ProgressionMetric = "pace" | "hr" | "vo2max" | "aerobic_te" | "anaerobic_te" | "efficiency_factor";
|
||||||
|
|||||||
Reference in New Issue
Block a user