Starting work on Dashboard! (#65)

* initial commit

* fix build issues and configurable time duration

* show in nav

* Positive response rate by posting freshness

* load today's jobs for charts

* fix infinite refetching with onboarding gate

* application to response rate

* refactor charts to their own directory

* bar hover color

* Duration selector embedded in navbar

* always load env

* remove warning about low conversion rate

* trend graph for applications per day

* better copy

* remove freshness response chart

* bottom line chart color and tooltip improved

* introduce check all command

* fix lint

* tests added and CI passing
This commit is contained in:
Shaheer Sarfaraz 2026-01-30 11:40:17 +00:00 committed by GitHub
parent 0e27dbe52b
commit 3be0d25c87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 2777 additions and 261 deletions

View File

@ -21,6 +21,10 @@ services:
# Python path (uses system python in container)
- PYTHON_PATH=/usr/bin/python3
env_file:
- path: ./.env
required: false
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001/health"]

View File

@ -39,6 +39,7 @@
"react-hook-form": "^7.71.1",
"react-markdown": "^10.1.0",
"react-transition-group": "^4.4.5",
"recharts": "^2.12.5",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
@ -3573,6 +3574,69 @@
"@types/node": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@ -4660,6 +4724,127 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/data-urls": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
@ -4696,6 +4881,12 @@
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/decode-named-character-reference": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
@ -5228,6 +5419,12 @@
"node": ">= 0.6"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@ -5312,6 +5509,15 @@
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@ -5875,6 +6081,15 @@
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -6324,6 +6539,12 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@ -8001,6 +8222,21 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"license": "MIT",
"dependencies": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@ -8077,6 +8313,44 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts-scale": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
"license": "MIT",
"dependencies": {
"decimal.js-light": "^2.4.1"
}
},
"node_modules/recharts/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@ -8874,6 +9148,12 @@
"node": ">=6"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@ -9744,6 +10024,28 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",

View File

@ -8,6 +8,7 @@
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
"dev:server": "tsx watch src/server/index.ts",
"dev:client": "vite --host",
"check:all": "npm run check:types && npm run check:fix && npm run format:fix",
"ci": "biome ci",
"check": "biome check",
"check:fix": "biome check --write",
@ -56,6 +57,7 @@
"next-themes": "^0.4.6",
"react-hook-form": "^7.71.1",
"react-markdown": "^10.1.0",
"recharts": "^2.12.5",
"react-transition-group": "^4.4.5",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",

View File

