Observer platform overhaul: Nivo charts, round-type stats, UX improvements
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m29s

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 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-02-19 21:44:38 +01:00
parent 8ae8145d86
commit 9d945c33f9
18 changed files with 2095 additions and 1082 deletions

749
package-lock.json generated
View File

@@ -18,6 +18,11 @@
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@mantine/core": "^8.3.13", "@mantine/core": "^8.3.13",
"@mantine/hooks": "^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", "@notionhq/client": "^2.3.0",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-alert-dialog": "^1.1.4",
@@ -37,6 +42,7 @@
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-tooltip": "^1.1.6",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
@@ -72,7 +78,6 @@
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"react-phone-number-input": "^3.4.14", "react-phone-number-input": "^3.4.14",
"recharts": "^3.7.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"superjson": "^2.2.2", "superjson": "^2.2.2",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
@@ -1974,6 +1979,408 @@
"node": ">= 10" "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": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "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": { "node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
@@ -3507,40 +3939,76 @@
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
} }
}, },
"node_modules/@reduxjs/toolkit": { "node_modules/@react-spring/animated": {
"version": "2.11.2", "version": "10.0.3",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-10.0.3.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", "integrity": "sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@react-spring/shared": "~10.0.3",
"@standard-schema/utils": "^0.3.0", "@react-spring/types": "~10.0.3"
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
} }
}, },
"node_modules/@reduxjs/toolkit/node_modules/immer": { "node_modules/@react-spring/core": {
"version": "11.1.3", "version": "10.0.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz", "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-10.0.3.tgz",
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==", "integrity": "sha512-D4DwNO68oohDf/0HG2G0Uragzb9IA1oXblxrd6MZAcBcUQG2EHUWXewjdECMPLNmQvlYVyyBRH6gPxXM5DX7DQ==",
"license": "MIT", "license": "MIT",
"dependencies": {
"@react-spring/animated": "~10.0.3",
"@react-spring/shared": "~10.0.3",
"@react-spring/types": "~10.0.3"
},
"funding": { "funding": {
"type": "opencollective", "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": { "node_modules/@remirror/core-constants": {
@@ -3933,12 +4401,7 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT" "devOptional": true,
},
"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==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
@@ -4570,22 +5033,22 @@
"assertion-error": "^2.0.1" "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": { "node_modules/@types/d3-color": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-ease": { "node_modules/@types/d3-delaunay": {
"version": "3.0.2", "version": "6.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", "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" "license": "MIT"
}, },
"node_modules/@types/d3-interpolate": { "node_modules/@types/d3-interpolate": {
@@ -4612,6 +5075,12 @@
"@types/d3-time": "*" "@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": { "node_modules/@types/d3-shape": {
"version": "3.1.8", "version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
@@ -4627,10 +5096,10 @@
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-timer": { "node_modules/@types/d3-time-format": {
"version": "3.0.2", "version": "2.3.4",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.3.4.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "integrity": "sha512-xdDXbpVO74EvadI3UDxjxTdR6QIxm1FKzEA/+F8tL4GWWUg/hgvBqf6chql64U5A9ZUGWo7pEu4eNlyLwbKdhg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/debug": { "node_modules/@types/debug": {
@@ -6363,11 +6832,14 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-ease": { "node_modules/d3-delaunay": {
"version": "3.0.1", "version": "6.0.4",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
"license": "BSD-3-Clause", "license": "ISC",
"dependencies": {
"delaunator": "5"
},
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@@ -6418,6 +6890,19 @@
"node": ">=12" "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": { "node_modules/d3-shape": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
@@ -6454,15 +6939,6 @@
"node": ">=12" "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": { "node_modules/damerau-levenshtein": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "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": { "node_modules/decode-named-character-reference": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@@ -6644,6 +7114,15 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -7001,16 +7480,6 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/esbuild": {
"version": "0.27.2", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
@@ -8444,16 +8913,6 @@
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT" "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": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -12037,29 +12496,6 @@
"react-dom": ">=16.8" "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": { "node_modules/react-remove-scroll": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", "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" "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": { "node_modules/readable-stream": {
"version": "3.6.2", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@@ -12174,51 +12620,6 @@
"url": "https://paulmillr.com/funding/" "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": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -12411,12 +12812,6 @@
"url": "https://opencollective.com/unified" "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": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -12479,6 +12874,12 @@
"node": ">= 0.8.15" "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": { "node_modules/rollup": {
"version": "4.57.0", "version": "4.57.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz",
@@ -13279,12 +13680,6 @@
"readable-stream": "3" "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": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -13960,28 +14355,6 @@
"url": "https://opencollective.com/unified" "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": { "node_modules/vite": {
"version": "7.3.1", "version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",

View File

@@ -31,6 +31,11 @@
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@mantine/core": "^8.3.13", "@mantine/core": "^8.3.13",
"@mantine/hooks": "^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", "@notionhq/client": "^2.3.0",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-alert-dialog": "^1.1.4",
@@ -50,6 +55,7 @@
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-tooltip": "^1.1.6",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
@@ -85,7 +91,6 @@
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"react-phone-number-input": "^3.4.14", "react-phone-number-input": "^3.4.14",
"recharts": "^3.7.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"superjson": "^2.2.2", "superjson": "^2.2.2",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { import {
Card, Card,
@@ -10,6 +11,7 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Toggle } from '@/components/ui/toggle'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { import {
@@ -61,11 +63,21 @@ function parseSelection(value: string | null): { roundId?: string; programId?: s
return { roundId: value } return { roundId: value }
} }
const ROUND_TYPE_LABELS: Record<string, string> = {
INTAKE: 'Intake',
FILTERING: 'Filtering',
EVALUATION: 'Evaluation',
SUBMISSION: 'Submission',
MENTORING: 'Mentoring',
LIVE_FINAL: 'Live Final',
DELIBERATION: 'Deliberation',
}
function OverviewTab({ selectedValue }: { selectedValue: string | null }) { function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true }) const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true })
const stages = programs?.flatMap(p => 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, ...s,
programName: `${p.year} Edition`, 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 ?? 0
const totalProjects = overviewStats?.projectCount ?? stages.reduce((acc, s) => acc + (s._count?.projects || 0), 0) const activeRounds = stages.filter((s) => s.status === 'ROUND_ACTIVE').length
const activeStages = stages.filter((s) => s.status === 'ROUND_ACTIVE').length
const totalPrograms = programs?.length || 0 const totalPrograms = programs?.length || 0
return ( return (
@@ -114,10 +125,10 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
<CardContent className="p-5"> <CardContent className="p-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-muted-foreground">Total Stages</p> <p className="text-sm font-medium text-muted-foreground">Total Rounds</p>
<p className="text-2xl font-bold mt-1">{stages.length}</p> <p className="text-2xl font-bold mt-1">{stages.length}</p>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
{activeStages} active {activeRounds} active
</p> </p>
</div> </div>
<div className="rounded-xl bg-blue-50 p-3"> <div className="rounded-xl bg-blue-50 p-3">
@@ -135,7 +146,7 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
<div> <div>
<p className="text-sm font-medium text-muted-foreground">Total Projects</p> <p className="text-sm font-medium text-muted-foreground">Total Projects</p>
<p className="text-2xl font-bold mt-1">{totalProjects}</p> <p className="text-2xl font-bold mt-1">{totalProjects}</p>
<p className="text-xs text-muted-foreground mt-1">Across all stages</p> <p className="text-xs text-muted-foreground mt-1">Across all rounds</p>
</div> </div>
<div className="rounded-xl bg-emerald-50 p-3"> <div className="rounded-xl bg-emerald-50 p-3">
<ClipboardList className="h-5 w-5 text-emerald-600" /> <ClipboardList className="h-5 w-5 text-emerald-600" />
@@ -150,8 +161,8 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
<CardContent className="p-5"> <CardContent className="p-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-muted-foreground">Active Stages</p> <p className="text-sm font-medium text-muted-foreground">Active Rounds</p>
<p className="text-2xl font-bold mt-1">{activeStages}</p> <p className="text-2xl font-bold mt-1">{activeRounds}</p>
<p className="text-xs text-muted-foreground mt-1">Currently active</p> <p className="text-xs text-muted-foreground mt-1">Currently active</p>
</div> </div>
<div className="rounded-xl bg-violet-50 p-3"> <div className="rounded-xl bg-violet-50 p-3">
@@ -255,14 +266,14 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
{/* Stages Table - Desktop */} {/* Stages Table - Desktop */}
<Card className="hidden md:block"> <Card className="hidden md:block">
<CardHeader> <CardHeader>
<CardTitle>Stage Reports</CardTitle> <CardTitle>Round Reports</CardTitle>
<CardDescription>Progress overview for each stage</CardDescription> <CardDescription>Progress overview for each round</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Stage</TableHead> <TableHead>Round</TableHead>
<TableHead>Program</TableHead> <TableHead>Program</TableHead>
<TableHead>Projects</TableHead> <TableHead>Projects</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
@@ -305,7 +316,7 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
{/* Stages Cards - Mobile */} {/* Stages Cards - Mobile */}
<div className="space-y-4 md:hidden"> <div className="space-y-4 md:hidden">
<h2 className="text-lg font-semibold">Stage Reports</h2> <h2 className="text-lg font-semibold">Round Reports</h2>
{stages.map((stage) => ( {stages.map((stage) => (
<Card key={stage.id}> <Card key={stage.id}>
<CardContent className="pt-4 space-y-3"> <CardContent className="pt-4 space-y-3">
@@ -485,25 +496,27 @@ function CrossStageTab() {
<div className="space-y-6"> <div className="space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Select Stages to Compare</CardTitle> <CardTitle>Select Rounds to Compare</CardTitle>
<CardDescription>Choose at least 2 stages</CardDescription> <CardDescription>Choose at least 2 rounds</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2" role="group" aria-label="Select rounds to compare">
{stages.map((stage) => ( {stages.map((stage) => (
<Badge <Toggle
key={stage.id} key={stage.id}
variant={selectedRoundIds.includes(stage.id) ? 'default' : 'outline'} variant="outline"
className="cursor-pointer text-sm py-1.5 px-3" size="sm"
onClick={() => toggleRound(stage.id)} pressed={selectedRoundIds.includes(stage.id)}
onPressedChange={() => toggleRound(stage.id)}
aria-label={`${stage.programName} - ${stage.name}`}
> >
{stage.programName} - {stage.name} {stage.programName} - {stage.name}
</Badge> </Toggle>
))} ))}
</div> </div>
{selectedRoundIds.length < 2 && ( {selectedRoundIds.length < 2 && (
<p className="text-sm text-muted-foreground mt-3"> <p className="text-sm text-muted-foreground mt-3">
Select at least 2 stages to enable comparison Select at least 2 rounds to enable comparison
</p> </p>
)} )}
</CardContent> </CardContent>
@@ -541,7 +554,7 @@ function JurorConsistencyTab({ selectedValue }: { selectedValue: string }) {
data={consistency as { data={consistency as {
overallAverage: number overallAverage: number
jurors: Array<{ jurors: Array<{
userId: string; name: string; email: string userId: string; name: string
evaluationCount: number; averageScore: number evaluationCount: number; averageScore: number
stddev: number; deviationFromOverall: number; isOutlier: boolean stddev: number; deviationFromOverall: number; isOutlier: boolean
}> }>
@@ -578,19 +591,21 @@ function DiversityTab({ selectedValue }: { selectedValue: string }) {
} }
export default function ObserverReportsPage() { export default function ObserverReportsPage() {
const [selectedValue, setSelectedValue] = useState<string | null>(null) const searchParams = useSearchParams()
const roundFromUrl = searchParams.get('round')
const [selectedValue, setSelectedValue] = useState<string | null>(roundFromUrl)
const { data: programs, isLoading: stagesLoading } = trpc.program.list.useQuery({ includeStages: true }) const { data: programs, isLoading: stagesLoading } = trpc.program.list.useQuery({ includeStages: true })
const stages = programs?.flatMap(p => 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, ...s,
programId: p.id, programId: p.id,
programName: `${p.year} Edition`, 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(() => { useEffect(() => {
if (stages.length && !selectedValue) { if (stages.length && !selectedValue) {
const active = stages.find((s) => s.status === 'ROUND_ACTIVE') const active = stages.find((s) => s.status === 'ROUND_ACTIVE')
@@ -613,29 +628,29 @@ export default function ObserverReportsPage() {
{/* Stage Selector */} {/* Stage Selector */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<label className="text-sm font-medium">Select Stage:</label> <label className="text-sm font-medium">Select Round:</label>
{stagesLoading ? ( {stagesLoading ? (
<Skeleton className="h-10 w-full sm:w-[300px]" /> <Skeleton className="h-10 w-full sm:w-[300px]" />
) : stages.length > 0 ? ( ) : stages.length > 0 ? (
<Select value={selectedValue || ''} onValueChange={setSelectedValue}> <Select value={selectedValue || ''} onValueChange={setSelectedValue}>
<SelectTrigger className="w-full sm:w-[300px]"> <SelectTrigger className="w-full sm:w-[300px]">
<SelectValue placeholder="Select a stage" /> <SelectValue placeholder="Select a round" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{programs?.map((p) => ( {programs?.map((p) => (
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}> <SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
{p.year} Edition All Stages {p.year} Edition All Rounds
</SelectItem> </SelectItem>
))} ))}
{stages.map((stage) => ( {stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}> <SelectItem key={stage.id} value={stage.id}>
{stage.programName} - {stage.name} {stage.programName} - {stage.name}{stage.roundType ? ` (${ROUND_TYPE_LABELS[stage.roundType] || stage.roundType})` : ''}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
) : ( ) : (
<p className="text-sm text-muted-foreground">No stages available</p> <p className="text-sm text-muted-foreground">No rounds available</p>
)} )}
</div> </div>

View File

@@ -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<string, string> = {
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<string, string> = {
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, ' ')
}

View File

@@ -1,16 +1,8 @@
'use client' 'use client'
import { import { ResponsiveBar } from '@nivo/bar'
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { nivoTheme, scoreGradient } from './chart-theme'
interface CriteriaScoreData { interface CriteriaScoreData {
id: string id: string
@@ -23,27 +15,27 @@ interface CriteriaScoresProps {
data: CriteriaScoreData[] data: CriteriaScoreData[]
} }
// Color scale from red to green based on score type CriterionBarDatum = {
const getScoreColor = (score: number): string => { criterion: string
if (score >= 8) return '#0bd90f' // Excellent - green averageScore: number
if (score >= 6) return '#82ca9d' // Good - light green fullName: string
if (score >= 4) return '#ffc658' // Average - yellow count: number
if (score >= 2) return '#ff7300' // Poor - orange
return '#de0f1e' // Very poor - red
} }
export function CriteriaScoresChart({ data }: CriteriaScoresProps) { 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 = const overallAverage =
data.length > 0 data.length > 0
? data.reduce((sum, d) => sum + d.averageScore, 0) / data.length ? data.reduce((sum, d) => sum + d.averageScore, 0) / data.length
: 0 : 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 ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -55,50 +47,54 @@ export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-[300px]"> <div style={{ height: '300px' }}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveBar
<BarChart data={chartData}
data={formattedData} keys={['averageScore']}
margin={{ top: 20, right: 30, bottom: 60, left: 20 }} indexBy="criterion"
> theme={nivoTheme}
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> colors={(bar) =>
<XAxis scoreGradient(bar.data.averageScore as number)
dataKey="displayName" }
tick={{ fontSize: 11 }} valueScale={{ type: 'linear', max: 10 }}
angle={-45} borderRadius={4}
textAnchor="end" enableLabel={true}
interval={0} label={(d) => {
height={60} const v = d.value
/> return v != null ? Number(v).toFixed(1) : ''
<YAxis domain={[0, 10]} /> }}
<Tooltip labelSkipHeight={12}
contentStyle={{ labelTextColor="#ffffff"
backgroundColor: 'hsl(var(--card))', axisBottom={{
border: '1px solid hsl(var(--border))', tickRotation: -45,
borderRadius: '6px', }}
axisLeft={{
legend: 'Score',
legendPosition: 'middle',
legendOffset: -40,
}}
margin={{ top: 20, right: 20, bottom: 80, left: 50 }}
padding={0.25}
tooltip={({ data: rowData }) => (
<div
style={{
background: '#ffffff',
padding: '8px 12px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
border: '1px solid #e5e7eb',
fontSize: 12,
}} }}
formatter={(value: number | undefined) => [ >
(value ?? 0).toFixed(2), <strong>{rowData.fullName}</strong>
'Average Score', <br />
]} Average Score: {Number(rowData.averageScore).toFixed(2)}
labelFormatter={(_, payload) => { <br />
if (payload && payload[0]) { Ratings: {rowData.count}
const item = payload[0].payload as CriteriaScoreData </div>
return `${item.name} (${item.count} ratings)` )}
} animate={true}
return '' />
}}
/>
<Bar dataKey="averageScore" radius={[4, 4, 0, 0]}>
{formattedData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={getScoreColor(entry.averageScore)}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,16 +1,8 @@
'use client' 'use client'
import { import { ResponsiveBar } from '@nivo/bar'
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { nivoTheme, BRAND_COLORS } from './chart-theme'
interface StageComparison { interface StageComparison {
roundId: string roundId: string
@@ -26,128 +18,152 @@ interface CrossStageComparisonProps {
data: StageComparison[] data: StageComparison[]
} }
const STAGE_COLORS = ['#053d57', '#de0f1e', '#557f8c', '#f38a52', '#6ad82f'] export function CrossStageComparisonChart({
data,
export function CrossStageComparisonChart({ data }: CrossStageComparisonProps) { }: CrossStageComparisonProps) {
// Prepare comparison data const baseData = data.map((round) => ({
const comparisonData = data.map((stage, i) => ({ name:
name: stage.roundName.length > 20 ? stage.roundName.slice(0, 20) + '...' : stage.roundName, round.roundName.length > 20
projects: stage.projectCount, ? round.roundName.slice(0, 20) + '...'
evaluations: stage.evaluationCount, : round.roundName,
completionRate: stage.completionRate, projects: round.projectCount,
avgScore: stage.averageScore ? parseFloat(stage.averageScore.toFixed(2)) : 0, evaluations: round.evaluationCount,
color: STAGE_COLORS[i % STAGE_COLORS.length], completionRate: round.completionRate,
avgScore: round.averageScore
? parseFloat(round.averageScore.toFixed(2))
: 0,
})) }))
const sharedMargin = { top: 10, right: 10, bottom: 40, left: 40 }
return ( return (
<div className="space-y-6"> <Card>
{/* Metrics Comparison */} <CardHeader>
<Card> <CardTitle>Round Metrics Comparison</CardTitle>
<CardHeader> </CardHeader>
<CardTitle>Stage Metrics Comparison</CardTitle> <CardContent>
</CardHeader> <div className="grid grid-cols-2 gap-4">
<CardContent> <Card>
<div className="h-[350px]"> <CardHeader className="pb-2">
<ResponsiveContainer width="100%" height="100%"> <CardTitle className="text-sm font-medium">Projects</CardTitle>
<BarChart </CardHeader>
data={comparisonData} <CardContent className="pt-0">
margin={{ top: 20, right: 30, bottom: 60, left: 20 }} <div style={{ height: '200px' }}>
> <ResponsiveBar
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> data={baseData}
<XAxis keys={['projects']}
dataKey="name" indexBy="name"
angle={-25} theme={nivoTheme}
textAnchor="end" colors={[BRAND_COLORS[0]]}
height={60} borderRadius={4}
tick={{ fontSize: 12 }} enableLabel={true}
/> labelSkipHeight={12}
<YAxis /> labelTextColor="#ffffff"
<Tooltip margin={sharedMargin}
contentStyle={{ padding={0.3}
backgroundColor: 'hsl(var(--card))', axisBottom={{
border: '1px solid hsl(var(--border))', tickRotation: -25,
borderRadius: '6px',
}} }}
animate={true}
/> />
<Legend /> </div>
<Bar dataKey="projects" name="Projects" fill="#053d57" radius={[4, 4, 0, 0]} /> </CardContent>
<Bar dataKey="evaluations" name="Evaluations" fill="#557f8c" radius={[4, 4, 0, 0]} /> </Card>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Completion & Score Comparison */} <Card>
<div className="grid gap-6 lg:grid-cols-2"> <CardHeader className="pb-2">
<Card> <CardTitle className="text-sm font-medium">
<CardHeader> Evaluations
<CardTitle>Completion Rate by Stage</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="pt-0">
<div className="h-[300px]"> <div style={{ height: '200px' }}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveBar
<BarChart data={baseData}
data={comparisonData} keys={['evaluations']}
margin={{ top: 20, right: 20, bottom: 60, left: 20 }} indexBy="name"
> theme={nivoTheme}
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> colors={[BRAND_COLORS[2]]}
<XAxis borderRadius={4}
dataKey="name" enableLabel={true}
angle={-25} labelSkipHeight={12}
textAnchor="end" labelTextColor="#ffffff"
height={60} margin={sharedMargin}
tick={{ fontSize: 12 }} padding={0.3}
/> axisBottom={{
<YAxis domain={[0, 100]} unit="%" /> tickRotation: -25,
<Tooltip }}
contentStyle={{ animate={true}
backgroundColor: 'hsl(var(--card))', />
border: '1px solid hsl(var(--border))', </div>
borderRadius: '6px', </CardContent>
}} </Card>
/>
<Bar dataKey="completionRate" name="Completion %" fill="#6ad82f" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader className="pb-2">
<CardTitle>Average Score by Stage</CardTitle> <CardTitle className="text-sm font-medium">
</CardHeader> Completion Rate
<CardContent> </CardTitle>
<div className="h-[300px]"> </CardHeader>
<ResponsiveContainer width="100%" height="100%"> <CardContent className="pt-0">
<BarChart <div style={{ height: '200px' }}>
data={comparisonData} <ResponsiveBar
margin={{ top: 20, right: 20, bottom: 60, left: 20 }} data={baseData}
> keys={['completionRate']}
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> indexBy="name"
<XAxis theme={nivoTheme}
dataKey="name" colors={[BRAND_COLORS[1]]}
angle={-25} valueScale={{ type: 'linear', max: 100 }}
textAnchor="end" borderRadius={4}
height={60} enableLabel={true}
tick={{ fontSize: 12 }} labelSkipHeight={12}
/> labelTextColor="#ffffff"
<YAxis domain={[0, 10]} /> valueFormat={(v) => `${v}%`}
<Tooltip margin={sharedMargin}
contentStyle={{ padding={0.3}
backgroundColor: 'hsl(var(--card))', axisBottom={{
border: '1px solid hsl(var(--border))', tickRotation: -25,
borderRadius: '6px', }}
}} axisLeft={{
/> format: (v) => `${v}%`,
<Bar dataKey="avgScore" name="Avg Score" fill="#de0f1e" radius={[4, 4, 0, 0]} /> }}
</BarChart> animate={true}
</ResponsiveContainer> />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div>
</div> <Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Average Score
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div style={{ height: '200px' }}>
<ResponsiveBar
data={baseData}
keys={['avgScore']}
indexBy="name"
theme={nivoTheme}
colors={[BRAND_COLORS[0]]}
valueScale={{ type: 'linear', max: 10 }}
borderRadius={4}
enableLabel={true}
labelSkipHeight={12}
labelTextColor="#ffffff"
margin={sharedMargin}
padding={0.3}
axisBottom={{
tickRotation: -25,
}}
animate={true}
/>
</div>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
) )
} }

View File

@@ -1,20 +1,10 @@
'use client' 'use client'
import { import { ResponsivePie } from '@nivo/pie'
PieChart, import { ResponsiveBar } from '@nivo/bar'
Pie,
Cell,
Tooltip,
ResponsiveContainer,
Legend,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { nivoTheme, BRAND_COLORS } from './chart-theme'
interface DiversityData { interface DiversityData {
total: number total: number
@@ -28,12 +18,6 @@ interface DiversityMetricsProps {
data: DiversityData 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 */ /** Convert ISO 3166-1 alpha-2 code to full country name using Intl API */
function getCountryName(code: string): string { function getCountryName(code: string): string {
if (code === 'Others') return 'Others' if (code === 'Others') return 'Others'
@@ -54,33 +38,6 @@ function formatLabel(value: string): string {
.replace(/\b\w/g, (c) => c.toUpperCase()) .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 (
<div className="rounded-md border bg-card px-3 py-2 text-sm shadow-md">
<p className="font-medium">{getCountryName(d.country)}</p>
<p className="text-muted-foreground">{d.count} projects ({d.percentage.toFixed(1)}%)</p>
</div>
)
}
/** 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<string, unknown> }
const dataPoint = rawPayload.payload
const rawLabel = (dataPoint.category || dataPoint.issue || '') as string
return (
<div className="rounded-md border bg-card px-3 py-2 text-sm shadow-md">
<p className="font-medium">{labelFormatter(rawLabel)}</p>
<p className="text-muted-foreground">{entry.value} projects</p>
</div>
)
}
export function DiversityMetricsChart({ data }: DiversityMetricsProps) { export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
if (data.total === 0) { if (data.total === 0) {
return ( return (
@@ -103,15 +60,21 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
}] }]
: topCountries : 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 // Pre-format category and ocean issue data for display
const formattedCategories = data.byCategory.slice(0, 10).map((c) => ({ const formattedCategories = data.byCategory.slice(0, 10).map((c) => ({
...c,
category: formatLabel(c.category), category: formatLabel(c.category),
count: c.count,
})) }))
const formattedOceanIssues = data.byOceanIssue.slice(0, 15).map((o) => ({ const formattedOceanIssues = data.byOceanIssue.slice(0, 15).map((o) => ({
...o,
issue: formatLabel(o.issue), issue: formatLabel(o.issue),
count: o.count,
})) }))
return ( return (
@@ -151,35 +114,42 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
<CardTitle>Geographic Distribution</CardTitle> <CardTitle>Geographic Distribution</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-[400px]"> <div style={{ height: '400px' }}>
<ResponsiveContainer width="100%" height="100%"> <ResponsivePie
<PieChart> data={nivoPieData}
<Pie theme={nivoTheme}
data={countryPieData} colors={[...BRAND_COLORS]}
cx="50%" innerRadius={0.4}
cy="50%" padAngle={0.5}
innerRadius={60} cornerRadius={3}
outerRadius={120} activeOuterRadiusOffset={8}
paddingAngle={2} margin={{ top: 40, right: 80, bottom: 80, left: 80 }}
dataKey="count" enableArcLinkLabels={true}
nameKey="country" arcLinkLabelsSkipAngle={10}
label={((props: unknown) => { arcLinkLabelsTextColor="#374151"
const p = props as { country: string; percentage: number } arcLinkLabelsThickness={2}
return `${getCountryName(p.country)} (${p.percentage.toFixed(0)}%)` arcLinkLabelsColor={{ from: 'color' }}
}) as unknown as boolean} enableArcLabels={true}
fontSize={13} arcLabelsSkipAngle={10}
> arcLabelsTextColor={{ from: 'color', modifiers: [['darker', 2]] }}
{countryPieData.map((_, index) => ( legends={[
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} /> {
))} anchor: 'bottom',
</Pie> direction: 'row',
<Tooltip content={<CountryTooltip />} /> justify: false,
<Legend translateX: 0,
formatter={(value: string) => getCountryName(value)} translateY: 56,
wrapperStyle={{ fontSize: '13px' }} itemsSpacing: 0,
/> itemWidth: 100,
</PieChart> itemHeight: 18,
</ResponsiveContainer> itemTextColor: '#374151',
itemDirection: 'left-to-right',
itemOpacity: 1,
symbolSize: 12,
symbolShape: 'circle',
},
]}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -191,25 +161,27 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{formattedCategories.length > 0 ? ( {formattedCategories.length > 0 ? (
<div className="h-[400px]"> <div style={{ height: '400px' }}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveBar
<BarChart data={formattedCategories}
data={formattedCategories} theme={nivoTheme}
layout="vertical" keys={['count']}
margin={{ top: 5, right: 30, bottom: 5, left: 120 }} indexBy="category"
> layout="horizontal"
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> colors={[BRAND_COLORS[0]]}
<XAxis type="number" tick={{ fontSize: 13 }} /> borderRadius={4}
<YAxis margin={{ top: 10, right: 30, bottom: 10, left: 120 }}
type="category" padding={0.3}
dataKey="category" enableLabel={true}
width={110} labelTextColor="#ffffff"
tick={{ fontSize: 13 }} enableGridX={true}
/> enableGridY={false}
<Tooltip content={<BarTooltip labelFormatter={(v) => v} />} /> axisBottom={null}
<Bar dataKey="count" fill="#053d57" radius={[0, 4, 4, 0]} /> axisLeft={{
</BarChart> tickSize: 0,
</ResponsiveContainer> tickPadding: 8,
}}
/>
</div> </div>
) : ( ) : (
<p className="text-muted-foreground text-center py-8">No category data</p> <p className="text-muted-foreground text-center py-8">No category data</p>
@@ -225,26 +197,31 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
<CardTitle>Ocean Issues Addressed</CardTitle> <CardTitle>Ocean Issues Addressed</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-[400px]"> <div style={{ height: '400px' }}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveBar
<BarChart data={formattedOceanIssues}
data={formattedOceanIssues} theme={nivoTheme}
margin={{ top: 20, right: 30, bottom: 80, left: 20 }} keys={['count']}
> indexBy="issue"
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> layout="vertical"
<XAxis colors={[BRAND_COLORS[2]]}
dataKey="issue" borderRadius={4}
angle={-35} margin={{ top: 20, right: 30, bottom: 80, left: 40 }}
textAnchor="end" padding={0.3}
height={100} enableLabel={true}
tick={{ fontSize: 12 }} labelTextColor="#ffffff"
interval={0} enableGridX={false}
/> enableGridY={true}
<YAxis tick={{ fontSize: 13 }} /> axisBottom={{
<Tooltip content={<BarTooltip labelFormatter={(v) => v} />} /> tickSize: 0,
<Bar dataKey="count" fill="#557f8c" radius={[4, 4, 0, 0]} /> tickPadding: 8,
</BarChart> tickRotation: -35,
</ResponsiveContainer> }}
axisLeft={{
tickSize: 0,
tickPadding: 8,
}}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,19 +1,8 @@
'use client' 'use client'
import { import { ResponsiveLine } from '@nivo/line'
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
Area,
ComposedChart,
Bar,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { nivoTheme, BRAND_DARK_BLUE } from './chart-theme'
interface TimelineDataPoint { interface TimelineDataPoint {
date: string date: string
@@ -26,7 +15,6 @@ interface EvaluationTimelineProps {
} }
export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) { export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
// Format date for display
const formattedData = data.map((d) => ({ const formattedData = data.map((d) => ({
...d, ...d,
dateFormatted: new Date(d.date).toLocaleDateString('en-US', { dateFormatted: new Date(d.date).toLocaleDateString('en-US', {
@@ -38,6 +26,16 @@ export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
const totalEvaluations = const totalEvaluations =
data.length > 0 ? data[data.length - 1].cumulative : 0 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 ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -49,52 +47,55 @@ export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-[300px]"> <div style={{ height: '300px' }}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveLine
<ComposedChart data={lineData}
data={formattedData} theme={nivoTheme}
margin={{ top: 20, right: 30, bottom: 20, left: 20 }} colors={[BRAND_DARK_BLUE]}
> enableArea={true}
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> areaOpacity={0.1}
<XAxis areaBaselineValue={0}
dataKey="dateFormatted" curve="monotoneX"
tick={{ fontSize: 12 }} pointSize={6}
interval="preserveStartEnd" pointColor={BRAND_DARK_BLUE}
/> pointBorderWidth={2}
<YAxis yAxisId="left" orientation="left" stroke="#8884d8" /> pointBorderColor="#ffffff"
<YAxis yAxisId="right" orientation="right" stroke="#82ca9d" /> useMesh={true}
<Tooltip enableSlices="x"
contentStyle={{ sliceTooltip={({ slice }) => {
backgroundColor: 'hsl(var(--card))', const point = slice.points[0]
border: '1px solid hsl(var(--border))', const dataItem = formattedData.find(
borderRadius: '6px', (d) => d.dateFormatted === point.data.xFormatted
}} )
formatter={(value: number | undefined, name: string | undefined) => [ return (
value ?? 0, <div
(name ?? '') === 'daily' ? 'Daily' : 'Cumulative', style={{
]} background: '#fff',
labelFormatter={(label) => `Date: ${label}`} padding: '8px 12px',
/> border: '1px solid #e5e7eb',
<Legend /> borderRadius: '8px',
<Bar boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
yAxisId="left" }}
dataKey="daily" >
name="Daily Evaluations" <strong>{point.data.xFormatted}</strong>
fill="#8884d8" <div>Cumulative: {point.data.yFormatted}</div>
radius={[4, 4, 0, 0]} {dataItem && <div>Daily: {dataItem.daily}</div>}
/> </div>
<Line )
yAxisId="right" }}
type="monotone" margin={{ top: 20, right: 20, bottom: 50, left: 60 }}
dataKey="cumulative" axisBottom={{
name="Cumulative Total" tickRotation: -45,
stroke="#82ca9d" legend: '',
strokeWidth={2} legendOffset: 36,
dot={{ r: 3 }} }}
activeDot={{ r: 6 }} axisLeft={{
/> legend: 'Evaluations',
</ComposedChart> legendOffset: -50,
</ResponsiveContainer> legendPosition: 'middle',
}}
yScale={{ type: 'linear', min: 0, max: 'auto' }}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,15 +1,11 @@
'use client' 'use client'
import { import { ResponsiveScatterPlot } from '@nivo/scatterplot'
ScatterChart, import type {
Scatter, ScatterPlotDatum,
XAxis, ScatterPlotNodeProps,
YAxis, } from '@nivo/scatterplot'
CartesianGrid, import { animated } from '@react-spring/web'
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { import {
@@ -21,11 +17,11 @@ import {
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { AlertTriangle } from 'lucide-react' import { AlertTriangle } from 'lucide-react'
import { nivoTheme, BRAND_DARK_BLUE, BRAND_RED } from './chart-theme'
interface JurorMetric { interface JurorMetric {
userId: string userId: string
name: string name: string
email: string
evaluationCount: number evaluationCount: number
averageScore: number averageScore: number
stddev: 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<JurorDatum>) {
const fillColor = node.data.isOutlier ? BRAND_RED : BRAND_DARK_BLUE
return (
<animated.circle
cx={style.x}
cy={style.y}
r={style.size.to((s: number) => 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) { export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
const scatterData = data.jurors.map((j) => ({ const scatterData = [
name: j.name, {
avgScore: parseFloat(j.averageScore.toFixed(2)), id: 'Jurors',
stddev: parseFloat(j.stddev.toFixed(2)), data: data.jurors.map((j) => ({
evaluations: j.evaluationCount, x: parseFloat(j.averageScore.toFixed(2)),
isOutlier: j.isOutlier, y: parseFloat(j.stddev.toFixed(2)),
})) name: j.name,
evaluations: j.evaluationCount,
isOutlier: j.isOutlier,
})),
},
]
const outlierCount = data.jurors.filter((j) => j.isOutlier).length const outlierCount = data.jurors.filter((j) => j.isOutlier).length
@@ -69,51 +124,63 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-[400px]"> <div style={{ height: '400px' }}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveScatterPlot<JurorDatum>
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}> data={scatterData}
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> theme={nivoTheme}
<XAxis colors={[BRAND_DARK_BLUE]}
type="number" xScale={{ type: 'linear', min: 0, max: 10 }}
dataKey="avgScore" yScale={{ type: 'linear', min: 0, max: 'auto' }}
name="Average Score" axisBottom={{
domain={[0, 10]} legend: 'Average Score',
label={{ value: 'Average Score', position: 'insideBottom', offset: -10 }} legendPosition: 'middle',
/> legendOffset: 40,
<YAxis }}
type="number" axisLeft={{
dataKey="stddev" legend: 'Std Deviation',
name="Std Deviation" legendPosition: 'middle',
label={{ value: 'Std Deviation', angle: -90, position: 'insideLeft' }} legendOffset: -50,
/> }}
<Tooltip useMesh={true}
contentStyle={{ nodeSize={(node) =>
backgroundColor: 'hsl(var(--card))', Math.max(8, Math.min(20, node.data.evaluations * 2))
border: '1px solid hsl(var(--border))', }
borderRadius: '6px', nodeComponent={CustomNode}
margin={{ top: 20, right: 20, bottom: 60, left: 60 }}
tooltip={({ node }) => (
<div
style={{
background: '#fff',
padding: '8px 12px',
border: '1px solid #e5e7eb',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
}} }}
/> >
<ReferenceLine <strong>{node.data.name}</strong>
x={data.overallAverage} <div>Avg Score: {node.data.x}</div>
stroke="#de0f1e" <div>Std Dev: {node.data.y}</div>
strokeDasharray="3 3" <div>Evaluations: {node.data.evaluations}</div>
label={{ value: 'Avg', fill: '#de0f1e', position: 'top' }} </div>
/> )}
<Scatter data={scatterData} fill="#053d57"> markers={[
{scatterData.map((entry, index) => ( {
<circle axis: 'x',
key={index} value: data.overallAverage,
r={Math.max(4, entry.evaluations)} lineStyle: {
fill={entry.isOutlier ? '#de0f1e' : '#053d57'} stroke: BRAND_RED,
fillOpacity={0.7} strokeWidth: 2,
/> strokeDasharray: '6 4',
))} },
</Scatter> legend: `Avg: ${data.overallAverage.toFixed(1)}`,
</ScatterChart> legendPosition: 'top',
</ResponsiveContainer> },
]}
/>
</div> </div>
<p className="text-xs text-muted-foreground mt-2 text-center"> <p className="text-xs text-muted-foreground mt-2 text-center">
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).
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -131,22 +198,30 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
<TableHead className="text-right">Evaluations</TableHead> <TableHead className="text-right">Evaluations</TableHead>
<TableHead className="text-right">Avg Score</TableHead> <TableHead className="text-right">Avg Score</TableHead>
<TableHead className="text-right">Std Dev</TableHead> <TableHead className="text-right">Std Dev</TableHead>
<TableHead className="text-right">Deviation from Mean</TableHead> <TableHead className="text-right">
Deviation from Mean
</TableHead>
<TableHead className="text-center">Status</TableHead> <TableHead className="text-center">Status</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data.jurors.map((juror) => ( {data.jurors.map((juror) => (
<TableRow key={juror.userId} className={juror.isOutlier ? 'bg-destructive/5' : ''}> <TableRow
key={juror.userId}
className={juror.isOutlier ? 'bg-destructive/5' : ''}
>
<TableCell> <TableCell>
<div> <p className="font-medium">{juror.name}</p>
<p className="font-medium">{juror.name}</p> </TableCell>
<p className="text-xs text-muted-foreground">{juror.email}</p> <TableCell className="text-right tabular-nums">
</div> {juror.evaluationCount}
</TableCell>
<TableCell className="text-right tabular-nums">
{juror.averageScore.toFixed(2)}
</TableCell>
<TableCell className="text-right tabular-nums">
{juror.stddev.toFixed(2)}
</TableCell> </TableCell>
<TableCell className="text-right tabular-nums">{juror.evaluationCount}</TableCell>
<TableCell className="text-right tabular-nums">{juror.averageScore.toFixed(2)}</TableCell>
<TableCell className="text-right tabular-nums">{juror.stddev.toFixed(2)}</TableCell>
<TableCell className="text-right tabular-nums"> <TableCell className="text-right tabular-nums">
{juror.deviationFromOverall.toFixed(2)} {juror.deviationFromOverall.toFixed(2)}
</TableCell> </TableCell>

View File

@@ -1,16 +1,8 @@
'use client' 'use client'
import { import { ResponsiveBar, type ComputedDatum } from '@nivo/bar'
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { nivoTheme } from './chart-theme'
interface JurorWorkloadData { interface JurorWorkloadData {
id: string id: string
@@ -24,18 +16,32 @@ interface JurorWorkloadProps {
data: JurorWorkloadData[] data: JurorWorkloadData[]
} }
export function JurorWorkloadChart({ data }: JurorWorkloadProps) { type WorkloadBarDatum = {
// Truncate names for display juror: string
const formattedData = data.map((d) => ({ completed: number
...d, remaining: number
displayName: d.name.length > 15 ? d.name.substring(0, 15) + '...' : d.name, completionRate: number
})) fullName: string
}
export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
const totalAssigned = data.reduce((sum, d) => sum + d.assigned, 0) const totalAssigned = data.reduce((sum, d) => sum + d.assigned, 0)
const totalCompleted = data.reduce((sum, d) => sum + d.completed, 0) const totalCompleted = data.reduce((sum, d) => sum + d.completed, 0)
const overallRate = const overallRate =
totalAssigned > 0 ? Math.round((totalCompleted / totalAssigned) * 100) : 0 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 ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -47,54 +53,65 @@ export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-[400px]"> <div
<ResponsiveContainer width="100%" height="100%"> style={{ height: `${Math.max(300, data.length * 35)}px` }}
<BarChart >
data={formattedData} <ResponsiveBar
layout="vertical" data={chartData}
margin={{ top: 20, right: 30, bottom: 20, left: 100 }} keys={['completed', 'remaining']}
> indexBy="juror"
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> layout="horizontal"
<XAxis type="number" /> theme={nivoTheme}
<YAxis colors={['#053d57', '#e5e7eb']}
dataKey="displayName" borderRadius={2}
type="category" enableLabel={true}
width={90} label={(d: ComputedDatum<WorkloadBarDatum>) => {
tick={{ fontSize: 12 }} if (d.id === 'completed') {
/> return `${d.data.completionRate}%`
<Tooltip }
contentStyle={{ return ''
backgroundColor: 'hsl(var(--card))', }}
border: '1px solid hsl(var(--border))', labelSkipWidth={40}
borderRadius: '6px', labelTextColor={(d) => {
const datum = d as unknown as { data: ComputedDatum<WorkloadBarDatum> }
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 }) => (
<div
style={{
background: '#ffffff',
padding: '8px 12px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
border: '1px solid #e5e7eb',
fontSize: 12,
}} }}
formatter={(value: number | undefined, name: string | undefined) => [ >
value ?? 0, <strong>{rowData.fullName}</strong>
(name ?? '') === 'assigned' ? 'Assigned' : 'Completed', <br />
]} {id === 'completed' ? 'Completed' : 'Remaining'}: {value}
labelFormatter={(_, payload) => { <br />
if (payload && payload[0]) { Completion: {rowData.completionRate}%
const item = payload[0].payload as JurorWorkloadData </div>
return `${item.name} (${item.completionRate}% complete)` )}
} legends={[
return '' {
}} dataFrom: 'keys',
/> anchor: 'bottom',
<Legend /> direction: 'row',
<Bar translateY: 30,
dataKey="assigned" itemsSpacing: 20,
name="Assigned" itemWidth: 100,
fill="#8884d8" itemHeight: 18,
radius={[0, 4, 4, 0]} symbolSize: 12,
/> symbolShape: 'square',
<Bar },
dataKey="completed" ]}
name="Completed" animate={true}
fill="#82ca9d" />
radius={[0, 4, 4, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,17 +1,8 @@
'use client' 'use client'
import { import { ResponsiveBar } from '@nivo/bar'
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
ReferenceLine,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { nivoTheme, scoreGradient } from './chart-theme'
interface ProjectRankingData { interface ProjectRankingData {
id: string id: string
@@ -27,93 +18,119 @@ interface ProjectRankingsProps {
limit?: number limit?: number
} }
// Generate color based on score (red to green gradient) type RankingBarDatum = {
const getScoreColor = (score: number): string => { project: string
if (score >= 8) return '#0bd90f' // Excellent - green score: number
if (score >= 6) return '#82ca9d' // Good - light green fullTitle: string
if (score >= 4) return '#ffc658' // Average - yellow teamName: string
if (score >= 2) return '#ff7300' // Poor - orange evaluationCount: number
return '#de0f1e' // Very poor - red
} }
export function ProjectRankingsChart({ export function ProjectRankingsChart({
data, data,
limit = 20, limit = 20,
}: ProjectRankingsProps) { }: ProjectRankingsProps) {
const displayData = data.slice(0, limit).map((d, index) => ({ const scoredData = data.filter(
...d, (d): d is ProjectRankingData & { averageScore: number } =>
rank: index + 1, d.averageScore !== null,
displayTitle: )
d.title.length > 25 ? d.title.substring(0, 25) + '...' : d.title,
score: d.averageScore || 0,
}))
const averageScore = const averageScore =
data.length > 0 scoredData.length > 0
? data.reduce((sum, d) => sum + (d.averageScore || 0), 0) / data.length ? scoredData.reduce((sum, d) => sum + d.averageScore, 0) /
scoredData.length
: 0 : 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 ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center justify-between"> <CardTitle className="flex items-center justify-between">
<span>Project Rankings</span> <span>Project Rankings</span>
<span className="text-sm font-normal text-muted-foreground"> <span className="text-sm font-normal text-muted-foreground">
Top {displayData.length} of {data.length} projects Top {displayData.length} of {scoredData.length} scored projects
</span> </span>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-[500px]"> <div
<ResponsiveContainer width="100%" height="100%"> style={{
<BarChart height: `${Math.max(400, displayData.length * 30)}px`,
data={displayData} }}
layout="vertical" >
margin={{ top: 20, right: 30, bottom: 20, left: 150 }} <ResponsiveBar
> data={chartData}
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> keys={['score']}
<XAxis type="number" domain={[0, 10]} /> indexBy="project"
<YAxis layout="horizontal"
dataKey="displayTitle" theme={nivoTheme}
type="category" colors={(bar) => scoreGradient(bar.data.score as number)}
width={140} valueScale={{ type: 'linear', max: 10 }}
tick={{ fontSize: 11 }} borderRadius={4}
/> enableLabel={true}
<Tooltip label={(d) => {
contentStyle={{ const v = d.value
backgroundColor: 'hsl(var(--card))', return v != null ? Number(v).toFixed(1) : ''
border: '1px solid hsl(var(--border))', }}
borderRadius: '6px', labelSkipWidth={30}
}} labelTextColor="#ffffff"
formatter={(value: number | undefined) => [(value ?? 0).toFixed(2), 'Average Score']} margin={{ top: 10, right: 30, bottom: 30, left: 200 }}
labelFormatter={(_, payload) => { padding={0.2}
if (payload && payload[0]) { markers={[
const item = payload[0].payload as ProjectRankingData & { {
rank: number axis: 'x',
} value: averageScore,
return `#${item.rank} - ${item.title}${item.teamName ? ` (${item.teamName})` : ''}` lineStyle: {
} stroke: '#6b7280',
return '' strokeWidth: 2,
}} strokeDasharray: '6 4',
/> },
<ReferenceLine legend: `Avg: ${averageScore.toFixed(1)}`,
x={averageScore} legendPosition: 'top',
stroke="#666" textStyle: {
strokeDasharray="5 5" fill: '#6b7280',
label={{
value: `Avg: ${averageScore.toFixed(1)}`,
position: 'top',
fill: '#666',
fontSize: 11, fontSize: 11,
},
},
]}
tooltip={({ data: rowData }) => (
<div
style={{
background: '#ffffff',
padding: '8px 12px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
border: '1px solid #e5e7eb',
fontSize: 12,
}} }}
/> >
<Bar dataKey="score" radius={[0, 4, 4, 0]}> <strong>{rowData.fullTitle}</strong>
{displayData.map((entry, index) => ( {rowData.teamName && (
<Cell key={`cell-${index}`} fill={getScoreColor(entry.score)} /> <>
))} <br />
</Bar> <span style={{ color: '#6b7280' }}>
</BarChart> {rowData.teamName}
</ResponsiveContainer> </span>
</>
)}
<br />
Score: {Number(rowData.score).toFixed(2)}
<br />
Evaluations: {rowData.evaluationCount}
</div>
)}
animate={true}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,16 +1,8 @@
'use client' 'use client'
import { import { ResponsiveBar } from '@nivo/bar'
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { nivoTheme, scoreGradient } from './chart-theme'
interface ScoreDistributionProps { interface ScoreDistributionProps {
data: { score: number; count: number }[] data: { score: number; count: number }[]
@@ -18,24 +10,16 @@ interface ScoreDistributionProps {
totalScores: number 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({ export function ScoreDistributionChart({
data, data,
averageScore, averageScore,
totalScores, totalScores,
}: ScoreDistributionProps) { }: ScoreDistributionProps) {
const chartData = data.map((d) => ({
score: String(d.score),
count: d.count,
}))
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -47,44 +31,31 @@ export function ScoreDistributionChart({
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-[300px]"> <div style={{ height: '300px' }}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveBar
<BarChart data={chartData}
data={data} keys={['count']}
margin={{ top: 20, right: 20, bottom: 20, left: 20 }} indexBy="score"
> theme={nivoTheme}
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> colors={(bar) => scoreGradient(Number(bar.indexValue))}
<XAxis borderRadius={4}
dataKey="score" enableLabel={true}
label={{ labelSkipHeight={12}
value: 'Score', labelTextColor="#ffffff"
position: 'insideBottom', axisBottom={{
offset: -10, legend: 'Score',
}} legendPosition: 'middle',
/> legendOffset: 36,
<YAxis }}
label={{ axisLeft={{
value: 'Count', legend: 'Count',
angle: -90, legendPosition: 'middle',
position: 'insideLeft', legendOffset: -40,
}} }}
/> margin={{ top: 20, right: 20, bottom: 50, left: 50 }}
<Tooltip padding={0.2}
contentStyle={{ animate={true}
backgroundColor: 'hsl(var(--card))', />
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
formatter={(value: number | undefined) => [value ?? 0, 'Count']}
labelFormatter={(label) => `Score: ${label}`}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
{data.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,13 +1,8 @@
'use client' 'use client'
import {
PieChart, import { ResponsivePie } from '@nivo/pie'
Pie,
Cell,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { nivoTheme, getStatusColor, formatStatus } from './chart-theme'
interface StatusDataPoint { interface StatusDataPoint {
status: string status: string
@@ -18,66 +13,14 @@ interface StatusBreakdownProps {
data: StatusDataPoint[] data: StatusDataPoint[]
} }
const STATUS_COLORS: Record<string, string> = {
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 (
<text
x={x}
y={y}
fill="white"
textAnchor={x > cx ? 'start' : 'end'}
dominantBaseline="central"
fontSize={12}
fontWeight={600}
>
{`${(percent * 100).toFixed(0)}%`}
</text>
)
}
export function StatusBreakdownChart({ data }: StatusBreakdownProps) { export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
const total = data.reduce((sum, item) => sum + item.count, 0) const total = data.reduce((sum, item) => sum + item.count, 0)
// Format status for display const pieData = data.map((d) => ({
const formattedData = data.map((d) => ({ id: d.status,
...d, label: formatStatus(d.status),
name: d.status.replace(/_/g, ' '), value: d.count,
color: STATUS_COLORS[d.status] || '#8884d8', color: getStatusColor(d.status),
})) }))
return ( return (
@@ -91,39 +34,42 @@ export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-[300px]"> <div style={{ height: '300px' }}>
<ResponsiveContainer width="100%" height="100%"> <ResponsivePie
<PieChart> data={pieData}
<Pie theme={nivoTheme}
data={formattedData} colors={{ datum: 'data.color' }}
cx="50%" innerRadius={0.5}
cy="50%" padAngle={0.7}
labelLine={false} cornerRadius={3}
label={renderCustomLabel} activeOuterRadiusOffset={8}
outerRadius={100} margin={{ top: 40, right: 80, bottom: 80, left: 80 }}
innerRadius={50} enableArcLinkLabels={true}
fill="#8884d8" arcLinkLabelsSkipAngle={10}
dataKey="count" arcLinkLabelsTextColor="#374151"
nameKey="name" arcLinkLabelsThickness={2}
> arcLinkLabelsColor={{ from: 'color' }}
{formattedData.map((entry, index) => ( enableArcLabels={true}
<Cell key={`cell-${index}`} fill={entry.color} /> arcLabelsSkipAngle={10}
))} arcLabelsTextColor={{ from: 'color', modifiers: [['darker', 2]] }}
</Pie> legends={[
<Tooltip {
contentStyle={{ anchor: 'bottom',
backgroundColor: 'hsl(var(--card))', direction: 'row',
border: '1px solid hsl(var(--border))', justify: false,
borderRadius: '6px', translateX: 0,
}} translateY: 56,
formatter={(value: number | undefined, name: string | undefined) => [ itemsSpacing: 0,
`${value ?? 0} (${(((value ?? 0) / total) * 100).toFixed(1)}%)`, itemWidth: 100,
name ?? '', itemHeight: 18,
]} itemTextColor: '#374151',
/> itemDirection: 'left-to-right',
<Legend /> itemOpacity: 1,
</PieChart> symbolSize: 12,
</ResponsiveContainer> symbolShape: 'circle',
},
]}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useEffect } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { import {
Card, Card,
@@ -40,9 +41,13 @@ import {
Search, Search,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
ArrowUpDown,
ArrowUp,
ArrowDown,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
import { RoundTypeStatsCards } from '@/components/observer/round-type-stats'
import { useDebouncedCallback } from 'use-debounce' import { useDebouncedCallback } from 'use-debounce'
const PER_PAGE_OPTIONS = [10, 20, 50] const PER_PAGE_OPTIONS = [10, 20, 50]
@@ -52,6 +57,8 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all') const [statusFilter, setStatusFilter] = useState<string>('all')
const [sortBy, setSortBy] = useState<'title' | 'score' | 'evaluations'>('title')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [perPage, setPerPage] = useState(20) const [perPage, setPerPage] = useState(20)
@@ -75,18 +82,44 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
setPage(1) 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 <ArrowUpDown className="ml-1 inline h-3 w-3 text-muted-foreground/50" />
return sortDir === 'asc'
? <ArrowUp className="ml-1 inline h-3 w-3" />
: <ArrowDown className="ml-1 inline h-3 w-3" />
}
// Fetch programs/rounds for the filter dropdown // 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) => 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, id: r.id,
name: r.name, name: r.name,
programName: `${p.year} Edition`, programName: `${p.year} Edition`,
status: r.status, 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 // Fetch dashboard stats
const roundIdParam = selectedRoundId !== 'all' ? selectedRoundId : undefined const roundIdParam = selectedRoundId !== 'all' ? selectedRoundId : undefined
const { data: stats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery( const { data: stats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery(
@@ -98,18 +131,14 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
roundId: roundIdParam, roundId: roundIdParam,
search: debouncedSearch || undefined, search: debouncedSearch || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined, status: statusFilter !== 'all' ? statusFilter : undefined,
sortBy,
sortDir,
page, page,
perPage, perPage,
}) })
// Fetch recent rounds for jury completion // Recent rounds for jury completion (reuse existing programs data)
const { data: recentRoundsData } = trpc.program.list.useQuery({}) const recentRounds = rounds.slice(0, 5)
const recentRounds = recentRoundsData?.flatMap((p) =>
(p.rounds ?? []).map((r: { id: string; name: string; status: string }) => ({
...r,
programName: `${p.year} Edition`,
}))
)?.slice(0, 5) || []
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -152,7 +181,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
<SelectItem value="all">All Rounds</SelectItem> <SelectItem value="all">All Rounds</SelectItem>
{rounds.map((round) => ( {rounds.map((round) => (
<SelectItem key={round.id} value={round.id}> <SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name} {round.programName} - {round.name}{round.roundType ? ` (${round.roundType.replace(/_/g, ' ')})` : ''}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -175,83 +204,93 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
))} ))}
</div> </div>
) : stats ? ( ) : stats ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="space-y-4">
<AnimatedCard index={0}> {/* Universal stats: Programs + Projects */}
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"> <div className="grid gap-4 md:grid-cols-2">
<CardContent className="p-5"> <AnimatedCard index={0}>
<div className="flex items-center justify-between"> <Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<div> <CardContent className="p-5">
<p className="text-sm font-medium text-muted-foreground">Programs</p> <div className="flex items-center justify-between">
<p className="text-2xl font-bold mt-1">{stats.programCount}</p> <div>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-sm font-medium text-muted-foreground">Programs</p>
{stats.activeRoundCount} active round{stats.activeRoundCount !== 1 ? 's' : ''} <p className="text-2xl font-bold mt-1">{stats.programCount}</p>
</p> <p className="text-xs text-muted-foreground mt-1">
</div> {stats.activeRoundCount} active round{stats.activeRoundCount !== 1 ? 's' : ''}
<div className="rounded-xl bg-blue-50 p-3">
<FolderKanban className="h-5 w-5 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={1}>
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Projects</p>
<p className="text-2xl font-bold mt-1">{stats.projectCount}</p>
<p className="text-xs text-muted-foreground mt-1">
{selectedRoundId !== 'all' ? 'In selected round' : 'Across all rounds'}
</p>
</div>
<div className="rounded-xl bg-emerald-50 p-3">
<ClipboardList className="h-5 w-5 text-emerald-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={2}>
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Jury Members</p>
<p className="text-2xl font-bold mt-1">{stats.jurorCount}</p>
<p className="text-xs text-muted-foreground mt-1">Active members</p>
</div>
<div className="rounded-xl bg-violet-50 p-3">
<Users className="h-5 w-5 text-violet-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={3}>
<Card className="border-l-4 border-l-brand-teal transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
<p className="text-2xl font-bold mt-1">{stats.submittedEvaluations}</p>
<div className="mt-2">
<Progress value={stats.completionRate} className="h-2" gradient />
<p className="mt-1 text-xs text-muted-foreground">
{stats.completionRate}% completion rate
</p> </p>
</div> </div>
<div className="rounded-xl bg-blue-50 p-3">
<FolderKanban className="h-5 w-5 text-blue-600" />
</div>
</div> </div>
<div className="rounded-xl bg-brand-teal/10 p-3"> </CardContent>
<CheckCircle2 className="h-5 w-5 text-brand-teal" /> </Card>
</AnimatedCard>
<AnimatedCard index={1}>
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Projects</p>
<p className="text-2xl font-bold mt-1">{stats.projectCount}</p>
<p className="text-xs text-muted-foreground mt-1">
{selectedRoundId !== 'all' ? 'In selected round' : 'Across all rounds'}
</p>
</div>
<div className="rounded-xl bg-emerald-50 p-3">
<ClipboardList className="h-5 w-5 text-emerald-600" />
</div>
</div> </div>
</div> </CardContent>
</CardContent> </Card>
</Card> </AnimatedCard>
</AnimatedCard> </div>
{/* Round-type-aware stats */}
{selectedRoundId !== 'all' ? (
<RoundTypeStatsCards roundId={selectedRoundId} />
) : (
<div className="grid gap-4 md:grid-cols-2">
<AnimatedCard index={2}>
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Jury Members</p>
<p className="text-2xl font-bold mt-1">{stats.jurorCount}</p>
<p className="text-xs text-muted-foreground mt-1">Active members</p>
</div>
<div className="rounded-xl bg-violet-50 p-3">
<Users className="h-5 w-5 text-violet-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={3}>
<Card className="border-l-4 border-l-brand-teal transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
<p className="text-2xl font-bold mt-1">{stats.submittedEvaluations}</p>
<div className="mt-2">
<Progress value={stats.completionRate} className="h-2" gradient />
<p className="mt-1 text-xs text-muted-foreground">
{stats.completionRate}% completion rate
</p>
</div>
</div>
<div className="rounded-xl bg-brand-teal/10 p-3">
<CheckCircle2 className="h-5 w-5 text-brand-teal" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
</div>
)}
</div> </div>
) : null} ) : null}
@@ -320,12 +359,24 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Title</TableHead> <TableHead>
<button type="button" onClick={() => handleSort('title')} className="inline-flex items-center hover:text-foreground transition-colors">
Title<SortIcon column="title" />
</button>
</TableHead>
<TableHead>Team</TableHead> <TableHead>Team</TableHead>
<TableHead>Round</TableHead> <TableHead>Round</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="text-right">Avg Score</TableHead> <TableHead className="text-right">
<TableHead className="text-right">Evaluations</TableHead> <button type="button" onClick={() => handleSort('score')} className="inline-flex items-center hover:text-foreground transition-colors">
Avg Score<SortIcon column="score" />
</button>
</TableHead>
<TableHead className="text-right">
<button type="button" onClick={() => handleSort('evaluations')} className="inline-flex items-center hover:text-foreground transition-colors">
Evaluations<SortIcon column="evaluations" />
</button>
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -441,14 +492,24 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
<div className="space-y-2"> <div className="space-y-2">
{(() => { {(() => {
const maxCount = Math.max(...stats.scoreDistribution.map((b) => b.count), 1) 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'] // Score-based colors: high scores = brand dark blue, low = brand red
return stats.scoreDistribution.map((bucket, i) => ( const scoreColors: Record<string, string> = {
<div key={bucket.label} className="flex items-center gap-3"> '9-10': '#053d57',
'7-8': '#1e7a8a',
'5-6': '#557f8c',
'3-4': '#c4453a',
'1-2': '#de0f1e',
}
return stats.scoreDistribution.map((bucket) => (
<div key={bucket.label} className="flex items-center gap-3" role="img" aria-label={`Score ${bucket.label}: ${bucket.count} evaluations`}>
<span className="text-sm w-16 text-right tabular-nums">{bucket.label}</span> <span className="text-sm w-16 text-right tabular-nums">{bucket.label}</span>
<div className="flex-1 h-6 bg-muted rounded-full overflow-hidden"> <div className="flex-1 h-6 bg-muted rounded-full overflow-hidden">
<div <div
className={cn('h-full rounded-full transition-all', colors[i])} className="h-full rounded-full transition-all"
style={{ width: `${maxCount > 0 ? (bucket.count / maxCount) * 100 : 0}%` }} style={{
width: `${maxCount > 0 ? (bucket.count / maxCount) * 100 : 0}%`,
backgroundColor: scoreColors[bucket.label] || '#557f8c',
}}
/> />
</div> </div>
<span className="text-sm tabular-nums w-8">{bucket.count}</span> <span className="text-sm tabular-nums w-8">{bucket.count}</span>
@@ -477,13 +538,19 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{recentRounds.map((round) => ( {recentRounds.map((round) => (
<div <Link
key={round.id} key={round.id}
className="flex items-center justify-between rounded-lg border p-4 transition-all hover:shadow-sm" href={`/observer/reports?round=${round.id}`}
className="flex items-center justify-between rounded-lg border p-4 transition-all hover:shadow-sm hover:bg-muted/50"
> >
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="font-medium">{round.name}</p> <p className="font-medium">{round.name}</p>
{round.roundType && (
<Badge variant="outline" className="text-xs">
{round.roundType.replace(/_/g, ' ')}
</Badge>
)}
<Badge <Badge
variant={ variant={
round.status === 'ROUND_ACTIVE' round.status === 'ROUND_ACTIVE'
@@ -500,13 +567,8 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
{round.programName} {round.programName}
</p> </p>
</div> </div>
<div className="text-right text-sm"> <ChevronRight className="h-5 w-5 text-muted-foreground" />
<p>Round details</p> </Link>
<p className="text-muted-foreground">
View analytics
</p>
</div>
</div>
))} ))}
</div> </div>
</CardContent> </CardContent>

View File

@@ -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 (
<AnimatedCard index={index}>
<Card className="relative overflow-hidden">
<div className={`absolute left-0 top-0 bottom-0 w-1`} style={{ backgroundColor: color }} />
<CardContent className="flex items-center gap-4 pt-6">
<div className="rounded-lg p-2" style={{ backgroundColor: `${color}15` }}>
<Icon className="h-5 w-5" style={{ color }} />
</div>
<div>
<p className="text-2xl font-bold tabular-nums">{value}</p>
<p className="text-sm text-muted-foreground">{label}</p>
</div>
</CardContent>
</Card>
</AnimatedCard>
)
}
interface RoundTypeStatsCardsProps {
roundId: string
}
export function RoundTypeStatsCards({ roundId }: RoundTypeStatsCardsProps) {
const { data, isLoading } = trpc.analytics.getRoundTypeStats.useQuery(
{ roundId },
{ enabled: !!roundId }
)
if (isLoading) {
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardContent className="pt-6">
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-4 w-24" />
</CardContent>
</Card>
))}
</div>
)
}
if (!data) return null
const stats = data.stats as Record<string, unknown>
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<unknown>)?.length ?? 0), icon: BarChart3, color: '#557f8c' },
{ label: 'Categories', value: ((stats.byCategory as Array<unknown>)?.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 (
<div className={
cards.length <= 2
? 'grid gap-4 sm:grid-cols-2'
: cards.length === 3
? 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'
: 'grid gap-4 sm:grid-cols-2 lg:grid-cols-4'
}>
{cards.map((card, i) => (
<StatCard key={card.label} {...card} index={i} />
))}
</div>
)
}
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
}
}

View File

@@ -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<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }

View File

@@ -121,7 +121,7 @@ export const analyticsRouter = router({
const assignments = await ctx.prisma.assignment.findMany({ const assignments = await ctx.prisma.assignment.findMany({
where: assignmentWhere(input), where: assignmentWhere(input),
include: { include: {
user: { select: { name: true, email: true } }, user: { select: { name: true } },
evaluation: { evaluation: {
select: { id: true, status: true }, select: { id: true, status: true },
}, },
@@ -138,7 +138,7 @@ export const analyticsRouter = router({
const userId = assignment.userId const userId = assignment.userId
if (!byUser[userId]) { if (!byUser[userId]) {
byUser[userId] = { byUser[userId] = {
name: assignment.user.name || assignment.user.email || 'Unknown', name: assignment.user.name || 'Unknown',
assigned: 0, assigned: 0,
completed: 0, completed: 0,
} }
@@ -317,21 +317,24 @@ export const analyticsRouter = router({
return [] return []
} }
const criteriaMap = new Map<string, { id: string; label: string }>() // Build label → Set<id> map so program-level queries match all IDs for the same criterion label
const labelToIds = new Map<string, Set<string>>()
const labelToFirst = new Map<string, { id: string; label: string }>()
evaluationForms.forEach((form) => { evaluationForms.forEach((form) => {
const criteria = form.criteriaJson as Array<{ id: string; label: string }> | null const criteria = form.criteriaJson as Array<{ id: string; label: string }> | null
if (criteria) { if (criteria) {
criteria.forEach((c) => { criteria.forEach((c) => {
const key = input.roundId ? c.id : c.label if (!labelToIds.has(c.label)) {
if (!criteriaMap.has(key)) { labelToIds.set(c.label, new Set())
criteriaMap.set(key, c) labelToFirst.set(c.label, c)
} }
labelToIds.get(c.label)!.add(c.id)
}) })
} }
}) })
const criteria = Array.from(criteriaMap.values()) const criteriaLabels = Array.from(labelToFirst.values())
if (criteria.length === 0) { if (criteriaLabels.length === 0) {
return [] return []
} }
@@ -341,17 +344,23 @@ export const analyticsRouter = router({
select: { criterionScoresJson: true }, select: { criterionScoresJson: true },
}) })
// Calculate average score per criterion // Calculate average score per criterion, checking ALL IDs that share the same label
const criteriaScores = criteria.map((criterion) => { const criteriaScores = criteriaLabels.map((criterion) => {
const scores: number[] = [] const scores: number[] = []
const ids = labelToIds.get(criterion.label) ?? new Set([criterion.id])
evaluations.forEach((evaluation) => { evaluations.forEach((evaluation) => {
const criterionScoresJson = evaluation.criterionScoresJson as Record< const criterionScoresJson = evaluation.criterionScoresJson as Record<
string, string,
number number
> | null > | null
if (criterionScoresJson && typeof criterionScoresJson[criterion.id] === 'number') { if (criterionScoresJson) {
scores.push(criterionScoresJson[criterion.id]) 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: { include: {
assignment: { assignment: {
include: { include: {
user: { select: { id: true, name: true, email: true } }, user: { select: { id: true, name: true } },
}, },
}, },
}, },
}) })
// Group scores by juror // Group scores by juror
const jurorScores: Record<string, { name: string; email: string; scores: number[] }> = {} const jurorScores: Record<string, { name: string; scores: number[] }> = {}
evaluations.forEach((e) => { evaluations.forEach((e) => {
const userId = e.assignment.userId const userId = e.assignment.userId
if (!jurorScores[userId]) { if (!jurorScores[userId]) {
jurorScores[userId] = { jurorScores[userId] = {
name: e.assignment.user.name || e.assignment.user.email || 'Unknown', name: e.assignment.user.name || 'Unknown',
email: e.assignment.user.email || '',
scores: [], scores: [],
} }
} }
@@ -539,7 +547,6 @@ export const analyticsRouter = router({
return { return {
userId, userId,
name: data.name, name: data.name,
email: data.email,
evaluationCount: data.scores.length, evaluationCount: data.scores.length,
averageScore: avg, averageScore: avg,
stddev, stddev,
@@ -731,7 +738,12 @@ export const analyticsRouter = router({
evaluationScores, evaluationScores,
] = await Promise.all([ ] = await Promise.all([
ctx.prisma.program.count(), 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 }), ctx.prisma.project.count({ where: projectFilter }),
roundId roundId
? ctx.prisma.assignment.findMany({ ? ctx.prisma.assignment.findMany({
@@ -949,6 +961,8 @@ export const analyticsRouter = router({
roundId: z.string().optional(), roundId: z.string().optional(),
search: z.string().optional(), search: z.string().optional(),
status: 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), page: z.number().min(1).default(1),
perPage: z.number().min(1).max(100).default(20), 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([ const [projects, total] = await Promise.all([
ctx.prisma.project.findMany({ ctx.prisma.project.findMany({
where, where,
@@ -990,9 +1009,11 @@ export const analyticsRouter = router({
}, },
}, },
}, },
orderBy: { title: 'asc' }, orderBy: prismaOrderBy,
skip: (input.page - 1) * input.perPage, // When sorting by computed fields, fetch all then slice in JS
take: input.perPage, ...(input.sortBy === 'title'
? { skip: (input.page - 1) * input.perPage, take: input.perPage }
: {}),
}), }),
ctx.prisma.project.count({ where }), ctx.prisma.project.count({ where }),
]) ])
@@ -1009,7 +1030,10 @@ export const analyticsRouter = router({
? scores.reduce((a, b) => a + b, 0) / scores.length ? scores.reduce((a, b) => a + b, 0) / scores.length
: null : 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 { return {
id: p.id, id: p.id,
@@ -1017,19 +1041,200 @@ export const analyticsRouter = router({
teamName: p.teamName, teamName: p.teamName,
status: p.status, status: p.status,
country: p.country, country: p.country,
roundId: firstAssignment?.round?.id ?? '', roundId: roundAssignment?.round?.id ?? '',
roundName: firstAssignment?.round?.name ?? '', roundName: roundAssignment?.round?.name ?? '',
averageScore, averageScore,
evaluationCount: submitted.length, 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 { return {
projects: mapped, projects: paginated,
total, total,
page: input.page, page: input.page,
perPage: input.perPage, perPage: input.perPage,
totalPages: Math.ceil(total / 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: {} }
}
}),
}) })

View File

@@ -63,6 +63,7 @@ export const programRouter = router({
name: round.name, name: round.name,
competitionId: round.competitionId, competitionId: round.competitionId,
status: round.status, status: round.status,
roundType: round.roundType,
votingEndAt: round.windowCloseAt, votingEndAt: round.windowCloseAt,
_count: { _count: {
projects: round._count?.projectRoundStates || 0, projects: round._count?.projectRoundStates || 0,