From 647757dcab3f36def769b7262b66180d6776fdf3 Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Sun, 19 Jul 2026 15:05:46 +0200 Subject: [PATCH] Add a raw-data viewer to Review Queue cards, improve chart color palette A "Raw data" button at the bottom right of each card opens a modal with the full activity/laps/assignment JSON (embedded RawJSON strings parsed back into objects for readability), to make it easier to inspect exactly what data is available when iterating on the charts. Phase band colors (warm-up/effort/recovery/cool-down) previously reused the same blue/red as the pace/HR accent colors, making them hard to tell apart. New palette (amber/pink/teal/violet) avoids both accents entirely. --- frontend/src/App.css | 51 +++++++++++++++++++ frontend/src/components/RawDataModal.tsx | 47 +++++++++++++++++ .../charts/ExpectedVsActualChart.tsx | 27 ++++++---- frontend/src/pages/ReviewQueue.tsx | 10 ++++ frontend/src/types/api.ts | 8 +++ 5 files changed, 133 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/RawDataModal.tsx diff --git a/frontend/src/App.css b/frontend/src/App.css index f7be6d8..1e30239 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -262,6 +262,57 @@ button:disabled { display: inline-block; } +.review-item-footer { + display: flex; + justify-content: flex-end; + margin-top: 0.5rem; +} + +.raw-data-button { + font-size: 0.75rem; + padding: 0.25rem 0.6rem; + color: #9aa0ab; +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.modal-content { + background: #14161c; + border: 1px solid #2a2d35; + border-radius: 8px; + width: min(720px, 90vw); + max-height: 80vh; + display: flex; + flex-direction: column; +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + border-bottom: 1px solid #2a2d35; + font-weight: 600; +} + +.modal-json { + margin: 0; + padding: 1rem; + overflow: auto; + font-size: 0.8rem; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; +} + .kinds-table { width: 100%; border-collapse: collapse; diff --git a/frontend/src/components/RawDataModal.tsx b/frontend/src/components/RawDataModal.tsx new file mode 100644 index 0000000..26fd2ea --- /dev/null +++ b/frontend/src/components/RawDataModal.tsx @@ -0,0 +1,47 @@ +import { useEffect } from "react"; + +// Fields whose value is itself a JSON string (Garmin's raw payloads, kept +// verbatim in the DB). Parsed back into objects so the modal shows one +// readable nested tree instead of an escaped string blob. +const RAW_JSON_STRING_FIELDS = new Set(["RawJSON", "DetailsRawJSON"]); + +function parseEmbeddedJSON(value: unknown): unknown { + if (Array.isArray(value)) return value.map(parseEmbeddedJSON); + if (value != null && typeof value === "object") { + const out: Record = {}; + for (const [key, v] of Object.entries(value as Record)) { + if (typeof v === "string" && RAW_JSON_STRING_FIELDS.has(key)) { + try { + out[key] = JSON.parse(v); + continue; + } catch { + // Not valid JSON (e.g. empty string before details are fetched) -- + // keep it as-is. + } + } + out[key] = parseEmbeddedJSON(v); + } + return out; + } + return value; +} + +export function RawDataModal({ data, onClose }: { data: unknown; onClose: () => void }) { + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => e.key === "Escape" && onClose(); + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onClose]); + + return ( +
+
e.stopPropagation()}> +
+ Raw data + +
+
{JSON.stringify(parseEmbeddedJSON(data), null, 2)}
+
+
+ ); +} diff --git a/frontend/src/components/charts/ExpectedVsActualChart.tsx b/frontend/src/components/charts/ExpectedVsActualChart.tsx index 2bfc8e0..c546bb1 100644 --- a/frontend/src/components/charts/ExpectedVsActualChart.tsx +++ b/frontend/src/components/charts/ExpectedVsActualChart.tsx @@ -30,15 +30,22 @@ function paddedDomain(values: Array, pad: number): [n return [Math.min(...nums) - pad, Math.max(...nums) + pad]; } +// Pace and HR each keep one dedicated accent color across every chart, so +// the two metrics stay visually distinct from each other and from the phase +// bands below. +const PACE_COLOR = "#3b82f6"; // blue +const HR_COLOR = "#ef4444"; // red + // Phase a lap represents, by Garmin's own per-lap IntensityType tagging. // Colors distinguish warm-up / effort / recovery / cool-down bands behind -// the pace/HR trace so a run's structure reads at a glance. +// the pace/HR trace so a run's structure reads at a glance -- deliberately +// avoiding blue/red, since those are already spoken for by PACE_COLOR/HR_COLOR. const PHASE_COLORS: Record = { - WARMUP: "#f59e0b", - ACTIVE: "#ef4444", - REST: "#3b82f6", - RECOVERY: "#3b82f6", - COOLDOWN: "#a855f7", + WARMUP: "#f59e0b", // amber + ACTIVE: "#ec4899", // pink + REST: "#14b8a6", // teal + RECOVERY: "#14b8a6", // teal + COOLDOWN: "#8b5cf6", // violet }; const PHASE_LABELS: Record = { WARMUP: "Warm-up", @@ -125,7 +132,7 @@ export function ExpectedVsActualChart({ laps }: { laps: Lap[] }) { {phaseBands.map((b, i) => ( - + ))} `${Math.round(Number(v))}m`} /> formatPaceShort(Number(v))} /> @@ -133,7 +140,7 @@ export function ExpectedVsActualChart({ laps }: { laps: Lap[] }) { {hasPaceTarget && ( )} - + @@ -142,7 +149,7 @@ export function ExpectedVsActualChart({ laps }: { laps: Lap[] }) { {phaseBands.map((b, i) => ( - + ))} `${Math.round(Number(v))}m`} /> String(Math.round(Number(v)))} /> @@ -150,7 +157,7 @@ export function ExpectedVsActualChart({ laps }: { laps: Lap[] }) { {hasHRTarget && ( )} - + diff --git a/frontend/src/pages/ReviewQueue.tsx b/frontend/src/pages/ReviewQueue.tsx index 3c46bc9..1d2ea1c 100644 --- a/frontend/src/pages/ReviewQueue.tsx +++ b/frontend/src/pages/ReviewQueue.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { api } from "../api/client"; import { ExpectedVsActualChart } from "../components/charts/ExpectedVsActualChart"; +import { RawDataModal } from "../components/RawDataModal"; import type { ReviewQueueItem, ScoredKind, WorkoutKind } from "../types/api"; const UNSORTED = "__unsorted__"; @@ -27,6 +28,7 @@ export function ReviewQueue() { const [filterKindId, setFilterKindId] = useState(""); const [error, setError] = useState(null); const [resolvingId, setResolvingId] = useState(null); + const [rawDataItem, setRawDataItem] = useState(null); function reload() { Promise.all([api.reviewQueue(), api.listWorkoutKinds()]) @@ -143,11 +145,19 @@ export function ReviewQueue() { ))} + +
+ +
); })} )} + + {rawDataItem && setRawDataItem(null)} />} ); } diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index a997b8e..37ce122 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -7,6 +7,8 @@ export interface Activity { GarminActivityID: number; ActivityName: string; ActivityType: string; + EventTypeKey: string; + WorkoutID: number | null; StartTimeUTC: string; BeginTimestampMs: number; DurationSeconds: number; @@ -23,7 +25,12 @@ export interface Activity { AnaerobicTrainingEffect: number | null; TrainingEffectLabel: string; VO2MaxValue: number | null; + // Raw JSON strings from Garmin, kept for fields not modeled above. Only + // needed by the Review Queue's raw-data viewer, so left as strings here + // rather than typed -- the viewer parses them for display. + RawJSON: string; DetailsFetchedAt: string | null; + DetailsRawJSON: string | null; SplitsFetchedAt: string | null; CreatedAt: string; UpdatedAt: string; @@ -52,6 +59,7 @@ export interface Lap { TargetPaceHighMps: number | null; TargetHRLowBpm: number | null; TargetHRHighBpm: number | null; + RawJSON: string; } export interface ActivityListItem extends Activity {