diff --git a/package-lock.json b/package-lock.json index 7ad1dd693..6a438a592 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2276,6 +2276,32 @@ "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==" }, + "@types/d3-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.0.0.tgz", + "integrity": "sha512-0g/A+mZXgFkQxN3HniRDbXMN79K3CdTpLsevj+PXiTcb2hVyvkZUBg37StmgCQkaD84cUJ4uaDAWq7UJOQy2Tg==" + }, + "@types/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==", + "requires": { + "@types/d3-time": "*" + } + }, + "@types/d3-shape": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.0.2.tgz", + "integrity": "sha512-5+ButCmIfNX8id5seZ7jKj3igdcxx+S9IDBiT35fQGTLZUfkFgTv+oBH34xgeoWDKpWcMITSzBILWQtBoN5Piw==", + "requires": { + "@types/d3-path": "*" + } + }, + "@types/d3-time": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", + "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==" + }, "@types/eslint-visitor-keys": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", @@ -2559,6 +2585,33 @@ "@types/styled-components": "*" } }, + "@types/recharts": { + "version": "1.8.23", + "resolved": "https://registry.npmjs.org/@types/recharts/-/recharts-1.8.23.tgz", + "integrity": "sha512-O/mIPm9f6dwRWfenOI3GQwsGta3x1YWjwqXOCZqC0MATQ6C+A+Jc8VxFnSUr4N3uYv64zkq90RwXFaMNbhJKvg==", + "dev": true, + "requires": { + "@types/d3-shape": "^1", + "@types/react": "*" + }, + "dependencies": { + "@types/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-NaIeSIBiFgSC6IGUBjZWcscUJEq7vpVu7KthHN8eieTV9d9MqkSOZLH4chq1PmcKy06PNe3axLeKmRIyxJ+PZQ==", + "dev": true + }, + "@types/d3-shape": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.8.tgz", + "integrity": "sha512-gqfnMz6Fd5H6GOLYixOZP/xlrMtJms9BaS+6oWxTKHNqPGZ93BkWWupQSCYm6YHqx6h9wjRupuJb90bun6ZaYg==", + "dev": true, + "requires": { + "@types/d3-path": "^1" + } + } + } + }, "@types/stack-utils": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz", @@ -5124,9 +5177,9 @@ "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=" }, "core-js": { - "version": "2.5.7", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.7.tgz", - "integrity": "sha512-RszJCAxg/PP6uzXVXL6BsxSXx/B05oJAQ2vkJRjyjrEcNVycaqOmNb5OTxZPE3xa5gwZduqza6L9JOCenh/Ecw==" + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", + "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==" }, "core-js-compat": { "version": "3.6.5", @@ -5655,6 +5708,73 @@ "type": "^1.0.1" } }, + "d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==" + }, + "d3-collection": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz", + "integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==" + }, + "d3-color": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz", + "integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==" + }, + "d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==" + }, + "d3-interpolate": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz", + "integrity": "sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==", + "requires": { + "d3-color": "1" + } + }, + "d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==" + }, + "d3-scale": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-2.2.2.tgz", + "integrity": "sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw==", + "requires": { + "d3-array": "^1.2.0", + "d3-collection": "1", + "d3-format": "1", + "d3-interpolate": "1", + "d3-time": "1", + "d3-time-format": "2" + } + }, + "d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "requires": { + "d3-path": "1" + } + }, + "d3-time": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz", + "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==" + }, + "d3-time-format": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.3.0.tgz", + "integrity": "sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ==", + "requires": { + "d3-time": "1" + } + }, "damerau-levenshtein": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.6.tgz", @@ -5710,6 +5830,11 @@ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" }, + "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==" + }, "decode-uri-component": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", @@ -7345,8 +7470,8 @@ } }, "file-saver": { - "version": "github:eligrey/FileSaver.js#e865e37af9f9947ddcced76b549e27dc45c1cb2e", - "from": "github:eligrey/FileSaver.js#1.3.8" + "version": "git+ssh://git@github.com/eligrey/FileSaver.js.git#e865e37af9f9947ddcced76b549e27dc45c1cb2e", + "from": "file-saver@github:eligrey/FileSaver.js#1.3.8" }, "file-selector": { "version": "0.2.4", @@ -10113,7 +10238,7 @@ "integrity": "sha512-J9X76xnncMw+wIqb15HeWfPMqPwYxSpPY8yWPJ7rAZN/ZDzFkjCSZObryCyUe8zbrVRNiuCnIeQteCzMn7GnWw==", "requires": { "canvg": "1.5.3", - "file-saver": "github:eligrey/FileSaver.js#1.3.8", + "file-saver": "file-saver@github:eligrey/FileSaver.js#1.3.8", "html2canvas": "1.0.0-alpha.12", "omggif": "1.0.7", "promise-polyfill": "8.1.0", @@ -10320,6 +10445,11 @@ "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz", "integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=" }, + "lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=" + }, "lodash.flow": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/lodash.flow/-/lodash.flow-3.5.0.tgz", @@ -10353,6 +10483,11 @@ "lodash._reinterpolate": "^3.0.0" } }, + "lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=" + }, "lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", @@ -10464,6 +10599,11 @@ "css-mediaquery": "^0.1.2" } }, + "math-expression-evaluator": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.3.8.tgz", + "integrity": "sha512-9FbRY3i6U+CbHgrdNbAUaisjWTozkm1ZfupYQJiZ87NtYHk2Zh9DvxMgp/fifxVhqTLpd5fCCLossUbpZxGeKw==" + }, "md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -13497,6 +13637,17 @@ "webrtc-adapter": "^6.4.0" } }, + "react-resize-detector": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/react-resize-detector/-/react-resize-detector-2.3.0.tgz", + "integrity": "sha512-oCAddEWWeFWYH5FAcHdBYcZjAw9fMzRUK9sWSx6WvSSOPVRxcHd5zTIGy/mOus+AhN/u6T4TMiWxvq79PywnJQ==", + "requires": { + "lodash.debounce": "^4.0.8", + "lodash.throttle": "^4.1.1", + "prop-types": "^15.6.0", + "resize-observer-polyfill": "^1.5.0" + } + }, "react-responsive": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-6.1.1.tgz", @@ -13733,6 +13884,17 @@ "shallowequal": "^1.0.1" } }, + "react-smooth": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-1.0.6.tgz", + "integrity": "sha512-B2vL4trGpNSMSOzFiAul9kFAsxTukL9Wyy9EXtkQy3GJr6sZqW9e1nShdVOJ3hRYamPZ94O17r3Q0bjSw3UYtg==", + "requires": { + "lodash": "~4.17.4", + "prop-types": "^15.6.0", + "raf": "^3.4.0", + "react-transition-group": "^2.5.0" + } + }, "react-table": { "version": "6.8.6", "resolved": "https://registry.npmjs.org/react-table/-/react-table-6.8.6.tgz", @@ -13860,6 +14022,32 @@ "util.promisify": "^1.0.0" } }, + "recharts": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-1.8.5.tgz", + "integrity": "sha512-tM9mprJbXVEBxjM7zHsIy6Cc41oO/pVYqyAsOHLxlJrbNBuLs0PHB3iys2M+RqCF0//k8nJtZF6X6swSkWY3tg==", + "requires": { + "classnames": "^2.2.5", + "core-js": "^2.6.10", + "d3-interpolate": "^1.3.0", + "d3-scale": "^2.1.0", + "d3-shape": "^1.2.0", + "lodash": "^4.17.5", + "prop-types": "^15.6.0", + "react-resize-detector": "^2.3.0", + "react-smooth": "^1.0.5", + "recharts-scale": "^0.4.2", + "reduce-css-calc": "^1.3.0" + } + }, + "recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "requires": { + "decimal.js-light": "^2.4.1" + } + }, "recursive-readdir": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.2.tgz", @@ -13868,6 +14056,31 @@ "minimatch": "3.0.4" } }, + "reduce-css-calc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz", + "integrity": "sha1-dHyRTgSWFKTJz7umKYca0dKSdxY=", + "requires": { + "balanced-match": "^0.4.2", + "math-expression-evaluator": "^1.2.14", + "reduce-function-call": "^1.0.1" + }, + "dependencies": { + "balanced-match": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz", + "integrity": "sha1-yz8+PHMtwPAe5wtAPzAuYddwmDg=" + } + } + }, + "reduce-function-call": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/reduce-function-call/-/reduce-function-call-1.0.3.tgz", + "integrity": "sha512-Hl/tuV2VDgWgCSEeWMLwxLZqX7OK59eU1guxXsRKTAyeYimivsKdtcV4fu3r710tpG5GmDKDhQ0HSZLExnNmyQ==", + "requires": { + "balanced-match": "^1.0.0" + } + }, "regenerate": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.0.tgz", @@ -14101,6 +14314,11 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" }, + "resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" + }, "resolve": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.8.1.tgz", diff --git a/package.json b/package.json index 43d9d473d..98a859def 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "@rebass/grid": "^6.0.0-7", "@sentry/react": "^5.30.0", "@sentry/tracing": "^5.30.0", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.0.2", "@types/react-burger-menu": "^2.6.0", "@types/styled-jsx": "^2.2.8", "ajv": "^6.7.0", @@ -43,6 +45,7 @@ "react-router-dom": "^5.1.5", "react-scripts": "^3.4.1", "react-select": "^2.3.0", + "recharts": "^1.4.2", "react-table": "^6.8.6", "react-toastify": "^7.0.4", "styled-components": "^4.1.3", @@ -69,6 +72,7 @@ "@types/react-select": "^2.0.11", "@types/react-table": "^6.7.21", "@types/react-virtualized-select": "^3.0.7", + "@types/recharts": "^1.1.6", "@types/rebass__grid": "^6.0.2", "@types/styled-components": "^4.1.6", "@types/yup": "^0.26.7", diff --git a/src/App.tsx b/src/App.tsx index f89deb50e..370753c0a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import ConfirmAccountPage from './pages/Account/Confirm'; import CreateAccountPage from './pages/Account/Create'; import EditAccountPage from './pages/Account/Edit'; import AdminSearchPage from './pages/Admin/Search'; +import AdminStatsPage from './pages/Admin/Stats'; import SettingsPage from './pages/Admin/Settings'; import ConfirmAttendancePage from './pages/Application/Confirm'; import CreateApplicationPage from './pages/Application/Create'; @@ -263,6 +264,22 @@ class App extends React.Component { ) )} /> + + user.confirmed && user.accountType === UserType.STAFF, + }), + { activePage: 'stats' } + ) + )} + /> >> { + public async getStats( + parameters: ISearchParameter[], + overrideCache?: boolean + ): Promise>> { + const model = 'hacker'; + const q = JSON.stringify(parameters); + const key = `${CACHE_STATS_KEY}-${model}-${q}`; + const cached: any = LocalCache.get(key); + if (cached && !overrideCache) { + return cached as Promise>>; + } + const result = await API.getEndpoint(APIRoute.HACKER_STATS).getAll({ + params: { model, q }, + }); + LocalCache.set(key, result, new Date(Date.now() + 5 * 60 * 1000)); + return result; + } + + /** + * Get all hacker stats info + */ + public async getAllStats(): Promise>> { const key = CACHE_STATS_KEY; - const value = await API.getEndpoint(APIRoute.HACKER_STATS).getAll(); + const value = await API.getEndpoint(APIRoute.HACKER_STATS).getAll({ + params: { + model: "hacker", + q: {} + } + }); LocalCache.set(key, value, new Date(Date.now() + 5 * 60 * 1000)); return value; } + + } export const Hacker = new HackerAPI(); diff --git a/src/api/search.ts b/src/api/search.ts index 61889558d..aad18fce7 100644 --- a/src/api/search.ts +++ b/src/api/search.ts @@ -1,23 +1,42 @@ -import { AxiosPromise } from 'axios'; -import { APIRoute, ISearchOptions, ISearchParameter } from '../config'; +import { AxiosPromise, AxiosResponse } from 'axios'; +import { + APIRoute, + CACHE_SEARCH_TABLE_KEY, + ISearchOptions, + ISearchParameter, +} from '../config'; +import LocalCache from '../util/LocalCache'; import API from './api'; import APIResponse from './APIResponse'; class SearchAPI { constructor() { API.createEntity(APIRoute.SEARCH); } - public search( + public async search( model: string, parameters: ISearchParameter[], - searchOptions: ISearchOptions - ): AxiosPromise> { - return API.getEndpoint(APIRoute.SEARCH).getAll({ + searchOptions: ISearchOptions, + overrideCache?: boolean + ): Promise>> { + const q = JSON.stringify(parameters); + const key = `${CACHE_SEARCH_TABLE_KEY}-${model}-${q}`; + const cached: any = LocalCache.get(key); + if (cached && !overrideCache) { + return cached as AxiosPromise>; + } + const result: AxiosResponse> = await API.getEndpoint( + APIRoute.SEARCH + ).getAll({ params: { - q: JSON.stringify(parameters), + q, model, ...searchOptions, }, }); + // save the response in local cache for 5 minutes + const fiveMinutes = 5 * 60 * 1000; + LocalCache.set(key, result, new Date(Date.now() + fiveMinutes)); + return result; } } export const Search = new SearchAPI(); diff --git a/src/config/constants.ts b/src/config/constants.ts index fcb274610..a0db70b41 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -8,6 +8,7 @@ export const CACHE_HACKER_KEY = 'hackerInfo'; export const CACHE_STATS_KEY = 'statsInfo'; export const CACHE_SPONSOR_KEY = 'sponsorInfo'; export const CACHE_TRAVEL_KEY = 'travelInfo'; +export const CACHE_SEARCH_TABLE_KEY = 'searchTableInfo'; export const CACHE_SETTINGS_KEY = 'settingsInfo'; // General information diff --git a/src/config/frontendRoutes.ts b/src/config/frontendRoutes.ts index b0094b1a7..ba22e87be 100644 --- a/src/config/frontendRoutes.ts +++ b/src/config/frontendRoutes.ts @@ -1,5 +1,6 @@ export enum FrontendRoute { ADMIN_SEARCH_PAGE = '/admin/search', + ADMIN_STATS_PAGE = '/admin/stats', CHECKIN_HACKER_PAGE = '/hacker/checkin', PASS_HACKER_PAGE = '/hacker/pass', CONFIRM_ACCOUNT_PAGE = '/account/confirm', diff --git a/src/config/index.ts b/src/config/index.ts index 33f87687b..bf5a3c351 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -21,6 +21,7 @@ export * from './settings'; export * from './shirtSizes'; export * from './skills'; export * from './previousHackathons'; +export * from './statsApplications'; export * from './statsResponse'; export * from './team'; export * from './travelStatus'; diff --git a/src/config/statsApplications.ts b/src/config/statsApplications.ts new file mode 100644 index 000000000..96fa9e4e6 --- /dev/null +++ b/src/config/statsApplications.ts @@ -0,0 +1,4 @@ +export interface IStatsApplications { + date: string, + applications: number, +} diff --git a/src/config/statsResponse.ts b/src/config/statsResponse.ts index 45131284c..f9639a81c 100644 --- a/src/config/statsResponse.ts +++ b/src/config/statsResponse.ts @@ -1,20 +1,22 @@ import { AttendenceOptions, DietaryRestriction, HackerStatus, JobInterest, ShirtSize } from '.'; export interface IStatsResponse { - stats: { - total: number; - status: { [key in HackerStatus]: number }; - school: { [key: string]: number }; - degree: { [key: string]: number }; - gender: { [key: string]: number }; - travel: { true: number; false: number }; - ethnicity: { [key: string]: number }; - jobInterest: { [key in JobInterest]: number }; - major: { [key: string]: number }; - graduationYear: { [key: string]: number }; - dietaryRestriction: { [key in DietaryRestriction & string]: number }; - ShirtSize: { [key in ShirtSize]: number }; - attendancePreference: { [key in AttendenceOptions]: number }; - age: { [key: string]: number }; - }; + stats: IStats; +} + +export interface IStats { + total: number; + status: { [key in HackerStatus]: number }; + school: { [key: string]: number }; + degree: { [key: string]: number }; + gender: { [key: string]: number }; + needsBus: { [key: string]: number }; + ethnicity: { [key: string]: number }; + jobInterest: { [key in JobInterest]: number }; + major: { [key: string]: number }; + graduationYear: { [key: string]: number }; + dietaryRestrictions: { [key in DietaryRestriction & string]: number }; + shirtSize: { [key in ShirtSize]: number }; + age: { [key: string]: number }; + applicationDate: { [key: string]: number }; } diff --git a/src/features/Dashboard/DashboardText.tsx b/src/features/Dashboard/DashboardText.tsx index 4ad305296..a2eb00de1 100644 --- a/src/features/Dashboard/DashboardText.tsx +++ b/src/features/Dashboard/DashboardText.tsx @@ -13,6 +13,8 @@ export const BusDeposit: string = 'Bus Deposit'; export const Search: string = 'Search'; +export const Stats: string = 'Stats'; + export const SponsorOnboarding: string = 'Onboarding'; export const Checkin: string = 'Check In'; diff --git a/src/features/Dashboard/StaffDashboard.tsx b/src/features/Dashboard/StaffDashboard.tsx index e8bb71eb2..e3308f82d 100644 --- a/src/features/Dashboard/StaffDashboard.tsx +++ b/src/features/Dashboard/StaffDashboard.tsx @@ -60,6 +60,12 @@ class AdminDashboardContainer extends React.Component<{}, IDashboardState> { imageSrc: SearchIcon, validation: this.confirmAccountToastError, }, + { + title: DashboardText.Stats, + route: routes.ADMIN_STATS_PAGE, + imageSrc: SearchIcon, // TODO: add StatsIcon + validation: this.confirmAccountToastError, + }, { title: DashboardText.Profile, route: routes.EDIT_ACCOUNT_PAGE, diff --git a/src/features/Nav/Navbar.tsx b/src/features/Nav/Navbar.tsx index c2284f52e..0472cb541 100644 --- a/src/features/Nav/Navbar.tsx +++ b/src/features/Nav/Navbar.tsx @@ -243,6 +243,12 @@ export default class Navbar extends React.Component< > Search + + Stats + ; loading: boolean; userType: UserType; - filter: string; } -const ResultsTable: React.StatelessComponent = (props) => { +const ResultsTable: React.FunctionComponent = (props) => { const volunteerColumns = [ { Header: 'First Name', diff --git a/src/features/Search/Search.tsx b/src/features/Search/Search.tsx index 66c3b7c5f..3f7049d17 100644 --- a/src/features/Search/Search.tsx +++ b/src/features/Search/Search.tsx @@ -124,7 +124,6 @@ class SearchContainer extends React.Component<{}, ISearchState> { results={this.filter()} loading={loading} userType={account ? account.accountType : UserType.UNKNOWN} - filter={searchBar} /> diff --git a/src/features/Search/Stats/ActiveShape.tsx b/src/features/Search/Stats/ActiveShape.tsx new file mode 100644 index 000000000..bbb9f054b --- /dev/null +++ b/src/features/Search/Stats/ActiveShape.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; +import { Sector, Text } from 'recharts'; + +const ActiveShapeComponent: React.StatelessComponent = (props) => { + const RADIAN = Math.PI / 180; + const { + cx, + cy, + midAngle, + innerRadius, + outerRadius, + startAngle, + endAngle, + fill, + payload, + value, + percent, + } = props; + const sin = Math.sin(-RADIAN * midAngle); + const cos = Math.cos(-RADIAN * midAngle); + const activeRadiusStart = 4; + const activeRadiusEnd = 6; + const sx = cx + (outerRadius + activeRadiusEnd) * cos; + const sy = cy + (outerRadius + activeRadiusEnd) * sin; + const mx = cx + (outerRadius + activeRadiusEnd + 10) * cos; + const my = cy + (outerRadius + activeRadiusEnd + 10) * sin; + const ex = mx + (cos >= 0 ? 1 : -1) * 22; + const ey = my; + const textAnchor = cos >= 0 ? 'start' : 'end'; + + const centerText = `${payload.name.substr(0, 30)}${ + payload.name.length > 30 ? '...' : '' + }`; + + const percentVal = Math.round(percent * 100); + + return ( + + + {centerText} + + + + + + = 0 ? 1 : -1) * 12} + y={ey} + textAnchor={textAnchor} + fill={fill} + > + {value} ({percentVal === 0 ? '<1' : percentVal}%) + + + ); +}; + +export { ActiveShapeComponent }; diff --git a/src/features/Search/Stats/SingleStat.tsx b/src/features/Search/Stats/SingleStat.tsx new file mode 100644 index 000000000..979df7e3b --- /dev/null +++ b/src/features/Search/Stats/SingleStat.tsx @@ -0,0 +1,102 @@ +import { Box } from '@rebass/grid'; +import * as React from 'react'; +import { Cell, Pie, PieChart } from 'recharts'; + +import { ISearchParameter, StringOperations } from '../../../config'; +import { H2 } from '../../../shared/Elements'; +import { ActiveShapeComponent } from './ActiveShape'; + +interface IStatComponentProps { + statName: string; + searchReference?: string; + onFilterChange?: (newFilters: ISearchParameter[]) => void; + stat: { [key: string]: number }; +} + +interface IStatComponentState { + activeIndex: number; + data: Array<{ name: string; value: number }>; +} + +const COLORS = ['#3DCC91', '#FFB366', '#FF7373', '#FFCC00', '#3B22FF']; + +class SingleStatComponent extends React.Component< + IStatComponentProps, + IStatComponentState +> { + constructor(props: IStatComponentProps) { + super(props); + if (this.props.stat) { + const data = Object.keys(this.props.stat).map((k: string, index) => { + return { + name: k, + value: this.props.stat[k], + }; + }); + data.sort((a, b) => b.value - a.value); + this.state = { + activeIndex: 0, + data, + }; + + this.onPieEnter = this.onPieEnter.bind(this); + this.handleClick = this.handleClick.bind(this); + } + } + public render() { + if (!this.props.stat) { + return (null); + } else { + return ( + +

