diff --git a/.vscode/settings.json b/.vscode/settings.json index b66f77d21..601544d91 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -54,5 +54,5 @@ "search.exclude": { "package-lock.json": true }, - "debug.node.autoAttach": "off", + "debug.node.autoAttach": "off" } diff --git a/package-lock.json b/package-lock.json index 67fe8f162..efa8fe118 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7324,6 +7324,19 @@ "postcss": "7.x.x" } }, + "@types/d3-path": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.8.tgz", + "integrity": "sha512-AZGHWslq/oApTAHu9+yH/Bnk63y9oFOMROtqPAtxl5uB6qm1x2lueWdVEjsjjV3Qc2+QfuzKIwIR5MvVBakfzA==" + }, + "@types/d3-shape": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.2.tgz", + "integrity": "sha512-LtD8EaNYCaBRzHzaAiIPrfcL3DdIysc81dkGlQvv7WQP3+YXV7b0JJTtR1U3bzeRieS603KF4wUo+ZkJVenh8w==", + "requires": { + "@types/d3-path": "*" + } + }, "@types/debug": { "version": "0.0.30", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-0.0.30.tgz", @@ -8007,8 +8020,7 @@ "@types/prop-types": { "version": "15.5.8", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.5.8.tgz", - "integrity": "sha512-3AQoUxQcQtLHsK25wtTWIoIpgYjH3vSDroZOUr7PpCHw/jLY1RB9z9E8dBT/OSmwStVgkRNvdh+ZHNiomRieaw==", - "dev": true + "integrity": "sha512-3AQoUxQcQtLHsK25wtTWIoIpgYjH3vSDroZOUr7PpCHw/jLY1RB9z9E8dBT/OSmwStVgkRNvdh+ZHNiomRieaw==" }, "@types/q": { "version": "1.5.1", @@ -8040,7 +8052,6 @@ "version": "16.9.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.31.tgz", "integrity": "sha512-NpYJpNMWScFXtx3A2BJMeew2G3+9SEslVWMdxNJ6DLvxIuxWjY1bizK9q5Y1ujhln31vtjmhjOAYDr9Xx3k9FQ==", - "dev": true, "requires": { "@types/prop-types": "*", "csstype": "^2.2.0" @@ -8129,6 +8140,21 @@ "@types/react": "*" } }, + "@types/recharts": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/@types/recharts/-/recharts-1.8.11.tgz", + "integrity": "sha512-1E1+3uKo7EyNk3LZ6J2cqfayIdQEUt1YOq2Y/9eRSXUzasf100ncF4f0iARnaXnED6DyFgTPRomwtChcwLYvhA==", + "requires": { + "@types/d3-shape": "*", + "@types/react": "*", + "@types/recharts-scale": "*" + } + }, + "@types/recharts-scale": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/recharts-scale/-/recharts-scale-1.0.0.tgz", + "integrity": "sha512-HR/PrCcxYb2YHviTqH7CMdL1TUhUZLTUKzfrkMhxm1HTa5mg/QtP8XMiuSPz6dZ6wecazAOu8aYZ5DqkNlgHHQ==" + }, "@types/recompose": { "version": "0.30.7", "resolved": "https://registry.npmjs.org/@types/recompose/-/recompose-0.30.7.tgz", @@ -13023,8 +13049,7 @@ "classnames": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", - "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==", - "dev": true + "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==" }, "clean-css": { "version": "4.2.1", @@ -15621,6 +15646,73 @@ "es5-ext": "^0.10.9" } }, + "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.4", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.4.tgz", + "integrity": "sha512-TWks25e7t8/cqctxCmxpUuzZN11QxIA7YrMbram94zMQ0PXjE4LVIMe/f6a4+xxL8HQ3OsAFULOINQi1pE62Aw==" + }, + "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.2.3", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.2.3.tgz", + "integrity": "sha512-RAHNnD8+XvC4Zc4d2A56Uw0yJoM7bsvOlJR33bclxq399Rak/b9bhvu/InjxdWhPtkgU53JJcleJTGkNRnN6IA==", + "requires": { + "d3-time": "1" + } + }, "damerau-levenshtein": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.5.tgz", @@ -15927,6 +16019,11 @@ "integrity": "sha1-rzJJRl4TOYjDB1D3fqr0RQXKpeM=", "dev": true }, + "decimal.js-light": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.0.tgz", + "integrity": "sha512-b3VJCbd2hwUpeRGG3Toob+CRo8W22xplipNhP3tN7TSVB/cyMX71P1vM2Xjc9H74uV6dS2hDDmo/rHq8L87Upg==" + }, "decode-uri-component": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", @@ -17539,7 +17636,6 @@ "version": "3.4.0", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz", "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==", - "dev": true, "requires": { "@babel/runtime": "^7.1.2" } @@ -21389,28 +21485,28 @@ "dependencies": { "abbrev": { "version": "1.1.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true, "optional": true }, "ansi-regex": { "version": "2.1.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", "dev": true, "optional": true }, "aproba": { "version": "1.2.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", "dev": true, "optional": true }, "are-we-there-yet": { "version": "1.1.5", - "resolved": false, + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz", "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==", "dev": true, "optional": true, @@ -21421,14 +21517,14 @@ }, "balanced-match": { "version": "1.0.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", "dev": true, "optional": true }, "brace-expansion": { "version": "1.1.11", - "resolved": false, + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, "optional": true, @@ -21439,42 +21535,42 @@ }, "chownr": { "version": "1.1.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.1.tgz", "integrity": "sha512-j38EvO5+LHX84jlo6h4UzmOwi0UgW61WRyPtJz4qaadK5eY3BTS5TY/S1Stc3Uk2lIM6TPevAlULiEJwie860g==", "dev": true, "optional": true }, "code-point-at": { "version": "1.1.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", "dev": true, "optional": true }, "concat-map": { "version": "0.0.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true, "optional": true }, "console-control-strings": { "version": "1.1.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", "dev": true, "optional": true }, "core-util-is": { "version": "1.0.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", "dev": true, "optional": true }, "debug": { "version": "4.1.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", "dev": true, "optional": true, @@ -21484,28 +21580,28 @@ }, "deep-extend": { "version": "0.6.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "dev": true, "optional": true }, "delegates": { "version": "1.0.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", "dev": true, "optional": true }, "detect-libc": { "version": "1.0.3", - "resolved": false, + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=", "dev": true, "optional": true }, "fs-minipass": { "version": "1.2.5", - "resolved": false, + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.5.tgz", "integrity": "sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ==", "dev": true, "optional": true, @@ -21515,14 +21611,14 @@ }, "fs.realpath": { "version": "1.0.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "dev": true, "optional": true }, "gauge": { "version": "2.7.4", - "resolved": false, + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", "dev": true, "optional": true, @@ -21539,7 +21635,7 @@ }, "glob": { "version": "7.1.3", - "resolved": false, + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", "dev": true, "optional": true, @@ -21554,14 +21650,14 @@ }, "has-unicode": { "version": "2.0.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", "dev": true, "optional": true }, "iconv-lite": { "version": "0.4.24", - "resolved": false, + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "dev": true, "optional": true, @@ -21571,7 +21667,7 @@ }, "ignore-walk": { "version": "3.0.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.1.tgz", "integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==", "dev": true, "optional": true, @@ -21581,7 +21677,7 @@ }, "inflight": { "version": "1.0.6", - "resolved": false, + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", "dev": true, "optional": true, @@ -21592,21 +21688,21 @@ }, "inherits": { "version": "2.0.3", - "resolved": false, + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", "dev": true, "optional": true }, "ini": { "version": "1.3.5", - "resolved": false, + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", "dev": true, "optional": true }, "is-fullwidth-code-point": { "version": "1.0.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", "dev": true, "optional": true, @@ -21616,14 +21712,14 @@ }, "isarray": { "version": "1.0.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", "dev": true, "optional": true }, "minimatch": { "version": "3.0.4", - "resolved": false, + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "dev": true, "optional": true, @@ -21633,14 +21729,14 @@ }, "minimist": { "version": "0.0.8", - "resolved": false, + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", "dev": true, "optional": true }, "minipass": { "version": "2.3.5", - "resolved": false, + "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.3.5.tgz", "integrity": "sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==", "dev": true, "optional": true, @@ -21651,7 +21747,7 @@ }, "minizlib": { "version": "1.2.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.2.1.tgz", "integrity": "sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA==", "dev": true, "optional": true, @@ -21661,7 +21757,7 @@ }, "mkdirp": { "version": "0.5.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "dev": true, "optional": true, @@ -21671,7 +21767,7 @@ }, "ms": { "version": "2.1.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", "dev": true, "optional": true @@ -21685,7 +21781,7 @@ }, "needle": { "version": "2.3.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/needle/-/needle-2.3.0.tgz", "integrity": "sha512-QBZu7aAFR0522EyaXZM0FZ9GLpq6lvQ3uq8gteiDUp7wKdy0lSd2hPlgFwVuW1CBkfEs9PfDQsQzZghLs/psdg==", "dev": true, "optional": true, @@ -21697,7 +21793,7 @@ }, "node-pre-gyp": { "version": "0.12.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.12.0.tgz", "integrity": "sha512-4KghwV8vH5k+g2ylT+sLTjy5wmUOb9vPhnM8NHvRf9dHmnW/CndrFXy2aRPaPST6dugXSdHXfeaHQm77PIz/1A==", "dev": true, "optional": true, @@ -21716,7 +21812,7 @@ }, "nopt": { "version": "4.0.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz", "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=", "dev": true, "optional": true, @@ -21727,14 +21823,14 @@ }, "npm-bundled": { "version": "1.0.6", - "resolved": false, + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.0.6.tgz", "integrity": "sha512-8/JCaftHwbd//k6y2rEWp6k1wxVfpFzB6t1p825+cUb7Ym2XQfhwIC5KwhrvzZRJu+LtDE585zVaS32+CGtf0g==", "dev": true, "optional": true }, "npm-packlist": { "version": "1.4.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.1.tgz", "integrity": "sha512-+TcdO7HJJ8peiiYhvPxsEDhF3PJFGUGRcFsGve3vxvxdcpO2Z4Z7rkosRM0kWj6LfbK/P0gu3dzk5RU1ffvFcw==", "dev": true, "optional": true, @@ -21745,7 +21841,7 @@ }, "npmlog": { "version": "4.1.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", "dev": true, "optional": true, @@ -21758,21 +21854,21 @@ }, "number-is-nan": { "version": "1.0.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", "dev": true, "optional": true }, "object-assign": { "version": "4.1.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", "dev": true, "optional": true }, "once": { "version": "1.4.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "dev": true, "optional": true, @@ -21782,21 +21878,21 @@ }, "os-homedir": { "version": "1.0.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", "dev": true, "optional": true }, "os-tmpdir": { "version": "1.0.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", "dev": true, "optional": true }, "osenv": { "version": "0.1.5", - "resolved": false, + "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", "dev": true, "optional": true, @@ -21807,21 +21903,21 @@ }, "path-is-absolute": { "version": "1.0.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", "dev": true, "optional": true }, "process-nextick-args": { "version": "2.0.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", "dev": true, "optional": true }, "rc": { "version": "1.2.8", - "resolved": false, + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "dev": true, "optional": true, @@ -21834,7 +21930,7 @@ "dependencies": { "minimist": { "version": "1.2.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", "dev": true, "optional": true @@ -21843,7 +21939,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": false, + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "dev": true, "optional": true, @@ -21859,7 +21955,7 @@ }, "rimraf": { "version": "2.6.3", - "resolved": false, + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", "dev": true, "optional": true, @@ -21869,49 +21965,49 @@ }, "safe-buffer": { "version": "5.1.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, "optional": true }, "safer-buffer": { "version": "2.1.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true, "optional": true }, "sax": { "version": "1.2.4", - "resolved": false, + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", "dev": true, "optional": true }, "semver": { "version": "5.7.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", "dev": true, "optional": true }, "set-blocking": { "version": "2.0.0", - "resolved": false, + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", "dev": true, "optional": true }, "signal-exit": { "version": "3.0.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", "dev": true, "optional": true }, "string-width": { "version": "1.0.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", "dev": true, "optional": true, @@ -21923,7 +22019,7 @@ }, "string_decoder": { "version": "1.1.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "optional": true, @@ -21933,7 +22029,7 @@ }, "strip-ansi": { "version": "3.0.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "dev": true, "optional": true, @@ -21943,14 +22039,14 @@ }, "strip-json-comments": { "version": "2.0.1", - "resolved": false, + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", "dev": true, "optional": true }, "tar": { "version": "4.4.8", - "resolved": false, + "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.8.tgz", "integrity": "sha512-LzHF64s5chPQQS0IYBn9IN5h3i98c12bo4NCO7e0sGM2llXQ3p2FGC5sdENN4cTW48O915Sh+x+EXx7XW96xYQ==", "dev": true, "optional": true, @@ -21966,14 +22062,14 @@ }, "util-deprecate": { "version": "1.0.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "dev": true, "optional": true }, "wide-align": { "version": "1.1.3", - "resolved": false, + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", "dev": true, "optional": true, @@ -21983,14 +22079,14 @@ }, "wrappy": { "version": "1.0.2", - "resolved": false, + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true, "optional": true }, "yallist": { "version": "3.0.3", - "resolved": false, + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz", "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==", "dev": true, "optional": true @@ -33108,8 +33204,7 @@ "js-tokens": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", - "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=", - "dev": true + "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=" }, "js-yaml": { "version": "3.13.1", @@ -34018,7 +34113,7 @@ }, "chalk": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", "dev": true, "requires": { @@ -34314,8 +34409,7 @@ "lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=", - "dev": true + "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=" }, "lodash.deburr": { "version": "4.1.0", @@ -34494,9 +34588,7 @@ "lodash.throttle": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", - "integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=", - "dev": true, - "optional": true + "integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=" }, "lodash.toarray": { "version": "4.4.0", @@ -34635,7 +34727,6 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz", "integrity": "sha1-0aitM/qc4OcT1l/dCsi3SNR4yEg=", - "dev": true, "requires": { "js-tokens": "^3.0.0" } @@ -34885,6 +34976,11 @@ "integrity": "sha1-mnHEh0chjrylHlGmbaaCA4zct78=", "dev": true }, + "math-expression-evaluator": { + "version": "1.2.22", + "resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.2.22.tgz", + "integrity": "sha512-L0j0tFVZBQQLeEjmWOvDLoRciIY8gQGWahvkztXUal8jH8R5Rlqo9GCvgqvXcy9LQhEWdQCVvzqAbxgYNt4blQ==" + }, "mathjs": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-2.7.0.tgz", @@ -42971,7 +43067,6 @@ "version": "15.6.2", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.2.tgz", "integrity": "sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==", - "dev": true, "requires": { "loose-envify": "^1.3.1", "object-assign": "^4.1.1" @@ -43178,7 +43273,6 @@ "version": "3.4.1", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", - "dev": true, "requires": { "performance-now": "^2.1.0" } @@ -44000,8 +44094,7 @@ "react-fast-compare": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", - "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==", - "dev": true + "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==" }, "react-feather": { "version": "2.0.3", @@ -44063,7 +44156,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-5.2.1.tgz", "integrity": "sha512-CnwD822LU8NDBnjCpZ4ySh8L6HYyngViTZLfBBb3NjtrpN8m49clH8hidHouq20I51Y6TpCTISCBbqiY5GamwA==", - "dev": true, "requires": { "object-assign": "^4.1.1", "prop-types": "^15.5.4", @@ -44165,8 +44257,7 @@ "react-lifecycles-compat": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", - "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", - "dev": true + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" }, "react-live": { "version": "2.2.2", @@ -44395,7 +44486,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-1.2.0.tgz", "integrity": "sha512-v1ht1aHg5k/thv56DRcjw+WtojuuDHFUgGfc+bFHOWsF4ZK6C2V57DO0Or0GPsg6+LSTE0M6Ry/gfzhzSwbc5w==", - "dev": true, "requires": { "shallowequal": "^1.0.1" } @@ -44406,6 +44496,38 @@ "integrity": "sha512-bL5W5mAxSW6+cLwqqVWY47Silqgy2DKDTR4hDBrLrUqC5BXc29YVx17l2IZk5v36VcDEq1Bszu2oHm1qBwKqBA==", "dev": true }, + "react-smooth": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-1.0.5.tgz", + "integrity": "sha512-eW057HT0lFgCKh8ilr0y2JaH2YbNcuEdFpxyg7Gf/qDKk9hqGMyXryZJ8iMGJEuKH0+wxS0ccSsBBB3W8yCn8w==", + "requires": { + "lodash": "~4.17.4", + "prop-types": "^15.6.0", + "raf": "^3.4.0", + "react-transition-group": "^2.5.0" + }, + "dependencies": { + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "react-transition-group": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz", + "integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==", + "requires": { + "dom-helpers": "^3.4.0", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2", + "react-lifecycles-compat": "^3.0.4" + } + } + } + }, "react-static-container": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/react-static-container/-/react-static-container-1.0.2.tgz", @@ -44585,6 +44707,50 @@ } } }, + "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" + }, + "dependencies": { + "core-js": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz", + "integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==" + }, + "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" + } + } + } + }, + "recharts-scale": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.3.tgz", + "integrity": "sha512-t8p5sccG9Blm7c1JQK/ak9O8o95WGhNXD7TXg/BW5bYbVlr6eCeRBNpgyigD4p6pSSMehC5nSvBUPj6F68rbFA==", + "requires": { + "decimal.js-light": "^2.4.1" + } + }, "rechoir": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", @@ -44653,6 +44819,31 @@ "redis-errors": "^1.0.0" } }, + "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" + } + }, "redux": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz", @@ -45914,8 +46105,7 @@ "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==", - "dev": true + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" }, "resolve": { "version": "1.8.1", @@ -46446,8 +46636,7 @@ "shallowequal": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", - "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", - "dev": true + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" }, "shebang-command": { "version": "1.2.0", diff --git a/package.json b/package.json index bc3d9dbf4..5e1000f98 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@hapi/joi": "^17.1.1", "@metascraper/helpers": "^5.11.6", "@rudderstack/rudder-sdk-node": "0.0.2", + "@types/recharts": "^1.8.9", "abort-controller": "^3.0.0", "akismet-api": "^5.0.0", "apollo-server-express": "^2.11.0", @@ -135,6 +136,8 @@ "querystringify": "^2.1.1", "source-map-support": "^0.5.16", "stack-utils": "^2.0.1", + "react-helmet": "^5.2.1", + "recharts": "^1.8.5", "striptags": "^3.1.1", "tsscmp": "^1.0.6", "url-regex": "^5.0.0", @@ -327,7 +330,6 @@ "react-error-overlay": "^6.0.7", "react-final-form": "6.3.0", "react-final-form-arrays": "^3.1.1", - "react-helmet": "^5.2.1", "react-popper": "^1.3.7", "react-relay": "^9.0.0", "react-relay-network-modern": "^4.6.1", diff --git a/src/core/client/admin/App/Navigation/Navigation.spec.tsx b/src/core/client/admin/App/Navigation/Navigation.spec.tsx index 2f493507c..f5a62408e 100644 --- a/src/core/client/admin/App/Navigation/Navigation.spec.tsx +++ b/src/core/client/admin/App/Navigation/Navigation.spec.tsx @@ -5,6 +5,6 @@ import Navigation from "./Navigation"; it("renders correctly", () => { const renderer = createRenderer(); - renderer.render(); + renderer.render(); expect(renderer.getRenderOutput()).toMatchSnapshot(); }); diff --git a/src/core/client/admin/App/Navigation/Navigation.tsx b/src/core/client/admin/App/Navigation/Navigation.tsx index d4477966f..4e2c54cbf 100644 --- a/src/core/client/admin/App/Navigation/Navigation.tsx +++ b/src/core/client/admin/App/Navigation/Navigation.tsx @@ -7,6 +7,7 @@ import NavigationLink from "./NavigationLink"; interface Props { showConfigure: boolean; + showDashboard: boolean; } const Navigation: FunctionComponent = (props) => ( @@ -25,6 +26,11 @@ const Navigation: FunctionComponent = (props) => ( Configure )} + {props.showDashboard && ( + + Dashboard + + )} ); diff --git a/src/core/client/admin/App/Navigation/NavigationContainer.tsx b/src/core/client/admin/App/Navigation/NavigationContainer.tsx index bc60a5770..22f328733 100644 --- a/src/core/client/admin/App/Navigation/NavigationContainer.tsx +++ b/src/core/client/admin/App/Navigation/NavigationContainer.tsx @@ -21,6 +21,9 @@ class NavigationContainer extends React.Component { public render() { return ( + + + Dashboard + + `; diff --git a/src/core/client/admin/permissions.tsx b/src/core/client/admin/permissions.tsx index ed318f3b4..241e70c20 100644 --- a/src/core/client/admin/permissions.tsx +++ b/src/core/client/admin/permissions.tsx @@ -23,6 +23,7 @@ const permissionMap = { CHANGE_STORY_STATUS: [GQLUSER_ROLE.ADMIN, GQLUSER_ROLE.MODERATOR], // Mutation.inviteUsers INVITE_USERS: [GQLUSER_ROLE.ADMIN], + VIEW_STATISTICS: [GQLUSER_ROLE.ADMIN, GQLUSER_ROLE.MODERATOR], }; export type AbilityType = keyof typeof permissionMap; diff --git a/src/core/client/admin/routeConfig.tsx b/src/core/client/admin/routeConfig.tsx index a3d198b12..b63b165a5 100644 --- a/src/core/client/admin/routeConfig.tsx +++ b/src/core/client/admin/routeConfig.tsx @@ -29,6 +29,8 @@ import { Sites } from "./routes/Configure/sections/Sites"; import AddSiteRoute from "./routes/Configure/sections/Sites/AddSiteRoute"; import SiteRoute from "./routes/Configure/sections/Sites/SiteRoute"; import WebhookEndpointsLayout from "./routes/Configure/sections/WebhookEndpoints/WebhookEndpointsLayout"; +import DashboardRoute from "./routes/Dashboard"; +import SiteDashboardRoute from "./routes/Dashboard/SiteDashboardRoute"; import ForgotPasswordRoute from "./routes/ForgotPassword"; import InviteRoute from "./routes/Invite"; import LoginRoute from "./routes/Login"; @@ -111,6 +113,8 @@ export default makeRouteConfig( /> + + = (props) => ( + + + + + + + +
+ +
+
+
+); + +export default Dashboard; diff --git a/src/core/client/admin/routes/Dashboard/DashboardContainer.css b/src/core/client/admin/routes/Dashboard/DashboardContainer.css new file mode 100644 index 000000000..32ce7aec8 --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/DashboardContainer.css @@ -0,0 +1,17 @@ +.root { + margin-top: var(--v2-spacing-4); +} + +.icon { + color: var(--v2-colors-mono-100); + margin-top: var(--v2-spacing-1); + margin-left: var(--v2-spacing-1); +} + +.header { + font-family: var(--v2-font-family-primary); + font-weight: var(--v2-font-weight-primary-bold); + font-size: var(--v2-font-size-6); + color: var(--v2-colors-teal-600); + margin: 0; +} diff --git a/src/core/client/admin/routes/Dashboard/DashboardContainer.tsx b/src/core/client/admin/routes/Dashboard/DashboardContainer.tsx new file mode 100644 index 000000000..437ad13c2 --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/DashboardContainer.tsx @@ -0,0 +1,155 @@ +import React, { useCallback, useState } from "react"; +import { graphql, RelayPaginationProp } from "react-relay"; + +import MainLayout from "coral-admin/components/MainLayout"; +import { IntersectionProvider } from "coral-framework/lib/intersection"; +import { + useLoadMore, + useRefetch, + withPaginationContainer, +} from "coral-framework/lib/relay"; +import { + BaseButton, + Button, + ButtonIcon, + ClickOutside, + Dropdown, + Flex, + Popover, +} from "coral-ui/components/v2"; + +import { DashboardContainer_query as QueryData } from "coral-admin/__generated__/DashboardContainer_query.graphql"; +import { DashboardContainerPaginationQueryVariables } from "coral-admin/__generated__/DashboardContainerPaginationQuery.graphql"; + +import SiteDashboardTimestamp from "./components/SiteDashboardTimestamp"; +import Dashboard from "./Dashboard"; +import DashboardSiteSelector from "./DashboardSiteSelector"; + +import styles from "./DashboardContainer.css"; + +interface Props { + query: QueryData | null; + relay: RelayPaginationProp; + selectedSiteID?: string; +} +const DashboardContainer: React.FunctionComponent = (props) => { + const sites = props.query + ? props.query.sites.edges.map((edge) => edge.node) + : []; + const selectedSite = props.selectedSiteID + ? sites.find((s) => s.id === props.selectedSiteID) + : sites[0]; + + if (!selectedSite) { + return null; + } + const [lastUpdated, setLastUpdated] = useState(new Date().toString()); + const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10); + const [, isRefetching] = useRefetch< + DashboardContainerPaginationQueryVariables + >(props.relay); + const onRefetch = useCallback(() => { + setLastUpdated(new Date().toString()); + }, []); + return ( + + ( + + + + + + + + )} + > + {({ toggleVisibility, ref, visible }) => ( + + +

{selectedSite.name}

+ { + + {visible ? "arrow_drop_up" : "arrow_drop_down"} + + } +
+
+ )} +
+ + + + + +
+ ); +}; + +type FragmentVariables = DashboardContainerPaginationQueryVariables; + +const enhanced = withPaginationContainer< + Props, + DashboardContainerPaginationQueryVariables, + FragmentVariables +>( + { + query: graphql` + fragment DashboardContainer_query on Query + @argumentDefinitions( + count: { type: "Int!", defaultValue: 20 } + cursor: { type: "Cursor" } + ) { + sites(first: $count, after: $cursor) + @connection(key: "SitesConfig_sites") { + edges { + node { + id + name + ...DashboardSiteContainer_site + } + } + } + } + `, + }, + { + direction: "forward", + getConnectionFromProps(props) { + return props.query && props.query.sites; + }, + // This is also the default implementation of `getFragmentVariables` if it isn't provided. + getFragmentVariables(prevVars, totalCount) { + return { + ...prevVars, + count: totalCount, + }; + }, + getVariables(props, { count, cursor }, fragmentVariables) { + return { + count, + cursor, + }; + }, + query: graphql` + # Pagination query to be fetched upon calling 'loadMore'. + # Notice that we re-use our fragment, and the shape of this query matches our fragment spec. + query DashboardContainerPaginationQuery($count: Int!, $cursor: Cursor) { + ...DashboardContainer_query @arguments(count: $count, cursor: $cursor) + } + `, + } +)(DashboardContainer); +export default enhanced; diff --git a/src/core/client/admin/routes/Dashboard/DashboardRoute.tsx b/src/core/client/admin/routes/Dashboard/DashboardRoute.tsx new file mode 100644 index 000000000..9fc275ee8 --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/DashboardRoute.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { graphql } from "react-relay"; + +import { withRouteConfig } from "coral-framework/lib/router"; + +import { DashboardRouteQueryResponse } from "coral-admin/__generated__/DashboardRouteQuery.graphql"; + +import DashboardSiteSelectorContainer from "./DashboardContainer"; + +interface Props { + data: DashboardRouteQueryResponse | null; +} +const DashboardRoute: React.FunctionComponent = ({ data }) => { + if (!data) { + return null; + } + + return ; +}; + +const enhanced = withRouteConfig({ + query: graphql` + query DashboardRouteQuery { + ...DashboardContainer_query + } + `, +})(DashboardRoute); + +export default enhanced; diff --git a/src/core/client/admin/routes/Dashboard/DashboardSiteContainer.css b/src/core/client/admin/routes/Dashboard/DashboardSiteContainer.css new file mode 100644 index 000000000..6e0a0939e --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/DashboardSiteContainer.css @@ -0,0 +1,3 @@ +.button { + color: var(--v2-palette-primary-main); +} diff --git a/src/core/client/admin/routes/Dashboard/DashboardSiteContainer.tsx b/src/core/client/admin/routes/Dashboard/DashboardSiteContainer.tsx new file mode 100644 index 000000000..716b762af --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/DashboardSiteContainer.tsx @@ -0,0 +1,36 @@ +import React, { FunctionComponent } from "react"; +import { graphql } from "react-relay"; + +import { withFragmentContainer } from "coral-framework/lib/relay"; +import { DropdownButton } from "coral-ui/components/v2"; + +import { DashboardSiteContainer_site } from "coral-admin/__generated__/DashboardSiteContainer_site.graphql"; + +import styles from "./DashboardSiteContainer.css"; + +interface Props { + site: DashboardSiteContainer_site; +} + +const DashboardSiteContainer: FunctionComponent = ({ site }) => { + return ( + + {site.name} + + ); +}; + +const enhanced = withFragmentContainer({ + site: graphql` + fragment DashboardSiteContainer_site on Site { + id + name + createdAt + } + `, +})(DashboardSiteContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/Dashboard/DashboardSiteSelector.tsx b/src/core/client/admin/routes/Dashboard/DashboardSiteSelector.tsx new file mode 100644 index 000000000..8a905d48b --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/DashboardSiteSelector.tsx @@ -0,0 +1,42 @@ +import React, { FunctionComponent } from "react"; + +import AutoLoadMore from "coral-admin/components/AutoLoadMore"; +import { PropTypesOf } from "coral-framework/types"; +import { Flex, Spinner } from "coral-ui/components/v2"; + +import DashboardSiteContainer from "./DashboardSiteContainer"; + +interface Props { + sites: Array< + { id: string } & PropTypesOf["site"] + >; + onLoadMore: () => void; + hasMore: boolean; + disableLoadMore: boolean; + loading: boolean; +} + +const SitesTable: FunctionComponent = (props) => { + return ( + <> + {props.sites.map((site) => ( + + ))} + {props.loading && ( + + + + )} + {props.hasMore && ( + + + + )} + + ); +}; + +export default SitesTable; diff --git a/src/core/client/admin/routes/Dashboard/SiteDashboardRoute.tsx b/src/core/client/admin/routes/Dashboard/SiteDashboardRoute.tsx new file mode 100644 index 000000000..6cd1b227d --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/SiteDashboardRoute.tsx @@ -0,0 +1,45 @@ +import { Match, Router } from "found"; +import React from "react"; +import { graphql } from "react-relay"; + +import { withRouteConfig } from "coral-framework/lib/router"; + +import { SiteDashboardRouteQueryResponse } from "coral-admin/__generated__/SiteDashboardRouteQuery.graphql"; + +import DashboardSiteSelectorContainer from "./DashboardContainer"; + +interface RouteParams { + siteID: string; +} + +interface Props { + data: SiteDashboardRouteQueryResponse | null; + router: Router; + match: Match & { params: RouteParams }; +} +const SiteDashboardRoute: React.FunctionComponent = (props) => { + const { data } = props; + if (data && data.site) { + return ( + + ); + } + return null; +}; + +const enhanced = withRouteConfig({ + query: graphql` + query SiteDashboardRouteQuery($siteID: ID!) { + ...DashboardContainer_query + site(id: $siteID) { + name + id + } + } + `, +})(SiteDashboardRoute); + +export default enhanced; diff --git a/src/core/client/admin/routes/Dashboard/components/DashboardBox.css b/src/core/client/admin/routes/Dashboard/components/DashboardBox.css new file mode 100644 index 000000000..39a8232f5 --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/components/DashboardBox.css @@ -0,0 +1,7 @@ +.root { + border: 1px solid var(--v2-colors-grey-300); + border-radius: 4px; + padding: var(--v2-spacing-3); + font-family: var(--v2-font-family-primary); + flex: 1; +} diff --git a/src/core/client/admin/routes/Dashboard/components/DashboardBox.tsx b/src/core/client/admin/routes/Dashboard/components/DashboardBox.tsx new file mode 100644 index 000000000..4f558a7b2 --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/components/DashboardBox.tsx @@ -0,0 +1,14 @@ +import cn from "classnames"; +import React, { FunctionComponent } from "react"; + +import styles from "./DashboardBox.css"; + +interface Props { + className?: string; +} + +const DashboardBox: FunctionComponent = ({ children, className }) => { + return
{children}
; +}; + +export default DashboardBox; diff --git a/src/core/client/admin/routes/Dashboard/components/DashboardComponentHeading.css b/src/core/client/admin/routes/Dashboard/components/DashboardComponentHeading.css new file mode 100644 index 000000000..fb5bf9a0d --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/components/DashboardComponentHeading.css @@ -0,0 +1,6 @@ +.root { + font-family: var(--v2-font-family-primary); + font-weight: var(--v2-font-weight-primary-semi-bold); + font-size: var(--v2-font-size-3); + margin-top: 0; +} diff --git a/src/core/client/admin/routes/Dashboard/components/DashboardComponentHeading.tsx b/src/core/client/admin/routes/Dashboard/components/DashboardComponentHeading.tsx new file mode 100644 index 000000000..637d847e8 --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/components/DashboardComponentHeading.tsx @@ -0,0 +1,17 @@ +import cn from "classnames"; +import React, { FunctionComponent } from "react"; + +import styles from "./DashboardComponentHeading.css"; + +interface Props { + className?: string; +} + +const DashboardComponentHeading: FunctionComponent = ({ + children, + className, +}) => { + return

{children}

; +}; + +export default DashboardComponentHeading; diff --git a/src/core/client/admin/routes/Dashboard/components/Loader.css b/src/core/client/admin/routes/Dashboard/components/Loader.css new file mode 100644 index 000000000..7214b4550 --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/components/Loader.css @@ -0,0 +1,3 @@ +.root { + height: 100%; +} diff --git a/src/core/client/admin/routes/Dashboard/components/Loader.tsx b/src/core/client/admin/routes/Dashboard/components/Loader.tsx new file mode 100644 index 000000000..ca6ae0774 --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/components/Loader.tsx @@ -0,0 +1,28 @@ +import React, { FunctionComponent } from "react"; + +import { Flex, Spinner } from "coral-ui/components/v2"; + +import styles from "./Loader.css"; + +interface Props { + loading: boolean; + height?: number; +} + +const Loader: FunctionComponent = ({ loading, height }) => { + if (!loading) { + return null; + } + return ( + + + + ); +}; + +export default Loader; diff --git a/src/core/client/admin/routes/Dashboard/components/SiteDashboardTimestamp.css b/src/core/client/admin/routes/Dashboard/components/SiteDashboardTimestamp.css new file mode 100644 index 000000000..bfb5907c4 --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/components/SiteDashboardTimestamp.css @@ -0,0 +1,11 @@ +.timestamp { + font-family: var(--v2-font-family-primary); + font-weight: var(--v2-font-weight-primary-regular); + font-size: var(--v2-font-size-1); + color: var(--v2-colors-mono-500); + margin-top: var(--v2-spacing-1); +} + +.root { + text-align: left; +} diff --git a/src/core/client/admin/routes/Dashboard/components/SiteDashboardTimestamp.tsx b/src/core/client/admin/routes/Dashboard/components/SiteDashboardTimestamp.tsx new file mode 100644 index 000000000..70ea8e59c --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/components/SiteDashboardTimestamp.tsx @@ -0,0 +1,26 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent, useEffect, useState } from "react"; + +import { AbsoluteTime } from "coral-ui/components"; + +import styles from "./SiteDashboardTimestamp.css"; + +const SiteDashboardHeader: FunctionComponent = () => { + const [updatedAt, setUpdatedAt] = useState(null); + useEffect(() => { + setUpdatedAt(new Date()); + }, []); + if (!updatedAt) { + return null; + } + return ( +

+ + Last updated: + {" "} + +

+ ); +}; + +export default SiteDashboardHeader; diff --git a/src/core/client/admin/routes/Dashboard/components/TodayCompareValue.css b/src/core/client/admin/routes/Dashboard/components/TodayCompareValue.css new file mode 100644 index 000000000..1dd2877c7 --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/components/TodayCompareValue.css @@ -0,0 +1,8 @@ +.valueBoxCompareValue { + font-size: var(--v2-font-size-2); + font-weight: var(--v2-font-weight-primary-semi-bold); +} + +.valueBoxCompareName { + font-size: var(--v2-font-size-1); +} diff --git a/src/core/client/admin/routes/Dashboard/components/TodayCompareValue.tsx b/src/core/client/admin/routes/Dashboard/components/TodayCompareValue.tsx new file mode 100644 index 000000000..1d4e54b82 --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/components/TodayCompareValue.tsx @@ -0,0 +1,23 @@ +import React, { FunctionComponent } from "react"; + +import { HorizontalGutter } from "coral-ui/components/v2"; + +import styles from "./TodayCompareValue.css"; + +interface Props { + value?: string; +} + +const TodayCompareValue: FunctionComponent = ({ + value = "-", + children, +}) => { + return ( + +

{value}

+

{children}

+
+ ); +}; + +export default TodayCompareValue; diff --git a/src/core/client/admin/routes/Dashboard/components/TodayDashboardBox.css b/src/core/client/admin/routes/Dashboard/components/TodayDashboardBox.css new file mode 100644 index 000000000..03adc1125 --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/components/TodayDashboardBox.css @@ -0,0 +1,41 @@ +.root { + flex: 1; + min-height: 120px; +} + +.icon { + display: flex; + justify-content: center; + align-items: center; + min-width: 28px; + height: 28px; + border-radius: 50%; +} + +.tealIcon { + background-color: var(--v2-colors-teal-100); + color: var(--v2-colors-teal-600); +} + +.redIcon { + background-color: var(--v2-colors-red-100); + color: var(--v2-colors-red-500); +} + +.greyIcon { + background-color: var(--v2-colors-grey-200); + color: var(--v2-colors-grey-500); +} + +.blueIcon { + background-color: var(--v2-colors-blue-100); + color: var(--v2-colors-blue-500); +} + +.outer { + height: 100%; +} + +.inner { + height: 100%; +} diff --git a/src/core/client/admin/routes/Dashboard/components/TodayDashboardBox.tsx b/src/core/client/admin/routes/Dashboard/components/TodayDashboardBox.tsx new file mode 100644 index 000000000..db12eda44 --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/components/TodayDashboardBox.tsx @@ -0,0 +1,52 @@ +import cn from "classnames"; +import React, { FunctionComponent } from "react"; + +import { Flex, HorizontalGutter, Icon } from "coral-ui/components/v2"; + +import DashboardBox from "./DashboardBox"; +import Loader from "./Loader"; + +import styles from "./TodayDashboardBox.css"; + +interface Props { + icon: "forum" | "close" | "badge" | "person_add" | "block"; + loading: boolean; +} + +const TodayDashboardBox: FunctionComponent = ({ + children, + icon, + loading, +}) => { + return ( + + {loading ? ( + + ) : ( + +
+ {icon} +
+ + + {children} + + +
+ )} +
+ ); +}; + +export default TodayDashboardBox; diff --git a/src/core/client/admin/routes/Dashboard/components/TodayValue.css b/src/core/client/admin/routes/Dashboard/components/TodayValue.css new file mode 100644 index 000000000..805558904 --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/components/TodayValue.css @@ -0,0 +1,10 @@ +.valueBoxValue { + font-size: var(--v2-font-size-7); + font-weight: var(--v2-font-weight-primary-semi-bold); + line-height: var(--v2-line-height-reset); +} + +.valueBoxName { + font-size: var(--v2-font-size-2); + font-weight: var(--v2-font-weight-primary-semi-bold); +} diff --git a/src/core/client/admin/routes/Dashboard/components/TodayValue.tsx b/src/core/client/admin/routes/Dashboard/components/TodayValue.tsx new file mode 100644 index 000000000..6b927aa7d --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/components/TodayValue.tsx @@ -0,0 +1,20 @@ +import React, { FunctionComponent } from "react"; + +import { HorizontalGutter } from "coral-ui/components/v2"; + +import styles from "./TodayValue.css"; + +interface Props { + value?: string; +} + +const TodayValue: FunctionComponent = ({ value = "-", children }) => { + return ( + +

{value}

+

{children}

+
+ ); +}; + +export default TodayValue; diff --git a/src/core/client/admin/routes/Dashboard/components/index.ts b/src/core/client/admin/routes/Dashboard/components/index.ts new file mode 100644 index 000000000..78a0424b2 --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/components/index.ts @@ -0,0 +1,7 @@ +export { default as DashboardBox } from "./DashboardBox"; +export { default as TodayCompareValue } from "./TodayCompareValue"; +export { default as TodayValue } from "./TodayValue"; +export { default as TodayDashboardBox } from "./TodayDashboardBox"; +export { default as SiteDashboardHeader } from "./SiteDashboardTimestamp"; +export { default as DashboardComponentHeading } from "./DashboardComponentHeading"; +export { default as Loader } from "./Loader"; diff --git a/src/core/client/admin/routes/Dashboard/createDashboardFetch.ts b/src/core/client/admin/routes/Dashboard/createDashboardFetch.ts new file mode 100644 index 000000000..45c8f14bf --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/createDashboardFetch.ts @@ -0,0 +1,26 @@ +import { Environment } from "relay-runtime"; + +import { createFetch } from "coral-framework/lib/relay"; + +export default function createDashboardFetch(name: string, url: string) { + return createFetch( + name, + async ( + environment: Environment, + variables: { siteID: string }, + { rest } + ) => { + const params = new URLSearchParams(variables); + + const { timeZone } = Intl.DateTimeFormat().resolvedOptions(); + params.set("tz", timeZone); + + // // FIXME: remove, date forced for development + // params.set("date", "2020-05-05T12:30:00.000Z"); + + return rest.fetch(`${url}?${params.toString()}`, { + method: "GET", + }); + } + ); +} diff --git a/src/core/client/admin/routes/Dashboard/index.ts b/src/core/client/admin/routes/Dashboard/index.ts new file mode 100644 index 000000000..b070bf377 --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/index.ts @@ -0,0 +1 @@ +export { default, default as DashboardRoute } from "./DashboardRoute"; diff --git a/src/core/client/admin/routes/Dashboard/sections/ChartColors.ts b/src/core/client/admin/routes/Dashboard/sections/ChartColors.ts new file mode 100644 index 000000000..5e2a2290b --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/sections/ChartColors.ts @@ -0,0 +1,8 @@ +export const CHART_COLOR_PRIMARY = "#419EA7"; +export const CHART_COLOR_PRIMARY_LIGHT = "#59C3C3"; +export const CHART_COLOR_SECONDARY = "#F77160"; +export const CHART_COLOR_MONO_500 = "#353F44"; +export const CHART_COLOR_MONO_100 = "#65696B"; +export const CHART_COLOR_GREY_200 = "#EAEFF0"; +export const CHART_COLOR_PRIMARY_PALE = "#9FECDF"; +export const CHART_COLOR_PRIMARY_DARK = "#2C7B8C"; diff --git a/src/core/client/admin/routes/Dashboard/sections/CommentActivity.css b/src/core/client/admin/routes/Dashboard/sections/CommentActivity.css new file mode 100644 index 000000000..33ae143c9 --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/sections/CommentActivity.css @@ -0,0 +1,31 @@ +.heading { + font-family: var(--v2-font-family-primary); + font-weight: var(--v2-font-weight-primary-semi-bold); + font-size: var(--v2-font-size-2); +} + +.chart { + font-family: var(--v2-font-family-primary); +} + +.loader { + height: 300px; +} + +.legend { + color: var(--v2-colors-coral-500); + font-size: var(--v2-font-size-2); + font-weight: var(--v2-font-weight-primary-semi-bold); + margin-bottom: 0; + position: relative; + padding-left: var(--v2-spacing-6); + &:before { + width: 24px; + height: 1px; + background: var(--v2-colors-coral-500); + content: ""; + position: absolute; + left: 0; + top: 50%; + } +} diff --git a/src/core/client/admin/routes/Dashboard/sections/CommentActivity.tsx b/src/core/client/admin/routes/Dashboard/sections/CommentActivity.tsx new file mode 100644 index 000000000..1ad8e05a4 --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/sections/CommentActivity.tsx @@ -0,0 +1,129 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; +import { + CartesianGrid, + Line, + LineChart, + ReferenceLine, + ResponsiveContainer, + Tooltip, + TooltipProps, + XAxis, + YAxis, +} from "recharts"; + +import { Flex } from "coral-ui/components/v2"; + +import { TimeSeriesMetricsJSON } from "coral-common/rest/dashboard/types"; +import { useImmediateFetch } from "coral-framework/lib/relay/fetch"; +import { useUIContext } from "coral-ui/components"; + +import { DashboardBox, DashboardComponentHeading, Loader } from "../components"; +import createDashboardFetch from "../createDashboardFetch"; +import { + CHART_COLOR_GREY_200, + CHART_COLOR_MONO_500, + CHART_COLOR_PRIMARY, + CHART_COLOR_SECONDARY, +} from "./ChartColors"; +import CommentActivityTooltip from "./CommentActivityTooltip"; + +import styles from "./CommentActivity.css"; + +interface Props { + locales?: string[]; + siteID: string; + lastUpdated: string; +} + +const HourlyCommentsMetricsFetch = createDashboardFetch( + "hourlyCommentsMetricsFetch", + "/dashboard/hourly/comments" +); + +const CommentActivity: FunctionComponent = ({ + locales: localesFromProps, + siteID, + lastUpdated, +}) => { + const [hourly, loading] = useImmediateFetch( + HourlyCommentsMetricsFetch, + { siteID }, + lastUpdated + ); + const { locales: localesFromContext } = useUIContext(); + const locales = localesFromProps || localesFromContext || ["en-US"]; + return ( + + + + Hourly comment activity + + + + {!loading && ( + <> + + + {hourly && ( + + )} + { + const formatter = new Intl.DateTimeFormat(locales, { + hour: "numeric", + hour12: true, + }); + return formatter + .format(new Date(unixTime)) + .toLowerCase() + .replace(" ", ""); + }} + /> + + + + ( + + )} + /> + + + + +

All-time average

+
+
+ + )} +
+ ); +}; + +export default CommentActivity; diff --git a/src/core/client/admin/routes/Dashboard/sections/CommentActivityTooltip.css b/src/core/client/admin/routes/Dashboard/sections/CommentActivityTooltip.css new file mode 100644 index 000000000..c3ebf6801 --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/sections/CommentActivityTooltip.css @@ -0,0 +1,27 @@ +.root { + background: var(--v2-colors-pure-white); + border-radius: 4px; + border: 1px solid var(--v2-colors-grey-300); + padding: var(--v2-spacing-3); +} + +.time { + color: var(--v2-colors-mono-100); + font-size: var(--v2-font-size-2); + margin: 0; + font-weight: var(--v2-font-weight-primary-semi-bold); +} + +.count { + color: var(--v2-colors-teal-600); + font-size: var(--v2-font-size-4); + font-weight: var(--v2-font-weight-primary-bold); + margin: 0; +} + +.comments { + color: var(--v2-colors-mono-500); + font-size: var(--v2-font-size-1); + font-weight: var(--v2-font-weight-primary-semi-bold); + margin: 0; +} diff --git a/src/core/client/admin/routes/Dashboard/sections/CommentActivityTooltip.tsx b/src/core/client/admin/routes/Dashboard/sections/CommentActivityTooltip.tsx new file mode 100644 index 000000000..eb8142bca --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/sections/CommentActivityTooltip.tsx @@ -0,0 +1,41 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent, useMemo } from "react"; +import { TooltipProps } from "recharts"; + +import styles from "./CommentActivityTooltip.css"; + +type Props = TooltipProps & { + locales: string[]; +}; + +const CommentActivityTooltip: FunctionComponent = ({ + active, + payload, + label, + locales, +}) => { + const formattedLabel = useMemo(() => { + if (label) { + const formatter = new Intl.DateTimeFormat(locales, { + hour: "2-digit", + minute: "2-digit", + }); + return formatter.format(new Date(label)).toLowerCase(); + } + return ""; + }, [label]); + if (active) { + return ( +
+

{formattedLabel}

+ {payload &&

{payload[0].value}

} + +

Comments

+
+
+ ); + } + return null; +}; + +export default CommentActivityTooltip; diff --git a/src/core/client/admin/routes/Dashboard/sections/SignupActivity.css b/src/core/client/admin/routes/Dashboard/sections/SignupActivity.css new file mode 100644 index 000000000..ba386eae4 --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/sections/SignupActivity.css @@ -0,0 +1,9 @@ +.heading { + font-family: var(--v2-font-family-primary); + font-weight: var(--v2-font-weight-primary-semi-bold); + font-size: var(--v2-font-size-2); +} + +.chart { + font-family: var(--v2-font-family-primary); +} diff --git a/src/core/client/admin/routes/Dashboard/sections/SignupActivity.tsx b/src/core/client/admin/routes/Dashboard/sections/SignupActivity.tsx new file mode 100644 index 000000000..6935e71cd --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/sections/SignupActivity.tsx @@ -0,0 +1,114 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + ResponsiveContainer, + XAxis, + YAxis, +} from "recharts"; + +import { TimeSeriesMetricsJSON } from "coral-common/rest/dashboard/types"; +import { useImmediateFetch } from "coral-framework/lib/relay/fetch"; +import { useUIContext } from "coral-ui/components"; + +import { DashboardBox, DashboardComponentHeading, Loader } from "../components"; +import createDashboardFetch from "../createDashboardFetch"; +import { + CHART_COLOR_GREY_200, + CHART_COLOR_MONO_500, + CHART_COLOR_PRIMARY_LIGHT, + CHART_COLOR_PRIMARY_PALE, +} from "./ChartColors"; +import SignupActivityTick from "./SignupActivityTick"; + +import styles from "./SignupActivity.css"; + +interface Props { + locales?: string[]; + siteID: string; + lastUpdated: string; +} +const DailySignupMetrics = createDashboardFetch( + "commenterActivityFetch", + "/dashboard/daily/users" +); + +const CommenterActivity: FunctionComponent = ({ + locales: localesFromProps, + siteID, + lastUpdated, +}) => { + const [daily, loading] = useImmediateFetch( + DailySignupMetrics, + { siteID }, + lastUpdated + ); + const { locales: localesFromContext } = useUIContext(); + const locales = localesFromProps || localesFromContext || ["en-US"]; + return ( + + + New Signups + + + {!loading && ( + + + + ( + + )} + /> + + + {(daily ? daily.series : []).map((entry, index) => { + return ( + + ); + })} + + + + )} + + ); +}; + +export default CommenterActivity; diff --git a/src/core/client/admin/routes/Dashboard/sections/SignupActivityTick.tsx b/src/core/client/admin/routes/Dashboard/sections/SignupActivityTick.tsx new file mode 100644 index 000000000..aa0f36b9f --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/sections/SignupActivityTick.tsx @@ -0,0 +1,69 @@ +import React, { FunctionComponent, useMemo } from "react"; + +import { + CHART_COLOR_MONO_100, + CHART_COLOR_MONO_500, + CHART_COLOR_PRIMARY_DARK, +} from "./ChartColors"; + +interface TickPayload { + value: string; +} + +interface Props { + x: number; + locales: string[]; + y: number; + payload: TickPayload; + isToday: boolean; +} + +const SignupActivityTick: FunctionComponent = ({ + x, + y, + payload, + locales, + isToday, +}) => { + const date = useMemo(() => { + const formatter = new Intl.DateTimeFormat(locales, { + day: "numeric", + month: "numeric", + }); + return formatter.format(new Date(payload.value)); + }, [payload.value]); + const dayOfWeek = useMemo(() => { + const formatter = new Intl.DateTimeFormat(locales, { + weekday: "short", + }); + return formatter.format(new Date(payload.value)); + }, [payload.value]); + return ( + + + {date} + + + {dayOfWeek} + + + ); +}; + +export default SignupActivityTick; diff --git a/src/core/client/admin/routes/Dashboard/sections/Today.css b/src/core/client/admin/routes/Dashboard/sections/Today.css new file mode 100644 index 000000000..954b5c170 --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/sections/Today.css @@ -0,0 +1,39 @@ +.heading { + font-family: var(--v2-font-family-primary); + font-weight: var(--v2-font-weight-primary-semi-bold); + font-size: var(--v2-font-size-4); +} + +.valueBoxValue { + font-size: var(--v2-font-size-7); + font-weight: var(--v2-font-weight-primary-semi-bold); + line-height: var(--v2-line-height-reset); +} + +.valueBoxName { + font-size: var(--v2-font-size-2); + font-weight: var(--v2-font-weight-primary-semi-bold); +} + +.valueBoxCompareValue { + font-size: var(--v2-font-size-2); + font-weight: var(--v2-font-weight-primary-semi-bold); +} + +.valueBoxCompareName { + font-size: var(--v2-font-size-1); +} + +.icon { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; +} + +.forumIcon { + background-color: var(--v2-colors-teal-100); + color: var(--v2-colors-teal-600); +} diff --git a/src/core/client/admin/routes/Dashboard/sections/Today.tsx b/src/core/client/admin/routes/Dashboard/sections/Today.tsx new file mode 100644 index 000000000..25b87c3c4 --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/sections/Today.tsx @@ -0,0 +1,129 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; + +import { TodayMetricsJSON } from "coral-common/rest/dashboard/types"; +import { useImmediateFetch } from "coral-framework/lib/relay/fetch"; +import { Flex } from "coral-ui/components/v2"; + +import { + TodayCompareValue, + TodayDashboardBox, + TodayValue, +} from "../components"; +import createDashboardFetch from "../createDashboardFetch"; + +import styles from "./Today.css"; + +const TodayMetricsFetch = createDashboardFetch( + "todayMetricsFetch", + "/dashboard/today" +); + +const TotalMetricsFetch = createDashboardFetch( + "totalMetricsFetch", + "/dashboard/total" +); + +interface Props { + siteID: string; + lastUpdated: string; +} + +const TodayTotals: FunctionComponent = ({ siteID, lastUpdated }) => { + const [today, loading] = useImmediateFetch( + TodayMetricsFetch, + { siteID }, + lastUpdated + ); + const [total, totalLoading] = useImmediateFetch( + TotalMetricsFetch, + { siteID }, + lastUpdated + ); + + return ( +
+ +

Today's activity

+
+ + + + + New comments + + + + + All time total + + + + + 0 + ? (today.comments.rejected / today.comments.total) * 100 + : 0 + ).toFixed(2)} %` + : "-.-- %" + } + > + + Rejection rate + + + 0 + ? (total.comments.rejected / total.comments.total) * 100 + : 0 + ).toFixed(2)} %` + : "-.-- %" + } + > + + All time average + + + + + + + Staff comments + + + + + All time total + + + + + + + New community members + + + + Total members + + + + + Banned members + + + + Total banned members + + + + +
+ ); +}; + +export default TodayTotals; diff --git a/src/core/client/admin/routes/Dashboard/sections/TopStories.tsx b/src/core/client/admin/routes/Dashboard/sections/TopStories.tsx new file mode 100644 index 000000000..210fc1af7 --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/sections/TopStories.tsx @@ -0,0 +1,90 @@ +import { Localized } from "@fluent/react/compat"; +import { Link } from "found"; +import React, { FunctionComponent } from "react"; + +import NotAvailable from "coral-admin/components/NotAvailable"; +import { TodayStoriesMetricsJSON } from "coral-common/rest/dashboard/types"; +import { getModerationLink } from "coral-framework/helpers"; +import { useImmediateFetch } from "coral-framework/lib/relay/fetch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextLink, +} from "coral-ui/components/v2"; + +import { DashboardBox, DashboardComponentHeading, Loader } from "../components"; +import createDashboardFetch from "../createDashboardFetch"; + +const TodayStoriesMetrics = createDashboardFetch( + "topStoriesFetch", + "/dashboard/today/stories" +); + +interface Props { + siteID: string; + lastUpdated: string; +} + +const TopStories: FunctionComponent = ({ siteID, lastUpdated }) => { + const [today, loading] = useImmediateFetch( + TodayStoriesMetrics, + { siteID }, + lastUpdated + ); + return ( + + + + Today's most commented stories + + + + + + + Story + + + Comments + + + + + {loading && ( + + + + + + )} + {today && today.results.length < 1 && ( + + + No comments today. + + + )} + {today && + today.results.map((result) => ( + + + + {result.story.title ? result.story.title : } + + + {result.count} + + ))} + +
+
+ ); +}; + +export default TopStories; diff --git a/src/core/client/admin/routes/Dashboard/sections/index.ts b/src/core/client/admin/routes/Dashboard/sections/index.ts new file mode 100644 index 000000000..cb6683e64 --- /dev/null +++ b/src/core/client/admin/routes/Dashboard/sections/index.ts @@ -0,0 +1,4 @@ +export { default as Today } from "./Today"; +export { default as SignupActivity } from "./SignupActivity"; +export { default as CommentActivity } from "./CommentActivity"; +export { default as TopStories } from "./TopStories"; diff --git a/src/core/client/framework/lib/relay/fetch.tsx b/src/core/client/framework/lib/relay/fetch.tsx index de2356e47..e7d0d0602 100644 --- a/src/core/client/framework/lib/relay/fetch.tsx +++ b/src/core/client/framework/lib/relay/fetch.tsx @@ -1,4 +1,5 @@ -import React, { useCallback } from "react"; +import { isUndefined } from "lodash"; +import React, { useCallback, useEffect, useState } from "react"; import { fetchQuery as relayFetchQuery } from "react-relay"; import { compose, @@ -70,6 +71,34 @@ export function useFetch( ); } +export function useImmediateFetch( + fetch: Fetch>, + variables: V, + refetch?: string +): [R | null, boolean] { + const fetcher = useFetch(fetch); + const [state, setState] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + async function doTheFetch() { + setState(null); + setLoading(true); + const value = await fetcher(variables); + + // TODO: Maybe we don't need this timeout? + setTimeout(() => { + setState(value); + setLoading(false); + }, 100 + 50 * Math.random()); + } + + doTheFetch(); + }, Object.values(variables).concat(isUndefined(refetch) ? [] : [refetch])); + + return [state, loading]; +} + /** * withFetch creates a HOC that injects the fetch as * a property. diff --git a/src/core/client/ui/components/v2/Dropdown/Button.tsx b/src/core/client/ui/components/v2/Dropdown/Button.tsx index bec4dd3ba..8349f590e 100644 --- a/src/core/client/ui/components/v2/Dropdown/Button.tsx +++ b/src/core/client/ui/components/v2/Dropdown/Button.tsx @@ -1,18 +1,18 @@ import cn from "classnames"; import React, { FunctionComponent } from "react"; +import { Flex } from "coral-ui/components"; +import Icon from "coral-ui/components/Icon"; +import BaseButton, { BaseButtonProps } from "coral-ui/components/v2/BaseButton"; import { withStyles } from "coral-ui/hocs"; -import BaseButton, { BaseButtonProps } from "coral-ui/components/BaseButton"; -import Icon from "coral-ui/components/Icon"; - -import { Flex } from "coral-ui/components"; import styles from "./Button.css"; interface Props extends Omit { children: React.ReactNode; icon?: React.ReactNode; href?: string; + to?: string; className?: string; onClick?: React.EventHandler; classes: typeof styles; diff --git a/src/core/common/rest/dashboard/types.ts b/src/core/common/rest/dashboard/types.ts new file mode 100644 index 000000000..cb2c6ef95 --- /dev/null +++ b/src/core/common/rest/dashboard/types.ts @@ -0,0 +1,26 @@ +export interface TodayMetricsJSON { + users: { + total: number; + bans: number; + }; + comments: { + total: number; + rejected: number; + staff: number; + }; +} + +export interface TimeSeriesMetricsJSON { + series: Array<{ count: number; timestamp: string }>; + average?: number; +} + +export interface TodayStoriesMetricsJSON { + results: Array<{ + count: number; + story: { + id: string; + title?: string; + }; + }>; +} diff --git a/src/core/server/app/handlers/api/dashboard/index.ts b/src/core/server/app/handlers/api/dashboard/index.ts new file mode 100644 index 000000000..bc02342f8 --- /dev/null +++ b/src/core/server/app/handlers/api/dashboard/index.ts @@ -0,0 +1,189 @@ +import { + TimeSeriesMetricsJSON, + TodayMetricsJSON, + TodayStoriesMetricsJSON, +} from "coral-common/rest/dashboard/types"; +import { AppOptions } from "coral-server/app"; +import { calculateTotalCommentCount } from "coral-server/models/comment"; +import { + retrieveAllTimeStaffCommentMetrics, + retrieveAverageCommentsMetric, + retrieveHourlyCommentMetrics, + retrieveTodayCommentMetrics, + retrieveTodayTopStoryMetrics, +} from "coral-server/models/comment/metrics"; +import { retrieveSite } from "coral-server/models/site"; +import { retrieveManyStories } from "coral-server/models/story"; +import { + retrieveAllTimeUserMetrics, + retrieveDailyUserMetrics, + retrieveTodayUserMetrics, +} from "coral-server/models/user/metrics"; +import { Request, RequestHandler } from "coral-server/types/express"; + +function getMetricsOptions(req: Request) { + // Get the current Tenant on the request. + const { id: tenantID } = req.coral?.tenant!; + + const now = req.coral?.now!; + + // To set a fixed date for the date, uncomment the line below. + // const now = DateTime.utc(2020, 5, 5, 12, 30).toJSDate(); + + // To allow for date overrides (to load metrics for another time then now), + // uncomment the lines below. + // const { date = req.coral?.now!.toISOString() } = req.query; + // const now = new Date(date); + + // Get the Site ID and timezone for this set of metrics. + const { siteID, tz } = req.query; + if (!tz) { + throw new Error("tz was not provided"); + } + + return { tenantID, siteID, tz, now }; +} + +export const todayMetricsHandler = ({ + mongo, +}: AppOptions): RequestHandler => async (req, res, next) => { + try { + const { tenantID, siteID, tz, now } = getMetricsOptions(req); + if (!siteID) { + throw new Error("siteID was not provided"); + } + + const [users, comments] = await Promise.all([ + retrieveTodayUserMetrics(mongo, tenantID, tz, now), + retrieveTodayCommentMetrics(mongo, tenantID, siteID, tz, now), + ]); + + const result: TodayMetricsJSON = { + users, + comments, + }; + + return res.json(result); + } catch (err) { + return next(err); + } +}; + +export const totalMetricsHandler = ({ + mongo, +}: AppOptions): RequestHandler => async (req, res, next) => { + try { + const { tenantID, siteID } = getMetricsOptions(req); + if (!siteID) { + throw new Error("siteID was not provided"); + } + + const site = await retrieveSite(mongo, tenantID, siteID); + if (!site) { + throw new Error("site specified was not found"); + } + + const [users, staff] = await Promise.all([ + retrieveAllTimeUserMetrics(mongo, tenantID), + retrieveAllTimeStaffCommentMetrics(mongo, tenantID, siteID), + ]); + + const result: TodayMetricsJSON = { + users, + comments: { + total: calculateTotalCommentCount(site.commentCounts.status), + rejected: site.commentCounts.status.REJECTED, + staff, + }, + }; + + return res.json(result); + } catch (err) { + return next(err); + } +}; + +export const hourlyCommentsMetricsHandler = ({ + mongo, +}: AppOptions): RequestHandler => async (req, res, next) => { + try { + const { tenantID, siteID, tz, now } = getMetricsOptions(req); + if (!siteID) { + throw new Error("siteID was not provided"); + } + + const [series, average] = await Promise.all([ + retrieveHourlyCommentMetrics(mongo, tenantID, siteID, tz, now), + retrieveAverageCommentsMetric(mongo, tenantID, siteID, tz, now), + ]); + + const result: TimeSeriesMetricsJSON = { + series, + average, + }; + + return res.json(result); + } catch (err) { + return next(err); + } +}; + +export const dailyUsersMetricsHandler = ({ + mongo, +}: AppOptions): RequestHandler => async (req, res, next) => { + try { + const { tenantID, tz, now } = getMetricsOptions(req); + + const result: TimeSeriesMetricsJSON = { + series: await retrieveDailyUserMetrics(mongo, tenantID, tz, now), + }; + + return res.json(result); + } catch (err) { + return next(err); + } +}; + +export const todayStoriesMetricsHandler = ({ + mongo, +}: AppOptions): RequestHandler => async (req, res, next) => { + try { + const { tenantID, siteID, tz, now } = getMetricsOptions(req); + if (!siteID) { + throw new Error("siteID was not provided"); + } + + const results = await retrieveTodayTopStoryMetrics( + mongo, + tenantID, + siteID, + tz, + now + ); + + // Fetch all the stories for each count. + const stories = await retrieveManyStories( + mongo, + tenantID, + results.map(({ _id }) => _id) + ); + + // Ensure that all entries are not null. + if (!stories.every((story) => story) || results.length !== stories.length) { + throw new Error("some stories with comments were not found"); + } + + const result: TodayStoriesMetricsJSON = { + results: results.map(({ count }, idx) => { + // We verified above that there were no null values. + const story = stories[idx]!; + + return { count, story: { id: story.id, title: story.metadata?.title } }; + }), + }; + + return res.json(result); + } catch (err) { + return next(err); + } +}; diff --git a/src/core/server/app/handlers/api/index.ts b/src/core/server/app/handlers/api/index.ts index 95ff1823a..652642c03 100644 --- a/src/core/server/app/handlers/api/index.ts +++ b/src/core/server/app/handlers/api/index.ts @@ -4,5 +4,6 @@ export * from "./graphql"; export * from "./health"; export * from "./install"; export * from "./version"; +export * from "./dashboard"; export * from "./user"; export * from "./story"; diff --git a/src/core/server/app/middleware/passport/strategies/jwt.ts b/src/core/server/app/middleware/passport/strategies/jwt.ts index 218637806..773209b19 100644 --- a/src/core/server/app/middleware/passport/strategies/jwt.ts +++ b/src/core/server/app/middleware/passport/strategies/jwt.ts @@ -21,7 +21,7 @@ import { SSOToken, SSOVerifier } from "./verifiers/sso"; export type JWTStrategyOptions = Pick< AppOptions, - "signingConfig" | "mongo" | "redis" | "tenantCache" + "signingConfig" | "mongo" | "redis" | "tenantCache" | "mongo" >; /** diff --git a/src/core/server/app/middleware/passport/strategies/oauth2.ts b/src/core/server/app/middleware/passport/strategies/oauth2.ts index 0131e014d..e75552e0f 100644 --- a/src/core/server/app/middleware/passport/strategies/oauth2.ts +++ b/src/core/server/app/middleware/passport/strategies/oauth2.ts @@ -1,5 +1,6 @@ import { Db } from "mongodb"; -import { Strategy as BaseStrategy, StrategyCreated } from "passport"; +import { Profile, Strategy as BaseStrategy, StrategyCreated } from "passport"; +import { VerifyCallback } from "passport-oauth2"; import { Strategy } from "passport-strategy"; import { Config } from "coral-server/config"; @@ -12,8 +13,6 @@ import { TenantCacheAdapter, } from "coral-server/services/tenant/cache"; import { Request } from "coral-server/types/express"; -import { Profile } from "passport"; -import { VerifyCallback } from "passport-oauth2"; interface OAuth2Integration { enabled: boolean; diff --git a/src/core/server/app/middleware/passport/strategies/verifiers/oidc.ts b/src/core/server/app/middleware/passport/strategies/verifiers/oidc.ts index 9827acdf8..6edd8db62 100644 --- a/src/core/server/app/middleware/passport/strategies/verifiers/oidc.ts +++ b/src/core/server/app/middleware/passport/strategies/verifiers/oidc.ts @@ -16,10 +16,7 @@ import { export { OIDCIDToken } from "../oidc"; -export type OIDCVerifierOptions = Pick< - AppOptions, - "mongo" | "redis" | "tenantCache" ->; +export type OIDCVerifierOptions = Pick; export class OIDCVerifier implements Verifier { private mongo: Db; diff --git a/src/core/server/app/middleware/role.ts b/src/core/server/app/middleware/role.ts new file mode 100644 index 000000000..ceb36f595 --- /dev/null +++ b/src/core/server/app/middleware/role.ts @@ -0,0 +1,21 @@ +import { UserForbiddenError } from "coral-server/errors"; +import { RequestHandler } from "coral-server/types/express"; + +import { GQLUSER_ROLE } from "coral-server/graph/schema/__generated__/types"; + +export const roleMiddleware = (roles: GQLUSER_ROLE[]): RequestHandler => ( + req, + res, + next +) => { + if (!req.user || !roles.includes(req.user.role)) { + return next( + new UserForbiddenError( + "user does not have sufficient privileges", + req.originalUrl, + req.method + ) + ); + } + return next(); +}; diff --git a/src/core/server/app/middleware/userLimiter.ts b/src/core/server/app/middleware/userLimiter.ts new file mode 100644 index 000000000..d4fb9a0e8 --- /dev/null +++ b/src/core/server/app/middleware/userLimiter.ts @@ -0,0 +1,30 @@ +import { Redis } from "ioredis"; + +import { RequestLimiter } from "coral-server/app/request/limiter"; +import { Config } from "coral-server/config"; +import { RequestHandler } from "coral-server/types/express"; + +export interface MiddlewareOptions { + redis: Redis; + config: Config; +} + +export const userLimiterMiddleware = ({ + redis, + config, +}: MiddlewareOptions): RequestHandler => { + const limiter = new RequestLimiter({ + redis, + ttl: "1m", + max: 5, + prefix: "userID", + config, + }); + + return async (req, res, next) => { + limiter + .test(req, req.user!.id) + .then(() => next()) + .catch((err) => next(err)); + }; +}; diff --git a/src/core/server/app/router/api/dashboard.ts b/src/core/server/app/router/api/dashboard.ts new file mode 100644 index 000000000..0adc10daa --- /dev/null +++ b/src/core/server/app/router/api/dashboard.ts @@ -0,0 +1,25 @@ +import { AppOptions } from "coral-server/app"; +import { + dailyUsersMetricsHandler, + hourlyCommentsMetricsHandler, + todayMetricsHandler, + todayStoriesMetricsHandler, + totalMetricsHandler, +} from "coral-server/app/handlers"; +import { userLimiterMiddleware } from "coral-server/app/middleware/userLimiter"; + +import { createAPIRouter } from "./helpers"; + +export function createDashboardRouter(app: AppOptions) { + const router = createAPIRouter({ cache: "30s" }); + + router.use(userLimiterMiddleware(app)); + + router.get("/today", todayMetricsHandler(app)); + router.get("/total", totalMetricsHandler(app)); + router.get("/hourly/comments", hourlyCommentsMetricsHandler(app)); + router.get("/daily/users", dailyUsersMetricsHandler(app)); + router.get("/today/stories", todayStoriesMetricsHandler(app)); + + return router; +} diff --git a/src/core/server/app/router/api/index.ts b/src/core/server/app/router/api/index.ts index cc138c48d..3e3d43837 100644 --- a/src/core/server/app/router/api/index.ts +++ b/src/core/server/app/router/api/index.ts @@ -6,13 +6,17 @@ import { graphQLHandler } from "coral-server/app/handlers"; import { JSONErrorHandler } from "coral-server/app/middleware/error"; import { persistedQueryMiddleware } from "coral-server/app/middleware/graphql"; import { jsonMiddleware } from "coral-server/app/middleware/json"; +import { loggedInMiddleware } from "coral-server/app/middleware/loggedIn"; import { errorLogger } from "coral-server/app/middleware/logging"; import { notFoundMiddleware } from "coral-server/app/middleware/notFound"; import { authenticate } from "coral-server/app/middleware/passport"; +import { roleMiddleware } from "coral-server/app/middleware/role"; import { tenantMiddleware } from "coral-server/app/middleware/tenant"; +import { STAFF_ROLES } from "coral-server/models/user/constants"; import { createNewAccountRouter } from "./account"; import { createNewAuthRouter } from "./auth"; +import { createDashboardRouter } from "./dashboard"; import { createNewInstallRouter } from "./install"; import { createStoryRouter } from "./story"; import { createNewUserRouter } from "./user"; @@ -56,6 +60,14 @@ export function createAPIRouter(app: AppOptions, options: RouterOptions) { graphQLHandler(app) ); + router.use( + "/dashboard", + authenticate(options.passport), + loggedInMiddleware, + roleMiddleware(STAFF_ROLES), + createDashboardRouter(app) + ); + // General API error handler. router.use(notFoundMiddleware); router.use(errorLogger); diff --git a/src/core/server/helpers/metrics.ts b/src/core/server/helpers/metrics.ts new file mode 100644 index 000000000..9734f4303 --- /dev/null +++ b/src/core/server/helpers/metrics.ts @@ -0,0 +1,89 @@ +import { DateTime } from "luxon"; + +export interface TimeRange { + readonly start: DateTime; + readonly end: DateTime; + readonly hours: number; +} + +export type TimeUnit = "day" | "hour"; + +const TIME_UNIT_FORMAT: Record = { + day: { js: "yyyy-LL-dd", mongo: "%Y-%m-%d" }, + hour: { js: "yyyy-LL-dd HH:00", mongo: "%Y-%m-%d %H:00" }, +}; + +const TIME_UNIT_HOURS: Record = { + day: 24, + hour: 1, +}; + +const TIME_UNIT_MAX: Record = { + day: 7, + hour: 24, +}; + +export function getTimeRange( + unit: TimeUnit, + zone: string, + now: Date, + interval = TIME_UNIT_MAX[unit] +): TimeRange { + // Convert the date to the specified zone. + const end = DateTime.fromJSDate(now).setZone(zone); + + return { + start: end.startOf(unit).minus({ [unit]: interval - 1 }), + end, + hours: interval * TIME_UNIT_HOURS[unit], + }; +} + +export function getMongoFormat(unit: TimeUnit): string { + return TIME_UNIT_FORMAT[unit].mongo; +} + +export interface Result { + _id: string; + count: number; +} + +export interface Point { + count: number; + timestamp: string; +} + +export function formatTimeRangeSeries( + unit: TimeUnit, + start: DateTime, + results: Result[] +) { + if (results.length > TIME_UNIT_MAX[unit]) { + throw new Error( + `invalid number of items, expected ${TIME_UNIT_MAX[unit]}, got ${results.length}` + ); + } + + const series: Point[] = []; + + let date = start; + for (let i = 0; i < TIME_UNIT_MAX[unit]; i++) { + const search = date.toFormat(TIME_UNIT_FORMAT[unit].js); + const result = results.find(({ _id }) => _id === search); + + // Add the result (or zero if it doesn't exist) to the series. + series.push({ + count: result ? result.count : 0, + timestamp: date.toJSDate().toISOString(), + }); + + // Increment the date by the specified unit. + date = date.plus({ [unit]: 1 }); + } + + return series; +} + +export function getCount>(results: T) { + return results.length === 1 ? results[0].count : 0; +} diff --git a/src/core/server/models/comment/metrics.ts b/src/core/server/models/comment/metrics.ts new file mode 100644 index 000000000..79a6b0885 --- /dev/null +++ b/src/core/server/models/comment/metrics.ts @@ -0,0 +1,175 @@ +import { DateTime } from "luxon"; +import { Db } from "mongodb"; + +import { + formatTimeRangeSeries, + getCount, + getMongoFormat, + getTimeRange, + Result, +} from "coral-server/helpers/metrics"; +import { comments as collection } from "coral-server/services/mongodb/collections"; + +import { + GQLCOMMENT_STATUS, + GQLTAG, +} from "coral-server/graph/schema/__generated__/types"; + +export async function retrieveHourlyCommentMetrics( + mongo: Db, + tenantID: string, + siteID: string, + timezone: string, + now: Date +) { + const { start, end } = getTimeRange("hour", timezone, now); + + // Return the last 24 hours (in hour documents). + const results = await collection(mongo) + .aggregate([ + { $match: { tenantID, siteID, createdAt: { $gte: start, $lte: end } } }, + { + $group: { + _id: { + $dateToString: { + date: "$createdAt", + format: getMongoFormat("hour"), + timezone, + }, + }, + count: { $sum: 1 }, + }, + }, + { $sort: { _id: 1 } }, + ]) + .toArray(); + + return formatTimeRangeSeries("hour", start, results); +} + +export async function retrieveTodayCommentMetrics( + mongo: Db, + tenantID: string, + siteID: string, + timezone: string, + now: Date +) { + const start = DateTime.fromJSDate(now).setZone(timezone).startOf("day"); + const end = DateTime.fromJSDate(now); + + const status = await collection<{ _id: GQLCOMMENT_STATUS; count: number }>( + mongo + ) + .aggregate([ + { + $match: { + tenantID, + siteID, + createdAt: { $gte: start, $lte: end }, + }, + }, + { + $group: { + _id: "$status", + count: { $sum: 1 }, + }, + }, + ]) + .toArray(); + + const rejected = status.find((doc) => doc._id === GQLCOMMENT_STATUS.REJECTED); + const total = status.reduce((acc, doc) => acc + doc.count, 0); + + const staff = await collection<{ count: number }>(mongo) + .aggregate([ + { + $match: { + tenantID, + siteID, + createdAt: { $gte: start, $lte: end }, + "tags.type": GQLTAG.STAFF, + }, + }, + { $count: "count" }, + ]) + .toArray(); + + return { + total, + rejected: rejected ? rejected.count : 0, + staff: getCount(staff), + }; +} + +export async function retrieveAllTimeStaffCommentMetrics( + mongo: Db, + tenantID: string, + siteID: string +) { + // Get the referenced tenant, site, and staff comments. + const staff = await collection<{ count: number }>(mongo) + .aggregate([ + { + $match: { + tenantID, + siteID, + "tags.type": GQLTAG.STAFF, + }, + }, + { $count: "count" }, + ]) + .toArray(); + + return getCount(staff); +} + +export async function retrieveAverageCommentsMetric( + mongo: Db, + tenantID: string, + siteID: string, + timezone: string, + now: Date +) { + const { start, hours, end } = getTimeRange("hour", timezone, now, 72); + + // Return the last 24 hours (in hour documents). + const results = await collection<{ count: number }>(mongo) + .aggregate([ + { $match: { tenantID, siteID, createdAt: { $gte: start, $lte: end } } }, + { $count: "count" }, + ]) + .toArray(); + + const total = getCount(results); + + return Math.floor(total / hours); +} + +export async function retrieveTodayTopStoryMetrics( + mongo: Db, + tenantID: string, + siteID: string, + timezone: string, + now: Date +) { + const start = DateTime.fromJSDate(now).setZone(timezone).startOf("day"); + const end = DateTime.fromJSDate(now); + + // Return the last 24 hours worth of comments. + const results = await collection(mongo) + .aggregate([ + { $match: { tenantID, siteID, createdAt: { $gte: start, $lte: end } } }, + { + $group: { + _id: "$storyID", + count: { $sum: 1 }, + }, + }, + { $sort: { count: -1 } }, + // TODO: 17 was for visual treatment, feel free to change this! + { $limit: 17 }, + ]) + .toArray(); + + return results; +} diff --git a/src/core/server/models/site/helpers.ts b/src/core/server/models/site/helpers.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/core/server/models/site/index.ts b/src/core/server/models/site/index.ts index b4d472c16..caa35bb69 100644 --- a/src/core/server/models/site/index.ts +++ b/src/core/server/models/site/index.ts @@ -121,6 +121,10 @@ async function retrieveConnection( return resolveConnection(query, input, (_, index) => index + skip + 1); } +export async function countTenantSites(mongo: Db, tenantID: string) { + return collection(mongo).find({ tenantID }).count(); +} + export async function retrieveSiteConnection( mongo: Db, tenantID: string, diff --git a/src/core/server/models/user/metrics.ts b/src/core/server/models/user/metrics.ts new file mode 100644 index 000000000..526d7e178 --- /dev/null +++ b/src/core/server/models/user/metrics.ts @@ -0,0 +1,96 @@ +import { DateTime } from "luxon"; +import { Db } from "mongodb"; + +import { + formatTimeRangeSeries, + getCount, + getMongoFormat, + getTimeRange, + Result, +} from "coral-server/helpers/metrics"; +import { users as collection } from "coral-server/services/mongodb/collections"; + +export async function retrieveTodayUserMetrics( + mongo: Db, + tenantID: string, + timezone: string, + now: Date +) { + const start = DateTime.fromJSDate(now).setZone(timezone).startOf("day"); + const end = DateTime.fromJSDate(now); + + const [total, bans] = await Promise.all([ + collection<{ count: number }>(mongo) + .aggregate([ + { $match: { tenantID, createdAt: { $gte: start, $lte: end } } }, + { $count: "count" }, + ]) + .toArray(), + collection<{ count: number }>(mongo) + .aggregate([ + { + $match: { + tenantID, + "status.ban.active": true, + "status.ban.history.createdAt": { $gte: start, $lte: end }, + }, + }, + { $count: "count" }, + ]) + .toArray(), + ]); + + return { + total: getCount(total), + bans: getCount(bans), + }; +} + +export async function retrieveAllTimeUserMetrics(mongo: Db, tenantID: string) { + const [bans, total] = await Promise.all([ + collection<{ count: number }>(mongo) + .aggregate([ + { $match: { tenantID, "status.ban.active": true } }, + { $count: "count" }, + ]) + .toArray(), + collection<{ count: number }>(mongo) + .aggregate([{ $match: { tenantID } }, { $count: "count" }]) + .toArray(), + ]); + + return { + total: getCount(total), + bans: getCount(bans), + }; +} + +export async function retrieveDailyUserMetrics( + mongo: Db, + tenantID: string, + timezone: string, + now: Date +) { + const { start, end } = getTimeRange("day", timezone, now); + + // Return the last 7 days (in day documents). + const results = await collection(mongo) + .aggregate([ + { $match: { tenantID, createdAt: { $gte: start, $lte: end } } }, + { + $group: { + _id: { + $dateToString: { + date: "$createdAt", + format: getMongoFormat("day"), + timezone, + }, + }, + count: { $sum: 1 }, + }, + }, + ]) + .toArray(); + + return formatTimeRangeSeries("day", start, results); +} diff --git a/src/core/server/stacks/createComment.ts b/src/core/server/stacks/createComment.ts index 048cb3e6e..2f7442e5a 100644 --- a/src/core/server/stacks/createComment.ts +++ b/src/core/server/stacks/createComment.ts @@ -1,7 +1,6 @@ import { Db } from "mongodb"; import { ERROR_TYPES } from "coral-common/errors"; - import { Config } from "coral-server/config"; import { CommentNotFoundError, diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index 325bcc81a..eca90d005 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -29,6 +29,7 @@ navigation-moderate = Moderate navigation-community = Community navigation-stories = Stories navigation-configure = Configure +navigation-dashboard = Dashboard ## User Menu userMenu-signOut = Sign Out @@ -1254,3 +1255,28 @@ hotkeysModal-shortcuts-ban = Ban comment author hotkeysModal-shortcuts-zen = Toggle single-comment view authcheck-network-error = A network error occurred. Please refresh the page. + +dashboard-heading-last-updated = Last updated: + +dashboard-today-heading = Today's activity +dashboard-today-new-comments = New comments +dashboard-alltime-new-comments = All time total +dashboard-today-rejections = Rejection rate +dashboard-alltime-rejections = All time average +dashboard-today-staff-comments = Staff comments +dashboard-alltime-staff-comments = All time total +dashboard-today-signups = New community members +dashboard-alltime-signups = Total members +dashboard-today-bans = Banned members +dashboard-alltime-bans = Total banned members + +dashboard-top-stories-today-heading = Today's most commented stories +dashboard-top-stories-table-header-story = Story +dashboard-top-stories-table-header-comments = Comments +dashboard-top-stories-no-comments = No comments today + +dashboard-commenters-activity-heading = New community members this week + +dashboard-comment-activity-heading = Hourly comment activity +dashboard-comment-activity-tooltip-comments = Comments +dashboard-comment-activity-legend = All-time average