From 9d945c33f93f814364ed7476b57042f92e3d1f4e Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 19 Feb 2026 21:44:38 +0100 Subject: [PATCH] Observer platform overhaul: Nivo charts, round-type stats, UX improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: Fix 6 backend data bugs in analytics.ts (roundName filtering, unscored projects, criteria scores, activeRoundCount scoping, email privacy leaks in juror consistency + workload) Phase 2-3: Migrate all 9 chart components from Recharts to Nivo (@nivo/bar, @nivo/line, @nivo/pie, @nivo/scatterplot) with shared brand theme, scoreGradient colors, and STATUS_COLORS map. Fixes scatter plot outlier coloring and pie chart label visibility bugs. Phase 4: Add round-type-aware stats (getRoundTypeStats backend + RoundTypeStatsCards component) showing appropriate metrics per round type (intake/filtering/evaluation/submission/mentoring/live/deliberation). Phase 5: UX improvements — Stage→Round terminology, clickable dashboard round links, URL-based round selection (?round=), round type indicators in selectors, accessible Toggle-based cross-round comparison, sortable project table columns (title/score/evaluations), brand score colors on dashboard bar chart with aria labels. Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 749 +++++++++++++----- package.json | 7 +- src/app/(observer)/observer/reports/page.tsx | 77 +- src/components/charts/chart-theme.ts | 133 ++++ src/components/charts/criteria-scores.tsx | 128 ++- .../charts/cross-round-comparison.tsx | 268 ++++--- src/components/charts/diversity-metrics.tsx | 209 +++-- src/components/charts/evaluation-timeline.tsx | 121 +-- src/components/charts/juror-consistency.tsx | 213 +++-- src/components/charts/juror-workload.tsx | 143 ++-- src/components/charts/project-rankings.tsx | 169 ++-- src/components/charts/score-distribution.tsx | 93 +-- src/components/charts/status-breakdown.tsx | 142 +--- .../observer/observer-dashboard-content.tsx | 266 ++++--- src/components/observer/round-type-stats.tsx | 158 ++++ src/components/ui/toggle.tsx | 45 ++ src/server/routers/analytics.ts | 255 +++++- src/server/routers/program.ts | 1 + 18 files changed, 2095 insertions(+), 1082 deletions(-) create mode 100644 src/components/charts/chart-theme.ts create mode 100644 src/components/observer/round-type-stats.tsx create mode 100644 src/components/ui/toggle.tsx diff --git a/package-lock.json b/package-lock.json index 35d6774..030470b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,11 @@ "@hookform/resolvers": "^3.9.1", "@mantine/core": "^8.3.13", "@mantine/hooks": "^8.3.13", + "@nivo/bar": "^0.99.0", + "@nivo/core": "^0.99.0", + "@nivo/line": "^0.99.0", + "@nivo/pie": "^0.99.0", + "@nivo/scatterplot": "^0.99.0", "@notionhq/client": "^2.3.0", "@prisma/client": "^6.19.2", "@radix-ui/react-alert-dialog": "^1.1.4", @@ -37,6 +42,7 @@ "@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", + "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tooltip": "^1.1.6", "@tailwindcss/postcss": "^4.1.18", "@tanstack/react-query": "^5.62.0", @@ -72,7 +78,6 @@ "react-hook-form": "^7.54.2", "react-leaflet": "^5.0.0", "react-phone-number-input": "^3.4.14", - "recharts": "^3.7.0", "sonner": "^2.0.7", "superjson": "^2.2.2", "tailwind-merge": "^3.4.0", @@ -1974,6 +1979,408 @@ "node": ">= 10" } }, + "node_modules/@nivo/annotations": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/annotations/-/annotations-0.99.0.tgz", + "integrity": "sha512-jCuuXPbvpaqaz4xF7k5dv0OT2ubn5Nt0gWryuTe/8oVsC/9bzSuK8bM9vBty60m9tfO+X8vUYliuaCDwGksC2g==", + "license": "MIT", + "dependencies": { + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/arcs": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/arcs/-/arcs-0.99.0.tgz", + "integrity": "sha512-UcvWLQPl+A3APk2Gm74N5xDfT+ATnVs2XkP73WxhYPWJk+dBzF00cndA5g/dptOwdFBvvo62VgcCsNiwUsjKTw==", + "license": "MIT", + "dependencies": { + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/text": "0.99.0", + "@nivo/theming": "0.99.0", + "@react-spring/core": "9.4.5 || ^9.7.2 || ^10.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "@types/d3-shape": "^3.1.6", + "d3-shape": "^3.2.0" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/axes": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/axes/-/axes-0.99.0.tgz", + "integrity": "sha512-3KschnmEL0acRoa7INSSOSEFwJLm54aZwSev7/r8XxXlkgRBriu6ReZy/FG0wfN+ljZ4GMvx+XyIIf6kxzvrZg==", + "license": "MIT", + "dependencies": { + "@nivo/core": "0.99.0", + "@nivo/scales": "0.99.0", + "@nivo/text": "0.99.0", + "@nivo/theming": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "@types/d3-format": "^1.4.1", + "@types/d3-time-format": "^2.3.1", + "d3-format": "^1.4.4", + "d3-time-format": "^3.0.0" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/axes/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/@nivo/axes/node_modules/d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@nivo/axes/node_modules/d3-time": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz", + "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "2" + } + }, + "node_modules/@nivo/axes/node_modules/d3-time-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", + "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-time": "1 - 2" + } + }, + "node_modules/@nivo/axes/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/@nivo/bar": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/bar/-/bar-0.99.0.tgz", + "integrity": "sha512-9yfMn7H6UF/TqtCwVZ/vihVAXUff9wWvSaeF2Z1DCfgr5S07qs31Qb2p0LZA+YgCWpaU7zqkeb3VZ4WCpZbrDA==", + "license": "MIT", + "dependencies": { + "@nivo/annotations": "0.99.0", + "@nivo/axes": "0.99.0", + "@nivo/canvas": "0.99.0", + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/legends": "0.99.0", + "@nivo/scales": "0.99.0", + "@nivo/text": "0.99.0", + "@nivo/theming": "0.99.0", + "@nivo/tooltip": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "@types/d3-scale": "^4.0.8", + "@types/d3-shape": "^3.1.6", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/canvas": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/canvas/-/canvas-0.99.0.tgz", + "integrity": "sha512-UxA8zb+NPwqmNm81hoyUZSMAikgjU1ukLf4KybVNyV8ejcJM+BUFXsb8DxTcLdt4nmCFHqM56GaJQv2hnAHmzg==", + "license": "MIT" + }, + "node_modules/@nivo/colors": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/colors/-/colors-0.99.0.tgz", + "integrity": "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w==", + "license": "MIT", + "dependencies": { + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@types/d3-color": "^3.0.0", + "@types/d3-scale": "^4.0.8", + "@types/d3-scale-chromatic": "^3.0.0", + "d3-color": "^3.1.0", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.0.0", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/core": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/core/-/core-0.99.0.tgz", + "integrity": "sha512-olCItqhPG3xHL5ei+vg52aB6o+6S+xR2idpkd9RormTTUniZb8U2rOdcQojOojPY5i9kVeQyLFBpV4YfM7OZ9g==", + "license": "MIT", + "dependencies": { + "@nivo/theming": "0.99.0", + "@nivo/tooltip": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "@types/d3-shape": "^3.1.6", + "d3-color": "^3.1.0", + "d3-format": "^1.4.4", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-scale-chromatic": "^3.0.0", + "d3-shape": "^3.2.0", + "d3-time-format": "^3.0.0", + "lodash": "^4.17.21", + "react-virtualized-auto-sizer": "^1.0.26", + "use-debounce": "^10.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nivo/donate" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/core/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/@nivo/core/node_modules/d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@nivo/core/node_modules/d3-time": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz", + "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "2" + } + }, + "node_modules/@nivo/core/node_modules/d3-time-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", + "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-time": "1 - 2" + } + }, + "node_modules/@nivo/core/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/@nivo/legends": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/legends/-/legends-0.99.0.tgz", + "integrity": "sha512-P16FjFqNceuTTZphINAh5p0RF0opu3cCKoWppe2aRD9IuVkvRm/wS5K1YwMCxDzKyKh5v0AuTlu9K6o3/hk8hA==", + "license": "MIT", + "dependencies": { + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/text": "0.99.0", + "@nivo/theming": "0.99.0", + "@types/d3-scale": "^4.0.8", + "d3-scale": "^4.0.2" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/line": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/line/-/line-0.99.0.tgz", + "integrity": "sha512-bAqTXSjpnpcGMs341qWFUi7hJTqQiNoSeJHsYPuPS3icuXPcp3WETQH+zRZACeEF79ZigeOWCW+dzODgne1y9w==", + "license": "MIT", + "dependencies": { + "@nivo/annotations": "0.99.0", + "@nivo/axes": "0.99.0", + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/legends": "0.99.0", + "@nivo/scales": "0.99.0", + "@nivo/theming": "0.99.0", + "@nivo/tooltip": "0.99.0", + "@nivo/voronoi": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "@types/d3-shape": "^3.1.6", + "d3-shape": "^3.2.0" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/pie": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/pie/-/pie-0.99.0.tgz", + "integrity": "sha512-zUbo8UdLndp2RMljrOqitAKKEnl7YypkJrOzjKLk8jQGU7qqUKtgFoJIPhiBsvNPs3xtX2KwgtS1+JKNTNns7A==", + "license": "MIT", + "dependencies": { + "@nivo/arcs": "0.99.0", + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/legends": "0.99.0", + "@nivo/theming": "0.99.0", + "@nivo/tooltip": "0.99.0", + "@types/d3-shape": "^3.1.6", + "d3-shape": "^3.2.0" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/scales": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/scales/-/scales-0.99.0.tgz", + "integrity": "sha512-g/2K4L6L8si6E2BWAHtFVGahtDKbUcO6xHJtlIZMwdzaJc7yB16EpWLK8AfI/A42KadLhJSJqBK3mty+c7YZ+w==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "^3.0.4", + "@types/d3-scale": "^4.0.8", + "@types/d3-time": "^1.1.1", + "@types/d3-time-format": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-time": "^1.0.11", + "d3-time-format": "^3.0.0", + "lodash": "^4.17.21" + } + }, + "node_modules/@nivo/scales/node_modules/@types/d3-time": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-1.1.4.tgz", + "integrity": "sha512-JIvy2HjRInE+TXOmIGN5LCmeO0hkFZx5f9FZ7kiN+D+YTcc8pptsiLiuHsvwxwC7VVKmJ2ExHUgNlAiV7vQM9g==", + "license": "MIT" + }, + "node_modules/@nivo/scales/node_modules/@types/d3-time-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-3.0.4.tgz", + "integrity": "sha512-or9DiDnYI1h38J9hxKEsw513+KVuFbEVhl7qdxcaudoiqWWepapUen+2vAriFGexr6W5+P4l9+HJrB39GG+oRg==", + "license": "MIT" + }, + "node_modules/@nivo/scales/node_modules/d3-time": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz", + "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==", + "license": "BSD-3-Clause" + }, + "node_modules/@nivo/scales/node_modules/d3-time-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz", + "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-time": "1 - 2" + } + }, + "node_modules/@nivo/scatterplot": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/scatterplot/-/scatterplot-0.99.0.tgz", + "integrity": "sha512-/fc0LPw2BdPgj+4cKqXlfSsQ9Z3EU0U4UBLDQ07GJCAkqA+IrufYGVVkAKRKyrLrG3sKFGBdZDinkp3LxuiwFg==", + "license": "MIT", + "dependencies": { + "@nivo/annotations": "0.99.0", + "@nivo/axes": "0.99.0", + "@nivo/colors": "0.99.0", + "@nivo/core": "0.99.0", + "@nivo/legends": "0.99.0", + "@nivo/scales": "0.99.0", + "@nivo/theming": "0.99.0", + "@nivo/tooltip": "0.99.0", + "@nivo/voronoi": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", + "@types/d3-scale": "^4.0.8", + "@types/d3-shape": "^3.1.6", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/text": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/text/-/text-0.99.0.tgz", + "integrity": "sha512-ho3oZpAZApsJNjsIL5WJSAdg/wjzTBcwo1KiHBlRGUmD+yUWO8qp7V+mnYRhJchwygtRVALlPgZ/rlcW2Xr/MQ==", + "license": "MIT", + "dependencies": { + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/theming": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/theming/-/theming-0.99.0.tgz", + "integrity": "sha512-KvXlf0nqBzh/g2hAIV9bzscYvpq1uuO3TnFN3RDXGI72CrbbZFTGzprPju3sy/myVsauv+Bb+V4f5TZ0jkYKRg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/tooltip": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/tooltip/-/tooltip-0.99.0.tgz", + "integrity": "sha512-weoEGR3xAetV4k2P6k96cdamGzKQ5F2Pq+uyDaHr1P3HYArM879Pl+x+TkU0aWjP6wgUZPx/GOBiV1Hb1JxIqg==", + "license": "MIT", + "dependencies": { + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, + "node_modules/@nivo/voronoi": { + "version": "0.99.0", + "resolved": "https://registry.npmjs.org/@nivo/voronoi/-/voronoi-0.99.0.tgz", + "integrity": "sha512-KfmMdidbYzhiUCki1FG4X4nHEFT4loK8G5bMBnmCl9U+S78W+gvkfrgD2Aoqp/Q9yKQvr3Y8UcZKSFZnn3HgjQ==", + "license": "MIT", + "dependencies": { + "@nivo/core": "0.99.0", + "@nivo/theming": "0.99.0", + "@nivo/tooltip": "0.99.0", + "@types/d3-delaunay": "^6.0.4", + "@types/d3-scale": "^4.0.8", + "d3-delaunay": "^6.0.4", + "d3-scale": "^4.0.2" + }, + "peerDependencies": { + "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3261,6 +3668,31 @@ } } }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", @@ -3507,40 +3939,76 @@ "react-dom": "^19.0.0" } }, - "node_modules/@reduxjs/toolkit": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", - "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "node_modules/@react-spring/animated": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-10.0.3.tgz", + "integrity": "sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ==", "license": "MIT", "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" + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" }, "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/@reduxjs/toolkit/node_modules/immer": { - "version": "11.1.3", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz", - "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==", + "node_modules/@react-spring/core": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-10.0.3.tgz", + "integrity": "sha512-D4DwNO68oohDf/0HG2G0Uragzb9IA1oXblxrd6MZAcBcUQG2EHUWXewjdECMPLNmQvlYVyyBRH6gPxXM5DX7DQ==", "license": "MIT", + "dependencies": { + "@react-spring/animated": "~10.0.3", + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/immer" + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-10.0.3.tgz", + "integrity": "sha512-Ri2/xqt8OnQ2iFKkxKMSF4Nqv0LSWnxXT4jXFzBDsHgeeH/cHxTLupAWUwmV9hAGgmEhBmh5aONtj3J6R/18wg==", + "license": "MIT" + }, + "node_modules/@react-spring/shared": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-10.0.3.tgz", + "integrity": "sha512-geCal66nrkaQzUVhPkGomylo+Jpd5VPK8tPMEDevQEfNSWAQP15swHm+MCRG4wVQrQlTi9lOzKzpRoTL3CA84Q==", + "license": "MIT", + "dependencies": { + "@react-spring/rafz": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-spring/types": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-10.0.3.tgz", + "integrity": "sha512-H5Ixkd2OuSIgHtxuHLTt7aJYfhMXKXT/rK32HPD/kSrOB6q6ooeiWAXkBy7L8F3ZxdkBb9ini9zP9UwnEFzWgQ==", + "license": "MIT" + }, + "node_modules/@react-spring/web": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.0.3.tgz", + "integrity": "sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~10.0.3", + "@react-spring/core": "~10.0.3", + "@react-spring/shared": "~10.0.3", + "@react-spring/types": "~10.0.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/@remirror/core-constants": { @@ -3933,12 +4401,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/@standard-schema/utils": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", - "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "devOptional": true, "license": "MIT" }, "node_modules/@swc/helpers": { @@ -4570,22 +5033,22 @@ "assertion-error": "^2.0.1" } }, - "node_modules/@types/d3-array": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "license": "MIT" - }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-mLxrC1MSWupOSncXN/HOlWUAAIffAEBaI4+PKy2uMPsKe4FNZlk7qrbTjmzJXITQQqBHivaks4Td18azgqnotA==", "license": "MIT" }, "node_modules/@types/d3-interpolate": { @@ -4612,6 +5075,12 @@ "@types/d3-time": "*" } }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", @@ -4627,10 +5096,10 @@ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "license": "MIT" }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "node_modules/@types/d3-time-format": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.3.4.tgz", + "integrity": "sha512-xdDXbpVO74EvadI3UDxjxTdR6QIxm1FKzEA/+F8tL4GWWUg/hgvBqf6chql64U5A9ZUGWo7pEu4eNlyLwbKdhg==", "license": "MIT" }, "node_modules/@types/debug": { @@ -6363,11 +6832,14 @@ "node": ">=12" } }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, "engines": { "node": ">=12" } @@ -6418,6 +6890,19 @@ "node": ">=12" } }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -6454,15 +6939,6 @@ "node": ">=12" } }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -6557,12 +7033,6 @@ } } }, - "node_modules/decimal.js-light": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", - "license": "MIT" - }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -6644,6 +7114,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -7001,16 +7480,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-toolkit": { - "version": "1.44.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", - "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -8444,16 +8913,6 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, - "node_modules/immer": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", - "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -12037,29 +12496,6 @@ "react-dom": ">=16.8" } }, - "node_modules/react-redux": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", - "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", - "license": "MIT", - "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" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "redux": { - "optional": true - } - } - }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -12146,6 +12582,16 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/react-virtualized-auto-sizer": { + "version": "1.0.26", + "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.26.tgz", + "integrity": "sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A==", + "license": "MIT", + "peerDependencies": { + "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -12174,51 +12620,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/recharts": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", - "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", - "license": "MIT", - "workspaces": [ - "www" - ], - "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" - }, - "engines": { - "node": ">=18" - }, - "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" - } - }, - "node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" - }, - "node_modules/redux-thunk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "license": "MIT", - "peerDependencies": { - "redux": "^5.0.0" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -12411,12 +12812,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", - "license": "MIT" - }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -12479,6 +12874,12 @@ "node": ">= 0.8.15" } }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, "node_modules/rollup": { "version": "4.57.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz", @@ -13279,12 +13680,6 @@ "readable-stream": "3" } }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "license": "MIT" - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -13960,28 +14355,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/victory-vendor": { - "version": "37.3.6", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", - "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", - "license": "MIT AND ISC", - "dependencies": { - "@types/d3-array": "^3.0.3", - "@types/d3-ease": "^3.0.0", - "@types/d3-interpolate": "^3.0.1", - "@types/d3-scale": "^4.0.2", - "@types/d3-shape": "^3.1.0", - "@types/d3-time": "^3.0.0", - "@types/d3-timer": "^3.0.0", - "d3-array": "^3.1.6", - "d3-ease": "^3.0.1", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-shape": "^3.1.0", - "d3-time": "^3.0.0", - "d3-timer": "^3.0.1" - } - }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index ba9bc3e..35a40ad 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,11 @@ "@hookform/resolvers": "^3.9.1", "@mantine/core": "^8.3.13", "@mantine/hooks": "^8.3.13", + "@nivo/bar": "^0.99.0", + "@nivo/core": "^0.99.0", + "@nivo/line": "^0.99.0", + "@nivo/pie": "^0.99.0", + "@nivo/scatterplot": "^0.99.0", "@notionhq/client": "^2.3.0", "@prisma/client": "^6.19.2", "@radix-ui/react-alert-dialog": "^1.1.4", @@ -50,6 +55,7 @@ "@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", + "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tooltip": "^1.1.6", "@tailwindcss/postcss": "^4.1.18", "@tanstack/react-query": "^5.62.0", @@ -85,7 +91,6 @@ "react-hook-form": "^7.54.2", "react-leaflet": "^5.0.0", "react-phone-number-input": "^3.4.14", - "recharts": "^3.7.0", "sonner": "^2.0.7", "superjson": "^2.2.2", "tailwind-merge": "^3.4.0", diff --git a/src/app/(observer)/observer/reports/page.tsx b/src/app/(observer)/observer/reports/page.tsx index fcbf397..2ab0279 100644 --- a/src/app/(observer)/observer/reports/page.tsx +++ b/src/app/(observer)/observer/reports/page.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect } from 'react' +import { useSearchParams } from 'next/navigation' import { trpc } from '@/lib/trpc/client' import { Card, @@ -10,6 +11,7 @@ import { CardTitle, } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' +import { Toggle } from '@/components/ui/toggle' import { Progress } from '@/components/ui/progress' import { Skeleton } from '@/components/ui/skeleton' import { @@ -61,11 +63,21 @@ function parseSelection(value: string | null): { roundId?: string; programId?: s return { roundId: value } } +const ROUND_TYPE_LABELS: Record = { + INTAKE: 'Intake', + FILTERING: 'Filtering', + EVALUATION: 'Evaluation', + SUBMISSION: 'Submission', + MENTORING: 'Mentoring', + LIVE_FINAL: 'Live Final', + DELIBERATION: 'Deliberation', +} + function OverviewTab({ selectedValue }: { selectedValue: string | null }) { const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true }) const stages = programs?.flatMap(p => - (p.stages as { id: string; name: string; status: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map(s => ({ + (p.stages as { id: string; name: string; status: string; roundType: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map(s => ({ ...s, programName: `${p.year} Edition`, })) @@ -100,9 +112,8 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) { ) } - // Count distinct projects by collecting unique IDs, not summing per-round states - const totalProjects = overviewStats?.projectCount ?? stages.reduce((acc, s) => acc + (s._count?.projects || 0), 0) - const activeStages = stages.filter((s) => s.status === 'ROUND_ACTIVE').length + const totalProjects = overviewStats?.projectCount ?? 0 + const activeRounds = stages.filter((s) => s.status === 'ROUND_ACTIVE').length const totalPrograms = programs?.length || 0 return ( @@ -114,10 +125,10 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
-

Total Stages

+

Total Rounds

{stages.length}

- {activeStages} active + {activeRounds} active

@@ -135,7 +146,7 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {

Total Projects

{totalProjects}

-

Across all stages

+

Across all rounds

@@ -150,8 +161,8 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
-

Active Stages

-

{activeStages}

+

Active Rounds

+

{activeRounds}

Currently active

@@ -255,14 +266,14 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) { {/* Stages Table - Desktop */} - Stage Reports - Progress overview for each stage + Round Reports + Progress overview for each round - Stage + Round Program Projects Status @@ -305,7 +316,7 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) { {/* Stages Cards - Mobile */}
-

Stage Reports

+

Round Reports

{stages.map((stage) => ( @@ -485,25 +496,27 @@ function CrossStageTab() {
- Select Stages to Compare - Choose at least 2 stages + Select Rounds to Compare + Choose at least 2 rounds -
+
{stages.map((stage) => ( - toggleRound(stage.id)} + variant="outline" + size="sm" + pressed={selectedRoundIds.includes(stage.id)} + onPressedChange={() => toggleRound(stage.id)} + aria-label={`${stage.programName} - ${stage.name}`} > {stage.programName} - {stage.name} - + ))}
{selectedRoundIds.length < 2 && (

- Select at least 2 stages to enable comparison + Select at least 2 rounds to enable comparison

)} @@ -541,7 +554,7 @@ function JurorConsistencyTab({ selectedValue }: { selectedValue: string }) { data={consistency as { overallAverage: number jurors: Array<{ - userId: string; name: string; email: string + userId: string; name: string evaluationCount: number; averageScore: number stddev: number; deviationFromOverall: number; isOutlier: boolean }> @@ -578,19 +591,21 @@ function DiversityTab({ selectedValue }: { selectedValue: string }) { } export default function ObserverReportsPage() { - const [selectedValue, setSelectedValue] = useState(null) + const searchParams = useSearchParams() + const roundFromUrl = searchParams.get('round') + const [selectedValue, setSelectedValue] = useState(roundFromUrl) const { data: programs, isLoading: stagesLoading } = trpc.program.list.useQuery({ includeStages: true }) const stages = programs?.flatMap(p => - (p.stages as { id: string; name: string; status: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map(s => ({ + (p.stages as { id: string; name: string; status: string; roundType: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map(s => ({ ...s, programId: p.id, programName: `${p.year} Edition`, })) ) || [] - // Set default selected stage — prefer the active round, fall back to first + // Set default selected round — prefer URL param, then active round, then first useEffect(() => { if (stages.length && !selectedValue) { const active = stages.find((s) => s.status === 'ROUND_ACTIVE') @@ -613,29 +628,29 @@ export default function ObserverReportsPage() { {/* Stage Selector */}
- + {stagesLoading ? ( ) : stages.length > 0 ? ( ) : ( -

No stages available

+

No rounds available

)}
diff --git a/src/components/charts/chart-theme.ts b/src/components/charts/chart-theme.ts new file mode 100644 index 0000000..a5378af --- /dev/null +++ b/src/components/charts/chart-theme.ts @@ -0,0 +1,133 @@ +import type { PartialTheme } from '@nivo/theming' + +// Brand colors from CLAUDE.md +export const BRAND_DARK_BLUE = '#053d57' +export const BRAND_RED = '#de0f1e' +export const BRAND_TEAL = '#557f8c' +export const BRAND_WHITE = '#fefefe' + +// Extended palette derived from brand +export const BRAND_COLORS = [ + '#053d57', // Dark Blue + '#de0f1e', // Red + '#557f8c', // Teal + '#1e7a8a', // Deep Teal + '#c4453a', // Coral + '#3a6f7f', // Mid Teal + '#8b1a24', // Dark Red + '#2d8659', // Sea Green + '#7c9aa6', // Light Teal + '#a83240', // Rose +] as const + +// Project status colors — mapped to actual ProjectStatus enum values +export const STATUS_COLORS: Record = { + SUBMITTED: '#557f8c', // Teal + ELIGIBLE: '#053d57', // Dark Blue + ASSIGNED: '#1e7a8a', // Deep Teal + SEMIFINALIST: '#c4453a', // Coral + FINALIST: '#2d8659', // Sea Green + REJECTED: '#de0f1e', // Red + DRAFT: '#9ca3af', // Gray + WITHDRAWN: '#6b7280', // Dark Gray +} + +// Human-readable status labels +export const STATUS_LABELS: Record = { + SUBMITTED: 'Submitted', + ELIGIBLE: 'Eligible', + ASSIGNED: 'Assigned', + SEMIFINALIST: 'Semi-finalist', + FINALIST: 'Finalist', + REJECTED: 'Rejected', + DRAFT: 'Draft', + WITHDRAWN: 'Withdrawn', +} + +/** + * Score gradient: Red (low) → Teal (mid) → Dark Blue (high) + * for scores on a 1-10 scale + */ +export function scoreGradient(score: number): string { + const t = Math.max(0, Math.min(1, (score - 1) / 9)) + if (t < 0.5) { + // Red → Teal (0 → 0.5) + const p = t * 2 + return lerpColor(BRAND_RED, BRAND_TEAL, p) + } + // Teal → Dark Blue (0.5 → 1) + const p = (t - 0.5) * 2 + return lerpColor(BRAND_TEAL, BRAND_DARK_BLUE, p) +} + +function lerpColor(a: string, b: string, t: number): string { + const ar = parseInt(a.slice(1, 3), 16) + const ag = parseInt(a.slice(3, 5), 16) + const ab = parseInt(a.slice(5, 7), 16) + const br = parseInt(b.slice(1, 3), 16) + const bg = parseInt(b.slice(3, 5), 16) + const bb = parseInt(b.slice(5, 7), 16) + const r = Math.round(ar + (br - ar) * t) + const g = Math.round(ag + (bg - ag) * t) + const bl = Math.round(ab + (bb - ab) * t) + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${bl.toString(16).padStart(2, '0')}` +} + +/** + * Shared Nivo theme — brand fonts, clean grid, shadcn-style tooltips + */ +export const nivoTheme: PartialTheme = { + background: 'transparent', + text: { + fontSize: 12, + fill: '#374151', + fontFamily: 'Montserrat, system-ui, sans-serif', + }, + axis: { + domain: { + line: { stroke: '#e5e7eb', strokeWidth: 1 }, + }, + ticks: { + line: { stroke: '#e5e7eb', strokeWidth: 1 }, + text: { fontSize: 11, fill: '#6b7280' }, + }, + legend: { + text: { fontSize: 13, fill: '#374151', fontWeight: 600 }, + }, + }, + grid: { + line: { stroke: '#f3f4f6', strokeWidth: 1 }, + }, + legends: { + text: { fontSize: 12, fill: '#374151' }, + }, + labels: { + text: { fontSize: 12, fill: '#374151', fontWeight: 500 }, + }, + tooltip: { + container: { + background: '#ffffff', + color: '#1f2937', + fontSize: 12, + borderRadius: '8px', + boxShadow: '0 4px 12px rgba(0,0,0,0.1)', + padding: '8px 12px', + border: '1px solid #e5e7eb', + }, + }, +} + +/** + * Helper: get color for a status value from STATUS_COLORS + * Falls back to a neutral gray + */ +export function getStatusColor(status: string): string { + return STATUS_COLORS[status] || '#9ca3af' +} + +/** + * Helper: format a status value for display + */ +export function formatStatus(status: string): string { + return STATUS_LABELS[status] || status.charAt(0) + status.slice(1).toLowerCase().replace(/_/g, ' ') +} diff --git a/src/components/charts/criteria-scores.tsx b/src/components/charts/criteria-scores.tsx index eb34896..1f6c97b 100644 --- a/src/components/charts/criteria-scores.tsx +++ b/src/components/charts/criteria-scores.tsx @@ -1,16 +1,8 @@ 'use client' -import { - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - Cell, -} from 'recharts' +import { ResponsiveBar } from '@nivo/bar' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { nivoTheme, scoreGradient } from './chart-theme' interface CriteriaScoreData { id: string @@ -23,27 +15,27 @@ interface CriteriaScoresProps { data: CriteriaScoreData[] } -// Color scale from red to green based on score -const getScoreColor = (score: number): string => { - if (score >= 8) return '#0bd90f' // Excellent - green - if (score >= 6) return '#82ca9d' // Good - light green - if (score >= 4) return '#ffc658' // Average - yellow - if (score >= 2) return '#ff7300' // Poor - orange - return '#de0f1e' // Very poor - red +type CriterionBarDatum = { + criterion: string + averageScore: number + fullName: string + count: number } export function CriteriaScoresChart({ data }: CriteriaScoresProps) { - const formattedData = data.map((d) => ({ - ...d, - displayName: - d.name.length > 20 ? d.name.substring(0, 20) + '...' : d.name, - })) - const overallAverage = data.length > 0 ? data.reduce((sum, d) => sum + d.averageScore, 0) / data.length : 0 + const chartData: CriterionBarDatum[] = data.map((d) => ({ + criterion: + d.name.length > 25 ? d.name.substring(0, 25) + '...' : d.name, + averageScore: d.averageScore, + fullName: d.name, + count: d.count, + })) + return ( @@ -55,50 +47,54 @@ export function CriteriaScoresChart({ data }: CriteriaScoresProps) { -
- - - - - - + + scoreGradient(bar.data.averageScore as number) + } + valueScale={{ type: 'linear', max: 10 }} + borderRadius={4} + enableLabel={true} + label={(d) => { + const v = d.value + return v != null ? Number(v).toFixed(1) : '' + }} + labelSkipHeight={12} + labelTextColor="#ffffff" + axisBottom={{ + tickRotation: -45, + }} + axisLeft={{ + legend: 'Score', + legendPosition: 'middle', + legendOffset: -40, + }} + margin={{ top: 20, right: 20, bottom: 80, left: 50 }} + padding={0.25} + tooltip={({ data: rowData }) => ( +
[ - (value ?? 0).toFixed(2), - 'Average Score', - ]} - labelFormatter={(_, payload) => { - if (payload && payload[0]) { - const item = payload[0].payload as CriteriaScoreData - return `${item.name} (${item.count} ratings)` - } - return '' - }} - /> - - {formattedData.map((entry, index) => ( - - ))} - - - + > + {rowData.fullName} +
+ Average Score: {Number(rowData.averageScore).toFixed(2)} +
+ Ratings: {rowData.count} +
+ )} + animate={true} + />
diff --git a/src/components/charts/cross-round-comparison.tsx b/src/components/charts/cross-round-comparison.tsx index 48e91af..a5e847b 100644 --- a/src/components/charts/cross-round-comparison.tsx +++ b/src/components/charts/cross-round-comparison.tsx @@ -1,16 +1,8 @@ 'use client' -import { - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - Legend, -} from 'recharts' +import { ResponsiveBar } from '@nivo/bar' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { nivoTheme, BRAND_COLORS } from './chart-theme' interface StageComparison { roundId: string @@ -26,128 +18,152 @@ interface CrossStageComparisonProps { data: StageComparison[] } -const STAGE_COLORS = ['#053d57', '#de0f1e', '#557f8c', '#f38a52', '#6ad82f'] - -export function CrossStageComparisonChart({ data }: CrossStageComparisonProps) { - // Prepare comparison data - const comparisonData = data.map((stage, i) => ({ - name: stage.roundName.length > 20 ? stage.roundName.slice(0, 20) + '...' : stage.roundName, - projects: stage.projectCount, - evaluations: stage.evaluationCount, - completionRate: stage.completionRate, - avgScore: stage.averageScore ? parseFloat(stage.averageScore.toFixed(2)) : 0, - color: STAGE_COLORS[i % STAGE_COLORS.length], +export function CrossStageComparisonChart({ + data, +}: CrossStageComparisonProps) { + const baseData = data.map((round) => ({ + name: + round.roundName.length > 20 + ? round.roundName.slice(0, 20) + '...' + : round.roundName, + projects: round.projectCount, + evaluations: round.evaluationCount, + completionRate: round.completionRate, + avgScore: round.averageScore + ? parseFloat(round.averageScore.toFixed(2)) + : 0, })) + const sharedMargin = { top: 10, right: 10, bottom: 40, left: 40 } + return ( -
- {/* Metrics Comparison */} - - - Stage Metrics Comparison - - -
- - - - - - + + Round Metrics Comparison + + +
+ + + Projects + + +
+ - - - - - -
-
-
+
+
+ - {/* Completion & Score Comparison */} -
- - - Completion Rate by Stage - - -
- - - - - - - - - -
-
-
+ + + + Evaluations + + + +
+ +
+
+
- - - Average Score by Stage - - -
- - - - - - - - - -
-
-
-
-
+ + + + Completion Rate + + + +
+ `${v}%`} + margin={sharedMargin} + padding={0.3} + axisBottom={{ + tickRotation: -25, + }} + axisLeft={{ + format: (v) => `${v}%`, + }} + animate={true} + /> +
+
+
+ + + + + Average Score + + + +
+ +
+
+
+
+ + ) } diff --git a/src/components/charts/diversity-metrics.tsx b/src/components/charts/diversity-metrics.tsx index 12ea97c..6b441bd 100644 --- a/src/components/charts/diversity-metrics.tsx +++ b/src/components/charts/diversity-metrics.tsx @@ -1,20 +1,10 @@ 'use client' -import { - PieChart, - Pie, - Cell, - Tooltip, - ResponsiveContainer, - Legend, - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, -} from 'recharts' +import { ResponsivePie } from '@nivo/pie' +import { ResponsiveBar } from '@nivo/bar' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' +import { nivoTheme, BRAND_COLORS } from './chart-theme' interface DiversityData { total: number @@ -28,12 +18,6 @@ interface DiversityMetricsProps { data: DiversityData } -const PIE_COLORS = [ - '#053d57', '#de0f1e', '#557f8c', '#f38a52', '#6ad82f', - '#3be31e', '#c9c052', '#e6382f', '#ed6141', '#0bd90f', - '#8884d8', '#82ca9d', '#ffc658', '#ff7c7c', '#8dd1e1', -] - /** Convert ISO 3166-1 alpha-2 code to full country name using Intl API */ function getCountryName(code: string): string { if (code === 'Others') return 'Others' @@ -54,33 +38,6 @@ function formatLabel(value: string): string { .replace(/\b\w/g, (c) => c.toUpperCase()) } -/** Custom tooltip for the pie chart */ -function CountryTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: { country: string; count: number; percentage: number } }> }) { - if (!active || !payload?.length) return null - const d = payload[0].payload - return ( -
-

{getCountryName(d.country)}

-

{d.count} projects ({d.percentage.toFixed(1)}%)

-
- ) -} - -/** Custom tooltip for bar charts */ -function BarTooltip({ active, payload, labelFormatter }: { active?: boolean; payload?: Array<{ value: number }>; label?: string; labelFormatter: (val: string) => string }) { - if (!active || !payload?.length) return null - const entry = payload[0] - const rawPayload = entry as unknown as { payload: Record } - const dataPoint = rawPayload.payload - const rawLabel = (dataPoint.category || dataPoint.issue || '') as string - return ( -
-

{labelFormatter(rawLabel)}

-

{entry.value} projects

-
- ) -} - export function DiversityMetricsChart({ data }: DiversityMetricsProps) { if (data.total === 0) { return ( @@ -103,15 +60,21 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) { }] : topCountries + const nivoPieData = countryPieData.map((c) => ({ + id: c.country, + label: getCountryName(c.country), + value: c.count, + })) + // Pre-format category and ocean issue data for display const formattedCategories = data.byCategory.slice(0, 10).map((c) => ({ - ...c, category: formatLabel(c.category), + count: c.count, })) const formattedOceanIssues = data.byOceanIssue.slice(0, 15).map((o) => ({ - ...o, issue: formatLabel(o.issue), + count: o.count, })) return ( @@ -151,35 +114,42 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) { Geographic Distribution -
- - - { - const p = props as { country: string; percentage: number } - return `${getCountryName(p.country)} (${p.percentage.toFixed(0)}%)` - }) as unknown as boolean} - fontSize={13} - > - {countryPieData.map((_, index) => ( - - ))} - - } /> - getCountryName(value)} - wrapperStyle={{ fontSize: '13px' }} - /> - - +
+
@@ -191,25 +161,27 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) { {formattedCategories.length > 0 ? ( -
- - - - - - v} />} /> - - - +
+
) : (

No category data

@@ -225,26 +197,31 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) { Ocean Issues Addressed -
- - - - - - v} />} /> - - - +
+
diff --git a/src/components/charts/evaluation-timeline.tsx b/src/components/charts/evaluation-timeline.tsx index 9f33ba0..6336ecb 100644 --- a/src/components/charts/evaluation-timeline.tsx +++ b/src/components/charts/evaluation-timeline.tsx @@ -1,19 +1,8 @@ 'use client' -import { - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - Legend, - ResponsiveContainer, - Area, - ComposedChart, - Bar, -} from 'recharts' +import { ResponsiveLine } from '@nivo/line' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { nivoTheme, BRAND_DARK_BLUE } from './chart-theme' interface TimelineDataPoint { date: string @@ -26,7 +15,6 @@ interface EvaluationTimelineProps { } export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) { - // Format date for display const formattedData = data.map((d) => ({ ...d, dateFormatted: new Date(d.date).toLocaleDateString('en-US', { @@ -38,6 +26,16 @@ export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) { const totalEvaluations = data.length > 0 ? data[data.length - 1].cumulative : 0 + const lineData = [ + { + id: 'Cumulative Evaluations', + data: formattedData.map((d) => ({ + x: d.dateFormatted, + y: d.cumulative, + })), + }, + ] + return ( @@ -49,52 +47,55 @@ export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) { -
- - - - - - - [ - value ?? 0, - (name ?? '') === 'daily' ? 'Daily' : 'Cumulative', - ]} - labelFormatter={(label) => `Date: ${label}`} - /> - - - - - +
+ { + const point = slice.points[0] + const dataItem = formattedData.find( + (d) => d.dateFormatted === point.data.xFormatted + ) + return ( +
+ {point.data.xFormatted} +
Cumulative: {point.data.yFormatted}
+ {dataItem &&
Daily: {dataItem.daily}
} +
+ ) + }} + margin={{ top: 20, right: 20, bottom: 50, left: 60 }} + axisBottom={{ + tickRotation: -45, + legend: '', + legendOffset: 36, + }} + axisLeft={{ + legend: 'Evaluations', + legendOffset: -50, + legendPosition: 'middle', + }} + yScale={{ type: 'linear', min: 0, max: 'auto' }} + />
diff --git a/src/components/charts/juror-consistency.tsx b/src/components/charts/juror-consistency.tsx index 5bb9742..e97556a 100644 --- a/src/components/charts/juror-consistency.tsx +++ b/src/components/charts/juror-consistency.tsx @@ -1,15 +1,11 @@ 'use client' -import { - ScatterChart, - Scatter, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - ReferenceLine, -} from 'recharts' +import { ResponsiveScatterPlot } from '@nivo/scatterplot' +import type { + ScatterPlotDatum, + ScatterPlotNodeProps, +} from '@nivo/scatterplot' +import { animated } from '@react-spring/web' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { @@ -21,11 +17,11 @@ import { TableRow, } from '@/components/ui/table' import { AlertTriangle } from 'lucide-react' +import { nivoTheme, BRAND_DARK_BLUE, BRAND_RED } from './chart-theme' interface JurorMetric { userId: string name: string - email: string evaluationCount: number averageScore: number stddev: number @@ -40,14 +36,73 @@ interface JurorConsistencyProps { } } +interface JurorDatum extends ScatterPlotDatum { + x: number + y: number + name: string + evaluations: number + isOutlier: boolean +} + +function CustomNode({ + node, + style, + blendMode, + isInteractive, + onMouseEnter, + onMouseMove, + onMouseLeave, + onClick, +}: ScatterPlotNodeProps) { + const fillColor = node.data.isOutlier ? BRAND_RED : BRAND_DARK_BLUE + + return ( + s / 2)} + fill={fillColor} + fillOpacity={0.7} + stroke={fillColor} + strokeWidth={1} + style={{ mixBlendMode: blendMode }} + onMouseEnter={ + isInteractive && onMouseEnter + ? (event) => onMouseEnter(node, event) + : undefined + } + onMouseMove={ + isInteractive && onMouseMove + ? (event) => onMouseMove(node, event) + : undefined + } + onMouseLeave={ + isInteractive && onMouseLeave + ? (event) => onMouseLeave(node, event) + : undefined + } + onClick={ + isInteractive && onClick + ? (event) => onClick(node, event) + : undefined + } + /> + ) +} + export function JurorConsistencyChart({ data }: JurorConsistencyProps) { - const scatterData = data.jurors.map((j) => ({ - name: j.name, - avgScore: parseFloat(j.averageScore.toFixed(2)), - stddev: parseFloat(j.stddev.toFixed(2)), - evaluations: j.evaluationCount, - isOutlier: j.isOutlier, - })) + const scatterData = [ + { + id: 'Jurors', + data: data.jurors.map((j) => ({ + x: parseFloat(j.averageScore.toFixed(2)), + y: parseFloat(j.stddev.toFixed(2)), + name: j.name, + evaluations: j.evaluationCount, + isOutlier: j.isOutlier, + })), + }, + ] const outlierCount = data.jurors.filter((j) => j.isOutlier).length @@ -69,51 +124,63 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) { -
- - - - - - + + data={scatterData} + theme={nivoTheme} + colors={[BRAND_DARK_BLUE]} + xScale={{ type: 'linear', min: 0, max: 10 }} + yScale={{ type: 'linear', min: 0, max: 'auto' }} + axisBottom={{ + legend: 'Average Score', + legendPosition: 'middle', + legendOffset: 40, + }} + axisLeft={{ + legend: 'Std Deviation', + legendPosition: 'middle', + legendOffset: -50, + }} + useMesh={true} + nodeSize={(node) => + Math.max(8, Math.min(20, node.data.evaluations * 2)) + } + nodeComponent={CustomNode} + margin={{ top: 20, right: 20, bottom: 60, left: 60 }} + tooltip={({ node }) => ( +
- - - {scatterData.map((entry, index) => ( - - ))} - - - + > + {node.data.name} +
Avg Score: {node.data.x}
+
Std Dev: {node.data.y}
+
Evaluations: {node.data.evaluations}
+
+ )} + markers={[ + { + axis: 'x', + value: data.overallAverage, + lineStyle: { + stroke: BRAND_RED, + strokeWidth: 2, + strokeDasharray: '6 4', + }, + legend: `Avg: ${data.overallAverage.toFixed(1)}`, + legendPosition: 'top', + }, + ]} + />

- Dot size represents number of evaluations. Red dots indicate outlier jurors (2+ points from mean). + Dot size represents number of evaluations. Red dots indicate outlier + jurors (2+ points from mean).

@@ -131,22 +198,30 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) { Evaluations Avg Score Std Dev - Deviation from Mean + + Deviation from Mean + Status {data.jurors.map((juror) => ( - + -
-

{juror.name}

-

{juror.email}

-
+

{juror.name}

+
+ + {juror.evaluationCount} + + + {juror.averageScore.toFixed(2)} + + + {juror.stddev.toFixed(2)} - {juror.evaluationCount} - {juror.averageScore.toFixed(2)} - {juror.stddev.toFixed(2)} {juror.deviationFromOverall.toFixed(2)} diff --git a/src/components/charts/juror-workload.tsx b/src/components/charts/juror-workload.tsx index e6052e2..b682575 100644 --- a/src/components/charts/juror-workload.tsx +++ b/src/components/charts/juror-workload.tsx @@ -1,16 +1,8 @@ 'use client' -import { - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - Legend, - ResponsiveContainer, -} from 'recharts' +import { ResponsiveBar, type ComputedDatum } from '@nivo/bar' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { nivoTheme } from './chart-theme' interface JurorWorkloadData { id: string @@ -24,18 +16,32 @@ interface JurorWorkloadProps { data: JurorWorkloadData[] } -export function JurorWorkloadChart({ data }: JurorWorkloadProps) { - // Truncate names for display - const formattedData = data.map((d) => ({ - ...d, - displayName: d.name.length > 15 ? d.name.substring(0, 15) + '...' : d.name, - })) +type WorkloadBarDatum = { + juror: string + completed: number + remaining: number + completionRate: number + fullName: string +} +export function JurorWorkloadChart({ data }: JurorWorkloadProps) { const totalAssigned = data.reduce((sum, d) => sum + d.assigned, 0) const totalCompleted = data.reduce((sum, d) => sum + d.completed, 0) const overallRate = totalAssigned > 0 ? Math.round((totalCompleted / totalAssigned) * 100) : 0 + const sortedData = [...data].sort( + (a, b) => b.completionRate - a.completionRate, + ) + + const chartData: WorkloadBarDatum[] = sortedData.map((d) => ({ + juror: d.name.length > 25 ? d.name.substring(0, 25) + '...' : d.name, + completed: d.completed, + remaining: d.assigned - d.completed, + completionRate: d.completionRate, + fullName: d.name, + })) + return ( @@ -47,54 +53,65 @@ export function JurorWorkloadChart({ data }: JurorWorkloadProps) { -
- - - - - - + ) => { + if (d.id === 'completed') { + return `${d.data.completionRate}%` + } + return '' + }} + labelSkipWidth={40} + labelTextColor={(d) => { + const datum = d as unknown as { data: ComputedDatum } + return datum.data.id === 'completed' ? '#ffffff' : '#374151' + }} + margin={{ top: 10, right: 30, bottom: 30, left: 160 }} + padding={0.25} + groupMode="stacked" + tooltip={({ id, value, data: rowData }) => ( +
[ - value ?? 0, - (name ?? '') === 'assigned' ? 'Assigned' : 'Completed', - ]} - labelFormatter={(_, payload) => { - if (payload && payload[0]) { - const item = payload[0].payload as JurorWorkloadData - return `${item.name} (${item.completionRate}% complete)` - } - return '' - }} - /> - - - - - + > + {rowData.fullName} +
+ {id === 'completed' ? 'Completed' : 'Remaining'}: {value} +
+ Completion: {rowData.completionRate}% +
+ )} + legends={[ + { + dataFrom: 'keys', + anchor: 'bottom', + direction: 'row', + translateY: 30, + itemsSpacing: 20, + itemWidth: 100, + itemHeight: 18, + symbolSize: 12, + symbolShape: 'square', + }, + ]} + animate={true} + />
diff --git a/src/components/charts/project-rankings.tsx b/src/components/charts/project-rankings.tsx index 05ce625..1b60158 100644 --- a/src/components/charts/project-rankings.tsx +++ b/src/components/charts/project-rankings.tsx @@ -1,17 +1,8 @@ 'use client' -import { - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - Cell, - ReferenceLine, -} from 'recharts' +import { ResponsiveBar } from '@nivo/bar' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { nivoTheme, scoreGradient } from './chart-theme' interface ProjectRankingData { id: string @@ -27,93 +18,119 @@ interface ProjectRankingsProps { limit?: number } -// Generate color based on score (red to green gradient) -const getScoreColor = (score: number): string => { - if (score >= 8) return '#0bd90f' // Excellent - green - if (score >= 6) return '#82ca9d' // Good - light green - if (score >= 4) return '#ffc658' // Average - yellow - if (score >= 2) return '#ff7300' // Poor - orange - return '#de0f1e' // Very poor - red +type RankingBarDatum = { + project: string + score: number + fullTitle: string + teamName: string + evaluationCount: number } export function ProjectRankingsChart({ data, limit = 20, }: ProjectRankingsProps) { - const displayData = data.slice(0, limit).map((d, index) => ({ - ...d, - rank: index + 1, - displayTitle: - d.title.length > 25 ? d.title.substring(0, 25) + '...' : d.title, - score: d.averageScore || 0, - })) + const scoredData = data.filter( + (d): d is ProjectRankingData & { averageScore: number } => + d.averageScore !== null, + ) const averageScore = - data.length > 0 - ? data.reduce((sum, d) => sum + (d.averageScore || 0), 0) / data.length + scoredData.length > 0 + ? scoredData.reduce((sum, d) => sum + d.averageScore, 0) / + scoredData.length : 0 + const displayData = scoredData.slice(0, limit) + + const chartData: RankingBarDatum[] = displayData.map((d) => ({ + project: + d.title.length > 30 ? d.title.substring(0, 30) + '...' : d.title, + score: d.averageScore, + fullTitle: d.title, + teamName: d.teamName ?? '', + evaluationCount: d.evaluationCount, + })) + return ( Project Rankings - Top {displayData.length} of {data.length} projects + Top {displayData.length} of {scoredData.length} scored projects -
- - - - - - [(value ?? 0).toFixed(2), 'Average Score']} - labelFormatter={(_, payload) => { - if (payload && payload[0]) { - const item = payload[0].payload as ProjectRankingData & { - rank: number - } - return `#${item.rank} - ${item.title}${item.teamName ? ` (${item.teamName})` : ''}` - } - return '' - }} - /> - + scoreGradient(bar.data.score as number)} + valueScale={{ type: 'linear', max: 10 }} + borderRadius={4} + enableLabel={true} + label={(d) => { + const v = d.value + return v != null ? Number(v).toFixed(1) : '' + }} + labelSkipWidth={30} + labelTextColor="#ffffff" + margin={{ top: 10, right: 30, bottom: 30, left: 200 }} + padding={0.2} + markers={[ + { + axis: 'x', + value: averageScore, + lineStyle: { + stroke: '#6b7280', + strokeWidth: 2, + strokeDasharray: '6 4', + }, + legend: `Avg: ${averageScore.toFixed(1)}`, + legendPosition: 'top', + textStyle: { + fill: '#6b7280', fontSize: 11, + }, + }, + ]} + tooltip={({ data: rowData }) => ( +
- - {displayData.map((entry, index) => ( - - ))} - - - + > + {rowData.fullTitle} + {rowData.teamName && ( + <> +
+ + {rowData.teamName} + + + )} +
+ Score: {Number(rowData.score).toFixed(2)} +
+ Evaluations: {rowData.evaluationCount} +
+ )} + animate={true} + />
diff --git a/src/components/charts/score-distribution.tsx b/src/components/charts/score-distribution.tsx index 31c37e0..49cd6bf 100644 --- a/src/components/charts/score-distribution.tsx +++ b/src/components/charts/score-distribution.tsx @@ -1,16 +1,8 @@ 'use client' -import { - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - Cell, -} from 'recharts' +import { ResponsiveBar } from '@nivo/bar' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { nivoTheme, scoreGradient } from './chart-theme' interface ScoreDistributionProps { data: { score: number; count: number }[] @@ -18,24 +10,16 @@ interface ScoreDistributionProps { totalScores: number } -const COLORS = [ - '#de0f1e', // 1 - red (poor) - '#e6382f', - '#ed6141', - '#f38a52', - '#f8b364', // 5 - yellow (average) - '#c9c052', - '#99cc41', - '#6ad82f', - '#3be31e', - '#0bd90f', // 10 - green (excellent) -] - export function ScoreDistributionChart({ data, averageScore, totalScores, }: ScoreDistributionProps) { + const chartData = data.map((d) => ({ + score: String(d.score), + count: d.count, + })) + return ( @@ -47,44 +31,31 @@ export function ScoreDistributionChart({ -
- - - - - - [value ?? 0, 'Count']} - labelFormatter={(label) => `Score: ${label}`} - /> - - {data.map((_, index) => ( - - ))} - - - +
+ scoreGradient(Number(bar.indexValue))} + borderRadius={4} + enableLabel={true} + labelSkipHeight={12} + labelTextColor="#ffffff" + axisBottom={{ + legend: 'Score', + legendPosition: 'middle', + legendOffset: 36, + }} + axisLeft={{ + legend: 'Count', + legendPosition: 'middle', + legendOffset: -40, + }} + margin={{ top: 20, right: 20, bottom: 50, left: 50 }} + padding={0.2} + animate={true} + />
diff --git a/src/components/charts/status-breakdown.tsx b/src/components/charts/status-breakdown.tsx index b6d569f..b2bf2d3 100644 --- a/src/components/charts/status-breakdown.tsx +++ b/src/components/charts/status-breakdown.tsx @@ -1,13 +1,8 @@ 'use client' -import { - PieChart, - Pie, - Cell, - Tooltip, - Legend, - ResponsiveContainer, -} from 'recharts' + +import { ResponsivePie } from '@nivo/pie' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { nivoTheme, getStatusColor, formatStatus } from './chart-theme' interface StatusDataPoint { status: string @@ -18,66 +13,14 @@ interface StatusBreakdownProps { data: StatusDataPoint[] } -const STATUS_COLORS: Record = { - PENDING: '#8884d8', - UNDER_REVIEW: '#82ca9d', - SHORTLISTED: '#ffc658', - SEMIFINALIST: '#ff7300', - FINALIST: '#00C49F', - WINNER: '#0088FE', - ELIMINATED: '#de0f1e', - WITHDRAWN: '#999999', -} - -const renderCustomLabel = ({ - cx, - cy, - midAngle, - innerRadius, - outerRadius, - percent, -}: { - cx?: number - cy?: number - midAngle?: number - innerRadius?: number - outerRadius?: number - percent?: number -}) => { - if (cx === undefined || cy === undefined || midAngle === undefined || - innerRadius === undefined || outerRadius === undefined || percent === undefined) { - return null - } - if (percent < 0.05) return null // Don't show labels for small slices - - const RADIAN = Math.PI / 180 - const radius = innerRadius + (outerRadius - innerRadius) * 0.5 - const x = cx + radius * Math.cos(-midAngle * RADIAN) - const y = cy + radius * Math.sin(-midAngle * RADIAN) - - return ( - cx ? 'start' : 'end'} - dominantBaseline="central" - fontSize={12} - fontWeight={600} - > - {`${(percent * 100).toFixed(0)}%`} - - ) -} - export function StatusBreakdownChart({ data }: StatusBreakdownProps) { const total = data.reduce((sum, item) => sum + item.count, 0) - // Format status for display - const formattedData = data.map((d) => ({ - ...d, - name: d.status.replace(/_/g, ' '), - color: STATUS_COLORS[d.status] || '#8884d8', + const pieData = data.map((d) => ({ + id: d.status, + label: formatStatus(d.status), + value: d.count, + color: getStatusColor(d.status), })) return ( @@ -91,39 +34,42 @@ export function StatusBreakdownChart({ data }: StatusBreakdownProps) { -
- - - - {formattedData.map((entry, index) => ( - - ))} - - [ - `${value ?? 0} (${(((value ?? 0) / total) * 100).toFixed(1)}%)`, - name ?? '', - ]} - /> - - - +
+
diff --git a/src/components/observer/observer-dashboard-content.tsx b/src/components/observer/observer-dashboard-content.tsx index d6a7a1b..f669cc6 100644 --- a/src/components/observer/observer-dashboard-content.tsx +++ b/src/components/observer/observer-dashboard-content.tsx @@ -1,6 +1,7 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect } from 'react' +import Link from 'next/link' import { trpc } from '@/lib/trpc/client' import { Card, @@ -40,9 +41,13 @@ import { Search, ChevronLeft, ChevronRight, + ArrowUpDown, + ArrowUp, + ArrowDown, } from 'lucide-react' import { cn } from '@/lib/utils' import { AnimatedCard } from '@/components/shared/animated-container' +import { RoundTypeStatsCards } from '@/components/observer/round-type-stats' import { useDebouncedCallback } from 'use-debounce' const PER_PAGE_OPTIONS = [10, 20, 50] @@ -52,6 +57,8 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) { const [search, setSearch] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('') const [statusFilter, setStatusFilter] = useState('all') + const [sortBy, setSortBy] = useState<'title' | 'score' | 'evaluations'>('title') + const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc') const [page, setPage] = useState(1) const [perPage, setPerPage] = useState(20) @@ -75,18 +82,44 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) { setPage(1) } + const handleSort = (column: 'title' | 'score' | 'evaluations') => { + if (sortBy === column) { + setSortDir(sortDir === 'asc' ? 'desc' : 'asc') + } else { + setSortBy(column) + setSortDir(column === 'title' ? 'asc' : 'desc') + } + setPage(1) + } + + const SortIcon = ({ column }: { column: 'title' | 'score' | 'evaluations' }) => { + if (sortBy !== column) return + return sortDir === 'asc' + ? + : + } + // Fetch programs/rounds for the filter dropdown - const { data: programs } = trpc.program.list.useQuery({}) + const { data: programs } = trpc.program.list.useQuery({ includeStages: true }) const rounds = programs?.flatMap((p) => - (p.rounds ?? []).map((r: { id: string; name: string; status: string }) => ({ + (p.rounds ?? []).map((r: { id: string; name: string; status: string; roundType?: string }) => ({ id: r.id, name: r.name, programName: `${p.year} Edition`, status: r.status, + roundType: r.roundType, })) ) || [] + // Default to the active round + useEffect(() => { + if (rounds.length && selectedRoundId === 'all') { + const active = rounds.find((r) => r.status === 'ROUND_ACTIVE') + if (active) setSelectedRoundId(active.id) + } + }, [rounds.length]) // eslint-disable-line react-hooks/exhaustive-deps + // Fetch dashboard stats const roundIdParam = selectedRoundId !== 'all' ? selectedRoundId : undefined const { data: stats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery( @@ -98,18 +131,14 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) { roundId: roundIdParam, search: debouncedSearch || undefined, status: statusFilter !== 'all' ? statusFilter : undefined, + sortBy, + sortDir, page, perPage, }) - // Fetch recent rounds for jury completion - const { data: recentRoundsData } = trpc.program.list.useQuery({}) - const recentRounds = recentRoundsData?.flatMap((p) => - (p.rounds ?? []).map((r: { id: string; name: string; status: string }) => ({ - ...r, - programName: `${p.year} Edition`, - })) - )?.slice(0, 5) || [] + // Recent rounds for jury completion (reuse existing programs data) + const recentRounds = rounds.slice(0, 5) return (
@@ -152,7 +181,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) { All Rounds {rounds.map((round) => ( - {round.programName} - {round.name} + {round.programName} - {round.name}{round.roundType ? ` (${round.roundType.replace(/_/g, ' ')})` : ''} ))} @@ -175,83 +204,93 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) { ))}
) : stats ? ( -
- - - -
-
-

Programs

-

{stats.programCount}

-

- {stats.activeRoundCount} active round{stats.activeRoundCount !== 1 ? 's' : ''} -

-
-
- -
-
-
-
-
- - - - -
-
-

Projects

-

{stats.projectCount}

-

- {selectedRoundId !== 'all' ? 'In selected round' : 'Across all rounds'} -

-
-
- -
-
-
-
-
- - - - -
-
-

Jury Members

-

{stats.jurorCount}

-

Active members

-
-
- -
-
-
-
-
- - - - -
-
-

Evaluations

-

{stats.submittedEvaluations}

-
- -

- {stats.completionRate}% completion rate +

+ {/* Universal stats: Programs + Projects */} +
+ + + +
+
+

Programs

+

{stats.programCount}

+

+ {stats.activeRoundCount} active round{stats.activeRoundCount !== 1 ? 's' : ''}

+
+ +
-
- + + + + + + + +
+
+

Projects

+

{stats.projectCount}

+

+ {selectedRoundId !== 'all' ? 'In selected round' : 'Across all rounds'} +

+
+
+ +
-
-
-
-
+ + + +
+ + {/* Round-type-aware stats */} + {selectedRoundId !== 'all' ? ( + + ) : ( +
+ + + +
+
+

Jury Members

+

{stats.jurorCount}

+

Active members

+
+
+ +
+
+
+
+
+ + + + +
+
+

Evaluations

+

{stats.submittedEvaluations}

+
+ +

+ {stats.completionRate}% completion rate +

+
+
+
+ +
+
+
+
+
+
+ )}
) : null} @@ -320,12 +359,24 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
- Title + + + Team Round Status - Avg Score - Evaluations + + + + + + @@ -441,14 +492,24 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
{(() => { const maxCount = Math.max(...stats.scoreDistribution.map((b) => b.count), 1) - const colors = ['bg-green-500', 'bg-emerald-400', 'bg-amber-400', 'bg-orange-400', 'bg-red-400'] - return stats.scoreDistribution.map((bucket, i) => ( -
+ // Score-based colors: high scores = brand dark blue, low = brand red + const scoreColors: Record = { + '9-10': '#053d57', + '7-8': '#1e7a8a', + '5-6': '#557f8c', + '3-4': '#c4453a', + '1-2': '#de0f1e', + } + return stats.scoreDistribution.map((bucket) => ( +
{bucket.label}
0 ? (bucket.count / maxCount) * 100 : 0}%` }} + className="h-full rounded-full transition-all" + style={{ + width: `${maxCount > 0 ? (bucket.count / maxCount) * 100 : 0}%`, + backgroundColor: scoreColors[bucket.label] || '#557f8c', + }} />
{bucket.count} @@ -477,13 +538,19 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
{recentRounds.map((round) => ( -

{round.name}

+ {round.roundType && ( + + {round.roundType.replace(/_/g, ' ')} + + )}
-
-

Round details

-

- View analytics -

-
-
+ + ))}
diff --git a/src/components/observer/round-type-stats.tsx b/src/components/observer/round-type-stats.tsx new file mode 100644 index 0000000..a2e63bb --- /dev/null +++ b/src/components/observer/round-type-stats.tsx @@ -0,0 +1,158 @@ +'use client' + +import { trpc } from '@/lib/trpc/client' +import { Card, CardContent } from '@/components/ui/card' +import { AnimatedCard } from '@/components/shared/animated-container' +import { Skeleton } from '@/components/ui/skeleton' +import { + Inbox, + Filter, + ClipboardCheck, + Upload, + Users, + Presentation, + Vote, + CheckCircle2, + BarChart3, + FileText, + MessageSquare, + Lock, +} from 'lucide-react' +import type { LucideIcon } from 'lucide-react' + +interface StatCardData { + label: string + value: string | number + icon: LucideIcon + color: string +} + +function StatCard({ label, value, icon: Icon, color, index }: StatCardData & { index: number }) { + return ( + + +
+ +
+ +
+
+

{value}

+

{label}

+
+
+ + + ) +} + +interface RoundTypeStatsCardsProps { + roundId: string +} + +export function RoundTypeStatsCards({ roundId }: RoundTypeStatsCardsProps) { + const { data, isLoading } = trpc.analytics.getRoundTypeStats.useQuery( + { roundId }, + { enabled: !!roundId } + ) + + if (isLoading) { + return ( +
+ {[...Array(4)].map((_, i) => ( + + + + + + + ))} +
+ ) + } + + if (!data) return null + + const stats = data.stats as Record + + const cards: StatCardData[] = (() => { + switch (data.roundType) { + case 'INTAKE': + return [ + { label: 'Total Projects', value: (stats.totalProjects as number) ?? 0, icon: Inbox, color: '#053d57' }, + { label: 'States', value: ((stats.byState as Array)?.length ?? 0), icon: BarChart3, color: '#557f8c' }, + { label: 'Categories', value: ((stats.byCategory as Array)?.length ?? 0), icon: Filter, color: '#1e7a8a' }, + ] + + case 'FILTERING': + return [ + { label: 'Total Screened', value: (stats.totalScreened as number) ?? 0, icon: Filter, color: '#053d57' }, + { label: 'Passed', value: (stats.passed as number) ?? 0, icon: CheckCircle2, color: '#2d8659' }, + { label: 'Filtered Out', value: (stats.filteredOut as number) ?? 0, icon: Filter, color: '#de0f1e' }, + { label: 'Pass Rate', value: `${(stats.passRate as number) ?? 0}%`, icon: BarChart3, color: '#557f8c' }, + ] + + case 'EVALUATION': + return [ + { label: 'Assignments', value: (stats.totalAssignments as number) ?? 0, icon: ClipboardCheck, color: '#053d57' }, + { label: 'Completed', value: (stats.completedEvaluations as number) ?? 0, icon: CheckCircle2, color: '#2d8659' }, + { label: 'Completion Rate', value: `${(stats.completionRate as number) ?? 0}%`, icon: BarChart3, color: '#557f8c' }, + { label: 'Active Jurors', value: (stats.activeJurors as number) ?? 0, icon: Users, color: '#1e7a8a' }, + ] + + case 'SUBMISSION': + return [ + { label: 'Total Files', value: (stats.totalFiles as number) ?? 0, icon: Upload, color: '#053d57' }, + { label: 'Teams Submitted', value: (stats.teamsSubmitted as number) ?? 0, icon: FileText, color: '#557f8c' }, + ] + + case 'MENTORING': + return [ + { label: 'Mentor Assignments', value: (stats.mentorAssignments as number) ?? 0, icon: Users, color: '#053d57' }, + { label: 'Total Messages', value: (stats.totalMessages as number) ?? 0, icon: MessageSquare, color: '#557f8c' }, + ] + + case 'LIVE_FINAL': + return [ + { label: 'Session Status', value: formatSessionStatus((stats.sessionStatus as string) ?? 'NOT_STARTED'), icon: Presentation, color: '#053d57' }, + { label: 'Total Votes', value: (stats.voteCount as number) ?? 0, icon: Vote, color: '#de0f1e' }, + ] + + case 'DELIBERATION': + return [ + { label: 'Sessions', value: (stats.totalSessions as number) ?? 0, icon: Users, color: '#053d57' }, + { label: 'Votes Cast', value: (stats.totalVotes as number) ?? 0, icon: Vote, color: '#557f8c' }, + { label: 'Results Locked', value: (stats.resultsLocked as number) ?? 0, icon: Lock, color: '#2d8659' }, + ] + + default: + return [] + } + })() + + if (cards.length === 0) return null + + return ( +
+ {cards.map((card, i) => ( + + ))} +
+ ) +} + +function formatSessionStatus(status: string): string { + switch (status) { + case 'NOT_STARTED': return 'Not Started' + case 'IN_PROGRESS': return 'In Progress' + case 'PAUSED': return 'Paused' + case 'COMPLETED': return 'Completed' + default: return status + } +} diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx new file mode 100644 index 0000000..c19bea3 --- /dev/null +++ b/src/components/ui/toggle.tsx @@ -0,0 +1,45 @@ +"use client" + +import * as React from "react" +import * as TogglePrimitive from "@radix-ui/react-toggle" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const toggleVariants = cva( + "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 gap-2", + { + variants: { + variant: { + default: "bg-transparent", + outline: + "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-10 px-3 min-w-10", + sm: "h-9 px-2.5 min-w-9", + lg: "h-11 px-5 min-w-11", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +const Toggle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, ...props }, ref) => ( + +)) + +Toggle.displayName = TogglePrimitive.Root.displayName + +export { Toggle, toggleVariants } diff --git a/src/server/routers/analytics.ts b/src/server/routers/analytics.ts index 44292f7..5dc7258 100644 --- a/src/server/routers/analytics.ts +++ b/src/server/routers/analytics.ts @@ -121,7 +121,7 @@ export const analyticsRouter = router({ const assignments = await ctx.prisma.assignment.findMany({ where: assignmentWhere(input), include: { - user: { select: { name: true, email: true } }, + user: { select: { name: true } }, evaluation: { select: { id: true, status: true }, }, @@ -138,7 +138,7 @@ export const analyticsRouter = router({ const userId = assignment.userId if (!byUser[userId]) { byUser[userId] = { - name: assignment.user.name || assignment.user.email || 'Unknown', + name: assignment.user.name || 'Unknown', assigned: 0, completed: 0, } @@ -317,21 +317,24 @@ export const analyticsRouter = router({ return [] } - const criteriaMap = new Map() + // Build label → Set map so program-level queries match all IDs for the same criterion label + const labelToIds = new Map>() + const labelToFirst = new Map() evaluationForms.forEach((form) => { const criteria = form.criteriaJson as Array<{ id: string; label: string }> | null if (criteria) { criteria.forEach((c) => { - const key = input.roundId ? c.id : c.label - if (!criteriaMap.has(key)) { - criteriaMap.set(key, c) + if (!labelToIds.has(c.label)) { + labelToIds.set(c.label, new Set()) + labelToFirst.set(c.label, c) } + labelToIds.get(c.label)!.add(c.id) }) } }) - const criteria = Array.from(criteriaMap.values()) - if (criteria.length === 0) { + const criteriaLabels = Array.from(labelToFirst.values()) + if (criteriaLabels.length === 0) { return [] } @@ -341,17 +344,23 @@ export const analyticsRouter = router({ select: { criterionScoresJson: true }, }) - // Calculate average score per criterion - const criteriaScores = criteria.map((criterion) => { + // Calculate average score per criterion, checking ALL IDs that share the same label + const criteriaScores = criteriaLabels.map((criterion) => { const scores: number[] = [] + const ids = labelToIds.get(criterion.label) ?? new Set([criterion.id]) evaluations.forEach((evaluation) => { const criterionScoresJson = evaluation.criterionScoresJson as Record< string, number > | null - if (criterionScoresJson && typeof criterionScoresJson[criterion.id] === 'number') { - scores.push(criterionScoresJson[criterion.id]) + if (criterionScoresJson) { + for (const cid of ids) { + if (typeof criterionScoresJson[cid] === 'number') { + scores.push(criterionScoresJson[cid]) + break // Only count one score per evaluation per criterion + } + } } }) @@ -496,21 +505,20 @@ export const analyticsRouter = router({ include: { assignment: { include: { - user: { select: { id: true, name: true, email: true } }, + user: { select: { id: true, name: true } }, }, }, }, }) // Group scores by juror - const jurorScores: Record = {} + const jurorScores: Record = {} evaluations.forEach((e) => { const userId = e.assignment.userId if (!jurorScores[userId]) { jurorScores[userId] = { - name: e.assignment.user.name || e.assignment.user.email || 'Unknown', - email: e.assignment.user.email || '', + name: e.assignment.user.name || 'Unknown', scores: [], } } @@ -539,7 +547,6 @@ export const analyticsRouter = router({ return { userId, name: data.name, - email: data.email, evaluationCount: data.scores.length, averageScore: avg, stddev, @@ -731,7 +738,12 @@ export const analyticsRouter = router({ evaluationScores, ] = await Promise.all([ ctx.prisma.program.count(), - ctx.prisma.round.count({ where: { status: 'ROUND_ACTIVE' } }), + roundId + ? ctx.prisma.round.findUnique({ where: { id: roundId }, select: { competitionId: true } }) + .then((r) => r?.competitionId + ? ctx.prisma.round.count({ where: { competitionId: r.competitionId, status: 'ROUND_ACTIVE' } }) + : ctx.prisma.round.count({ where: { status: 'ROUND_ACTIVE' } })) + : ctx.prisma.round.count({ where: { status: 'ROUND_ACTIVE' } }), ctx.prisma.project.count({ where: projectFilter }), roundId ? ctx.prisma.assignment.findMany({ @@ -949,6 +961,8 @@ export const analyticsRouter = router({ roundId: z.string().optional(), search: z.string().optional(), status: z.string().optional(), + sortBy: z.enum(['title', 'score', 'evaluations']).default('title'), + sortDir: z.enum(['asc', 'desc']).default('asc'), page: z.number().min(1).default(1), perPage: z.number().min(1).max(100).default(20), }) @@ -971,6 +985,11 @@ export const analyticsRouter = router({ ] } + // Prisma-level sort for title; score/evaluations sorted post-query + const prismaOrderBy = input.sortBy === 'title' + ? { title: input.sortDir as 'asc' | 'desc' } + : { title: 'asc' as const } + const [projects, total] = await Promise.all([ ctx.prisma.project.findMany({ where, @@ -990,9 +1009,11 @@ export const analyticsRouter = router({ }, }, }, - orderBy: { title: 'asc' }, - skip: (input.page - 1) * input.perPage, - take: input.perPage, + orderBy: prismaOrderBy, + // When sorting by computed fields, fetch all then slice in JS + ...(input.sortBy === 'title' + ? { skip: (input.page - 1) * input.perPage, take: input.perPage } + : {}), }), ctx.prisma.project.count({ where }), ]) @@ -1009,7 +1030,10 @@ export const analyticsRouter = router({ ? scores.reduce((a, b) => a + b, 0) / scores.length : null - const firstAssignment = p.assignments[0] + // Filter assignments to the queried round so we show the correct round name + const roundAssignment = input.roundId + ? p.assignments.find((a) => a.roundId === input.roundId) + : p.assignments[0] return { id: p.id, @@ -1017,19 +1041,200 @@ export const analyticsRouter = router({ teamName: p.teamName, status: p.status, country: p.country, - roundId: firstAssignment?.round?.id ?? '', - roundName: firstAssignment?.round?.name ?? '', + roundId: roundAssignment?.round?.id ?? '', + roundName: roundAssignment?.round?.name ?? '', averageScore, evaluationCount: submitted.length, } }) + // Sort by computed fields (score, evaluations) in JS + let sorted = mapped + if (input.sortBy === 'score') { + sorted = mapped.sort((a, b) => { + const sa = a.averageScore ?? -1 + const sb = b.averageScore ?? -1 + return input.sortDir === 'asc' ? sa - sb : sb - sa + }) + } else if (input.sortBy === 'evaluations') { + sorted = mapped.sort((a, b) => + input.sortDir === 'asc' + ? a.evaluationCount - b.evaluationCount + : b.evaluationCount - a.evaluationCount + ) + } + + // Paginate in JS for computed-field sorts + const paginated = input.sortBy !== 'title' + ? sorted.slice((input.page - 1) * input.perPage, input.page * input.perPage) + : sorted + return { - projects: mapped, + projects: paginated, total, page: input.page, perPage: input.perPage, totalPages: Math.ceil(total / input.perPage), } }), + + /** + * Get round-type-aware stats for a specific round. + * Returns different metrics depending on the round type. + */ + getRoundTypeStats: observerProcedure + .input(z.object({ roundId: z.string() })) + .query(async ({ ctx, input }) => { + const round = await ctx.prisma.round.findUniqueOrThrow({ + where: { id: input.roundId }, + select: { id: true, roundType: true, competitionId: true }, + }) + + const roundType = round.roundType + + switch (roundType) { + case 'INTAKE': { + const [total, byState, byCategory] = await Promise.all([ + ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId } }), + ctx.prisma.projectRoundState.groupBy({ + by: ['state'], + where: { roundId: input.roundId }, + _count: true, + }), + ctx.prisma.project.groupBy({ + by: ['competitionCategory'], + where: { projectRoundStates: { some: { roundId: input.roundId } } }, + _count: true, + }), + ]) + return { + roundType, + stats: { + totalProjects: total, + byState: byState.map((s) => ({ state: s.state, count: s._count })), + byCategory: byCategory.map((c) => ({ + category: c.competitionCategory ?? 'Uncategorized', + count: c._count, + })), + }, + } + } + + case 'FILTERING': { + const [total, byOutcome] = await Promise.all([ + ctx.prisma.filteringResult.count({ where: { roundId: input.roundId } }), + ctx.prisma.filteringResult.groupBy({ + by: ['outcome'], + where: { roundId: input.roundId }, + _count: true, + }), + ]) + const passed = byOutcome.find((o) => o.outcome === 'PASSED')?._count ?? 0 + return { + roundType, + stats: { + totalScreened: total, + passed, + filteredOut: byOutcome.find((o) => o.outcome === 'FILTERED_OUT')?._count ?? 0, + flagged: byOutcome.find((o) => o.outcome === 'FLAGGED')?._count ?? 0, + passRate: total > 0 ? Math.round((passed / total) * 100) : 0, + }, + } + } + + case 'EVALUATION': { + const [assignmentCount, submittedCount, jurorCount] = await Promise.all([ + ctx.prisma.assignment.count({ where: { roundId: input.roundId } }), + ctx.prisma.evaluation.count({ + where: { assignment: { roundId: input.roundId }, status: 'SUBMITTED' }, + }), + ctx.prisma.assignment.findMany({ + where: { roundId: input.roundId }, + select: { userId: true }, + distinct: ['userId'], + }).then((rows) => rows.length), + ]) + return { + roundType, + stats: { + totalAssignments: assignmentCount, + completedEvaluations: submittedCount, + completionRate: assignmentCount > 0 ? Math.round((submittedCount / assignmentCount) * 100) : 0, + activeJurors: jurorCount, + }, + } + } + + case 'SUBMISSION': { + const [fileCount, teamsWithFiles] = await Promise.all([ + ctx.prisma.projectFile.count({ where: { roundId: input.roundId } }), + ctx.prisma.projectFile.findMany({ + where: { roundId: input.roundId }, + select: { projectId: true }, + distinct: ['projectId'], + }).then((rows) => rows.length), + ]) + return { + roundType, + stats: { + totalFiles: fileCount, + teamsSubmitted: teamsWithFiles, + }, + } + } + + case 'MENTORING': { + const [assignmentCount, messageCount] = await Promise.all([ + ctx.prisma.mentorAssignment.count({ + where: { project: { projectRoundStates: { some: { roundId: input.roundId } } } }, + }), + ctx.prisma.mentorMessage.count({ + where: { project: { projectRoundStates: { some: { roundId: input.roundId } } } }, + }), + ]) + return { + roundType, + stats: { + mentorAssignments: assignmentCount, + totalMessages: messageCount, + }, + } + } + + case 'LIVE_FINAL': { + const session = await ctx.prisma.liveVotingSession.findUnique({ + where: { roundId: input.roundId }, + select: { id: true, status: true, _count: { select: { votes: true } } }, + }) + return { + roundType, + stats: { + sessionStatus: session?.status ?? 'NOT_STARTED', + voteCount: session?._count.votes ?? 0, + }, + } + } + + case 'DELIBERATION': { + const [sessions, votes, locks] = await Promise.all([ + ctx.prisma.deliberationSession.count({ where: { roundId: input.roundId } }), + ctx.prisma.deliberationVote.count({ + where: { session: { roundId: input.roundId } }, + }), + ctx.prisma.resultLock.count({ where: { roundId: input.roundId } }), + ]) + return { + roundType, + stats: { + totalSessions: sessions, + totalVotes: votes, + resultsLocked: locks, + }, + } + } + + default: + return { roundType, stats: {} } + } + }), }) diff --git a/src/server/routers/program.ts b/src/server/routers/program.ts index 16eaf96..267e810 100644 --- a/src/server/routers/program.ts +++ b/src/server/routers/program.ts @@ -63,6 +63,7 @@ export const programRouter = router({ name: round.name, competitionId: round.competitionId, status: round.status, + roundType: round.roundType, votingEndAt: round.windowCloseAt, _count: { projects: round._count?.projectRoundStates || 0,