mirror of
https://github.com/NotAShelf/nix-evaluator-stats.git
synced 2026-04-27 04:17:36 +00:00
Merge pull request #18 from NotAShelf/notashelf/push-swyypvuqxmyv
packages/web: add paste-from-clipboard in compare mode; fix overlaps
This commit is contained in:
commit
06b78c6b0e
7 changed files with 483 additions and 128 deletions
|
|
@ -5,6 +5,12 @@ export default [
|
|||
...tseslint.configs.recommended,
|
||||
eslintConfigPrettier,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
ignores: ['dist/', 'node_modules/', '.DS_Store', '*.md'],
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@ export function formatBytes(bytes: number, precision = 2): string {
|
|||
}
|
||||
|
||||
export function formatNumber(num: number, precision = 2): string {
|
||||
if (num >= 1e9) return (num / 1e9).toFixed(precision) + 'B';
|
||||
if (num >= 1e6) return (num / 1e6).toFixed(precision) + 'M';
|
||||
if (num >= 1e3) return (num / 1e3).toFixed(precision) + 'K';
|
||||
return num.toString();
|
||||
if (num >= 1e9) return parseFloat((num / 1e9).toFixed(precision)).toString() + 'B';
|
||||
if (num >= 1e6) return parseFloat((num / 1e6).toFixed(precision)).toString() + 'M';
|
||||
if (num >= 1e3) return parseFloat((num / 1e3).toFixed(precision)).toString() + 'K';
|
||||
const factor = Math.pow(10, precision);
|
||||
return (Math.round(num * factor) / factor).toString();
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number, precision = 2): string {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,75 @@
|
|||
import { Component, For, createSignal, createMemo, Show } from 'solid-js';
|
||||
import { ComparisonEntry, calculateChange } from '@ns/core';
|
||||
import { Component, For, createSignal, createMemo, Show, onMount, onCleanup } from 'solid-js';
|
||||
import { ComparisonEntry, calculateChange, StatsData } from '@ns/core';
|
||||
import { formatBytes, formatNumber, formatTime, formatPercent } from '@ns/ui-utils';
|
||||
import ArrowRightIcon from 'lucide-solid/icons/arrow-right';
|
||||
import ArrowDownIcon from 'lucide-solid/icons/arrow-down';
|
||||
import ArrowUpIcon from 'lucide-solid/icons/arrow-up';
|
||||
import XIcon from 'lucide-solid/icons/x';
|
||||
import FileUpload from './FileUpload';
|
||||
|
||||
interface ComparisonViewProps {
|
||||
entries: ComparisonEntry[];
|
||||
onSelect: (entry: ComparisonEntry) => void;
|
||||
onDelete: (id: number) => void;
|
||||
precision?: number;
|
||||
pasteMode: 'advance' | 'replace';
|
||||
onPasteModeChange: (mode: 'advance' | 'replace') => void;
|
||||
onPasteStats: (text: string, name: string) => ComparisonEntry | null;
|
||||
onFileLoad: (data: StatsData, raw: Record<string, unknown>) => void;
|
||||
onTextLoad: (text: string) => void;
|
||||
}
|
||||
|
||||
const ComparisonView: Component<ComparisonViewProps> = props => {
|
||||
const prec = () => props.precision ?? 2;
|
||||
const [leftEntry, setLeftEntry] = createSignal<ComparisonEntry | null>(null);
|
||||
const [rightEntry, setRightEntry] = createSignal<ComparisonEntry | null>(null);
|
||||
const [showPasteModal, setShowPasteModal] = createSignal(false);
|
||||
const [pasteError, setPasteError] = createSignal('');
|
||||
const [pasteName, setPasteName] = createSignal('');
|
||||
const [pendingPasteText, setPendingPasteText] = createSignal('');
|
||||
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
const text = e.clipboardData?.getData('text');
|
||||
if (!text) return;
|
||||
try {
|
||||
JSON.parse(text);
|
||||
setPendingPasteText(text);
|
||||
setPasteName(`Snapshot ${props.entries.length + 1}`);
|
||||
setShowPasteModal(true);
|
||||
} catch {
|
||||
// Silently ignore invalid JSON on paste
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener('paste', handlePaste);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener('paste', handlePaste);
|
||||
});
|
||||
|
||||
const confirmPaste = () => {
|
||||
const entry = props.onPasteStats(pendingPasteText(), pasteName());
|
||||
if (!entry) {
|
||||
setPasteError('Failed to process pasted statistics');
|
||||
return;
|
||||
}
|
||||
if (props.pasteMode === 'advance') {
|
||||
if (rightEntry()) {
|
||||
setLeftEntry(rightEntry());
|
||||
}
|
||||
setRightEntry(entry);
|
||||
} else {
|
||||
setRightEntry(entry);
|
||||
}
|
||||
setShowPasteModal(false);
|
||||
setPasteError('');
|
||||
};
|
||||
|
||||
const comparison = createMemo(() => {
|
||||
const left = leftEntry();
|
||||
|
|
@ -100,29 +153,47 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
|
|||
|
||||
return (
|
||||
<div class="comparison-view">
|
||||
<div class="comparison-controls">
|
||||
<div class="compare-selector">
|
||||
<label>Baseline</label>
|
||||
<select onChange={selectLeft} value={leftEntry()?.id || ''}>
|
||||
<option value="">Select snapshot...</option>
|
||||
<For each={props.entries}>
|
||||
{entry => <option value={entry.id}>{entry.name}</option>}
|
||||
</For>
|
||||
</select>
|
||||
<Show when={props.entries.length >= 2}>
|
||||
<div class="comparison-controls">
|
||||
<div class="compare-selector">
|
||||
<label>Baseline</label>
|
||||
<select onChange={selectLeft} value={leftEntry()?.id || ''}>
|
||||
<option value="">Select snapshot...</option>
|
||||
<For each={props.entries}>
|
||||
{entry => <option value={entry.id}>{entry.name}</option>}
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
<div class="compare-arrow">
|
||||
<ArrowRightIcon size={20} />
|
||||
</div>
|
||||
<div class="compare-selector">
|
||||
<label>Current</label>
|
||||
<select onChange={selectRight} value={rightEntry()?.id || ''}>
|
||||
<option value="">Select snapshot...</option>
|
||||
<For each={props.entries}>
|
||||
{entry => <option value={entry.id}>{entry.name}</option>}
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
<div class="compare-paste-toggle">
|
||||
<button
|
||||
class={props.pasteMode === 'advance' ? 'active' : ''}
|
||||
onClick={() => props.onPasteModeChange('advance')}
|
||||
title="Paste shifts current to baseline"
|
||||
>
|
||||
Auto
|
||||
</button>
|
||||
<button
|
||||
class={props.pasteMode === 'replace' ? 'active' : ''}
|
||||
onClick={() => props.onPasteModeChange('replace')}
|
||||
title="Paste replaces current only"
|
||||
>
|
||||
Replace
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="compare-arrow">
|
||||
<ArrowRightIcon size={20} />
|
||||
</div>
|
||||
<div class="compare-selector">
|
||||
<label>Current</label>
|
||||
<select onChange={selectRight} value={rightEntry()?.id || ''}>
|
||||
<option value="">Select snapshot...</option>
|
||||
<For each={props.entries}>
|
||||
{entry => <option value={entry.id}>{entry.name}</option>}
|
||||
</For>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.entries.length > 0}>
|
||||
<div class="snapshots-list">
|
||||
|
|
@ -141,101 +212,186 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
|
|||
</Show>
|
||||
|
||||
<Show
|
||||
when={leftEntry() && rightEntry()}
|
||||
fallback={<div class="compare-placeholder">Select two snapshots to compare metrics</div>}
|
||||
>
|
||||
<div class="comparison-table">
|
||||
<div class="compare-header">
|
||||
<div class="col-label">Metric</div>
|
||||
<div class="col-value">{leftEntry()?.name}</div>
|
||||
<div class="col-value">{rightEntry()?.name}</div>
|
||||
<div class="col-change">Change</div>
|
||||
</div>
|
||||
<For each={comparison()}>
|
||||
{row => (
|
||||
<div
|
||||
class={`compare-row ${row.isDifferent ? 'diff' : ''} ${row.isMissing ? 'missing' : ''}`}
|
||||
>
|
||||
<div class="col-label" title={row.label}>
|
||||
{row.label}
|
||||
</div>
|
||||
<div class="col-value">
|
||||
<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">
|
||||
<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 && !row.isMissing ? 'bad' : ''}`}
|
||||
>
|
||||
<Show
|
||||
when={row.isMissing}
|
||||
fallback={
|
||||
<Show when={row.isDifferent} fallback={<span class="neutral">—</span>}>
|
||||
<span class="change-value">
|
||||
<Show when={row.isReduction}>
|
||||
<ArrowDownIcon size={14} />
|
||||
</Show>
|
||||
<Show when={!row.isReduction}>
|
||||
<ArrowUpIcon size={14} />
|
||||
</Show>
|
||||
{Math.abs(row.change).toFixed(prec())}%
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<span
|
||||
class="missing-indicator"
|
||||
title="Field not available in one or both implementations"
|
||||
>
|
||||
N/A
|
||||
</span>
|
||||
</Show>
|
||||
when={props.entries.length >= 2}
|
||||
fallback={
|
||||
<div class="compare-upload-section">
|
||||
<FileUpload
|
||||
onFileLoad={props.onFileLoad}
|
||||
onTextLoad={props.onTextLoad}
|
||||
snapshots={props.entries}
|
||||
onLoadSnapshot={props.onSelect}
|
||||
/>
|
||||
<Show when={props.entries.length === 1}>
|
||||
<div class="compare-more-needed">
|
||||
<div class="compare-placeholder-hint">
|
||||
Upload or paste one more snapshot to start comparing
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={leftEntry() && rightEntry()}
|
||||
fallback={
|
||||
<div class="compare-placeholder">
|
||||
<div>Select two snapshots to compare metrics</div>
|
||||
<div class="compare-placeholder-hint">
|
||||
Paste JSON stats here (Ctrl+V) while in compare mode
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="comparison-table">
|
||||
<div class="compare-header">
|
||||
<div class="col-label">Metric</div>
|
||||
<div class="col-value">{leftEntry()?.name}</div>
|
||||
<div class="col-value">{rightEntry()?.name}</div>
|
||||
<div class="col-change">Change</div>
|
||||
</div>
|
||||
<For each={comparison()}>
|
||||
{row => (
|
||||
<div
|
||||
class={`compare-row ${row.isDifferent ? 'diff' : ''} ${row.isMissing ? 'missing' : ''}`}
|
||||
>
|
||||
<div class="col-label" title={row.label}>
|
||||
{row.label}
|
||||
</div>
|
||||
<div class="col-value">
|
||||
<Show
|
||||
when={row.isMissing}
|
||||
fallback={
|
||||
row.format === 'bytes'
|
||||
? formatBytes(row.leftValue, prec())
|
||||
: row.format === 'time'
|
||||
? formatTime(row.leftValue, prec())
|
||||
: row.format === 'percent'
|
||||
? formatPercent(row.leftValue, prec())
|
||||
: formatNumber(row.leftValue, prec())
|
||||
}
|
||||
>
|
||||
<span class="missing-value">N/A</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="col-value">
|
||||
<Show
|
||||
when={row.isMissing}
|
||||
fallback={
|
||||
row.format === 'bytes'
|
||||
? formatBytes(row.rightValue, prec())
|
||||
: row.format === 'time'
|
||||
? formatTime(row.rightValue, prec())
|
||||
: row.format === 'percent'
|
||||
? formatPercent(row.rightValue, prec())
|
||||
: formatNumber(row.rightValue, prec())
|
||||
}
|
||||
>
|
||||
<span class="missing-value">N/A</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div
|
||||
class={`col-change ${row.isReduction ? 'good' : row.isDifferent && !row.isMissing ? 'bad' : ''}`}
|
||||
>
|
||||
<Show
|
||||
when={row.isMissing}
|
||||
fallback={
|
||||
<Show when={row.isDifferent} fallback={<span class="neutral">—</span>}>
|
||||
<span class="change-value">
|
||||
<Show when={row.isReduction}>
|
||||
<ArrowDownIcon size={14} />
|
||||
</Show>
|
||||
<Show when={!row.isReduction}>
|
||||
<ArrowUpIcon size={14} />
|
||||
</Show>
|
||||
{parseFloat(
|
||||
(
|
||||
Math.round(Math.abs(row.change) * Math.pow(10, prec())) /
|
||||
Math.pow(10, prec())
|
||||
).toFixed(prec()),
|
||||
)}
|
||||
%
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<span
|
||||
class="missing-indicator"
|
||||
title="Field not available in one or both implementations"
|
||||
>
|
||||
N/A
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="comparison-summary">
|
||||
<Show when={comparison()?.some(r => r.isReduction)}>
|
||||
<div class="summary-good">
|
||||
<ArrowDownIcon size={16} />{' '}
|
||||
{comparison()?.filter(r => r.isReduction && r.isDifferent).length} improved
|
||||
<div class="comparison-summary">
|
||||
<Show when={comparison()?.some(r => r.isReduction)}>
|
||||
<div class="summary-good">
|
||||
<ArrowDownIcon size={16} />{' '}
|
||||
{comparison()?.filter(r => r.isReduction && r.isDifferent).length} improved
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={comparison()?.some(r => !r.isReduction && r.isDifferent)}>
|
||||
<div class="summary-bad">
|
||||
<ArrowUpIcon size={16} />{' '}
|
||||
{comparison()?.filter(r => !r.isReduction && r.isDifferent).length} regressed
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<Show when={showPasteModal()}>
|
||||
<div
|
||||
class="modal-overlay"
|
||||
onClick={() => {
|
||||
setShowPasteModal(false);
|
||||
setPasteError('');
|
||||
}}
|
||||
>
|
||||
<div class="modal" onClick={e => e.stopPropagation()}>
|
||||
<div class="modal-header">
|
||||
<h3>Paste Statistics</h3>
|
||||
<button
|
||||
class="close-btn"
|
||||
onClick={() => {
|
||||
setShowPasteModal(false);
|
||||
setPasteError('');
|
||||
}}
|
||||
>
|
||||
<XIcon size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={comparison()?.some(r => !r.isReduction && r.isDifferent)}>
|
||||
<div class="summary-bad">
|
||||
<ArrowUpIcon size={16} />{' '}
|
||||
{comparison()?.filter(r => !r.isReduction && r.isDifferent).length} regressed
|
||||
<input
|
||||
type="text"
|
||||
class="snapshot-name-input"
|
||||
placeholder="Enter snapshot name..."
|
||||
value={pasteName()}
|
||||
onInput={e => setPasteName(e.currentTarget.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && confirmPaste()}
|
||||
autofocus
|
||||
/>
|
||||
<Show when={pasteError()}>
|
||||
<div class="error">{pasteError()}</div>
|
||||
</Show>
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
class="cancel-btn"
|
||||
onClick={() => {
|
||||
setShowPasteModal(false);
|
||||
setPasteError('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="confirm-btn" onClick={confirmPaste}>
|
||||
{props.entries.length >= 2 ? 'Save & Compare' : 'Save Snapshot'}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,16 +6,15 @@ import ClockIcon from 'lucide-solid/icons/clock';
|
|||
interface FileUploadProps {
|
||||
onFileLoad: (data: StatsData, raw: Record<string, unknown>) => void;
|
||||
onTextLoad: (text: string) => void;
|
||||
showHelp: boolean;
|
||||
onToggleHelp: () => void;
|
||||
snapshots?: ComparisonEntry[];
|
||||
onLoadSnapshot?: (entry: ComparisonEntry) => void;
|
||||
}
|
||||
|
||||
export default function FileUpload(props: FileUploadProps) {
|
||||
const [textInput, setTextInput] = createSignal('');
|
||||
const [isTextMode, setIsTextMode] = createSignal(false);
|
||||
const [isTextMode, setIsTextMode] = createSignal(true);
|
||||
const [error, setError] = createSignal('');
|
||||
const [showHelp, setShowHelp] = createSignal(false);
|
||||
|
||||
const handleFile = async (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
|
|
@ -49,11 +48,11 @@ export default function FileUpload(props: FileUploadProps) {
|
|||
</div>
|
||||
<h2>Load Statistics</h2>
|
||||
|
||||
<div class="help-link" onClick={props.onToggleHelp}>
|
||||
<div class="help-link" onClick={() => setShowHelp(!showHelp())}>
|
||||
How do I use this?
|
||||
</div>
|
||||
|
||||
<Show when={props.showHelp}>
|
||||
<Show when={showHelp()}>
|
||||
<div class="help-panel">
|
||||
<h4>Generating Stats</h4>
|
||||
<code>NIX_SHOW_STATS=1 nix-instantiate expr.nix > stats.json</code>
|
||||
|
|
@ -85,6 +84,18 @@ export default function FileUpload(props: FileUploadProps) {
|
|||
class="json-input"
|
||||
value={textInput()}
|
||||
onInput={e => setTextInput(e.currentTarget.value)}
|
||||
onPaste={e => {
|
||||
const text = e.clipboardData?.getData('text');
|
||||
if (!text) return;
|
||||
try {
|
||||
JSON.parse(text);
|
||||
e.preventDefault();
|
||||
props.onTextLoad(text);
|
||||
} catch {
|
||||
setTextInput(text);
|
||||
setError('Invalid JSON in clipboard');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button class="load-btn" onClick={handleTextLoad}>
|
||||
Load
|
||||
|
|
|
|||
|
|
@ -49,7 +49,10 @@ const ThunkChart: Component<ThunkChartProps> = props => {
|
|||
<div class="ratio-bar">
|
||||
<div class="ratio-fill" style={{ width: `${avoidedRatio() * 100}%` }} />
|
||||
</div>
|
||||
<span class="ratio-label">Avoidance rate: {(avoidedRatio() * 100).toFixed(prec())}%</span>
|
||||
<span class="ratio-label">
|
||||
Avoidance rate:{' '}
|
||||
{Math.round(avoidedRatio() * 100 * Math.pow(10, prec())) / Math.pow(10, prec())}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -29,10 +29,12 @@ function App() {
|
|||
const [view, setView] = createSignal<'analysis' | 'compare'>('analysis');
|
||||
const [snapshotName, setSnapshotName] = createSignal('');
|
||||
const [showSaveDialog, setShowSaveDialog] = createSignal(false);
|
||||
const [showHelp, setShowHelp] = createSignal(false);
|
||||
const [showManageSnapshots, setShowManageSnapshots] = createSignal(false);
|
||||
const [precision, setPrecision] = createSignal(2);
|
||||
const [pasteMode, setPasteMode] = createSignal<'advance' | 'replace'>('advance');
|
||||
const [isLoading, setIsLoading] = createSignal(true);
|
||||
const [showOverrideModal, setShowOverrideModal] = createSignal(false);
|
||||
const [pendingOverrideText, setPendingOverrideText] = createSignal('');
|
||||
|
||||
const STORAGE_KEY = 'ns-data';
|
||||
|
||||
|
|
@ -62,6 +64,9 @@ function App() {
|
|||
if (typeof parsed.precision === 'number' && parsed.precision >= 0) {
|
||||
setPrecision(parsed.precision);
|
||||
}
|
||||
if (parsed.pasteMode === 'advance' || parsed.pasteMode === 'replace') {
|
||||
setPasteMode(parsed.pasteMode);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load saved data:', e);
|
||||
|
|
@ -76,8 +81,28 @@ function App() {
|
|||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
const handleAnalysisPaste = (e: ClipboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
const text = e.clipboardData?.getData('text');
|
||||
if (!text) return;
|
||||
try {
|
||||
JSON.parse(text);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (view() === 'analysis' && currentStats()) {
|
||||
setPendingOverrideText(text);
|
||||
setShowOverrideModal(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener('paste', handleAnalysisPaste);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener('paste', handleAnalysisPaste);
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -89,6 +114,7 @@ function App() {
|
|||
snaps: ComparisonEntry[],
|
||||
v: 'analysis' | 'compare',
|
||||
prec: number,
|
||||
pm: 'advance' | 'replace',
|
||||
) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
|
|
@ -99,6 +125,7 @@ function App() {
|
|||
currentRaw: raw,
|
||||
view: v,
|
||||
precision: prec,
|
||||
pasteMode: pm,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
|
|
@ -115,10 +142,11 @@ function App() {
|
|||
const snaps = snapshots();
|
||||
const v = view();
|
||||
const prec = precision();
|
||||
const pm = pasteMode();
|
||||
|
||||
if (isLoading()) return;
|
||||
|
||||
saveToStorage(stats, raw, snaps, v, prec);
|
||||
saveToStorage(stats, raw, snaps, v, prec, pm);
|
||||
});
|
||||
|
||||
const saveSnapshot = () => {
|
||||
|
|
@ -171,6 +199,55 @@ function App() {
|
|||
}
|
||||
};
|
||||
|
||||
const handlePasteStats = (text: string, name: string): ComparisonEntry | null => {
|
||||
let raw: Record<string, unknown>;
|
||||
let data: StatsData;
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
data = parseStats(raw);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse pasted stats:', e);
|
||||
return null;
|
||||
}
|
||||
const entry: ComparisonEntry = {
|
||||
id: Date.now(),
|
||||
name: name.trim() || `Snapshot ${snapshots().length + 1}`,
|
||||
data,
|
||||
raw,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setSnapshots(prev => [...prev, entry]);
|
||||
return entry;
|
||||
};
|
||||
|
||||
const handleCompareFileLoad = (data: StatsData, raw: Record<string, unknown>) => {
|
||||
const entry: ComparisonEntry = {
|
||||
id: Date.now(),
|
||||
name: `Snapshot ${snapshots().length + 1}`,
|
||||
data,
|
||||
raw,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setSnapshots(prev => [...prev, entry]);
|
||||
};
|
||||
|
||||
const handleCompareTextLoad = (text: string) => {
|
||||
try {
|
||||
const raw = JSON.parse(text);
|
||||
const data = parseStats(raw);
|
||||
const entry: ComparisonEntry = {
|
||||
id: Date.now(),
|
||||
name: `Snapshot ${snapshots().length + 1}`,
|
||||
data,
|
||||
raw,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setSnapshots(prev => [...prev, entry]);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse stats:', e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="app">
|
||||
<header class="header">
|
||||
|
|
@ -181,11 +258,7 @@ function App() {
|
|||
<button class={view() === 'analysis' ? 'active' : ''} onClick={() => setView('analysis')}>
|
||||
Analysis
|
||||
</button>
|
||||
<button
|
||||
class={view() === 'compare' ? 'active' : ''}
|
||||
onClick={() => setView('compare')}
|
||||
disabled={snapshots().length < 2}
|
||||
>
|
||||
<button class={view() === 'compare' ? 'active' : ''} onClick={() => setView('compare')}>
|
||||
Compare ({snapshots().length})
|
||||
</button>
|
||||
<Show when={snapshots().length > 0}>
|
||||
|
|
@ -223,8 +296,6 @@ function App() {
|
|||
<FileUpload
|
||||
onFileLoad={handleFileLoad}
|
||||
onTextLoad={loadFromText}
|
||||
showHelp={showHelp()}
|
||||
onToggleHelp={() => setShowHelp(!showHelp())}
|
||||
snapshots={snapshots()}
|
||||
onLoadSnapshot={loadSnapshot}
|
||||
/>
|
||||
|
|
@ -255,6 +326,30 @@ function App() {
|
|||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={showOverrideModal()}>
|
||||
<div class="modal-overlay" onClick={() => setShowOverrideModal(false)}>
|
||||
<div class="modal" onClick={e => e.stopPropagation()}>
|
||||
<h3>Override Analysis?</h3>
|
||||
<p style={{ color: 'var(--text-secondary)', 'margin-bottom': '1rem' }}>
|
||||
This will replace the current analysis with pasted data.
|
||||
</p>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" onClick={() => setShowOverrideModal(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="confirm-btn"
|
||||
onClick={() => {
|
||||
loadFromText(pendingOverrideText());
|
||||
setShowOverrideModal(false);
|
||||
}}
|
||||
>
|
||||
Override
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
|
|
@ -264,6 +359,11 @@ function App() {
|
|||
onSelect={entry => setCurrentStats(entry.data)}
|
||||
onDelete={deleteSnapshot}
|
||||
precision={precision()}
|
||||
pasteMode={pasteMode()}
|
||||
onPasteModeChange={setPasteMode}
|
||||
onPasteStats={handlePasteStats}
|
||||
onFileLoad={handleCompareFileLoad}
|
||||
onTextLoad={handleCompareTextLoad}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -183,6 +183,7 @@ body {
|
|||
align-items: center;
|
||||
gap: 2rem;
|
||||
padding-top: 4rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-card {
|
||||
|
|
@ -824,6 +825,9 @@ body {
|
|||
color: var(--text-primary);
|
||||
font-family: var(--font-nums);
|
||||
font-variant-numeric: tabular-nums;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
|
|
@ -983,6 +987,8 @@ body {
|
|||
color: var(--text-primary);
|
||||
font-family: var(--font-nums);
|
||||
font-variant-numeric: tabular-nums;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
|
|
@ -1029,6 +1035,8 @@ body {
|
|||
color: var(--text-primary);
|
||||
font-family: var(--font-nums);
|
||||
font-variant-numeric: tabular-nums;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.legend-percent {
|
||||
|
|
@ -1036,6 +1044,8 @@ body {
|
|||
font-size: 0.75rem;
|
||||
text-align: right;
|
||||
min-width: 40px;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.donut-chart svg {
|
||||
|
|
@ -1130,6 +1140,8 @@ body {
|
|||
color: var(--text-secondary);
|
||||
min-width: 80px;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.gc-stats {
|
||||
|
|
@ -1157,6 +1169,8 @@ body {
|
|||
color: var(--text-primary);
|
||||
font-family: var(--font-nums);
|
||||
font-variant-numeric: tabular-nums;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Operations Chart */
|
||||
|
|
@ -1213,6 +1227,8 @@ body {
|
|||
font-family: var(--font-nums);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Thunk Chart */
|
||||
|
|
@ -1267,6 +1283,8 @@ body {
|
|||
font-family: var(--font-nums);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.thunk-ratio {
|
||||
|
|
@ -1294,6 +1312,8 @@ body {
|
|||
.ratio-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
|
|
@ -1341,6 +1361,8 @@ body {
|
|||
font-family: var(--font-nums);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Top Lists */
|
||||
|
|
@ -1384,6 +1406,9 @@ body {
|
|||
color: var(--text-secondary);
|
||||
font-family: var(--font-nums);
|
||||
font-variant-numeric: tabular-nums;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.top-item .location {
|
||||
|
|
@ -1441,6 +1466,36 @@ body {
|
|||
justify-content: center;
|
||||
}
|
||||
|
||||
.compare-paste-toggle {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
align-items: flex-end;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.compare-paste-toggle button {
|
||||
height: calc(0.75rem * 2 + 1rem + 2px);
|
||||
padding: 0 0.625rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.compare-paste-toggle button:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.compare-paste-toggle button.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.compare-placeholder {
|
||||
text-align: center;
|
||||
padding: 4rem;
|
||||
|
|
@ -1450,6 +1505,25 @@ body {
|
|||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.compare-placeholder-hint {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.compare-upload-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.compare-more-needed {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.comparison-table {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
|
|
@ -1510,10 +1584,14 @@ body {
|
|||
color: var(--text-secondary);
|
||||
font-family: var(--font-nums);
|
||||
font-variant-numeric: tabular-nums;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.compare-row .col-change {
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.change-value {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue