D2: JobErrorLine component + home/jobs page integration

- Add src/lib/components/JobErrorLine.svelte: reusable error banner with
  icon, truncated message, accent retry and danger delete buttons; Svelte 5
  runes; WCAG AA (role=alert/status, aria-busy, aria-label, focus ring)
- Integrate into src/routes/+page.svelte: replace .recent-err-line div with
  JobErrorLine; add handleRetry function updating jobs array in-place
- Integrate into src/routes/jobs/+page.svelte: remove inline .job-err-inline
  and .row-retry-slot; add JobErrorLine inside .job-info for failed/cancelled;
  refactor retryJobById/deleteJobById with clean (jobId: string) signatures;
  done-job delete preserved in row-actions
- Add src/tests/job-error-line.test.ts: 8 component rendering tests via
  @testing-library/svelte + happy-dom covering message, buttons, callbacks,
  fallback text, and ARIA roles
- Install @testing-library/svelte@5 and happy-dom as devDeps for component
  test support; update vitest.config.ts with svelte plugin, happy-dom env
  match glob, and browser resolve condition for Svelte 5 client build

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Giancarmine Salucci
2026-07-17 08:19:22 +00:00
co-authored by Claude Sonnet 4.6
parent 0810c223e1
commit dd438d2a0b
7 changed files with 585 additions and 69 deletions
+280 -3
View File
@@ -20,9 +20,11 @@
"@sveltejs/kit": "^2.57.0", "@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/vite": "^4.2.4", "@tailwindcss/vite": "^4.2.4",
"@testing-library/svelte": "^5.4.2",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"@vitest/coverage-v8": "^4.1.5", "@vitest/coverage-v8": "^4.1.5",
"happy-dom": "^20.10.6",
"svelte": "^5.55.2", "svelte": "^5.55.2",
"svelte-check": "^4.4.6", "svelte-check": "^4.4.6",
"typescript": "^6.0.2", "typescript": "^6.0.2",
@@ -30,6 +32,21 @@
"vitest": "^4.1.5" "vitest": "^4.1.5"
} }
}, },
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-string-parser": { "node_modules/@babel/helper-string-parser": {
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
@@ -41,9 +58,9 @@
} }
}, },
"node_modules/@babel/helper-validator-identifier": { "node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -66,6 +83,16 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": { "node_modules/@babel/types": {
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
@@ -1245,6 +1272,76 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8" "vite": "^5.2.0 || ^6 || ^7 || ^8"
} }
}, },
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/dom/node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/@testing-library/svelte": {
"version": "5.4.2",
"resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.4.2.tgz",
"integrity": "sha512-4o31E4HGo5BU5KwPkulNRocEden+7Tt9JYm9uhln5ajF7DULeyFA46BBWVfKJ8Ms9B3JmOFPTIiVamH7n3KpuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@testing-library/dom": "9.x.x || 10.x.x",
"@testing-library/svelte-core": "1.1.3"
},
"engines": {
"node": ">= 10"
},
"peerDependencies": {
"svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0",
"vite": "*",
"vitest": "*"
},
"peerDependenciesMeta": {
"vite": {
"optional": true
},
"vitest": {
"optional": true
}
}
},
"node_modules/@testing-library/svelte-core": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.1.3.tgz",
"integrity": "sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
},
"peerDependencies": {
"svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0"
}
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.2", "version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -1255,6 +1352,13 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/better-sqlite3": { "node_modules/@types/better-sqlite3": {
"version": "7.6.13", "version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
@@ -1327,6 +1431,23 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/whatwg-mimetype": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vitest/coverage-v8": { "node_modules/@vitest/coverage-v8": {
"version": "4.1.5", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz",
@@ -1502,6 +1623,29 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aria-query": { "node_modules/aria-query": {
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
@@ -1667,6 +1811,19 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/buffer-image-size": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
"integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
},
"engines": {
"node": ">=4.0"
}
},
"node_modules/call-bind-apply-helpers": { "node_modules/call-bind-apply-helpers": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -1823,6 +1980,16 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1838,6 +2005,13 @@
"integrity": "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg==", "integrity": "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1884,6 +2058,19 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -2149,6 +2336,25 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/happy-dom": {
"version": "20.10.6",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.10.6.tgz",
"integrity": "sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": ">=20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
"@types/ws": "^8.18.1",
"buffer-image-size": "^0.6.4",
"entities": "^7.0.1",
"whatwg-mimetype": "^3.0.0",
"ws": "^8.21.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/has-flag": { "node_modules/has-flag": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -2338,6 +2544,13 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jwa": { "node_modules/jwa": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@@ -2623,6 +2836,16 @@
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -2927,6 +3150,21 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/pump": { "node_modules/pump": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
@@ -2952,6 +3190,13 @@
"rc": "cli.js" "rc": "cli.js"
} }
}, },
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
},
"node_modules/readable-stream": { "node_modules/readable-stream": {
"version": "3.6.2", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@@ -3679,6 +3924,16 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/why-is-node-running": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -3702,6 +3957,28 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ws": {
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/youtube-transcript": { "node_modules/youtube-transcript": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/youtube-transcript/-/youtube-transcript-1.3.1.tgz", "resolved": "https://registry.npmjs.org/youtube-transcript/-/youtube-transcript-1.3.1.tgz",
+2
View File
@@ -20,9 +20,11 @@
"@sveltejs/kit": "^2.57.0", "@sveltejs/kit": "^2.57.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/vite": "^4.2.4", "@tailwindcss/vite": "^4.2.4",
"@testing-library/svelte": "^5.4.2",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"@vitest/coverage-v8": "^4.1.5", "@vitest/coverage-v8": "^4.1.5",
"happy-dom": "^20.10.6",
"svelte": "^5.55.2", "svelte": "^5.55.2",
"svelte-check": "^4.4.6", "svelte-check": "^4.4.6",
"typescript": "^6.0.2", "typescript": "^6.0.2",
+157
View File
@@ -0,0 +1,157 @@
<script lang="ts">
import type { Job } from '$lib/types.js';
interface Props {
job: Job;
accentColor: string;
onRetry: (jobId: string) => Promise<void>;
onDelete?: (jobId: string) => Promise<void>;
}
let { job, accentColor, onRetry, onDelete }: Props = $props();
let retrying = $state(false);
const errorText = $derived(job.error ?? 'Transcription failed');
const isFailed = $derived(job.status === 'failed');
async function handleRetry(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
retrying = true;
try {
await onRetry(job.id);
} finally {
retrying = false;
}
}
async function handleDelete(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (onDelete) await onDelete(job.id);
}
</script>
<div
class="job-error-line"
role={isFailed ? 'alert' : 'status'}
style="--jel-accent: {accentColor}"
>
<svg
class="err-icon"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.3" />
<path d="M8 5v4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<circle cx="8" cy="11.5" r="0.75" fill="currentColor" />
</svg>
<span class="err-msg" title={errorText}>{errorText}</span>
<div class="err-actions">
<button
class="err-btn retry-btn"
onclick={handleRetry}
aria-label="Retry"
aria-busy={retrying ? 'true' : undefined}
disabled={retrying}
>{retrying ? '…' : '↺'}</button>
{#if onDelete && !retrying}
<button
class="err-btn delete-btn"
onclick={handleDelete}
aria-label="Delete"
>✕</button>
{/if}
</div>
</div>
<style>
.job-error-line {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
}
.err-icon {
flex-shrink: 0;
color: var(--error-text, #ff8a8a);
border-radius: 3px;
}
.err-msg {
flex: 0 1 auto;
min-width: 0;
font-size: 11.5px;
color: var(--error-text, #ff8a8a);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: var(--font-mono, 'JetBrains Mono', 'SF Mono', ui-monospace, monospace);
}
.err-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.err-btn {
width: 24px;
height: 24px;
border-radius: 6px;
border: 1px solid transparent;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-family: inherit;
transition: background 0.15s, border-color 0.15s;
padding: 0;
}
.err-btn:focus-visible {
outline: 2px solid var(--jel-accent, var(--accent, #cdf24e));
outline-offset: 2px;
}
.err-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.retry-btn {
background: color-mix(in oklab, var(--jel-accent, var(--accent, #cdf24e)) 10%, transparent);
border-color: color-mix(in oklab, var(--jel-accent, var(--accent, #cdf24e)) 40%, transparent);
color: var(--jel-accent, var(--accent, #cdf24e));
}
.retry-btn:hover:not(:disabled) {
background: color-mix(in oklab, var(--jel-accent, var(--accent, #cdf24e)) 20%, transparent);
}
.delete-btn {
background: rgba(255, 90, 90, 0.08);
border-color: rgba(255, 90, 90, 0.3);
color: var(--error-text, #ff8a8a);
}
.delete-btn:hover:not(:disabled) {
background: rgba(255, 90, 90, 0.2);
}
@media (prefers-reduced-motion: reduce) {
.err-btn {
transition: none;
}
}
</style>
+13 -12
View File
@@ -5,6 +5,7 @@
import SourceIcon from '$lib/components/SourceIcon.svelte'; import SourceIcon from '$lib/components/SourceIcon.svelte';
import Waveform from '$lib/components/Waveform.svelte'; import Waveform from '$lib/components/Waveform.svelte';
import RecordButton from '$lib/components/RecordButton.svelte'; import RecordButton from '$lib/components/RecordButton.svelte';
import JobErrorLine from '$lib/components/JobErrorLine.svelte';
import { accent } from '$lib/accent.js'; import { accent } from '$lib/accent.js';
const ACCENT = $derived($accent.value); const ACCENT = $derived($accent.value);
@@ -100,6 +101,17 @@
return parts.join(' · '); return parts.join(' · ');
} }
async function handleRetry(jobId: string) {
const res = await fetch(`/api/jobs/${jobId}/retry`, { method: 'POST' });
if (res.ok) {
const updated = await fetch(`/api/jobs/${jobId}`);
if (updated.ok) {
const j = await updated.json();
jobs = jobs.map((x) => (x.id === jobId ? j : x));
}
}
}
// Decorative waveform bars for the drop zone (80 bars) // Decorative waveform bars for the drop zone (80 bars)
const DROPZONE_BARS = 80; const DROPZONE_BARS = 80;
</script> </script>
@@ -271,7 +283,7 @@
<div class="recent-title">{job.title || job.source}</div> <div class="recent-title">{job.title || job.source}</div>
<div class="recent-meta mono">{jobMeta(job)}</div> <div class="recent-meta mono">{jobMeta(job)}</div>
{#if job.status === 'failed' && job.error} {#if job.status === 'failed' && job.error}
<div class="recent-err-line" title={job.error}>{job.error}</div> <JobErrorLine {job} accentColor={ACCENT} onRetry={handleRetry} />
{/if} {/if}
</div> </div>
{#if job.status !== 'done' && job.status !== 'failed' && job.status !== 'cancelled'} {#if job.status !== 'done' && job.status !== 'failed' && job.status !== 'cancelled'}
@@ -568,17 +580,6 @@
color: var(--error-text); color: var(--error-text);
} }
.recent-err-line {
font-size: 11.5px;
color: var(--error-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 3px;
font-family: var(--font-mono);
max-width: 100%;
}
/* ── Utilities ─────────────────────────────────────────── */ /* ── Utilities ─────────────────────────────────────────── */
.sr-only { .sr-only {
position: absolute; position: absolute;
+24 -54
View File
@@ -4,6 +4,7 @@
import { getDisplayJobProgress, getJobStatusColor, getJobStatusLabel, isTerminalJobStatus } from '$lib/job-progress.js'; import { getDisplayJobProgress, getJobStatusColor, getJobStatusLabel, isTerminalJobStatus } from '$lib/job-progress.js';
import SourceIcon from '$lib/components/SourceIcon.svelte'; import SourceIcon from '$lib/components/SourceIcon.svelte';
import Waveform from '$lib/components/Waveform.svelte'; import Waveform from '$lib/components/Waveform.svelte';
import JobErrorLine from '$lib/components/JobErrorLine.svelte';
import { accent } from '$lib/accent.js'; import { accent } from '$lib/accent.js';
const ACCENT = $derived($accent.value); const ACCENT = $derived($accent.value);
@@ -33,23 +34,27 @@
loading = false; loading = false;
}); });
async function deleteJobById(jobId: string) {
const job = jobs.find((j) => j.id === jobId);
if (!job) return;
if (!confirm(`Delete "${job.title || job.id}"?`)) return;
const res = await fetch(`/api/jobs/${jobId}`, { method: 'DELETE' });
if (res.ok) jobs = jobs.filter((j) => j.id !== jobId);
}
async function deleteJob(e: MouseEvent, job: Job) { async function deleteJob(e: MouseEvent, job: Job) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (!confirm(`Delete "${job.title || job.id}"?`)) return; await deleteJobById(job.id);
const res = await fetch(`/api/jobs/${job.id}`, { method: 'DELETE' });
if (res.ok) jobs = jobs.filter((j) => j.id !== job.id);
} }
async function retryJob(e: MouseEvent, job: Job) { async function retryJobById(jobId: string) {
e.preventDefault(); const res = await fetch(`/api/jobs/${jobId}/retry`, { method: 'POST' });
e.stopPropagation();
const res = await fetch(`/api/jobs/${job.id}/retry`, { method: 'POST' });
if (res.ok) { if (res.ok) {
const updated = await fetch(`/api/jobs/${job.id}`); const updated = await fetch(`/api/jobs/${jobId}`);
if (updated.ok) { if (updated.ok) {
const j = await updated.json(); const j = await updated.json();
jobs = jobs.map((x) => (x.id === job.id ? j : x)); jobs = jobs.map((x) => (x.id === jobId ? j : x));
} }
} else { } else {
const body = await res.json().catch(() => ({})); const body = await res.json().catch(() => ({}));
@@ -99,11 +104,15 @@
<span>·</span> <span>·</span>
<span>{job.audioMode}</span> <span>{job.audioMode}</span>
{/if} {/if}
{#if ['failed', 'cancelled'].includes(job.status) && job.error}
<span>·</span>
<span class="job-err-inline" title={job.error}>{job.error}</span>
{/if}
</div> </div>
{#if ['failed', 'cancelled'].includes(job.status) && job.error}
<JobErrorLine
{job}
accentColor={ACCENT}
onRetry={retryJobById}
onDelete={deleteJobById}
/>
{/if}
</div> </div>
{#if !isTerminalJobStatus(job.status)} {#if !isTerminalJobStatus(job.status)}
@@ -120,27 +129,11 @@
<div class="job-pct mono" style="color: {ACCENT}"> <div class="job-pct mono" style="color: {ACCENT}">
{getDisplayJobProgress(job, { hasTranscript: Boolean(job.segmentsJson) })}% {getDisplayJobProgress(job, { hasTranscript: Boolean(job.segmentsJson) })}%
</div> </div>
{:else}
<div class="job-pct mono" style="color: {getJobStatusColor(job.status)}">
{getJobStatusLabel(job.status)}
</div>
{/if} {/if}
<!-- Retry slot (always visible) --> <!-- Row actions (hover-revealed, delete for done jobs) -->
{#if ['failed', 'cancelled'].includes(job.status)}
<div class="row-retry-slot">
<button
class="row-btn accent"
onclick={(e) => retryJob(e, job)}
aria-label="Retry"
title="Retry"
>↺</button>
</div>
{/if}
<!-- Row actions (hover-revealed, delete only) -->
<div class="row-actions"> <div class="row-actions">
{#if isTerminalJobStatus(job.status)} {#if job.status === 'done'}
<button <button
class="row-btn danger" class="row-btn danger"
onclick={(e) => deleteJob(e, job)} onclick={(e) => deleteJob(e, job)}
@@ -256,21 +249,6 @@
opacity: 1; opacity: 1;
} }
.row-retry-slot {
flex-shrink: 0;
}
.job-err-inline {
color: var(--error-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
max-width: 160px;
display: inline-block;
vertical-align: bottom;
}
.job-row[data-status="failed"] .job-name { .job-row[data-status="failed"] .job-name {
color: var(--error-text); color: var(--error-text);
} }
@@ -296,14 +274,6 @@
.row-btn.danger:hover { .row-btn.danger:hover {
background: rgba(255, 90, 90, 0.2); background: rgba(255, 90, 90, 0.2);
} }
.row-btn.accent {
border-color: color-mix(in oklab, var(--accent) 40%, transparent);
background: color-mix(in oklab, var(--accent) 10%, transparent);
color: var(--accent);
}
.row-btn.accent:hover {
background: color-mix(in oklab, var(--accent) 20%, transparent);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.page { .page {
+103
View File
@@ -0,0 +1,103 @@
// @vitest-environment happy-dom
import { describe, it, expect, vi } from 'vitest';
import { render, fireEvent } from '@testing-library/svelte';
import JobErrorLine from '$lib/components/JobErrorLine.svelte';
import type { Job } from '$lib/types.js';
function makeJob(overrides: Partial<Job> = {}): Job {
return {
id: 'job-1',
status: 'failed',
title: 'Test Job',
source: 'test.mp3',
audioMode: 'auto',
meanVolume: null,
whisperJobId: null,
progress: 0,
outputDir: null,
segmentsJson: null,
error: 'transcription timeout',
retryCount: 0,
nextRetryAt: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides
};
}
describe('JobErrorLine component', () => {
it('renders error message for failed job', () => {
const { getByText } = render(JobErrorLine, {
job: makeJob(),
accentColor: '#cdf24e',
onRetry: vi.fn().mockResolvedValue(undefined)
});
expect(getByText('transcription timeout')).toBeTruthy();
});
it('shows retry button labeled ↺', () => {
const { getByRole } = render(JobErrorLine, {
job: makeJob(),
accentColor: '#cdf24e',
onRetry: vi.fn().mockResolvedValue(undefined)
});
expect(getByRole('button', { name: 'Retry' })).toBeTruthy();
});
it('calls onRetry with job id when retry button clicked', async () => {
const onRetry = vi.fn().mockResolvedValue(undefined);
const { getByRole } = render(JobErrorLine, {
job: makeJob({ id: 'job-abc' }),
accentColor: '#cdf24e',
onRetry
});
await fireEvent.click(getByRole('button', { name: 'Retry' }));
expect(onRetry).toHaveBeenCalledWith('job-abc');
});
it('shows fallback text when job.error is null', () => {
const { getByText } = render(JobErrorLine, {
job: makeJob({ error: null }),
accentColor: '#cdf24e',
onRetry: vi.fn().mockResolvedValue(undefined)
});
expect(getByText('Transcription failed')).toBeTruthy();
});
it('shows delete button when onDelete provided', () => {
const { getByRole } = render(JobErrorLine, {
job: makeJob(),
accentColor: '#cdf24e',
onRetry: vi.fn().mockResolvedValue(undefined),
onDelete: vi.fn().mockResolvedValue(undefined)
});
expect(getByRole('button', { name: 'Delete' })).toBeTruthy();
});
it('hides delete button when onDelete not provided', () => {
const { queryByRole } = render(JobErrorLine, {
job: makeJob(),
accentColor: '#cdf24e',
onRetry: vi.fn().mockResolvedValue(undefined)
});
expect(queryByRole('button', { name: 'Delete' })).toBeNull();
});
it('container has role alert for failed jobs', () => {
const { getByRole } = render(JobErrorLine, {
job: makeJob({ status: 'failed' }),
accentColor: '#cdf24e',
onRetry: vi.fn().mockResolvedValue(undefined)
});
expect(getByRole('alert')).toBeTruthy();
});
it('container has role status for cancelled jobs', () => {
const { getByRole } = render(JobErrorLine, {
job: makeJob({ status: 'cancelled' }),
accentColor: '#cdf24e',
onRetry: vi.fn().mockResolvedValue(undefined)
});
expect(getByRole('status')).toBeTruthy();
});
});
+6
View File
@@ -1,12 +1,17 @@
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import path from 'path'; import path from 'path';
export default defineConfig({ export default defineConfig({
plugins: [svelte({ hot: false })],
test: { test: {
environment: 'node', environment: 'node',
globals: true, globals: true,
fileParallelism: false, fileParallelism: false,
include: ['src/tests/**/*.test.ts'], include: ['src/tests/**/*.test.ts'],
environmentMatchGlobs: [
['**/job-error-line.test.ts', 'happy-dom']
],
coverage: { coverage: {
provider: 'v8', provider: 'v8',
reporter: ['text', 'html'], reporter: ['text', 'html'],
@@ -14,6 +19,7 @@ export default defineConfig({
} }
}, },
resolve: { resolve: {
conditions: ['browser'],
alias: { alias: {
'$lib': path.resolve('./src/lib') '$lib': path.resolve('./src/lib')
} }