refactor(api): merge review-queue into /api/activities, rename resolve to assign
Removes the unused GET /api/activities list/detail endpoints, moves the review-queue list and resolve/unlock/unassign actions under /api/activities, renames resolve to assign end-to-end, and drops the now-unused store.ReviewQueue helper. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -9,109 +11,248 @@ import (
|
||||
"geniusrun/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 {
|
||||
activityResponse
|
||||
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"`
|
||||
// defaultActivitiesPageSize matches the frontend's initial/incremental
|
||||
// page size for its infinite-scroll list.
|
||||
const defaultActivitiesPageSize = 10
|
||||
|
||||
type activityItem struct {
|
||||
store.KindAssignment
|
||||
Activity activityResponse `json:"activity"`
|
||||
Laps []lapResponse `json:"laps"`
|
||||
Samples []store.Sample `json:"samples"`
|
||||
}
|
||||
|
||||
// handleListActivities 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) handleListActivities(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
q := r.URL.Query()
|
||||
filter := store.ActivityFilter{
|
||||
FromDate: q.Get("from"),
|
||||
ToDate: q.Get("to"),
|
||||
limit := defaultActivitiesPageSize
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
if limit, err := strconv.Atoi(q.Get("limit")); err == nil {
|
||||
filter.Limit = limit
|
||||
}
|
||||
if offset, err := strconv.Atoi(q.Get("offset")); err == nil {
|
||||
filter.Offset = offset
|
||||
}
|
||||
cursor := r.URL.Query().Get("before") // activity StartTimeUTC of the last item on the previous page
|
||||
|
||||
activities, err := s.DB.ListActivities(r.Context(), userID, filter)
|
||||
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(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
kinds, err := s.DB.ListWorkoutKinds(r.Context(), userID, false)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
type withActivity struct {
|
||||
assignment store.KindAssignment
|
||||
activity store.Activity
|
||||
}
|
||||
kindNames := make(map[int64]string, len(kinds))
|
||||
for _, k := range kinds {
|
||||
kindNames[k.ID] = k.Name
|
||||
all := make([]withActivity, 0, len(queue))
|
||||
for _, a := range queue {
|
||||
if kindIDFilter != nil && (a.WorkoutKindID == nil || *a.WorkoutKindID != *kindIDFilter) {
|
||||
continue
|
||||
}
|
||||
|
||||
resp := make([]activityListItem, 0, len(activities))
|
||||
for _, a := range activities {
|
||||
item := activityListItem{activityResponse: toActivityResponse(a)}
|
||||
assignment, ok, err := s.DB.CurrentAssignment(r.Context(), userID, a.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
if unclassifiedOnly && a.WorkoutKindID != nil {
|
||||
continue
|
||||
}
|
||||
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) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||
return
|
||||
}
|
||||
|
||||
activity, ok, err := s.DB.GetActivity(r.Context(), userID, id)
|
||||
activity, ok, err := s.DB.GetActivity(r.Context(), userID, a.ActivityID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "activity not found")
|
||||
return
|
||||
continue
|
||||
}
|
||||
all = append(all, withActivity{assignment: a, activity: activity})
|
||||
}
|
||||
|
||||
laps, err := s.DB.LapsForActivity(r.Context(), userID, id)
|
||||
// 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([]activityItem, 0, len(all))
|
||||
for _, wa := range all {
|
||||
laps, err := s.DB.LapsForActivity(r.Context(), userID, wa.assignment.ActivityID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
assignment, hasAssignment, err := s.DB.CurrentAssignment(r.Context(), userID, id)
|
||||
// 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(), userID, wa.assignment.ActivityID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]any{"activity": toActivityResponse(activity), "laps": toLapResponses(laps)}
|
||||
if hasAssignment {
|
||||
resp["assignment"] = assignment
|
||||
items = append(items, activityItem{
|
||||
KindAssignment: wa.assignment,
|
||||
Activity: toActivityResponse(wa.activity),
|
||||
Laps: toLapResponses(laps),
|
||||
Samples: samples,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
|
||||
var nextCursor *string
|
||||
if hasMore && len(items) > 0 {
|
||||
c := items[len(items)-1].Activity.StartTimeUTC
|
||||
nextCursor = &c
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items,
|
||||
"next_cursor": nextCursor,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAssignActivity(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
WorkoutKindID int64 `json:"workout_kind_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.WorkoutKindID == 0 {
|
||||
writeError(w, http.StatusBadRequest, "workout_kind_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, 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(), userID, store.KindAssignment{
|
||||
ActivityID: activityID,
|
||||
WorkoutKindID: &kindID,
|
||||
AssignmentSource: store.AssignmentSourceManual,
|
||||
Status: store.AssignmentStatusAssigned,
|
||||
CandidateKindsJSON: "[]",
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "assigned"})
|
||||
}
|
||||
|
||||
// handleUnassignActivity manually clears an activity's kind back to
|
||||
// Unclassified. Like handleAssignActivity, 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
|
||||
// handleAssignActivity's own lack of a lock precondition check.
|
||||
func (s *Server) handleUnassignActivity(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
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(), userID, 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"})
|
||||
}
|
||||
|
||||
// handleUnlockActivity 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
|
||||
// handleAssignActivity, not a delete: the kind stays visible as-is until
|
||||
// something actually reclassifies it.
|
||||
func (s *Server) handleUnlockActivity(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
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(), userID, 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(), userID, 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"})
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ func TestWorkoutKindUpdate_RejectsInvertedPaceRange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewQueueResolve(t *testing.T) {
|
||||
func TestActivitiesAssign(t *testing.T) {
|
||||
s, db, userID := newTestServer(t)
|
||||
ctx := newCtx()
|
||||
|
||||
@@ -229,9 +229,9 @@ func TestReviewQueueResolve(t *testing.T) {
|
||||
}
|
||||
|
||||
router := s.Router()
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/activities/", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("review queue status = %d", rec.Code)
|
||||
t.Fatalf("activities list status = %d", rec.Code)
|
||||
}
|
||||
var page struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
@@ -240,43 +240,43 @@ func TestReviewQueueResolve(t *testing.T) {
|
||||
}
|
||||
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())
|
||||
t.Fatalf("expected 1 item in activities list (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())
|
||||
t.Fatalf("expected 1 lap in activities list item, got %d: %s", len(laps), rec.Body.String())
|
||||
}
|
||||
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())
|
||||
t.Fatalf("expected 1 sample in activities list item, got %d: %s", len(samples), rec.Body.String())
|
||||
}
|
||||
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/resolve", map[string]any{"workout_kind_id": kindID})
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", map[string]any{"workout_kind_id": kindID})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("resolve status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
t.Fatalf("assign status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// The activity stays listed after resolving -- the Activities page shows
|
||||
// every activity, locked or not, not just ones still needing review.
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil)
|
||||
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)
|
||||
t.Fatalf("expected activity to remain listed after assign, 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"])
|
||||
t.Fatalf("expected AssignmentSource=manual after assign, 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)
|
||||
t.Fatalf("expected WorkoutKindID=%d after assign, 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)
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+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)
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/activities/", 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"])
|
||||
@@ -286,18 +286,18 @@ func TestReviewQueueResolve(t *testing.T) {
|
||||
}
|
||||
|
||||
// Unlocking an already-unlocked activity is rejected.
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unlock", nil)
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+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)
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+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)
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/activities/", 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"])
|
||||
@@ -307,7 +307,7 @@ func TestReviewQueueResolve(t *testing.T) {
|
||||
}
|
||||
|
||||
// It also shows up under the Unclassified filter now.
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/?unclassified=true", nil)
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/activities/?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)
|
||||
@@ -315,18 +315,18 @@ func TestReviewQueueResolve(t *testing.T) {
|
||||
|
||||
// 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)
|
||||
rec = doJSON(t, router, http.MethodPost, "/api/activities/"+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)
|
||||
rec = doJSON(t, router, http.MethodGet, "/api/activities/", 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) {
|
||||
func TestActivities_PaginatesByCursor(t *testing.T) {
|
||||
s, db, userID := newTestServer(t)
|
||||
ctx := newCtx()
|
||||
|
||||
@@ -356,7 +356,7 @@ func TestReviewQueue_PaginatesByCursor(t *testing.T) {
|
||||
}
|
||||
getPage := func(query string) page {
|
||||
t.Helper()
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/"+query, nil)
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/activities/"+query, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -404,7 +404,7 @@ func TestReviewQueue_PaginatesByCursor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
||||
func TestActivities_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
||||
s, db, userID := newTestServer(t)
|
||||
ctx := newCtx()
|
||||
|
||||
@@ -448,7 +448,7 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
||||
}
|
||||
getPage := func(query string) page {
|
||||
t.Helper()
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/"+query, nil)
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/activities/"+query, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -491,7 +491,7 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReview_RejectsRaceKind(t *testing.T) {
|
||||
func TestAssignActivity_RejectsRaceKind(t *testing.T) {
|
||||
s, db, userID := newTestServer(t)
|
||||
ctx := newCtx()
|
||||
|
||||
@@ -504,9 +504,9 @@ func TestResolveReview_RejectsRaceKind(t *testing.T) {
|
||||
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})
|
||||
rec := doJSON(t, s.Router(), http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", 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())
|
||||
t.Fatalf("assign to Race status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,57 +577,6 @@ func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) {
|
||||
s, db, userID := newTestServer(t)
|
||||
ctx := newCtx()
|
||||
|
||||
easyID, err := db.CreateWorkoutKind(ctx, userID, 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, userID, "Race")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
|
||||
}
|
||||
|
||||
ruleEngineActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
|
||||
manualActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
|
||||
raceActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
|
||||
|
||||
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
||||
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
||||
db.InsertKindAssignment(ctx, userID, 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 TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
|
||||
s, db, userID := newTestServer(t)
|
||||
ctx := newCtx()
|
||||
|
||||
@@ -43,7 +43,7 @@ func doJSONAs(t *testing.T, handler http.Handler, sub, method, path string, body
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestIsolation_ActivityDetailNotVisibleToOtherUser(t *testing.T) {
|
||||
func TestIsolation_AssignCannotTargetOtherUsersActivity(t *testing.T) {
|
||||
s, db, _ := newTestServer(t) // provisions "test-user" (userA)
|
||||
userB, err := db.ProvisionUser(newCtx(), "user-b", "B")
|
||||
if err != nil {
|
||||
@@ -58,21 +58,26 @@ func TestIsolation_ActivityDetailNotVisibleToOtherUser(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertActivity: %v", err)
|
||||
}
|
||||
_ = userB
|
||||
|
||||
router := s.Router()
|
||||
|
||||
// userA (the default doJSON identity) can see it.
|
||||
rec := doJSON(t, router, http.MethodGet, "/api/activities/"+itoa(activityID), nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("userA GetActivity status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
kindsB, err := db.ListWorkoutKinds(newCtx(), userB, false)
|
||||
if err != nil || len(kindsB) == 0 {
|
||||
t.Fatalf("ListWorkoutKinds(b): len=%d err=%v", len(kindsB), err)
|
||||
}
|
||||
kindB := kindsB[0]
|
||||
if kindB.Name == "Race" { // Race is rejected before the ownership check
|
||||
kindB = kindsB[1]
|
||||
}
|
||||
|
||||
// userB, given the exact same activity id, gets 404 -- not another
|
||||
// user's data, and not a 500 that would leak existence either way.
|
||||
rec = doJSONAs(t, router, "user-b", http.MethodGet, "/api/activities/"+itoa(activityID), nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("userB GetActivity(userA's id) status = %d, want 404, body = %s", rec.Code, rec.Body.String())
|
||||
// userB, given userA's real activity id (and a kind userB legitimately
|
||||
// owns), must not be able to write an assignment onto it.
|
||||
rec := doJSONAs(t, s.Router(), "user-b", http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", map[string]any{"workout_kind_id": kindB.ID})
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("userB assigning userA's activity succeeded (status %d), body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, ok, err := db.CurrentAssignment(newCtx(), userA.ID, activityID); err != nil {
|
||||
t.Fatalf("CurrentAssignment: %v", err)
|
||||
} else if ok {
|
||||
t.Fatalf("userB's rejected assign still created an assignment on userA's activity")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +111,7 @@ func TestIsolation_WorkoutKindUpdateCannotTargetOtherUsersKind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsolation_ReviewQueueOnlyShowsOwnActivities(t *testing.T) {
|
||||
func TestIsolation_ActivitiesListOnlyShowsOwnActivities(t *testing.T) {
|
||||
s, db, _ := newTestServer(t) // provisions "test-user" (userA)
|
||||
userB, err := db.ProvisionUser(newCtx(), "user-b", "B")
|
||||
if err != nil {
|
||||
@@ -125,7 +130,7 @@ func TestIsolation_ReviewQueueOnlyShowsOwnActivities(t *testing.T) {
|
||||
t.Fatalf("InsertKindAssignment(b): %v", err)
|
||||
}
|
||||
|
||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/review-queue/", nil) // as userA
|
||||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil) // as userA
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
@@ -135,7 +140,7 @@ func TestIsolation_ReviewQueueOnlyShowsOwnActivities(t *testing.T) {
|
||||
}
|
||||
json.Unmarshal(rec.Body.Bytes(), &page)
|
||||
if page.Total != 0 || len(page.Items) != 0 {
|
||||
t.Fatalf("expected userA's review queue to be empty (the only assignment belongs to userB), got total=%d items=%d", page.Total, len(page.Items))
|
||||
t.Fatalf("expected userA's activities list to be empty (the only assignment belongs to userB), got total=%d items=%d", page.Total, len(page.Items))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"geniusrun/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) {
|
||||
userID := userIDFromContext(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(), userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
type withActivity struct {
|
||||
assignment store.KindAssignment
|
||||
activity store.Activity
|
||||
}
|
||||
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(), userID, a.ActivityID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
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(), userID, wa.assignment.ActivityID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
// 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(), userID, wa.assignment.ActivityID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
items = append(items, reviewQueueItem{
|
||||
KindAssignment: wa.assignment,
|
||||
Activity: toActivityResponse(wa.activity),
|
||||
Laps: toLapResponses(laps),
|
||||
Samples: samples,
|
||||
})
|
||||
}
|
||||
|
||||
var nextCursor *string
|
||||
if hasMore && len(items) > 0 {
|
||||
c := items[len(items)-1].Activity.StartTimeUTC
|
||||
nextCursor = &c
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items,
|
||||
"next_cursor": nextCursor,
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
WorkoutKindID int64 `json:"workout_kind_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.WorkoutKindID == 0 {
|
||||
writeError(w, http.StatusBadRequest, "workout_kind_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, 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(), userID, store.KindAssignment{
|
||||
ActivityID: activityID,
|
||||
WorkoutKindID: &kindID,
|
||||
AssignmentSource: store.AssignmentSourceManual,
|
||||
Status: store.AssignmentStatusAssigned,
|
||||
CandidateKindsJSON: "[]",
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
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) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
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(), userID, 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) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
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(), userID, 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(), userID, 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"})
|
||||
}
|
||||
@@ -350,7 +350,9 @@ func (s *Server) Router() http.Handler {
|
||||
|
||||
r.Route("/activities", func(r chi.Router) {
|
||||
r.Get("/", s.handleListActivities)
|
||||
r.Get("/{id}", s.handleGetActivity)
|
||||
r.Post("/{activityID}/assign", s.handleAssignActivity)
|
||||
r.Post("/{activityID}/unlock", s.handleUnlockActivity)
|
||||
r.Post("/{activityID}/unassign", s.handleUnassignActivity)
|
||||
})
|
||||
|
||||
r.Route("/workout-kinds", func(r chi.Router) {
|
||||
@@ -361,13 +363,6 @@ func (s *Server) Router() http.Handler {
|
||||
|
||||
r.Post("/reclassify", s.handleReclassifyAll)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,31 +76,6 @@ func (db *DB) CurrentAssignment(ctx context.Context, userID, activityID int64) (
|
||||
return a, true, nil
|
||||
}
|
||||
|
||||
// ReviewQueue returns userID's activities whose current assignment status
|
||||
// is needs_review, newest first.
|
||||
func (db *DB) ReviewQueue(ctx context.Context, userID int64) ([]KindAssignment, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT `+kindAssignmentColumns+`
|
||||
FROM current_kind_assignment a
|
||||
JOIN activities ON activities.id = a.activity_id
|
||||
WHERE activities.user_id = ? AND a.status = ?
|
||||
ORDER BY a.created_at DESC`, userID, AssignmentStatusNeedsReview)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("review queue for user %d: %w", userID, 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()
|
||||
}
|
||||
|
||||
// AllCurrentAssignments returns the latest assignment for every one of
|
||||
// userID's activities that has one, regardless of status or source -- the
|
||||
// basis for deciding which activities a global reclassify pass may touch.
|
||||
|
||||
@@ -149,12 +149,12 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
||||
t.Fatalf("InsertKindAssignment (rule engine): %v", err)
|
||||
}
|
||||
|
||||
queue, err := db.ReviewQueue(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewQueue: %v", err)
|
||||
pending, ok, err := db.CurrentAssignment(ctx, userID, activityID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("CurrentAssignment (needs_review): ok=%v err=%v", ok, err)
|
||||
}
|
||||
if len(queue) != 1 {
|
||||
t.Fatalf("expected 1 item in review queue, got %d", len(queue))
|
||||
if pending.Status != AssignmentStatusNeedsReview {
|
||||
t.Fatalf("current status = %q, want needs_review", pending.Status)
|
||||
}
|
||||
|
||||
// Then: user manually resolves it.
|
||||
@@ -167,14 +167,6 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
||||
t.Fatalf("InsertKindAssignment (manual): %v", err)
|
||||
}
|
||||
|
||||
queue, err = db.ReviewQueue(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewQueue after resolve: %v", err)
|
||||
}
|
||||
if len(queue) != 0 {
|
||||
t.Fatalf("expected 0 items in review queue after manual resolve, got %d", len(queue))
|
||||
}
|
||||
|
||||
current, ok, err := db.CurrentAssignment(ctx, userID, activityID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import type {
|
||||
Activity,
|
||||
ActivityListItem,
|
||||
ActivitiesPage,
|
||||
AuthResponse,
|
||||
KindAssignment,
|
||||
Profile,
|
||||
ProgressionMetric,
|
||||
ProgressionPoint,
|
||||
ReviewQueuePage,
|
||||
SessionInfo,
|
||||
SyncRun,
|
||||
SyncStatus,
|
||||
@@ -37,7 +34,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
// is not something to show a user; every caller's catch-and-showError
|
||||
// already displays whatever this throws, so the friendly text only
|
||||
// needs to be written once, here.
|
||||
throw new NetworkError("Can't reach the server — check your connection.");
|
||||
throw new NetworkError("Can't reach the server.");
|
||||
}
|
||||
if (res.status === 401 && path !== "/api/session/me") {
|
||||
// An established session expired mid-use (not the initial "am I logged
|
||||
@@ -97,18 +94,6 @@ export const api = {
|
||||
syncRuns: () => request<SyncRun[]>("/api/sync/runs"),
|
||||
syncStatus: () => request<SyncStatus>("/api/sync/status"),
|
||||
|
||||
// Activities
|
||||
listActivities: (params?: { from?: string; to?: string; limit?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.from) q.set("from", params.from);
|
||||
if (params?.to) q.set("to", params.to);
|
||||
if (params?.limit) q.set("limit", String(params.limit));
|
||||
const qs = q.toString();
|
||||
return request<ActivityListItem[]>(`/api/activities/${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
getActivity: (id: number) =>
|
||||
request<{ activity: Activity; laps: unknown[]; assignment?: KindAssignment }>(`/api/activities/${id}`),
|
||||
|
||||
// Training types -- fixed taxonomy, no create/delete
|
||||
listWorkoutKinds: (includeInactive = false) =>
|
||||
request<WorkoutKind[]>(`/api/workout-kinds/${includeInactive ? "?include_inactive=true" : ""}`),
|
||||
@@ -141,34 +126,34 @@ export const api = {
|
||||
// navigation -- this does not touch the session cookie (see Profile.tsx).
|
||||
deleteProfile: () => request<void>("/api/profile", { method: "DELETE" }),
|
||||
|
||||
// Review queue -- cursor-paginated: pass the previous page's next_cursor
|
||||
// Activities -- 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 }) => {
|
||||
listActivities: (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}` : ""}`);
|
||||
return request<ActivitiesPage>(`/api/activities/${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
resolveReview: (activityId: number, workoutKindId: number) =>
|
||||
request<{ status: string }>(`/api/review-queue/${activityId}/resolve`, {
|
||||
assignActivity: (activityId: number, workoutKindId: number) =>
|
||||
request<{ status: string }>(`/api/activities/${activityId}/assign`, {
|
||||
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" }),
|
||||
unlockActivity: (activityId: number) =>
|
||||
request<{ status: string }>(`/api/activities/${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" }),
|
||||
// decision, same as assignActivity locks a specific kind).
|
||||
unassignActivity: (activityId: number) =>
|
||||
request<{ status: string }>(`/api/activities/${activityId}/unassign`, { method: "POST" }),
|
||||
|
||||
// Progression
|
||||
progression: (kindId: number, metric: ProgressionMetric = "pace", from?: string, to?: string) => {
|
||||
|
||||
@@ -4,12 +4,12 @@ import { showError } from "../banner";
|
||||
import { ExpectedVsActualChart } from "../components/charts/ExpectedVsActualChart";
|
||||
import { formatMinutesSeconds } from "../components/PaceField";
|
||||
import { RawDataModal } from "../components/RawDataModal";
|
||||
import type { Profile, ReviewQueueItem, ScoredKind, WorkoutKind } from "../types/api";
|
||||
import type { Profile, ActivityItem, ScoredKind, WorkoutKind } from "../types/api";
|
||||
|
||||
const UNCLASSIFIED = "__unclassified__";
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
function candidates(item: ReviewQueueItem): ScoredKind[] {
|
||||
function candidates(item: ActivityItem): ScoredKind[] {
|
||||
try {
|
||||
return JSON.parse(item.CandidateKindsJSON) as ScoredKind[];
|
||||
} catch {
|
||||
@@ -47,8 +47,8 @@ function formatActivityDateTime(startTimeUTC: string): { date: string; time: str
|
||||
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("Tempo")) return "#22c55e"; // green
|
||||
if (name.includes("Threshold")) return "#eab308"; // yellow
|
||||
if (name.startsWith("Interval")) return "#f97316"; // orange
|
||||
if (name.startsWith("MAS")) return "#ef4444"; // red
|
||||
if (name.startsWith("Race")) return "#a855f7"; // purple
|
||||
@@ -70,7 +70,7 @@ function ClassifyControl({
|
||||
onUnassign,
|
||||
onUnlock,
|
||||
}: {
|
||||
item: ReviewQueueItem;
|
||||
item: ActivityItem;
|
||||
kinds: WorkoutKind[];
|
||||
busy: boolean;
|
||||
onAssign: (activityId: number, kindId: number) => void;
|
||||
@@ -150,14 +150,14 @@ function filterParams(filter: string): { kindId?: number; unclassified?: boolean
|
||||
return {};
|
||||
}
|
||||
|
||||
function matchesFilter(item: ReviewQueueItem, filter: string): boolean {
|
||||
function matchesFilter(item: ActivityItem, filter: string): boolean {
|
||||
if (filter === "") return true;
|
||||
if (filter === UNCLASSIFIED) return item.WorkoutKindID == null;
|
||||
return item.WorkoutKindID === Number(filter);
|
||||
}
|
||||
|
||||
export function Activities() {
|
||||
const [items, setItems] = useState<ReviewQueueItem[]>([]);
|
||||
const [items, setItems] = useState<ActivityItem[]>([]);
|
||||
const [grandTotal, setGrandTotal] = useState(0); // unfiltered count, for the "All" pill
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
@@ -166,11 +166,11 @@ export function Activities() {
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [filterKindId, setFilterKindId] = useState<string>("");
|
||||
const [resolvingId, setResolvingId] = useState<number | null>(null);
|
||||
const [rawDataItem, setRawDataItem] = useState<ReviewQueueItem | null>(null);
|
||||
const [rawDataItem, setRawDataItem] = useState<ActivityItem | null>(null);
|
||||
|
||||
// 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/.
|
||||
// scroll) instead of all at once -- see the paginated GET /api/activities/.
|
||||
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
|
||||
@@ -201,7 +201,7 @@ export function Activities() {
|
||||
setLoadingMore(true);
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const page = await api.reviewQueue({
|
||||
const page = await api.listActivities({
|
||||
limit: PAGE_SIZE,
|
||||
before: nextCursorRef.current ?? undefined,
|
||||
...filterParams(filterRef.current),
|
||||
@@ -231,7 +231,7 @@ export function Activities() {
|
||||
setItems([]);
|
||||
setInitialLoading(true);
|
||||
api
|
||||
.reviewQueue({ limit: PAGE_SIZE, ...filterParams(filter) })
|
||||
.listActivities({ limit: PAGE_SIZE, ...filterParams(filter) })
|
||||
.then((page) => {
|
||||
if (gen !== generationRef.current) return;
|
||||
nextCursorRef.current = page.next_cursor;
|
||||
@@ -271,10 +271,10 @@ export function Activities() {
|
||||
return () => observer.disconnect();
|
||||
}, [loadMore, items.length]);
|
||||
|
||||
async function resolve(activityId: number, kindId: number) {
|
||||
async function assign(activityId: number, kindId: number) {
|
||||
setResolvingId(activityId);
|
||||
try {
|
||||
await api.resolveReview(activityId, kindId);
|
||||
await api.assignActivity(activityId, kindId);
|
||||
setItems((prev) =>
|
||||
prev
|
||||
.map((i) =>
|
||||
@@ -298,7 +298,7 @@ export function Activities() {
|
||||
async function unassign(activityId: number) {
|
||||
setResolvingId(activityId);
|
||||
try {
|
||||
await api.unassignReview(activityId);
|
||||
await api.unassignActivity(activityId);
|
||||
setItems((prev) =>
|
||||
prev
|
||||
.map((i) =>
|
||||
@@ -306,7 +306,7 @@ export function Activities() {
|
||||
? { ...i, WorkoutKindID: null, AssignmentSource: "manual" as const, Status: "needs_review" as const }
|
||||
: i,
|
||||
)
|
||||
// Same as resolve() -- an active kind filter no longer matches an
|
||||
// Same as assign() -- an active kind filter no longer matches an
|
||||
// activity just cleared back to Unclassified, so drop it.
|
||||
.filter((i) => matchesFilter(i, filterKindId)),
|
||||
);
|
||||
@@ -320,7 +320,7 @@ export function Activities() {
|
||||
async function unlock(activityId: number) {
|
||||
setResolvingId(activityId);
|
||||
try {
|
||||
await api.unlockReview(activityId);
|
||||
await api.unlockActivity(activityId);
|
||||
setItems((prev) =>
|
||||
prev
|
||||
.map((i) => (i.ActivityID === activityId ? { ...i, AssignmentSource: "rule_engine" as const } : i))
|
||||
@@ -391,7 +391,7 @@ export function Activities() {
|
||||
item={item}
|
||||
kinds={kinds}
|
||||
busy={resolvingId === item.ActivityID}
|
||||
onAssign={resolve}
|
||||
onAssign={assign}
|
||||
onUnassign={unassign}
|
||||
onUnlock={unlock}
|
||||
/>
|
||||
|
||||
@@ -67,14 +67,6 @@ export interface Sample {
|
||||
ElevationM: 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;
|
||||
@@ -153,14 +145,14 @@ export interface KindAssignment {
|
||||
CreatedAt: string;
|
||||
}
|
||||
|
||||
export interface ReviewQueueItem extends KindAssignment {
|
||||
export interface ActivityItem extends KindAssignment {
|
||||
activity: Activity;
|
||||
laps: Lap[];
|
||||
samples: Sample[];
|
||||
}
|
||||
|
||||
export interface ReviewQueuePage {
|
||||
items: ReviewQueueItem[];
|
||||
export interface ActivitiesPage {
|
||||
items: ActivityItem[];
|
||||
next_cursor: string | null;
|
||||
total: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user