Compare commits

...

10 Commits

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
9818d35910 Tolerate exactly one extra trailing lap when aligning workout targets
Confirmed via Garmin Connect against a real activity ("Auriol - W3-3-Double
Barrel"): the workout's step count didn't match its 18 recorded laps
because the athlete kept running 6:46 past the prescribed 5-minute
cool-down, logged as an 18th lap the workout never defined. The strict
equality check meant this one extra lap discarded every other lap's real
target too, not just its own.

alignWorkoutTargets now tolerates exactly one extra recorded lap beyond the
step count: the steps that do exist still zip to their laps normally, and
only the trailing extra lap is left without a target. Any larger mismatch
still falls back to nil for every lap, since that can't be trusted at all.
2026-07-19 18:01:06 +02:00
513418123f Add configurable pace-artifact filtering to replace the hardcoded cutoff
Two new Profile settings -- "minimum representative pace" and "minimum
representative time" -- replace the previous hardcoded 20:00/km cutoff. A
stretch of consecutive samples slower than the configured pace is now
dropped from the chart (and its Y-axis scale) only if it lasts no longer
than the configured time; a longer stretch is kept as a real stop or walk
break rather than noise. Defaults to 12:00/km and 3 seconds.

Caught a boundary bug while verifying against real data: a run lasting
exactly the threshold duration survived filtering because the comparison
used strict "<" instead of "<=", contradicting "not lasting more than N
seconds" (which should include exactly N).
2026-07-19 17:38:50 +02:00
619277b0ee Suppress the target band for laps that are just an unplanned continuation
Confirmed against Garmin Connect's own workout view: a lap whose
IntensityType merely repeats the immediately preceding lap's (e.g. a second
"cool-down" lap right after the first) means the recording continued past
the end of that step, not that the workout prescribed a second target for
it. Only the first lap of such a run keeps its target/HR fields; the
continuation shows the actual trace with no expectation overlay. Doesn't
affect normal interval structure, since Effort/Recovery always alternate
and never appear back-to-back.
2026-07-19 17:17:19 +02:00
1fb00f5bc6 Merge consecutive same-phase laps before computing the average reference line
A cool-down (or warm-up) split across two or more laps was drawing each
lap's own average as a separate flat segment, so a workout with a 2-lap
cool-down showed two different "cool-down averages" back to back instead
of one. Consecutive laps sharing an IntensityType are now merged into a
single phase segment with one duration-weighted average across all of them.
2026-07-19 16:57:47 +02:00
2c2d4966b2 Fix pace/elapsed-time formatting rounding 59.6s up to ":60" instead of carrying over
formatPaceShort and formatElapsed each floored the minutes and rounded the
leftover seconds independently, so e.g. 419.6 sec/km displayed as "6:60/km"
instead of "7:00/km". Both now round the total seconds once, then split
into minutes/seconds from that rounded value.
2026-07-19 16:55:11 +02:00
82f08c0c1e Stack Pace/HR charts vertically at double height instead of side by side
Each chart now takes the full card width and 200px height (was 100px,
half-width side by side), making the phase bands, target range, and
per-second trace much easier to read.
2026-07-19 16:41:30 +02:00
db4c76b558 Paginate the Review Queue with cursor-based infinite scroll
Fetching every needs_review activity's laps and per-second samples up front
gets expensive once there are many -- that work is now deferred to the
current page only. GET /api/review-queue/ takes limit/before and returns
{items, next_cursor, total}; sorting/cursor filtering still needs each
activity's cheap summary row, but only the requested page's items get their
laps/samples fetched.

Frontend loads 10 at a time and fetches the next page when the list's
bottom sentinel scrolls into view. Selecting a specific-kind filter (not
"All") loads the rest of the backlog up front, since filtering only the
items scrolled into view so far would hide matches further down.
2026-07-19 16:31:24 +02:00
9ec721b9fc Style chart tooltips to match the dark theme
Recharts' default tooltip is a plain white box, which stood out badly
against the app's dark background. Applied across both the Review Queue's
pace/HR charts and the Progression chart.
2026-07-19 16:13:52 +02:00
02ee9fa76e Show the average line for single-effort-type workouts too; rebrand header to geniusrun
Previously the dashed average pace/HR line only drew for warm-up/cool-down
laps. Workouts with only one kind of effort throughout (no warm-up/cool-down
segmentation at all, e.g. a plain Long Run) had nothing to compare the
actual trace against, so they now get the same dashed line spanning the
whole activity at its overall (duration-weighted) average.

UI-only rename: "smartrun" -> "geniusrun", prefixed with the genie emoji.
2026-07-19 16:05:41 +02:00
57 changed files with 2827 additions and 847 deletions

3
.gitignore vendored
View File

@@ -20,3 +20,6 @@ frontend/dist/
backend/mcpspike
backend/cmd/mcpspike/mcpspike
.superpowers/
# Claude Code session-local runtime state
.claude/*.lock

6
backend/.idea/vcs.xml generated Normal file
View 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>

View File

@@ -152,17 +152,17 @@ func seedActivity(ctx context.Context, db *store.DB, p seedParams) int64 {
anaerobic := p.anaerobicTE
id, err := db.UpsertActivity(ctx, store.Activity{
GarminActivityID: p.garminID,
ActivityName: p.name,
ActivityType: "running",
StartTimeUTC: p.start.Format("2006-01-02 15:04:05"),
BeginTimestampMs: p.start.UnixMilli(),
DurationSeconds: p.duration,
DistanceMeters: p.distance,
AvgSpeedMps: &speed,
AvgHR: &hr,
AerobicTrainingEffect: &aerobic,
AnaerobicTrainingEffect: &anaerobic,
RawJSON: "{}",
// ActivityName/ActivityType aren't stored columns anymore -- the API
// decodes them from RawJSON, so seeded activities need them here to
// still show a sensible name/type in the dev UI.
RawJSON: fmt.Sprintf(`{"activityName":%q,"activityType":{"typeKey":"running"}}`, p.name),
})
must(err)
return id
@@ -213,9 +213,14 @@ func seedIntervalLapsAndSamples(ctx context.Context, db *store.DB, activityID in
}
laps = append(laps, store.Lap{
LapIndex: i + 1, DurationSeconds: lapDuration,
DistanceMeters: 400, IntensityType: intensity, RawJSON: "{}",
HRDriftBpmPerMin: drift, HRRecoveryBpmPerMin: recovery,
LapIndex: i + 1,
IntensityType: intensity,
// DurationSeconds/AvgHR aren't stored columns anymore -- the API
// decodes them from RawJSON, so seeded laps need them here to
// still show a sensible duration/HR on the dev chart.
RawJSON: fmt.Sprintf(`{"duration":%v,"distance":400,"averageHR":%v}`, lapDuration, baseHR),
HRDriftBpmPerMin: drift,
HRRecoveryBpmPerMin: recovery,
})
elapsed += lapDuration
}

View File

@@ -13,7 +13,7 @@ import (
// current classification so the frontend can show what kind it's assigned
// to and whether that assignment is locked (see Locked's doc comment).
type activityListItem struct {
store.Activity
activityResponse
WorkoutKindID *int64 `json:"workout_kind_id"`
WorkoutKindName *string `json:"workout_kind_name"`
AssignmentSource *string `json:"assignment_source"`
@@ -55,7 +55,7 @@ func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
resp := make([]activityListItem, 0, len(activities))
for _, a := range activities {
item := activityListItem{Activity: a}
item := activityListItem{activityResponse: toActivityResponse(a)}
assignment, ok, err := s.DB.CurrentAssignment(r.Context(), a.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
@@ -107,7 +107,7 @@ func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
return
}
resp := map[string]any{"activity": activity, "laps": laps}
resp := map[string]any{"activity": toActivityResponse(activity), "laps": toLapResponses(laps)}
if hasAssignment {
resp["assignment"] = assignment
}

View File

@@ -4,8 +4,10 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"testing"
@@ -72,7 +74,7 @@ func TestWorkoutKindList_ReturnsEightSeededTypesWithPaceFields(t *testing.T) {
t.Fatalf("expected 8 seeded kinds, got %d", len(kinds))
}
for _, k := range kinds {
if k.PaceMinSecPerKm != nil || k.PaceMaxSecPerKm != nil || k.ExpectedHRZone != nil {
if k.PaceMinSecPerKm != nil || k.PaceMaxSecPerKm != nil || k.HRMinPctHRR != nil || k.HRMaxPctHRR != nil {
t.Errorf("expected freshly-seeded kind %q to have nil pace fields, got %+v", k.Name, k)
}
}
@@ -87,13 +89,14 @@ func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) {
json.Unmarshal(rec.Body.Bytes(), &kinds)
target := kinds[0]
minPace, maxPace, zone := 330.0, 420.0, 2
minPace, maxPace, hrMin, hrMax := 330.0, 420.0, 70.0, 80.0
body := map[string]any{
"name": target.Name,
"rule": json.RawMessage(`{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[270,300]}]}`),
"pace_min_sec_per_km": minPace,
"pace_max_sec_per_km": maxPace,
"expected_hr_zone": zone,
"hr_min_pct_hrr": hrMin,
"hr_max_pct_hrr": hrMax,
}
rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(target.ID), body)
if rec.Code != http.StatusOK {
@@ -106,8 +109,11 @@ func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) {
if updated.PaceMinSecPerKm == nil || *updated.PaceMinSecPerKm != 330 {
t.Errorf("PaceMinSecPerKm = %v, want 330", updated.PaceMinSecPerKm)
}
if updated.ExpectedHRZone == nil || *updated.ExpectedHRZone != 2 {
t.Errorf("ExpectedHRZone = %v, want 2", updated.ExpectedHRZone)
if updated.HRMinPctHRR == nil || *updated.HRMinPctHRR != 70 {
t.Errorf("HRMinPctHRR = %v, want 70", updated.HRMinPctHRR)
}
if updated.HRMaxPctHRR == nil || *updated.HRMaxPctHRR != 80 {
t.Errorf("HRMaxPctHRR = %v, want 80", updated.HRMaxPctHRR)
}
}
@@ -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)
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{
"name": kinds[0].Name,
"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 {
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)
ctx := newCtx()
activityID, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
activityID, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
kindID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Easy", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
kindID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
@@ -182,16 +208,20 @@ func TestReviewQueueResolve(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("review queue status = %d", rec.Code)
}
var queue []map[string]any
json.Unmarshal(rec.Body.Bytes(), &queue)
if len(queue) != 1 {
t.Fatalf("expected 1 item in review queue, got %d: %s", len(queue), rec.Body.String())
var page struct {
Items []map[string]any `json:"items"`
NextCursor *string `json:"next_cursor"`
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 {
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 {
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())
}
// The activity stays listed after resolving -- the Activities page shows
// every activity, locked or not, not just ones still needing review.
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
json.Unmarshal(rec.Body.Bytes(), &queue)
if len(queue) != 0 {
t.Fatalf("expected empty review queue after resolve, got %d", len(queue))
json.Unmarshal(rec.Body.Bytes(), &page)
if len(page.Items) != 1 || page.Total != 1 {
t.Fatalf("expected activity to remain listed after resolve, got %d items, total=%d", len(page.Items), page.Total)
}
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual {
t.Fatalf("expected AssignmentSource=manual after resolve, got %v", page.Items[0]["AssignmentSource"])
}
if got := page.Items[0]["WorkoutKindID"]; got != float64(kindID) {
t.Fatalf("expected WorkoutKindID=%d after resolve, got %v", kindID, got)
}
// Unlocking reverts the source to rule_engine but keeps the same kind,
// so a later reclassify pass is free to change it again.
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unlock", nil)
if rec.Code != http.StatusOK {
t.Fatalf("unlock status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
json.Unmarshal(rec.Body.Bytes(), &page)
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine {
t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"])
}
if got := page.Items[0]["WorkoutKindID"]; got != float64(kindID) {
t.Fatalf("expected WorkoutKindID=%d to survive unlock, got %v", kindID, got)
}
// Unlocking an already-unlocked activity is rejected.
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unlock", nil)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 unlocking a non-manual assignment, got %d", rec.Code)
}
// Manually unassigning clears the kind back to Unclassified and locks
// that decision (source=manual), same as resolving to a specific kind.
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unassign", nil)
if rec.Code != http.StatusOK {
t.Fatalf("unassign status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
json.Unmarshal(rec.Body.Bytes(), &page)
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual {
t.Fatalf("expected AssignmentSource=manual after unassign, got %v", page.Items[0]["AssignmentSource"])
}
if got := page.Items[0]["WorkoutKindID"]; got != nil {
t.Fatalf("expected WorkoutKindID=nil after unassign, got %v", got)
}
// It also shows up under the Unclassified filter now.
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/?unclassified=true", nil)
json.Unmarshal(rec.Body.Bytes(), &page)
if len(page.Items) != 1 || page.Total != 1 {
t.Fatalf("expected unassigned activity to show up as unclassified, got %d items, total=%d", len(page.Items), page.Total)
}
// Unassigning locks it, so unlocking works again (reverting to
// rule_engine sourcing with no kind, i.e. needs_review).
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unlock", nil)
if rec.Code != http.StatusOK {
t.Fatalf("unlock after unassign status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
json.Unmarshal(rec.Body.Bytes(), &page)
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine {
t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"])
}
}
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)
ctx := newCtx()
activityID, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
activityID, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
@@ -240,9 +498,9 @@ func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
}
ruleEngineActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, ActivityType: "running", StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
raceActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 3, ActivityType: "running", EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
ruleEngineActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
raceActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine,
@@ -307,9 +565,9 @@ func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) {
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
}
ruleEngineActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, ActivityType: "running", StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
raceActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 3, ActivityType: "running", EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
ruleEngineActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
manualActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
raceActivity, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
@@ -349,7 +607,7 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
s, db := newTestServer(t)
ctx := newCtx()
if _, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
if _, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
@@ -385,11 +643,11 @@ func TestProgression_ReturnsSortedTimeSeries(t *testing.T) {
s, db := newTestServer(t)
ctx := newCtx()
kindID, _ := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Easy", RuleJSON: `{}`, IsActive: true})
kindID, _ := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Kind", RuleJSON: `{}`, IsActive: true})
speed := 3.0
a1, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-01 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
a2, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, ActivityType: "running", StartTimeUTC: "2026-07-05 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
a1, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
a2, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-05 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
for _, id := range []int64{a2, a1} { // insert out of order on purpose
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: id, WorkoutKindID: &kindID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
@@ -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 {
return strconv.FormatInt(v, 10)
}

View File

@@ -0,0 +1,75 @@
package api
import (
"encoding/json"
"smartrun/backend/internal/store"
)
// activityResponse adds back ActivityName/ActivityType as fields decoded
// from RawJSON. They're no longer stored as their own columns (Phase 2 of
// the 2026-07 duplication cleanup -- see store.Activity's doc comment):
// Garmin's raw JSON is the only place they live now, decoded fresh for each
// API response instead of kept as a redundant copy in the database.
type activityResponse struct {
store.Activity
ActivityName string `json:"ActivityName"`
ActivityType string `json:"ActivityType"`
}
func toActivityResponse(a store.Activity) activityResponse {
name, typeKey := decodeActivityDisplayFields(a.RawJSON)
return activityResponse{Activity: a, ActivityName: name, ActivityType: typeKey}
}
type rawActivityDisplayFields struct {
ActivityName string `json:"activityName"`
ActivityType struct {
TypeKey string `json:"typeKey"`
} `json:"activityType"`
}
func decodeActivityDisplayFields(rawJSON string) (name, typeKey string) {
var f rawActivityDisplayFields
_ = json.Unmarshal([]byte(rawJSON), &f) // best-effort -- zero values are fine if rawJSON is empty/malformed
return f.ActivityName, f.ActivityType.TypeKey
}
// lapResponse adds back DurationSeconds/AvgHR as fields decoded from
// RawJSON, same rationale as activityResponse -- the frontend chart still
// needs them (x-axis timing, dashed average-HR line) even though nothing on
// the backend does.
type lapResponse struct {
store.Lap
DurationSeconds float64 `json:"DurationSeconds"`
AvgHR *float64 `json:"AvgHR"`
}
func toLapResponse(l store.Lap) lapResponse {
duration, avgHR := decodeLapDisplayFields(l.RawJSON)
return lapResponse{Lap: l, DurationSeconds: duration, AvgHR: avgHR}
}
func toLapResponses(laps []store.Lap) []lapResponse {
out := make([]lapResponse, len(laps))
for i, l := range laps {
out[i] = toLapResponse(l)
}
return out
}
type rawLapDisplayFields struct {
Duration float64 `json:"duration"`
AverageHR float64 `json:"averageHR"`
}
func decodeLapDisplayFields(rawJSON string) (durationSeconds float64, avgHR *float64) {
var f rawLapDisplayFields
_ = json.Unmarshal([]byte(rawJSON), &f)
durationSeconds = f.Duration
if f.AverageHR > 0 {
v := f.AverageHR
avgHR = &v
}
return
}

View File

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

View File

@@ -43,6 +43,16 @@ func metricValue(metric string, a store.Activity) (float64, bool) {
return 0, false
}
return *a.AnaerobicTrainingEffect, true
case "efficiency_factor":
// Speed per heartbeat: how much ground each beat covers, on average.
// A rising trend at similar effort/HR over time is a classic sign of
// improving aerobic fitness, independent of pace/HR shown alone.
// Scaled by 1000 (m/s per bpm is otherwise a tiny fraction like
// 0.02, hard to read/compare at a glance).
if a.AvgSpeedMps == nil || a.AvgHR == nil || *a.AvgHR <= 0 {
return 0, false
}
return (*a.AvgSpeedMps / *a.AvgHR) * 1000, true
default:
return 0, false
}

View File

@@ -11,21 +11,64 @@ import (
"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) {
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 {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
type item struct {
store.KindAssignment
Activity store.Activity `json:"activity"`
Laps []store.Lap `json:"laps"`
Samples []store.Sample `json:"samples"`
type withActivity struct {
assignment store.KindAssignment
activity store.Activity
}
items := make([]item, 0, len(queue))
all := make([]withActivity, 0, len(queue))
for _, a := range queue {
if kindIDFilter != nil && (a.WorkoutKindID == nil || *a.WorkoutKindID != *kindIDFilter) {
continue
}
if unclassifiedOnly && a.WorkoutKindID != nil {
continue
}
activity, ok, err := s.DB.GetActivity(r.Context(), a.ActivityID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
@@ -34,7 +77,32 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
if !ok {
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 {
writeError(w, http.StatusInternalServerError, err.Error())
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
// show real within-lap variation instead of one flat segment per lap
// (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 {
writeError(w, http.StatusInternalServerError, err.Error())
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
// when the rule engine flagged it), so the queue reads like a log.
sort.Slice(items, func(i, j int) bool {
return items[i].Activity.StartTimeUTC > items[j].Activity.StartTimeUTC
})
var nextCursor *string
if hasMore && len(items) > 0 {
c := items[len(items)-1].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) {
@@ -104,3 +181,74 @@ func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"})
}
// handleUnassignReview manually clears an activity's kind back to
// Unclassified. Like handleResolveReview, it's a deliberate manual choice --
// recorded as source=manual (with no kind) so the rule engine leaves it alone
// on a later reclassify pass, exactly as it would for a manual kind
// assignment. The frontend only offers this while the activity is unlocked
// (locked activities must be unlocked first, same precondition as picking a
// different kind), but the backend doesn't re-enforce that here, matching
// handleResolveReview's own lack of a lock precondition check.
func (s *Server) handleUnassignReview(w http.ResponseWriter, r *http.Request) {
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid activity id")
return
}
if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{
ActivityID: activityID,
WorkoutKindID: nil,
AssignmentSource: store.AssignmentSourceManual,
Status: store.AssignmentStatusNeedsReview,
CandidateKindsJSON: "[]",
}); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "unassigned"})
}
// handleUnlockReview reverts a manual assignment back to rule_engine
// sourcing, keeping the same kind, so a later reclassify pass (which always
// skips manual assignments) is free to change it again. It's the inverse of
// handleResolveReview, not a delete: the kind stays visible as-is until
// something actually reclassifies it.
func (s *Server) handleUnlockReview(w http.ResponseWriter, r *http.Request) {
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid activity id")
return
}
current, ok, err := s.DB.CurrentAssignment(r.Context(), activityID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !ok {
writeError(w, http.StatusNotFound, "activity has no assignment yet")
return
}
if current.AssignmentSource != store.AssignmentSourceManual {
writeError(w, http.StatusBadRequest, "activity is not manually locked")
return
}
status := store.AssignmentStatusAssigned
if current.WorkoutKindID == nil {
status = store.AssignmentStatusNeedsReview
}
if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{
ActivityID: activityID,
WorkoutKindID: current.WorkoutKindID,
AssignmentSource: store.AssignmentSourceRuleEngine,
Status: status,
CandidateKindsJSON: "[]",
}); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "unlocked"})
}

View File

@@ -74,6 +74,8 @@ func (s *Server) Router() http.Handler {
r.Route("/review-queue", func(r chi.Router) {
r.Get("/", s.handleReviewQueue)
r.Post("/{activityID}/resolve", s.handleResolveReview)
r.Post("/{activityID}/unlock", s.handleUnlockReview)
r.Post("/{activityID}/unassign", s.handleUnassignReview)
})
r.Get("/progression/{kindID}", s.handleProgression)

View File

@@ -15,16 +15,12 @@ const detailFillBatchSize = 50
// picked up automatically), then IncrementalSync (catches anything new since
// the latest known activity), then fills in details for whatever's still
// missing them. Activities already fully processed are left untouched --
// see internal/sync.Service.FillPendingDetails.
// see internal/sync.Service.FillPendingDetails. Recorded as a single
// FullSync run so "last sync" reports the combined activity count, not just
// whichever of Backfill/IncrementalSync happened to finish last.
func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
ok := s.backgroundSync(func(ctx context.Context) error {
if err := s.Sync.Backfill(ctx); err != nil {
return err
}
if err := s.Sync.IncrementalSync(ctx); err != nil {
return err
}
return s.Sync.FillPendingDetails(ctx, detailFillBatchSize)
return s.Sync.FullSync(ctx, detailFillBatchSize)
})
if !ok {
writeError(w, http.StatusConflict, "a sync is already in progress")

View File

@@ -244,10 +244,23 @@ func (c *mcpClient) GetActivitySplits(ctx context.Context, activityID int64) (Ac
return ActivitySplits{}, err
}
var splits ActivitySplits
if err := json.Unmarshal([]byte(msg), &splits); err != nil {
var envelope struct {
ActivityID int64 `json:"activityId"`
Laps []json.RawMessage `json:"lapDTOs"`
}
if err := json.Unmarshal([]byte(msg), &envelope); err != nil {
return ActivitySplits{}, fmt.Errorf("get_activity_splits did not return valid JSON (%q): %w", truncate(msg, 200), err)
}
splits := ActivitySplits{ActivityID: envelope.ActivityID, Laps: make([]Lap, 0, len(envelope.Laps))}
for _, raw := range envelope.Laps {
var l Lap
if err := json.Unmarshal(raw, &l); err != nil {
return ActivitySplits{}, fmt.Errorf("parse lap: %w", err)
}
l.Raw = raw
splits.Laps = append(splits.Laps, l)
}
return splits, nil
}
@@ -269,6 +282,7 @@ func (c *mcpClient) GetActivityDetails(ctx context.Context, activityID int64) (A
if err := json.Unmarshal([]byte(msg), &details); err != nil {
return ActivityDetails{}, fmt.Errorf("get_activity_details did not return valid JSON (%q): %w", truncate(msg, 200), err)
}
details.Raw = json.RawMessage(msg)
return details, nil
}
@@ -290,6 +304,7 @@ func (c *mcpClient) GetWorkoutByID(ctx context.Context, workoutID int64) (Workou
if err := json.Unmarshal([]byte(msg), &workout); err != nil {
return Workout{}, fmt.Errorf("get_workout_by_id did not return valid JSON (%q): %w", truncate(msg, 200), err)
}
workout.Raw = json.RawMessage(msg)
return workout, nil
}

View File

@@ -148,6 +148,10 @@ type Workout struct {
WorkoutID int64 `json:"workoutId"`
WorkoutName string `json:"workoutName"`
Segments []WorkoutSegment `json:"workoutSegments"`
// Raw holds the full original JSON object for this workout, same as
// Activity.Raw -- see GetWorkoutByID.
Raw json.RawMessage `json:"-"`
}
// FlattenSteps expands repeat groups (RepeatGroupDTO) into their repeated
@@ -195,6 +199,10 @@ type ActivityDetails struct {
ActivityID int64 `json:"activityId"`
MetricDescriptors []MetricDescriptor `json:"metricDescriptors"`
ActivityDetailMetrics []activityDetailMetricsRow `json:"activityDetailMetrics"`
// Raw holds the full original JSON object for this response, for fields
// not modeled above (or discovered later) without needing to re-fetch.
Raw json.RawMessage `json:"-"`
}
// Sample is one ~1-second telemetry reading extracted from ActivityDetails

View File

@@ -9,38 +9,42 @@ import (
// Activity is smartrun's persisted view of a Garmin activity. Field mapping
// from the Garmin/MCP response happens in internal/sync, not here, so this
// package stays independent of internal/garmin.
//
// Deliberately NOT modeled here (though Garmin's response includes them):
// ActivityName/ActivityType, plus every field removed in the 2026-07
// dedup pass (BeginTimestampMs, MaxSpeedMps, ElevationLossM, Calories,
// LapCount, TrainingEffectLabel, HrTimeInZone1-5). None of them are read by
// any SQL query, the classify rule engine, or any other Go code -- they'd be
// pure duplicates of RawJSON with no purpose beyond being slightly more
// convenient to read than parsing JSON. ActivityName/ActivityType do have a
// real frontend display need, so the API layer (internal/api) decodes them
// from RawJSON at response time instead of storing a redundant copy -- see
// decodeActivityDisplayFields.
type Activity struct {
ID int64
GarminActivityID int64
ActivityName string
ActivityType string
EventTypeKey string
WorkoutID *int64
StartTimeUTC string
BeginTimestampMs int64
DurationSeconds float64
DistanceMeters float64
AvgHR *float64
MaxHR *float64
AvgSpeedMps *float64
MaxSpeedMps *float64
ElevationGainM *float64
ElevationLossM *float64
Calories *float64
LapCount int
AerobicTrainingEffect *float64
AnaerobicTrainingEffect *float64
TrainingEffectLabel string
VO2MaxValue *float64
HrTimeInZone1 *float64
HrTimeInZone2 *float64
HrTimeInZone3 *float64
HrTimeInZone4 *float64
HrTimeInZone5 *float64
RawJSON string
DetailsFetchedAt *string
DetailsRawJSON *string
SplitsFetchedAt *string
// WorkoutRawJSON is the genuine raw get_workout_by_id() response for this
// activity's structured workout -- the source used to compute each lap's
// TargetPaceLowMps/HighMps and TargetHRLowBpm/HighBpm (see
// internal/sync/mapping.go's alignWorkoutTargets). Nil when the activity
// has no WorkoutID, or was synced before this column existed.
WorkoutRawJSON *string
CreatedAt string
UpdatedAt string
}
@@ -51,49 +55,32 @@ type Activity struct {
func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) {
_, err := db.ExecContext(ctx, `
INSERT INTO activities (
garmin_activity_id, activity_name, activity_type, event_type_key, workout_id, start_time_utc,
begin_timestamp_ms, duration_seconds, distance_meters, avg_hr, max_hr,
avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
calories, lap_count, aerobic_training_effect, anaerobic_training_effect,
training_effect_label, vo2max_value,
hr_time_in_zone_1, hr_time_in_zone_2, hr_time_in_zone_3, hr_time_in_zone_4, hr_time_in_zone_5,
garmin_activity_id, event_type_key, workout_id, start_time_utc,
duration_seconds, distance_meters, avg_hr, max_hr,
avg_speed_mps, elevation_gain_m,
aerobic_training_effect, anaerobic_training_effect, vo2max_value,
raw_json, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now'))
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now'))
ON CONFLICT(garmin_activity_id) DO UPDATE SET
activity_name=excluded.activity_name,
activity_type=excluded.activity_type,
event_type_key=excluded.event_type_key,
workout_id=excluded.workout_id,
start_time_utc=excluded.start_time_utc,
begin_timestamp_ms=excluded.begin_timestamp_ms,
duration_seconds=excluded.duration_seconds,
distance_meters=excluded.distance_meters,
avg_hr=excluded.avg_hr,
max_hr=excluded.max_hr,
avg_speed_mps=excluded.avg_speed_mps,
max_speed_mps=excluded.max_speed_mps,
elevation_gain_m=excluded.elevation_gain_m,
elevation_loss_m=excluded.elevation_loss_m,
calories=excluded.calories,
lap_count=excluded.lap_count,
aerobic_training_effect=excluded.aerobic_training_effect,
anaerobic_training_effect=excluded.anaerobic_training_effect,
training_effect_label=excluded.training_effect_label,
vo2max_value=excluded.vo2max_value,
hr_time_in_zone_1=excluded.hr_time_in_zone_1,
hr_time_in_zone_2=excluded.hr_time_in_zone_2,
hr_time_in_zone_3=excluded.hr_time_in_zone_3,
hr_time_in_zone_4=excluded.hr_time_in_zone_4,
hr_time_in_zone_5=excluded.hr_time_in_zone_5,
raw_json=excluded.raw_json,
updated_at=datetime('now')
`,
a.GarminActivityID, a.ActivityName, a.ActivityType, a.EventTypeKey, a.WorkoutID, a.StartTimeUTC,
a.BeginTimestampMs, a.DurationSeconds, a.DistanceMeters, a.AvgHR, a.MaxHR,
a.AvgSpeedMps, a.MaxSpeedMps, a.ElevationGainM, a.ElevationLossM,
a.Calories, a.LapCount, a.AerobicTrainingEffect, a.AnaerobicTrainingEffect,
a.TrainingEffectLabel, a.VO2MaxValue,
a.HrTimeInZone1, a.HrTimeInZone2, a.HrTimeInZone3, a.HrTimeInZone4, a.HrTimeInZone5,
a.GarminActivityID, a.EventTypeKey, a.WorkoutID, a.StartTimeUTC,
a.DurationSeconds, a.DistanceMeters, a.AvgHR, a.MaxHR,
a.AvgSpeedMps, a.ElevationGainM,
a.AerobicTrainingEffect, a.AnaerobicTrainingEffect, a.VO2MaxValue,
a.RawJSON,
)
if err != nil {
@@ -110,26 +97,22 @@ func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) {
func scanActivity(row interface{ Scan(...any) error }) (Activity, error) {
var a Activity
err := row.Scan(
&a.ID, &a.GarminActivityID, &a.ActivityName, &a.ActivityType, &a.EventTypeKey, &a.WorkoutID, &a.StartTimeUTC,
&a.BeginTimestampMs, &a.DurationSeconds, &a.DistanceMeters, &a.AvgHR, &a.MaxHR,
&a.AvgSpeedMps, &a.MaxSpeedMps, &a.ElevationGainM, &a.ElevationLossM,
&a.Calories, &a.LapCount, &a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect,
&a.TrainingEffectLabel, &a.VO2MaxValue,
&a.HrTimeInZone1, &a.HrTimeInZone2, &a.HrTimeInZone3, &a.HrTimeInZone4, &a.HrTimeInZone5,
&a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt,
&a.ID, &a.GarminActivityID, &a.EventTypeKey, &a.WorkoutID, &a.StartTimeUTC,
&a.DurationSeconds, &a.DistanceMeters, &a.AvgHR, &a.MaxHR,
&a.AvgSpeedMps, &a.ElevationGainM,
&a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect, &a.VO2MaxValue,
&a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt, &a.WorkoutRawJSON,
&a.CreatedAt, &a.UpdatedAt,
)
return a, err
}
const activityColumns = `
id, garmin_activity_id, activity_name, activity_type, event_type_key, workout_id, start_time_utc,
begin_timestamp_ms, duration_seconds, distance_meters, avg_hr, max_hr,
avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
calories, lap_count, aerobic_training_effect, anaerobic_training_effect,
training_effect_label, vo2max_value,
hr_time_in_zone_1, hr_time_in_zone_2, hr_time_in_zone_3, hr_time_in_zone_4, hr_time_in_zone_5,
raw_json, details_fetched_at, details_raw_json, splits_fetched_at,
id, garmin_activity_id, event_type_key, workout_id, start_time_utc,
duration_seconds, distance_meters, avg_hr, max_hr,
avg_speed_mps, elevation_gain_m,
aerobic_training_effect, anaerobic_training_effect, vo2max_value,
raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json,
created_at, updated_at
`
@@ -189,6 +172,24 @@ func (db *DB) ListActivities(ctx context.Context, f ActivityFilter) ([]Activity,
return activities, rows.Err()
}
// ActivityExists reports whether an activity with this garmin_activity_id is
// already stored, checked before each upsert during a sync pass so the
// reported "activities fetched" count reflects genuinely new activities, not
// every activity Garmin's API happens to return for the queried date range
// (which, thanks to the incremental overlap window and backfill's
// already-covered history, is almost always a re-listing of known ones).
func (db *DB) ActivityExists(ctx context.Context, garminActivityID int64) (bool, error) {
var id int64
err := db.QueryRowContext(ctx, `SELECT id FROM activities WHERE garmin_activity_id = ?`, garminActivityID).Scan(&id)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, fmt.Errorf("check activity %d exists: %w", garminActivityID, err)
}
return true, nil
}
// LatestActivityStartTime returns the start_time_utc of the most recently
// started activity we have, used to compute the incremental sync window.
func (db *DB) LatestActivityStartTime(ctx context.Context) (string, bool, error) {
@@ -215,6 +216,18 @@ func (db *DB) SetActivityDetails(ctx context.Context, activityID int64, rawJSON
return nil
}
// SetActivityWorkout stores the raw get_workout_by_id() response used to
// compute this activity's laps' target pace/HR bands.
func (db *DB) SetActivityWorkout(ctx context.Context, activityID int64, rawJSON string) error {
_, err := db.ExecContext(ctx, `
UPDATE activities SET workout_raw_json = ?, updated_at = datetime('now')
WHERE id = ?`, rawJSON, activityID)
if err != nil {
return fmt.Errorf("set activity %d workout: %w", activityID, err)
}
return nil
}
// SetActivitySplitsFetched records that get_activity_splits has been fetched
// for this activity.
func (db *DB) SetActivitySplitsFetched(ctx context.Context, activityID int64) error {

View File

@@ -7,19 +7,18 @@ import (
// Lap is one lap/split of an activity, from get_activity_splits, plus
// derived HR drift/recovery metrics computed from activity_samples.
//
// Deliberately NOT modeled here: StartTimeUTC, DistanceMeters, MaxHR,
// MaxSpeedMps, ElevationGainM, ElevationLossM (removed in the 2026-07
// dedup pass -- zero consumers anywhere, pure duplicates of RawJSON), plus
// DurationSeconds and AvgHR, which DO have a real frontend chart need but no
// backend one, so the API layer decodes them from RawJSON at response time
// instead of storing a redundant copy -- see decodeLapDisplayFields.
type Lap struct {
ID int64
ActivityID int64
LapIndex int
StartTimeUTC string
DurationSeconds float64
DistanceMeters float64
AvgHR *float64
MaxHR *float64
AvgSpeedMps *float64
MaxSpeedMps *float64
ElevationGainM *float64
ElevationLossM *float64
IntensityType string
HRDriftBpmPerMin *float64
HRRecoveryBpmPerMin *float64
@@ -52,13 +51,11 @@ func (db *DB) ReplaceLaps(ctx context.Context, activityID int64, laps []Lap) err
for _, l := range laps {
_, err := tx.ExecContext(ctx, `
INSERT INTO laps (
activity_id, lap_index, start_time_utc, duration_seconds, distance_meters,
avg_hr, max_hr, avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
activity_id, lap_index, avg_speed_mps,
intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min,
target_pace_low_mps, target_pace_high_mps, target_hr_low_bpm, target_hr_high_bpm, raw_json
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
activityID, l.LapIndex, l.StartTimeUTC, l.DurationSeconds, l.DistanceMeters,
l.AvgHR, l.MaxHR, l.AvgSpeedMps, l.MaxSpeedMps, l.ElevationGainM, l.ElevationLossM,
) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
activityID, l.LapIndex, l.AvgSpeedMps,
l.IntensityType, l.HRDriftBpmPerMin, l.HRRecoveryBpmPerMin,
l.TargetPaceLowMps, l.TargetPaceHighMps, l.TargetHRLowBpm, l.TargetHRHighBpm, l.RawJSON,
)
@@ -72,8 +69,7 @@ func (db *DB) ReplaceLaps(ctx context.Context, activityID int64, laps []Lap) err
// LapsForActivity returns all laps for an activity, ordered by lap_index.
func (db *DB) LapsForActivity(ctx context.Context, activityID int64) ([]Lap, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, activity_id, lap_index, start_time_utc, duration_seconds, distance_meters,
avg_hr, max_hr, avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
SELECT id, activity_id, lap_index, avg_speed_mps,
intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min,
target_pace_low_mps, target_pace_high_mps, target_hr_low_bpm, target_hr_high_bpm, raw_json
FROM laps WHERE activity_id = ? ORDER BY lap_index`, activityID)
@@ -86,8 +82,7 @@ func (db *DB) LapsForActivity(ctx context.Context, activityID int64) ([]Lap, err
for rows.Next() {
var l Lap
if err := rows.Scan(
&l.ID, &l.ActivityID, &l.LapIndex, &l.StartTimeUTC, &l.DurationSeconds, &l.DistanceMeters,
&l.AvgHR, &l.MaxHR, &l.AvgSpeedMps, &l.MaxSpeedMps, &l.ElevationGainM, &l.ElevationLossM,
&l.ID, &l.ActivityID, &l.LapIndex, &l.AvgSpeedMps,
&l.IntensityType, &l.HRDriftBpmPerMin, &l.HRRecoveryBpmPerMin,
&l.TargetPaceLowMps, &l.TargetPaceHighMps, &l.TargetHRLowBpm, &l.TargetHRHighBpm, &l.RawJSON,
); err != nil {

View File

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

View 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';

View File

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

View File

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

View 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';

View File

@@ -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';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,9 @@ import (
// Profile is the single active user's Garmin credentials plus every
// tunable analysis-engine parameter. Always exactly one row (id=1).
type Profile struct {
// Name labels this profile so a future multi-profile setup can show
// which one is active. Only one profile row exists today (id=1).
Name string
GarminEmail string
GarminPassword string
RollingWindowDays int
@@ -32,15 +35,49 @@ type Profile struct {
// don't use these.
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
}
const profileColumns = `
garmin_email, garmin_password, rolling_window_days, backfill_horizon_days, max_heart_rate, resting_heart_rate,
name, garmin_email, garmin_password, rolling_window_days, backfill_horizon_days, max_heart_rate, resting_heart_rate,
hr_zone1_min_pct, hr_zone1_max_pct, hr_zone2_min_pct, hr_zone2_max_pct,
hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct,
hr_zone5_min_pct, hr_zone5_max_pct,
warmup_minutes, cooldown_minutes,
min_representative_pace_sec_per_km, min_representative_time_seconds,
pace_color, heart_rate_color, warmup_color, effort_color, recovery_color, cooldown_color,
main_line_tint_pct, background_darken_pct, target_brighten_pct,
created_at, updated_at
`
@@ -48,11 +85,14 @@ const profileColumns = `
func (db *DB) GetProfile(ctx context.Context) (Profile, error) {
var p Profile
err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profile WHERE id = 1`).Scan(
&p.GarminEmail, &p.GarminPassword, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate,
&p.Name, &p.GarminEmail, &p.GarminPassword, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate,
&p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct,
&p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct,
&p.HRZone5MinPct, &p.HRZone5MaxPct,
&p.WarmupMinutes, &p.CooldownMinutes,
&p.MinRepresentativePaceSecPerKm, &p.MinRepresentativeTimeSeconds,
&p.PaceColor, &p.HeartRateColor, &p.WarmupColor, &p.EffortColor, &p.RecoveryColor, &p.CooldownColor,
&p.MainLineTintPct, &p.BackgroundDarkenPct, &p.TargetBrightenPct,
&p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
@@ -67,18 +107,24 @@ func (db *DB) GetProfile(ctx context.Context) (Profile, error) {
func (db *DB) UpdateProfile(ctx context.Context, p Profile) error {
_, err := db.ExecContext(ctx, `
UPDATE profile SET
garmin_email=?, garmin_password=?, rolling_window_days=?, backfill_horizon_days=?, max_heart_rate=?, resting_heart_rate=?,
name=?, garmin_email=?, garmin_password=?, rolling_window_days=?, backfill_horizon_days=?, max_heart_rate=?, resting_heart_rate=?,
hr_zone1_min_pct=?, hr_zone1_max_pct=?, hr_zone2_min_pct=?, hr_zone2_max_pct=?,
hr_zone3_min_pct=?, hr_zone3_max_pct=?, hr_zone4_min_pct=?, hr_zone4_max_pct=?,
hr_zone5_min_pct=?, hr_zone5_max_pct=?,
warmup_minutes=?, cooldown_minutes=?,
min_representative_pace_sec_per_km=?, min_representative_time_seconds=?,
pace_color=?, heart_rate_color=?, warmup_color=?, effort_color=?, recovery_color=?, cooldown_color=?,
main_line_tint_pct=?, background_darken_pct=?, target_brighten_pct=?,
updated_at=datetime('now')
WHERE id = 1`,
p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, p.MaxHeartRate, p.RestingHeartRate,
p.Name, p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, p.MaxHeartRate, p.RestingHeartRate,
p.HRZone1MinPct, p.HRZone1MaxPct, p.HRZone2MinPct, p.HRZone2MaxPct,
p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct,
p.HRZone5MinPct, p.HRZone5MaxPct,
p.WarmupMinutes, p.CooldownMinutes,
p.MinRepresentativePaceSecPerKm, p.MinRepresentativeTimeSeconds,
p.PaceColor, p.HeartRateColor, p.WarmupColor, p.EffortColor, p.RecoveryColor, p.CooldownColor,
p.MainLineTintPct, p.BackgroundDarkenPct, p.TargetBrightenPct,
)
if err != nil {
return fmt.Errorf("update profile: %w", err)

View File

@@ -25,8 +25,35 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
if p.BackfillHorizonDays != 1095 {
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
p.Name = "Kriss"
p.PaceColor = "#111111"
p.EffortColor = "#222222"
p.MainLineTintPct = 45
p.BackgroundDarkenPct = 60
p.TargetBrightenPct = 50
p.GarminEmail = "runner@example.com"
p.GarminPassword = "hunter2"
p.RollingWindowDays = 120
@@ -34,6 +61,8 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
p.RestingHeartRate = &restingHR
p.WarmupMinutes = 8
p.BackfillHorizonDays = 14
p.MinRepresentativePaceSecPerKm = 600
p.MinRepresentativeTimeSeconds = 5
if err := db.UpdateProfile(ctx, p); err != nil {
t.Fatalf("UpdateProfile: %v", err)
@@ -46,6 +75,9 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
if got.GarminEmail != "runner@example.com" || got.RollingWindowDays != 120 {
t.Errorf("got = %+v, want updated email/window", got)
}
if got.Name != "Kriss" {
t.Errorf("Name = %q, want %q after update", got.Name, "Kriss")
}
if got.MaxHeartRate == nil || *got.MaxHeartRate != 190 {
t.Errorf("MaxHeartRate = %v, want 190", got.MaxHeartRate)
}
@@ -55,4 +87,19 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
if got.BackfillHorizonDays != 14 {
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)
}
}

View File

@@ -9,7 +9,7 @@ func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *test
db := openTestDB(t)
ctx := context.Background()
activityID, err := db.UpsertActivity(ctx, Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
activityID, err := db.UpsertActivity(ctx, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}

View File

@@ -40,14 +40,11 @@ func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
a := Activity{
GarminActivityID: 23554066504,
ActivityName: "Auriol - W2-5-Base Endurance",
ActivityType: "running",
StartTimeUTC: "2026-07-11 05:02:35",
BeginTimestampMs: 1783746155000,
DurationSeconds: 1800,
DistanceMeters: 6858,
AvgHR: f(148),
RawJSON: `{"activityId":23554066504}`,
RawJSON: `{"activityId":23554066504,"activityName":"Auriol - W2-5-Base Endurance","activityType":{"typeKey":"running"}}`,
}
id, err := db.UpsertActivity(ctx, a)
@@ -87,7 +84,6 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
activityID, err := db.UpsertActivity(ctx, Activity{
GarminActivityID: 1,
ActivityType: "running",
StartTimeUTC: "2026-07-11 05:00:00",
RawJSON: "{}",
})
@@ -172,7 +168,6 @@ func TestReplaceLapsIsIdempotent(t *testing.T) {
activityID, err := db.UpsertActivity(ctx, Activity{
GarminActivityID: 2,
ActivityType: "running",
StartTimeUTC: "2026-07-11 05:00:00",
RawJSON: "{}",
})
@@ -181,8 +176,8 @@ func TestReplaceLapsIsIdempotent(t *testing.T) {
}
laps := []Lap{
{LapIndex: 1, StartTimeUTC: "2026-07-11 05:00:00", DurationSeconds: 300, DistanceMeters: 1000, AvgHR: f(140), RawJSON: "{}"},
{LapIndex: 2, StartTimeUTC: "2026-07-11 05:05:00", DurationSeconds: 300, DistanceMeters: 1000, AvgHR: f(150), RawJSON: "{}"},
{LapIndex: 1, RawJSON: "{}"},
{LapIndex: 2, RawJSON: "{}"},
}
if err := db.ReplaceLaps(ctx, activityID, laps); err != nil {
t.Fatalf("ReplaceLaps (first): %v", err)

View File

@@ -9,6 +9,11 @@ import (
const (
SyncKindBackfill = "backfill"
SyncKindIncremental = "incremental"
// SyncKindFull is a manually-triggered "Sync now" pass: Backfill followed
// by IncrementalSync followed by FillPendingDetails, recorded as one run
// so the reported activity count covers the whole action instead of only
// whichever stage happened to finish last.
SyncKindFull = "full"
SyncStatusRunning = "running"
SyncStatusSuccess = "success"

View File

@@ -18,8 +18,8 @@ func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) {
}
wantNames := map[string]bool{
"Easy Run": false, "Long Run": false, "Threshold 30'": false, "Threshold 60'": false,
"Tempo": false, "Interval": false, "MAS Test": false, "Race": false,
"Easy": false, "Long": false, "60' Threshold": false, "30' Threshold": false,
"Tempo": false, "Intervals": false, "MAS Test": false, "Race": false,
}
for _, k := range kinds {
if _, ok := wantNames[k.Name]; !ok {
@@ -34,3 +34,31 @@ func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) {
}
}
}
// Every list of training types (Activities filters, Progression's kind
// picker, Profile's Training types card) relies on ListWorkoutKinds' own
// ORDER BY priority DESC, name to already be in this exact fixed order --
// none of them re-sort client-side.
func TestWorkoutTaxonomy_ListedInFixedDisplayOrder(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
kinds, err := db.ListWorkoutKinds(ctx, true)
if err != nil {
t.Fatalf("ListWorkoutKinds: %v", err)
}
want := []string{"Easy", "Long", "60' Threshold", "30' Threshold", "Tempo", "Intervals", "MAS Test", "Race"}
if len(kinds) != len(want) {
t.Fatalf("expected %d kinds, got %d", len(want), len(kinds))
}
for i, name := range want {
if kinds[i].Name != name {
got := make([]string, len(kinds))
for j, k := range kinds {
got[j] = k.Name
}
t.Fatalf("position %d: got %q, want %q (full order: %v)", i, kinds[i].Name, name, got)
}
}
}

View File

@@ -6,23 +6,25 @@ import (
"fmt"
)
// WorkoutTypePace is a workout kind's user-declared target pace range and
// expected HR zone. Informational only -- never read by the classification
// rule engine. No history: fields are overwritten in place.
// WorkoutTypePace is a workout kind's user-declared target pace range and HR
// range (percent of heart rate reserve). Informational only -- never read by
// the classification rule engine. No history: fields are overwritten in
// place.
type WorkoutTypePace struct {
WorkoutKindID int64
PaceMinSecPerKm *float64
PaceMaxSecPerKm *float64
ExpectedHRZone *int
HRMinPctHRR *float64
HRMaxPctHRR *float64
}
func scanWorkoutTypePace(row interface{ Scan(...any) error }) (WorkoutTypePace, error) {
var p WorkoutTypePace
err := row.Scan(&p.WorkoutKindID, &p.PaceMinSecPerKm, &p.PaceMaxSecPerKm, &p.ExpectedHRZone)
err := row.Scan(&p.WorkoutKindID, &p.PaceMinSecPerKm, &p.PaceMaxSecPerKm, &p.HRMinPctHRR, &p.HRMaxPctHRR)
return p, err
}
const workoutTypePaceColumns = `workout_kind_id, pace_min_sec_per_km, pace_max_sec_per_km, expected_hr_zone`
const workoutTypePaceColumns = `workout_kind_id, pace_min_sec_per_km, pace_max_sec_per_km, hr_min_pct_hrr, hr_max_pct_hrr`
// GetWorkoutTypePace fetches the pace/zone row for one workout kind.
func (db *DB) GetWorkoutTypePace(ctx context.Context, workoutKindID int64) (WorkoutTypePace, error) {
@@ -40,9 +42,9 @@ func (db *DB) GetWorkoutTypePace(ctx context.Context, workoutKindID int64) (Work
// UpdateWorkoutTypePace overwrites the pace/zone row for one workout kind.
func (db *DB) UpdateWorkoutTypePace(ctx context.Context, p WorkoutTypePace) error {
_, err := db.ExecContext(ctx, `
UPDATE workout_type_paces SET pace_min_sec_per_km=?, pace_max_sec_per_km=?, expected_hr_zone=?
UPDATE workout_type_paces SET pace_min_sec_per_km=?, pace_max_sec_per_km=?, hr_min_pct_hrr=?, hr_max_pct_hrr=?
WHERE workout_kind_id=?`,
p.PaceMinSecPerKm, p.PaceMaxSecPerKm, p.ExpectedHRZone, p.WorkoutKindID)
p.PaceMinSecPerKm, p.PaceMaxSecPerKm, p.HRMinPctHRR, p.HRMaxPctHRR, p.WorkoutKindID)
if err != nil {
return fmt.Errorf("update workout type pace for kind %d: %w", p.WorkoutKindID, err)
}

View File

@@ -17,16 +17,17 @@ func TestWorkoutTypePaces_SeededOnePerKindThenUpdate(t *testing.T) {
t.Fatalf("expected 8 seeded pace rows (one per taxonomy kind), got %d", len(all))
}
for _, p := range all {
if p.PaceMinSecPerKm != nil || p.PaceMaxSecPerKm != nil || p.ExpectedHRZone != nil {
if p.PaceMinSecPerKm != nil || p.PaceMaxSecPerKm != nil || p.HRMinPctHRR != nil || p.HRMaxPctHRR != nil {
t.Errorf("expected all-nil seeded pace row for kind %d, got %+v", p.WorkoutKindID, p)
}
}
target := all[0]
minPace, maxPace, zone := 330.0, 420.0, 2
minPace, maxPace, hrMin, hrMax := 330.0, 420.0, 70.0, 80.0
target.PaceMinSecPerKm = &minPace
target.PaceMaxSecPerKm = &maxPace
target.ExpectedHRZone = &zone
target.HRMinPctHRR = &hrMin
target.HRMaxPctHRR = &hrMax
if err := db.UpdateWorkoutTypePace(ctx, target); err != nil {
t.Fatalf("UpdateWorkoutTypePace: %v", err)
@@ -39,7 +40,10 @@ func TestWorkoutTypePaces_SeededOnePerKindThenUpdate(t *testing.T) {
if got.PaceMinSecPerKm == nil || *got.PaceMinSecPerKm != 330 {
t.Errorf("PaceMinSecPerKm = %v, want 330", got.PaceMinSecPerKm)
}
if got.ExpectedHRZone == nil || *got.ExpectedHRZone != 2 {
t.Errorf("ExpectedHRZone = %v, want 2", got.ExpectedHRZone)
if got.HRMinPctHRR == nil || *got.HRMinPctHRR != 70 {
t.Errorf("HRMinPctHRR = %v, want 70", got.HRMinPctHRR)
}
if got.HRMaxPctHRR == nil || *got.HRMaxPctHRR != 80 {
t.Errorf("HRMaxPctHRR = %v, want 80", got.HRMaxPctHRR)
}
}

View File

@@ -22,31 +22,18 @@ func isRunningActivityType(typeKey string) bool {
func toActivityRow(a garmin.Activity) store.Activity {
return store.Activity{
GarminActivityID: a.ActivityID,
ActivityName: a.ActivityName,
ActivityType: a.ActivityType.TypeKey,
EventTypeKey: a.EventType.TypeKey,
WorkoutID: a.WorkoutID,
StartTimeUTC: a.StartTimeGMT,
BeginTimestampMs: a.BeginTimestamp,
DurationSeconds: a.Duration,
DistanceMeters: a.Distance,
AvgHR: nonZero(a.AverageHR),
MaxHR: nonZero(a.MaxHR),
AvgSpeedMps: nonZero(a.AverageSpeed),
MaxSpeedMps: nonZero(a.MaxSpeed),
ElevationGainM: a.ElevationGain,
ElevationLossM: a.ElevationLoss,
Calories: nonZero(a.Calories),
LapCount: a.LapCount,
AerobicTrainingEffect: nonZero(a.AerobicTrainingEffect),
AnaerobicTrainingEffect: nonZero(a.AnaerobicTrainingEffect),
TrainingEffectLabel: a.TrainingEffectLabel,
VO2MaxValue: a.VO2MaxValue,
HrTimeInZone1: nonZero(a.HrTimeInZone1),
HrTimeInZone2: nonZero(a.HrTimeInZone2),
HrTimeInZone3: nonZero(a.HrTimeInZone3),
HrTimeInZone4: nonZero(a.HrTimeInZone4),
HrTimeInZone5: nonZero(a.HrTimeInZone5),
RawJSON: string(a.Raw),
}
}
@@ -90,18 +77,9 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.Wor
hrLow, hrHigh = targetHRRange(*targets[i], profile)
}
raw, _ := json.Marshal(l)
rows = append(rows, store.Lap{
LapIndex: l.LapIndex,
StartTimeUTC: l.StartTimeGMT,
DurationSeconds: l.Duration,
DistanceMeters: l.Distance,
AvgHR: nonZero(l.AverageHR),
MaxHR: nonZero(l.MaxHR),
AvgSpeedMps: nonZero(l.AverageSpeed),
MaxSpeedMps: nonZero(l.MaxSpeed),
ElevationGainM: nonZero(l.ElevationGain),
ElevationLossM: nonZero(l.ElevationLoss),
IntensityType: l.IntensityType,
HRDriftBpmPerMin: driftPtr,
HRRecoveryBpmPerMin: recoveryPtr,
@@ -109,7 +87,7 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.Wor
TargetPaceHighMps: paceHigh,
TargetHRLowBpm: hrLow,
TargetHRHighBpm: hrHigh,
RawJSON: string(raw),
RawJSON: string(l.Raw),
})
elapsedStart = elapsedEnd
}
@@ -118,20 +96,31 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.Wor
// alignWorkoutTargets zips an activity's recorded laps against its
// structured workout's flattened steps, returning one *garmin.WorkoutStep
// per lap (nil where unavailable). Zipping only happens when the counts
// 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
// back nil rather than risk showing a target against the wrong lap.
// per lap (nil where unavailable).
//
// Confirmed against a real activity (via Garmin Connect's own workout view)
// 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 {
steps := workout.FlattenSteps()
if len(steps) != len(laps) {
return make([]*garmin.WorkoutStep, len(laps))
}
out := make([]*garmin.WorkoutStep, len(steps))
out := make([]*garmin.WorkoutStep, len(laps))
switch len(laps) - len(steps) {
case 0, 1:
for i := range steps {
s := steps[i]
out[i] = &s
}
}
return out
}

View File

@@ -111,19 +111,30 @@ func (s *Service) Backfill(ctx context.Context) error {
return err
}
profile, err := s.db.GetProfile(ctx)
total, err := s.backfillCore(ctx)
if err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, 0, &msg)
return fmt.Errorf("load profile: %w", err)
s.db.FinishSyncRun(ctx, runID, total, &msg)
return err
}
return s.db.FinishSyncRun(ctx, runID, total, nil)
}
// backfillCore holds Backfill's actual fetch logic, without the SyncRun
// bookkeeping, so FullSync can run it as one step of a single combined run
// instead of its own separately-recorded one. The returned count reflects
// whatever was fetched even when an error is also returned, matching
// Backfill's own partial-progress-on-error behavior.
func (s *Service) backfillCore(ctx context.Context) (int, error) {
profile, err := s.db.GetProfile(ctx)
if err != nil {
return 0, fmt.Errorf("load profile: %w", err)
}
horizon := s.now().AddDate(0, 0, -profile.BackfillHorizonDays)
state, err := s.db.GetSyncState(ctx)
if err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, 0, &msg)
return err
return 0, err
}
end := s.now()
@@ -132,7 +143,7 @@ func (s *Service) Backfill(ctx context.Context) error {
if state.BackfillComplete && !watermark.After(horizon) {
// Already backfilled at least as far back as the configured
// horizon -- nothing new to fetch from Garmin at all.
return s.db.FinishSyncRun(ctx, runID, 0, nil)
return 0, nil
}
end = watermark.AddDate(0, 0, -1)
}
@@ -146,29 +157,23 @@ func (s *Service) Backfill(ctx context.Context) error {
start = horizon
}
n, err := s.fetchAndStoreWindow(ctx, dateStr(start), dateStr(end))
rawCount, newCount, err := s.fetchAndStoreWindow(ctx, dateStr(start), dateStr(end))
if err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, total, &msg)
return fmt.Errorf("backfill window %s..%s: %w", dateStr(start), dateStr(end), err)
return total, fmt.Errorf("backfill window %s..%s: %w", dateStr(start), dateStr(end), err)
}
total += n
total += newCount
if n == 0 {
if rawCount == 0 {
// Empty page: reached the start of this account's history,
// regardless of the configured horizon.
reachedStartOfHistory = true
if err := s.db.UpdateSyncState(ctx, dateStr(start), true); err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, total, &msg)
return err
return total, err
}
break
}
if err := s.db.UpdateSyncState(ctx, dateStr(start), false); err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, total, &msg)
return err
return total, err
}
end = start.AddDate(0, 0, -1)
}
@@ -177,13 +182,11 @@ func (s *Service) Backfill(ctx context.Context) error {
// Reached the configured horizon (not Garmin's actual history
// start) -- mark complete relative to that horizon.
if err := s.db.UpdateSyncState(ctx, dateStr(horizon), true); err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, total, &msg)
return err
return total, err
}
}
return s.db.FinishSyncRun(ctx, runID, total, nil)
return total, nil
}
// IncrementalSync fetches activities from just before the latest known
@@ -194,14 +197,7 @@ func (s *Service) IncrementalSync(ctx context.Context) error {
return err
}
start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
if latest, ok, err := s.db.LatestActivityStartTime(ctx); err == nil && ok {
if t, err := time.Parse("2006-01-02 15:04:05", latest); err == nil {
start = t.AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
}
}
n, err := s.fetchAndStoreWindow(ctx, dateStr(start), dateStr(s.now()))
n, err := s.incrementalSyncCore(ctx)
if err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, n, &msg)
@@ -210,6 +206,58 @@ func (s *Service) IncrementalSync(ctx context.Context) error {
return s.db.FinishSyncRun(ctx, runID, n, nil)
}
// incrementalSyncCore holds IncrementalSync's actual fetch logic, without
// the SyncRun bookkeeping -- see backfillCore.
func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
if latest, ok, err := s.db.LatestActivityStartTime(ctx); err == nil && ok {
if t, err := time.Parse("2006-01-02 15:04:05", latest); err == nil {
start = t.AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
}
}
_, newCount, err := s.fetchAndStoreWindow(ctx, dateStr(start), dateStr(s.now()))
return newCount, err
}
// FullSync performs a complete manual "Sync now" pass -- Backfill (resumes
// from the watermark), then IncrementalSync (catches anything new since the
// latest known activity), then FillPendingDetails -- recorded as a single
// SyncRun. Backfill and IncrementalSync each record their own SyncRun when
// called on their own (used by the periodic background loop), but a manual
// sync runs both back to back, and FillPendingDetails records no run at all;
// showing the user only the most recently *recorded* run (IncrementalSync's)
// would silently hide however many activities Backfill fetched. Recording
// one combined run makes the reported count match the whole action.
func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
runID, err := s.db.StartSyncRun(ctx, store.SyncKindFull)
if err != nil {
return err
}
backfillCount, err := s.backfillCore(ctx)
if err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, backfillCount, &msg)
return err
}
incrementalCount, err := s.incrementalSyncCore(ctx)
total := backfillCount + incrementalCount
if err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, total, &msg)
return err
}
if err := s.FillPendingDetails(ctx, detailFillLimit); err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, total, &msg)
return err
}
return s.db.FinishSyncRun(ctx, runID, total, nil)
}
// ResetAll deletes every synced activity (and its laps/samples/kind
// assignments) and rewinds the backfill watermark, so the next Backfill
// call performs a genuinely fresh pull from Garmin instead of resuming from
@@ -218,26 +266,42 @@ func (s *Service) ResetAll(ctx context.Context) error {
return s.db.ResetAllSyncedData(ctx)
}
func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate string) (int, error) {
// fetchAndStoreWindow returns two counts: rawCount is every activity Garmin's
// API returned for this date range, regardless of sport or whether it was
// already known -- Backfill's "reached start of history" check needs this
// exact unfiltered count, since a page containing only non-running
// activities must not look like an empty page. newCount is how many running
// activities were genuinely new (not already stored), which is what actually
// belongs in the user-facing "activities fetched" report: the incremental
// overlap window and backfill's already-covered history mean Garmin almost
// always re-returns activities we already have, and reporting rawCount there
// produced a confusing, meaningless number (e.g. "2 activities" on a sync
// that found nothing new, just because 2 already-known activities happened
// to fall inside the queried window).
func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate string) (rawCount, newCount int, err error) {
activities, err := s.garmin.GetActivities(ctx, startDate, endDate, 500)
if err != nil {
return 0, fmt.Errorf("get_activities(%s, %s): %w", startDate, endDate, err)
return 0, 0, fmt.Errorf("get_activities(%s, %s): %w", startDate, endDate, err)
}
for _, a := range activities {
// Only running activities are of interest here; other sports (padel,
// cycling, strength training, ...) also come back from
// get_activities() but are dropped rather than stored. len(activities)
// below stays the unfiltered count, since it drives the backfill
// watermark's "reached start of history" check -- a page containing
// only non-running activities must not look like an empty page.
// get_activities() but are dropped rather than stored.
if !isRunningActivityType(a.ActivityType.TypeKey) {
continue
}
exists, err := s.db.ActivityExists(ctx, a.ActivityID)
if err != nil {
return 0, 0, err
}
if _, err := s.db.UpsertActivity(ctx, toActivityRow(a)); err != nil {
return 0, fmt.Errorf("store activity %d: %w", a.ActivityID, err)
return 0, 0, fmt.Errorf("store activity %d: %w", a.ActivityID, err)
}
if !exists {
newCount++
}
}
return len(activities), nil
return len(activities), newCount, nil
}
// FillPendingDetails fetches get_activity_splits/get_activity_details for up
@@ -293,6 +357,9 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, pro
log.Printf("sync: get_workout_by_id(%d) for activity %d failed, continuing without target zones: %v", *a.WorkoutID, a.GarminActivityID, err)
} else {
targets = alignWorkoutTargets(splits.Laps, workout)
if err := s.db.SetActivityWorkout(ctx, a.ID, string(workout.Raw)); err != nil {
return err
}
}
}
@@ -303,8 +370,7 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, pro
if err := s.db.ReplaceLaps(ctx, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil {
return err
}
detailsRaw, _ := json.Marshal(details)
if err := s.db.SetActivityDetails(ctx, a.ID, string(detailsRaw)); err != nil {
if err := s.db.SetActivityDetails(ctx, a.ID, string(details.Raw)); err != nil {
return err
}
return s.db.SetActivitySplitsFetched(ctx, a.ID)

View File

@@ -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) {
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
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)
ctx := context.Background()
@@ -329,7 +358,10 @@ func TestFillPendingDetails_MismatchedLapCountLeavesTargetsNil(t *testing.T) {
},
Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
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{
{Steps: []garmin.WorkoutStep{
{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 {
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
}
for i, l := range laps {
if l.TargetPaceLowMps != nil || l.TargetPaceHighMps != nil {
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[0].TargetPaceLowMps == nil || *laps[0].TargetPaceLowMps != 3.0 {
t.Errorf("laps[0].TargetPaceLowMps = %v, want 3.0 (the one defined step's target)", laps[0].TargetPaceLowMps)
}
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) {
db := openTestDB(t)
ctx := context.Background()

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<title>geniusrun</title>
</head>
<body>
<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

View File

@@ -10,7 +10,7 @@ body {
}
.app {
max-width: 960px;
max-width: 1100px;
margin: 0 auto;
padding: 0 1.5rem 3rem;
}
@@ -42,18 +42,57 @@ body {
cursor: pointer;
}
.tab-icon {
margin-right: 0.4rem;
}
.tab.active {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
/* Pill-shaped and icon-led, deliberately unlike the rectangular .tab
buttons: this opens account/profile settings, not an application view
alongside Activities/Progression/Training plan, so it reads more like an
account chip (à la Slack/GitHub's corner avatar) than another nav tab. */
.profile-name {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: #1a1d24;
border: 1px solid #2a2d35;
color: #9aa0ab;
padding: 0.4rem 0.9rem 0.4rem 0.7rem;
border-radius: 999px;
cursor: pointer;
font-weight: 600;
font-size: 0.85rem;
}
.profile-name::before {
content: "⚙️";
font-size: 0.8rem;
}
.profile-name:hover:not(.active) {
border-color: #3b82f6;
color: #e6e6e6;
}
.profile-name.active {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
.garmin-connection {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem 0;
border-bottom: 1px solid #2a2d35;
padding-top: 0.75rem;
border-top: 1px solid #2a2d35;
}
.garmin-connection-row {
@@ -62,6 +101,22 @@ body {
gap: 0.75rem;
}
.garmin-connection-main {
justify-content: space-between;
}
.garmin-connection-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.garmin-connection-status {
display: flex;
align-items: center;
gap: 0.75rem;
}
.garmin-connection-message {
margin: 0;
font-size: 0.8rem;
@@ -128,6 +183,13 @@ button {
cursor: pointer;
}
input[type="color"] {
padding: 0.2rem;
width: 3.5rem;
height: 2rem;
cursor: pointer;
}
button:hover:not(:disabled) {
border-color: #3b82f6;
}
@@ -151,11 +213,15 @@ button:disabled {
.filter-pills {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
flex-wrap: nowrap;
overflow-x: auto;
margin-bottom: 1rem;
padding-bottom: 0.25rem;
}
.filter-pill {
flex-shrink: 0;
white-space: nowrap;
border-radius: 999px;
padding: 0.35rem 0.9rem;
font-size: 0.85rem;
@@ -183,6 +249,11 @@ button:disabled {
gap: 1rem;
}
.review-list-sentinel {
padding: 1rem 0;
text-align: center;
}
.review-item {
border: 1px solid #2a2d35;
border-radius: 8px;
@@ -192,14 +263,30 @@ button:disabled {
.review-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
font-size: 1rem;
}
.review-item-title {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.review-item-header span {
color: #9aa0ab;
font-size: 0.85rem;
}
.review-item-datetime {
display: flex;
flex-direction: column;
align-items: flex-end;
flex-shrink: 0;
line-height: 1.3;
}
.review-item-stats {
display: flex;
gap: 1rem;
@@ -214,35 +301,73 @@ button:disabled {
margin: 0.5rem 0;
}
.review-item-actions {
.classify-control {
position: relative;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.5rem;
align-items: center;
gap: 0.4rem;
}
.classify-label {
font-size: 0.85rem;
}
.classify-lock {
padding: 0.25rem 0.5rem;
font-size: 0.85rem;
line-height: 1;
}
.classify-dropdown {
position: absolute;
top: 100%;
left: 0;
z-index: 10;
margin-top: 0.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.4rem;
background: #14161c;
border: 1px solid #2a2d35;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.classify-dropdown button {
text-align: left;
white-space: nowrap;
}
.classify-dropdown-unassign {
color: #9aa0ab;
padding-bottom: 0.4rem;
margin-bottom: 0.15rem;
border-bottom: 1px solid #2a2d35;
}
.expected-actual-charts {
display: flex;
flex-direction: column;
gap: 1rem;
flex-wrap: wrap;
margin: 0.5rem 0;
}
.mini-chart {
flex: 1 1 220px;
min-width: 180px;
width: 100%;
}
.mini-chart-label {
display: block;
text-align: center;
font-size: 0.75rem;
color: #9aa0ab;
margin-bottom: 0.15rem;
}
.phase-legend {
flex-basis: 100%;
display: flex;
justify-content: center;
gap: 0.75rem;
flex-wrap: wrap;
}
@@ -288,8 +413,8 @@ button:disabled {
background: #14161c;
border: 1px solid #2a2d35;
border-radius: 8px;
width: min(720px, 90vw);
max-height: 80vh;
width: min(1100px, 94vw);
max-height: 88vh;
display: flex;
flex-direction: column;
}
@@ -303,42 +428,75 @@ button:disabled {
font-weight: 600;
}
.modal-header-actions {
display: flex;
gap: 0.5rem;
font-weight: 400;
}
.modal-header-actions button {
font-size: 0.75rem;
padding: 0.25rem 0.6rem;
}
.modal-json {
margin: 0;
padding: 1rem;
overflow: auto;
font-size: 0.8rem;
line-height: 1.4;
line-height: 1.5;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.json-row {
white-space: pre-wrap;
word-break: break-word;
}
.kinds-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
.lock-badge {
.json-toggle {
display: inline-block;
border-radius: 999px;
padding: 0.15rem 0.6rem;
font-size: 0.75rem;
width: 1rem;
padding: 0;
border: none;
background: none;
color: #9aa0ab;
border: 1px solid #2a2d35;
cursor: help;
cursor: pointer;
font-size: 0.7rem;
font-family: inherit;
}
.kinds-table th,
.kinds-table td {
text-align: left;
padding: 0.5rem;
border-bottom: 1px solid #2a2d35;
.json-toggle:hover {
color: #e6e6e6;
}
.kinds-table-actions {
display: flex;
gap: 0.5rem;
.json-key {
color: #9aa0ab;
}
.json-bracket {
color: #6b7280;
}
.json-summary {
color: #6b7280;
font-style: italic;
}
.json-string {
color: #6fcf97;
}
.json-number {
color: #6cb6ff;
}
.json-boolean {
color: #f5a742;
}
.json-null {
color: #6b7280;
font-style: italic;
}
.kind-editor {
@@ -348,7 +506,51 @@ button:disabled {
border: 1px solid #2a2d35;
border-radius: 8px;
padding: 1rem;
max-width: 480px;
max-width: 580px;
}
.profile-page {
display: flex;
flex-direction: column;
gap: 1.75rem;
}
.training-type-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.training-type-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
border: 1px solid #2a2d35;
border-radius: 6px;
padding: 0.75rem;
}
.training-type-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
.training-type-description {
margin: 0;
font-size: 0.85rem;
color: #9aa0ab;
white-space: pre-wrap;
}
.training-type-meta {
margin: 0;
font-size: 0.8rem;
color: #6b7280;
}
.kind-editor textarea {

View File

@@ -1,46 +1,61 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { api } from "./api/client";
import "./App.css";
import { GarminConnection } from "./components/GarminConnection";
import { Activities } from "./pages/Activities";
import { Dashboard } from "./pages/Dashboard";
import { Plan } from "./pages/Plan";
import { Profile } from "./pages/Profile";
import { ReviewQueue } from "./pages/ReviewQueue";
import { WorkoutKinds } from "./pages/WorkoutKinds";
const TABS = [
{ key: "dashboard", label: "Progression", Component: Dashboard },
{ key: "review", label: "Review Queue", Component: ReviewQueue },
{ key: "activities", label: "Activities", Component: Activities },
{ key: "kinds", label: "Workout Kinds", Component: WorkoutKinds },
{ key: "profile", label: "Profile", Component: Profile },
{ key: "activities", label: "Activities", icon: "☰", Component: ReviewQueue },
{ key: "dashboard", label: "Progression", icon: "↗", Component: Dashboard },
{ key: "plan", label: "Training plan", icon: "✎", Component: Plan },
] as const;
type TabKey = (typeof TABS)[number]["key"];
function App() {
const [tab, setTab] = useState<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;
return (
<div className="app">
<header className="app-header">
<h1>smartrun</h1>
<h1>🧞 geniusrun</h1>
<nav className="tabs">
{TABS.map((t) => (
<button
key={t.key}
className={t.key === tab ? "tab active" : "tab"}
onClick={() => setTab(t.key)}
className={!showProfile && t.key === tab ? "tab active" : "tab"}
onClick={() => {
setShowProfile(false);
setTab(t.key);
}}
>
<span className="tab-icon">{t.icon}</span>
{t.label}
</button>
))}
</nav>
<button
type="button"
className={showProfile ? "profile-name active" : "profile-name"}
onClick={() => setShowProfile(true)}
>
{profileName ?? "Profile"}
</button>
</header>
<GarminConnection />
<main>
<Active />
</main>
<main>{showProfile ? <Profile onSaved={(p) => setProfileName(p.Name)} /> : <Active />}</main>
</div>
);
}

View File

@@ -6,7 +6,7 @@ import type {
Profile,
ProgressionMetric,
ProgressionPoint,
ReviewQueueItem,
ReviewQueuePage,
SyncRun,
SyncStatus,
WorkoutKind,
@@ -55,7 +55,7 @@ export const api = {
getActivity: (id: number) =>
request<{ activity: Activity; laps: unknown[]; assignment?: KindAssignment }>(`/api/activities/${id}`),
// Workout kinds -- fixed taxonomy, no create/delete
// Training types -- fixed taxonomy, no create/delete
listWorkoutKinds: (includeInactive = false) =>
request<WorkoutKind[]>(`/api/workout-kinds/${includeInactive ? "?include_inactive=true" : ""}`),
updateWorkoutKind: (
@@ -69,7 +69,8 @@ export const api = {
is_active?: boolean;
pace_min_sec_per_km?: number | null;
pace_max_sec_per_km?: number | null;
expected_hr_zone?: number | null;
hr_min_pct_hrr?: number | null;
hr_max_pct_hrr?: number | null;
},
) => request<WorkoutKind>(`/api/workout-kinds/${id}`, { method: "PUT", body: JSON.stringify(body) }),
@@ -82,13 +83,34 @@ export const api = {
updateProfile: (profile: Profile) =>
request<Profile>("/api/profile", { method: "PUT", body: JSON.stringify(profile) }),
// Review queue
reviewQueue: () => request<ReviewQueueItem[]>("/api/review-queue/"),
// Review queue -- cursor-paginated: pass the previous page's next_cursor
// as `before` to fetch the next one. Fetching every activity's laps and
// per-second samples up front gets expensive once there are many, so the
// frontend loads it incrementally (infinite scroll) instead of all at once
// -- 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) =>
request<{ status: string }>(`/api/review-queue/${activityId}/resolve`, {
method: "POST",
body: JSON.stringify({ workout_kind_id: workoutKindId }),
}),
// Reverts a manual assignment back to rule_engine sourcing (same kind),
// so a later reclassify pass is free to change it again.
unlockReview: (activityId: number) =>
request<{ status: string }>(`/api/review-queue/${activityId}/unlock`, { method: "POST" }),
// Manually clears an activity's kind back to Unclassified (locks that
// decision, same as resolveReview locks a specific kind).
unassignReview: (activityId: number) =>
request<{ status: string }>(`/api/review-queue/${activityId}/unassign`, { method: "POST" }),
// Progression
progression: (kindId: number, metric: ProgressionMetric = "pace", from?: string, to?: string) => {

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

View File

@@ -20,6 +20,10 @@ export function GarminConnection() {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | 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() {
api.authStatus().then(setAuth).catch((e) => setError(String(e)));
@@ -47,6 +51,7 @@ export function GarminConnection() {
setError(null);
try {
setAuth(await api.login());
setDisconnected(false);
} catch (e) {
setError(String(e));
} finally {
@@ -54,6 +59,10 @@ export function GarminConnection() {
}
}
function disconnect() {
setDisconnected(true);
}
async function submitMFA() {
if (!code.trim()) return;
setBusy(true);
@@ -90,7 +99,7 @@ export function GarminConnection() {
try {
await api.resetSync();
// Reset runs as a background sync job; wait for it to actually finish
// before reloading, otherwise other pages (e.g. Review Queue) would
// before reloading, otherwise other pages (e.g. Activities) would
// still show the just-deleted activities from their own stale state.
let status = await api.syncStatus();
while (status.in_progress) {
@@ -104,19 +113,12 @@ export function GarminConnection() {
}
}
const status = auth?.status ?? "unknown";
const status = disconnected ? "unknown" : auth?.status ?? "unknown";
return (
<div className="garmin-connection">
<div className="garmin-connection-row">
<span className={`status-dot status-${status}`} />
<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>
<div className="garmin-connection-row garmin-connection-main">
<div className="garmin-connection-actions">
{status !== "authenticated" && status !== "mfa_required" && (
<button disabled={busy} onClick={connect}>
Connect to Garmin
@@ -128,21 +130,31 @@ export function GarminConnection() {
<button disabled={busy} onClick={sync}>
Sync now
</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}>
Reset all
</button>
{syncStatus?.in_progress && (
<span className="status-label">{syncProgressLabel(syncStatus)}</span>
)}
{syncStatus?.last_run && !syncStatus.in_progress && (
</div>
<div className="garmin-connection-status">
<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">
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`}
{status === "mfa_required" ? "MFA code required" : "Connection failed"}
</span>
)}
</>
)}
</div>
</div>
{status === "mfa_required" && (
@@ -159,7 +171,21 @@ export function GarminConnection() {
</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>}
</div>
);

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

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

View File

@@ -1,9 +1,13 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
// Fields whose value is itself a JSON string (Garmin's raw payloads, kept
// verbatim in the DB). Parsed back into objects so the modal shows one
// readable nested tree instead of an escaped string blob.
const RAW_JSON_STRING_FIELDS = new Set(["RawJSON", "DetailsRawJSON"]);
// verbatim in the DB). Parsed back into objects so the tree shows a real
// nested structure instead of an escaped string blob. Every other field is
// shown as-is, even ones that duplicate something inside these blobs --
// the backend's own duplication cleanup (2026-07) already removed every
// field that had no good reason to exist alongside RawJSON, so anything
// left here is worth seeing in full.
const RAW_JSON_STRING_FIELDS = new Set(["RawJSON", "DetailsRawJSON", "WorkoutRawJSON"]);
function parseEmbeddedJSON(value: unknown): unknown {
if (Array.isArray(value)) return value.map(parseEmbeddedJSON);
@@ -26,21 +30,147 @@ function parseEmbeddedJSON(value: unknown): unknown {
return value;
}
// Indentation per nesting level, in px. Array levels use a smaller step than
// object levels -- a numeric index carries far less structure than a named
// key, and Garmin payloads have long, deeply-repeated arrays (samples, laps,
// workout steps) where full per-level indentation quickly eats the width
// available for the values themselves.
const OBJECT_INDENT = 12;
const ARRAY_INDENT = 6;
type ValueKind = "object" | "array" | "string" | "number" | "boolean" | "null";
function kindOf(v: unknown): ValueKind {
if (v === null) return "null";
if (Array.isArray(v)) return "array";
if (typeof v === "object") return "object";
if (typeof v === "number") return "number";
if (typeof v === "boolean") return "boolean";
return "string";
}
// Bumped by the Expand/Collapse all buttons to force every node's local
// fold state, overriding whatever the user clicked individually before.
type ExpandSignal = { gen: number; expand: boolean } | null;
function Primitive({ value }: { value: unknown }) {
const kind = kindOf(value);
if (kind === "string") return <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 }) {
const [signal, setSignal] = useState<ExpandSignal>(null);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose]);
const parsed = parseEmbeddedJSON(data);
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<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>
<pre className="modal-json">{JSON.stringify(parseEmbeddedJSON(data), null, 2)}</pre>
</div>
</div>
);

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

View File

@@ -1,23 +1,51 @@
import { Area, AreaChart, Line, ReferenceArea, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { formatMinutesSeconds } from "../PaceField";
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 {
if (mps == null || mps <= 0) return null;
const pace = 1000 / mps;
return pace <= MAX_PLAUSIBLE_PACE_SEC_PER_KM ? pace : null;
return 1000 / mps;
}
// 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 {
const m = Math.floor(secPerKm / 60);
const s = Math.round(secPerKm % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
return formatMinutesSeconds(secPerKm);
}
function formatPace(secPerKm: number): string {
@@ -25,9 +53,7 @@ function formatPace(secPerKm: number): string {
}
function formatElapsed(minutes: number): string {
const m = Math.floor(minutes);
const s = Math.round((minutes - m) * 60);
return `${m}:${s.toString().padStart(2, "0")}`;
return formatMinutesSeconds(minutes * 60);
}
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
// 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 --
// 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
// denser tick set anyway.
function niceTicks([lo, hi]: [number, number], step: number, count = 3): number[] {
@@ -86,27 +112,48 @@ function hrTickStep(domain: [number, number]): number {
return 5;
}
// Pace and HR each keep one dedicated accent color across every chart, so
// the two metrics stay visually distinct from each other and from the phase
// bands below.
const PACE_COLOR = "#3b82f6"; // blue
const HR_COLOR = "#ef4444"; // red
// Color policy (all 6 colors configurable in Profile > Chart colors):
// - Each chart's main line (the actual pace/HR trace and its dashed phase
// average) is always its metric's own color -- blue for pace, red for HR
// -- regardless of effort kind.
// - The 4 effort-kind colors (warm-up/effort/recovery/cool-down) drive two
// derived fills: the area *under* the line is the effort color with a
// subtle tint of the main line color mixed in (mixColor); the background
// *above* the line (the full-height band behind everything) is the same
// effort color, darkened, with no main-line tint (darken).
function hexToRgb(hex: string): [number, number, number] {
const clean = hex.replace("#", "");
const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
const n = parseInt(full, 16) || 0;
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function rgbToHex([r, g, b]: [number, number, number]): string {
const toHex = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, "0");
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
// Mixes `weight` (0-1) of `tint` into `base` -- e.g. mixColor(effortColor,
// paceColor, 0.2) reads as "effort color with a subtle taint of pace blue".
function mixColor(base: string, tint: string, weight: number): string {
const b = hexToRgb(base);
const t = hexToRgb(tint);
return rgbToHex([0, 1, 2].map((i) => b[i] + (t[i] - b[i]) * weight) as [number, number, number]);
}
// Darkens `hex` by mixing in `amount` (0-1) of black -- no main-line tint,
// just a deeper shade of the same effort color.
function darken(hex: string, amount: number): string {
return mixColor(hex, "#000000", amount);
}
// Brightens `hex` by mixing in `amount` (0-1) of white -- the color cue that
// a chart has a structured-workout target range to show, replacing a text
// label (it's otherwise not visually obvious a target exists).
function brighten(hex: string, amount: number): string {
return mixColor(hex, "#ffffff", amount);
}
// Phase a lap represents, by Garmin's own per-lap IntensityType tagging.
// Colors distinguish warm-up / effort / recovery / cool-down bands behind
// the pace/HR trace so a run's structure reads at a glance -- deliberately
// avoiding blue/red, since those are already spoken for by PACE_COLOR/HR_COLOR.
// ACTIVE and INTERVAL both mean "this is a work/effort segment" -- Garmin
// uses ACTIVE for structured-workout steps and INTERVAL for manually-lapped
// or auto-detected effort segments, but they're the same concept here.
const PHASE_COLORS: Record<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> = {
WARMUP: "Warm-up",
ACTIVE: "Effort",
@@ -131,19 +178,29 @@ interface Point {
t: number;
actualPace: number | null;
targetPaceRange: [number, number] | null;
warmupCooldownAvgPace: number | null;
phaseAvgPace: number | null;
actualHR: 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[] = [];
let elapsedMin = 0;
for (const l of laps) {
laps.forEach((l, i) => {
const start = elapsedMin;
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
// bound is the *lower* (faster) pace bound.
const paceLow = l.TargetPaceHighMps != null ? paceSecPerKm(l.TargetPaceHighMps) : null;
@@ -153,13 +210,13 @@ function buildLapWindows(laps: Lap[]): LapWindow[] {
start,
end: elapsedMin,
intensityType: l.IntensityType,
targetPaceRange: paceLow != null && paceHigh != null ? [paceLow, paceHigh] : null,
targetHRRange: l.TargetHRLowBpm != null && l.TargetHRHighBpm != null ? [l.TargetHRLowBpm, l.TargetHRHighBpm] : null,
targetPaceRange: !isContinuation && paceLow != null && paceHigh != null ? [paceLow, paceHigh] : null,
targetHRRange: !isContinuation && l.TargetHRLowBpm != null && l.TargetHRHighBpm != null ? [l.TargetHRLowBpm, l.TargetHRHighBpm] : null,
avgPace: paceSecPerKm(l.AvgSpeedMps),
avgHR: l.AvgHR,
color: PHASE_COLORS[l.IntensityType],
color: phaseColors[l.IntensityType],
});
});
}
return windows;
}
@@ -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];
}
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) {
return (value: unknown, name: unknown): [string, string] => {
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,
// with the workout's warm-up/effort/recovery/cool-down structure shaded
// 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,
// 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.
// 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
// 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
// example, would otherwise render as a dead-flat line despite the pace/HR
// 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;
// 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 hasHRTarget = laps.some((l) => l.TargetHRLowBpm != null && l.TargetHRHighBpm != null);
// Brightened main line color is the cue that this chart has a target to
// show, replacing a "vs target" text label -- it's otherwise not visually
// obvious a target range is present versus just an unusually flat effort.
const brightenWeight = Math.max(0, Math.min(100, targetBrightenPct)) / 100;
const paceMainColor = hasPaceTarget ? brighten(paceColor, brightenWeight) : paceColor;
const hrMainColor = hasHRTarget ? brighten(heartRateColor, brightenWeight) : heartRateColor;
const singleEffortType = new Set(laps.map((l) => l.IntensityType)).size === 1;
const lapWindows = buildLapWindows(laps);
const lapWindows = buildLapWindows(laps, phaseColors);
const lapTotalMin = lapWindows[lapWindows.length - 1].end;
const phaseSegments = buildPhaseSegments(lapWindows);
function warmupCooldownAvg(w: LapWindow | undefined, metric: "avgPace" | "avgHR"): number | null {
if (!w || (w.intensityType !== "WARMUP" && w.intensityType !== "COOLDOWN")) return null;
return w[metric];
function phaseAvg(t: number, metric: "avgPace" | "avgHR"): number | null {
const seg = phaseSegmentAt(t, phaseSegments);
if (!seg || (seg.intensityType !== "WARMUP" && seg.intensityType !== "COOLDOWN")) return null;
return seg[metric];
}
let points: Point[];
@@ -216,10 +377,10 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples:
t,
actualPace: paceSecPerKm(s.SpeedMps),
targetPaceRange: w?.targetPaceRange ?? null,
warmupCooldownAvgPace: warmupCooldownAvg(w, "avgPace"),
phaseAvgPace: phaseAvg(t, "avgPace"),
actualHR: s.HeartRate,
targetHRRange: w?.targetHRRange ?? null,
warmupCooldownAvgHR: warmupCooldownAvg(w, "avgHR"),
phaseAvgHR: phaseAvg(t, "avgHR"),
};
});
elapsedMin = Math.max(lapTotalMin, points[points.length - 1].t);
@@ -230,21 +391,67 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples:
t: w.start,
actualPace: null,
targetPaceRange: w.targetPaceRange,
warmupCooldownAvgPace: warmupCooldownAvg(w, "avgPace"),
phaseAvgPace: phaseAvg(w.start, "avgPace"),
actualHR: null,
targetHRRange: w.targetHRRange,
warmupCooldownAvgHR: warmupCooldownAvg(w, "avgHR"),
phaseAvgHR: phaseAvg(w.start, "avgHR"),
}));
points.push({ ...points[points.length - 1], t: 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 phasesPresent = [...new Set(laps.map((l) => l.IntensityType).filter((t) => PHASE_COLORS[t]))];
// A light vertical line at every point the effort type changes (warm-up
// -> effort -> recovery -> ...), regardless of whether that type has a
// background color, so the workout's structure reads at a glance.
const phaseBoundaries = lapWindows.slice(1).filter((w, i) => w.intensityType !== lapWindows[i].intensityType).map((w) => w.start);
const phasesPresent = [...new Set(laps.map((l) => l.IntensityType).filter((t) => phaseColors[t]))];
// A light vertical line at every lap boundary -- not just where the effort
// kind changes (warm-up -> effort -> recovery -> ...), but also between two
// consecutive laps of the *same* kind, e.g. the workout's own cool-down lap
// followed by an extra cool-down lap logged after the recording continued
// past the prescribed target (see buildLapWindows' isContinuation). Those
// two laps get merged into one shaded phase segment (same color, same
// fill), so without this line they'd otherwise read as a single lap.
const phaseBoundaries = lapWindows.slice(1).map((w) => w.start);
// One Area per phase segment instead of one for the whole chart, so the
// fill under the line can vary by effort kind (each segment gets its own
// color) while the stroke stays the metric's single main color throughout.
// The last segment is extended to elapsedMin (not just its own end) so a
// sample slightly past the laps' own total duration is still covered --
// otherwise the last sliver of the trace would be left unfilled.
const tintWeight = Math.max(0, Math.min(100, mainLineTintPct)) / 100;
const darkenWeight = Math.max(0, Math.min(100, backgroundDarkenPct)) / 100;
function segmentsWithPoints(mainColor: string) {
return phaseSegments
.map((seg, i) => {
const color = phaseColors[seg.intensityType];
if (!color) return null;
const isLast = i === phaseSegments.length - 1;
const segPoints = points.filter((p) => p.t >= seg.start && (isLast || p.t <= seg.end));
if (segPoints.length === 0) return null;
return { key: `${seg.intensityType}-${seg.start}`, points: segPoints, fill: mixColor(color, mainColor, tintWeight) };
})
.filter((s): s is { key: string; points: Point[]; fill: string } => s != null);
}
const paceSegments = segmentsWithPoints(paceMainColor);
const hrSegments = segmentsWithPoints(hrMainColor);
const paceDomain = robustDomain(
points.map((p) => p.actualPace),
@@ -264,58 +471,95 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples:
return (
<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">
<span className="mini-chart-label">Pace{hasPaceTarget ? " vs target" : ""}</span>
<ResponsiveContainer width="100%" height={100}>
<span className="mini-chart-label">Pace</span>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={points} margin={{ top: 4, right: 4, bottom: 0, left: 4 }}>
{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) => (
<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`} />
<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 && (
<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 />
<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} />
{paceSegments.map((seg) => (
<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>
</ResponsiveContainer>
</div>
<div className="mini-chart">
<span className="mini-chart-label">HR{hasHRTarget ? " vs target" : ""}</span>
<ResponsiveContainer width="100%" height={100}>
<span className="mini-chart-label">Heart Rate</span>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={points} margin={{ top: 4, right: 4, bottom: 0, left: 4 }}>
{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) => (
<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`} />
<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 && (
<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 />
<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} />
{hrSegments.map((seg) => (
<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>
</ResponsiveContainer>
</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>
);
}

View File

@@ -1,20 +1,68 @@
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { formatMinutesSeconds } from "../PaceField";
import type { ProgressionPoint } from "../../types/api";
const METRIC_LABELS: Record<string, string> = {
pace: "Pace (sec/km)",
pace: "Pace (m:ss/km)",
hr: "Avg HR (bpm)",
vo2max: "VO2max",
aerobic_te: "Aerobic Training Effect",
anaerobic_te: "Anaerobic Training Effect",
// Speed per heartbeat, scaled by 1000 for readability (the raw m/s-per-bpm
// ratio is a tiny fraction like 0.02, too small to compare at a glance).
efficiency_factor: "Efficiency Factor (×1000)",
};
function formatPaceShort(secPerKm: number): string {
return formatMinutesSeconds(secPerKm);
}
function formatMetricValue(metric: string, value: number): string {
if (metric === "pace") return `${formatPaceShort(value)}/km`;
// Efficiency factor only really matters at whole-number granularity (11
// vs. 14) -- a fractional value like 12.34 implies precision the ×1000
// scaling doesn't actually carry.
if (metric === "efficiency_factor") return String(Math.round(value));
return value.toFixed(1);
}
// Axis ticks specifically (not the tooltip): with a tight, non-round domain
// (see computeDomain) Recharts interpolates tick values directly between the
// domain's exact bounds instead of snapping to round numbers, so unrounded
// ticks come out as e.g. "15.138246239532403" -- technically correct but
// unreadable, and easy to misread as a much bigger number than it is.
function formatAxisTick(metric: string, value: number): string {
if (metric === "pace") return formatPaceShort(value);
if (metric === "efficiency_factor") return String(Math.round(value));
return value.toFixed(1);
}
// Recharts' YAxis defaults to a [0, auto] domain, which for a metric like HR
// or pace makes every real activity's variation collapse into a sliver near
// the top of the chart (starting a heart-rate axis at 0bpm, for example).
// Padding tightly around the actual data range instead makes real
// progression visible. Progression series are small (one point per
// activity/week), so plain min/max is enough -- no need for the percentile
// trimming the per-second chart in ExpectedVsActualChart.tsx uses.
function computeDomain(values: number[]): [number, number] {
if (values.length === 0) return [0, 1];
const lo = Math.min(...values);
const hi = Math.max(...values);
if (lo === hi) {
const pad = Math.abs(lo) * 0.1 || 1;
return [lo - pad, hi + pad];
}
const pad = (hi - lo) * 0.15;
return [lo - pad, hi + pad];
}
export function ProgressionChart({ points, metric }: { points: ProgressionPoint[]; metric: string }) {
if (points.length === 0) {
return <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 domain = computeDomain(data.map((d) => d.value));
return (
<ResponsiveContainer width="100%" height={320}>
@@ -23,11 +71,20 @@ export function ProgressionChart({ points, metric }: { points: ProgressionPoint[
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis
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"}
domain={domain}
tickFormatter={(v) => formatAxisTick(metric, Number(v))}
label={{ value: METRIC_LABELS[metric] ?? metric, angle: -90, position: "insideLeft", fontSize: 12 }}
/>
<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 }} />
</LineChart>

View File

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

View File

@@ -9,6 +9,7 @@ const METRICS: { key: ProgressionMetric; label: string }[] = [
{ key: "vo2max", label: "VO2max" },
{ key: "aerobic_te", label: "Aerobic Training Effect" },
{ key: "anaerobic_te", label: "Anaerobic Training Effect" },
{ key: "efficiency_factor", label: "Efficiency Factor" },
];
export function Dashboard() {
@@ -42,17 +43,16 @@ export function Dashboard() {
return (
<div className="page">
<h2>Progression</h2>
{kinds.length === 0 ? (
<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>
) : (
<>
<div className="controls">
<label>
Workout kind
Training types
<select
value={selectedKindId ?? ""}
onChange={(e) => setSelectedKindId(Number(e.target.value))}

View File

@@ -0,0 +1,3 @@
export function Plan() {
return <div className="page" />;
}

View File

@@ -1,5 +1,10 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { api } from "../api/client";
import { ColorField } from "../components/ColorField";
import { GarminConnection } from "../components/GarminConnection";
import { NullableNumberField } from "../components/NullableNumberField";
import { PaceField } from "../components/PaceField";
import { TrainingTypesCard } from "../components/TrainingTypesCard";
import type { Profile as ProfileType } from "../types/api";
function NumberField({
@@ -26,71 +31,86 @@ function NumberField({
);
}
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>
);
}
// How long to wait after the last edit before actually saving, so typing a
// name or a multi-digit number doesn't fire one request per keystroke --
// only the last edit in a burst triggers a save, covering every field
// touched during that burst (not just the one that triggered the timer).
const AUTO_SAVE_DELAY_MS = 600;
export function Profile() {
export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) {
const [profile, setProfile] = useState<ProfileType | null>(null);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
// Mirrors `profile` synchronously (state updates don't apply until the
// next render), so set() always debounces from the latest edit rather
// than a stale snapshot from this render's closure.
const profileRef = useRef<ProfileType | null>(null);
profileRef.current = profile;
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
api.getProfile().then(setProfile).catch((e) => setError(String(e)));
}, []);
function set<K extends keyof ProfileType>(key: K, value: ProfileType[K]) {
setProfile((p) => (p ? { ...p, [key]: value } : p));
setSaved(false);
// Flush a still-pending debounced save on unmount (e.g. the user edits a
// field then immediately switches away from the Profile view) rather than
// silently dropping it -- there's no state left to update for, so errors
// are swallowed.
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
if (profileRef.current) api.updateProfile(profileRef.current).catch(() => {});
}
};
}, []);
async function save() {
if (!profile) return;
setError(null);
async function persist(next: ProfileType) {
try {
const updated = await api.updateProfile(profile);
const updated = await api.updateProfile(next);
profileRef.current = updated;
setProfile(updated);
setError(null);
setSaved(true);
onSaved?.(updated);
} catch (e) {
setError(String(e));
setSaved(false);
}
}
function set<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) {
return (
<div className="page">
<h2>Profile</h2>
{error ? <p className="error">{error}</p> : <p>Loading...</p>}
</div>
);
}
return (
<div className="page">
<h2>Profile</h2>
<div className="page profile-page">
{error && <p className="error">{error}</p>}
{saved && <p className="garmin-connection-message">Saved.</p>}
<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>
Email
<input
@@ -107,28 +127,43 @@ export function Profile() {
onChange={(e) => set("GarminPassword", e.target.value)}
/>
</label>
<NumberField
label="Past sync (days)"
value={profile.BackfillHorizonDays}
onChange={(v) => set("BackfillHorizonDays", v)}
/>
<GarminConnection />
</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">
<legend>Classification</legend>
<legend>Activity analysis</legend>
<NumberField
label="Rolling window (days)"
value={profile.RollingWindowDays}
onChange={(v) => set("RollingWindowDays", v)}
/>
</fieldset>
<fieldset className="kind-editor">
<legend>Sync</legend>
<NumberField
label="Backfill horizon (days)"
value={profile.BackfillHorizonDays}
onChange={(v) => set("BackfillHorizonDays", v)}
label="Warm-up (minutes)"
value={profile.WarmupMinutes}
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 className="kind-editor">
@@ -160,25 +195,39 @@ export function Profile() {
</fieldset>
<fieldset className="kind-editor">
<legend>Phase detection</legend>
<legend>Chart colors</legend>
<div className="controls">
<NumberField
label="Warm-up (minutes)"
value={profile.WarmupMinutes}
onChange={(v) => set("WarmupMinutes", v)}
/>
<NumberField
label="Cool-down (minutes)"
value={profile.CooldownMinutes}
onChange={(v) => set("CooldownMinutes", v)}
<ColorField label="Pace (main line)" value={profile.PaceColor} onChange={(v) => set("PaceColor", v)} />
<ColorField
label="Heart rate (main line)"
value={profile.HeartRateColor}
onChange={(v) => set("HeartRateColor", v)}
/>
</div>
<p className="empty-state">
Applies to every workout type. Interval workouts detect warm-up/cool-down from lap data directly and don't use this setting.
</p>
<div className="controls">
<ColorField label="Warm-up" value={profile.WarmupColor} onChange={(v) => set("WarmupColor", v)} />
<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>
<button onClick={save}>Save</button>
<TrainingTypesCard />
</div>
);
}

View File

@@ -1,10 +1,12 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../api/client";
import { ExpectedVsActualChart } from "../components/charts/ExpectedVsActualChart";
import { formatMinutesSeconds } from "../components/PaceField";
import { RawDataModal } from "../components/RawDataModal";
import type { 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[] {
try {
@@ -17,35 +19,275 @@ function candidates(item: ReviewQueueItem): ScoredKind[] {
function formatPace(avgSpeedMps: number | null): string | null {
if (avgSpeedMps == null || avgSpeedMps <= 0) return null;
const secPerKm = 1000 / avgSpeedMps;
const m = Math.floor(secPerKm / 60);
const s = Math.round(secPerKm % 60);
return `${m}:${s.toString().padStart(2, "0")}/km`;
return `${formatMinutesSeconds(secPerKm)}/km`;
}
// StartTimeUTC is "YYYY-MM-DD HH:MM:SS" in UTC with no offset marker, so it
// must be parsed as UTC explicitly (a bare space-separated string like this
// is otherwise ambiguous/inconsistent across browsers) before converting to
// the viewer's own timezone for display.
function formatActivityDateTime(startTimeUTC: string): { date: string; time: string } {
const d = new Date(startTimeUTC.replace(" ", "T") + "Z");
const day = d.getDate().toString().padStart(2, "0");
// Forced to English regardless of the viewer's own locale, to match the
// rest of this app's English-only UI (an auto-locale abbreviation like
// French "juil." would look inconsistent here).
const month = d.toLocaleDateString("en-US", { month: "short" });
const date = `${day}-${month}-${d.getFullYear()}`;
const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", hour12: false });
return { date, time };
}
// Grey for unclassified, otherwise grouped by training-load family rather
// than an exact name match, so "60' Threshold"/"30' Threshold" both read as
// green, etc. (Threshold uses includes(), not startsWith(), since the
// numeral now comes first.) Falls back to grey for any kind name outside
// this taxonomy.
function kindColor(name: string | undefined): string {
if (!name) return "#6b7280"; // grey -- unclassified
if (name.startsWith("Easy") || name.startsWith("Long")) return "#3b82f6"; // blue
if (name.includes("Threshold")) return "#22c55e"; // green
if (name.startsWith("Tempo")) return "#eab308"; // yellow
if (name.startsWith("Interval")) return "#f97316"; // orange
if (name.startsWith("MAS")) return "#ef4444"; // red
if (name.startsWith("Race")) return "#a855f7"; // purple
return "#6b7280";
}
// One combined control per activity: the label shows the current kind (or
// "Unclassified") and opens a picker when clicked, unless locked -- either
// because the user picked it by hand, or because it's Race, a hard fact from
// Garmin's own metadata rather than a retunable rule. The small lock icon is
// the only way back to unlocked, which is what lets a later "Reclassify all"
// touch this activity again -- it's only actionable while locked, since
// there's nothing to lock/unlock about an activity that's still unclassified.
function ClassifyControl({
item,
kinds,
busy,
onAssign,
onUnassign,
onUnlock,
}: {
item: ReviewQueueItem;
kinds: WorkoutKind[];
busy: boolean;
onAssign: (activityId: number, kindId: number) => void;
onUnassign: (activityId: number) => void;
onUnlock: (activityId: number) => void;
}) {
const [open, setOpen] = useState(false);
const kindName = item.WorkoutKindID != null ? kinds.find((k) => k.ID === item.WorkoutKindID)?.Name : undefined;
const isRace = kindName === "Race";
const locked = item.AssignmentSource === "manual" || isRace;
const assignableKinds = kinds.filter((k) => k.Name !== "Race");
const color = kindColor(kindName);
return (
<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() {
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 [profile, setProfile] = useState<Profile | null>(null);
const [filterKindId, setFilterKindId] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const [resolvingId, setResolvingId] = useState<number | null>(null);
const [rawDataItem, setRawDataItem] = useState<ReviewQueueItem | null>(null);
function reload() {
Promise.all([api.reviewQueue(), api.listWorkoutKinds()])
.then(([reviewItems, workoutKinds]) => {
setItems(reviewItems);
setKinds(workoutKinds);
// Fetching every activity's laps and per-second samples is expensive once
// there are many activities, so the list loads incrementally (infinite
// scroll) instead of all at once -- see the paginated GET /api/review-queue/.
const allLoaded = !initialLoading && nextCursor === null;
// A single in-flight request is shared by every concurrent caller (the
// scroll observer, a filter switch, ...): 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) {
setResolvingId(activityId);
try {
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) {
setError(String(e));
} finally {
@@ -53,43 +295,69 @@ export function ReviewQueue() {
}
}
const filteredItems = useMemo(() => {
if (filterKindId === "") return items;
if (filterKindId === UNSORTED) {
return items.filter((item) => candidates(item).length === 0);
async function unassign(activityId: number) {
setResolvingId(activityId);
try {
await api.unassignReview(activityId);
setItems((prev) =>
prev
.map((i) =>
i.ActivityID === activityId
? { ...i, WorkoutKindID: null, AssignmentSource: "manual" as const, Status: "needs_review" as const }
: i,
)
// Same as resolve() -- an active kind filter no longer matches an
// activity just cleared back to Unclassified, so drop it.
.filter((i) => matchesFilter(i, filterKindId)),
);
} catch (e) {
setError(String(e));
} finally {
setResolvingId(null);
}
}
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) {
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 (
<div className="page">
<h2>Review Queue</h2>
{error && <p className="error">{error}</p>}
{items.length > 0 && (
{grandTotal > 0 && (
<div className="filter-pills">
<button
type="button"
className={`filter-pill${filterKindId === "" ? " active" : ""}`}
onClick={() => toggleFilter("")}
>
All ({items.length})
All ({grandTotal})
</button>
<button
type="button"
className={`filter-pill${filterKindId === UNSORTED ? " active" : ""}`}
onClick={() => toggleFilter(UNSORTED)}
className={`filter-pill${filterKindId === UNCLASSIFIED ? " active" : ""}`}
onClick={() => toggleFilter(UNCLASSIFIED)}
>
Unsorted (no candidates)
Unclassified
</button>
{kinds.map((k) => (
<button
@@ -104,26 +372,42 @@ export function ReviewQueue() {
</div>
)}
{items.length === 0 ? (
<p className="empty-state">Nothing needs review right now.</p>
) : filteredItems.length === 0 ? (
<p className="empty-state">No runs match this filter.</p>
{initialLoading ? (
<p className="empty-state">Loading</p>
) : items.length === 0 ? (
<p className="empty-state">
{filterKindId === "" ? "No activities synced yet." : "No runs match this filter."}
</p>
) : (
<ul className="review-list">
{filteredItems.map((item) => {
{items.map((item) => {
const scored = candidates(item);
const pace = formatPace(item.activity.AvgSpeedMps);
const { date, time } = formatActivityDateTime(item.activity.StartTimeUTC);
return (
<li key={item.ActivityID} className="review-item">
<div className="review-item-header">
<div className="review-item-title">
<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 className="review-item-stats">
<span>{(item.activity.DistanceMeters / 1000).toFixed(2)} km</span>
<span>{Math.round(item.activity.DurationSeconds / 60)} min</span>
{pace && <span>{pace}</span>}
{item.activity.AvgHR != null && <span>{Math.round(item.activity.AvgHR)} bpm avg</span>}
<span>📏 {(item.activity.DistanceMeters / 1000).toFixed(2)} km</span>
<span> {Math.round(item.activity.DurationSeconds / 60)} min</span>
{pace && <span> {pace}</span>}
{item.activity.AvgHR != null && <span> {Math.round(item.activity.AvgHR)} bpm avg</span>}
</div>
{scored.length > 0 && (
@@ -132,19 +416,21 @@ export function ReviewQueue() {
</p>
)}
<ExpectedVsActualChart laps={item.laps} samples={item.samples} />
<div className="review-item-actions">
{manuallyAssignableKinds.map((k) => (
<button
key={k.ID}
disabled={resolvingId === item.ActivityID}
onClick={() => resolve(item.ActivityID, k.ID)}
>
{k.Name}
</button>
))}
</div>
<ExpectedVsActualChart
laps={item.laps}
samples={item.samples}
minRepresentativePaceSecPerKm={profile?.MinRepresentativePaceSecPerKm ?? 0}
minRepresentativeTimeSeconds={profile?.MinRepresentativeTimeSeconds ?? 0}
paceColor={profile?.PaceColor ?? "#3b82f6"}
heartRateColor={profile?.HeartRateColor ?? "#ef4444"}
warmupColor={profile?.WarmupColor ?? "#c2410c"}
effortColor={profile?.EffortColor ?? "#7c3aed"}
recoveryColor={profile?.RecoveryColor ?? "#15803d"}
cooldownColor={profile?.CooldownColor ?? "#fb923c"}
mainLineTintPct={profile?.MainLineTintPct ?? 20}
backgroundDarkenPct={profile?.BackgroundDarkenPct ?? 35}
targetBrightenPct={profile?.TargetBrightenPct ?? 20}
/>
<div className="review-item-footer">
<button type="button" className="raw-data-button" onClick={() => setRawDataItem(item)}>
@@ -157,6 +443,12 @@ export function ReviewQueue() {
</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)} />}
</div>
);

View File

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

View File

@@ -5,33 +5,33 @@
export interface Activity {
ID: number;
GarminActivityID: number;
// ActivityName/ActivityType aren't stored columns -- the backend decodes
// them from RawJSON at response time (see internal/api/display_fields.go).
ActivityName: string;
ActivityType: string;
EventTypeKey: string;
WorkoutID: number | null;
StartTimeUTC: string;
BeginTimestampMs: number;
DurationSeconds: number;
DistanceMeters: number;
AvgHR: number | null;
MaxHR: number | null;
AvgSpeedMps: number | null;
MaxSpeedMps: number | null;
ElevationGainM: number | null;
ElevationLossM: number | null;
Calories: number | null;
LapCount: number;
AerobicTrainingEffect: number | null;
AnaerobicTrainingEffect: number | null;
TrainingEffectLabel: string;
VO2MaxValue: number | null;
// Raw JSON strings from Garmin, kept for fields not modeled above. Only
// needed by the Review Queue's raw-data viewer, so left as strings here
// needed by the Activities page's raw-data viewer, so left as strings here
// rather than typed -- the viewer parses them for display.
RawJSON: string;
DetailsFetchedAt: string | null;
DetailsRawJSON: string | null;
SplitsFetchedAt: string | null;
// Genuine raw get_workout_by_id() response for this activity's structured
// workout. Null when the activity has no WorkoutID, or was synced before
// this column existed.
WorkoutRawJSON: string | null;
CreatedAt: string;
UpdatedAt: string;
}
@@ -40,15 +40,11 @@ export interface Lap {
ID: number;
ActivityID: number;
LapIndex: number;
StartTimeUTC: string;
// DurationSeconds/AvgHR aren't stored columns -- the backend decodes them
// from RawJSON at response time (see internal/api/display_fields.go).
DurationSeconds: number;
DistanceMeters: number;
AvgHR: number | null;
MaxHR: number | null;
AvgSpeedMps: number | null;
MaxSpeedMps: number | null;
ElevationGainM: number | null;
ElevationLossM: number | null;
IntensityType: string;
HRDriftBpmPerMin: number | null;
HRRecoveryBpmPerMin: number | null;
@@ -91,10 +87,12 @@ export interface WorkoutKind {
UpdatedAt: string;
pace_min_sec_per_km: number | null;
pace_max_sec_per_km: number | null;
expected_hr_zone: number | null;
hr_min_pct_hrr: number | null;
hr_max_pct_hrr: number | null;
}
export interface Profile {
Name: string;
GarminEmail: string;
GarminPassword: string;
RollingWindowDays: number;
@@ -113,6 +111,27 @@ export interface Profile {
HRZone5MaxPct: number;
WarmupMinutes: 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;
UpdatedAt: string;
}
@@ -140,6 +159,12 @@ export interface ReviewQueueItem extends KindAssignment {
samples: Sample[];
}
export interface ReviewQueuePage {
items: ReviewQueueItem[];
next_cursor: string | null;
total: number;
}
export interface ProgressionPoint {
date: string;
activity_id: number;
@@ -173,4 +198,4 @@ export interface SyncStatus {
last_run?: SyncRun;
}
export type ProgressionMetric = "pace" | "hr" | "vo2max" | "aerobic_te" | "anaerobic_te";
export type ProgressionMetric = "pace" | "hr" | "vo2max" | "aerobic_te" | "anaerobic_te" | "efficiency_factor";