From 80e0c9dc3de52bb4dfc1d1f348949b7946aef873 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 23:58:51 +0300 Subject: [PATCH] ui: distinguish between missing fields and zero values in comparison view Signed-off-by: NotAShelf Change-Id: I3fca108a637507c34f7579d6d52236136a6a6964 --- src/components/ComparisonView.tsx | 110 +++++++++++++++++++++--------- src/components/FileUpload.tsx | 4 +- src/index.tsx | 24 +++++-- src/styles.css | 28 ++++++++ src/utils/types.ts | 1 + 5 files changed, 129 insertions(+), 38 deletions(-) diff --git a/src/components/ComparisonView.tsx b/src/components/ComparisonView.tsx index b3cf4a9..38dff9a 100644 --- a/src/components/ComparisonView.tsx +++ b/src/components/ComparisonView.tsx @@ -47,24 +47,42 @@ const ComparisonView: Component = props => { })); return fields.map(field => { - const getValue = (data: ComparisonEntry['data']) => { + const getValue = ( + data: ComparisonEntry['data'], + raw: Record, + ): { value: number; present: boolean } => { const keys = field.key.split('.'); let value: unknown = data; for (const k of keys) value = value && (value as Record)?.[k]; - return typeof value === 'number' ? value : 0; + + let present = typeof value === 'number'; + if (!present) { + present = + keys.reduce((obj: unknown, k: string) => { + if (obj && typeof obj === 'object' && k in (obj as Record)) { + const nested = (obj as Record)[k]; + return typeof nested === 'object' && nested !== null ? nested : true; + } + return undefined; + }, raw as unknown) !== undefined; + } + + return { value: typeof value === 'number' ? value : 0, present }; }; - const leftVal = getValue(left.data); - const rightVal = getValue(right.data); - const change = calculateChange(rightVal, leftVal); + const leftVal = getValue(left.data, left.raw); + const rightVal = getValue(right.data, right.raw); + const change = calculateChange(rightVal.value, leftVal.value); + const isMissing = !leftVal.present || !rightVal.present; return { ...field, - leftValue: leftVal, - rightValue: rightVal, + leftValue: leftVal.value, + rightValue: rightVal.value, change: change.percent, isReduction: change.isReduction, - isDifferent: leftVal !== rightVal, + isDifferent: leftVal.value !== rightVal.value, + isMissing, }; }); }); @@ -136,40 +154,68 @@ const ComparisonView: Component = props => { {row => ( -
+
{row.label}
- {row.format === 'bytes' - ? formatBytes(row.leftValue) - : row.format === 'time' - ? formatTime(row.leftValue) - : row.format === 'percent' - ? formatPercent(row.leftValue) - : formatNumber(row.leftValue)} + + N/A +
- {row.format === 'bytes' - ? formatBytes(row.rightValue) - : row.format === 'time' - ? formatTime(row.rightValue) - : row.format === 'percent' - ? formatPercent(row.rightValue) - : formatNumber(row.rightValue)} + + N/A +
- —}> - - - + }> + + + + + + + + {Math.abs(row.change).toFixed(1)}% + - - - - {Math.abs(row.change).toFixed(1)}% + } + > + + N/A
diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx index a299896..a6fdfcc 100644 --- a/src/components/FileUpload.tsx +++ b/src/components/FileUpload.tsx @@ -3,7 +3,7 @@ import { StatsData } from '../utils/types'; import { BarChart2 } from 'lucide-solid'; interface FileUploadProps { - onFileLoad: (data: StatsData) => void; + onFileLoad: (data: StatsData, raw: Record) => void; onTextLoad: (text: string) => void; showHelp: boolean; onToggleHelp: () => void; @@ -22,7 +22,7 @@ export default function FileUpload(props: FileUploadProps) { try { const text = await file.text(); const json = JSON.parse(text); - props.onFileLoad(json); + props.onFileLoad(json, json); } catch { setError('Invalid JSON file'); } diff --git a/src/index.tsx b/src/index.tsx index b0b5bb0..04fbd86 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -11,6 +11,7 @@ const ComparisonView = lazy(() => import('./components/ComparisonView')); function App() { const [currentStats, setCurrentStats] = createSignal(null); + const [currentRaw, setCurrentRaw] = createSignal | null>(null); const [snapshots, setSnapshots] = createSignal([]); const [view, setView] = createSignal<'analysis' | 'compare'>('analysis'); const [snapshotName, setSnapshotName] = createSignal(''); @@ -28,11 +29,19 @@ function App() { if (saved) { const parsed = JSON.parse(saved); if (Array.isArray(parsed.snapshots)) { - setSnapshots(parsed.snapshots); + setSnapshots( + parsed.snapshots.map((s: ComparisonEntry) => ({ + ...s, + raw: s.raw || {}, + })), + ); } if (parsed.currentStats) { setCurrentStats(parsed.currentStats); } + if (parsed.currentRaw) { + setCurrentRaw(parsed.currentRaw); + } if (parsed.view) { setView(parsed.view); } @@ -58,6 +67,7 @@ function App() { // Save to localStorage on any change createEffect(() => { const stats = currentStats(); + const raw = currentRaw(); const snaps = snapshots(); const v = view(); @@ -70,6 +80,7 @@ function App() { JSON.stringify({ snapshots: snaps, currentStats: stats, + currentRaw: raw, view: v, }), ); @@ -80,12 +91,14 @@ function App() { const saveSnapshot = () => { const stats = currentStats(); - if (!stats) return; + const raw = currentRaw(); + if (!stats || !raw) return; const name = snapshotName().trim() || `Snapshot ${snapshots().length + 1}`; const entry: ComparisonEntry = { id: Date.now(), name, data: stats, + raw, timestamp: new Date(), }; setSnapshots(prev => [...prev, entry]); @@ -109,15 +122,18 @@ function App() { setShowManageSnapshots(false); }; - const handleFileLoad = (data: StatsData) => { + const handleFileLoad = (data: StatsData, raw: Record) => { setCurrentStats(data); + setCurrentRaw(raw); setView('analysis'); }; const loadFromText = (text: string) => { try { - const data = parseStats(JSON.parse(text)); + const raw = JSON.parse(text); + const data = parseStats(raw); setCurrentStats(data); + setCurrentRaw(raw); } catch (e) { console.error('Failed to parse stats:', e); } diff --git a/src/styles.css b/src/styles.css index d089e3f..81c4779 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1420,6 +1420,15 @@ body { background: var(--bg-tertiary); } +.compare-row.missing { + opacity: 0.6; + background: var(--bg-secondary); +} + +.compare-row.missing:hover { + background: var(--bg-tertiary); +} + .compare-row .col-label { font-size: 0.8125rem; color: var(--text-primary); @@ -1465,6 +1474,25 @@ body { color: var(--text-muted); } +.missing-value { + color: var(--text-tertiary); + font-style: italic; +} + +.missing-indicator { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 600; + font-family: var(--font-nums); + font-variant-numeric: tabular-nums; + background: rgba(156, 163, 175, 0.15); + color: var(--text-tertiary); +} + .comparison-summary { display: flex; gap: 1rem; diff --git a/src/utils/types.ts b/src/utils/types.ts index 5332f74..3ace095 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -80,5 +80,6 @@ export interface ComparisonEntry { id: number; name: string; data: StatsData; + raw: Record; timestamp: Date; }