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"),
|
||||
}
|
||||
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
|
||||
limit := defaultActivitiesPageSize
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
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 {
|
||||
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")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
resp = append(resp, item)
|
||||
all = append(all, withActivity{assignment: a, activity: activity})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
|
||||
// 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
|
||||
}
|
||||
// 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, activityItem{
|
||||
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) handleGetActivity(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleAssignActivity(w http.ResponseWriter, r *http.Request) {
|
||||
userID := userIDFromContext(r.Context())
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||
return
|
||||
}
|
||||
|
||||
activity, ok, err := s.DB.GetActivity(r.Context(), userID, id)
|
||||
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 not found")
|
||||
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
|
||||
}
|
||||
|
||||
laps, err := s.DB.LapsForActivity(r.Context(), userID, id)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
assignment, hasAssignment, err := s.DB.CurrentAssignment(r.Context(), userID, id)
|
||||
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
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "unlocked"})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user