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.
This commit is contained in:
@@ -9,6 +9,21 @@ import (
|
|||||||
"smartrun/backend/internal/store"
|
"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) {
|
func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
filter := store.ActivityFilter{
|
filter := store.ActivityFilter{
|
||||||
@@ -27,7 +42,40 @@ func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
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) {
|
func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -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) {
|
func TestProgression_ReturnsSortedTimeSeries(t *testing.T) {
|
||||||
s, db := newTestServer(t)
|
s, db := newTestServer(t)
|
||||||
ctx := newCtx()
|
ctx := newCtx()
|
||||||
|
|||||||
@@ -162,48 +162,3 @@ func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, resp)
|
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)})
|
|
||||||
}
|
|
||||||
|
|||||||
51
backend/internal/api/reclassify.go
Normal file
51
backend/internal/api/reclassify.go
Normal file
@@ -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)})
|
||||||
|
}
|
||||||
@@ -63,13 +63,18 @@ func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
} else if !ok {
|
} else if !ok {
|
||||||
writeError(w, http.StatusBadRequest, "workout kind not found")
|
writeError(w, http.StatusBadRequest, "workout kind not found")
|
||||||
return
|
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
|
kindID := body.WorkoutKindID
|
||||||
if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{
|
if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{
|
||||||
|
|||||||
@@ -67,9 +67,10 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Get("/", s.handleListWorkoutKinds)
|
r.Get("/", s.handleListWorkoutKinds)
|
||||||
r.Get("/{id}", s.handleGetWorkoutKind)
|
r.Get("/{id}", s.handleGetWorkoutKind)
|
||||||
r.Put("/{id}", s.handleUpdateWorkoutKind)
|
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.Route("/review-queue", func(r chi.Router) {
|
||||||
r.Get("/", s.handleReviewQueue)
|
r.Get("/", s.handleReviewQueue)
|
||||||
r.Post("/{activityID}/resolve", s.handleResolveReview)
|
r.Post("/{activityID}/resolve", s.handleResolveReview)
|
||||||
|
|||||||
@@ -83,6 +83,27 @@ func (db *DB) ReviewQueue(ctx context.Context) ([]KindAssignment, error) {
|
|||||||
return assignments, rows.Err()
|
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
|
// AssignmentsForKind returns every historical assignment where the given
|
||||||
// workout kind was the resolved kind (regardless of source), oldest first --
|
// workout kind was the resolved kind (regardless of source), oldest first --
|
||||||
// the basis for progression-over-time charts.
|
// the basis for progression-over-time charts.
|
||||||
|
|||||||
@@ -65,6 +65,19 @@ func (db *DB) GetWorkoutKind(ctx context.Context, id int64) (WorkoutKind, bool,
|
|||||||
return k, true, nil
|
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
|
// ListWorkoutKinds returns workout kinds. If activeOnly, soft-deleted
|
||||||
// (is_active=0) kinds are excluded.
|
// (is_active=0) kinds are excluded.
|
||||||
func (db *DB) ListWorkoutKinds(ctx context.Context, activeOnly bool) ([]WorkoutKind, error) {
|
func (db *DB) ListWorkoutKinds(ctx context.Context, activeOnly bool) ([]WorkoutKind, error) {
|
||||||
|
|||||||
@@ -216,6 +216,16 @@ button:disabled {
|
|||||||
margin-bottom: 1rem;
|
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 th,
|
||||||
.kinds-table td {
|
.kinds-table td {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import { GarminConnection } from "./components/GarminConnection";
|
import { GarminConnection } from "./components/GarminConnection";
|
||||||
|
import { Activities } from "./pages/Activities";
|
||||||
import { Dashboard } from "./pages/Dashboard";
|
import { Dashboard } from "./pages/Dashboard";
|
||||||
import { Profile } from "./pages/Profile";
|
import { Profile } from "./pages/Profile";
|
||||||
import { ReviewQueue } from "./pages/ReviewQueue";
|
import { ReviewQueue } from "./pages/ReviewQueue";
|
||||||
@@ -9,6 +10,7 @@ import { WorkoutKinds } from "./pages/WorkoutKinds";
|
|||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: "dashboard", label: "Progression", Component: Dashboard },
|
{ key: "dashboard", label: "Progression", Component: Dashboard },
|
||||||
{ key: "review", label: "Review Queue", Component: ReviewQueue },
|
{ key: "review", label: "Review Queue", Component: ReviewQueue },
|
||||||
|
{ key: "activities", label: "Activities", Component: Activities },
|
||||||
{ key: "kinds", label: "Workout Kinds", Component: WorkoutKinds },
|
{ key: "kinds", label: "Workout Kinds", Component: WorkoutKinds },
|
||||||
{ key: "profile", label: "Profile", Component: Profile },
|
{ key: "profile", label: "Profile", Component: Profile },
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
Activity,
|
Activity,
|
||||||
|
ActivityListItem,
|
||||||
AuthResponse,
|
AuthResponse,
|
||||||
KindAssignment,
|
KindAssignment,
|
||||||
Profile,
|
Profile,
|
||||||
@@ -46,7 +47,7 @@ export const api = {
|
|||||||
if (params?.to) q.set("to", params.to);
|
if (params?.to) q.set("to", params.to);
|
||||||
if (params?.limit) q.set("limit", String(params.limit));
|
if (params?.limit) q.set("limit", String(params.limit));
|
||||||
const qs = q.toString();
|
const qs = q.toString();
|
||||||
return request<Activity[]>(`/api/activities/${qs ? `?${qs}` : ""}`);
|
return request<ActivityListItem[]>(`/api/activities/${qs ? `?${qs}` : ""}`);
|
||||||
},
|
},
|
||||||
getActivity: (id: number) =>
|
getActivity: (id: number) =>
|
||||||
request<{ activity: Activity; laps: unknown[]; assignment?: KindAssignment }>(`/api/activities/${id}`),
|
request<{ activity: Activity; laps: unknown[]; assignment?: KindAssignment }>(`/api/activities/${id}`),
|
||||||
@@ -68,8 +69,10 @@ export const api = {
|
|||||||
expected_hr_zone?: number | null;
|
expected_hr_zone?: number | null;
|
||||||
},
|
},
|
||||||
) => request<WorkoutKind>(`/api/workout-kinds/${id}`, { method: "PUT", body: JSON.stringify(body) }),
|
) => request<WorkoutKind>(`/api/workout-kinds/${id}`, { method: "PUT", body: JSON.stringify(body) }),
|
||||||
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
|
// Profile
|
||||||
getProfile: () => request<Profile>("/api/profile"),
|
getProfile: () => request<Profile>("/api/profile"),
|
||||||
|
|||||||
73
frontend/src/pages/Activities.tsx
Normal file
73
frontend/src/pages/Activities.tsx
Normal file
@@ -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<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -63,6 +63,10 @@ export function ReviewQueue() {
|
|||||||
setFilterKindId((prev) => (prev === id ? "" : id));
|
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 (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<h2>Review Queue</h2>
|
<h2>Review Queue</h2>
|
||||||
@@ -126,7 +130,7 @@ export function ReviewQueue() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="review-item-actions">
|
<div className="review-item-actions">
|
||||||
{kinds.map((k) => (
|
{manuallyAssignableKinds.map((k) => (
|
||||||
<button
|
<button
|
||||||
key={k.ID}
|
key={k.ID}
|
||||||
disabled={resolvingId === item.ActivityID}
|
disabled={resolvingId === item.ActivityID}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function WorkoutKinds() {
|
|||||||
const [paceMaxText, setPaceMaxText] = useState("");
|
const [paceMaxText, setPaceMaxText] = useState("");
|
||||||
const [expectedZone, setExpectedZone] = useState<number | null>(null);
|
const [expectedZone, setExpectedZone] = useState<number | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busyId, setBusyId] = useState<number | null>(null);
|
const [reclassifying, setReclassifying] = useState(false);
|
||||||
|
|
||||||
function reload() {
|
function reload() {
|
||||||
api.listWorkoutKinds(true).then(setKinds).catch((e) => setError(String(e)));
|
api.listWorkoutKinds(true).then(setKinds).catch((e) => setError(String(e)));
|
||||||
@@ -93,15 +93,15 @@ export function WorkoutKinds() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reclassify(id: number) {
|
async function reclassifyAll() {
|
||||||
setBusyId(id);
|
setReclassifying(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.reclassifyWorkoutKind(id);
|
const res = await api.reclassifyAll();
|
||||||
setError(`Reclassified ${res.reclassified} activities.`);
|
setError(`Reclassified ${res.reclassified} activities.`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
} finally {
|
} finally {
|
||||||
setBusyId(null);
|
setReclassifying(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +110,16 @@ export function WorkoutKinds() {
|
|||||||
<h2>Workout Kinds</h2>
|
<h2>Workout Kinds</h2>
|
||||||
{error && <p className="error">{error}</p>}
|
{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">
|
<table className="kinds-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -133,9 +143,6 @@ export function WorkoutKinds() {
|
|||||||
<td>{k.expected_hr_zone ?? "—"}</td>
|
<td>{k.expected_hr_zone ?? "—"}</td>
|
||||||
<td className="kinds-table-actions">
|
<td className="kinds-table-actions">
|
||||||
<button onClick={() => startEdit(k)}>Edit</button>
|
<button onClick={() => startEdit(k)}>Edit</button>
|
||||||
<button disabled={busyId === k.ID} onClick={() => reclassify(k.ID)}>
|
|
||||||
Reclassify
|
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ export interface Lap {
|
|||||||
HRRecoveryBpmPerMin: number | null;
|
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 {
|
export interface WorkoutKind {
|
||||||
ID: number;
|
ID: number;
|
||||||
Name: string;
|
Name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user