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