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>
This commit is contained in:
@@ -17,18 +17,23 @@ const defaultReviewQueuePageSize = 10
|
||||
|
||||
type reviewQueueItem struct {
|
||||
store.KindAssignment
|
||||
Activity store.Activity `json:"activity"`
|
||||
Laps []store.Lap `json:"laps"`
|
||||
Samples []store.Sample `json:"samples"`
|
||||
Activity activityResponse `json:"activity"`
|
||||
Laps []lapResponse `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.
|
||||
// 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) {
|
||||
limit := defaultReviewQueuePageSize
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
@@ -38,7 +43,15 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
cursor := r.URL.Query().Get("before") // activity StartTimeUTC of the last item on the previous page
|
||||
|
||||
queue, err := s.DB.ReviewQueue(r.Context())
|
||||
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())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
@@ -50,6 +63,12 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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(), a.ActivityID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
@@ -96,7 +115,12 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
items = append(items, reviewQueueItem{KindAssignment: wa.assignment, Activity: wa.activity, Laps: laps, Samples: samples})
|
||||
items = append(items, reviewQueueItem{
|
||||
KindAssignment: wa.assignment,
|
||||
Activity: toActivityResponse(wa.activity),
|
||||
Laps: toLapResponses(laps),
|
||||
Samples: samples,
|
||||
})
|
||||
}
|
||||
|
||||
var nextCursor *string
|
||||
@@ -157,3 +181,74 @@ func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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) {
|
||||
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"})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user