mirror of
https://github.com/NotAShelf/nix-evaluator-stats.git
synced 2026-04-12 14:27:41 +00:00
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:
parent
2feb30dc49
commit
80e0c9dc3d
5 changed files with 129 additions and 38 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -80,5 +80,6 @@ export interface ComparisonEntry {
|
|||
id: number;
|
||||
name: string;
|
||||
data: StatsData;
|
||||
raw: Record<string, unknown>;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue