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
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
Activity activityResponse `json:"activity"`
|
|
|
|
|
Laps []lapResponse `json:"laps"`
|
|
|
|
|
Samples []store.Sample `json:"samples"`
|
2026-07-19 16:31:24 +02:00
|
|
|
}
|
|
|
|
|
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
// 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.
|
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
|
|
|
|
|
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
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())
|
2026-07-17 18:33:06 +02:00
|
|
|
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 {
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
if kindIDFilter != nil && (a.WorkoutKindID == nil || *a.WorkoutKindID != *kindIDFilter) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if unclassifiedOnly && a.WorkoutKindID != nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-07-17 18:33:06 +02:00
|
|
|
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
|
|
|
|
|
}
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
items = append(items, reviewQueueItem{
|
|
|
|
|
KindAssignment: wa.assignment,
|
|
|
|
|
Activity: toActivityResponse(wa.activity),
|
|
|
|
|
Laps: toLapResponses(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"})
|
|
|
|
|
}
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
|
|
|
|
|
// 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) {
|
|
|
|
|
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(), 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) {
|
|
|
|
|
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(), 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(), 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"})
|
|
|
|
|
}
|