ui: distinguish between missing fields and zero values in comparison view

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I3fca108a637507c34f7579d6d52236136a6a6964
This commit is contained in:
raf 2026-01-22 23:58:51 +03:00
commit 80e0c9dc3d
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
5 changed files with 129 additions and 38 deletions

View file

@ -47,24 +47,42 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
}));
return fields.map(field => {
const getValue = (data: ComparisonEntry['data']) => {
const getValue = (
data: ComparisonEntry['data'],
raw: Record<string, unknown>,
): { value: number; present: boolean } => {
const keys = field.key.split('.');
let value: unknown = data;
for (const k of keys) value = value && (value as Record<string, unknown>)?.[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<string, unknown>)) {
const nested = (obj as Record<string, unknown>)[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<ComparisonViewProps> = props => {
</div>
<For each={comparison()}>
{row => (
<div class={`compare-row ${row.isDifferent ? 'diff' : ''}`}>
<div
class={`compare-row ${row.isDifferent ? 'diff' : ''} ${row.isMissing ? 'missing' : ''}`}
>
<div class="col-label" title={row.label}>
{row.label}
</div>
<div class="col-value">
{row.format === 'bytes'
? formatBytes(row.leftValue)
: row.format === 'time'
? formatTime(row.leftValue)
: row.format === 'percent'
? formatPercent(row.leftValue)
: formatNumber(row.leftValue)}
<Show
when={row.isMissing}
fallback={
row.format === 'bytes'
? formatBytes(row.leftValue)
: row.format === 'time'
? formatTime(row.leftValue)
: row.format === 'percent'
? formatPercent(row.leftValue)
: formatNumber(row.leftValue)
}
>
<span class="missing-value">N/A</span>
</Show>
</div>
<div class="col-value">
{row.format === 'bytes'
? formatBytes(row.rightValue)
: row.format === 'time'
? formatTime(row.rightValue)
: row.format === 'percent'
? formatPercent(row.rightValue)
: formatNumber(row.rightValue)}
<Show
when={row.isMissing}
fallback={
row.format === 'bytes'
? formatBytes(row.rightValue)
: row.format === 'time'
? formatTime(row.rightValue)
: row.format === 'percent'
? formatPercent(row.rightValue)
: formatNumber(row.rightValue)
}
>
<span class="missing-value">N/A</span>
</Show>
</div>
<div
class={`col-change ${row.isReduction ? 'good' : row.isDifferent ? 'bad' : ''}`}
class={`col-change ${row.isReduction ? 'good' : row.isDifferent && !row.isMissing ? 'bad' : ''}`}
>
<Show when={row.isDifferent} fallback={<span class="neutral"></span>}>
<span class="change-value">
<Show when={row.isReduction}>
<ArrowDown size={14} />
<Show
when={row.isMissing}
fallback={
<Show when={row.isDifferent} fallback={<span class="neutral"></span>}>
<span class="change-value">
<Show when={row.isReduction}>
<ArrowDown size={14} />
</Show>
<Show when={!row.isReduction}>
<ArrowUp size={14} />
</Show>
{Math.abs(row.change).toFixed(1)}%
</span>
</Show>
<Show when={!row.isReduction}>
<ArrowUp size={14} />
</Show>
{Math.abs(row.change).toFixed(1)}%
}
>
<span
class="missing-indicator"
title="Field not available in one or both implementations"
>
N/A
</span>
</Show>
</div>

View file

@ -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<string, unknown>) => 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');
}

View file

@ -11,6 +11,7 @@ const ComparisonView = lazy(() => import('./components/ComparisonView'));
function App() {
const [currentStats, setCurrentStats] = createSignal<StatsData | null>(null);
const [currentRaw, setCurrentRaw] = createSignal<Record<string, unknown> | null>(null);
const [snapshots, setSnapshots] = createSignal<ComparisonEntry[]>([]);
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<string, unknown>) => {
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);
}

View file

@ -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;

View file

@ -80,5 +80,6 @@ export interface ComparisonEntry {
id: number;
name: string;
data: StatsData;
raw: Record<string, unknown>;
timestamp: Date;
}