Metrics Working

This commit is contained in:
2025-12-22 20:20:03 -06:00
parent 607d00c908
commit 74ca58a91a
4 changed files with 780 additions and 16 deletions

View File

@@ -9,6 +9,7 @@
"lucide-react": "^0.562.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"recharts": "^3.6.0",
},
"devDependencies": {
"@eslint/js": "^9.39.1",
@@ -150,6 +151,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.54.0", "", { "os": "android", "cpu": "arm" }, "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng=="],
@@ -196,6 +199,10 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
@@ -204,6 +211,24 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
@@ -212,6 +237,8 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
@@ -238,6 +265,8 @@
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
@@ -250,12 +279,38 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="],
"es-toolkit": ["es-toolkit@1.43.0", "", {}, "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA=="],
"esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
@@ -282,6 +337,8 @@
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
@@ -316,10 +373,14 @@
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
@@ -388,8 +449,20 @@
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
"react-is": ["react-is@19.2.3", "", {}, "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA=="],
"react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
"recharts": ["recharts@3.6.0", "", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg=="],
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="],
@@ -408,6 +481,8 @@
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
@@ -416,6 +491,10 @@
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
"vite": ["vite@7.3.0", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -433,5 +512,7 @@
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@reduxjs/toolkit/immer": ["immer@11.1.0", "", {}, "sha512-dlzb07f5LDY+tzs+iLCSXV2yuhaYfezqyZQc+n6baLECWkOMEWxkECAOnXL0ba7lsA25fM9b2jtzpu/uxo1a7g=="],
}
}

View File

@@ -13,7 +13,8 @@
"i": "^0.3.7",
"lucide-react": "^0.562.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
"react-dom": "^19.2.0",
"recharts": "^3.6.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",

View File

@@ -1,10 +1,14 @@
.metrics-container {
max-width: 1200px;
max-width: 1400px;
margin: 0 auto;
}
.metrics-header {
margin-bottom: 2rem;
margin-bottom: 1.5rem;
display: flex;
justify-content: space-between;
align-items: start;
gap: 2rem;
}
.metrics-header h2 {
@@ -22,20 +26,305 @@
font-size: 0.875rem;
}
.metrics-placeholder {
.filter-toggle-btn {
display: flex;
align-items: center;
gap: 0.5rem;
background-color: #7c3aed;
color: white;
padding: 0.625rem 1.25rem;
border: none;
border-radius: 0.375rem;
font-family: "JetBrains Mono", monospace;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.filter-toggle-btn:hover {
background-color: #6d28d9;
}
.filters-panel {
background-color: #2b2b2b;
border: 1px solid #374151;
border-radius: 0.5rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.filter-section {
margin-bottom: 1.5rem;
}
.filter-section:last-child {
margin-bottom: 0;
}
.filter-section h3 {
color: #d8b4fe;
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
.filter-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.filter-actions {
display: flex;
gap: 0.5rem;
}
.filter-action-btn {
background: none;
border: 1px solid #374151;
color: #9ca3af;
padding: 0.25rem 0.75rem;
border-radius: 0.25rem;
font-family: "JetBrains Mono", monospace;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
}
.filter-action-btn:hover {
border-color: #7c3aed;
color: #d8b4fe;
}
.filter-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.filter-options-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.5rem;
}
.filter-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
transition: background-color 0.2s;
}
.filter-checkbox:hover {
background-color: rgba(124, 58, 237, 0.1);
}
.filter-checkbox input[type="checkbox"] {
cursor: pointer;
width: 16px;
height: 16px;
}
.filter-checkbox span {
color: #e0e0e0;
font-size: 0.875rem;
}
.metrics-loading,
.metrics-error,
.metrics-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 6rem 2rem;
background-color: #2b2b2b;
border: 2px dashed #374151;
border-radius: 0.5rem;
padding: 4rem 2rem;
color: #9ca3af;
gap: 1rem;
}
.metrics-hint {
color: #6b7280;
font-size: 0.875rem;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid #374151;
border-top-color: #a78bfa;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.retry-btn {
background-color: #7c3aed;
color: white;
padding: 0.5rem 1.5rem;
border: none;
border-radius: 0.375rem;
font-family: "JetBrains Mono", monospace;
cursor: pointer;
transition: background-color 0.2s;
}
.retry-btn:hover {
background-color: #6d28d9;
}
.placeholder-icon {
font-size: 4rem;
opacity: 0.5;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(550px, 1fr));
gap: 1.5rem;
}
.metric-card {
background-color: #2b2b2b;
border: 1px solid #374151;
border-radius: 0.5rem;
padding: 1.25rem;
transition: all 0.2s;
}
.metric-card:hover {
border-color: #7c3aed;
box-shadow: 0 4px 12px rgba(124, 58, 237, 0.2);
}
.metric-card-header {
margin-bottom: 1rem;
}
.metric-class-name {
color: #d8b4fe;
font-weight: 600;
font-size: 1.125rem;
margin-bottom: 0.25rem;
}
.metric-class-info {
color: #9ca3af;
font-size: 0.75rem;
}
.chart-container {
margin: 1rem 0;
background-color: #1e1e1e;
border-radius: 0.375rem;
padding: 1rem 0.5rem;
}
.nw-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #374151;
}
.nw-stat {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.nw-stat-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.category-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.nw-name {
color: #9ca3af;
font-size: 0.75rem;
font-weight: 600;
}
.nw-stat-data {
display: flex;
align-items: center;
gap: 0.75rem;
}
.nw-grade {
color: #4ade80;
font-size: 1.125rem;
font-weight: 700;
}
.nw-trend {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
}
.trend-up {
color: #4ade80;
}
.trend-down {
color: #f87171;
}
.trend-none {
color: #9ca3af;
}
.nw-updates {
color: #6b7280;
font-size: 0.7rem;
}
@media (max-width: 1200px) {
.metrics-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.metrics-header {
flex-direction: column;
gap: 1rem;
}
.filter-toggle-btn {
width: 100%;
justify-content: center;
}
.filter-options-grid {
grid-template-columns: 1fr;
}
.nw-summary {
grid-template-columns: 1fr;
}
.chart-container {
padding: 0.5rem 0.25rem;
}
}

View File

@@ -1,17 +1,410 @@
import { useState, useEffect } from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { TrendingUp, TrendingDown, Minus, Filter } from 'lucide-react';
import { api } from '../services/api';
import { useAuth } from '../contexts/AuthContext';
import './Metrics.css';
export const Metrics = () => {
const { user } = useAuth();
const [finalGrades, setFinalGrades] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedPeriods, setSelectedPeriods] = useState(['NW1', 'NW2', 'NW3', 'NW4']);
const [selectedClasses, setSelectedClasses] = useState([]);
const [showFilters, setShowFilters] = useState(false);
useEffect(() => {
fetchFinalGrades();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const fetchFinalGrades = async () => {
setLoading(true);
setError(null);
try {
const response = await api.getFinalGrades(user.username);
const grades = Array.isArray(response) ? response : response.finalGrades || [];
setFinalGrades(grades);
// Auto-select all classes initially
const uniqueClasses = [...new Set(grades.map(g => `${g.class.className}-${g.class.period}`))];
setSelectedClasses(uniqueClasses);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
// Group grades by class and organize by nine-weeks periods
const groupGradesByClass = () => {
const grouped = {};
// Only consider NW periods, ignore SE/S1/S2/FIN
const validCategories = ['NW1', 'NW2', 'NW3', 'NW4'];
finalGrades.forEach(grade => {
const category = grade.class.category;
// Skip non-NW categories
if (!validCategories.includes(category)) return;
const classKey = `${grade.class.className}-${grade.class.period}`;
if (!grouped[classKey]) {
grouped[classKey] = {
className: grade.class.className,
period: grade.class.period,
teacher: grade.class.teacher,
classId: grade.classId,
nineWeeks: {
NW1: [],
NW2: [],
NW3: [],
NW4: []
}
};
}
grouped[classKey].nineWeeks[category].push({
grade: parseFloat(grade.grade.replace('%', '')),
date: new Date(grade.updatedAt),
timestamp: new Date(grade.updatedAt).getTime()
});
});
// Sort each nine-weeks period by timestamp (chronological)
Object.values(grouped).forEach(classData => {
Object.keys(classData.nineWeeks).forEach(nw => {
classData.nineWeeks[nw].sort((a, b) => a.timestamp - b.timestamp);
});
});
return grouped;
};
// Filter classes based on selection
const filterClasses = (grouped) => {
const filtered = {};
Object.entries(grouped).forEach(([key, classData]) => {
if (!selectedClasses.includes(key)) return;
// Filter nine-weeks periods
const filteredNW = {};
selectedPeriods.forEach(period => {
if (classData.nineWeeks[period] && classData.nineWeeks[period].length > 0) {
filteredNW[period] = classData.nineWeeks[period];
}
});
if (Object.keys(filteredNW).length > 0) {
filtered[key] = {
...classData,
nineWeeks: filteredNW
};
}
});
return filtered;
};
// Prepare chart data
const prepareChartData = (nineWeeks) => {
const maxLength = Math.max(
...Object.values(nineWeeks).map(arr => arr.length)
);
const chartData = [];
for (let i = 0; i < maxLength; i++) {
const dataPoint = { updateNumber: i + 1 };
Object.keys(nineWeeks).forEach(nw => {
dataPoint[nw] = nineWeeks[nw][i]?.grade || null;
});
chartData.push(dataPoint);
}
return chartData;
};
// Calculate Y-axis domain based on actual data
const calculateYDomain = (nineWeeks) => {
const allGrades = Object.values(nineWeeks)
.flat()
.map(g => g.grade)
.filter(g => g !== null && g !== undefined);
if (allGrades.length === 0) return [0, 100];
const min = Math.min(...allGrades);
const max = Math.max(...allGrades);
const padding = (max - min) * 0.1 || 5;
return [
Math.max(0, Math.floor(min - padding)),
Math.min(100, Math.ceil(max + padding))
];
};
const calculateTrend = (gradeData) => {
if (gradeData.length < 2) return { trend: 'none', change: 0 };
const first = gradeData[0].grade;
const last = gradeData[gradeData.length - 1].grade;
const change = last - first;
return {
trend: change > 0 ? 'up' : change < 0 ? 'down' : 'none',
change: change
};
};
const getTrendIcon = (trend) => {
if (trend === 'up') return <TrendingUp size={14} className="trend-up" />;
if (trend === 'down') return <TrendingDown size={14} className="trend-down" />;
return <Minus size={14} className="trend-none" />;
};
const getLatestGrade = (gradeData) => {
if (gradeData.length === 0) return null;
return gradeData[gradeData.length - 1].grade.toFixed(2);
};
const COLORS = {
NW1: '#8b5cf6',
NW2: '#ec4899',
NW3: '#3b82f6',
NW4: '#f43f5e'
};
const PERIOD_NAMES = {
NW1: '1st Nine Weeks',
NW2: '2nd Nine Weeks',
NW3: '3rd Nine Weeks',
NW4: '4th Nine Weeks'
};
const togglePeriod = (period) => {
setSelectedPeriods(prev =>
prev.includes(period)
? prev.filter(p => p !== period)
: [...prev, period]
);
};
const toggleClass = (classKey) => {
setSelectedClasses(prev =>
prev.includes(classKey)
? prev.filter(c => c !== classKey)
: [...prev, classKey]
);
};
const selectAllClasses = () => {
const allClasses = Object.keys(groupGradesByClass());
setSelectedClasses(allClasses);
};
const deselectAllClasses = () => {
setSelectedClasses([]);
};
if (loading) {
return (
<div className="metrics-loading">
<div className="spinner"></div>
<p>Loading metrics...</p>
</div>
);
}
if (error) {
return (
<div className="metrics-error">
<p>Error loading metrics: {error}</p>
<button onClick={fetchFinalGrades} className="retry-btn">Retry</button>
</div>
);
}
const groupedGrades = groupGradesByClass();
const filteredGrades = filterClasses(groupedGrades);
const allClasses = Object.keys(groupedGrades);
return (
<div className="metrics-container">
<div className="metrics-header">
<h2>Metrics</h2>
<p className="metrics-subtitle">Coming soon...</p>
<div>
<h2>Grade Metrics</h2>
<p className="metrics-subtitle">Track your nine-weeks grade trends over time</p>
</div>
<div className="metrics-placeholder">
<button
className="filter-toggle-btn"
onClick={() => setShowFilters(!showFilters)}
>
<Filter size={18} />
<span>Filters</span>
</button>
</div>
{showFilters && (
<div className="filters-panel">
<div className="filter-section">
<h3>Nine-Weeks Periods</h3>
<div className="filter-options">
{['NW1', 'NW2', 'NW3', 'NW4'].map(period => (
<label key={period} className="filter-checkbox">
<input
type="checkbox"
checked={selectedPeriods.includes(period)}
onChange={() => togglePeriod(period)}
/>
<span style={{ color: COLORS[period] }}>{PERIOD_NAMES[period]}</span>
</label>
))}
</div>
</div>
<div className="filter-section">
<div className="filter-section-header">
<h3>Classes ({selectedClasses.length}/{allClasses.length})</h3>
<div className="filter-actions">
<button onClick={selectAllClasses} className="filter-action-btn">Select All</button>
<button onClick={deselectAllClasses} className="filter-action-btn">Deselect All</button>
</div>
</div>
<div className="filter-options filter-options-grid">
{allClasses.map(classKey => {
const classData = groupedGrades[classKey];
return (
<label key={classKey} className="filter-checkbox">
<input
type="checkbox"
checked={selectedClasses.includes(classKey)}
onChange={() => toggleClass(classKey)}
/>
<span>{classData.className}</span>
</label>
);
})}
</div>
</div>
</div>
)}
{Object.keys(filteredGrades).length === 0 ? (
<div className="metrics-empty">
<div className="placeholder-icon">📊</div>
<p>Grade analytics and insights will appear here</p>
<p>No grade data available for the selected filters</p>
<p className="metrics-hint">Try selecting different periods or classes</p>
</div>
) : (
<div className="metrics-grid">
{Object.entries(filteredGrades).map(([key, classData]) => {
const chartData = prepareChartData(classData.nineWeeks);
const yDomain = calculateYDomain(classData.nineWeeks);
return (
<div key={key} className="metric-card">
<div className="metric-card-header">
<div>
<h3 className="metric-class-name">{classData.className}</h3>
<p className="metric-class-info">
{classData.period} {classData.teacher}
</p>
</div>
</div>
<div className="chart-container">
<ResponsiveContainer width="100%" height={280}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis
dataKey="updateNumber"
stroke="#9ca3af"
fontSize={11}
label={{ value: 'Update #', position: 'insideBottom', offset: -5, fill: '#9ca3af' }}
/>
<YAxis
stroke="#9ca3af"
fontSize={11}
domain={yDomain}
label={{ value: 'Grade %', angle: -90, position: 'insideLeft', fill: '#9ca3af' }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1e1e1e',
border: '1px solid #374151',
borderRadius: '0.375rem',
fontFamily: 'JetBrains Mono',
fontSize: '0.75rem'
}}
labelStyle={{ color: '#d8b4fe' }}
formatter={(value, name) => [
value ? `${value.toFixed(2)}%` : 'N/A',
PERIOD_NAMES[name]
]}
labelFormatter={(label) => `Update #${label}`}
/>
<Legend
wrapperStyle={{
fontFamily: 'JetBrains Mono',
fontSize: '11px'
}}
formatter={(value) => PERIOD_NAMES[value]}
/>
{Object.keys(classData.nineWeeks).map(nw => (
<Line
key={nw}
type="monotone"
dataKey={nw}
stroke={COLORS[nw]}
strokeWidth={2.5}
dot={{ r: 4, fill: COLORS[nw] }}
activeDot={{ r: 6 }}
connectNulls
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
<div className="nw-summary">
{Object.entries(classData.nineWeeks).map(([nw, grades]) => {
const latest = getLatestGrade(grades);
const trend = calculateTrend(grades);
return (
<div key={nw} className="nw-stat">
<div className="nw-stat-header">
<span
className="category-dot"
style={{ backgroundColor: COLORS[nw] }}
></span>
<span className="nw-name">{PERIOD_NAMES[nw]}</span>
</div>
<div className="nw-stat-data">
<span className="nw-grade">{latest}%</span>
<div className="nw-trend">
{getTrendIcon(trend.trend)}
<span className={`trend-${trend.trend}`}>
{Math.abs(trend.change).toFixed(2)}%
</span>
</div>
<span className="nw-updates">({grades.length} updates)</span>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
)}
</div>
);
};