2026-07-17 18:33:06 +02:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
2026-07-18 08:39:23 +02:00
|
|
|
"sort"
|
2026-07-17 18:33:06 +02:00
|
|
|
"strconv"
|
|
|
|
|
|
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
|
|
|
|
|
|
"smartrun/backend/internal/store"
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-19 16:31:24 +02:00
|
|
|
// defaultReviewQueuePageSize matches the frontend's initial/incremental
|
|
|
|
|
// page size for its infinite-scroll list.
|
|
|
|
|
const defaultReviewQueuePageSize = 10
|
|
|
|
|
|
|
|
|
|
type reviewQueueItem struct {
|
|
|
|
|
store.KindAssignment
|
|
|
|
|
Activity store.Activity `json:"activity"`
|
|
|
|
|
Laps []store.Lap `json:"laps"`
|
|
|
|
|
Samples []store.Sample `json:"samples"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handleReviewQueue is cursor-paginated: fetching every needs_review
|
|
|
|
|
// 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.
|
2026-07-17 18:33:06 +02:00
|
|
|
func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
2026-07-19 16:31:24 +02:00
|
|
|
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
|
|
|
|
|
|
2026-07-17 18:33:06 +02:00
|
|
|
queue, err := s.DB.ReviewQueue(r.Context())
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 16:31:24 +02:00
|
|
|
type withActivity struct {
|
|
|
|
|
assignment store.KindAssignment
|
|
|
|
|
activity store.Activity
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
2026-07-19 16:31:24 +02:00
|
|
|
all := make([]withActivity, 0, len(queue))
|
2026-07-17 18:33:06 +02:00
|
|
|
for _, a := range queue {
|
|
|
|
|
activity, ok, err := s.DB.GetActivity(r.Context(), a.ActivityID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-07-19 16:31:24 +02:00
|
|
|
all = append(all, withActivity{assignment: a, activity: activity})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Most recent run first, by when the activity actually happened (not
|
|
|
|
|
// when the rule engine flagged it), so the queue reads like a log.
|
|
|
|
|
sort.Slice(all, func(i, j int) bool {
|
|
|
|
|
return all[i].activity.StartTimeUTC > all[j].activity.StartTimeUTC
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
total := len(all)
|
|
|
|
|
|
|
|
|
|
if cursor != "" {
|
|
|
|
|
idx := 0
|
|
|
|
|
for idx < len(all) && all[idx].activity.StartTimeUTC >= cursor {
|
|
|
|
|
idx++
|
|
|
|
|
}
|
|
|
|
|
all = all[idx:]
|
|
|
|
|
}
|
|
|
|
|
hasMore := len(all) > limit
|
|
|
|
|
if len(all) > limit {
|
|
|
|
|
all = all[:limit]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
items := make([]reviewQueueItem, 0, len(all))
|
|
|
|
|
for _, wa := range all {
|
|
|
|
|
laps, err := s.DB.LapsForActivity(r.Context(), wa.assignment.ActivityID)
|
2026-07-19 11:55:13 +02:00
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-19 15:25:48 +02:00
|
|
|
// 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).
|
2026-07-19 16:31:24 +02:00
|
|
|
samples, err := s.DB.SamplesForActivity(r.Context(), wa.assignment.ActivityID)
|
2026-07-19 15:25:48 +02:00
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-19 16:31:24 +02:00
|
|
|
items = append(items, reviewQueueItem{KindAssignment: wa.assignment, Activity: wa.activity, Laps: laps, Samples: samples})
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
2026-07-18 08:39:23 +02:00
|
|
|
|
2026-07-19 16:31:24 +02:00
|
|
|
var nextCursor *string
|
|
|
|
|
if hasMore && len(items) > 0 {
|
|
|
|
|
c := items[len(items)-1].Activity.StartTimeUTC
|
|
|
|
|
nextCursor = &c
|
|
|
|
|
}
|
2026-07-18 08:39:23 +02:00
|
|
|
|
2026-07-19 16:31:24 +02:00
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
|
|
|
"items": items,
|
|
|
|
|
"next_cursor": nextCursor,
|
|
|
|
|
"total": total,
|
|
|
|
|
})
|
2026-07-17 18:33:06 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "invalid activity id")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 11:11:50 +02:00
|
|
|
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), body.WorkoutKindID)
|
|
|
|
|
if err != nil {
|
2026-07-17 18:33:06 +02:00
|
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
|
|
|
return
|
|
|
|
|
} else if !ok {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "workout kind not found")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-19 11:11:50 +02:00
|
|
|
if kind.Name == "Race" {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "Race is assigned automatically from Garmin metadata and cannot be set manually")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-17 18:33:06 +02:00
|
|
|
|
|
|
|
|
kindID := body.WorkoutKindID
|
|
|
|
|
if _, err := s.DB.InsertKindAssignment(r.Context(), 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"})
|
|
|
|
|
}
|