Starting work on Dashboard! (#65)
* initial commit * fix build issues and configurable time duration * show in nav * Positive response rate by posting freshness * load today's jobs for charts * fix infinite refetching with onboarding gate * application to response rate * refactor charts to their own directory * bar hover color * Duration selector embedded in navbar * always load env * remove warning about low conversion rate * trend graph for applications per day * better copy * remove freshness response chart * bottom line chart color and tooltip improved * introduce check all command * fix lint * tests added and CI passing
This commit is contained in:
parent
0e27dbe52b
commit
3be0d25c87
@ -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"]
|
||||
|
||||
302
orchestrator/package-lock.json
generated
302
orchestrator/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -8,6 +8,7 @@ import { CSSTransition, SwitchTransition } from "react-transition-group";
|
||||
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { OnboardingGate } from "./components/OnboardingGate";
|
||||
import { HomePage } from "./pages/HomePage";
|
||||
import { JobPage } from "./pages/JobPage";
|
||||
import { OrchestratorPage } from "./pages/OrchestratorPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
@ -41,6 +42,7 @@ export const App: React.FC = () => {
|
||||
<div ref={nodeRef}>
|
||||
<Routes location={location}>
|
||||
<Route path="/" element={<Navigate to="/ready" replace />} />
|
||||
<Route path="/home" element={<HomePage />} />
|
||||
<Route path="/job/:id" element={<JobPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/ukvisajobs" element={<UkVisaJobsPage />} />
|
||||
|
||||
@ -2,20 +2,10 @@
|
||||
* Header component with logo and pipeline trigger.
|
||||
*/
|
||||
|
||||
import {
|
||||
Briefcase,
|
||||
ChevronDown,
|
||||
Home,
|
||||
Loader2,
|
||||
Menu,
|
||||
Play,
|
||||
RefreshCcw,
|
||||
Settings,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { isNavActive, NAV_LINKS } from "@client/components/navigation";
|
||||
import { ChevronDown, Loader2, Menu, Play, RefreshCcw } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@ -33,7 +23,7 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { sourceLabel } from "@/lib/utils";
|
||||
import { cn, sourceLabel } from "@/lib/utils";
|
||||
import type { JobSource } from "../../shared/types";
|
||||
|
||||
interface HeaderProps {
|
||||
@ -63,13 +53,6 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
"ukvisajobs",
|
||||
];
|
||||
|
||||
const navLinks = [
|
||||
{ to: "/", label: "Dashboard", icon: Home },
|
||||
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
|
||||
{ to: "/ukvisajobs", label: "UK Visa Jobs", icon: Briefcase },
|
||||
{ to: "/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
const toggleSource = (source: JobSource, checked: boolean) => {
|
||||
const next = checked
|
||||
? Array.from(new Set([...pipelineSources, source]))
|
||||
@ -95,16 +78,17 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
<SheetTitle>JobOps</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="mt-6 flex flex-col gap-2">
|
||||
{navLinks.map(({ to, label, icon: Icon }) => (
|
||||
{NAV_LINKS.map(({ to, label, icon: Icon, activePaths }) => (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
onClick={() => setSheetOpen(false)}
|
||||
className={`flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground ${
|
||||
location.pathname === to
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground",
|
||||
isNavActive(location.pathname, to, activePaths)
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
|
||||
@ -13,6 +13,7 @@ import type { ValidationResult } from "@shared/types";
|
||||
import { Check } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
@ -42,12 +43,22 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
type ValidationState = ValidationResult & { checked: boolean };
|
||||
|
||||
type OnboardingFormData = {
|
||||
llmProvider: string;
|
||||
llmBaseUrl: string;
|
||||
llmApiKey: string;
|
||||
rxresumeEmail: string;
|
||||
rxresumePassword: string;
|
||||
rxresumeBaseResumeId: string | null;
|
||||
};
|
||||
|
||||
export const OnboardingGate: React.FC = () => {
|
||||
const {
|
||||
settings,
|
||||
isLoading: settingsLoading,
|
||||
refreshSettings,
|
||||
} = useSettings();
|
||||
|
||||
const [isSavingEnv, setIsSavingEnv] = useState(false);
|
||||
const [isValidatingLlm, setIsValidatingLlm] = useState(false);
|
||||
const [isValidatingRxresume, setIsValidatingRxresume] = useState(false);
|
||||
@ -72,54 +83,71 @@ export const OnboardingGate: React.FC = () => {
|
||||
});
|
||||
const [currentStep, setCurrentStep] = useState<string | null>(null);
|
||||
|
||||
const [llmProvider, setLlmProvider] = useState("");
|
||||
const [llmBaseUrl, setLlmBaseUrl] = useState("");
|
||||
const [llmApiKey, setLlmApiKey] = useState("");
|
||||
const [rxresumeEmail, setRxresumeEmail] = useState("");
|
||||
const [rxresumePassword, setRxresumePassword] = useState("");
|
||||
const [rxresumeBaseResumeId, setRxresumeBaseResumeId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const { control, watch, getValues, reset, setValue } =
|
||||
useForm<OnboardingFormData>({
|
||||
defaultValues: {
|
||||
llmProvider: "",
|
||||
llmBaseUrl: "",
|
||||
llmApiKey: "",
|
||||
rxresumeEmail: "",
|
||||
rxresumePassword: "",
|
||||
rxresumeBaseResumeId: null,
|
||||
},
|
||||
});
|
||||
|
||||
const validateLlm = useCallback(
|
||||
async (input: { provider?: string; baseUrl?: string; apiKey?: string }) => {
|
||||
setIsValidatingLlm(true);
|
||||
try {
|
||||
const result = await api.validateLlm(input);
|
||||
setLlmValidation({ ...result, checked: true });
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "LLM validation failed";
|
||||
const result = { valid: false, message };
|
||||
setLlmValidation({ ...result, checked: true });
|
||||
return result;
|
||||
} finally {
|
||||
setIsValidatingLlm(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const llmProvider = watch("llmProvider");
|
||||
|
||||
const validateRxresume = useCallback(
|
||||
async (email?: string, password?: string) => {
|
||||
setIsValidatingRxresume(true);
|
||||
try {
|
||||
const result = await api.validateRxresume(email, password);
|
||||
setRxresumeValidation({ ...result, checked: true });
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "RxResume validation failed";
|
||||
const result = { valid: false, message };
|
||||
setRxresumeValidation({ ...result, checked: true });
|
||||
return result;
|
||||
} finally {
|
||||
setIsValidatingRxresume(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const validateLlm = useCallback(async () => {
|
||||
const values = getValues();
|
||||
const selectedProvider = normalizeLlmProvider(
|
||||
values.llmProvider || settings?.llmProvider || "openrouter",
|
||||
);
|
||||
const providerConfig = getLlmProviderConfig(selectedProvider);
|
||||
const { requiresApiKey } = providerConfig;
|
||||
|
||||
setIsValidatingLlm(true);
|
||||
try {
|
||||
const result = await api.validateLlm({
|
||||
provider: selectedProvider,
|
||||
baseUrl: values.llmBaseUrl.trim() || undefined,
|
||||
apiKey: requiresApiKey
|
||||
? values.llmApiKey.trim() || undefined
|
||||
: undefined,
|
||||
});
|
||||
setLlmValidation({ ...result, checked: true });
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "LLM validation failed";
|
||||
const result = { valid: false, message };
|
||||
setLlmValidation({ ...result, checked: true });
|
||||
return result;
|
||||
} finally {
|
||||
setIsValidatingLlm(false);
|
||||
}
|
||||
}, [getValues, settings?.llmProvider]);
|
||||
|
||||
const validateRxresume = useCallback(async () => {
|
||||
const values = getValues();
|
||||
|
||||
setIsValidatingRxresume(true);
|
||||
try {
|
||||
const result = await api.validateRxresume(
|
||||
values.rxresumeEmail.trim() || undefined,
|
||||
values.rxresumePassword.trim() || undefined,
|
||||
);
|
||||
setRxresumeValidation({ ...result, checked: true });
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "RxResume validation failed";
|
||||
const result = { valid: false, message };
|
||||
setRxresumeValidation({ ...result, checked: true });
|
||||
return result;
|
||||
} finally {
|
||||
setIsValidatingRxresume(false);
|
||||
}
|
||||
}, [getValues]);
|
||||
|
||||
const validateBaseResume = useCallback(async () => {
|
||||
setIsValidatingBaseResume(true);
|
||||
@ -150,6 +178,7 @@ export const OnboardingGate: React.FC = () => {
|
||||
showBaseUrl,
|
||||
requiresApiKey: requiresLlmKey,
|
||||
} = providerConfig;
|
||||
|
||||
const llmKeyHint =
|
||||
settings?.llmApiKeyHint ?? settings?.openrouterApiKeyHint ?? null;
|
||||
const hasLlmKey = Boolean(llmKeyHint);
|
||||
@ -170,26 +199,31 @@ export const OnboardingGate: React.FC = () => {
|
||||
? settings.rxresumeEmail
|
||||
: undefined;
|
||||
const rxresumePasswordCurrent = settings?.rxresumePasswordHint
|
||||
? formatSecretHint(settings.rxresumePasswordHint)
|
||||
? formatSecretHint(settings?.rxresumePasswordHint)
|
||||
: undefined;
|
||||
|
||||
// Initialize form values from settings
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setRxresumeBaseResumeId(settings.rxresumeBaseResumeId || null);
|
||||
if (!llmProvider && settings.llmProvider) {
|
||||
setLlmProvider(settings.llmProvider);
|
||||
}
|
||||
if (!llmBaseUrl && settings.llmBaseUrl) {
|
||||
setLlmBaseUrl(settings.llmBaseUrl);
|
||||
}
|
||||
reset({
|
||||
llmProvider: settings.llmProvider || "",
|
||||
llmBaseUrl: settings.llmBaseUrl || "",
|
||||
llmApiKey: "",
|
||||
rxresumeEmail: "",
|
||||
rxresumePassword: "",
|
||||
rxresumeBaseResumeId: settings.rxresumeBaseResumeId || null,
|
||||
});
|
||||
}
|
||||
}, [llmBaseUrl, llmProvider, settings]);
|
||||
}, [settings, reset]);
|
||||
|
||||
// Clear base URL when provider doesn't require it
|
||||
useEffect(() => {
|
||||
if (showBaseUrl) return;
|
||||
if (llmBaseUrl) setLlmBaseUrl("");
|
||||
}, [llmBaseUrl, showBaseUrl]);
|
||||
if (!showBaseUrl) {
|
||||
setValue("llmBaseUrl", "");
|
||||
}
|
||||
}, [showBaseUrl, setValue]);
|
||||
|
||||
// Reset LLM validation when provider changes
|
||||
useEffect(() => {
|
||||
if (!selectedProvider) return;
|
||||
setLlmValidation({ valid: false, message: null, checked: false });
|
||||
@ -235,13 +269,7 @@ export const OnboardingGate: React.FC = () => {
|
||||
if (!settings) return;
|
||||
const validations: Promise<ValidationResult>[] = [];
|
||||
if (requiresLlmKey) {
|
||||
validations.push(
|
||||
validateLlm({
|
||||
provider: normalizedProvider,
|
||||
baseUrl: llmBaseUrl.trim() || undefined,
|
||||
apiKey: llmApiKey.trim() || undefined,
|
||||
}),
|
||||
);
|
||||
validations.push(validateLlm());
|
||||
} else {
|
||||
setLlmValidation({ valid: true, message: null, checked: true });
|
||||
}
|
||||
@ -262,11 +290,9 @@ export const OnboardingGate: React.FC = () => {
|
||||
validateLlm,
|
||||
validateRxresume,
|
||||
validateBaseResume,
|
||||
normalizedProvider,
|
||||
llmBaseUrl,
|
||||
llmApiKey,
|
||||
]);
|
||||
|
||||
// Run validations on mount when needed
|
||||
useEffect(() => {
|
||||
if (!settings || settingsLoading) return;
|
||||
const needsValidation =
|
||||
@ -300,8 +326,9 @@ export const OnboardingGate: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleSaveLlm = async (): Promise<boolean> => {
|
||||
const apiKeyValue = llmApiKey.trim();
|
||||
const baseUrlValue = llmBaseUrl.trim();
|
||||
const values = getValues();
|
||||
const apiKeyValue = values.llmApiKey.trim();
|
||||
const baseUrlValue = values.llmBaseUrl.trim();
|
||||
|
||||
if (requiresLlmKey && !apiKeyValue && !hasLlmKey) {
|
||||
toast.info("Add your LLM API key to continue");
|
||||
@ -310,11 +337,7 @@ export const OnboardingGate: React.FC = () => {
|
||||
|
||||
try {
|
||||
const validation = requiresLlmKey
|
||||
? await validateLlm({
|
||||
provider: normalizedProvider,
|
||||
baseUrl: baseUrlValue || undefined,
|
||||
apiKey: apiKeyValue || undefined,
|
||||
})
|
||||
? await validateLlm()
|
||||
: { valid: true, message: null };
|
||||
|
||||
if (!validation.valid) {
|
||||
@ -338,7 +361,7 @@ export const OnboardingGate: React.FC = () => {
|
||||
setIsSavingEnv(true);
|
||||
await api.updateSettings(update);
|
||||
await refreshSettings();
|
||||
setLlmApiKey("");
|
||||
setValue("llmApiKey", "");
|
||||
toast.success("LLM provider connected");
|
||||
return true;
|
||||
} catch (error) {
|
||||
@ -352,8 +375,9 @@ export const OnboardingGate: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleSaveRxresume = async (): Promise<boolean> => {
|
||||
const emailValue = rxresumeEmail.trim();
|
||||
const passwordValue = rxresumePassword.trim();
|
||||
const values = getValues();
|
||||
const emailValue = values.rxresumeEmail.trim();
|
||||
const passwordValue = values.rxresumePassword.trim();
|
||||
const missing: string[] = [];
|
||||
|
||||
if (!hasRxresumeEmail && !emailValue) missing.push("RxResume email");
|
||||
@ -368,10 +392,7 @@ export const OnboardingGate: React.FC = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = await validateRxresume(
|
||||
emailValue || undefined,
|
||||
passwordValue || undefined,
|
||||
);
|
||||
const validation = await validateRxresume();
|
||||
if (!validation.valid) {
|
||||
toast.error(validation.message || "RxResume validation failed");
|
||||
return false;
|
||||
@ -385,7 +406,7 @@ export const OnboardingGate: React.FC = () => {
|
||||
setIsSavingEnv(true);
|
||||
await api.updateSettings(update);
|
||||
await refreshSettings();
|
||||
setRxresumePassword("");
|
||||
setValue("rxresumePassword", "");
|
||||
}
|
||||
|
||||
toast.success("RxResume connected");
|
||||
@ -403,14 +424,18 @@ export const OnboardingGate: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleSaveBaseResume = async (): Promise<boolean> => {
|
||||
if (!rxresumeBaseResumeId) {
|
||||
const values = getValues();
|
||||
|
||||
if (!values.rxresumeBaseResumeId) {
|
||||
toast.info("Select a base resume to continue");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSavingEnv(true);
|
||||
await api.updateSettings({ rxresumeBaseResumeId: rxresumeBaseResumeId });
|
||||
await api.updateSettings({
|
||||
rxresumeBaseResumeId: values.rxresumeBaseResumeId,
|
||||
});
|
||||
const validation = await validateBaseResume();
|
||||
if (!validation.valid) {
|
||||
toast.error(validation.message || "Base resume validation failed");
|
||||
@ -492,7 +517,7 @@ export const OnboardingGate: React.FC = () => {
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Welcome to Job Ops</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Let’s get your workspace ready. Add your keys and resume once,
|
||||
Let's get your workspace ready. Add your keys and resume once,
|
||||
then the pipeline can run end-to-end.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
@ -559,53 +584,73 @@ export const OnboardingGate: React.FC = () => {
|
||||
<label htmlFor="llmProvider" className="text-sm font-medium">
|
||||
Provider
|
||||
</label>
|
||||
<Select
|
||||
value={selectedProvider}
|
||||
onValueChange={(value) => setLlmProvider(value)}
|
||||
disabled={isSavingEnv}
|
||||
>
|
||||
<SelectTrigger id="llmProvider">
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LLM_PROVIDERS.map((provider) => (
|
||||
<SelectItem key={provider} value={provider}>
|
||||
{LLM_PROVIDER_LABELS[provider]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Controller
|
||||
name="llmProvider"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={selectedProvider}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
disabled={isSavingEnv}
|
||||
>
|
||||
<SelectTrigger id="llmProvider">
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LLM_PROVIDERS.map((provider) => (
|
||||
<SelectItem key={provider} value={provider}>
|
||||
{LLM_PROVIDER_LABELS[provider]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{providerConfig.providerHint}
|
||||
</p>
|
||||
</div>
|
||||
{showBaseUrl && (
|
||||
<SettingsInput
|
||||
label="LLM base URL"
|
||||
inputProps={{
|
||||
name: "llmBaseUrl",
|
||||
value: llmBaseUrl,
|
||||
onChange: (event) => setLlmBaseUrl(event.target.value),
|
||||
}}
|
||||
placeholder={providerConfig.baseUrlPlaceholder}
|
||||
helper={providerConfig.baseUrlHelper}
|
||||
current={settings?.llmBaseUrl || "—"}
|
||||
disabled={isSavingEnv}
|
||||
<Controller
|
||||
name="llmBaseUrl"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsInput
|
||||
label="LLM base URL"
|
||||
inputProps={{
|
||||
name: "llmBaseUrl",
|
||||
value: field.value,
|
||||
onChange: field.onChange,
|
||||
}}
|
||||
placeholder={providerConfig.baseUrlPlaceholder}
|
||||
helper={providerConfig.baseUrlHelper}
|
||||
current={settings?.llmBaseUrl || "—"}
|
||||
disabled={isSavingEnv}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{showApiKey && (
|
||||
<SettingsInput
|
||||
label="LLM API key"
|
||||
inputProps={{
|
||||
name: "llmApiKey",
|
||||
value: llmApiKey,
|
||||
onChange: (event) => setLlmApiKey(event.target.value),
|
||||
}}
|
||||
type="password"
|
||||
placeholder="Enter key"
|
||||
current={llmKeyCurrent}
|
||||
helper={providerConfig.keyHelper}
|
||||
disabled={isSavingEnv}
|
||||
<Controller
|
||||
name="llmApiKey"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsInput
|
||||
label="LLM API key"
|
||||
inputProps={{
|
||||
name: "llmApiKey",
|
||||
value: field.value,
|
||||
onChange: field.onChange,
|
||||
}}
|
||||
type="password"
|
||||
placeholder="Enter key"
|
||||
current={llmKeyCurrent}
|
||||
helper={providerConfig.keyHelper}
|
||||
disabled={isSavingEnv}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -621,29 +666,40 @@ export const OnboardingGate: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<SettingsInput
|
||||
label="Email"
|
||||
inputProps={{
|
||||
name: "rxresumeEmail",
|
||||
value: rxresumeEmail,
|
||||
onChange: (event) => setRxresumeEmail(event.target.value),
|
||||
}}
|
||||
placeholder="you@example.com"
|
||||
current={rxresumeEmailCurrent}
|
||||
disabled={isSavingEnv}
|
||||
<Controller
|
||||
name="rxresumeEmail"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsInput
|
||||
label="Email"
|
||||
inputProps={{
|
||||
name: "rxresumeEmail",
|
||||
value: field.value,
|
||||
onChange: field.onChange,
|
||||
}}
|
||||
placeholder="you@example.com"
|
||||
current={rxresumeEmailCurrent}
|
||||
disabled={isSavingEnv}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SettingsInput
|
||||
label="Password"
|
||||
inputProps={{
|
||||
name: "rxresumePassword",
|
||||
value: rxresumePassword,
|
||||
onChange: (event) =>
|
||||
setRxresumePassword(event.target.value),
|
||||
}}
|
||||
type="password"
|
||||
placeholder="Enter password"
|
||||
current={rxresumePasswordCurrent}
|
||||
disabled={isSavingEnv}
|
||||
<Controller
|
||||
name="rxresumePassword"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsInput
|
||||
label="Password"
|
||||
inputProps={{
|
||||
name: "rxresumePassword",
|
||||
value: field.value,
|
||||
onChange: field.onChange,
|
||||
}}
|
||||
type="password"
|
||||
placeholder="Enter password"
|
||||
current={rxresumePasswordCurrent}
|
||||
disabled={isSavingEnv}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@ -658,11 +714,17 @@ export const OnboardingGate: React.FC = () => {
|
||||
resume will be used as a template for tailoring.
|
||||
</p>
|
||||
</div>
|
||||
<BaseResumeSelection
|
||||
value={rxresumeBaseResumeId}
|
||||
onValueChange={setRxresumeBaseResumeId}
|
||||
hasRxResumeAccess={rxresumeValidation.valid}
|
||||
disabled={isSavingEnv}
|
||||
<Controller
|
||||
name="rxresumeBaseResumeId"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<BaseResumeSelection
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
hasRxResumeAccess={rxresumeValidation.valid}
|
||||
disabled={isSavingEnv}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@ -0,0 +1,347 @@
|
||||
/**
|
||||
* ApplicationsPerDayChart Edge Case Tests
|
||||
* Tests real-world edge cases and data transformation logic
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApplicationsPerDayChart } from "./ApplicationsPerDayChart";
|
||||
|
||||
// Mock UI components
|
||||
vi.mock("@/components/ui/card", () => ({
|
||||
Card: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="card">{children}</div>
|
||||
),
|
||||
CardContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="card-content">{children}</div>
|
||||
),
|
||||
CardHeader: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="card-header">{children}</div>
|
||||
),
|
||||
CardTitle: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="card-title">{children}</div>
|
||||
),
|
||||
CardDescription: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="card-description">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/chart", () => ({
|
||||
ChartContainer: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="chart-container">{children}</div>
|
||||
),
|
||||
ChartTooltip: () => <div data-testid="chart-tooltip">Tooltip</div>,
|
||||
ChartTooltipContent: () => (
|
||||
<div data-testid="chart-tooltip-content">TooltipContent</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("recharts", () => ({
|
||||
BarChart: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="bar-chart">{children}</div>
|
||||
),
|
||||
Bar: () => <div data-testid="bar">Bar</div>,
|
||||
CartesianGrid: () => <div data-testid="cartesian-grid">Grid</div>,
|
||||
XAxis: () => <div data-testid="x-axis">XAxis</div>,
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
TrendingUp: () => <div data-testid="trending-up">TrendingUp</div>,
|
||||
TrendingDown: () => <div data-testid="trending-down">TrendingDown</div>,
|
||||
}));
|
||||
|
||||
describe("ApplicationsPerDayChart - Edge Cases", () => {
|
||||
const mockDate = new Date("2025-01-15T12:00:00Z");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(mockDate);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("Empty and Null Data", () => {
|
||||
it("handles empty appliedAt array - shows zero total and average", () => {
|
||||
render(
|
||||
<ApplicationsPerDayChart
|
||||
appliedAt={[]}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("0.0")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Last 7 days · 0 total/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles appliedAt with all null values - filters out nulls correctly", () => {
|
||||
render(
|
||||
<ApplicationsPerDayChart
|
||||
appliedAt={[null, null, null]}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("0.0")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Last 7 days · 0 total/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles mixed null and valid dates - counts only valid dates", () => {
|
||||
const today = mockDate.toISOString();
|
||||
render(
|
||||
<ApplicationsPerDayChart
|
||||
appliedAt={[null, today, null, today, today]}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Last 7 days · 3 total/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Invalid Date Handling", () => {
|
||||
it("filters out invalid date strings", () => {
|
||||
const today = mockDate.toISOString();
|
||||
render(
|
||||
<ApplicationsPerDayChart
|
||||
appliedAt={["invalid-date", today, "", "not-a-date", today]}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Last 7 days · 2 total/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles malformed ISO dates gracefully", () => {
|
||||
const today = mockDate.toISOString();
|
||||
render(
|
||||
<ApplicationsPerDayChart
|
||||
appliedAt={["2025-13-45", today, "2025-01-00", today]}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Last 7 days · 2 total/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Date Range Filtering", () => {
|
||||
it("filters out dates before the start of range", () => {
|
||||
const today = mockDate.toISOString();
|
||||
const oldDate = "2025-01-01T00:00:00Z"; // Before 7-day window
|
||||
render(
|
||||
<ApplicationsPerDayChart
|
||||
appliedAt={[oldDate, today, today]}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Last 7 days · 2 total/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters out dates after the end of range (future dates)", () => {
|
||||
const today = mockDate.toISOString();
|
||||
const futureDate = "2025-01-20T00:00:00Z"; // After today
|
||||
render(
|
||||
<ApplicationsPerDayChart
|
||||
appliedAt={[today, futureDate, today]}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Last 7 days · 2 total/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles single day range (daysToShow=1)", () => {
|
||||
const today = mockDate.toISOString();
|
||||
const yesterday = "2025-01-14T00:00:00Z";
|
||||
render(
|
||||
<ApplicationsPerDayChart
|
||||
appliedAt={[today, yesterday, today]}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
daysToShow={1}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Last 1 days · 2 total/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Trend Calculation Edge Cases", () => {
|
||||
it("shows neutral trend when first half average is 0 and second half is also 0", () => {
|
||||
// All zeros - no trend indicator should show
|
||||
render(
|
||||
<ApplicationsPerDayChart
|
||||
appliedAt={[]}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("trending-up")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("trending-down")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows up trend when first half is 0 but second half has activity", () => {
|
||||
const dates = [
|
||||
"2025-01-15T00:00:00Z", // Today (second half)
|
||||
"2025-01-15T00:00:00Z",
|
||||
"2025-01-15T00:00:00Z",
|
||||
];
|
||||
render(
|
||||
<ApplicationsPerDayChart
|
||||
appliedAt={dates}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("trending-up")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calculates trend percentage correctly for positive trend", () => {
|
||||
// First half: 1 app per day avg, Second half: 3 apps per day avg = 200% increase
|
||||
const dates = [
|
||||
"2025-01-09T00:00:00Z", // First half
|
||||
"2025-01-15T00:00:00Z", // Second half
|
||||
"2025-01-15T00:00:00Z",
|
||||
"2025-01-15T00:00:00Z",
|
||||
];
|
||||
render(
|
||||
<ApplicationsPerDayChart
|
||||
appliedAt={dates}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("trending-up")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows down trend for significant negative trend", () => {
|
||||
// First half: high activity, Second half: low activity
|
||||
const dates = [
|
||||
"2025-01-09T00:00:00Z", // First half - 3 apps
|
||||
"2025-01-09T00:00:00Z",
|
||||
"2025-01-09T00:00:00Z",
|
||||
"2025-01-15T00:00:00Z", // Second half - 1 app
|
||||
];
|
||||
render(
|
||||
<ApplicationsPerDayChart
|
||||
appliedAt={dates}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("trending-down")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Loading and Error States", () => {
|
||||
it("shows loading state description", () => {
|
||||
render(
|
||||
<ApplicationsPerDayChart
|
||||
appliedAt={[]}
|
||||
isLoading={true}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Loading applied jobs...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays error message when error prop is set", () => {
|
||||
render(
|
||||
<ApplicationsPerDayChart
|
||||
appliedAt={[]}
|
||||
isLoading={false}
|
||||
error="Failed to load application data"
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Failed to load application data"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("chart-container")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders chart when no error", () => {
|
||||
render(
|
||||
<ApplicationsPerDayChart
|
||||
appliedAt={[]}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("chart-container")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Large Data Stress Tests", () => {
|
||||
it("handles large number of applications efficiently", () => {
|
||||
const today = mockDate.toISOString();
|
||||
const largeData = Array(1000).fill(today);
|
||||
|
||||
render(
|
||||
<ApplicationsPerDayChart
|
||||
appliedAt={largeData}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Last 7 days · 1,000 total/)).toBeInTheDocument();
|
||||
expect(screen.getByText("142.9")).toBeInTheDocument(); // 1000/7
|
||||
});
|
||||
|
||||
it("handles applications spread across different days in range", () => {
|
||||
const dates = [
|
||||
"2025-01-09T00:00:00Z",
|
||||
"2025-01-10T00:00:00Z",
|
||||
"2025-01-10T00:00:00Z",
|
||||
"2025-01-11T00:00:00Z",
|
||||
"2025-01-11T00:00:00Z",
|
||||
"2025-01-11T00:00:00Z",
|
||||
"2025-01-15T00:00:00Z",
|
||||
];
|
||||
render(
|
||||
<ApplicationsPerDayChart
|
||||
appliedAt={dates}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Last 7 days · 7 total/)).toBeInTheDocument();
|
||||
expect(screen.getByText("1.0")).toBeInTheDocument(); // 7/7
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Applications Per Day Chart
|
||||
* Shows daily application volume over a selected time range.
|
||||
*/
|
||||
|
||||
import { TrendingDown, TrendingUp } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
|
||||
type DailyApplications = {
|
||||
date: string;
|
||||
applications: number;
|
||||
};
|
||||
|
||||
const chartConfig = {
|
||||
applications: {
|
||||
label: "Applications",
|
||||
color: "var(--chart-1)",
|
||||
},
|
||||
};
|
||||
|
||||
const toDateKey = (value: Date) => {
|
||||
const year = value.getFullYear();
|
||||
const month = `${value.getMonth() + 1}`.padStart(2, "0");
|
||||
const day = `${value.getDate()}`.padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const buildApplicationsPerDay = (
|
||||
appliedAt: Array<string | null>,
|
||||
daysToShow: number,
|
||||
) => {
|
||||
const end = new Date();
|
||||
end.setHours(23, 59, 59, 999);
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - (daysToShow - 1));
|
||||
start.setHours(0, 0, 0, 0);
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
for (const value of appliedAt) {
|
||||
if (!value) continue;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) continue;
|
||||
if (date < start || date > end) continue;
|
||||
const key = toDateKey(date);
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const data: DailyApplications[] = [];
|
||||
for (
|
||||
let day = new Date(start);
|
||||
day <= end;
|
||||
day = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1)
|
||||
) {
|
||||
const key = toDateKey(day);
|
||||
data.push({ date: key, applications: counts.get(key) ?? 0 });
|
||||
}
|
||||
|
||||
const total = data.reduce((sum, item) => sum + item.applications, 0);
|
||||
|
||||
// Calculate trend by comparing first half vs second half
|
||||
const halfPoint = Math.floor(data.length / 2);
|
||||
const firstHalf = data.slice(0, halfPoint);
|
||||
const secondHalf = data.slice(halfPoint);
|
||||
const firstHalfAvg =
|
||||
firstHalf.length > 0
|
||||
? firstHalf.reduce((sum, item) => sum + item.applications, 0) /
|
||||
firstHalf.length
|
||||
: 0;
|
||||
const secondHalfAvg =
|
||||
secondHalf.length > 0
|
||||
? secondHalf.reduce((sum, item) => sum + item.applications, 0) /
|
||||
secondHalf.length
|
||||
: 0;
|
||||
const trend =
|
||||
firstHalfAvg === 0
|
||||
? secondHalfAvg > 0
|
||||
? "up"
|
||||
: "neutral"
|
||||
: ((secondHalfAvg - firstHalfAvg) / firstHalfAvg) * 100;
|
||||
|
||||
return { data, total, trend };
|
||||
};
|
||||
|
||||
interface ApplicationsPerDayChartProps {
|
||||
appliedAt: Array<string | null>;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
daysToShow: number;
|
||||
}
|
||||
|
||||
export function ApplicationsPerDayChart({
|
||||
appliedAt,
|
||||
isLoading,
|
||||
error,
|
||||
daysToShow,
|
||||
}: ApplicationsPerDayChartProps) {
|
||||
const {
|
||||
data: chartData,
|
||||
total,
|
||||
trend,
|
||||
} = useMemo(() => {
|
||||
return buildApplicationsPerDay(appliedAt, daysToShow);
|
||||
}, [appliedAt, daysToShow]);
|
||||
|
||||
const average = useMemo(() => {
|
||||
if (chartData.length === 0) return 0;
|
||||
return total / chartData.length;
|
||||
}, [chartData, total]);
|
||||
|
||||
const showTrendUp = typeof trend === "number" ? trend > 5 : trend === "up";
|
||||
const showTrendDown = typeof trend === "number" ? trend < -5 : false;
|
||||
|
||||
return (
|
||||
<Card className="py-0">
|
||||
<CardHeader className="flex flex-col gap-2 border-b !p-0 sm:flex-row sm:items-stretch">
|
||||
<div className="flex flex-1 flex-col justify-center gap-1 px-6 pt-4 pb-3 sm:!py-0">
|
||||
<CardTitle>Applications per day</CardTitle>
|
||||
<CardDescription>
|
||||
{isLoading
|
||||
? "Loading applied jobs..."
|
||||
: `Last ${daysToShow} days · ${total.toLocaleString()} total`}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-center gap-3 border-t px-6 py-4 text-left sm:border-t-0 sm:border-l sm:px-8 sm:py-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Avg / day</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-bold leading-none sm:text-3xl">
|
||||
{average.toFixed(1)}
|
||||
</span>
|
||||
{showTrendUp ? (
|
||||
<TrendingUp className="h-4 w-4 text-emerald-500" />
|
||||
) : showTrendDown ? (
|
||||
<TrendingDown className="h-4 w-4 text-destructive" />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 sm:p-6">
|
||||
{error ? (
|
||||
<div className="px-4 py-6 text-sm text-destructive">{error}</div>
|
||||
) : (
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[280px] w-full"
|
||||
>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={chartData}
|
||||
margin={{ left: 12, right: 12 }}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString("en-GB", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={{ fill: "var(--chart-1)", opacity: 0.3 }}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
className="w-[160px]"
|
||||
nameKey="applications"
|
||||
labelFormatter={(value) =>
|
||||
new Date(value as string).toLocaleDateString("en-GB", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="applications"
|
||||
fill="var(--color-applications)"
|
||||
radius={[6, 6, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,497 @@
|
||||
/**
|
||||
* ConversionAnalytics Edge Case Tests
|
||||
* Tests real-world edge cases for conversion funnel and analytics
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ApplicationStage, StageEvent } from "../../../shared/types";
|
||||
import { ConversionAnalytics } from "./ConversionAnalytics";
|
||||
|
||||
// Mock UI components
|
||||
vi.mock("@/components/ui/card", () => ({
|
||||
Card: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="card">{children}</div>
|
||||
),
|
||||
CardContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="card-content">{children}</div>
|
||||
),
|
||||
CardHeader: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="card-header">{children}</div>
|
||||
),
|
||||
CardTitle: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="card-title">{children}</div>
|
||||
),
|
||||
CardDescription: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="card-description">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/chart", () => ({
|
||||
ChartContainer: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="chart-container">{children}</div>
|
||||
),
|
||||
ChartTooltip: () => <div data-testid="chart-tooltip">Tooltip</div>,
|
||||
}));
|
||||
|
||||
vi.mock("recharts", () => ({
|
||||
BarChart: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="bar-chart">{children}</div>
|
||||
),
|
||||
Bar: () => <div data-testid="bar">Bar</div>,
|
||||
Cell: () => <div data-testid="cell">Cell</div>,
|
||||
LabelList: () => <div data-testid="label-list">LabelList</div>,
|
||||
LineChart: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="line-chart">{children}</div>
|
||||
),
|
||||
Line: () => <div data-testid="line">Line</div>,
|
||||
CartesianGrid: () => <div data-testid="cartesian-grid">Grid</div>,
|
||||
XAxis: () => <div data-testid="x-axis">XAxis</div>,
|
||||
YAxis: () => <div data-testid="y-axis">YAxis</div>,
|
||||
Tooltip: () => <div data-testid="tooltip">Tooltip</div>,
|
||||
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="responsive-container">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
TrendingUp: () => <div data-testid="trending-up">TrendingUp</div>,
|
||||
TrendingDown: () => <div data-testid="trending-down">TrendingDown</div>,
|
||||
}));
|
||||
|
||||
describe("ConversionAnalytics - Edge Cases", () => {
|
||||
const mockDate = new Date("2025-01-15T12:00:00Z");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(mockDate);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const createJob = (
|
||||
id: string,
|
||||
appliedAt: string | null,
|
||||
events: StageEvent[] = [],
|
||||
) => ({
|
||||
id,
|
||||
datePosted: null,
|
||||
discoveredAt: "2025-01-01T00:00:00Z",
|
||||
appliedAt,
|
||||
events,
|
||||
});
|
||||
|
||||
const createEvent = (
|
||||
toStage: ApplicationStage,
|
||||
occurredAt: number,
|
||||
): StageEvent => ({
|
||||
id: `event-${toStage}`,
|
||||
applicationId: "job-1",
|
||||
title: `Moved to ${toStage}`,
|
||||
groupId: null,
|
||||
fromStage: "applied",
|
||||
toStage,
|
||||
occurredAt,
|
||||
metadata: null,
|
||||
outcome: null,
|
||||
});
|
||||
|
||||
describe("Empty and Null Data", () => {
|
||||
it("handles empty jobsWithEvents array - shows 0% conversion", () => {
|
||||
render(
|
||||
<ConversionAnalytics jobsWithEvents={[]} error={null} daysToShow={7} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("0.0%")).toBeInTheDocument();
|
||||
expect(screen.getByText(/0 of 0 applications/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("excludes jobs with null appliedAt from conversion calculation", () => {
|
||||
const jobs = [
|
||||
createJob("job-1", null, []),
|
||||
createJob("job-2", null, [
|
||||
createEvent("recruiter_screen", 1704844800000),
|
||||
]),
|
||||
];
|
||||
|
||||
render(
|
||||
<ConversionAnalytics
|
||||
jobsWithEvents={jobs}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("0.0%")).toBeInTheDocument();
|
||||
expect(screen.getByText(/0 of 0 applications/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("counts all jobs with appliedAt regardless of date range for overall stats", () => {
|
||||
const today = mockDate.toISOString();
|
||||
const oldDate = "2025-01-01T00:00:00Z"; // Outside 7-day range
|
||||
const jobs = [
|
||||
createJob("job-1", today, []),
|
||||
createJob("job-2", today, []),
|
||||
createJob("job-3", oldDate, []), // Still counted in overall stats
|
||||
];
|
||||
|
||||
render(
|
||||
<ConversionAnalytics
|
||||
jobsWithEvents={jobs}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Overall conversion counts all jobs with appliedAt (not filtered by date)
|
||||
expect(screen.getByText(/0 of 3 applications/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Conversion Rate Edge Cases", () => {
|
||||
it("shows 0% conversion when no jobs have conversion events", () => {
|
||||
const today = mockDate.toISOString();
|
||||
const jobs = [
|
||||
createJob("job-1", today, []),
|
||||
createJob("job-2", today, []),
|
||||
createJob("job-3", today, [createEvent("closed", 1704844800000)]),
|
||||
];
|
||||
|
||||
render(
|
||||
<ConversionAnalytics
|
||||
jobsWithEvents={jobs}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("0.0%")).toBeInTheDocument();
|
||||
expect(screen.getByText(/0 of 3 applications/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("trending-down")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 100% conversion when all jobs have conversion events", () => {
|
||||
const today = mockDate.toISOString();
|
||||
const jobs = [
|
||||
createJob("job-1", today, [
|
||||
createEvent("recruiter_screen", 1704844800000),
|
||||
]),
|
||||
createJob("job-2", today, [
|
||||
createEvent("technical_interview", 1704844800000),
|
||||
]),
|
||||
];
|
||||
|
||||
render(
|
||||
<ConversionAnalytics
|
||||
jobsWithEvents={jobs}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("100.0%")).toBeInTheDocument();
|
||||
expect(screen.getByText(/2 of 2 applications/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("trending-up")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calculates partial conversion rate correctly", () => {
|
||||
const today = mockDate.toISOString();
|
||||
const jobs = [
|
||||
createJob("job-1", today, [
|
||||
createEvent("recruiter_screen", 1704844800000),
|
||||
]),
|
||||
createJob("job-2", today, []),
|
||||
createJob("job-3", today, []),
|
||||
createJob("job-4", today, [createEvent("offer", 1704844800000)]),
|
||||
];
|
||||
|
||||
render(
|
||||
<ConversionAnalytics
|
||||
jobsWithEvents={jobs}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("50.0%")).toBeInTheDocument();
|
||||
expect(screen.getByText(/2 of 4 applications/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles jobs with multiple events - counts as converted if any event is in CONVERSION_STAGES", () => {
|
||||
const today = mockDate.toISOString();
|
||||
const jobs = [
|
||||
createJob("job-1", today, [
|
||||
createEvent("closed", 1704844800000),
|
||||
createEvent("recruiter_screen", 1704931200000),
|
||||
]),
|
||||
];
|
||||
|
||||
render(
|
||||
<ConversionAnalytics
|
||||
jobsWithEvents={jobs}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("100.0%")).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 of 1 applications/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Funnel Data Edge Cases", () => {
|
||||
it("shows all zeros in funnel when no jobs are applied", () => {
|
||||
const jobs = [createJob("job-1", null, []), createJob("job-2", null, [])];
|
||||
|
||||
render(
|
||||
<ConversionAnalytics
|
||||
jobsWithEvents={jobs}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Funnel should still render with 0 values
|
||||
expect(screen.getByTestId("bar-chart")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("correctly categorizes screening stages (recruiter_screen, assessment)", () => {
|
||||
const today = mockDate.toISOString();
|
||||
const jobs = [
|
||||
createJob("job-1", today, [
|
||||
createEvent("recruiter_screen", 1704844800000),
|
||||
]),
|
||||
createJob("job-2", today, [createEvent("assessment", 1704844800000)]),
|
||||
createJob("job-3", today, []),
|
||||
];
|
||||
|
||||
render(
|
||||
<ConversionAnalytics
|
||||
jobsWithEvents={jobs}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Both recruiter_screen and assessment count as screening
|
||||
expect(screen.getByTestId("bar-chart")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("correctly categorizes interview stages", () => {
|
||||
const today = mockDate.toISOString();
|
||||
const jobs = [
|
||||
createJob("job-1", today, [
|
||||
createEvent("hiring_manager_screen", 1704844800000),
|
||||
]),
|
||||
createJob("job-2", today, [
|
||||
createEvent("technical_interview", 1704844800000),
|
||||
]),
|
||||
createJob("job-3", today, [createEvent("onsite", 1704844800000)]),
|
||||
];
|
||||
|
||||
render(
|
||||
<ConversionAnalytics
|
||||
jobsWithEvents={jobs}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("bar-chart")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles job that reached multiple funnel stages", () => {
|
||||
const today = mockDate.toISOString();
|
||||
const jobs = [
|
||||
createJob("job-1", today, [
|
||||
createEvent("recruiter_screen", 1704844800000),
|
||||
createEvent("technical_interview", 1704931200000),
|
||||
createEvent("offer", 1705017600000),
|
||||
]),
|
||||
];
|
||||
|
||||
render(
|
||||
<ConversionAnalytics
|
||||
jobsWithEvents={jobs}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Job should count in all stages it reached
|
||||
expect(screen.getByTestId("bar-chart")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Date Range and Invalid Dates", () => {
|
||||
it("counts jobs with any non-null appliedAt (overall stats don't validate dates)", () => {
|
||||
const today = mockDate.toISOString();
|
||||
const jobs = [
|
||||
createJob("job-1", today, []),
|
||||
createJob("job-2", "invalid-date", []),
|
||||
createJob("job-3", "", []),
|
||||
];
|
||||
|
||||
render(
|
||||
<ConversionAnalytics
|
||||
jobsWithEvents={jobs}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
// calculateOverallConversion only checks !job.appliedAt (null/undefined)
|
||||
// Empty string "" is falsy in JS, so it's filtered. "invalid-date" is truthy, so counted.
|
||||
// Result: job-1 and job-2 are counted = 2 total
|
||||
expect(screen.getByText(/0 of 2 applications/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("includes jobs outside date range in overall conversion stats", () => {
|
||||
const oldDate = "2025-01-01T00:00:00Z"; // Before 7-day window
|
||||
const jobs = [
|
||||
createJob("job-1", oldDate, [createEvent("offer", 1704153600000)]),
|
||||
createJob("job-2", oldDate, [
|
||||
createEvent("recruiter_screen", 1704153600000),
|
||||
]),
|
||||
];
|
||||
|
||||
render(
|
||||
<ConversionAnalytics
|
||||
jobsWithEvents={jobs}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Overall conversion counts all jobs with appliedAt (not filtered by date)
|
||||
// Both jobs have conversion events (offer and recruiter_screen)
|
||||
expect(screen.getByText("100.0%")).toBeInTheDocument();
|
||||
expect(screen.getByText(/2 of 2 applications/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error State", () => {
|
||||
it("displays error message when error prop is set", () => {
|
||||
render(
|
||||
<ConversionAnalytics
|
||||
jobsWithEvents={[]}
|
||||
error="Failed to fetch conversion data"
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Failed to fetch conversion data"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("bar-chart")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("line-chart")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders charts when no error", () => {
|
||||
render(
|
||||
<ConversionAnalytics jobsWithEvents={[]} error={null} daysToShow={7} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("bar-chart")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("line-chart")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Trend Indicator Logic", () => {
|
||||
it("shows down trend indicator when conversion rate is below 10%", () => {
|
||||
const today = mockDate.toISOString();
|
||||
const jobs = [
|
||||
createJob("job-1", today, []),
|
||||
createJob("job-2", today, []),
|
||||
createJob("job-3", today, []),
|
||||
createJob("job-4", today, []),
|
||||
createJob("job-5", today, [
|
||||
createEvent("recruiter_screen", 1704844800000),
|
||||
]),
|
||||
];
|
||||
|
||||
render(
|
||||
<ConversionAnalytics
|
||||
jobsWithEvents={jobs}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("20.0%")).toBeInTheDocument();
|
||||
// 20% is not < 10%, so no trending-down
|
||||
expect(screen.queryByTestId("trending-down")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no trend indicator for moderate conversion rates (10-25%)", () => {
|
||||
const today = mockDate.toISOString();
|
||||
const jobs = [
|
||||
createJob("job-1", today, [
|
||||
createEvent("recruiter_screen", 1704844800000),
|
||||
]),
|
||||
createJob("job-2", today, []),
|
||||
createJob("job-3", today, []),
|
||||
createJob("job-4", today, []),
|
||||
];
|
||||
|
||||
render(
|
||||
<ConversionAnalytics
|
||||
jobsWithEvents={jobs}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("25.0%")).toBeInTheDocument();
|
||||
// 25% is not > 25%, so no trending-up
|
||||
expect(screen.queryByTestId("trending-up")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("trending-down")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Time Series Data Edge Cases", () => {
|
||||
it("handles conversion rate calculation with rolling window", () => {
|
||||
const today = mockDate.toISOString();
|
||||
const yesterday = "2025-01-14T00:00:00Z";
|
||||
const jobs = [
|
||||
createJob("job-1", today, [
|
||||
createEvent("recruiter_screen", 1705276800000),
|
||||
]),
|
||||
createJob("job-2", yesterday, []),
|
||||
];
|
||||
|
||||
render(
|
||||
<ConversionAnalytics
|
||||
jobsWithEvents={jobs}
|
||||
error={null}
|
||||
daysToShow={7}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("line-chart")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles single day range for time series", () => {
|
||||
const today = mockDate.toISOString();
|
||||
const jobs = [
|
||||
createJob("job-1", today, [
|
||||
createEvent("recruiter_screen", 1705276800000),
|
||||
]),
|
||||
];
|
||||
|
||||
render(
|
||||
<ConversionAnalytics
|
||||
jobsWithEvents={jobs}
|
||||
error={null}
|
||||
daysToShow={1}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("line-chart")).toBeInTheDocument();
|
||||
expect(screen.getByText(/rolling 1-day average/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,468 @@
|
||||
/**
|
||||
* Conversion Analytics
|
||||
* Shows Application → Response conversion metrics including funnel, time-series, and insights.
|
||||
*/
|
||||
|
||||
import { TrendingDown, TrendingUp } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
LabelList,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
|
||||
import type { StageEvent } from "../../../shared/types";
|
||||
|
||||
type FunnelStage = {
|
||||
name: string;
|
||||
value: number;
|
||||
fill: string;
|
||||
};
|
||||
|
||||
type ConversionDataPoint = {
|
||||
date: string;
|
||||
conversionRate: number;
|
||||
appliedCount: number;
|
||||
convertedCount: number;
|
||||
};
|
||||
|
||||
type JobWithEvents = {
|
||||
id: string;
|
||||
datePosted: string | null;
|
||||
discoveredAt: string;
|
||||
appliedAt: string | null;
|
||||
events: StageEvent[];
|
||||
};
|
||||
|
||||
const chartConfig = {
|
||||
conversionRate: {
|
||||
label: "Conversion Rate",
|
||||
color: "var(--chart-1)",
|
||||
},
|
||||
};
|
||||
|
||||
// Stage definitions for funnel
|
||||
const FUNNEL_STAGES = [
|
||||
{ key: "applied", label: "Applied", color: "#3b82f6" },
|
||||
{ key: "screening", label: "Screening", color: "#8b5cf6" },
|
||||
{ key: "interview", label: "Interview", color: "#f59e0b" },
|
||||
{ key: "offer", label: "Offer", color: "#10b981" },
|
||||
] as const;
|
||||
|
||||
// Stages that count as "screening"
|
||||
const SCREENING_STAGES = new Set(["recruiter_screen", "assessment"]);
|
||||
|
||||
// Stages that count as "interview" (for funnel display)
|
||||
const INTERVIEW_STAGES = new Set([
|
||||
"hiring_manager_screen",
|
||||
"technical_interview",
|
||||
"onsite",
|
||||
]);
|
||||
|
||||
// Stages that count as conversion (any positive response from company)
|
||||
const CONVERSION_STAGES = new Set([
|
||||
"recruiter_screen",
|
||||
"assessment",
|
||||
"hiring_manager_screen",
|
||||
"technical_interview",
|
||||
"onsite",
|
||||
"offer",
|
||||
]);
|
||||
|
||||
// Stages that count as "offer"
|
||||
const OFFER_STAGES = new Set(["offer"]);
|
||||
|
||||
const toDateKey = (value: Date) => {
|
||||
const year = value.getFullYear();
|
||||
const month = `${value.getMonth() + 1}`.padStart(2, "0");
|
||||
const day = `${value.getDate()}`.padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
// Build funnel data from jobs with their stage events
|
||||
const buildFunnelData = (jobsWithEvents: JobWithEvents[]): FunnelStage[] => {
|
||||
let applied = 0;
|
||||
let screening = 0;
|
||||
let interview = 0;
|
||||
let offer = 0;
|
||||
|
||||
for (const job of jobsWithEvents) {
|
||||
if (!job.appliedAt) continue;
|
||||
applied++;
|
||||
|
||||
const reachedStages = new Set<string>();
|
||||
for (const event of job.events) {
|
||||
reachedStages.add(event.toStage);
|
||||
}
|
||||
|
||||
// Check if reached screening
|
||||
for (const stage of SCREENING_STAGES) {
|
||||
if (reachedStages.has(stage)) {
|
||||
screening++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if reached interview
|
||||
for (const stage of INTERVIEW_STAGES) {
|
||||
if (reachedStages.has(stage)) {
|
||||
interview++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if reached offer
|
||||
for (const stage of OFFER_STAGES) {
|
||||
if (reachedStages.has(stage)) {
|
||||
offer++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{ name: "Applied", value: applied, fill: FUNNEL_STAGES[0].color },
|
||||
{ name: "Screening", value: screening, fill: FUNNEL_STAGES[1].color },
|
||||
{ name: "Interview", value: interview, fill: FUNNEL_STAGES[2].color },
|
||||
{ name: "Offer", value: offer, fill: FUNNEL_STAGES[3].color },
|
||||
];
|
||||
};
|
||||
|
||||
// Build conversion rate time-series data
|
||||
const buildConversionTimeSeries = (
|
||||
jobsWithEvents: JobWithEvents[],
|
||||
daysToShow: number,
|
||||
): ConversionDataPoint[] => {
|
||||
const end = new Date();
|
||||
end.setHours(23, 59, 59, 999);
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - (daysToShow - 1));
|
||||
start.setHours(0, 0, 0, 0);
|
||||
|
||||
// Group jobs by application date
|
||||
const jobsByDate = new Map<string, JobWithEvents[]>();
|
||||
|
||||
for (const job of jobsWithEvents) {
|
||||
if (!job.appliedAt) continue;
|
||||
const date = new Date(job.appliedAt);
|
||||
if (Number.isNaN(date.getTime())) continue;
|
||||
if (date < start || date > end) continue;
|
||||
|
||||
const key = toDateKey(date);
|
||||
const list = jobsByDate.get(key) ?? [];
|
||||
list.push(job);
|
||||
jobsByDate.set(key, list);
|
||||
}
|
||||
|
||||
// Build time series with rolling conversion rate
|
||||
const data: ConversionDataPoint[] = [];
|
||||
const rollingWindow = Math.min(7, daysToShow); // 7-day rolling average, capped by daysToShow
|
||||
|
||||
for (
|
||||
let day = new Date(start);
|
||||
day <= end;
|
||||
day = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1)
|
||||
) {
|
||||
const key = toDateKey(day);
|
||||
|
||||
// Calculate rolling window range
|
||||
const windowStart = new Date(day);
|
||||
windowStart.setDate(windowStart.getDate() - rollingWindow + 1);
|
||||
|
||||
let appliedCount = 0;
|
||||
let convertedCount = 0;
|
||||
|
||||
// Sum up jobs in the rolling window
|
||||
for (
|
||||
let windowDay = new Date(windowStart);
|
||||
windowDay <= day;
|
||||
windowDay = new Date(
|
||||
windowDay.getFullYear(),
|
||||
windowDay.getMonth(),
|
||||
windowDay.getDate() + 1,
|
||||
)
|
||||
) {
|
||||
const windowKey = toDateKey(windowDay);
|
||||
const jobs = jobsByDate.get(windowKey) ?? [];
|
||||
|
||||
for (const job of jobs) {
|
||||
appliedCount++;
|
||||
|
||||
// Check if reached any conversion stage
|
||||
const reachedConversion = job.events.some((event) =>
|
||||
CONVERSION_STAGES.has(event.toStage),
|
||||
);
|
||||
if (reachedConversion) {
|
||||
convertedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const conversionRate =
|
||||
appliedCount > 0 ? (convertedCount / appliedCount) * 100 : 0;
|
||||
|
||||
data.push({
|
||||
date: key,
|
||||
conversionRate,
|
||||
appliedCount,
|
||||
convertedCount,
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
// Calculate overall conversion rate
|
||||
const calculateOverallConversion = (
|
||||
jobsWithEvents: JobWithEvents[],
|
||||
): { rate: number; total: number; converted: number } => {
|
||||
let total = 0;
|
||||
let converted = 0;
|
||||
|
||||
for (const job of jobsWithEvents) {
|
||||
if (!job.appliedAt) continue;
|
||||
total++;
|
||||
|
||||
const reachedConversion = job.events.some((event) =>
|
||||
CONVERSION_STAGES.has(event.toStage),
|
||||
);
|
||||
if (reachedConversion) {
|
||||
converted++;
|
||||
}
|
||||
}
|
||||
|
||||
const rate = total > 0 ? (converted / total) * 100 : 0;
|
||||
return { rate, total, converted };
|
||||
};
|
||||
|
||||
interface ConversionAnalyticsProps {
|
||||
jobsWithEvents: JobWithEvents[];
|
||||
error: string | null;
|
||||
daysToShow: number;
|
||||
}
|
||||
|
||||
export function ConversionAnalytics({
|
||||
jobsWithEvents,
|
||||
error,
|
||||
daysToShow,
|
||||
}: ConversionAnalyticsProps) {
|
||||
const funnelData = useMemo(() => {
|
||||
return buildFunnelData(jobsWithEvents);
|
||||
}, [jobsWithEvents]);
|
||||
|
||||
const conversionTimeSeries = useMemo(() => {
|
||||
return buildConversionTimeSeries(jobsWithEvents, daysToShow);
|
||||
}, [jobsWithEvents, daysToShow]);
|
||||
|
||||
const overallConversion = useMemo(() => {
|
||||
return calculateOverallConversion(jobsWithEvents);
|
||||
}, [jobsWithEvents]);
|
||||
|
||||
return (
|
||||
<Card className="py-0">
|
||||
<CardHeader className="flex flex-col gap-2 border-b !p-0 sm:flex-row sm:items-stretch">
|
||||
<div className="flex flex-1 flex-col justify-center gap-1 px-6 pt-4 pb-3 sm:!py-0">
|
||||
<CardTitle>Application → Response Conversion</CardTitle>
|
||||
<CardDescription>
|
||||
How many applications received a positive response from the company.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-center gap-3 border-t px-6 py-4 text-left sm:border-t-0 sm:border-l sm:px-8 sm:py-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Conversion Rate
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-bold leading-none sm:text-3xl">
|
||||
{overallConversion.rate.toFixed(1)}%
|
||||
</span>
|
||||
{overallConversion.rate < 10 ? (
|
||||
<TrendingDown className="h-4 w-4 text-destructive" />
|
||||
) : overallConversion.rate > 25 ? (
|
||||
<TrendingUp className="h-4 w-4 text-emerald-500" />
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{overallConversion.converted} of {overallConversion.total}{" "}
|
||||
applications
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 sm:p-6">
|
||||
{error ? (
|
||||
<div className="px-4 py-6 text-sm text-destructive">{error}</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Funnel Chart */}
|
||||
<div>
|
||||
<h4 className="mb-3 text-sm font-medium text-muted-foreground">
|
||||
Funnel: Applied → Screening → Interview → Offer
|
||||
</h4>
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[200px] w-full"
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={funnelData}
|
||||
layout="vertical"
|
||||
margin={{ left: 60, right: 20, top: 5, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
dataKey="name"
|
||||
type="category"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={80}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "var(--chart-1)", opacity: 0.3 }}
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const data = payload[0].payload as FunnelStage;
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-background px-3 py-2 text-xs shadow-sm">
|
||||
<div className="font-medium">{data.name}</div>
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
{data.value} applications
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
|
||||
{funnelData.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.fill} />
|
||||
))}
|
||||
<LabelList
|
||||
dataKey="value"
|
||||
position="right"
|
||||
className="text-xs fill-foreground"
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
|
||||
{/* Time Series Chart */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
Conversion rate over time (rolling {Math.min(7, daysToShow)}
|
||||
-day average)
|
||||
</h4>
|
||||
</div>
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-[200px] w-full"
|
||||
>
|
||||
<LineChart
|
||||
data={conversionTimeSeries}
|
||||
margin={{ left: 12, right: 12, top: 5, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid vertical={false} strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
minTickGap={32}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString("en-GB", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value.toFixed(0)}%`}
|
||||
domain={[0, "auto"]}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={{ fill: "var(--chart-1)", opacity: 0.3 }}
|
||||
content={({ active, payload, label }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const data = payload[0].payload as ConversionDataPoint;
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 bg-background px-3 py-2 text-xs shadow-sm">
|
||||
<div className="mb-2 text-[11px] font-medium text-muted-foreground">
|
||||
{new Date(label as string).toLocaleDateString(
|
||||
"en-GB",
|
||||
{
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">
|
||||
Conversion Rate
|
||||
</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{data.conversionRate.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">
|
||||
Applied ({Math.min(7, daysToShow)}d window)
|
||||
</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{data.appliedCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">
|
||||
Converted
|
||||
</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{data.convertedCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="conversionRate"
|
||||
stroke="var(--color-conversionRate)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,211 @@
|
||||
/**
|
||||
* DurationSelector Edge Case Tests
|
||||
* Tests all duration options and interaction edge cases
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DurationSelector } from "./DurationSelector";
|
||||
|
||||
// Mock UI components
|
||||
vi.mock("@/components/ui/tabs", () => ({
|
||||
Tabs: ({
|
||||
children,
|
||||
value,
|
||||
onValueChange,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
}) => (
|
||||
<div data-testid="tabs" data-value={value}>
|
||||
{children}
|
||||
{onValueChange && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="tab-trigger-7"
|
||||
onClick={() => onValueChange("7")}
|
||||
>
|
||||
7d
|
||||
</button>
|
||||
)}
|
||||
{onValueChange && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="tab-trigger-14"
|
||||
onClick={() => onValueChange("14")}
|
||||
>
|
||||
14d
|
||||
</button>
|
||||
)}
|
||||
{onValueChange && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="tab-trigger-30"
|
||||
onClick={() => onValueChange("30")}
|
||||
>
|
||||
30d
|
||||
</button>
|
||||
)}
|
||||
{onValueChange && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="tab-trigger-90"
|
||||
onClick={() => onValueChange("90")}
|
||||
>
|
||||
90d
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
TabsList: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="tabs-list">{children}</div>
|
||||
),
|
||||
TabsTrigger: ({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value: string;
|
||||
}) => (
|
||||
<button type="button" data-testid={`tab-${value}`} value={value}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("DurationSelector - Edge Cases", () => {
|
||||
const mockOnChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("All Duration Options", () => {
|
||||
it("renders with 7 days selected", () => {
|
||||
render(<DurationSelector value={7} onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByTestId("tabs")).toHaveAttribute("data-value", "7");
|
||||
});
|
||||
|
||||
it("renders with 14 days selected", () => {
|
||||
render(<DurationSelector value={14} onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByTestId("tabs")).toHaveAttribute("data-value", "14");
|
||||
});
|
||||
|
||||
it("renders with 30 days selected", () => {
|
||||
render(<DurationSelector value={30} onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByTestId("tabs")).toHaveAttribute("data-value", "30");
|
||||
});
|
||||
|
||||
it("renders with 90 days selected", () => {
|
||||
render(<DurationSelector value={90} onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByTestId("tabs")).toHaveAttribute("data-value", "90");
|
||||
});
|
||||
});
|
||||
|
||||
describe("onChange Callback", () => {
|
||||
it("calls onChange with 7 when 7d tab is clicked", () => {
|
||||
render(<DurationSelector value={30} onChange={mockOnChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("tab-trigger-7"));
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(7);
|
||||
expect(mockOnChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onChange with 14 when 14d tab is clicked", () => {
|
||||
render(<DurationSelector value={7} onChange={mockOnChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("tab-trigger-14"));
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(14);
|
||||
expect(mockOnChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onChange with 30 when 30d tab is clicked", () => {
|
||||
render(<DurationSelector value={7} onChange={mockOnChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("tab-trigger-30"));
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(30);
|
||||
expect(mockOnChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onChange with 90 when 90d tab is clicked", () => {
|
||||
render(<DurationSelector value={7} onChange={mockOnChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("tab-trigger-90"));
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(90);
|
||||
expect(mockOnChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("parses string value to number correctly", () => {
|
||||
render(<DurationSelector value={7} onChange={mockOnChange} />);
|
||||
|
||||
// Simulate clicking different tabs
|
||||
fireEvent.click(screen.getByTestId("tab-trigger-30"));
|
||||
expect(mockOnChange).toHaveBeenCalledWith(30);
|
||||
expect(typeof mockOnChange.mock.calls[0][0]).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Value Synchronization", () => {
|
||||
it("updates when value prop changes", () => {
|
||||
const { rerender } = render(
|
||||
<DurationSelector value={7} onChange={mockOnChange} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("tabs")).toHaveAttribute("data-value", "7");
|
||||
|
||||
rerender(<DurationSelector value={30} onChange={mockOnChange} />);
|
||||
|
||||
expect(screen.getByTestId("tabs")).toHaveAttribute("data-value", "30");
|
||||
});
|
||||
|
||||
it("maintains correct value type (number)", () => {
|
||||
render(<DurationSelector value={14} onChange={mockOnChange} />);
|
||||
|
||||
const tabs = screen.getByTestId("tabs");
|
||||
const value = tabs.getAttribute("data-value");
|
||||
expect(value).toBe("14");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Callback Consistency", () => {
|
||||
it("calls onChange multiple times for multiple selections", () => {
|
||||
render(<DurationSelector value={7} onChange={mockOnChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("tab-trigger-14"));
|
||||
fireEvent.click(screen.getByTestId("tab-trigger-30"));
|
||||
fireEvent.click(screen.getByTestId("tab-trigger-7"));
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledTimes(3);
|
||||
expect(mockOnChange).toHaveBeenNthCalledWith(1, 14);
|
||||
expect(mockOnChange).toHaveBeenNthCalledWith(2, 30);
|
||||
expect(mockOnChange).toHaveBeenNthCalledWith(3, 7);
|
||||
});
|
||||
|
||||
it("passes correct duration values for all options", () => {
|
||||
render(<DurationSelector value={7} onChange={mockOnChange} />);
|
||||
|
||||
const expectedValues = [7, 14, 30, 90];
|
||||
const triggers = [
|
||||
"tab-trigger-7",
|
||||
"tab-trigger-14",
|
||||
"tab-trigger-30",
|
||||
"tab-trigger-90",
|
||||
];
|
||||
|
||||
triggers.forEach((trigger, index) => {
|
||||
fireEvent.click(screen.getByTestId(trigger));
|
||||
expect(mockOnChange).toHaveBeenLastCalledWith(expectedValues[index]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,50 @@
|
||||
/**
|
||||
* DurationSelector - Sticky nav component for selecting time range
|
||||
* Controls the duration for all charts on the home page
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
const DURATION_OPTIONS = [
|
||||
{ value: 7, label: "7d" },
|
||||
{ value: 14, label: "14d" },
|
||||
{ value: 30, label: "30d" },
|
||||
{ value: 90, label: "90d" },
|
||||
] as const;
|
||||
|
||||
export type DurationValue = (typeof DURATION_OPTIONS)[number]["value"];
|
||||
|
||||
interface DurationSelectorProps {
|
||||
value: DurationValue;
|
||||
onChange: (value: DurationValue) => void;
|
||||
}
|
||||
|
||||
export function DurationSelector({ value, onChange }: DurationSelectorProps) {
|
||||
const handleChange = useCallback(
|
||||
(newValue: string) => {
|
||||
const parsed = Number(newValue) as DurationValue;
|
||||
onChange(parsed);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tabs value={String(value)} onValueChange={handleChange}>
|
||||
<TabsList className="h-8">
|
||||
{DURATION_OPTIONS.map((option) => (
|
||||
<TabsTrigger
|
||||
key={option.value}
|
||||
value={String(option.value)}
|
||||
className="px-3 text-xs"
|
||||
>
|
||||
{option.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
orchestrator/src/client/components/charts/index.ts
Normal file
4
orchestrator/src/client/components/charts/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { ApplicationsPerDayChart } from "./ApplicationsPerDayChart";
|
||||
export { ConversionAnalytics } from "./ConversionAnalytics";
|
||||
export type { DurationValue } from "./DurationSelector";
|
||||
export { DurationSelector } from "./DurationSelector";
|
||||
@ -2,14 +2,7 @@
|
||||
* Shared layout components for consistent page structure.
|
||||
*/
|
||||
|
||||
import {
|
||||
Briefcase,
|
||||
Home,
|
||||
type LucideIcon,
|
||||
Menu,
|
||||
Settings,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { type LucideIcon, Menu } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
@ -24,18 +17,12 @@ import {
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isNavActive, NAV_LINKS } from "./navigation";
|
||||
|
||||
// ============================================================================
|
||||
// Page Header
|
||||
// ============================================================================
|
||||
|
||||
const navLinks = [
|
||||
{ to: "/", label: "Dashboard", icon: Home },
|
||||
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
|
||||
{ to: "/ukvisajobs", label: "UK Visa Jobs", icon: Briefcase },
|
||||
{ to: "/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
interface PageHeaderProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
@ -57,8 +44,8 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
const navigate = useNavigate();
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
|
||||
const handleNavClick = (to: string) => {
|
||||
if (location.pathname === to) {
|
||||
const handleNavClick = (to: string, activePaths?: string[]) => {
|
||||
if (isNavActive(location.pathname, to, activePaths)) {
|
||||
setNavOpen(false);
|
||||
return;
|
||||
}
|
||||
@ -82,21 +69,14 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
<SheetTitle>JobOps</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="mt-6 flex flex-col gap-2">
|
||||
{navLinks.map(({ to, label, icon: NavIcon }) => (
|
||||
{NAV_LINKS.map(({ to, label, icon: NavIcon, activePaths }) => (
|
||||
<button
|
||||
key={to}
|
||||
type="button"
|
||||
onClick={() => handleNavClick(to)}
|
||||
onClick={() => handleNavClick(to, activePaths)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left",
|
||||
location.pathname === to ||
|
||||
(to === "/" &&
|
||||
[
|
||||
"/ready",
|
||||
"/discovered",
|
||||
"/applied",
|
||||
"/all",
|
||||
].includes(location.pathname))
|
||||
isNavActive(location.pathname, to, activePaths)
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
|
||||
33
orchestrator/src/client/components/navigation.ts
Normal file
33
orchestrator/src/client/components/navigation.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import {
|
||||
Briefcase,
|
||||
Home,
|
||||
LayoutDashboard,
|
||||
Settings,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
|
||||
export type NavLink = {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: typeof Home;
|
||||
activePaths?: string[];
|
||||
};
|
||||
|
||||
export const NAV_LINKS: NavLink[] = [
|
||||
{ to: "/home", label: "Home", icon: Home },
|
||||
{
|
||||
to: "/ready",
|
||||
label: "Dashboard",
|
||||
icon: LayoutDashboard,
|
||||
activePaths: ["/ready", "/discovered", "/applied", "/all"],
|
||||
},
|
||||
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
|
||||
{ to: "/ukvisajobs", label: "UK Visa Jobs", icon: Briefcase },
|
||||
{ to: "/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
export const isNavActive = (
|
||||
pathname: string,
|
||||
to: string,
|
||||
activePaths?: string[],
|
||||
) => pathname === to || (activePaths ? activePaths.includes(pathname) : false);
|
||||
224
orchestrator/src/client/pages/HomePage.tsx
Normal file
224
orchestrator/src/client/pages/HomePage.tsx
Normal file
@ -0,0 +1,224 @@
|
||||
import * as api from "@client/api";
|
||||
import {
|
||||
ApplicationsPerDayChart,
|
||||
ConversionAnalytics,
|
||||
DurationSelector,
|
||||
type DurationValue,
|
||||
} from "@client/components/charts";
|
||||
import { PageMain } from "@client/components/layout";
|
||||
import { Home, Menu } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { StageEvent } from "../../shared/types";
|
||||
import { isNavActive, NAV_LINKS } from "../components/navigation";
|
||||
|
||||
type JobWithEvents = {
|
||||
id: string;
|
||||
datePosted: string | null;
|
||||
discoveredAt: string;
|
||||
appliedAt: string | null;
|
||||
events: StageEvent[];
|
||||
};
|
||||
|
||||
const DURATION_OPTIONS = [7, 14, 30, 90] as const;
|
||||
const DEFAULT_DURATION = 30;
|
||||
|
||||
export const HomePage: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [jobsWithEvents, setJobsWithEvents] = useState<JobWithEvents[]>([]);
|
||||
const [appliedDates, setAppliedDates] = useState<Array<string | null>>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Read initial duration from URL
|
||||
const initialDuration: DurationValue = (() => {
|
||||
const value = Number(searchParams.get("duration"));
|
||||
return (
|
||||
(DURATION_OPTIONS as readonly number[]).includes(value)
|
||||
? value
|
||||
: DEFAULT_DURATION
|
||||
) as DurationValue;
|
||||
})();
|
||||
|
||||
const [duration, setDuration] = useState<DurationValue>(initialDuration);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
setIsLoading(true);
|
||||
|
||||
api
|
||||
.getJobs()
|
||||
.then(async (response) => {
|
||||
if (!isMounted) return;
|
||||
const appliedDates = response.jobs.map((job) => job.appliedAt);
|
||||
const jobSummaries = response.jobs.map((job) => ({
|
||||
id: job.id,
|
||||
datePosted: job.datePosted,
|
||||
discoveredAt: job.discoveredAt,
|
||||
appliedAt: job.appliedAt,
|
||||
positiveResponse: false,
|
||||
}));
|
||||
|
||||
const appliedJobs = jobSummaries.filter((job) => job.appliedAt);
|
||||
const results = await Promise.allSettled(
|
||||
appliedJobs.map((job) => api.getJobStageEvents(job.id)),
|
||||
);
|
||||
const eventsMap = new Map<string, StageEvent[]>();
|
||||
|
||||
results.forEach((result, index) => {
|
||||
const jobId = appliedJobs[index]?.id;
|
||||
if (!jobId) return;
|
||||
if (result.status !== "fulfilled") {
|
||||
eventsMap.set(jobId, []);
|
||||
return;
|
||||
}
|
||||
eventsMap.set(jobId, result.value);
|
||||
});
|
||||
|
||||
const resolvedJobsWithEvents: JobWithEvents[] = jobSummaries
|
||||
.filter((job) => job.appliedAt)
|
||||
.map((job) => ({
|
||||
...job,
|
||||
events: eventsMap.get(job.id) ?? [],
|
||||
}));
|
||||
|
||||
setJobsWithEvents(resolvedJobsWithEvents);
|
||||
setAppliedDates(appliedDates);
|
||||
setError(null);
|
||||
})
|
||||
.catch((fetchError) => {
|
||||
if (!isMounted) return;
|
||||
const message =
|
||||
fetchError instanceof Error
|
||||
? fetchError.message
|
||||
: "Failed to load applications";
|
||||
setError(message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isMounted) return;
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDurationChange = useCallback(
|
||||
(newDuration: DurationValue) => {
|
||||
setDuration(newDuration);
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (newDuration === DEFAULT_DURATION) {
|
||||
next.delete("duration");
|
||||
} else {
|
||||
next.set("duration", String(newDuration));
|
||||
}
|
||||
// Clean up old params
|
||||
next.delete("days");
|
||||
next.delete("conversionWindow");
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const handleNavClick = (to: string, activePaths?: string[]) => {
|
||||
if (isNavActive(location.pathname, to, activePaths)) {
|
||||
setNavOpen(false);
|
||||
return;
|
||||
}
|
||||
setNavOpen(false);
|
||||
setTimeout(() => navigate(to), 150);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Custom Header with Duration Selector */}
|
||||
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sheet open={navOpen} onOpenChange={setNavOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">Open navigation menu</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-64">
|
||||
<SheetHeader>
|
||||
<SheetTitle>JobOps</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="mt-6 flex flex-col gap-2">
|
||||
{NAV_LINKS.map(
|
||||
({ to, label, icon: NavIcon, activePaths }) => (
|
||||
<button
|
||||
key={to}
|
||||
type="button"
|
||||
onClick={() => handleNavClick(to, activePaths)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left",
|
||||
isNavActive(location.pathname, to, activePaths)
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<NavIcon className="h-4 w-4" />
|
||||
{label}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/60 bg-muted/30">
|
||||
<Home className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 leading-tight">
|
||||
<div className="text-sm font-semibold tracking-tight">Home</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Applications over the last {duration} days
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<DurationSelector
|
||||
value={duration}
|
||||
onChange={handleDurationChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<PageMain>
|
||||
<ApplicationsPerDayChart
|
||||
appliedAt={appliedDates}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
daysToShow={duration}
|
||||
/>
|
||||
|
||||
<ConversionAnalytics
|
||||
jobsWithEvents={jobsWithEvents}
|
||||
error={error}
|
||||
daysToShow={duration}
|
||||
/>
|
||||
</PageMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -2,6 +2,7 @@
|
||||
* UK Visa Jobs search page.
|
||||
*/
|
||||
|
||||
import { isNavActive, NAV_LINKS } from "@client/components/navigation";
|
||||
import {
|
||||
Briefcase,
|
||||
Calendar,
|
||||
@ -12,19 +13,15 @@ import {
|
||||
DollarSign,
|
||||
ExternalLink,
|
||||
GraduationCap,
|
||||
Home,
|
||||
Loader2,
|
||||
MapPin,
|
||||
Menu,
|
||||
Search,
|
||||
Settings,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@ -47,13 +44,6 @@ const clampText = (value: string, max = 160) =>
|
||||
|
||||
const jobKey = (job: CreateJobInput) => job.sourceJobId || job.jobUrl;
|
||||
|
||||
const navLinks = [
|
||||
{ to: "/", label: "Dashboard", icon: Home },
|
||||
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
|
||||
{ to: "/ukvisajobs", label: "UK Visa Jobs", icon: Briefcase },
|
||||
{ to: "/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
export const UkVisaJobsPage: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
@ -78,7 +68,6 @@ export const UkVisaJobsPage: React.FC = () => {
|
||||
? window.matchMedia("(min-width: 1024px)").matches
|
||||
: false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (results.length === 0) {
|
||||
setSelectedJobId(null);
|
||||
@ -375,12 +364,12 @@ export const UkVisaJobsPage: React.FC = () => {
|
||||
<SheetTitle>JobOps</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="mt-6 flex flex-col gap-2">
|
||||
{navLinks.map(({ to, label, icon: Icon }) => (
|
||||
{NAV_LINKS.map(({ to, label, icon: Icon, activePaths }) => (
|
||||
<button
|
||||
key={to}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (location.pathname === to) {
|
||||
if (isNavActive(location.pathname, to, activePaths)) {
|
||||
setNavOpen(false);
|
||||
return;
|
||||
}
|
||||
@ -389,14 +378,7 @@ export const UkVisaJobsPage: React.FC = () => {
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left",
|
||||
location.pathname === to ||
|
||||
(to === "/" &&
|
||||
[
|
||||
"/ready",
|
||||
"/discovered",
|
||||
"/applied",
|
||||
"/all",
|
||||
].includes(location.pathname))
|
||||
isNavActive(location.pathname, to, activePaths)
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
|
||||
@ -1,18 +1,14 @@
|
||||
import { isNavActive, NAV_LINKS } from "@client/components/navigation";
|
||||
import {
|
||||
Briefcase,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
Home,
|
||||
Loader2,
|
||||
Menu,
|
||||
Play,
|
||||
Settings,
|
||||
Shield,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@ -29,8 +25,7 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
|
||||
import { sourceLabel } from "@/lib/utils";
|
||||
import { cn, sourceLabel } from "@/lib/utils";
|
||||
import type { JobSource } from "../../../shared/types";
|
||||
import { orderedSources } from "./constants";
|
||||
|
||||
@ -46,13 +41,6 @@ interface OrchestratorHeaderProps {
|
||||
onOpenManualImport: () => void;
|
||||
}
|
||||
|
||||
const navLinks = [
|
||||
{ to: "/", label: "Dashboard", icon: Home },
|
||||
{ to: "/visa-sponsors", label: "Visa Sponsors", icon: Shield },
|
||||
{ to: "/ukvisajobs", label: "UK Visa Jobs", icon: Briefcase },
|
||||
{ to: "/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
|
||||
navOpen,
|
||||
onNavOpenChange,
|
||||
@ -72,7 +60,6 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
|
||||
const allSourcesSelected =
|
||||
visibleSources.length > 0 &&
|
||||
visibleSources.every((source) => pipelineSources.includes(source));
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-4">
|
||||
@ -89,32 +76,24 @@ export const OrchestratorHeader: React.FC<OrchestratorHeaderProps> = ({
|
||||
<SheetTitle>JobOps</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="mt-6 flex flex-col gap-2">
|
||||
{navLinks.map(({ to, label, icon: Icon }) => (
|
||||
{NAV_LINKS.map(({ to, label, icon: Icon, activePaths }) => (
|
||||
<button
|
||||
key={to}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (location.pathname === to) {
|
||||
if (isNavActive(location.pathname, to, activePaths)) {
|
||||
onNavOpenChange(false);
|
||||
return;
|
||||
}
|
||||
onNavOpenChange(false);
|
||||
setTimeout(() => navigate(to), 150);
|
||||
}}
|
||||
className={`flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left ${
|
||||
location.pathname === to ||
|
||||
(
|
||||
to === "/" &&
|
||||
[
|
||||
"/ready",
|
||||
"/discovered",
|
||||
"/applied",
|
||||
"/all",
|
||||
].includes(location.pathname)
|
||||
)
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground text-left",
|
||||
isNavActive(location.pathname, to, activePaths)
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
|
||||
177
orchestrator/src/components/ui/chart.tsx
Normal file
177
orchestrator/src/components/ui/chart.tsx
Normal file
@ -0,0 +1,177 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Tooltip as RechartsTooltip,
|
||||
ResponsiveContainer,
|
||||
type TooltipProps,
|
||||
} from "recharts";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ChartConfig = Record<
|
||||
string,
|
||||
{
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
color?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
const ChartConfigContext = React.createContext<ChartConfig | null>(null);
|
||||
|
||||
const useChartConfig = () => React.useContext(ChartConfigContext);
|
||||
|
||||
const ChartStyle: React.FC<{ id: string; config: ChartConfig }> = ({
|
||||
id,
|
||||
config,
|
||||
}) => {
|
||||
const entries = Object.entries(config).filter(([, value]) => value.color);
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<style>{`
|
||||
[data-chart="${id}"] {
|
||||
${entries
|
||||
.map(([key, value]) => `--color-${key}: ${value.color};`)
|
||||
.join("\n")}
|
||||
}
|
||||
`}</style>
|
||||
);
|
||||
};
|
||||
|
||||
export const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
config: ChartConfig;
|
||||
children?: React.ReactElement | null;
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
const generatedId = React.useId();
|
||||
const chartId = id ?? generatedId;
|
||||
|
||||
return (
|
||||
<ChartConfigContext.Provider value={config}>
|
||||
<div
|
||||
ref={ref}
|
||||
data-chart={chartId}
|
||||
className={cn("flex aspect-video justify-center text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
{React.isValidElement(children) ? (
|
||||
<ResponsiveContainer>{children}</ResponsiveContainer>
|
||||
) : null}
|
||||
</div>
|
||||
</ChartConfigContext.Provider>
|
||||
);
|
||||
});
|
||||
ChartContainer.displayName = "ChartContainer";
|
||||
|
||||
export const ChartTooltip = RechartsTooltip;
|
||||
|
||||
export type ChartTooltipContentProps = React.ComponentPropsWithoutRef<"div"> &
|
||||
Pick<TooltipProps<number, string>, "active" | "payload" | "label"> & {
|
||||
indicator?: "dot" | "line" | "dashed";
|
||||
labelFormatter?: (value: unknown, payload: unknown[]) => React.ReactNode;
|
||||
formatter?: (
|
||||
value: unknown,
|
||||
name: string,
|
||||
item: unknown,
|
||||
index: number,
|
||||
) => React.ReactNode;
|
||||
nameKey?: string;
|
||||
};
|
||||
|
||||
export const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ChartTooltipContentProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
className,
|
||||
indicator = "dot",
|
||||
labelFormatter,
|
||||
formatter,
|
||||
nameKey,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const config = useChartConfig() ?? {};
|
||||
if (!active || !payload?.length) return null;
|
||||
const formattedLabel = labelFormatter
|
||||
? labelFormatter(label, payload)
|
||||
: label;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border border-border/60 bg-background px-3 py-2 text-xs shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{formattedLabel ? (
|
||||
<div className="mb-2 text-[11px] font-medium text-muted-foreground">
|
||||
{formattedLabel}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-1">
|
||||
{payload.map((item, index) => {
|
||||
const dataKey = String(item.dataKey ?? item.name ?? "");
|
||||
const configKey = nameKey ?? dataKey;
|
||||
const entry = config[configKey] ?? config[dataKey];
|
||||
const IndicatorIcon = entry?.icon;
|
||||
const value = formatter
|
||||
? formatter(item.value, dataKey, item, index)
|
||||
: item.value;
|
||||
const labelText = entry?.label ?? item.name ?? dataKey;
|
||||
const indicatorColor =
|
||||
entry?.color ?? item.color ?? item.fill ?? "currentColor";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${dataKey}-${String(index)}`}
|
||||
className="flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
{IndicatorIcon ? (
|
||||
<IndicatorIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block",
|
||||
indicator === "dot" && "h-2 w-2 rounded-full",
|
||||
indicator === "line" && "h-0.5 w-3 rounded-full",
|
||||
indicator === "dashed" &&
|
||||
"h-0.5 w-3 rounded-full border border-dashed",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor:
|
||||
indicator === "dot" || indicator === "line"
|
||||
? indicatorColor
|
||||
: "transparent",
|
||||
borderColor:
|
||||
indicator === "dashed" ? indicatorColor : undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span>{labelText}</span>
|
||||
</div>
|
||||
<span className="font-semibold text-foreground">
|
||||
{typeof value === "number"
|
||||
? value.toLocaleString()
|
||||
: (value as React.ReactNode)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
ChartTooltipContent.displayName = "ChartTooltipContent";
|
||||
@ -96,7 +96,7 @@ export async function applyStoredEnvOverrides(): Promise<void> {
|
||||
} catch (error) {
|
||||
// In some test harnesses or first-boot scenarios, the DB may exist but not yet
|
||||
// have the settings table. Treat this as "no overrides".
|
||||
const msg = String((error as any)?.message ?? error);
|
||||
const msg = String((error as Error)?.message ?? error);
|
||||
if (msg.includes("no such table") && msg.includes("settings"))
|
||||
return null;
|
||||
throw error;
|
||||
@ -107,7 +107,7 @@ export async function applyStoredEnvOverrides(): Promise<void> {
|
||||
try {
|
||||
await settingsRepo.setSetting(key, value);
|
||||
} catch (error) {
|
||||
const msg = String((error as any)?.message ?? error);
|
||||
const msg = String((error as Error)?.message ?? error);
|
||||
if (msg.includes("no such table") && msg.includes("settings")) return;
|
||||
throw error;
|
||||
}
|
||||
@ -149,9 +149,8 @@ export async function applyStoredEnvOverrides(): Promise<void> {
|
||||
console.warn(
|
||||
"[DEPRECATED] OPENROUTER_API_KEY is deprecated. Copying to LLM_API_KEY for compatibility.",
|
||||
);
|
||||
process.env.LLM_API_KEY = normalizeEnvInput(
|
||||
process.env.OPENROUTER_API_KEY,
|
||||
)!;
|
||||
const normalizedKey = normalizeEnvInput(process.env.OPENROUTER_API_KEY);
|
||||
if (normalizedKey) process.env.LLM_API_KEY = normalizedKey;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user