From 3be0d25c875d7079f46fd7716ba1d8567de54161 Mon Sep 17 00:00:00 2001
From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com>
Date: Fri, 30 Jan 2026 11:40:17 +0000
Subject: [PATCH] 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
---
docker-compose.yml | 4 +
orchestrator/package-lock.json | 302 +++++++++++
orchestrator/package.json | 2 +
orchestrator/src/client/App.tsx | 2 +
orchestrator/src/client/components/Header.tsx | 34 +-
.../src/client/components/OnboardingGate.tsx | 366 +++++++------
.../charts/ApplicationsPerDayChart.test.tsx | 347 ++++++++++++
.../charts/ApplicationsPerDayChart.tsx | 209 ++++++++
.../charts/ConversionAnalytics.test.tsx | 497 ++++++++++++++++++
.../components/charts/ConversionAnalytics.tsx | 468 +++++++++++++++++
.../charts/DurationSelector.test.tsx | 211 ++++++++
.../components/charts/DurationSelector.tsx | 50 ++
.../src/client/components/charts/index.ts | 4 +
orchestrator/src/client/components/layout.tsx | 34 +-
.../src/client/components/navigation.ts | 33 ++
orchestrator/src/client/pages/HomePage.tsx | 224 ++++++++
.../src/client/pages/UkVisaJobsPage.tsx | 26 +-
.../pages/orchestrator/OrchestratorHeader.tsx | 39 +-
orchestrator/src/components/ui/chart.tsx | 177 +++++++
.../src/server/services/envSettings.ts | 9 +-
20 files changed, 2777 insertions(+), 261 deletions(-)
create mode 100644 orchestrator/src/client/components/charts/ApplicationsPerDayChart.test.tsx
create mode 100644 orchestrator/src/client/components/charts/ApplicationsPerDayChart.tsx
create mode 100644 orchestrator/src/client/components/charts/ConversionAnalytics.test.tsx
create mode 100644 orchestrator/src/client/components/charts/ConversionAnalytics.tsx
create mode 100644 orchestrator/src/client/components/charts/DurationSelector.test.tsx
create mode 100644 orchestrator/src/client/components/charts/DurationSelector.tsx
create mode 100644 orchestrator/src/client/components/charts/index.ts
create mode 100644 orchestrator/src/client/components/navigation.ts
create mode 100644 orchestrator/src/client/pages/HomePage.tsx
create mode 100644 orchestrator/src/components/ui/chart.tsx
diff --git a/docker-compose.yml b/docker-compose.yml
index 554b2d0..99c16f2 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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"]
diff --git a/orchestrator/package-lock.json b/orchestrator/package-lock.json
index ef42ff4..3cd51ca 100644
--- a/orchestrator/package-lock.json
+++ b/orchestrator/package-lock.json
@@ -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",
diff --git a/orchestrator/package.json b/orchestrator/package.json
index 9aaf88f..3014444 100644
--- a/orchestrator/package.json
+++ b/orchestrator/package.json
@@ -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",
diff --git a/orchestrator/src/client/App.tsx b/orchestrator/src/client/App.tsx
index 6056102..776dca6 100644
--- a/orchestrator/src/client/App.tsx
+++ b/orchestrator/src/client/App.tsx
@@ -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 = () => {
} />
+ } />
} />
} />
} />
diff --git a/orchestrator/src/client/components/Header.tsx b/orchestrator/src/client/components/Header.tsx
index a67b0fe..978ede8 100644
--- a/orchestrator/src/client/components/Header.tsx
+++ b/orchestrator/src/client/components/Header.tsx
@@ -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 = ({
"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 = ({
JobOps
{showBaseUrl && (
- setLlmBaseUrl(event.target.value),
- }}
- placeholder={providerConfig.baseUrlPlaceholder}
- helper={providerConfig.baseUrlHelper}
- current={settings?.llmBaseUrl || "—"}
- disabled={isSavingEnv}
+ (
+
+ )}
/>
)}
{showApiKey && (
- setLlmApiKey(event.target.value),
- }}
- type="password"
- placeholder="Enter key"
- current={llmKeyCurrent}
- helper={providerConfig.keyHelper}
- disabled={isSavingEnv}
+ (
+
+ )}
/>
)}
@@ -621,29 +666,40 @@ export const OnboardingGate: React.FC = () => {
- setRxresumeEmail(event.target.value),
- }}
- placeholder="you@example.com"
- current={rxresumeEmailCurrent}
- disabled={isSavingEnv}
+ (
+
+ )}
/>
-
- setRxresumePassword(event.target.value),
- }}
- type="password"
- placeholder="Enter password"
- current={rxresumePasswordCurrent}
- disabled={isSavingEnv}
+ (
+
+ )}
/>
@@ -658,11 +714,17 @@ export const OnboardingGate: React.FC = () => {
resume will be used as a template for tailoring.
- (
+
+ )}
/>
diff --git a/orchestrator/src/client/components/charts/ApplicationsPerDayChart.test.tsx b/orchestrator/src/client/components/charts/ApplicationsPerDayChart.test.tsx
new file mode 100644
index 0000000..1e1348e
--- /dev/null
+++ b/orchestrator/src/client/components/charts/ApplicationsPerDayChart.test.tsx
@@ -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 }) => (
+ {children}
+ ),
+ CardContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ CardHeader: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ CardTitle: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ CardDescription: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+vi.mock("@/components/ui/chart", () => ({
+ ChartContainer: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ ChartTooltip: () => Tooltip
,
+ ChartTooltipContent: () => (
+ TooltipContent
+ ),
+}));
+
+vi.mock("recharts", () => ({
+ BarChart: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ Bar: () => Bar
,
+ CartesianGrid: () => Grid
,
+ XAxis: () => XAxis
,
+}));
+
+vi.mock("lucide-react", () => ({
+ TrendingUp: () => TrendingUp
,
+ TrendingDown: () => TrendingDown
,
+}));
+
+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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ expect(screen.getByText(/Last 7 days · 3 total/)).toBeInTheDocument();
+ });
+ });
+
+ describe("Invalid Date Handling", () => {
+ it("filters out invalid date strings", () => {
+ const today = mockDate.toISOString();
+ render(
+ ,
+ );
+
+ expect(screen.getByText(/Last 7 days · 2 total/)).toBeInTheDocument();
+ });
+
+ it("handles malformed ISO dates gracefully", () => {
+ const today = mockDate.toISOString();
+ render(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ expect(screen.getByTestId("trending-down")).toBeInTheDocument();
+ });
+ });
+
+ describe("Loading and Error States", () => {
+ it("shows loading state description", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Loading applied jobs...")).toBeInTheDocument();
+ });
+
+ it("displays error message when error prop is set", () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByText("Failed to load application data"),
+ ).toBeInTheDocument();
+ expect(screen.queryByTestId("chart-container")).not.toBeInTheDocument();
+ });
+
+ it("renders chart when no error", () => {
+ render(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ expect(screen.getByText(/Last 7 days · 7 total/)).toBeInTheDocument();
+ expect(screen.getByText("1.0")).toBeInTheDocument(); // 7/7
+ });
+ });
+});
diff --git a/orchestrator/src/client/components/charts/ApplicationsPerDayChart.tsx b/orchestrator/src/client/components/charts/ApplicationsPerDayChart.tsx
new file mode 100644
index 0000000..ee4b1fc
--- /dev/null
+++ b/orchestrator/src/client/components/charts/ApplicationsPerDayChart.tsx
@@ -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,
+ 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();
+ 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;
+ 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 (
+
+
+
+ Applications per day
+
+ {isLoading
+ ? "Loading applied jobs..."
+ : `Last ${daysToShow} days · ${total.toLocaleString()} total`}
+
+
+
+
+
Avg / day
+
+
+ {average.toFixed(1)}
+
+ {showTrendUp ? (
+
+ ) : showTrendDown ? (
+
+ ) : null}
+
+
+
+
+
+ {error ? (
+ {error}
+ ) : (
+
+
+
+ {
+ const date = new Date(value);
+ return date.toLocaleDateString("en-GB", {
+ month: "short",
+ day: "numeric",
+ });
+ }}
+ />
+
+ new Date(value as string).toLocaleDateString("en-GB", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ })
+ }
+ />
+ }
+ />
+
+
+
+ )}
+
+
+ );
+}
diff --git a/orchestrator/src/client/components/charts/ConversionAnalytics.test.tsx b/orchestrator/src/client/components/charts/ConversionAnalytics.test.tsx
new file mode 100644
index 0000000..53f9614
--- /dev/null
+++ b/orchestrator/src/client/components/charts/ConversionAnalytics.test.tsx
@@ -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 }) => (
+ {children}
+ ),
+ CardContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ CardHeader: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ CardTitle: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ CardDescription: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+vi.mock("@/components/ui/chart", () => ({
+ ChartContainer: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ ChartTooltip: () => Tooltip
,
+}));
+
+vi.mock("recharts", () => ({
+ BarChart: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ Bar: () => Bar
,
+ Cell: () => Cell
,
+ LabelList: () => LabelList
,
+ LineChart: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ Line: () => Line
,
+ CartesianGrid: () => Grid
,
+ XAxis: () => XAxis
,
+ YAxis: () => YAxis
,
+ Tooltip: () => Tooltip
,
+ ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+vi.mock("lucide-react", () => ({
+ TrendingUp: () => TrendingUp
,
+ TrendingDown: () => TrendingDown
,
+}));
+
+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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ // 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ // 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(
+ ,
+ );
+
+ // 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ // 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(
+ ,
+ );
+
+ // 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(
+ ,
+ );
+
+ // 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ expect(screen.getByTestId("line-chart")).toBeInTheDocument();
+ expect(screen.getByText(/rolling 1-day average/)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/orchestrator/src/client/components/charts/ConversionAnalytics.tsx b/orchestrator/src/client/components/charts/ConversionAnalytics.tsx
new file mode 100644
index 0000000..d585c05
--- /dev/null
+++ b/orchestrator/src/client/components/charts/ConversionAnalytics.tsx
@@ -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();
+ 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();
+
+ 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 (
+
+
+
+ Application → Response Conversion
+
+ How many applications received a positive response from the company.
+
+
+
+
+
+ Conversion Rate
+
+
+
+ {overallConversion.rate.toFixed(1)}%
+
+ {overallConversion.rate < 10 ? (
+
+ ) : overallConversion.rate > 25 ? (
+
+ ) : null}
+
+
+ {overallConversion.converted} of {overallConversion.total}{" "}
+ applications
+
+
+
+
+
+ {error ? (
+ {error}
+ ) : (
+
+ {/* Funnel Chart */}
+
+
+ Funnel: Applied → Screening → Interview → Offer
+
+
+
+
+
+
+
+ {
+ if (!active || !payload?.length) return null;
+ const data = payload[0].payload as FunnelStage;
+ return (
+
+
{data.name}
+
+ {data.value} applications
+
+
+ );
+ }}
+ />
+
+ {funnelData.map((entry) => (
+ |
+ ))}
+
+
+
+
+
+
+
+ {/* Time Series Chart */}
+
+
+
+ Conversion rate over time (rolling {Math.min(7, daysToShow)}
+ -day average)
+
+
+
+
+
+ {
+ const date = new Date(value);
+ return date.toLocaleDateString("en-GB", {
+ month: "short",
+ day: "numeric",
+ });
+ }}
+ />
+ `${value.toFixed(0)}%`}
+ domain={[0, "auto"]}
+ />
+ {
+ if (!active || !payload?.length) return null;
+ const data = payload[0].payload as ConversionDataPoint;
+ return (
+
+
+ {new Date(label as string).toLocaleDateString(
+ "en-GB",
+ {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ },
+ )}
+
+
+
+
+ Conversion Rate
+
+
+ {data.conversionRate.toFixed(1)}%
+
+
+
+
+ Applied ({Math.min(7, daysToShow)}d window)
+
+
+ {data.appliedCount}
+
+
+
+
+ Converted
+
+
+ {data.convertedCount}
+
+
+
+
+ );
+ }}
+ />
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/orchestrator/src/client/components/charts/DurationSelector.test.tsx b/orchestrator/src/client/components/charts/DurationSelector.test.tsx
new file mode 100644
index 0000000..a750b7a
--- /dev/null
+++ b/orchestrator/src/client/components/charts/DurationSelector.test.tsx
@@ -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;
+ }) => (
+
+ {children}
+ {onValueChange && (
+
+ )}
+ {onValueChange && (
+
+ )}
+ {onValueChange && (
+
+ )}
+ {onValueChange && (
+
+ )}
+
+ ),
+ TabsList: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ TabsTrigger: ({
+ children,
+ value,
+ }: {
+ children: React.ReactNode;
+ value: string;
+ }) => (
+
+ ),
+}));
+
+describe("DurationSelector - Edge Cases", () => {
+ const mockOnChange = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("All Duration Options", () => {
+ it("renders with 7 days selected", () => {
+ render();
+
+ expect(screen.getByTestId("tabs")).toHaveAttribute("data-value", "7");
+ });
+
+ it("renders with 14 days selected", () => {
+ render();
+
+ expect(screen.getByTestId("tabs")).toHaveAttribute("data-value", "14");
+ });
+
+ it("renders with 30 days selected", () => {
+ render();
+
+ expect(screen.getByTestId("tabs")).toHaveAttribute("data-value", "30");
+ });
+
+ it("renders with 90 days selected", () => {
+ render();
+
+ expect(screen.getByTestId("tabs")).toHaveAttribute("data-value", "90");
+ });
+ });
+
+ describe("onChange Callback", () => {
+ it("calls onChange with 7 when 7d tab is clicked", () => {
+ render();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ fireEvent.click(screen.getByTestId("tab-trigger-90"));
+
+ expect(mockOnChange).toHaveBeenCalledWith(90);
+ expect(mockOnChange).toHaveBeenCalledTimes(1);
+ });
+
+ it("parses string value to number correctly", () => {
+ render();
+
+ // 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(
+ ,
+ );
+
+ expect(screen.getByTestId("tabs")).toHaveAttribute("data-value", "7");
+
+ rerender();
+
+ expect(screen.getByTestId("tabs")).toHaveAttribute("data-value", "30");
+ });
+
+ it("maintains correct value type (number)", () => {
+ render();
+
+ 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();
+
+ 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();
+
+ 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]);
+ });
+ });
+ });
+});
diff --git a/orchestrator/src/client/components/charts/DurationSelector.tsx b/orchestrator/src/client/components/charts/DurationSelector.tsx
new file mode 100644
index 0000000..ab13ffd
--- /dev/null
+++ b/orchestrator/src/client/components/charts/DurationSelector.tsx
@@ -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 (
+
+
+
+ {DURATION_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ );
+}
diff --git a/orchestrator/src/client/components/charts/index.ts b/orchestrator/src/client/components/charts/index.ts
new file mode 100644
index 0000000..537e5b5
--- /dev/null
+++ b/orchestrator/src/client/components/charts/index.ts
@@ -0,0 +1,4 @@
+export { ApplicationsPerDayChart } from "./ApplicationsPerDayChart";
+export { ConversionAnalytics } from "./ConversionAnalytics";
+export type { DurationValue } from "./DurationSelector";
+export { DurationSelector } from "./DurationSelector";
diff --git a/orchestrator/src/client/components/layout.tsx b/orchestrator/src/client/components/layout.tsx
index d74ecf7..682f947 100644
--- a/orchestrator/src/client/components/layout.tsx
+++ b/orchestrator/src/client/components/layout.tsx
@@ -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 = ({
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 = ({
JobOps