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.
This commit is contained in:
@@ -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;
|
||||
|
||||
47
frontend/src/components/RawDataModal.tsx
Normal file
47
frontend/src/components/RawDataModal.tsx
Normal file
@@ -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<string, unknown> = {};
|
||||
for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
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 (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<span>Raw data</span>
|
||||
<button onClick={onClose}>Close</button>
|
||||
</div>
|
||||
<pre className="modal-json">{JSON.stringify(parseEmbeddedJSON(data), null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,15 +30,22 @@ function paddedDomain(values: Array<number | null | undefined>, 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<string, string> = {
|
||||
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<string, string> = {
|
||||
WARMUP: "Warm-up",
|
||||
@@ -125,7 +132,7 @@ export function ExpectedVsActualChart({ laps }: { laps: Lap[] }) {
|
||||
<ResponsiveContainer width="100%" height={100}>
|
||||
<AreaChart data={points} margin={{ top: 4, right: 4, bottom: 0, left: 4 }}>
|
||||
{phaseBands.map((b, i) => (
|
||||
<ReferenceArea key={i} x1={b.x1} x2={b.x2} fill={b.color} fillOpacity={0.12} strokeOpacity={0} />
|
||||
<ReferenceArea key={i} x1={b.x1} x2={b.x2} fill={b.color} fillOpacity={0.18} strokeOpacity={0} />
|
||||
))}
|
||||
<XAxis dataKey="t" type="number" domain={[0, elapsedMin]} tick={{ fontSize: 10 }} tickFormatter={(v) => `${Math.round(Number(v))}m`} />
|
||||
<YAxis reversed domain={paceDomain} tick={{ fontSize: 10 }} width={34} tickFormatter={(v) => formatPaceShort(Number(v))} />
|
||||
@@ -133,7 +140,7 @@ export function ExpectedVsActualChart({ laps }: { laps: Lap[] }) {
|
||||
{hasPaceTarget && (
|
||||
<Area type="stepAfter" dataKey="targetPaceRange" stroke="none" fill="#9aa0ab" fillOpacity={0.25} isAnimationActive={false} name="Target range" connectNulls />
|
||||
)}
|
||||
<Area type="monotone" dataKey="actualPace" stroke="#3b82f6" strokeWidth={2} fill="#3b82f6" fillOpacity={0.3} dot={{ r: 2 }} isAnimationActive={false} name="Actual" connectNulls />
|
||||
<Area type="monotone" dataKey="actualPace" stroke={PACE_COLOR} strokeWidth={2} fill={PACE_COLOR} fillOpacity={0.3} dot={{ r: 2 }} isAnimationActive={false} name="Actual" connectNulls />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -142,7 +149,7 @@ export function ExpectedVsActualChart({ laps }: { laps: Lap[] }) {
|
||||
<ResponsiveContainer width="100%" height={100}>
|
||||
<AreaChart data={points} margin={{ top: 4, right: 4, bottom: 0, left: 4 }}>
|
||||
{phaseBands.map((b, i) => (
|
||||
<ReferenceArea key={i} x1={b.x1} x2={b.x2} fill={b.color} fillOpacity={0.12} strokeOpacity={0} />
|
||||
<ReferenceArea key={i} x1={b.x1} x2={b.x2} fill={b.color} fillOpacity={0.18} strokeOpacity={0} />
|
||||
))}
|
||||
<XAxis dataKey="t" type="number" domain={[0, elapsedMin]} tick={{ fontSize: 10 }} tickFormatter={(v) => `${Math.round(Number(v))}m`} />
|
||||
<YAxis domain={hrDomain} tick={{ fontSize: 10 }} width={30} tickFormatter={(v) => String(Math.round(Number(v)))} />
|
||||
@@ -150,7 +157,7 @@ export function ExpectedVsActualChart({ laps }: { laps: Lap[] }) {
|
||||
{hasHRTarget && (
|
||||
<Area type="stepAfter" dataKey="targetHRRange" stroke="none" fill="#9aa0ab" fillOpacity={0.25} isAnimationActive={false} name="Target range" connectNulls />
|
||||
)}
|
||||
<Area type="monotone" dataKey="actualHR" stroke="#ef4444" strokeWidth={2} fill="#ef4444" fillOpacity={0.3} dot={{ r: 2 }} isAnimationActive={false} name="Actual" connectNulls />
|
||||
<Area type="monotone" dataKey="actualHR" stroke={HR_COLOR} strokeWidth={2} fill={HR_COLOR} fillOpacity={0.3} dot={{ r: 2 }} isAnimationActive={false} name="Actual" connectNulls />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
@@ -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<string>("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [resolvingId, setResolvingId] = useState<number | null>(null);
|
||||
const [rawDataItem, setRawDataItem] = useState<ReviewQueueItem | null>(null);
|
||||
|
||||
function reload() {
|
||||
Promise.all([api.reviewQueue(), api.listWorkoutKinds()])
|
||||
@@ -143,11 +145,19 @@ export function ReviewQueue() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="review-item-footer">
|
||||
<button type="button" className="raw-data-button" onClick={() => setRawDataItem(item)}>
|
||||
Raw data
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{rawDataItem && <RawDataModal data={rawDataItem} onClose={() => setRawDataItem(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user