js-ux-toast-error-reasons
Root cause (DSD-GAP-018): both the per-file error toast and the final summary toast were written to the same #toast container, so the summary (Done: 0 succeeded, 1 failed) clobbered the per-file message. Users saw counts but never why a file failed.
Fix (4 parts, implemented in src/uploader.js):
handleMultipleUpload collects failReasons as `${file.name}: ${e.message}` per failing file.warn: true when any file failed — the detail can no longer be overwritten/lost._processSingleFile throws a specific, actionable error (No amount-*/cost-* CSV found in archive) instead of a generic unzip failure.textContent/dataset into #toast) so end-to-end tests can assert exactly what the user sees.// handleMultipleUpload — key changed lines
const failReasons = [];
for (const file of list) {
try {
results.push({ file: file.name, ok: true, data: await _processSingleFile(file) });
} catch (e) {
const reason = `${file.name}: ${e.message}`; // (1) collect reason
failReasons.push(reason);
results.push({ file: file.name, ok: false, error: e.message });
toast({ type: "error", message: truncateReason(reason, reasonLimit), warn: true });
}
}
const okCount = results.filter(r => r.ok).length;
const failCount = list.length - okCount;
let summary = `Done: ${okCount} succeeded, ${failCount} failed`;
if (failReasons.length) summary += " — " + truncateReason(failReasons.join("; "), reasonLimit);
toast({ type: failCount ? "error" : "success",
message: truncateReason(summary, reasonLimit), // (2) counts + reasons, ≤200 chars
warn: failCount > 0 });
return { ok: failCount === 0, results, failReasons };
// _processSingleFile — specific error (3)
if (!amountCsv && !costCsv) {
throw new Error("No amount-*/cost-* CSV found in archive");
}
// truncateReason (shared helper)
export function truncateReason(text, limit = 200) {
if (!text) return "";
return text.length <= limit ? text : text.slice(0, limit - 3).trimEnd() + "...";
}
Files: ~/src/uploader.js (fix), ~/test/uploader.test.js (e2e suite, node:test + jsdom + real JSZip).
Verified end-to-end in **jsdom with real JSZip-generated synthetic ZIPs** (built via `JSZip.generateAsync({type:'uint8array'})`, wrapped in real `File`), asserting the actual DOM `textContent` of `#toast`.
Rendered final toast for a CSV-less upload:
```
"Done: 0 succeeded, 1 failed — august-report.zip: No amount-*/cost-* CSV found in archive"
type: error | warn: true
```
**8/8 tests pass** (`npm test`):
1. `_processSingleFile` rejects CSV-less archive with the specific error
2. `_processSingleFile` parses rows from valid `amount-2024.csv`/`cost-2024.csv`
3. Single failed upload → toast contains **counts AND the reason** (the DSD-GAP regression)
4. Per-file error toast fires with the specific reason (2 toast writes, nothing lost)
5. Mixed valid+invalid → `Done: 1 succeeded, 1 failed` + only the failing file's reason
6. All valid → `Done: 2 succeeded, 0 failed`, `warn: "false"`
7. Truncation: 252-char reason → whole summary capped at 200 chars with `...`, `warn: "true"`
8. `truncateReason` unit: passthrough, exact-200 boundary kept, empty-safe
**Edge cases handled:** CSV-less ZIP (bad), valid amount/cost ZIPs (good), mixed batches, >200-char reason overflow, per-file vs summary toast ordering, jsdom `File` lacking `.arrayBuffer()` (FileReader fallback so JSZip can read it in any realm).{"model": "deepseek-v4-flash", "problem_class": "js-ux-toast-error-reasons", "result": "passed", "tests": 8}Root cause (DSD-GAP-018): both the per-file error toast and the final summary toast were written to the same #toast container, so the summary (Done: 0 succeeded, 1 failed) clobbered the per-file message. Users saw counts but never why a file failed.
Fix (4 parts, implemented in src/uploader.js):
handleMultipleUpload collects failReasons as `${file.name}: ${e.message}` per failing file.warn: true when any file failed — the detail can no longer be overwritten/lost._processSingleFile throws a specific, actionable error (No amount-*/cost-* CSV found in archive) instead of a generic unzip failure.textContent/dataset into #toast) so end-to-end tests can assert exactly what the user sees.// handleMultipleUpload — key changed lines
const failReasons = [];
for (const file of list) {
try {
results.push({ file: file.name, ok: true, data: await _processSingleFile(file) });
} catch (e) {
const reason = `${file.name}: ${e.message}`; // (1) collect reason
failReasons.push(reason);
results.push({ file: file.name, ok: false, error: e.message });
toast({ type: "error", message: truncateReason(reason, reasonLimit), warn: true });
}
}
const okCount = results.filter(r => r.ok).length;
const failCount = list.length - okCount;
let summary = `Done: ${okCount} succeeded, ${failCount} failed`;
if (failReasons.length) summary += " — " + truncateReason(failReasons.join("; "), reasonLimit);
toast({ type: failCount ? "error" : "success",
message: truncateReason(summary, reasonLimit), // (2) counts + reasons, ≤200 chars
warn: failCount > 0 });
return { ok: failCount === 0, results, failReasons };
// _processSingleFile — specific error (3)
if (!amountCsv && !costCsv) {
throw new Error("No amount-*/cost-* CSV found in archive");
}
// truncateReason (shared helper)
export function truncateReason(text, limit = 200) {
if (!text) return "";
return text.length <= limit ? text : text.slice(0, limit - 3).trimEnd() + "...";
}
Files: ~/src/uploader.js (fix), ~/test/uploader.test.js (e2e suite, node:test + jsdom + real JSZip).
Verified end-to-end in **jsdom with real JSZip-generated synthetic ZIPs** (built via `JSZip.generateAsync({type:'uint8array'})`, wrapped in real `File`), asserting the actual DOM `textContent` of `#toast`.
Rendered final toast for a CSV-less upload:
```
"Done: 0 succeeded, 1 failed — august-report.zip: No amount-*/cost-* CSV found in archive"
type: error | warn: true
```
**8/8 tests pass** (`npm test`):
1. `_processSingleFile` rejects CSV-less archive with the specific error
2. `_processSingleFile` parses rows from valid `amount-2024.csv`/`cost-2024.csv`
3. Single failed upload → toast contains **counts AND the reason** (the DSD-GAP regression)
4. Per-file error toast fires with the specific reason (2 toast writes, nothing lost)
5. Mixed valid+invalid → `Done: 1 succeeded, 1 failed` + only the failing file's reason
6. All valid → `Done: 2 succeeded, 0 failed`, `warn: "false"`
7. Truncation: 252-char reason → whole summary capped at 200 chars with `...`, `warn: "true"`
8. `truncateReason` unit: passthrough, exact-200 boundary kept, empty-safe
**Edge cases handled:** CSV-less ZIP (bad), valid amount/cost ZIPs (good), mixed batches, >200-char reason overflow, per-file vs summary toast ordering, jsdom `File` lacking `.arrayBuffer()` (FileReader fallback so JSZip can read it in any realm).{"model": "deepseek-v4-flash", "problem_class": "js-ux-toast-error-reasons", "result": "passed", "tests": 8}