@ -8,6 +8,7 @@ import { CSSTransition, SwitchTransition } from "react-transition-group";
import { Toaster } from "@/components/ui/sonner";
import { OnboardingGate } from "./components/OnboardingGate";
import { HomePage } from "./pages/HomePage";
import { JobPage } from "./pages/JobPage";
import { OrchestratorPage } from "./pages/OrchestratorPage";
import { SettingsPage } from "./pages/SettingsPage";
@ -41,6 +42,7 @@ export const App: React.FC = () => {
<div ref={nodeRef}>
<Routes location={location}>
<Route path="/" element={<Navigate to="/ready" replace />} />
<Route path="/home" element={<HomePage />} />
<Route path="/job/:id" element={<JobPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/ukvisajobs" element={<UkVisaJobsPage />} />

View File

@ -2,20 +2,10 @@
* Header component with logo and pipeline trigger.
*/
import {
Briefcase,
ChevronDown,
Home,
Loader2,
Menu,
Play,
RefreshCcw,
Settings,
Shield,
} from "lucide-react";
import { isNavActive, NAV_LINKS } from "@client/components/navigation";
import { ChevronDown, Loader2, Menu, Play, RefreshCcw } from "lucide-react";
import React from "react";
import { Link, useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -33,7 +23,7 @@ import {
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { sourceLabel } from "@/lib/utils";
import { cn, sourceLabel } from "@/lib/utils";
import type { JobSource } from "../../shared/types";
interface HeaderProps {
@ -63,13 +53,6 @@ export const Header: React.FC<HeaderProps> = ({
"ukvisajobs",
];
const navLinks = [
{ to: "/", label: "Dashboard", icon: Home },
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
{ to: "/ukvisajobs", label: "UK Visa Jobs", icon: Briefcase },
{ to: "/settings", label: "Settings", icon: Settings },
];
const toggleSource = (source: JobSource, checked: boolean) => {
const next = checked
? Array.from(new Set([...pipelineSources, source]))
@ -95,16 +78,17 @@ export const Header: React.FC<HeaderProps> = ({
<SheetTitle>JobOps</SheetTitle>
</SheetHeader>
<nav className="mt-6 flex flex-col gap-2">
{navLinks.map(({ to, label, icon: Icon }) => (
{NAV_LINKS.map(({ to, label, icon: Icon, activePaths }) => (
<Link
key={to}
to={to}
onClick={() => setSheetOpen(false)}
className={`flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground ${
location.pathname === to
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground",
isNavActive(location.pathname, to, activePaths)
? "bg-accent text-accent-foreground"
: "text-muted-foreground"
}`}
: "text-muted-foreground",
)}
>
<Icon className="h-4 w-4" />
{label}

View File

@ -13,6 +13,7 @@ import type { ValidationResult } from "@shared/types";
import { Check } from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { toast } from "sonner";
import {
AlertDialog,
@ -42,12 +43,22 @@ import { cn } from "@/lib/utils";
type ValidationState = ValidationResult & { checked: boolean };
type OnboardingFormData = {
llmProvider: string;
llmBaseUrl: string;
llmApiKey: string;
rxresumeEmail: string;
rxresumePassword: string;
rxresumeBaseResumeId: string | null;
};
export const OnboardingGate: React.FC = () => {
const {
settings,
isLoading: settingsLoading,
refreshSettings,
} = useSettings();
const [isSavingEnv, setIsSavingEnv] = useState(false);
const [isValidatingLlm, setIsValidatingLlm] = useState(false);
const [isValidatingRxresume, setIsValidatingRxresume] = useState(false);
@ -72,54 +83,71 @@ export const OnboardingGate: React.FC = () => {
});
const [currentStep, setCurrentStep] = useState<string | null>(null);
const [llmProvider, setLlmProvider] = useState("");
const [llmBaseUrl, setLlmBaseUrl] = useState("");
const [llmApiKey, setLlmApiKey] = useState("");
const [rxresumeEmail, setRxresumeEmail] = useState("");
const [rxresumePassword, setRxresumePassword] = useState("");
const [rxresumeBaseResumeId, setRxresumeBaseResumeId] = useState<
string | null
>(null);
const { control, watch, getValues, reset, setValue } =
useForm<OnboardingFormData>({
defaultValues: {
llmProvider: "",
llmBaseUrl: "",
llmApiKey: "",
rxresumeEmail: "",
rxresumePassword: "",
rxresumeBaseResumeId: null,
},
});
const validateLlm = useCallback(
async (input: { provider?: string; baseUrl?: string; apiKey?: string }) => {
setIsValidatingLlm(true);
try {
const result = await api.validateLlm(input);
setLlmValidation({ ...result, checked: true });
return result;
} catch (error) {
const message =
error instanceof Error ? error.message : "LLM validation failed";
const result = { valid: false, message };
setLlmValidation({ ...result, checked: true });
return result;
} finally {
setIsValidatingLlm(false);
}
},
[],
);
const llmProvider = watch("llmProvider");
const validateRxresume = useCallback(
async (email?: string, password?: string) => {
setIsValidatingRxresume(true);
try {
const result = await api.validateRxresume(email, password);
setRxresumeValidation({ ...result, checked: true });
return result;
} catch (error) {
const message =
error instanceof Error ? error.message : "RxResume validation failed";
const result = { valid: false, message };
setRxresumeValidation({ ...result, checked: true });
return result;
} finally {
setIsValidatingRxresume(false);
}
},
[],
);
const validateLlm = useCallback(async () => {
const values = getValues();
const selectedProvider = normalizeLlmProvider(
values.llmProvider || settings?.llmProvider || "openrouter",
);
const providerConfig = getLlmProviderConfig(selectedProvider);
const { requiresApiKey } = providerConfig;
setIsValidatingLlm(true);
try {
const result = await api.validateLlm({
provider: selectedProvider,
baseUrl: values.llmBaseUrl.trim() || undefined,
apiKey: requiresApiKey
? values.llmApiKey.trim() || undefined
: undefined,
});
setLlmValidation({ ...result, checked: true });
return result;
} catch (error) {
const message =
error instanceof Error ? error.message : "LLM validation failed";
const result = { valid: false, message };
setLlmValidation({ ...result, checked: true });
return result;
} finally {
setIsValidatingLlm(false);
}
}, [getValues, settings?.llmProvider]);
const validateRxresume = useCallback(async () => {
const values = getValues();
setIsValidatingRxresume(true);
try {
const result = await api.validateRxresume(
values.rxresumeEmail.trim() || undefined,
values.rxresumePassword.trim() || undefined,
);
setRxresumeValidation({ ...result, checked: true });
return result;
} catch (error) {
const message =
error instanceof Error ? error.message : "RxResume validation failed";
const result = { valid: false, message };
setRxresumeValidation({ ...result, checked: true });
return result;
} finally {
setIsValidatingRxresume(false);
}
}, [getValues]);
const validateBaseResume = useCallback(async () => {
setIsValidatingBaseResume(true);
@ -150,6 +178,7 @@ export const OnboardingGate: React.FC = () => {
showBaseUrl,
requiresApiKey: requiresLlmKey,
} = providerConfig;
const llmKeyHint =
settings?.llmApiKeyHint ?? settings?.openrouterApiKeyHint ?? null;
const hasLlmKey = Boolean(llmKeyHint);
@ -170,26 +199,31 @@ export const OnboardingGate: React.FC = () => {
? settings.rxresumeEmail
: undefined;
const rxresumePasswordCurrent = settings?.rxresumePasswordHint
? formatSecretHint(settings.rxresumePasswordHint)
? formatSecretHint(settings?.rxresumePasswordHint)
: undefined;
// Initialize form values from settings
useEffect(() => {
if (settings) {
setRxresumeBaseResumeId(settings.rxresumeBaseResumeId || null);
if (!llmProvider && settings.llmProvider) {
setLlmProvider(settings.llmProvider);
}
if (!llmBaseUrl && settings.llmBaseUrl) {
setLlmBaseUrl(settings.llmBaseUrl);
}
reset({
llmProvider: settings.llmProvider || "",
llmBaseUrl: settings.llmBaseUrl || "",
llmApiKey: "",
rxresumeEmail: "",
rxresumePassword: "",
rxresumeBaseResumeId: settings.rxresumeBaseResumeId || null,
});
}
}, [llmBaseUrl, llmProvider, settings]);
}, [settings, reset]);
// Clear base URL when provider doesn't require it
useEffect(() => {
if (showBaseUrl) return;
if (llmBaseUrl) setLlmBaseUrl("");
}, [llmBaseUrl, showBaseUrl]);
if (!showBaseUrl) {
setValue("llmBaseUrl", "");
}
}, [showBaseUrl, setValue]);
// Reset LLM validation when provider changes
useEffect(() => {
if (!selectedProvider) return;
setLlmValidation({ valid: false, message: null, checked: false });
@ -235,13 +269,7 @@ export const OnboardingGate: React.FC = () => {
if (!settings) return;
const validations: Promise<ValidationResult>[] = [];
if (requiresLlmKey) {
validations.push(
validateLlm({
provider: normalizedProvider,
baseUrl: llmBaseUrl.trim() || undefined,
apiKey: llmApiKey.trim() || undefined,
}),
);
validations.push(validateLlm());
} else {
setLlmValidation({ valid: true, message: null, checked: true });
}
@ -262,11 +290,9 @@ export const OnboardingGate: React.FC = () => {
validateLlm,
validateRxresume,
validateBaseResume,
normalizedProvider,
llmBaseUrl,
llmApiKey,
]);
// Run validations on mount when needed
useEffect(() => {
if (!settings || settingsLoading) return;
const needsValidation =
@ -300,8 +326,9 @@ export const OnboardingGate: React.FC = () => {
};
const handleSaveLlm = async (): Promise<boolean> => {
const apiKeyValue = llmApiKey.trim();
const baseUrlValue = llmBaseUrl.trim();
const values = getValues();
const apiKeyValue = values.llmApiKey.trim();
const baseUrlValue = values.llmBaseUrl.trim();
if (requiresLlmKey && !apiKeyValue && !hasLlmKey) {
toast.info("Add your LLM API key to continue");
@ -310,11 +337,7 @@ export const OnboardingGate: React.FC = () => {
try {
const validation = requiresLlmKey
? await validateLlm({
provider: normalizedProvider,
baseUrl: baseUrlValue || undefined,
apiKey: apiKeyValue || undefined,
})
? await validateLlm()
: { valid: true, message: null };
if (!validation.valid) {
@ -338,7 +361,7 @@ export const OnboardingGate: React.FC = () => {
setIsSavingEnv(true);
await api.updateSettings(update);
await refreshSettings();
setLlmApiKey("");
setValue("llmApiKey", "");
toast.success("LLM provider connected");
return true;
} catch (error) {
@ -352,8 +375,9 @@ export const OnboardingGate: React.FC = () => {
};
const handleSaveRxresume = async (): Promise<boolean> => {
const emailValue = rxresumeEmail.trim();
const passwordValue = rxresumePassword.trim();
const values = getValues();
const emailValue = values.rxresumeEmail.trim();
const passwordValue = values.rxresumePassword.trim();
const missing: string[] = [];
if (!hasRxresumeEmail && !emailValue) missing.push("RxResume email");
@ -368,10 +392,7 @@ export const OnboardingGate: React.FC = () => {
}
try {
const validation = await validateRxresume(
emailValue || undefined,
passwordValue || undefined,
);
const validation = await validateRxresume();
if (!validation.valid) {
toast.error(validation.message || "RxResume validation failed");
return false;
@ -385,7 +406,7 @@ export const OnboardingGate: React.FC = () => {
setIsSavingEnv(true);
await api.updateSettings(update);
await refreshSettings();
setRxresumePassword("");
setValue("rxresumePassword", "");
}
toast.success("RxResume connected");
@ -403,14 +424,18 @@ export const OnboardingGate: React.FC = () => {
};
const handleSaveBaseResume = async (): Promise<boolean> => {
if (!rxresumeBaseResumeId) {
const values = getValues();
if (!values.rxresumeBaseResumeId) {
toast.info("Select a base resume to continue");
return false;
}
try {
setIsSavingEnv(true);
await api.updateSettings({ rxresumeBaseResumeId: rxresumeBaseResumeId });
await api.updateSettings({
rxresumeBaseResumeId: values.rxresumeBaseResumeId,
});
const validation = await validateBaseResume();
if (!validation.valid) {
toast.error(validation.message || "Base resume validation failed");
@ -492,7 +517,7 @@ export const OnboardingGate: React.FC = () => {
<AlertDialogHeader>
<AlertDialogTitle>Welcome to Job Ops</AlertDialogTitle>
<AlertDialogDescription>
Lets get your workspace ready. Add your keys and resume once,
Let's get your workspace ready. Add your keys and resume once,
then the pipeline can run end-to-end.
</AlertDialogDescription>
</AlertDialogHeader>
@ -559,53 +584,73 @@ export const OnboardingGate: React.FC = () => {
<label htmlFor="llmProvider" className="text-sm font-medium">
Provider
</label>
<Select
value={selectedProvider}
onValueChange={(value) => setLlmProvider(value)}
disabled={isSavingEnv}
>
<SelectTrigger id="llmProvider">
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{LLM_PROVIDERS.map((provider) => (
<SelectItem key={provider} value={provider}>
{LLM_PROVIDER_LABELS[provider]}
</SelectItem>
))}
</SelectContent>
</Select>
<Controller
name="llmProvider"
control={control}
render={({ field }) => (
<Select
value={selectedProvider}
onValueChange={(value) => {
field.onChange(value);
}}
disabled={isSavingEnv}
>
<SelectTrigger id="llmProvider">
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{LLM_PROVIDERS.map((provider) => (
<SelectItem key={provider} value={provider}>
{LLM_PROVIDER_LABELS[provider]}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<p className="text-xs text-muted-foreground">
{providerConfig.providerHint}
</p>
</div>
{showBaseUrl && (
<SettingsInput
label="LLM base URL"
inputProps={{
name: "llmBaseUrl",
value: llmBaseUrl,
onChange: (event) => setLlmBaseUrl(event.target.value),
}}
placeholder={providerConfig.baseUrlPlaceholder}
helper={providerConfig.baseUrlHelper}
current={settings?.llmBaseUrl || "—"}
disabled={isSavingEnv}
<Controller
name="llmBaseUrl"
control={control}
render={({ field }) => (
<SettingsInput
label="LLM base URL"
inputProps={{
name: "llmBaseUrl",
value: field.value,
onChange: field.onChange,
}}
placeholder={providerConfig.baseUrlPlaceholder}
helper={providerConfig.baseUrlHelper}
current={settings?.llmBaseUrl || "—"}
disabled={isSavingEnv}
/>
)}
/>
)}
{showApiKey && (
<SettingsInput
label="LLM API key"
inputProps={{
name: "llmApiKey",
value: llmApiKey,
onChange: (event) => setLlmApiKey(event.target.value),
}}
type="password"
placeholder="Enter key"
current={llmKeyCurrent}
helper={providerConfig.keyHelper}
disabled={isSavingEnv}
<Controller
name="llmApiKey"
control={control}
render={({ field }) => (
<SettingsInput
label="LLM API key"
inputProps={{
name: "llmApiKey",
value: field.value,
onChange: field.onChange,
}}
type="password"
placeholder="Enter key"
current={llmKeyCurrent}
helper={providerConfig.keyHelper}
disabled={isSavingEnv}
/>
)}
/>
)}
</div>
@ -621,29 +666,40 @@ export const OnboardingGate: React.FC = () => {
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<SettingsInput
label="Email"
inputProps={{
name: "rxresumeEmail",
value: rxresumeEmail,
onChange: (event) => setRxresumeEmail(event.target.value),
}}
placeholder="you@example.com"
current={rxresumeEmailCurrent}
disabled={isSavingEnv}
<Controller
name="rxresumeEmail"
control={control}
render={({ field }) => (
<SettingsInput
label="Email"
inputProps={{
name: "rxresumeEmail",
value: field.value,
onChange: field.onChange,
}}
placeholder="you@example.com"
current={rxresumeEmailCurrent}
disabled={isSavingEnv}
/>
)}
/>
<SettingsInput
label="Password"
inputProps={{
name: "rxresumePassword",
value: rxresumePassword,
onChange: (event) =>
setRxresumePassword(event.target.value),
}}
type="password"
placeholder="Enter password"
current={rxresumePasswordCurrent}
disabled={isSavingEnv}
<Controller
name="rxresumePassword"
control={control}
render={({ field }) => (
<SettingsInput
label="Password"
inputProps={{
name: "rxresumePassword",
value: field.value,
onChange: field.onChange,
}}
type="password"
placeholder="Enter password"
current={rxresumePasswordCurrent}
disabled={isSavingEnv}
/>
)}
/>
</div>
</TabsContent>
@ -658,11 +714,17 @@ export const OnboardingGate: React.FC = () => {
resume will be used as a template for tailoring.
</p>
</div>
<BaseResumeSelection
value={rxresumeBaseResumeId}
onValueChange={setRxresumeBaseResumeId}
hasRxResumeAccess={rxresumeValidation.valid}
disabled={isSavingEnv}
<Controller
name="rxresumeBaseResumeId"
control={control}
render={({ field }) => (
<BaseResumeSelection
value={field.value}
onValueChange={field.onChange}
hasRxResumeAccess={rxresumeValidation.valid}
disabled={isSavingEnv}
/>
)}
/>
</TabsContent>
</Tabs>

View File

@ -0,0 +1,347 @@
/**
* ApplicationsPerDayChart Edge Case Tests
* Tests real-world edge cases and data transformation logic
*/
import { render, screen } from "@testing-library/react";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ApplicationsPerDayChart } from "./ApplicationsPerDayChart";
// Mock UI components
vi.mock("@/components/ui/card", () => ({
Card: ({ children }: { children: React.ReactNode }) => (
<div data-testid="card">{children}</div>
),
CardContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="card-content">{children}</div>
),
CardHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="card-header">{children}</div>
),
CardTitle: ({ children }: { children: React.ReactNode }) => (
<div data-testid="card-title">{children}</div>
),
CardDescription: ({ children }: { children: React.ReactNode }) => (
<div data-testid="card-description">{children}</div>
),
}));
vi.mock("@/components/ui/chart", () => ({
ChartContainer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="chart-container">{children}</div>
),
ChartTooltip: () => <div data-testid="chart-tooltip">Tooltip</div>,
ChartTooltipContent: () => (
<div data-testid="chart-tooltip-content">TooltipContent</div>
),
}));
vi.mock("recharts", () => ({
BarChart: ({ children }: { children: React.ReactNode }) => (
<div data-testid="bar-chart">{children}</div>
),
Bar: () => <div data-testid="bar">Bar</div>,
CartesianGrid: () => <div data-testid="cartesian-grid">Grid</div>,
XAxis: () => <div data-testid="x-axis">XAxis</div>,
}));
vi.mock("lucide-react", () => ({
TrendingUp: () => <div data-testid="trending-up">TrendingUp</div>,
TrendingDown: () => <div data-testid="trending-down">TrendingDown</div>,
}));
describe("ApplicationsPerDayChart - Edge Cases", () => {
const mockDate = new Date("2025-01-15T12:00:00Z");
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(mockDate);
});
afterEach(() => {
vi.useRealTimers();
});
describe("Empty and Null Data", () => {
it("handles empty appliedAt array - shows zero total and average", () => {
render(
<ApplicationsPerDayChart
appliedAt={[]}
isLoading={false}
error={null}
daysToShow={7}
/>,
);
expect(screen.getByText("0.0")).toBeInTheDocument();
expect(screen.getByText(/Last 7 days · 0 total/)).toBeInTheDocument();
});
it("handles appliedAt with all null values - filters out nulls correctly", () => {
render(
<ApplicationsPerDayChart
appliedAt={[null, null, null]}
isLoading={false}
error={null}
daysToShow={7}
/>,
);
expect(screen.getByText("0.0")).toBeInTheDocument();
expect(screen.getByText(/Last 7 days · 0 total/)).toBeInTheDocument();
});
it("handles mixed null and valid dates - counts only valid dates", () => {
const today = mockDate.toISOString();
render(
<ApplicationsPerDayChart
appliedAt={[null, today, null, today, today]}
isLoading={false}
error={null}
daysToShow={7}
/>,
);
expect(screen.getByText(/Last 7 days · 3 total/)).toBeInTheDocument();
});
});
describe("Invalid Date Handling", () => {
it("filters out invalid date strings", () => {
const today = mockDate.toISOString();
render(
<ApplicationsPerDayChart
appliedAt={["invalid-date", today, "", "not-a-date", today]}
isLoading={false}
error={null}
daysToShow={7}
/>,
);
expect(screen.getByText(/Last 7 days · 2 total/)).toBeInTheDocument();
});
it("handles malformed ISO dates gracefully", () => {
const today = mockDate.toISOString();
render(
<ApplicationsPerDayChart
appliedAt={["2025-13-45", today, "2025-01-00", today]}
isLoading={false}
error={null}
daysToShow={7}
/>,
);
expect(screen.getByText(/Last 7 days · 2 total/)).toBeInTheDocument();
});
});
describe("Date Range Filtering", () => {
it("filters out dates before the start of range", () => {
const today = mockDate.toISOString();
const oldDate = "2025-01-01T00:00:00Z"; // Before 7-day window
render(
<ApplicationsPerDayChart
appliedAt={[oldDate, today, today]}
isLoading={false}
error={null}
daysToShow={7}
/>,
);
expect(screen.getByText(/Last 7 days · 2 total/)).toBeInTheDocument();
});
it("filters out dates after the end of range (future dates)", () => {
const today = mockDate.toISOString();
const futureDate = "2025-01-20T00:00:00Z"; // After today
render(
<ApplicationsPerDayChart
appliedAt={[today, futureDate, today]}
isLoading={false}
error={null}
daysToShow={7}
/>,
);
expect(screen.getByText(/Last 7 days · 2 total/)).toBeInTheDocument();
});
it("handles single day range (daysToShow=1)", () => {
const today = mockDate.toISOString();
const yesterday = "2025-01-14T00:00:00Z";
render(
<ApplicationsPerDayChart
appliedAt={[today, yesterday, today]}
isLoading={false}
error={null}
daysToShow={1}
/>,
);
expect(screen.getByText(/Last 1 days · 2 total/)).toBeInTheDocument();
});
});
describe("Trend Calculation Edge Cases", () => {
it("shows neutral trend when first half average is 0 and second half is also 0", () => {
// All zeros - no trend indicator should show
render(
<ApplicationsPerDayChart
appliedAt={[]}
isLoading={false}
error={null}
daysToShow={7}
/>,
);
expect(screen.queryByTestId("trending-up")).not.toBeInTheDocument();
expect(screen.queryByTestId("trending-down")).not.toBeInTheDocument();
});
it("shows up trend when first half is 0 but second half has activity", () => {
const dates = [
"2025-01-15T00:00:00Z", // Today (second half)
"2025-01-15T00:00:00Z",
"2025-01-15T00:00:00Z",
];
render(
<ApplicationsPerDayChart
appliedAt={dates}
isLoading={false}
error={null}
daysToShow={7}
/>,
);
expect(screen.getByTestId("trending-up")).toBeInTheDocument();
});
it("calculates trend percentage correctly for positive trend", () => {
// First half: 1 app per day avg, Second half: 3 apps per day avg = 200% increase
const dates = [
"2025-01-09T00:00:00Z", // First half
"2025-01-15T00:00:00Z", // Second half
"2025-01-15T00:00:00Z",
"2025-01-15T00:00:00Z",
];
render(
<ApplicationsPerDayChart
appliedAt={dates}
isLoading={false}
error={null}
daysToShow={7}
/>,
);
expect(screen.getByTestId("trending-up")).toBeInTheDocument();
});
it("shows down trend for significant negative trend", () => {
// First half: high activity, Second half: low activity
const dates = [
"2025-01-09T00:00:00Z", // First half - 3 apps
"2025-01-09T00:00:00Z",
"2025-01-09T00:00:00Z",
"2025-01-15T00:00:00Z", // Second half - 1 app
];
render(
<ApplicationsPerDayChart
appliedAt={dates}
isLoading={false}
error={null}
daysToShow={7}
/>,
);
expect(screen.getByTestId("trending-down")).toBeInTheDocument();
});
});
describe("Loading and Error States", () => {
it("shows loading state description", () => {
render(
<ApplicationsPerDayChart
appliedAt={[]}
isLoading={true}
error={null}
daysToShow={7}
/>,
);
expect(screen.getByText("Loading applied jobs...")).toBeInTheDocument();
});
it("displays error message when error prop is set", () => {
render(
<ApplicationsPerDayChart
appliedAt={[]}
isLoading={false}
error="Failed to load application data"
daysToShow={7}
/>,
);
expect(
screen.getByText("Failed to load application data"),
).toBeInTheDocument();
expect(screen.queryByTestId("chart-container")).not.toBeInTheDocument();
});
it("renders chart when no error", () => {
render(
<ApplicationsPerDayChart
appliedAt={[]}
isLoading={false}
error={null}
daysToShow={7}
/>,
);
expect(screen.getByTestId("chart-container")).toBeInTheDocument();
});
});
describe("Large Data Stress Tests", () => {
it("handles large number of applications efficiently", () => {
const today = mockDate.toISOString();
const largeData = Array(1000).fill(today);
render(
<ApplicationsPerDayChart
appliedAt={largeData}
isLoading={false}
error={null}
daysToShow={7}
/>,
);
expect(screen.getByText(/Last 7 days · 1,000 total/)).toBeInTheDocument();
expect(screen.getByText("142.9")).toBeInTheDocument(); // 1000/7
});
it("handles applications spread across different days in range", () => {
const dates = [
"2025-01-09T00:00:00Z",
"2025-01-10T00:00:00Z",
"2025-01-10T00:00:00Z",
"2025-01-11T00:00:00Z",
"2025-01-11T00:00:00Z",
"2025-01-11T00:00:00Z",
"2025-01-15T00:00:00Z",
];
render(
<ApplicationsPerDayChart
appliedAt={dates}
isLoading={false}
error={null}
daysToShow={7}
/>,
);
expect(screen.getByText(/Last 7 days · 7 total/)).toBeInTheDocument();
expect(screen.getByText("1.0")).toBeInTheDocument(); // 7/7
});
});
});

View File

@ -0,0 +1,209 @@
/**
* Applications Per Day Chart
* Shows daily application volume over a selected time range.
*/
import { TrendingDown, TrendingUp } from "lucide-react";
import { useMemo } from "react";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
type DailyApplications = {
date: string;
applications: number;
};
const chartConfig = {
applications: {
label: "Applications",
color: "var(--chart-1)",
},
};
const toDateKey = (value: Date) => {
const year = value.getFullYear();
const month = `${value.getMonth() + 1}`.padStart(2, "0");
const day = `${value.getDate()}`.padStart(2, "0");
return `${year}-${month}-${day}`;
};
const buildApplicationsPerDay = (
appliedAt: Array<string | null>,
daysToShow: number,
) => {
const end = new Date();
end.setHours(23, 59, 59, 999);
const start = new Date(end);
start.setDate(start.getDate() - (daysToShow - 1));
start.setHours(0, 0, 0, 0);
const counts = new Map<string, number>();
for (const value of appliedAt) {
if (!value) continue;
const date = new Date(value);
if (Number.isNaN(date.getTime())) continue;
if (date < start || date > end) continue;
const key = toDateKey(date);
counts.set(key, (counts.get(key) ?? 0) + 1);
}
const data: DailyApplications[] = [];
for (
let day = new Date(start);
day <= end;
day = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1)
) {
const key = toDateKey(day);
data.push({ date: key, applications: counts.get(key) ?? 0 });
}
const total = data.reduce((sum, item) => sum + item.applications, 0);
// Calculate trend by comparing first half vs second half
const halfPoint = Math.floor(data.length / 2);
const firstHalf = data.slice(0, halfPoint);
const secondHalf = data.slice(halfPoint);
const firstHalfAvg =
firstHalf.length > 0
? firstHalf.reduce((sum, item) => sum + item.applications, 0) /
firstHalf.length
: 0;
const secondHalfAvg =
secondHalf.length > 0
? secondHalf.reduce((sum, item) => sum + item.applications, 0) /
secondHalf.length
: 0;
const trend =
firstHalfAvg === 0
? secondHalfAvg > 0
? "up"
: "neutral"
: ((secondHalfAvg - firstHalfAvg) / firstHalfAvg) * 100;
return { data, total, trend };
};
interface ApplicationsPerDayChartProps {
appliedAt: Array<string | null>;
isLoading: boolean;
error: string | null;
daysToShow: number;
}
export function ApplicationsPerDayChart({
appliedAt,
isLoading,
error,
daysToShow,
}: ApplicationsPerDayChartProps) {
const {
data: chartData,
total,
trend,
} = useMemo(() => {
return buildApplicationsPerDay(appliedAt, daysToShow);
}, [appliedAt, daysToShow]);
const average = useMemo(() => {
if (chartData.length === 0) return 0;
return total / chartData.length;
}, [chartData, total]);
const showTrendUp = typeof trend === "number" ? trend > 5 : trend === "up";
const showTrendDown = typeof trend === "number" ? trend < -5 : false;
return (
<Card className="py-0">
<CardHeader className="flex flex-col gap-2 border-b !p-0 sm:flex-row sm:items-stretch">
<div className="flex flex-1 flex-col justify-center gap-1 px-6 pt-4 pb-3 sm:!py-0">
<CardTitle>Applications per day</CardTitle>
<CardDescription>
{isLoading
? "Loading applied jobs..."
: `Last ${daysToShow} days · ${total.toLocaleString()} total`}
</CardDescription>
</div>
<div className="flex flex-col items-start justify-center gap-3 border-t px-6 py-4 text-left sm:border-t-0 sm:border-l sm:px-8 sm:py-6">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Avg / day</span>
<div className="flex items-center gap-2">
<span className="text-lg font-bold leading-none sm:text-3xl">
{average.toFixed(1)}
</span>
{showTrendUp ? (
<TrendingUp className="h-4 w-4 text-emerald-500" />
) : showTrendDown ? (
<TrendingDown className="h-4 w-4 text-destructive" />
) : null}
</div>
</div>
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
{error ? (
<div className="px-4 py-6 text-sm text-destructive">{error}</div>
) : (
<ChartContainer
config={chartConfig}
className="aspect-auto h-[280px] w-full"
>
<BarChart
accessibilityLayer
data={chartData}
margin={{ left: 12, right: 12 }}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value);
return date.toLocaleDateString("en-GB", {
month: "short",
day: "numeric",
});
}}
/>
<ChartTooltip
cursor={{ fill: "var(--chart-1)", opacity: 0.3 }}
content={
<ChartTooltipContent
className="w-[160px]"
nameKey="applications"
labelFormatter={(value) =>
new Date(value as string).toLocaleDateString("en-GB", {
month: "short",
day: "numeric",
year: "numeric",
})
}
/>
}
/>
<Bar
dataKey="applications"
fill="var(--color-applications)"
radius={[6, 6, 0, 0]}
/>
</BarChart>
</ChartContainer>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,497 @@
/**
* ConversionAnalytics Edge Case Tests
* Tests real-world edge cases for conversion funnel and analytics
*/
import { render, screen } from "@testing-library/react";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ApplicationStage, StageEvent } from "../../../shared/types";
import { ConversionAnalytics } from "./ConversionAnalytics";
// Mock UI components
vi.mock("@/components/ui/card", () => ({
Card: ({ children }: { children: React.ReactNode }) => (
<div data-testid="card">{children}</div>
),
CardContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="card-content">{children}</div>
),
CardHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="card-header">{children}</div>
),
CardTitle: ({ children }: { children: React.ReactNode }) => (
<div data-testid="card-title">{children}</div>
),
CardDescription: ({ children }: { children: React.ReactNode }) => (
<div data-testid="card-description">{children}</div>
),
}));
vi.mock("@/components/ui/chart", () => ({
ChartContainer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="chart-container">{children}</div>
),
ChartTooltip: () => <div data-testid="chart-tooltip">Tooltip</div>,
}));
vi.mock("recharts", () => ({
BarChart: ({ children }: { children: React.ReactNode }) => (
<div data-testid="bar-chart">{children}</div>
),
Bar: () => <div data-testid="bar">Bar</div>,
Cell: () => <div data-testid="cell">Cell</div>,
LabelList: () => <div data-testid="label-list">LabelList</div>,
LineChart: ({ children }: { children: React.ReactNode }) => (
<div data-testid="line-chart">{children}</div>
),
Line: () => <div data-testid="line">Line</div>,
CartesianGrid: () => <div data-testid="cartesian-grid">Grid</div>,
XAxis: () => <div data-testid="x-axis">XAxis</div>,
YAxis: () => <div data-testid="y-axis">YAxis</div>,
Tooltip: () => <div data-testid="tooltip">Tooltip</div>,
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="responsive-container">{children}</div>
),
}));
vi.mock("lucide-react", () => ({
TrendingUp: () => <div data-testid="trending-up">TrendingUp</div>,
TrendingDown: () => <div data-testid="trending-down">TrendingDown</div>,
}));
describe("ConversionAnalytics - Edge Cases", () => {
const mockDate = new Date("2025-01-15T12:00:00Z");
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(mockDate);
});
afterEach(() => {
vi.useRealTimers();
});
const createJob = (
id: string,
appliedAt: string | null,
events: StageEvent[] = [],
) => ({
id,
datePosted: null,
discoveredAt: "2025-01-01T00:00:00Z",
appliedAt,
events,
});
const createEvent = (
toStage: ApplicationStage,
occurredAt: number,
): StageEvent => ({
id: `event-${toStage}`,
applicationId: "job-1",
title: `Moved to ${toStage}`,
groupId: null,
fromStage: "applied",
toStage,
occurredAt,
metadata: null,
outcome: null,
});
describe("Empty and Null Data", () => {
it("handles empty jobsWithEvents array - shows 0% conversion", () => {
render(
<ConversionAnalytics jobsWithEvents={[]} error={null} daysToShow={7} />,
);
expect(screen.getByText("0.0%")).toBeInTheDocument();
expect(screen.getByText(/0 of 0 applications/)).toBeInTheDocument();
});
it("excludes jobs with null appliedAt from conversion calculation", () => {
const jobs = [
createJob("job-1", null, []),
createJob("job-2", null, [
createEvent("recruiter_screen", 1704844800000),
]),
];
render(
<ConversionAnalytics
jobsWithEvents={jobs}
error={null}
daysToShow={7}
/>,
);
expect(screen.getByText("0.0%")).toBeInTheDocument();
expect(screen.getByText(/0 of 0 applications/)).toBeInTheDocument();
});
it("counts all jobs with appliedAt regardless of date range for overall stats", () => {
const today = mockDate.toISOString();
const oldDate = "2025-01-01T00:00:00Z"; // Outside 7-day range
const jobs = [
createJob("job-1", today, []),
createJob("job-2", today, []),
createJob("job-3", oldDate, []), // Still counted in overall stats
];
render(
<ConversionAnalytics
jobsWithEvents={jobs}
error={null}
daysToShow={7}
/>,
);
// Overall conversion counts all jobs with appliedAt (not filtered by date)
expect(screen.getByText(/0 of 3 applications/)).toBeInTheDocument();
});
});
describe("Conversion Rate Edge Cases", () => {
it("shows 0% conversion when no jobs have conversion events", () => {
const today = mockDate.toISOString();
const jobs = [
createJob("job-1", today, []),
createJob("job-2", today, []),
createJob("job-3", today, [createEvent("closed", 1704844800000)]),
];
render(
<ConversionAnalytics
jobsWithEvents={jobs}
error={null}
daysToShow={7}
/>,
);
expect(screen.getByText("0.0%")).toBeInTheDocument();
expect(screen.getByText(/0 of 3 applications/)).toBeInTheDocument();
expect(screen.getByTestId("trending-down")).toBeInTheDocument();
});
it("shows 100% conversion when all jobs have conversion events", () => {
const today = mockDate.toISOString();
const jobs = [
createJob("job-1", today, [
createEvent("recruiter_screen", 1704844800000),
]),
createJob("job-2", today, [
createEvent("technical_interview", 1704844800000),
]),
];
render(
<ConversionAnalytics
jobsWithEvents={jobs}
error={null}
daysToShow={7}
/>,
);
expect(screen.getByText("100.0%")).toBeInTheDocument();
expect(screen.getByText(/2 of 2 applications/)).toBeInTheDocument();
expect(screen.getByTestId("trending-up")).toBeInTheDocument();
});
it("calculates partial conversion rate correctly", () => {
const today = mockDate.toISOString();
const jobs = [
createJob("job-1", today, [
createEvent("recruiter_screen", 1704844800000),
]),
createJob("job-2", today, []),
createJob("job-3", today, []),
createJob("job-4", today, [createEvent("offer", 1704844800000)]),
];
render(
<ConversionAnalytics
jobsWithEvents={jobs}
error={null}
daysToShow={7}
/>,
);
expect(screen.getByText("50.0%")).toBeInTheDocument();
expect(screen.getByText(/2 of 4 applications/)).toBeInTheDocument();
});
it("handles jobs with multiple events - counts as converted if any event is in CONVERSION_STAGES", () => {
const today = mockDate.toISOString();
const jobs = [
createJob("job-1", today, [
createEvent("closed", 1704844800000),
createEvent("recruiter_screen", 1704931200000),
]),
];
render(
<ConversionAnalytics
jobsWithEvents={jobs}
error={null}
daysToShow={7}
/>,
);
expect(screen.getByText("100.0%")).toBeInTheDocument();
expect(screen.getByText(/1 of 1 applications/)).toBeInTheDocument();
});
});
describe("Funnel Data Edge Cases", () => {
it("shows all zeros in funnel when no jobs are applied", () => {
const jobs = [createJob("job-1", null, []), createJob("job-2", null, [])];
render(
<ConversionAnalytics
jobsWithEvents={jobs}
error={null}
daysToShow={7}
/>,
);
// Funnel should still render with 0 values
expect(screen.getByTestId("bar-chart")).toBeInTheDocument();
});
it("correctly categorizes screening stages (recruiter_screen, assessment)", () => {
const today = mockDate.toISOString();
const jobs = [
createJob("job-1", today, [
createEvent("recruiter_screen", 1704844800000),
]),
createJob("job-2", today, [createEvent("assessment", 1704844800000)]),
createJob("job-3", today, []),
];
render(
<ConversionAnalytics
jobsWithEvents={jobs}
error={null}
daysToShow={7}
/>,
);
// Both recruiter_screen and assessment count as screening
expect(screen.getByTestId("bar-chart")).toBeInTheDocument();
});
it("correctly categorizes interview stages", () => {
const today = mockDate.toISOString();
const jobs = [
createJob("job-1", today, [
createEvent("hiring_manager_screen", 1704844800000),
]),
createJob("job-2", today, [
createEvent("technical_interview", 1704844800000),
]),
createJob("job-3", today, [createEvent("onsite", 1704844800000)]),
];
render(
<ConversionAnalytics
jobsWithEvents={jobs}
error={null}
daysToShow={7}
/>,
);
expect(screen.getByTestId("bar-chart")).toBeInTheDocument();
});
it("handles job that reached multiple funnel stages", () => {
const today = mockDate.toISOString();
const jobs = [
createJob("job-1", today, [
createEvent("recruiter_screen", 1704844800000),
createEvent("technical_interview", 1704931200000),
createEvent("offer", 1705017600000),
]),
];
render(
<ConversionAnalytics
jobsWithEvents={jobs}
error={null}
daysToShow={7}
/>,
);
// Job should count in all stages it reached
expect(screen.getByTestId("bar-chart")).toBeInTheDocument();
});
});
describe("Date Range and Invalid Dates", () => {
it("counts jobs with any non-null appliedAt (overall stats don't validate dates)", () => {
const today = mockDate.toISOString();
const jobs = [
createJob("job-1", today, []),
createJob("job-2", "invalid-date", []),
createJob("job-3", "", []),
];
render(
<ConversionAnalytics
jobsWithEvents={jobs}
error={null}
daysToShow={7}
/>,
);
// calculateOverallConversion only checks !job.appliedAt (null/undefined)
// Empty string "" is falsy in JS, so it's filtered. "invalid-date" is truthy, so counted.
// Result: job-1 and job-2 are counted = 2 total
expect(screen.getByText(/0 of 2 applications/)).toBeInTheDocument();
});
it("includes jobs outside date range in overall conversion stats", () => {
const oldDate = "2025-01-01T00:00:00Z"; // Before 7-day window
const jobs = [
createJob("job-1", oldDate, [createEvent("offer", 1704153600000)]),
createJob("job-2", oldDate, [
createEvent("recruiter_screen", 1704153600000),
]),
];
render(
<ConversionAnalytics
jobsWithEvents={jobs}
error={null}
daysToShow={7}
/>,
);
// Overall conversion counts all jobs with appliedAt (not filtered by date)
// Both jobs have conversion events (offer and recruiter_screen)
expect(screen.getByText("100.0%")).toBeInTheDocument();
expect(screen.getByText(/2 of 2 applications/)).toBeInTheDocument();
});
});
describe("Error State", () => {
it("displays error message when error prop is set", () => {
render(
<ConversionAnalytics
jobsWithEvents={[]}
error="Failed to fetch conversion data"
daysToShow={7}
/>,
);
expect(
screen.getByText("Failed to fetch conversion data"),
).toBeInTheDocument();
expect(screen.queryByTestId("bar-chart")).not.toBeInTheDocument();
expect(screen.queryByTestId("line-chart")).not.toBeInTheDocument();
});
it("renders charts when no error", () => {
render(
<ConversionAnalytics jobsWithEvents={[]} error={null} daysToShow={7} />,
);
expect(screen.getByTestId("bar-chart")).toBeInTheDocument();
expect(screen.getByTestId("line-chart")).toBeInTheDocument();
});
});
describe("Trend Indicator Logic", () => {
it("shows down trend indicator when conversion rate is below 10%", () => {
const today = mockDate.toISOString();
const jobs = [
createJob("job-1", today, []),
createJob("job-2", today, []),
createJob("job-3", today, []),
createJob("job-4", today, []),
createJob("job-5", today, [
createEvent("recruiter_screen", 1704844800000),
]),
];
render(
<ConversionAnalytics
jobsWithEvents={jobs}
error={null}
daysToShow={7}
/>,
);
expect(screen.getByText("20.0%")).toBeInTheDocument();
// 20% is not < 10%, so no trending-down
expect(screen.queryByTestId("trending-down")).not.toBeInTheDocument();
});
it("shows no trend indicator for moderate conversion rates (10-25%)", () => {
const today = mockDate.toISOString();
const jobs = [
createJob("job-1", today, [
createEvent("recruiter_screen", 1704844800000),
]),
createJob("job-2", today, []),
createJob("job-3", today, []),
createJob("job-4", today, []),
];
render(
<ConversionAnalytics
jobsWithEvents={jobs}
error={null}
daysToShow={7}
/>,
);
expect(screen.getByText("25.0%")).toBeInTheDocument();
// 25% is not > 25%, so no trending-up
expect(screen.queryByTestId("trending-up")).not.toBeInTheDocument();
expect(screen.queryByTestId("trending-down")).not.toBeInTheDocument();
});
});
describe("Time Series Data Edge Cases", () => {
it("handles conversion rate calculation with rolling window", () => {
const today = mockDate.toISOString();
const yesterday = "2025-01-14T00:00:00Z";
const jobs = [
createJob("job-1", today, [
createEvent("recruiter_screen", 1705276800000),
]),
createJob("job-2", yesterday, []),
];
render(
<ConversionAnalytics
jobsWithEvents={jobs}
error={null}
daysToShow={7}
/>,
);
expect(screen.getByTestId("line-chart")).toBeInTheDocument();
});
it("handles single day range for time series", () => {
const today = mockDate.toISOString();
const jobs = [
createJob("job-1", today, [
createEvent("recruiter_screen", 1705276800000),
]),
];
render(
<ConversionAnalytics
jobsWithEvents={jobs}
error={null}
daysToShow={1}
/>,
);
expect(screen.getByTestId("line-chart")).toBeInTheDocument();
expect(screen.getByText(/rolling 1-day average/)).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,468 @@
/**
* Conversion Analytics
* Shows Application Response conversion metrics including funnel, time-series, and insights.
*/
import { TrendingDown, TrendingUp } from "lucide-react";
import { useMemo } from "react";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
LabelList,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
import type { StageEvent } from "../../../shared/types";
type FunnelStage = {
name: string;
value: number;
fill: string;
};
type ConversionDataPoint = {
date: string;
conversionRate: number;
appliedCount: number;
convertedCount: number;
};
type JobWithEvents = {
id: string;
datePosted: string | null;
discoveredAt: string;
appliedAt: string | null;
events: StageEvent[];
};
const chartConfig = {
conversionRate: {
label: "Conversion Rate",
color: "var(--chart-1)",
},
};
// Stage definitions for funnel
const FUNNEL_STAGES = [
{ key: "applied", label: "Applied", color: "#3b82f6" },
{ key: "screening", label: "Screening", color: "#8b5cf6" },
{ key: "interview", label: "Interview", color: "#f59e0b" },
{ key: "offer", label: "Offer", color: "#10b981" },
] as const;
// Stages that count as "screening"
const SCREENING_STAGES = new Set(["recruiter_screen", "assessment"]);
// Stages that count as "interview" (for funnel display)
const INTERVIEW_STAGES = new Set([
"hiring_manager_screen",
"technical_interview",
"onsite",
]);
// Stages that count as conversion (any positive response from company)
const CONVERSION_STAGES = new Set([
"recruiter_screen",
"assessment",
"hiring_manager_screen",
"technical_interview",
"onsite",
"offer",
]);
// Stages that count as "offer"
const OFFER_STAGES = new Set(["offer"]);
const toDateKey = (value: Date) => {
const year = value.getFullYear();
const month = `${value.getMonth() + 1}`.padStart(2, "0");
const day = `${value.getDate()}`.padStart(2, "0");
return `${year}-${month}-${day}`;
};
// Build funnel data from jobs with their stage events
const buildFunnelData = (jobsWithEvents: JobWithEvents[]): FunnelStage[] => {
let applied = 0;
let screening = 0;
let interview = 0;
let offer = 0;
for (const job of jobsWithEvents) {
if (!job.appliedAt) continue;
applied++;
const reachedStages = new Set<string>();
for (const event of job.events) {
reachedStages.add(event.toStage);
}
// Check if reached screening
for (const stage of SCREENING_STAGES) {
if (reachedStages.has(stage)) {
screening++;
break;
}
}
// Check if reached interview
for (const stage of INTERVIEW_STAGES) {
if (reachedStages.has(stage)) {
interview++;
break;
}
}
// Check if reached offer
for (const stage of OFFER_STAGES) {
if (reachedStages.has(stage)) {
offer++;
break;
}
}
}
return [
{ name: "Applied", value: applied, fill: FUNNEL_STAGES[0].color },
{ name: "Screening", value: screening, fill: FUNNEL_STAGES[1].color },
{ name: "Interview", value: interview, fill: FUNNEL_STAGES[2].color },
{ name: "Offer", value: offer, fill: FUNNEL_STAGES[3].color },
];
};
// Build conversion rate time-series data
const buildConversionTimeSeries = (
jobsWithEvents: JobWithEvents[],
daysToShow: number,
): ConversionDataPoint[] => {
const end = new Date();
end.setHours(23, 59, 59, 999);
const start = new Date(end);
start.setDate(start.getDate() - (daysToShow - 1));
start.setHours(0, 0, 0, 0);
// Group jobs by application date
const jobsByDate = new Map<string, JobWithEvents[]>();
for (const job of jobsWithEvents) {
if (!job.appliedAt) continue;
const date = new Date(job.appliedAt);
if (Number.isNaN(date.getTime())) continue;
if (date < start || date > end) continue;
const key = toDateKey(date);
const list = jobsByDate.get(key) ?? [];
list.push(job);
jobsByDate.set(key, list);
}
// Build time series with rolling conversion rate
const data: ConversionDataPoint[] = [];
const rollingWindow = Math.min(7, daysToShow); // 7-day rolling average, capped by daysToShow
for (
let day = new Date(start);
day <= end;
day = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1)
) {
const key = toDateKey(day);
// Calculate rolling window range
const windowStart = new Date(day);
windowStart.setDate(windowStart.getDate() - rollingWindow + 1);
let appliedCount = 0;
let convertedCount = 0;
// Sum up jobs in the rolling window
for (
let windowDay = new Date(windowStart);
windowDay <= day;
windowDay = new Date(
windowDay.getFullYear(),
windowDay.getMonth(),
windowDay.getDate() + 1,
)
) {
const windowKey = toDateKey(windowDay);
const jobs = jobsByDate.get(windowKey) ?? [];
for (const job of jobs) {
appliedCount++;
// Check if reached any conversion stage
const reachedConversion = job.events.some((event) =>
CONVERSION_STAGES.has(event.toStage),
);
if (reachedConversion) {
convertedCount++;
}
}
}
const conversionRate =
appliedCount > 0 ? (convertedCount / appliedCount) * 100 : 0;
data.push({
date: key,
conversionRate,
appliedCount,
convertedCount,
});
}
return data;
};
// Calculate overall conversion rate
const calculateOverallConversion = (
jobsWithEvents: JobWithEvents[],
): { rate: number; total: number; converted: number } => {
let total = 0;
let converted = 0;
for (const job of jobsWithEvents) {
if (!job.appliedAt) continue;
total++;
const reachedConversion = job.events.some((event) =>
CONVERSION_STAGES.has(event.toStage),
);
if (reachedConversion) {
converted++;
}
}
const rate = total > 0 ? (converted / total) * 100 : 0;
return { rate, total, converted };
};
interface ConversionAnalyticsProps {
jobsWithEvents: JobWithEvents[];
error: string | null;
daysToShow: number;
}
export function ConversionAnalytics({
jobsWithEvents,
error,
daysToShow,
}: ConversionAnalyticsProps) {
const funnelData = useMemo(() => {
return buildFunnelData(jobsWithEvents);
}, [jobsWithEvents]);
const conversionTimeSeries = useMemo(() => {
return buildConversionTimeSeries(jobsWithEvents, daysToShow);
}, [jobsWithEvents, daysToShow]);
const overallConversion = useMemo(() => {
return calculateOverallConversion(jobsWithEvents);
}, [jobsWithEvents]);
return (
<Card className="py-0">
<CardHeader className="flex flex-col gap-2 border-b !p-0 sm:flex-row sm:items-stretch">
<div className="flex flex-1 flex-col justify-center gap-1 px-6 pt-4 pb-3 sm:!py-0">
<CardTitle>Application Response Conversion</CardTitle>
<CardDescription>
How many applications received a positive response from the company.
</CardDescription>
</div>
<div className="flex flex-col items-start justify-center gap-3 border-t px-6 py-4 text-left sm:border-t-0 sm:border-l sm:px-8 sm:py-6">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">
Conversion Rate
</span>
<div className="flex items-center gap-2">
<span className="text-lg font-bold leading-none sm:text-3xl">
{overallConversion.rate.toFixed(1)}%
</span>
{overallConversion.rate < 10 ? (
<TrendingDown className="h-4 w-4 text-destructive" />
) : overallConversion.rate > 25 ? (
<TrendingUp className="h-4 w-4 text-emerald-500" />
) : null}
</div>
<span className="text-xs text-muted-foreground">
{overallConversion.converted} of {overallConversion.total}{" "}
applications
</span>
</div>
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
{error ? (
<div className="px-4 py-6 text-sm text-destructive">{error}</div>
) : (
<div className="space-y-6">
{/* Funnel Chart */}
<div>
<h4 className="mb-3 text-sm font-medium text-muted-foreground">
Funnel: Applied Screening Interview Offer
</h4>
<ChartContainer
config={chartConfig}
className="aspect-auto h-[200px] w-full"
>
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={funnelData}
layout="vertical"
margin={{ left: 60, right: 20, top: 5, bottom: 5 }}
>
<CartesianGrid vertical={false} />
<XAxis type="number" hide />
<YAxis
dataKey="name"
type="category"
tickLine={false}
axisLine={false}
width={80}
tick={{ fontSize: 12 }}
/>
<Tooltip
cursor={{ fill: "var(--chart-1)", opacity: 0.3 }}
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const data = payload[0].payload as FunnelStage;
return (
<div className="rounded-lg border border-border/60 bg-background px-3 py-2 text-xs shadow-sm">
<div className="font-medium">{data.name}</div>
<div className="mt-1 text-muted-foreground">
{data.value} applications
</div>
</div>
);
}}
/>
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
{funnelData.map((entry) => (
<Cell key={entry.name} fill={entry.fill} />
))}
<LabelList
dataKey="value"
position="right"
className="text-xs fill-foreground"
/>
</Bar>
</BarChart>
</ResponsiveContainer>
</ChartContainer>
</div>
{/* Time Series Chart */}
<div>
<div className="mb-3 flex items-center justify-between">
<h4 className="text-sm font-medium text-muted-foreground">
Conversion rate over time (rolling {Math.min(7, daysToShow)}
-day average)
</h4>
</div>
<ChartContainer
config={chartConfig}
className="aspect-auto h-[200px] w-full"
>
<LineChart
data={conversionTimeSeries}
margin={{ left: 12, right: 12, top: 5, bottom: 5 }}
>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value);
return date.toLocaleDateString("en-GB", {
month: "short",
day: "numeric",
});
}}
/>
<YAxis
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value.toFixed(0)}%`}
domain={[0, "auto"]}
/>
<ChartTooltip
cursor={{ fill: "var(--chart-1)", opacity: 0.3 }}
content={({ active, payload, label }) => {
if (!active || !payload?.length) return null;
const data = payload[0].payload as ConversionDataPoint;
return (
<div className="rounded-lg border border-border/60 bg-background px-3 py-2 text-xs shadow-sm">
<div className="mb-2 text-[11px] font-medium text-muted-foreground">
{new Date(label as string).toLocaleDateString(
"en-GB",
{
month: "short",
day: "numeric",
year: "numeric",
},
)}
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">
Conversion Rate
</span>
<span className="font-semibold text-foreground">
{data.conversionRate.toFixed(1)}%
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">
Applied ({Math.min(7, daysToShow)}d window)
</span>
<span className="font-semibold text-foreground">
{data.appliedCount}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">
Converted
</span>
<span className="font-semibold text-foreground">
{data.convertedCount}
</span>
</div>
</div>
</div>
);
}}
/>
<Line
type="monotone"
dataKey="conversionRate"
stroke="var(--color-conversionRate)"
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
</LineChart>
</ChartContainer>
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,211 @@
/**
* DurationSelector Edge Case Tests
* Tests all duration options and interaction edge cases
*/
import { fireEvent, render, screen } from "@testing-library/react";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DurationSelector } from "./DurationSelector";
// Mock UI components
vi.mock("@/components/ui/tabs", () => ({
Tabs: ({
children,
value,
onValueChange,
}: {
children: React.ReactNode;
value: string;
onValueChange?: (value: string) => void;
}) => (
<div data-testid="tabs" data-value={value}>
{children}
{onValueChange && (
<button
type="button"
data-testid="tab-trigger-7"
onClick={() => onValueChange("7")}
>
7d
</button>
)}
{onValueChange && (
<button
type="button"
data-testid="tab-trigger-14"
onClick={() => onValueChange("14")}
>
14d
</button>
)}
{onValueChange && (
<button
type="button"
data-testid="tab-trigger-30"
onClick={() => onValueChange("30")}
>
30d
</button>
)}
{onValueChange && (
<button
type="button"
data-testid="tab-trigger-90"
onClick={() => onValueChange("90")}
>
90d
</button>
)}
</div>
),
TabsList: ({ children }: { children: React.ReactNode }) => (
<div data-testid="tabs-list">{children}</div>
),
TabsTrigger: ({
children,
value,
}: {
children: React.ReactNode;
value: string;
}) => (
<button type="button" data-testid={`tab-${value}`} value={value}>
{children}
</button>
),
}));
describe("DurationSelector - Edge Cases", () => {
const mockOnChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
describe("All Duration Options", () => {
it("renders with 7 days selected", () => {
render(<DurationSelector value={7} onChange={mockOnChange} />);
expect(screen.getByTestId("tabs")).toHaveAttribute("data-value", "7");
});
it("renders with 14 days selected", () => {
render(<DurationSelector value={14} onChange={mockOnChange} />);
expect(screen.getByTestId("tabs")).toHaveAttribute("data-value", "14");
});
it("renders with 30 days selected", () => {
render(<DurationSelector value={30} onChange={mockOnChange} />);
expect(screen.getByTestId("tabs")).toHaveAttribute("data-value", "30");
});
it("renders with 90 days selected", () => {
render(<DurationSelector value={90} onChange={mockOnChange} />);
expect(screen.getByTestId("tabs")).toHaveAttribute("data-value", "90");
});
});
describe("onChange Callback", () => {
it("calls onChange with 7 when 7d tab is clicked", () => {
render(<DurationSelector value={30} onChange={mockOnChange} />);
fireEvent.click(screen.getByTestId("tab-trigger-7"));
expect(mockOnChange).toHaveBeenCalledWith(7);
expect(mockOnChange).toHaveBeenCalledTimes(1);
});
it("calls onChange with 14 when 14d tab is clicked", () => {
render(<DurationSelector value={7} onChange={mockOnChange} />);
fireEvent.click(screen.getByTestId("tab-trigger-14"));
expect(mockOnChange).toHaveBeenCalledWith(14);
expect(mockOnChange).toHaveBeenCalledTimes(1);
});
it("calls onChange with 30 when 30d tab is clicked", () => {
render(<DurationSelector value={7} onChange={mockOnChange} />);
fireEvent.click(screen.getByTestId("tab-trigger-30"));
expect(mockOnChange).toHaveBeenCalledWith(30);
expect(mockOnChange).toHaveBeenCalledTimes(1);
});
it("calls onChange with 90 when 90d tab is clicked", () => {
render(<DurationSelector value={7} onChange={mockOnChange} />);
fireEvent.click(screen.getByTestId("tab-trigger-90"));
expect(mockOnChange).toHaveBeenCalledWith(90);
expect(mockOnChange).toHaveBeenCalledTimes(1);
});
it("parses string value to number correctly", () => {
render(<DurationSelector value={7} onChange={mockOnChange} />);
// Simulate clicking different tabs
fireEvent.click(screen.getByTestId("tab-trigger-30"));
expect(mockOnChange).toHaveBeenCalledWith(30);
expect(typeof mockOnChange.mock.calls[0][0]).toBe("number");
});
});
describe("Value Synchronization", () => {
it("updates when value prop changes", () => {
const { rerender } = render(
<DurationSelector value={7} onChange={mockOnChange} />,
);
expect(screen.getByTestId("tabs")).toHaveAttribute("data-value", "7");
rerender(<DurationSelector value={30} onChange={mockOnChange} />);
expect(screen.getByTestId("tabs")).toHaveAttribute("data-value", "30");
});
it("maintains correct value type (number)", () => {
render(<DurationSelector value={14} onChange={mockOnChange} />);
const tabs = screen.getByTestId("tabs");
const value = tabs.getAttribute("data-value");
expect(value).toBe("14");
});
});
describe("Callback Consistency", () => {
it("calls onChange multiple times for multiple selections", () => {
render(<DurationSelector value={7} onChange={mockOnChange} />);
fireEvent.click(screen.getByTestId("tab-trigger-14"));
fireEvent.click(screen.getByTestId("tab-trigger-30"));
fireEvent.click(screen.getByTestId("tab-trigger-7"));
expect(mockOnChange).toHaveBeenCalledTimes(3);
expect(mockOnChange).toHaveBeenNthCalledWith(1, 14);
expect(mockOnChange).toHaveBeenNthCalledWith(2, 30);
expect(mockOnChange).toHaveBeenNthCalledWith(3, 7);
});
it("passes correct duration values for all options", () => {
render(<DurationSelector value={7} onChange={mockOnChange} />);
const expectedValues = [7, 14, 30, 90];
const triggers = [
"tab-trigger-7",
"tab-trigger-14",
"tab-trigger-30",
"tab-trigger-90",
];
triggers.forEach((trigger, index) => {
fireEvent.click(screen.getByTestId(trigger));
expect(mockOnChange).toHaveBeenLastCalledWith(expectedValues[index]);
});
});
});
});

View File

@ -0,0 +1,50 @@
/**
* DurationSelector - Sticky nav component for selecting time range
* Controls the duration for all charts on the home page
*/
import { useCallback } from "react";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
const DURATION_OPTIONS = [
{ value: 7, label: "7d" },
{ value: 14, label: "14d" },
{ value: 30, label: "30d" },
{ value: 90, label: "90d" },
] as const;
export type DurationValue = (typeof DURATION_OPTIONS)[number]["value"];
interface DurationSelectorProps {
value: DurationValue;
onChange: (value: DurationValue) => void;
}
export function DurationSelector({ value, onChange }: DurationSelectorProps) {
const handleChange = useCallback(
(newValue: string) => {
const parsed = Number(newValue) as DurationValue;
onChange(parsed);
},
[onChange],
);
return (
<div className="flex items-center gap-2">
<Tabs value={String(value)} onValueChange={handleChange}>
<TabsList className="h-8">
{DURATION_OPTIONS.map((option) => (
<TabsTrigger
key={option.value}
value={String(option.value)}
className="px-3 text-xs"
>
{option.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,4 @@
export { ApplicationsPerDayChart } from "./ApplicationsPerDayChart";
export { ConversionAnalytics } from "./ConversionAnalytics";
export type { DurationValue } from "./DurationSelector";
export { DurationSelector } from "./DurationSelector";

View File

@ -2,14 +2,7 @@
* Shared layout components for consistent page structure.
*/
import {
Briefcase,
Home,
type LucideIcon,
Menu,
Settings,
Shield,
} from "lucide-react";
import { type LucideIcon, Menu } from "lucide-react";
import type React from "react";
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
@ -24,18 +17,12 @@ import {
SheetTrigger,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { isNavActive, NAV_LINKS } from "./navigation";
// ============================================================================
// Page Header
// ============================================================================
const navLinks = [
{ to: "/", label: "Dashboard", icon: Home },
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
{ to: "/ukvisajobs", label: "UK Visa Jobs", icon: Briefcase },
{ to: "/settings", label: "Settings", icon: Settings },
];
interface PageHeaderProps {
icon: LucideIcon;
title: string;
@ -57,8 +44,8 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
const navigate = useNavigate();
const [navOpen, setNavOpen] = useState(false);
const handleNavClick = (to: string) => {
if (location.pathname === to) {
const handleNavClick = (to: string, activePaths?: string[]) => {
if (isNavActive(location.pathname, to, activePaths)) {
setNavOpen(false);
return;
}
@ -82,21 +69,14 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
<SheetTitle>JobOps</SheetTitle>
</SheetHeader>
<nav className="mt-6 flex flex-col gap-2">
{navLinks.map(({ to, label, icon: NavIcon }) => (
{NAV_LINKS.map(({ to, label, icon: NavIcon, activePaths }) => (
<button
key={to}
type="button"
onClick={() => handleNavClick(to)}
onClick={() => handleNavClick(to, activePaths)}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left",
location.pathname === to ||
(to === "/" &&
[
"/ready",
"/discovered",
"/applied",
"/all",
].includes(location.pathname))
isNavActive(location.pathname, to, activePaths)
? "bg-accent text-accent-foreground"
: "text-muted-foreground",
)}

View File

@ -0,0 +1,33 @@
import {
Briefcase,
Home,
LayoutDashboard,
Settings,
Shield,
} from "lucide-react";
export type NavLink = {
to: string;
label: string;
icon: typeof Home;
activePaths?: string[];
};
export const NAV_LINKS: NavLink[] = [
{ to: "/home", label: "Home", icon: Home },
{
to: "/ready",
label: "Dashboard",
icon: LayoutDashboard,
activePaths: ["/ready", "/discovered", "/applied", "/all"],
},
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
{ to: "/ukvisajobs", label: "UK Visa Jobs", icon: Briefcase },
{ to: "/settings", label: "Settings", icon: Settings },
];
export const isNavActive = (
pathname: string,
to: string,
activePaths?: string[],
) => pathname === to || (activePaths ? activePaths.includes(pathname) : false);

View File

@ -0,0 +1,224 @@
import * as api from "@client/api";
import {
ApplicationsPerDayChart,
ConversionAnalytics,
DurationSelector,
type DurationValue,
} from "@client/components/charts";
import { PageMain } from "@client/components/layout";
import { Home, Menu } from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useState } from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import type { StageEvent } from "../../shared/types";
import { isNavActive, NAV_LINKS } from "../components/navigation";
type JobWithEvents = {
id: string;
datePosted: string | null;
discoveredAt: string;
appliedAt: string | null;
events: StageEvent[];
};
const DURATION_OPTIONS = [7, 14, 30, 90] as const;
const DEFAULT_DURATION = 30;
export const HomePage: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [navOpen, setNavOpen] = useState(false);
const [jobsWithEvents, setJobsWithEvents] = useState<JobWithEvents[]>([]);
const [appliedDates, setAppliedDates] = useState<Array<string | null>>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Read initial duration from URL
const initialDuration: DurationValue = (() => {
const value = Number(searchParams.get("duration"));
return (
(DURATION_OPTIONS as readonly number[]).includes(value)
? value
: DEFAULT_DURATION
) as DurationValue;
})();
const [duration, setDuration] = useState<DurationValue>(initialDuration);
useEffect(() => {
let isMounted = true;
setIsLoading(true);
api
.getJobs()
.then(async (response) => {
if (!isMounted) return;
const appliedDates = response.jobs.map((job) => job.appliedAt);
const jobSummaries = response.jobs.map((job) => ({
id: job.id,
datePosted: job.datePosted,
discoveredAt: job.discoveredAt,
appliedAt: job.appliedAt,
positiveResponse: false,
}));
const appliedJobs = jobSummaries.filter((job) => job.appliedAt);
const results = await Promise.allSettled(
appliedJobs.map((job) => api.getJobStageEvents(job.id)),
);
const eventsMap = new Map<string, StageEvent[]>();
results.forEach((result, index) => {
const jobId = appliedJobs[index]?.id;
if (!jobId) return;
if (result.status !== "fulfilled") {
eventsMap.set(jobId, []);
return;
}
eventsMap.set(jobId, result.value);
});
const resolvedJobsWithEvents: JobWithEvents[] = jobSummaries
.filter((job) => job.appliedAt)
.map((job) => ({
...job,
events: eventsMap.get(job.id) ?? [],
}));
setJobsWithEvents(resolvedJobsWithEvents);
setAppliedDates(appliedDates);
setError(null);
})
.catch((fetchError) => {
if (!isMounted) return;
const message =
fetchError instanceof Error
? fetchError.message
: "Failed to load applications";
setError(message);
})
.finally(() => {
if (!isMounted) return;
setIsLoading(false);
});
return () => {
isMounted = false;
};
}, []);
const handleDurationChange = useCallback(
(newDuration: DurationValue) => {
setDuration(newDuration);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (newDuration === DEFAULT_DURATION) {
next.delete("duration");
} else {
next.set("duration", String(newDuration));
}
// Clean up old params
next.delete("days");
next.delete("conversionWindow");
return next;
});
},
[setSearchParams],
);
const handleNavClick = (to: string, activePaths?: string[]) => {
if (isNavActive(location.pathname, to, activePaths)) {
setNavOpen(false);
return;
}
setNavOpen(false);
setTimeout(() => navigate(to), 150);
};
return (
<>
{/* Custom Header with Duration Selector */}
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4">
<div className="flex items-center gap-3">
<Sheet open={navOpen} onOpenChange={setNavOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon">
<Menu className="h-5 w-5" />
<span className="sr-only">Open navigation menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-64">
<SheetHeader>
<SheetTitle>JobOps</SheetTitle>
</SheetHeader>
<nav className="mt-6 flex flex-col gap-2">
{NAV_LINKS.map(
({ to, label, icon: NavIcon, activePaths }) => (
<button
key={to}
type="button"
onClick={() => handleNavClick(to, activePaths)}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left",
isNavActive(location.pathname, to, activePaths)
? "bg-accent text-accent-foreground"
: "text-muted-foreground",
)}
>
<NavIcon className="h-4 w-4" />
{label}
</button>
),
)}
</nav>
</SheetContent>
</Sheet>
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/60 bg-muted/30">
<Home className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0 leading-tight">
<div className="text-sm font-semibold tracking-tight">Home</div>
<div className="text-xs text-muted-foreground">
Applications over the last {duration} days
</div>
</div>
</div>
<div className="flex items-center gap-2">
<DurationSelector
value={duration}
onChange={handleDurationChange}
/>
</div>
</div>
</header>
<PageMain>
<ApplicationsPerDayChart
appliedAt={appliedDates}
isLoading={isLoading}
error={error}
daysToShow={duration}
/>
<ConversionAnalytics
jobsWithEvents={jobsWithEvents}
error={error}
daysToShow={duration}
/>
</PageMain>
</>
);
};

View File

@ -2,6 +2,7 @@
* UK Visa Jobs search page.
*/
import { isNavActive, NAV_LINKS } from "@client/components/navigation";
import {
Briefcase,
Calendar,
@ -12,19 +13,15 @@ import {
DollarSign,
ExternalLink,
GraduationCap,
Home,
Loader2,
MapPin,
Menu,
Search,
Settings,
Shield,
} from "lucide-react";
import type React from "react";
import { useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@ -47,13 +44,6 @@ const clampText = (value: string, max = 160) =>
const jobKey = (job: CreateJobInput) => job.sourceJobId || job.jobUrl;
const navLinks = [
{ to: "/", label: "Dashboard", icon: Home },
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
{ to: "/ukvisajobs", label: "UK Visa Jobs", icon: Briefcase },
{ to: "/settings", label: "Settings", icon: Settings },
];
export const UkVisaJobsPage: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
@ -78,7 +68,6 @@ export const UkVisaJobsPage: React.FC = () => {
? window.matchMedia("(min-width: 1024px)").matches
: false,
);
useEffect(() => {
if (results.length === 0) {
setSelectedJobId(null);
@ -375,12 +364,12 @@ export const UkVisaJobsPage: React.FC = () => {
<SheetTitle>JobOps</SheetTitle>
</SheetHeader>
<nav className="mt-6 flex flex-col gap-2">
{navLinks.map(({ to, label, icon: Icon }) => (
{NAV_LINKS.map(({ to, label, icon: Icon, activePaths }) => (
<button
key={to}
type="button"
onClick={() => {
if (location.pathname === to) {
if (isNavActive(location.pathname, to, activePaths)) {
setNavOpen(false);
return;
}
@ -389,14 +378,7 @@ export const UkVisaJobsPage: React.FC = () => {
}}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left",
location.pathname === to ||
(to === "/" &&
[
"/ready",
"/discovered",
"/applied",
"/all",
].includes(location.pathname))
isNavActive(location.pathname, to, activePaths)
? "bg-accent text-accent-foreground"
: "text-muted-foreground",
)}

View File

@ -1,18 +1,14 @@
import { isNavActive, NAV_LINKS } from "@client/components/navigation";
import {
Briefcase,
ChevronDown,
FileText,
Home,
Loader2,
Menu,
Play,
Settings,
Shield,
Sparkles,
} from "lucide-react";
import type React from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@ -29,8 +25,7 @@ import {
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { sourceLabel } from "@/lib/utils";
import { cn, sourceLabel } from "@/lib/utils";
import type { JobSource } from "../../../shared/types";
import { orderedSources } from "./constants";
@ -46,13 +41,6 @@ interface OrchestratorHeaderProps {
onOpenManualImport: () => void;
}
const navLinks = [
{ to: "/", label: "Dashboard", icon: Home },
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
{ to: "/ukvisajobs", label: "UK Visa Jobs", icon: Briefcase },
{ to: "/settings", label: "Settings", icon: Settings },
];
export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
navOpen,
onNavOpenChange,
@ -72,7 +60,6 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
const allSourcesSelected =
visibleSources.length > 0 &&
visibleSources.every((source) => pipelineSources.includes(source));
return (
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4">
@ -89,32 +76,24 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
<SheetTitle>JobOps</SheetTitle>
</SheetHeader>
<nav className="mt-6 flex flex-col gap-2">
{navLinks.map(({ to, label, icon: Icon }) => (
{NAV_LINKS.map(({ to, label, icon: Icon, activePaths }) => (
<button
key={to}
type="button"
onClick={() => {
if (location.pathname === to) {
if (isNavActive(location.pathname, to, activePaths)) {
onNavOpenChange(false);
return;
}
onNavOpenChange(false);
setTimeout(() => navigate(to), 150);
}}
className={`flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left ${
location.pathname === to ||
(
to === "/" &&
[
"/ready",
"/discovered",
"/applied",
"/all",
].includes(location.pathname)
)
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left",
isNavActive(location.pathname, to, activePaths)
? "bg-accent text-accent-foreground"
: "text-muted-foreground"
}`}
: "text-muted-foreground",
)}
>
<Icon className="h-4 w-4" />
{label}

View File

@ -0,0 +1,177 @@
import * as React from "react";
import {
Tooltip as RechartsTooltip,
ResponsiveContainer,
type TooltipProps,
} from "recharts";
import { cn } from "@/lib/utils";
export type ChartConfig = Record<
string,
{
label?: React.ReactNode;
icon?: React.ComponentType<{ className?: string }>;
color?: string;
}
>;
const ChartConfigContext = React.createContext<ChartConfig | null>(null);
const useChartConfig = () => React.useContext(ChartConfigContext);
const ChartStyle: React.FC<{ id: string; config: ChartConfig }> = ({
id,
config,
}) => {
const entries = Object.entries(config).filter(([, value]) => value.color);
if (entries.length === 0) return null;
return (
<style>{`
[data-chart="${id}"] {
${entries
.map(([key, value]) => `--color-${key}: ${value.color};`)
.join("\n")}
}
`}</style>
);
};
export const ChartContainer = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & {
config: ChartConfig;
children?: React.ReactElement | null;
}
>(({ id, className, children, config, ...props }, ref) => {
const generatedId = React.useId();
const chartId = id ?? generatedId;
return (
<ChartConfigContext.Provider value={config}>
<div
ref={ref}
data-chart={chartId}
className={cn("flex aspect-video justify-center text-xs", className)}
{...props}
>
<ChartStyle id={chartId} config={config} />
{React.isValidElement(children) ? (
<ResponsiveContainer>{children}</ResponsiveContainer>
) : null}
</div>
</ChartConfigContext.Provider>
);
});
ChartContainer.displayName = "ChartContainer";
export const ChartTooltip = RechartsTooltip;
export type ChartTooltipContentProps = React.ComponentPropsWithoutRef<"div"> &
Pick<TooltipProps<number, string>, "active" | "payload" | "label"> & {
indicator?: "dot" | "line" | "dashed";
labelFormatter?: (value: unknown, payload: unknown[]) => React.ReactNode;
formatter?: (
value: unknown,
name: string,
item: unknown,
index: number,
) => React.ReactNode;
nameKey?: string;
};
export const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
ChartTooltipContentProps
>(
(
{
active,
payload,
label,
className,
indicator = "dot",
labelFormatter,
formatter,
nameKey,
...props
},
ref,
) => {
const config = useChartConfig() ?? {};
if (!active || !payload?.length) return null;
const formattedLabel = labelFormatter
? labelFormatter(label, payload)
: label;
return (
<div
ref={ref}
className={cn(
"rounded-lg border border-border/60 bg-background px-3 py-2 text-xs shadow-sm",
className,
)}
{...props}
>
{formattedLabel ? (
<div className="mb-2 text-[11px] font-medium text-muted-foreground">
{formattedLabel}
</div>
) : null}
<div className="space-y-1">
{payload.map((item, index) => {
const dataKey = String(item.dataKey ?? item.name ?? "");
const configKey = nameKey ?? dataKey;
const entry = config[configKey] ?? config[dataKey];
const IndicatorIcon = entry?.icon;
const value = formatter
? formatter(item.value, dataKey, item, index)
: item.value;
const labelText = entry?.label ?? item.name ?? dataKey;
const indicatorColor =
entry?.color ?? item.color ?? item.fill ?? "currentColor";
return (
<div
key={`${dataKey}-${String(index)}`}
className="flex items-center justify-between gap-3"
>
<div className="flex items-center gap-2 text-muted-foreground">
{IndicatorIcon ? (
<IndicatorIcon className="h-3.5 w-3.5" />
) : (
<span
className={cn(
"inline-block",
indicator === "dot" && "h-2 w-2 rounded-full",
indicator === "line" && "h-0.5 w-3 rounded-full",
indicator === "dashed" &&
"h-0.5 w-3 rounded-full border border-dashed",
)}
style={{
backgroundColor:
indicator === "dot" || indicator === "line"
? indicatorColor
: "transparent",
borderColor:
indicator === "dashed" ? indicatorColor : undefined,
}}
/>
)}
<span>{labelText}</span>
</div>
<span className="font-semibold text-foreground">
{typeof value === "number"
? value.toLocaleString()
: (value as React.ReactNode)}
</span>
</div>
);
})}
</div>
</div>
);
},
);
ChartTooltipContent.displayName = "ChartTooltipContent";

View File

@ -96,7 +96,7 @@ export async function applyStoredEnvOverrides(): Promise<void> {
} catch (error) {
// In some test harnesses or first-boot scenarios, the DB may exist but not yet
// have the settings table. Treat this as "no overrides".
const msg = String((error as any)?.message ?? error);
const msg = String((error as Error)?.message ?? error);
if (msg.includes("no such table") && msg.includes("settings"))
return null;
throw error;
@ -107,7 +107,7 @@ export async function applyStoredEnvOverrides(): Promise<void> {
try {
await settingsRepo.setSetting(key, value);
} catch (error) {
const msg = String((error as any)?.message ?? error);
const msg = String((error as Error)?.message ?? error);
if (msg.includes("no such table") && msg.includes("settings")) return;
throw error;
}
@ -149,9 +149,8 @@ export async function applyStoredEnvOverrides(): Promise<void> {
console.warn(
"[DEPRECATED] OPENROUTER_API_KEY is deprecated. Copying to LLM_API_KEY for compatibility.",
);
process.env.LLM_API_KEY = normalizeEnvInput(
process.env.OPENROUTER_API_KEY,
)!;
const normalizedKey = normalizeEnvInput(process.env.OPENROUTER_API_KEY);
if (normalizedKey) process.env.LLM_API_KEY = normalizedKey;
}
await Promise.all([