Skip to content

Commit 10dcbc6

Browse files
JavaZerooclaude
andcommitted
feat: P1 — toasts, LTTB downsampling, IndexedDB, parse progress, shared metric helpers
- Add ToastProvider/useToast and replace silent console.warn for parse errors, storage quota, and clipboard outcomes - Add LTTB downsampling for chart performance with UI toggle + threshold (default on, 2000 pts) - Migrate file persistence to IndexedDB with auto-migration from legacy localStorage; falls back to localStorage when IDB is unavailable - Worker emits PARSE_PROGRESS between metrics; FileList renders per-file progress bar while parsing - Extract MATCH_MODES/MODE_CONFIG/getMetricTitle to src/utils/metricHelpers, removing duplication across RegexControls, FileConfigModal, ChartContainer - Tests: +26 cases covering downsample, fileStorage, ToastContext, FileList progress, and worker progress messages Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 62f87b8 commit 10dcbc6

19 files changed

Lines changed: 939 additions & 178 deletions

public/locales/en/translation.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@
5656
"display.options": "🎛️ Display Options",
5757
"display.chart": "📊 Chart Display",
5858
"display.chartDesc": "Automatically shows charts for all configured metrics after upload",
59+
"display.performance": "Performance",
60+
"display.downsample": "Downsample large datasets",
61+
"display.downsampleDesc": "Uses LTTB to keep visual shape while reducing rendered points. Disable to see every data point.",
62+
"display.downsampleThreshold": "Max points per series",
5963
"display.baseline": "Baseline Settings",
6064
"display.relativeBaseline": "Relative error baseline",
6165
"display.relativeBaselineDesc": "Set baseline value for relative error comparison",
@@ -134,5 +138,10 @@
134138
"configModal.example2": "Start: 50, End: empty → shows data points 51-end",
135139
"configModal.example3": "Start: 0, End: empty → shows all data points (default)",
136140
"configModal.cancel": "Cancel",
137-
"configModal.save": "Save Config"
141+
"configModal.save": "Save Config",
142+
"toast.parseError": "Failed to parse {{name}}: {{error}}",
143+
"toast.parseComplete": "Parsed {{name}}",
144+
"toast.storageQuotaExceeded": "Storage limit reached. File persistence has been disabled for this session.",
145+
"toast.copyImageSuccess": "Image copied to clipboard",
146+
"toast.copyImageError": "Failed to copy image"
138147
}

public/locales/zh/translation.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@
5656
"display.options": "🎛️ 显示选项",
5757
"display.chart": "📊 图表显示",
5858
"display.chartDesc": "上传文件后自动展示所有已配置的指标图表",
59+
"display.performance": "性能",
60+
"display.downsample": "对大数据集启用降采样",
61+
"display.downsampleDesc": "使用 LTTB 算法在保留曲线形态的前提下减少绘制点数。关闭以查看完整数据点。",
62+
"display.downsampleThreshold": "每条曲线最大点数",
5963
"display.baseline": "基准线设置",
6064
"display.relativeBaseline": "相对误差 Baseline",
6165
"display.relativeBaselineDesc": "设置相对误差对比的基准线数值",
@@ -134,5 +138,10 @@
134138
"configModal.example2": "起始: 50, 结束: 留空 → 显示第51个数据点到结尾",
135139
"configModal.example3": "起始: 0, 结束: 留空 → 显示全部数据点(默认)",
136140
"configModal.cancel": "取消",
137-
"configModal.save": "保存配置"
141+
"configModal.save": "保存配置",
142+
"toast.parseError": "解析 {{name}} 失败:{{error}}",
143+
"toast.parseComplete": "已解析 {{name}}",
144+
"toast.storageQuotaExceeded": "存储空间已满,本次会话将不再持久化文件。",
145+
"toast.copyImageSuccess": "图片已复制到剪贴板",
146+
"toast.copyImageError": "复制图片失败"
138147
}

src/App.jsx

Lines changed: 155 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { ThemeToggle } from './components/ThemeToggle';
1010
import { Header } from './components/Header';
1111
import { PanelLeftClose, PanelLeftOpen } from 'lucide-react';
1212
import { mergeFilesWithReplacement } from './utils/mergeFiles.js';
13+
import { useToast } from './components/ToastContext.jsx';
14+
import { loadFiles as loadFilesFromStorage, saveFiles as saveFilesToStorage, clearFiles as clearFilesInStorage } from './utils/fileStorage.js';
1315

