From 06108975411296ae5a5b67749632810453de592d Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Sun, 19 Jul 2026 11:11:50 +0200 Subject: [PATCH] Replace per-kind reclassify with a single global reclassify that respects manual and Race locks Manual assignments are the user's definitive word and Race assignments come from a hard Garmin fact (eventType.typeKey), not a retunable rule -- neither is ever touched by reclassify again, and Race can no longer be set by hand via the review queue. Adds an Activities page so this locked/unlocked status is visible per workout, since Review Queue only ever showed unresolved items. --- backend/internal/api/activities.go | 50 ++++++++- backend/internal/api/api_test.go | 137 +++++++++++++++++++++++++ backend/internal/api/kinds.go | 45 -------- backend/internal/api/reclassify.go | 51 +++++++++ backend/internal/api/review.go | 7 +- backend/internal/api/server.go | 3 +- backend/internal/store/assignments.go | 21 ++++ backend/internal/store/workoutkinds.go | 13 +++ frontend/src/App.css | 10 ++ frontend/src/App.tsx | 2 + frontend/src/api/client.ts | 9 +- frontend/src/pages/Activities.tsx | 73 +++++++++++++ frontend/src/pages/ReviewQueue.tsx | 6 +- frontend/src/pages/WorkoutKinds.tsx | 23 +++-- frontend/src/types/api.ts | 8 ++ 15 files changed, 398 insertions(+), 60 deletions(-) create mode 100644 backend/internal/api/reclassify.go create mode 100644 frontend/src/pages/Activities.tsx diff --git a/backend/internal/api/activities.go b/backend/internal/api/activities.go index b566ebd..85ec924 100644 --- a/backend/internal/api/activities.go +++ b/backend/internal/api/activities.go @@ -9,6 +9,21 @@ import ( "smartrun/backend/internal/store" ) +// activityListItem is one row of the activities list, enriched with its +// 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 + WorkoutKindID *int64 `json:"workout_kind_id"` + WorkoutKindName *string `json:"workout_kind_name"` + AssignmentSource *string `json:"assignment_source"` + AssignmentStatus *string `json:"assignment_status"` + // Locked is true when a global reclassify pass will never touch this + // activity again: a manual assignment is the user's definitive word, and + // a Race assignment comes from a hard Garmin fact, not a retunable rule. + Locked bool `json:"locked"` +} + func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() filter := store.ActivityFilter{ @@ -27,7 +42,40 @@ func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err.Error()) return } - writeJSON(w, http.StatusOK, activities) + + kinds, err := s.DB.ListWorkoutKinds(r.Context(), false) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + kindNames := make(map[int64]string, len(kinds)) + for _, k := range kinds { + kindNames[k.ID] = k.Name + } + + resp := make([]activityListItem, 0, len(activities)) + for _, a := range activities { + item := activityListItem{Activity: a} + assignment, ok, err := s.DB.CurrentAssignment(r.Context(), a.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if ok { + source, status := assignment.AssignmentSource, assignment.Status + item.AssignmentSource, item.AssignmentStatus = &source, &status + if assignment.WorkoutKindID != nil { + item.WorkoutKindID = assignment.WorkoutKindID + if name, found := kindNames[*assignment.WorkoutKindID]; found { + item.WorkoutKindName = &name + } + } + item.Locked = source == store.AssignmentSourceManual || + (item.WorkoutKindName != nil && *item.WorkoutKindName == "Race") + } + resp = append(resp, item) + } + writeJSON(w, http.StatusOK, resp) } func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go index 0904fee..c40d3d6 100644 --- a/backend/internal/api/api_test.go +++ b/backend/internal/api/api_test.go @@ -188,6 +188,143 @@ func TestReviewQueueResolve(t *testing.T) { } } +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: "{}"}) + if err != nil { + t.Fatalf("UpsertActivity: %v", err) + } + raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race") + if err != nil || !ok { + t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err) + } + + rec := doJSON(t, s.Router(), http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/resolve", map[string]any{"workout_kind_id": raceKind.ID}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("resolve to Race status = %d, want 400, body = %s", rec.Code, rec.Body.String()) + } +} + +func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) { + s, db := newTestServer(t) + ctx := newCtx() + + easyID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Easy", RuleJSON: `{"match":"all","conditions":[{"metric":"distance_meters","op":">=","value":0}]}`, IsActive: true}) + if err != nil { + t.Fatalf("CreateWorkoutKind: %v", err) + } + raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race") + if err != nil || !ok { + 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: "{}"}) + + if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{ + ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine, + Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]", + }); err != nil { + t.Fatalf("InsertKindAssignment (rule engine): %v", err) + } + if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{ + ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual, + Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]", + }); err != nil { + t.Fatalf("InsertKindAssignment (manual): %v", err) + } + if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{ + ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine, + Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]", + }); err != nil { + t.Fatalf("InsertKindAssignment (race): %v", err) + } + + rec := doJSON(t, s.Router(), http.MethodPost, "/api/reclassify", nil) + if rec.Code != http.StatusOK { + t.Fatalf("reclassify status = %d, body = %s", rec.Code, rec.Body.String()) + } + var body struct { + Reclassified int `json:"reclassified"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if body.Reclassified != 1 { + t.Fatalf("reclassified = %d, want 1 (only the non-locked rule-engine activity)", body.Reclassified) + } + + manualAssignment, ok, err := db.CurrentAssignment(ctx, manualActivity) + if err != nil || !ok { + t.Fatalf("CurrentAssignment(manual): ok=%v err=%v", ok, err) + } + if manualAssignment.AssignmentSource != store.AssignmentSourceManual { + t.Errorf("manual assignment was overwritten: %+v", manualAssignment) + } + + raceAssignment, ok, err := db.CurrentAssignment(ctx, raceActivity) + if err != nil || !ok { + t.Fatalf("CurrentAssignment(race): ok=%v err=%v", ok, err) + } + if raceAssignment.WorkoutKindID == nil || *raceAssignment.WorkoutKindID != raceKind.ID { + t.Errorf("race assignment changed: %+v", raceAssignment) + } +} + +func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) { + s, db := newTestServer(t) + ctx := newCtx() + + easyID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Test Easy Locked", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true}) + if err != nil { + t.Fatalf("CreateWorkoutKind: %v", err) + } + raceKind, ok, err := db.GetWorkoutKindByName(ctx, "Race") + if err != nil || !ok { + 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: "{}"}) + + db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"}) + db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"}) + db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"}) + + rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + var items []activityListItem + if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { + t.Fatalf("unmarshal: %v", err) + } + locked := map[int64]bool{} + kindName := map[int64]string{} + for _, it := range items { + locked[it.ID] = it.Locked + if it.WorkoutKindName != nil { + kindName[it.ID] = *it.WorkoutKindName + } + } + if locked[ruleEngineActivity] { + t.Errorf("rule-engine activity should not be locked") + } + if !locked[manualActivity] { + t.Errorf("manual activity should be locked") + } + if !locked[raceActivity] { + t.Errorf("race activity should be locked") + } + if kindName[raceActivity] != "Race" { + t.Errorf("race activity workout_kind_name = %q, want %q", kindName[raceActivity], "Race") + } +} + func TestProgression_ReturnsSortedTimeSeries(t *testing.T) { s, db := newTestServer(t) ctx := newCtx() diff --git a/backend/internal/api/kinds.go b/backend/internal/api/kinds.go index 5bd47a8..c605ed1 100644 --- a/backend/internal/api/kinds.go +++ b/backend/internal/api/kinds.go @@ -162,48 +162,3 @@ func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request) } writeJSON(w, http.StatusOK, resp) } - -// handleReclassifyKind re-runs the rule engine for every activity currently -// assigned to (or under review for) this kind. It's a synchronous, bounded -// operation (unlike sync), so it runs inline rather than in the background. -func (s *Server) handleReclassifyKind(w http.ResponseWriter, r *http.Request) { - id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid workout kind id") - return - } - - assignments, err := s.DB.AssignmentsForKind(r.Context(), id) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - reviewQueue, err := s.DB.ReviewQueue(r.Context()) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - seen := make(map[int64]bool) - var activityIDs []int64 - for _, a := range assignments { - if !seen[a.ActivityID] { - seen[a.ActivityID] = true - activityIDs = append(activityIDs, a.ActivityID) - } - } - for _, a := range reviewQueue { - if !seen[a.ActivityID] { - seen[a.ActivityID] = true - activityIDs = append(activityIDs, a.ActivityID) - } - } - - for _, activityID := range activityIDs { - if err := s.Sync.ClassifyActivity(r.Context(), activityID); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - } - writeJSON(w, http.StatusOK, map[string]int{"reclassified": len(activityIDs)}) -} diff --git a/backend/internal/api/reclassify.go b/backend/internal/api/reclassify.go new file mode 100644 index 0000000..8491931 --- /dev/null +++ b/backend/internal/api/reclassify.go @@ -0,0 +1,51 @@ +package api + +import ( + "net/http" + + "smartrun/backend/internal/store" +) + +// handleReclassifyAll re-runs the rule engine for every activity except +// those that are locked: +// - a manual assignment is the user's definitive word and is never +// overwritten by the rule engine again; +// - an activity already assigned to Race is locked too, since that +// classification comes from a hard Garmin fact (eventType.typeKey == +// "race"), not a rule the user might retune -- there's nothing to +// re-derive. +// +// It's a synchronous, bounded operation (unlike sync), so it runs inline +// rather than in the background. +func (s *Server) handleReclassifyAll(w http.ResponseWriter, r *http.Request) { + raceKind, hasRaceKind, err := s.DB.GetWorkoutKindByName(r.Context(), "Race") + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + assignments, err := s.DB.AllCurrentAssignments(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + var activityIDs []int64 + for _, a := range assignments { + if a.AssignmentSource == store.AssignmentSourceManual { + continue + } + if hasRaceKind && a.WorkoutKindID != nil && *a.WorkoutKindID == raceKind.ID { + continue + } + activityIDs = append(activityIDs, a.ActivityID) + } + + for _, activityID := range activityIDs { + if err := s.Sync.ClassifyActivity(r.Context(), activityID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + } + writeJSON(w, http.StatusOK, map[string]int{"reclassified": len(activityIDs)}) +} diff --git a/backend/internal/api/review.go b/backend/internal/api/review.go index a054cb2..0ac0291 100644 --- a/backend/internal/api/review.go +++ b/backend/internal/api/review.go @@ -63,13 +63,18 @@ func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) { return } - if _, ok, err := s.DB.GetWorkoutKind(r.Context(), body.WorkoutKindID); err != nil { + kind, ok, err := s.DB.GetWorkoutKind(r.Context(), body.WorkoutKindID) + if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } else if !ok { writeError(w, http.StatusBadRequest, "workout kind not found") return } + if kind.Name == "Race" { + writeError(w, http.StatusBadRequest, "Race is assigned automatically from Garmin metadata and cannot be set manually") + return + } kindID := body.WorkoutKindID if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{ diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index 276b175..38897a7 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -67,9 +67,10 @@ func (s *Server) Router() http.Handler { r.Get("/", s.handleListWorkoutKinds) r.Get("/{id}", s.handleGetWorkoutKind) r.Put("/{id}", s.handleUpdateWorkoutKind) - r.Post("/{id}/reclassify", s.handleReclassifyKind) }) + r.Post("/reclassify", s.handleReclassifyAll) + r.Route("/review-queue", func(r chi.Router) { r.Get("/", s.handleReviewQueue) r.Post("/{activityID}/resolve", s.handleResolveReview) diff --git a/backend/internal/store/assignments.go b/backend/internal/store/assignments.go index 114acb3..3e4631a 100644 --- a/backend/internal/store/assignments.go +++ b/backend/internal/store/assignments.go @@ -83,6 +83,27 @@ func (db *DB) ReviewQueue(ctx context.Context) ([]KindAssignment, error) { return assignments, rows.Err() } +// AllCurrentAssignments returns the latest assignment for every activity +// that has one, regardless of status or source -- the basis for deciding +// which activities a global reclassify pass is allowed to touch. +func (db *DB) AllCurrentAssignments(ctx context.Context) ([]KindAssignment, error) { + rows, err := db.QueryContext(ctx, `SELECT `+kindAssignmentColumns+` FROM current_kind_assignment`) + if err != nil { + return nil, fmt.Errorf("all current assignments: %w", err) + } + defer rows.Close() + + assignments := []KindAssignment{} + for rows.Next() { + a, err := scanKindAssignment(rows) + if err != nil { + return nil, fmt.Errorf("scan kind assignment row: %w", err) + } + assignments = append(assignments, a) + } + return assignments, rows.Err() +} + // AssignmentsForKind returns every historical assignment where the given // workout kind was the resolved kind (regardless of source), oldest first -- // the basis for progression-over-time charts. diff --git a/backend/internal/store/workoutkinds.go b/backend/internal/store/workoutkinds.go index a4a62e6..5d062b7 100644 --- a/backend/internal/store/workoutkinds.go +++ b/backend/internal/store/workoutkinds.go @@ -65,6 +65,19 @@ func (db *DB) GetWorkoutKind(ctx context.Context, id int64) (WorkoutKind, bool, return k, true, nil } +// GetWorkoutKindByName fetches one workout kind by its unique name. +func (db *DB) GetWorkoutKindByName(ctx context.Context, name string) (WorkoutKind, bool, error) { + row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE name = ?`, name) + k, err := scanWorkoutKind(row) + if err == sql.ErrNoRows { + return WorkoutKind{}, false, nil + } + if err != nil { + return WorkoutKind{}, false, fmt.Errorf("get workout kind %q: %w", name, err) + } + return k, true, nil +} + // ListWorkoutKinds returns workout kinds. If activeOnly, soft-deleted // (is_active=0) kinds are excluded. func (db *DB) ListWorkoutKinds(ctx context.Context, activeOnly bool) ([]WorkoutKind, error) { diff --git a/frontend/src/App.css b/frontend/src/App.css index 3fbc555..c556417 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -216,6 +216,16 @@ button:disabled { margin-bottom: 1rem; } +.lock-badge { + display: inline-block; + border-radius: 999px; + padding: 0.15rem 0.6rem; + font-size: 0.75rem; + color: #9aa0ab; + border: 1px solid #2a2d35; + cursor: help; +} + .kinds-table th, .kinds-table td { text-align: left; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5467ca8..3afb95f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import "./App.css"; import { GarminConnection } from "./components/GarminConnection"; +import { Activities } from "./pages/Activities"; import { Dashboard } from "./pages/Dashboard"; import { Profile } from "./pages/Profile"; import { ReviewQueue } from "./pages/ReviewQueue"; @@ -9,6 +10,7 @@ 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 }, ] as const; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 12d5a1f..262c8e5 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,5 +1,6 @@ import type { Activity, + ActivityListItem, AuthResponse, KindAssignment, Profile, @@ -46,7 +47,7 @@ export const api = { if (params?.to) q.set("to", params.to); if (params?.limit) q.set("limit", String(params.limit)); const qs = q.toString(); - return request(`/api/activities/${qs ? `?${qs}` : ""}`); + return request(`/api/activities/${qs ? `?${qs}` : ""}`); }, getActivity: (id: number) => request<{ activity: Activity; laps: unknown[]; assignment?: KindAssignment }>(`/api/activities/${id}`), @@ -68,8 +69,10 @@ export const api = { expected_hr_zone?: number | null; }, ) => request(`/api/workout-kinds/${id}`, { method: "PUT", body: JSON.stringify(body) }), - reclassifyWorkoutKind: (id: number) => - request<{ reclassified: number }>(`/api/workout-kinds/${id}/reclassify`, { method: "POST" }), + + // Reclassifies every activity that isn't locked (manual assignments and + // Race assignments are never touched -- see handleReclassifyAll). + reclassifyAll: () => request<{ reclassified: number }>("/api/reclassify", { method: "POST" }), // Profile getProfile: () => request("/api/profile"), diff --git a/frontend/src/pages/Activities.tsx b/frontend/src/pages/Activities.tsx new file mode 100644 index 0000000..ad44b88 --- /dev/null +++ b/frontend/src/pages/Activities.tsx @@ -0,0 +1,73 @@ +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([]); + const [error, setError] = useState(null); + + useEffect(() => { + api.listActivities().then(setItems).catch((e) => setError(String(e))); + }, []); + + return ( +
+

