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 {