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:
2026-07-20 06:33:47 +02:00
parent 9818d35910
commit 518bccf5ab
56 changed files with 2334 additions and 890 deletions

View File

@@ -55,7 +55,7 @@ export const api = {
getActivity: (id: number) =>
request<{ activity: Activity; laps: unknown[]; assignment?: KindAssignment }>(`/api/activities/${id}`),
// Workout kinds -- fixed taxonomy, no create/delete
// Training types -- fixed taxonomy, no create/delete
listWorkoutKinds: (includeInactive = false) =>
request<WorkoutKind[]>(`/api/workout-kinds/${includeInactive ? "?include_inactive=true" : ""}`),
updateWorkoutKind: (
@@ -69,7 +69,8 @@ export const api = {
is_active?: boolean;
pace_min_sec_per_km?: number | null;
pace_max_sec_per_km?: number | null;
expected_hr_zone?: number | null;
hr_min_pct_hrr?: number | null;
hr_max_pct_hrr?: number | null;
},
) => request<WorkoutKind>(`/api/workout-kinds/${id}`, { method: "PUT", body: JSON.stringify(body) }),
@@ -85,11 +86,15 @@ export const api = {
// Review queue -- 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.
reviewQueue: (params?: { limit?: number; before?: string }) => {
// 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 }) => {
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}` : ""}`);
},
@@ -98,6 +103,14 @@ export const api = {
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" }),
// 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" }),
// Progression
progression: (kindId: number, metric: ProgressionMetric = "pace", from?: string, to?: string) => {