{this.props.statName}:

+ + + {this.state.data.map((entry, index) => ( + + ))} + + +
+ ); + } + } + private onPieEnter(data: any, index: number) { + this.setState({ + activeIndex: index, + }); + } + + private handleClick(e: any) { + if (this.props.searchReference && this.props.onFilterChange) { + if (this.props.searchReference === null) { + return; + } + const query = [ + { + param: this.props.searchReference, + operation: StringOperations.IN, + value: [this.state.data[this.state.activeIndex].name], + }, + ]; + this.props.onFilterChange(query); + } + } +} + +export default SingleStatComponent; diff --git a/src/features/Search/Stats/Stats.tsx b/src/features/Search/Stats/Stats.tsx new file mode 100644 index 000000000..a0e5fe5d7 --- /dev/null +++ b/src/features/Search/Stats/Stats.tsx @@ -0,0 +1,142 @@ +import { Box, Flex } from '@rebass/grid'; +import * as _ from 'lodash'; +import * as React from 'react'; + +import { ISearchParameter, IStats } from '../../../config'; +import { H2 } from '../../../shared/Elements'; +import { normalizeArray } from '../../../util'; +import SingleStatComponent from './SingleStat'; + +interface IStatsProps { + stats: IStats | null; + loading: boolean; + existingFilters?: ISearchParameter[]; + onFilterChange: (newFilters: ISearchParameter[]) => void; +} +const StatsComponent: React.StatelessComponent = (props) => { + const { existingFilters, stats, loading, onFilterChange } = props; + if (loading) { + return
loading...
; + } else if (stats !== null) { + return renderStats(stats, existingFilters || [], onFilterChange); + } else { + return
Error
; + } +}; + +function renderStats( + stats: IStats, + existingFilters: ISearchParameter[], + onFilterChange: (newFilters: ISearchParameter[]) => void +) { + /* + * total: number; + * status: { [key in HackerStatus]: number }; + * school: { [key: string]: number }; + * degree: { [key: string]: number }; + * gender: { [key: string]: number }; + * needsBus: { true: number; false: number }; + * ethnicity: { [key: string]: number }; + * jobInterest: { [key in JobInterest]: number }; + * major: { [key: string]: number }; + * graduationYear: { [key: string]: number }; + * dietaryRestriction: { [key in DietaryRestriction & string]: number }; + * ShirtSize: { [key in ShirtSize]: number }; + * age: { [key: string]: number }; + */ + const onFilterChangeWrapper = modifyFilterFactory( + existingFilters, + onFilterChange + ); + return ( + + +

Total results: {stats.total}

+
+ + + + + + + + + + + + + + + + +
+ ); +} + +/** + * Returns function which takes as input new filters to add to or remove from the old filters. + * @param oldFilters The previous filters to add to + * @param onFilterChange The callback that is called after modifying filters + */ + function modifyFilterFactory( + oldFilters: ISearchParameter[], + onFilterChange: (newFilters: ISearchParameter[]) => void + ): (newFilters: ISearchParameter[]) => void { + return (newFilters: ISearchParameter[]) => { + oldFilters = _.cloneDeep(oldFilters); + // Convert oldFilters list to object so that we can reference by param in O(1) time. + const oldFiltersObj = normalizeArray(oldFilters, 'param'); + // List of unseen filters that we will add to the list of returned filters. + const unseenFilters: ISearchParameter[] = []; + // Iterate through the new filters and either modify oldFilter value accordingly. + newFilters.forEach((newFilter: ISearchParameter) => { + const oldFilter = oldFiltersObj[newFilter.param]; + if (!oldFilter) { + // If there is no oldFilter, then newFilter is unseen. + unseenFilters.push(newFilter); + } else if (_.isEqual(oldFilter.value, newFilter.value)) { + // Remove the filter if the filter is exactly the same. + _.remove(oldFilters, (filter) => filter === oldFilter); + } else { + // Set the old filter to be exactly the new filter. + oldFilter.value = newFilter.value; + } + }); + onFilterChange(oldFilters.concat(unseenFilters)); + }; + } + +export { StatsComponent }; diff --git a/src/features/Stats/Demographics/ActiveShape.tsx b/src/features/Stats/Demographics/ActiveShape.tsx new file mode 100644 index 000000000..bbb9f054b --- /dev/null +++ b/src/features/Stats/Demographics/ActiveShape.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; +import { Sector, Text } from 'recharts'; + +const ActiveShapeComponent: React.StatelessComponent = (props) => { + const RADIAN = Math.PI / 180; + const { + cx, + cy, + midAngle, + innerRadius, + outerRadius, + startAngle, + endAngle, + fill, + payload, + value, + percent, + } = props; + const sin = Math.sin(-RADIAN * midAngle); + const cos = Math.cos(-RADIAN * midAngle); + const activeRadiusStart = 4; + const activeRadiusEnd = 6; + const sx = cx + (outerRadius + activeRadiusEnd) * cos; + const sy = cy + (outerRadius + activeRadiusEnd) * sin; + const mx = cx + (outerRadius + activeRadiusEnd + 10) * cos; + const my = cy + (outerRadius + activeRadiusEnd + 10) * sin; + const ex = mx + (cos >= 0 ? 1 : -1) * 22; + const ey = my; + const textAnchor = cos >= 0 ? 'start' : 'end'; + + const centerText = `${payload.name.substr(0, 30)}${ + payload.name.length > 30 ? '...' : '' + }`; + + const percentVal = Math.round(percent * 100); + + return ( + + + {centerText} + + + + + + = 0 ? 1 : -1) * 12} + y={ey} + textAnchor={textAnchor} + fill={fill} + > + {value} ({percentVal === 0 ? '<1' : percentVal}%) + + + ); +}; + +export { ActiveShapeComponent }; diff --git a/src/features/Stats/Demographics/SingleStat.tsx b/src/features/Stats/Demographics/SingleStat.tsx new file mode 100644 index 000000000..788a71830 --- /dev/null +++ b/src/features/Stats/Demographics/SingleStat.tsx @@ -0,0 +1,102 @@ +import { Box } from '@rebass/grid'; +import * as React from 'react'; +import { Cell, Pie, PieChart } from 'recharts'; + +import { ISearchParameter, StringOperations } from '../../../config'; +import { H2 } from '../../../shared/Elements'; +import { ActiveShapeComponent } from './ActiveShape'; + +interface IStatComponentProps { + statName: string; + searchReference?: string; + onFilterChange?: (newFilters: ISearchParameter[]) => void; + stat: { [key: string]: number }; +} + +interface IStatComponentState { + activeIndex: number; + data: Array<{ name: string; value: number }>; +} + +const COLORS = ['#3DCC91', '#FFB366', '#FF7373', '#FFCC00', '#3B22FF']; + +class SingleStatComponent extends React.Component< + IStatComponentProps, + IStatComponentState +> { + constructor(props: IStatComponentProps) { + super(props); + if (this.props.stat) { + const data = Object.keys(this.props.stat).map((k: string, index) => { + return { + name: k, + value: this.props.stat[k], + }; + }); + data.sort((a, b) => b.value - a.value); + this.state = { + activeIndex: 0, + data, + }; + + this.onPieEnter = this.onPieEnter.bind(this); + this.handleClick = this.handleClick.bind(this); + } + } + public render() { + if (!this.props.stat) { + return (null); + } else { + return ( + +

{this.props.statName}:

+ + + {this.state.data.map((entry, index) => ( + + ))} + + +
+ ); + } + } + private onPieEnter(data: any, index: number) { + this.setState({ + activeIndex: index, + }); + } + + private handleClick(e: any) { + if (this.props.searchReference && this.props.onFilterChange) { + if (this.props.searchReference === null) { + return; + } + const query = [ + { + param: this.props.searchReference, + operation: StringOperations.IN, + value: [this.state.data[this.state.activeIndex].name], + }, + ]; + this.props.onFilterChange(query); + } + } +} + +export default SingleStatComponent; diff --git a/src/features/Stats/Demographics/StatsDemographics.tsx b/src/features/Stats/Demographics/StatsDemographics.tsx new file mode 100644 index 000000000..932a2ed6c --- /dev/null +++ b/src/features/Stats/Demographics/StatsDemographics.tsx @@ -0,0 +1,138 @@ +import { Box, Flex } from '@rebass/grid'; +import * as _ from 'lodash'; +import * as React from 'react'; + +import { ISearchParameter, IStats } from '../../../config'; +import { normalizeArray } from '../../../util'; +import SingleStatComponent from './SingleStat'; + +interface IStatsProps { + stats: IStats | null; + loading: boolean; + existingFilters?: ISearchParameter[]; + onFilterChange: (newFilters: ISearchParameter[]) => void; +} +const StatsDemographics: React.FC = (props) => { + const { existingFilters, stats, loading, onFilterChange } = props; + if (loading) { + return
loading...
; + } else if (stats !== null) { + return renderStats(stats, existingFilters || [], onFilterChange); + } else { + return
Error
; + } +}; + +function renderStats( + stats: IStats, + existingFilters: ISearchParameter[], + onFilterChange: (newFilters: ISearchParameter[]) => void +) { + /* + * total: number; + * status: { [key in HackerStatus]: number }; + * school: { [key: string]: number }; + * degree: { [key: string]: number }; + * gender: { [key: string]: number }; + * needsBus: { true: number; false: number }; + * ethnicity: { [key: string]: number }; + * jobInterest: { [key in JobInterest]: number }; + * major: { [key: string]: number }; + * graduationYear: { [key: string]: number }; + * dietaryRestriction: { [key in DietaryRestriction & string]: number }; + * ShirtSize: { [key in ShirtSize]: number }; + * age: { [key: string]: number }; + */ + const onFilterChangeWrapper = modifyFilterFactory( + existingFilters, + onFilterChange + ); + return ( + + + + + + + + + + + + + + + + + + + ); +} + +/** + * Returns function which takes as input new filters to add to or remove from the old filters. + * @param oldFilters The previous filters to add to + * @param onFilterChange The callback that is called after modifying filters + */ +function modifyFilterFactory( + oldFilters: ISearchParameter[], + onFilterChange: (newFilters: ISearchParameter[]) => void +): (newFilters: ISearchParameter[]) => void { + return (newFilters: ISearchParameter[]) => { + oldFilters = _.cloneDeep(oldFilters); + // Convert oldFilters list to object so that we can reference by param in O(1) time. + const oldFiltersObj = normalizeArray(oldFilters, 'param'); + // List of unseen filters that we will add to the list of returned filters. + const unseenFilters: ISearchParameter[] = []; + // Iterate through the new filters and either modify oldFilter value accordingly. + newFilters.forEach((newFilter: ISearchParameter) => { + const oldFilter = oldFiltersObj[newFilter.param]; + if (!oldFilter) { + // If there is no oldFilter, then newFilter is unseen. + unseenFilters.push(newFilter); + } else if (_.isEqual(oldFilter.value, newFilter.value)) { + // Remove the filter if the filter is exactly the same. + _.remove(oldFilters, (filter) => filter === oldFilter); + } else { + // Set the old filter to be exactly the new filter. + oldFilter.value = newFilter.value; + } + }); + onFilterChange(oldFilters.concat(unseenFilters)); + }; +} + +export { StatsDemographics }; diff --git a/src/features/Stats/Stats.tsx b/src/features/Stats/Stats.tsx new file mode 100644 index 000000000..0e09806d4 --- /dev/null +++ b/src/features/Stats/Stats.tsx @@ -0,0 +1,396 @@ +import { Box, Flex } from '@rebass/grid'; +import fileDownload from 'js-file-download'; +import * as React from 'react'; +import Helmet from 'react-helmet'; + +import { Account, Sponsor, Hacker } from '../../api'; +import { + HACKATHON_NAME, + IAccount, + IHacker, + ISearchParameter, + ISponsor, + IStats, + isValidSearchParameter, + UserType, +} from '../../config'; +import * as CONSTANTS from '../../config/constants'; +import { Button, ButtonVariant, H1, H2 } from '../../shared/Elements'; +import { Input } from '../../shared/Form'; +import ValidationErrorGenerator from '../../shared/Form/validationErrorGenerator'; +import WithToasterContainer from '../../shared/HOC/withToaster'; +import theme from '../../shared/Styles/theme'; +import { getNestedAttr, getValueFromQuery, isSponsor } from '../../util'; + +import withContext from '../../shared/HOC/withContext'; +import StatsApplications from './StatsApplications'; +import { FilterComponent } from '../Search/Filters'; +import { StatsDemographics } from './Demographics/StatsDemographics'; + +interface IResult { + /** + * For now, we aren't exposing 'selected' attribute. We set it default equal to true. + * This is used for batch operations for changing hacker data. + */ + selected: boolean; + hacker: IHacker; +} + +enum SearchMode { + DEMOGRAPHICS = 'Demographics', + APPLICATIONS = 'Applications', +} + +interface ISearchState { + model: string; + mode: SearchMode; + query: ISearchParameter[]; + tableResults: IResult[]; + searchBar: string; + loading: boolean; + viewSaved: boolean; + account?: IAccount; + sponsor?: ISponsor; + statsResults: IStats | null; +} + +class SearchContainer extends React.Component<{}, ISearchState> { + constructor(props: {}) { + super(props); + this.state = { + model: 'hacker', + mode: SearchMode.APPLICATIONS, + query: this.getSearchFromQuery(), + tableResults: [], + statsResults: null, + searchBar: this.getSearchBarFromQuery(), + loading: false, + viewSaved: false, + }; + + this.onFilterChange = this.onFilterChange.bind(this); + this.triggerSearch = this.triggerSearch.bind(this); + this.downloadData = this.downloadData.bind(this); + this.onResetForm = this.onResetForm.bind(this); + this.onSearchBarChanged = this.onSearchBarChanged.bind(this); + this.handleSearchModeChanged = this.handleSearchModeChanged.bind(this); + } + + public render() { + const { query, loading } = this.state; + return ( + + + Search | {HACKATHON_NAME} + + +

+ Search Hackers +

+
+ + + + + + + + {this.state.mode === SearchMode.APPLICATIONS ? ( + + + + ) : ( + + + +

Filters

+ +
+ + + + + +
+
+ )} +
+ +
+ ); + } + public async componentDidMount() { + const account = (await Account.getSelf()).data.data; + this.setState({ account }); + + if (isSponsor(account)) { + const sponsor = (await Sponsor.getSelf()).data.data; + this.setState({ sponsor }); + } + await this.triggerSearch(); + } + private getSearchFromQuery(): ISearchParameter[] { + const search = getValueFromQuery('q'); + if (!search) { + return []; + } + try { + const searchParam = JSON.parse(search); + if (!Array.isArray(searchParam)) { + return []; + } + const isValidSearch = + searchParam + .map( + (value: any): boolean => { + return isValidSearchParameter(value); + } + ) + .indexOf(false) === -1; + return isValidSearch ? searchParam : []; + } catch (e) { + return []; + } + } + + private handleSearchModeChanged({ value }: any) { + console.log(value); + this.setState({ mode: value }, this.triggerSearch); + } + + private async triggerStatsSearch(): Promise { + try { + this.setState({ loading: true }); + const statsResponse = await Hacker.getStats(this.state.query); + const stats: IStats | null = getNestedAttr(statsResponse, [ + 'data', + 'data', + 'stats', + ]); + this.setState({ statsResults: stats, loading: false }); + } catch (e) { + ValidationErrorGenerator(e.data); + this.setState({ loading: false }); + } + } + + private getSearchBarFromQuery(): string { + const search = getValueFromQuery('searchBar'); + return search ? decodeURIComponent(search) : ''; + } + + private downloadData(): void { + const headers = [ + { label: CONSTANTS.FIRST_NAME_LABEL, key: 'accountId.firstName' }, + { label: CONSTANTS.LAST_NAME_LABEL, key: 'accountId.lastName' }, + { label: CONSTANTS.EMAIL_LABEL, key: 'accountId.email' }, + { label: CONSTANTS.SCHOOL_LABEL, key: 'application.general.school' }, + { + label: CONSTANTS.FIELD_OF_STUDY_LABEL, + key: 'application.general.fieldOfStudy', + }, + { + label: CONSTANTS.GRADUATION_YEAR_LABEL, + key: 'application.general.graduationYear', + }, + { label: CONSTANTS.DEGREE_LABEL, key: 'application.general.degree' }, + { + label: CONSTANTS.JOBINTEREST_LABEL, + key: 'application.general.jobInterest', + }, + ]; + // Return all fields for admin, and only subset for sponsors + if ( + this.state.account && + this.state.account.accountType === UserType.STAFF + ) { + headers.push({ label: 'Resume', key: 'application.general.URL.resume' }); + headers.push({ label: 'Github', key: 'application.general.URL.github' }); + headers.push({ + label: CONSTANTS.DRIBBBLE_LINK_LABEL, + key: 'application.general.URL.dribbble', + }); + headers.push({ + label: CONSTANTS.PERSONAL_LABEL, + key: 'application.general.URL.personal', + }); + headers.push({ + label: CONSTANTS.LINKEDIN_LINK_LABEL, + key: 'application.general.URL.linkedin', + }); + headers.push({ + label: CONSTANTS.OTHER_LINK_LABEL, + key: 'application.general.URL.other', + }); + headers.push({ + label: CONSTANTS.SKILLS_LABEL, + key: 'application.shortAnswer.skills', + }); + headers.push({ + label: CONSTANTS.COMMENTS_LABEL, + key: 'application.shortAnswer.comments', + }); + headers.push({ + label: CONSTANTS.QUESTION1_REQUEST_LABEL, + key: 'application.shortAnswer.question1', + }); + headers.push({ + label: CONSTANTS.QUESTION2_REQUEST_LABEL, + key: 'application.shortAnswer.question2', + }); + headers.push({ + label: CONSTANTS.SHIRT_SIZE_LABEL, + key: 'application.accommodation.shirtSize', + }); + headers.push({ + label: CONSTANTS.IMPAIRMENTS_LABEL, + key: 'application.accommodation.impairments', + }); + headers.push({ + label: CONSTANTS.BARRIERS_LABEL, + key: 'application.accommodation.barriers', + }); + headers.push({ + label: CONSTANTS.TRAVEL_LABEL, + key: 'application.accommodation.travel', + }); + headers.push({ + label: CONSTANTS.ETHNICITY_LABEL, + key: 'application.other.ethnicity', + }); + headers.push({ label: CONSTANTS.GENDER_LABEL, key: 'accountId.gender' }); + headers.push({ + label: CONSTANTS.PRONOUN_LABEL, + key: 'accountId.pronoun', + }); + } + const tempHeaders: string[] = []; + headers.forEach((header) => { + tempHeaders.push(header.label); + }); + const csvData: string[] = [tempHeaders.join('\t')]; + this.filter().forEach((result) => { + if (result.selected) { + const row: string[] = []; + headers.forEach((header) => { + let value; + if (header.key.indexOf('.') >= 0) { + const nestedAttr = header.key.split('.'); + value = getNestedAttr(result.hacker, nestedAttr); + } else { + value = result.hacker[header.key]; + } + row.push(value); + }); + csvData.push(row.join('\t')); + } + }); + fileDownload(csvData.join('\n'), 'hackerData.tsv', 'text/tsv'); + } + + private async triggerSearch(): Promise { + return this.triggerStatsSearch(); + } + + private onResetForm() { + this.updateQueryURL([], this.state.searchBar); + this.setState({ query: [] }, this.triggerSearch); + } + + private onFilterChange(newFilters: ISearchParameter[]) { + this.setState({ + query: newFilters, + }, this.triggerSearch); + this.updateQueryURL(newFilters, this.state.searchBar); + } + + private onSearchBarChanged(e: any) { + const searchBar = e.target.value; + this.setState({ searchBar }); + this.updateQueryURL(this.state.query, searchBar); + } + + private updateQueryURL(filters: ISearchParameter[], searchBar: string) { + const newSearch = `?q=${encodeURIComponent( + JSON.stringify(filters) + )}&searchBar=${encodeURIComponent(searchBar)}`; + window.history.replaceState( + null, + '', + window.location.href.split('?')[0] + newSearch + ); + } + + private filter() { + const { sponsor, viewSaved, tableResults: results } = this.state; + const searchBar = this.state.searchBar.toLowerCase(); + return results.filter(({ hacker }) => { + const { accountId } = hacker; + let foundAcct; + if (typeof accountId !== 'string') { + const account = accountId as IAccount; + if (account) { + const fullName = `${account.firstName} ${account.lastName}`.toLowerCase(); + foundAcct = + fullName.includes(searchBar) || + account.email.toLowerCase().includes(searchBar) || + account.phoneNumber.toString().includes(searchBar) || + account.gender.toLowerCase().includes(searchBar) || + (account._id && account._id.includes(searchBar)); + } + } else { + foundAcct = accountId.includes(searchBar); + } + const foundHacker = + hacker.id.includes(searchBar) || + hacker.application.general.school.includes(searchBar) || + hacker.application.general.degree.includes(searchBar) || + hacker.application.general.fieldOfStudy.includes(searchBar) || + hacker.application.general.graduationYear + .toString() + .includes(searchBar) || + hacker.application.general.jobInterest.includes(searchBar) || + hacker.status.includes(searchBar) || + hacker.application.shortAnswer.question1.includes(searchBar) || + hacker.application.shortAnswer.question2.includes(searchBar) || + hacker.application.accommodation.shirtSize.includes(searchBar) || + (hacker.application.shortAnswer.skills && + hacker.application.shortAnswer.skills.toString().includes(searchBar)); + + const isSavedBySponsorIfToggled = + !viewSaved || + (sponsor && sponsor.nominees.some((n) => n === hacker.id)); + + return (foundAcct || foundHacker) && isSavedBySponsorIfToggled; + }); + } + + private toggleSaved = async () => { + // Resets the sponsor if they made changes to their saved hackers + const sponsor = (await Sponsor.getSelf()).data.data; + const { viewSaved } = this.state; + if (sponsor) { + this.setState({ sponsor, viewSaved: !viewSaved }); + } + }; +} + +export default withContext(WithToasterContainer(SearchContainer)); diff --git a/src/features/Stats/StatsApplications.tsx b/src/features/Stats/StatsApplications.tsx new file mode 100644 index 000000000..863f1afaa --- /dev/null +++ b/src/features/Stats/StatsApplications.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { Box, Flex } from '@rebass/grid'; + +import { Hacker } from '../../api'; +import { H2 } from '../../shared/Elements'; +import { + IStatsApplications, +} from '../../config'; +import { StatsApplicationsGraph } from './StatsApplicationsGraph'; + + +interface IStatsState { + applications: Array; + applicationsToday: number; + loading: boolean; +} + + +class StatsApplications extends React.Component<{}, IStatsState> { + constructor(props: {}) { + super(props); + this.state = { + applications: new Array(), + applicationsToday: 0, + loading: true, + }; + } + + componentWillMount() { + this.getApplications().then( + applications => { + this.setState({ + applications, + applicationsToday: applications[0].applications, + loading: false, + }); + } + ); + } + + private async getApplications(): Promise> { + const result = await Hacker.getAllStats(); + const applicationDate = result.data.data.stats.applicationDate; + var d = new Date(); + var statsApplications = new Array(); + for (var i = 0; i < 30; i++) { + const date = d.toISOString().split('T')[0]; + const applications = date in applicationDate ? applicationDate[date] : 0; + statsApplications.push({ date, applications }); + d.setDate(d.getDate() - 1); + } + return statsApplications; + } + + public render() { + return ( + + + +

+ Number of hackers applied today: {this.state.applicationsToday} +

+
+ + + +
+
+ ); + } + +} + +export default StatsApplications; diff --git a/src/features/Stats/StatsApplicationsGraph.tsx b/src/features/Stats/StatsApplicationsGraph.tsx new file mode 100644 index 000000000..c3d628b3f --- /dev/null +++ b/src/features/Stats/StatsApplicationsGraph.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { IStatsApplications } from '../../config'; + +import { + ResponsiveContainer, + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend +} from 'recharts'; + + +interface IStatsApplicationsGraph { + applications: Array; +} + +const StatsApplicationsGraph: React.FC = (props) => { + const applications = props.applications; + applications.sort((a, b) => (a.date > b.date) ? 1 : ((b.date > a.date) ? -1 : 0)); + + // cannot use ResponsiveContainer since parent has no width and height + return ( + + + + + + + + + + + ); +} + +export { StatsApplicationsGraph }; diff --git a/src/features/Team/JoinCreateTeam.tsx b/src/features/Team/JoinCreateTeam.tsx index 17b5d880d..628eb37e2 100644 --- a/src/features/Team/JoinCreateTeam.tsx +++ b/src/features/Team/JoinCreateTeam.tsx @@ -3,7 +3,7 @@ import React, { useState } from 'react'; import { Button, ButtonVariant, -} from '../../shared/Elements'; +} from '../../shared/Elements'; import { IHacker } from '../../config'; diff --git a/src/pages/Admin/Stats.tsx b/src/pages/Admin/Stats.tsx new file mode 100644 index 000000000..d89290dcd --- /dev/null +++ b/src/pages/Admin/Stats.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +import Stats from '../../features/Stats/Stats'; + +const AdminStatsPage: React.FC = () => ( + + ); + +export default AdminStatsPage; diff --git a/src/util/util.ts b/src/util/util.ts index 45a26120e..73702a354 100644 --- a/src/util/util.ts +++ b/src/util/util.ts @@ -113,6 +113,15 @@ function getValueFromQuery(key: string): string | undefined { return queries[key]; } +function normalizeArray(array: T[], indexKey: keyof T) { + const normalizedObject: any = {}; + for (const el of array) { + const key = el[indexKey]; + normalizedObject[key] = el; + } + return normalizedObject as { [key: string]: T }; +} + export { padStart, getNestedAttr, @@ -124,4 +133,5 @@ export { datetime2input, input2datetime, date2human, + normalizeArray };