1416
// Threshold for "large file" - files above this won't have content persisted
1517
const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024; // 5MB of content
@@ -34,34 +36,58 @@ export const DEFAULT_GLOBAL_PARSING_CONFIG = {
3436
stepKeyword: 'step:'
3537
};
3638

39+
export const DEFAULT_CHART_CONFIG = {
40+
downsampleEnabled: true,
41+
downsampleThreshold: 2000
42+
};
43+
44+
function restoreFile(file) {
45+
return {
46+
...file,
47+
enabled: file.enabled ?? true,
48+
isParsing: false,
49+
metricsData: file.metricsData || {},
50+
needsReupload: file.isLargeFile && !file.content
51+
};
52+
}
53+
3754
function App() {
3855
const { t } = useTranslation();
56+
const toast = useToast();
57+
const toastRef = useRef(toast);
58+
const tRef = useRef(t);
59+
useEffect(() => { toastRef.current = toast; }, [toast]);
60+
useEffect(() => { tRef.current = t; }, [t]);
61+
// Sync init from legacy localStorage so the first paint matches prior behavior;
62+
// an async IndexedDB load below will replace this state if richer data is present.
3963
const [uploadedFiles, setUploadedFiles] = useState(() => {
4064
const stored = localStorage.getItem('uploadedFiles');
4165
if (!stored) return [];
4266
try {
4367
const parsed = JSON.parse(stored);
44-
// Restore files with proper defaults for large files that have metricsData
45-
return parsed.map(file => ({
46-
...file,
47-
enabled: file.enabled ?? true,
48-
isParsing: false,
49-
// For large files, metricsData is already stored; for small files it will be re-parsed
50-
metricsData: file.metricsData || {},
51-
// Mark large files that need re-upload for re-parsing
52-
needsReupload: file.isLargeFile && !file.content
53-
}));
68+
return parsed.map(restoreFile);
5469
} catch {
5570
return [];
5671
}
5772
});
73+
const initialLoadDoneRef = useRef(false);
5874

5975
// Global parsing configuration state
6076
const [globalParsingConfig, setGlobalParsingConfig] = useState(() => {
6177
const stored = localStorage.getItem('globalParsingConfig');
6278
return stored ? JSON.parse(stored) : JSON.parse(JSON.stringify(DEFAULT_GLOBAL_PARSING_CONFIG));
6379
});
6480

81+
const [chartConfig, setChartConfig] = useState(() => {
82+
const stored = localStorage.getItem('chartConfig');
83+
if (!stored) return { ...DEFAULT_CHART_CONFIG };
84+
try {
85+
return { ...DEFAULT_CHART_CONFIG, ...JSON.parse(stored) };
86+
} catch {
87+
return { ...DEFAULT_CHART_CONFIG };
88+
}
89+
});
90+
6591
const [compareMode, setCompareMode] = useState('normal');
6692
const [multiFileMode, setMultiFileMode] = useState('baseline');
6793
const [baselineFile, setBaselineFile] = useState('');
@@ -85,29 +111,46 @@ function App() {
85111

86112
workerRef.current.onmessage = (e) => {
87113
const { type, payload } = e.data;
88-
if (type === 'PARSE_COMPLETE') {
114+
if (type === 'PARSE_PROGRESS') {
89115
setUploadedFiles(prev => prev.map(file => {
90116
if (file.id === payload.fileId) {
91-
return {
92-
...file,
93-
metricsData: payload.metricsData,
94-
isParsing: false
95-
};
117+
return { ...file, progress: payload.progress };
96118
}
97119
return file;
98120
}));
99-
} else if (type === 'PARSE_ERROR') {
100-
console.error('Worker parsing error:', payload.error);
121+
} else if (type === 'PARSE_COMPLETE') {
101122
setUploadedFiles(prev => prev.map(file => {
102123
if (file.id === payload.fileId) {
103124
return {
104125
...file,
126+
metricsData: payload.metricsData,
105127
isParsing: false,
106-
error: payload.error
128+
progress: 1
107129
};
108130
}
109131
return file;
110132
}));
133+
} else if (type === 'PARSE_ERROR') {
134+
console.error('Worker parsing error:', payload.error);
135+
setUploadedFiles(prev => {
136+
const target = prev.find(f => f.id === payload.fileId);
137+
if (target) {
138+
toastRef.current.error(
139+
tRef.current('toast.parseError', { name: target.name, error: payload.error })
140+
);
141+
}
142+
return prev.map(file => {
143+
if (file.id === payload.fileId) {
144+
return {
145+
...file,
146+
isParsing: false,
147+
progress: undefined,
148+
error: payload.error
149+
};
150+
}
151+
return file;
152+
});
153+
});
111154
}
112155
};
113156

@@ -128,6 +171,26 @@ function App() {
128171
}
129172
}, [enabledFiles, baselineFile]);
130173

174+
// Async load from IndexedDB on mount. Overrides the sync localStorage init
175+
// if richer data is present (e.g. after migration or in IDB-capable browsers).
176+
useEffect(() => {
177+
let cancelled = false;
178+
loadFilesFromStorage().then(files => {
179+
if (cancelled) return;
180+
initialLoadDoneRef.current = true;
181+
if (Array.isArray(files) && files.length > 0) {
182+
setUploadedFiles(prev => {
183+
// Avoid overwriting if user already added files during async load
184+
if (prev.length > 0) return prev;
185+
return files.map(restoreFile);
186+
});
187+
}
188+
}).catch(() => {
189+
initialLoadDoneRef.current = true;
190+
});
191+
return () => { cancelled = true; };
192+
}, []);
193+
131194
// Persist configuration to localStorage
132195
useEffect(() => {
133196
if (savingDisabledRef.current) return;
@@ -136,53 +199,43 @@ function App() {
136199

137200
useEffect(() => {
138201
if (savingDisabledRef.current) return;
139-
try {
140-
// Smart serialization: for large files, only store metricsData (not raw content)
141-
// This allows the app to still display charts after refresh, but re-parsing will need re-upload
142-
const serialized = uploadedFiles.map(({ id, name, enabled, content, config, metricsData }) => {
143-
const isLargeFile = content && content.length > LARGE_FILE_THRESHOLD;
144-
return {
145-
id,
146-
name,
147-
enabled,
148-
// For large files, don't store content to save memory/storage
149-
content: isLargeFile ? null : content,
150-
config,
151-
// Store metricsData for large files so charts still work after refresh
152-
metricsData: isLargeFile ? metricsData : undefined,
153-
// Flag to indicate this file needs re-upload for re-parsing
154-
isLargeFile
155-
};
156-
});
157-
if (serialized.length > 0) {
158-
const json = JSON.stringify(serialized);
159-
// Avoid filling localStorage with very large data
160-
if (json.length > 5 * 1024 * 1024) {
161-
savingDisabledRef.current = true;
162-
console.warn('Uploaded files exceed storage limit; persistence disabled.');
163-
return;
164-
}
165-
localStorage.setItem('uploadedFiles', json);
166-
} else {
167-
localStorage.removeItem('uploadedFiles');
168-
}
169-
} catch (err) {
170-
if (err instanceof DOMException && err.name === 'QuotaExceededError') {
202+
localStorage.setItem('chartConfig', JSON.stringify(chartConfig));
203+
}, [chartConfig]);
204+
205+
useEffect(() => {
206+
if (savingDisabledRef.current) return;
207+
// Smart serialization: for large files, only store metricsData (not raw content).
208+
// Charts still render after refresh, but re-parsing requires re-upload.
209+
const serialized = uploadedFiles.map(({ id, name, enabled, content, config, metricsData }) => {
210+
const isLargeFile = content && content.length > LARGE_FILE_THRESHOLD;
211+
return {
212+
id,
213+
name,
214+
enabled,
215+
content: isLargeFile ? null : content,
216+
config,
217+
metricsData: isLargeFile ? metricsData : undefined,
218+
isLargeFile
219+
};
220+
});
221+
saveFilesToStorage(serialized).catch(err => {
222+
if (err && (err.name === 'QuotaExceededError' || err.code === 22)) {
171223
savingDisabledRef.current = true;
172-
console.warn('LocalStorage quota exceeded; uploaded files will not be persisted.');
173-
localStorage.removeItem('uploadedFiles');
224+
console.warn('Storage quota exceeded; uploaded files will not be persisted.');
225+
toastRef.current.warning(t('toast.storageQuotaExceeded'));
174226
} else {
175-
throw err;
227+
console.warn('Failed to persist uploaded files', err);
176228
}
177-
}
178-
}, [uploadedFiles]);
229+
});
230+
}, [uploadedFiles, t]);
179231

180232
const handleFilesUploaded = useCallback((files) => {
181233
const filesWithDefaults = files.map(file => ({
182234
...file,
183235
enabled: true,
184236
metricsData: {}, // Initialize empty
185237
isParsing: true, // Mark as parsing
238+
progress: 0,
186239
config: {
187240
// Use global parsing config as default values
188241
metrics: globalParsingConfig.metrics.map(m => ({ ...m })),
@@ -273,7 +326,7 @@ function App() {
273326
}
274327
});
275328
}
276-
return { ...file, config, isParsing: true };
329+
return { ...file, config, isParsing: true, progress: 0 };
277330
}
278331
return file;
279332
}));
@@ -320,7 +373,8 @@ function App() {
320373
return {
321374
...file,
322375
config: newFileConfig,
323-
isParsing: true
376+
isParsing: true,
377+
progress: 0
324378
};
325379
}
326380
return file;
@@ -333,8 +387,10 @@ function App() {
333387
const handleResetConfig = useCallback(() => {
334388
savingDisabledRef.current = true;
335389
localStorage.removeItem('globalParsingConfig');
336-
localStorage.removeItem('uploadedFiles');
390+
localStorage.removeItem('chartConfig');
391+
clearFilesInStorage();
337392
setGlobalParsingConfig(JSON.parse(JSON.stringify(DEFAULT_GLOBAL_PARSING_CONFIG)));
393+
setChartConfig({ ...DEFAULT_CHART_CONFIG });
338394
setUploadedFiles([]);
339395
setTimeout(() => {
340396
savingDisabledRef.current = false;
@@ -573,6 +629,44 @@ function App() {
573629
<p className="text-xs text-gray-500 dark:text-gray-400">{t('display.chartDesc')}</p>
574630
</div>
575631

632+
<div className="border-t border-gray-200 dark:border-gray-700 pt-3">
633+
<h4 className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">{t('display.performance')}</h4>
634+
<label className="flex items-center text-xs text-gray-700 dark:text-gray-300 mb-2">
635+
<input
636+
type="checkbox"
637+
className="mr-2 checkbox"
638+
checked={chartConfig.downsampleEnabled}
639+
onChange={(e) => setChartConfig(prev => ({ ...prev, downsampleEnabled: e.target.checked }))}
640+
/>
641+
{t('display.downsample')}
642+
</label>
643+
{chartConfig.downsampleEnabled && (
644+
<div>
645+
<label
646+
htmlFor="downsample-threshold"
647+
className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1"
648+
>
649+
{t('display.downsampleThreshold')}
650+
</label>
651+
<input
652+
id="downsample-threshold"
653+
type="number"
654+
min="100"
655+
step="100"
656+
value={chartConfig.downsampleThreshold}
657+
onChange={(e) => {
658+
const v = parseInt(e.target.value, 10);
659+
if (Number.isFinite(v) && v >= 100) {
660+
setChartConfig(prev => ({ ...prev, downsampleThreshold: v }));
661+
}
662+
}}
663+
className="input-field"
664+
/>
665+
</div>
666+
)}
667+
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{t('display.downsampleDesc')}</p>
668+
</div>
669+
576670
<div className="border-t border-gray-200 dark:border-gray-700 pt-3">
577671
<h4 className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">{t('display.baseline')}</h4>
578672
<div className="space-y-3">
@@ -649,6 +743,8 @@ function App() {
649743
onXRangeChange={setXRange}
650744
yRange={yRange}
651745
onMaxStepChange={setMaxStep}
746+
downsampleEnabled={chartConfig.downsampleEnabled}
747+
downsampleThreshold={chartConfig.downsampleThreshold}
652748
/>
653749
</section>
654750
</main>

0 commit comments

Comments
 (0)