Activities

+ {error &&

{error}

} + + {items.length === 0 ? ( +

No activities synced yet.

+ ) : ( + + + + + + + + + + + + + + {items.map((item) => { + const pace = formatPace(item.AvgSpeedMps); + return ( + + + + + + + + + + ); + })} + +
DateNameDistancePaceKindSource
{item.StartTimeUTC}{item.ActivityName || item.ActivityType}{(item.DistanceMeters / 1000).toFixed(2)} km{pace ?? "—"}{item.workout_kind_name ?? "—"}{item.assignment_source ?? "—"} + {item.locked && ( + + Locked + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/ReviewQueue.tsx b/frontend/src/pages/ReviewQueue.tsx index 75a292f..7fcfc9a 100644 --- a/frontend/src/pages/ReviewQueue.tsx +++ b/frontend/src/pages/ReviewQueue.tsx @@ -63,6 +63,10 @@ export function ReviewQueue() { setFilterKindId((prev) => (prev === id ? "" : id)); } + // 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 (

Review Queue

@@ -126,7 +130,7 @@ export function ReviewQueue() { )}
- {kinds.map((k) => ( + {manuallyAssignableKinds.map((k) => ( +
+

+ 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). +

+ @@ -133,9 +143,6 @@ export function WorkoutKinds() { ))} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 4d5d9d8..0007408 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -47,6 +47,14 @@ export interface Lap { HRRecoveryBpmPerMin: number | null; } +export interface ActivityListItem extends Activity { + workout_kind_id: number | null; + workout_kind_name: string | null; + assignment_source: "rule_engine" | "manual" | null; + assignment_status: "assigned" | "needs_review" | null; + locked: boolean; +} + export interface WorkoutKind { ID: number; Name: string;
{k.expected_hr_zone ?? "—"} -