diff --git a/package-lock.json b/package-lock.json
index 023cb60..e64ab17 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,11 +18,6 @@
"@hookform/resolvers": "^3.9.1",
"@mantine/core": "^8.3.13",
"@mantine/hooks": "^8.3.13",
- "@nivo/bar": "^0.99.0",
- "@nivo/core": "^0.99.0",
- "@nivo/line": "^0.99.0",
- "@nivo/pie": "^0.99.0",
- "@nivo/scatterplot": "^0.99.0",
"@notionhq/client": "^2.3.0",
"@prisma/client": "^6.19.2",
"@radix-ui/react-alert-dialog": "^1.1.4",
@@ -44,9 +39,9 @@
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.1.6",
- "@react-spring/web": "^10.0.3",
"@tailwindcss/postcss": "^4.1.18",
"@tanstack/react-query": "^5.62.0",
+ "@tremor/react": "^3.18.7",
"@trpc/client": "^11.0.0-rc.678",
"@trpc/react-query": "^11.0.0-rc.678",
"@trpc/server": "^11.0.0-rc.678",
@@ -1030,6 +1025,40 @@
"prosemirror-view": "^1.0.0"
}
},
+ "node_modules/@headlessui/react": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.0.tgz",
+ "integrity": "sha512-RzCEg+LXsuI7mHiSomsu/gBJSjpupm6A1qIZ5sWjd7JhARNlMiSA4kKfJpCKwU9tE+zMRterhhrP74PvfJrpXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react": "^0.26.16",
+ "@react-aria/focus": "^3.17.1",
+ "@react-aria/interactions": "^3.21.3",
+ "@tanstack/react-virtual": "^3.8.1"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19 || ^19.0.0-rc",
+ "react-dom": "^18 || ^19 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/@headlessui/react/node_modules/@floating-ui/react": {
+ "version": "0.26.28",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
+ "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react-dom": "^2.1.2",
+ "@floating-ui/utils": "^0.2.8",
+ "tabbable": "^6.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
"node_modules/@hookform/resolvers": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
@@ -1630,16 +1659,6 @@
"react": "^18.x || ^19.x"
}
},
- "node_modules/@mantine/utils": {
- "version": "6.0.22",
- "resolved": "https://registry.npmjs.org/@mantine/utils/-/utils-6.0.22.tgz",
- "integrity": "sha512-RSKlNZvxhMCkOFZ6slbYvZYbWjHUM+PxDQnupIOxIdsTZQQjx/BFfrfJ7kQFOP+g7MtpOds8weAetEs5obwMOQ==",
- "license": "MIT",
- "peer": true,
- "peerDependencies": {
- "react": ">=16.8.0"
- }
- },
"node_modules/@napi-rs/canvas": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
@@ -1980,408 +1999,6 @@
"node": ">= 10"
}
},
- "node_modules/@nivo/annotations": {
- "version": "0.99.0",
- "resolved": "https://registry.npmjs.org/@nivo/annotations/-/annotations-0.99.0.tgz",
- "integrity": "sha512-jCuuXPbvpaqaz4xF7k5dv0OT2ubn5Nt0gWryuTe/8oVsC/9bzSuK8bM9vBty60m9tfO+X8vUYliuaCDwGksC2g==",
- "license": "MIT",
- "dependencies": {
- "@nivo/colors": "0.99.0",
- "@nivo/core": "0.99.0",
- "@nivo/theming": "0.99.0",
- "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0",
- "lodash": "^4.17.21"
- },
- "peerDependencies": {
- "react": "^16.14 || ^17.0 || ^18.0 || ^19.0"
- }
- },
- "node_modules/@nivo/arcs": {
- "version": "0.99.0",
- "resolved": "https://registry.npmjs.org/@nivo/arcs/-/arcs-0.99.0.tgz",
- "integrity": "sha512-UcvWLQPl+A3APk2Gm74N5xDfT+ATnVs2XkP73WxhYPWJk+dBzF00cndA5g/dptOwdFBvvo62VgcCsNiwUsjKTw==",
- "license": "MIT",
- "dependencies": {
- "@nivo/colors": "0.99.0",
- "@nivo/core": "0.99.0",
- "@nivo/text": "0.99.0",
- "@nivo/theming": "0.99.0",
- "@react-spring/core": "9.4.5 || ^9.7.2 || ^10.0",
- "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0",
- "@types/d3-shape": "^3.1.6",
- "d3-shape": "^3.2.0"
- },
- "peerDependencies": {
- "react": "^16.14 || ^17.0 || ^18.0 || ^19.0"
- }
- },
- "node_modules/@nivo/axes": {
- "version": "0.99.0",
- "resolved": "https://registry.npmjs.org/@nivo/axes/-/axes-0.99.0.tgz",
- "integrity": "sha512-3KschnmEL0acRoa7INSSOSEFwJLm54aZwSev7/r8XxXlkgRBriu6ReZy/FG0wfN+ljZ4GMvx+XyIIf6kxzvrZg==",
- "license": "MIT",
- "dependencies": {
- "@nivo/core": "0.99.0",
- "@nivo/scales": "0.99.0",
- "@nivo/text": "0.99.0",
- "@nivo/theming": "0.99.0",
- "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0",
- "@types/d3-format": "^1.4.1",
- "@types/d3-time-format": "^2.3.1",
- "d3-format": "^1.4.4",
- "d3-time-format": "^3.0.0"
- },
- "peerDependencies": {
- "react": "^16.14 || ^17.0 || ^18.0 || ^19.0"
- }
- },
- "node_modules/@nivo/axes/node_modules/d3-array": {
- "version": "2.12.1",
- "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
- "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "internmap": "^1.0.0"
- }
- },
- "node_modules/@nivo/axes/node_modules/d3-format": {
- "version": "1.4.5",
- "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz",
- "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@nivo/axes/node_modules/d3-time": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz",
- "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "d3-array": "2"
- }
- },
- "node_modules/@nivo/axes/node_modules/d3-time-format": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz",
- "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "d3-time": "1 - 2"
- }
- },
- "node_modules/@nivo/axes/node_modules/internmap": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
- "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==",
- "license": "ISC"
- },
- "node_modules/@nivo/bar": {
- "version": "0.99.0",
- "resolved": "https://registry.npmjs.org/@nivo/bar/-/bar-0.99.0.tgz",
- "integrity": "sha512-9yfMn7H6UF/TqtCwVZ/vihVAXUff9wWvSaeF2Z1DCfgr5S07qs31Qb2p0LZA+YgCWpaU7zqkeb3VZ4WCpZbrDA==",
- "license": "MIT",
- "dependencies": {
- "@nivo/annotations": "0.99.0",
- "@nivo/axes": "0.99.0",
- "@nivo/canvas": "0.99.0",
- "@nivo/colors": "0.99.0",
- "@nivo/core": "0.99.0",
- "@nivo/legends": "0.99.0",
- "@nivo/scales": "0.99.0",
- "@nivo/text": "0.99.0",
- "@nivo/theming": "0.99.0",
- "@nivo/tooltip": "0.99.0",
- "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0",
- "@types/d3-scale": "^4.0.8",
- "@types/d3-shape": "^3.1.6",
- "d3-scale": "^4.0.2",
- "d3-shape": "^3.2.0",
- "lodash": "^4.17.21"
- },
- "peerDependencies": {
- "react": "^16.14 || ^17.0 || ^18.0 || ^19.0"
- }
- },
- "node_modules/@nivo/canvas": {
- "version": "0.99.0",
- "resolved": "https://registry.npmjs.org/@nivo/canvas/-/canvas-0.99.0.tgz",
- "integrity": "sha512-UxA8zb+NPwqmNm81hoyUZSMAikgjU1ukLf4KybVNyV8ejcJM+BUFXsb8DxTcLdt4nmCFHqM56GaJQv2hnAHmzg==",
- "license": "MIT"
- },
- "node_modules/@nivo/colors": {
- "version": "0.99.0",
- "resolved": "https://registry.npmjs.org/@nivo/colors/-/colors-0.99.0.tgz",
- "integrity": "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w==",
- "license": "MIT",
- "dependencies": {
- "@nivo/core": "0.99.0",
- "@nivo/theming": "0.99.0",
- "@types/d3-color": "^3.0.0",
- "@types/d3-scale": "^4.0.8",
- "@types/d3-scale-chromatic": "^3.0.0",
- "d3-color": "^3.1.0",
- "d3-scale": "^4.0.2",
- "d3-scale-chromatic": "^3.0.0",
- "lodash": "^4.17.21"
- },
- "peerDependencies": {
- "react": "^16.14 || ^17.0 || ^18.0 || ^19.0"
- }
- },
- "node_modules/@nivo/core": {
- "version": "0.99.0",
- "resolved": "https://registry.npmjs.org/@nivo/core/-/core-0.99.0.tgz",
- "integrity": "sha512-olCItqhPG3xHL5ei+vg52aB6o+6S+xR2idpkd9RormTTUniZb8U2rOdcQojOojPY5i9kVeQyLFBpV4YfM7OZ9g==",
- "license": "MIT",
- "dependencies": {
- "@nivo/theming": "0.99.0",
- "@nivo/tooltip": "0.99.0",
- "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0",
- "@types/d3-shape": "^3.1.6",
- "d3-color": "^3.1.0",
- "d3-format": "^1.4.4",
- "d3-interpolate": "^3.0.1",
- "d3-scale": "^4.0.2",
- "d3-scale-chromatic": "^3.0.0",
- "d3-shape": "^3.2.0",
- "d3-time-format": "^3.0.0",
- "lodash": "^4.17.21",
- "react-virtualized-auto-sizer": "^1.0.26",
- "use-debounce": "^10.0.4"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/nivo/donate"
- },
- "peerDependencies": {
- "react": "^16.14 || ^17.0 || ^18.0 || ^19.0"
- }
- },
- "node_modules/@nivo/core/node_modules/d3-array": {
- "version": "2.12.1",
- "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
- "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "internmap": "^1.0.0"
- }
- },
- "node_modules/@nivo/core/node_modules/d3-format": {
- "version": "1.4.5",
- "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz",
- "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@nivo/core/node_modules/d3-time": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-2.1.1.tgz",
- "integrity": "sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "d3-array": "2"
- }
- },
- "node_modules/@nivo/core/node_modules/d3-time-format": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz",
- "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "d3-time": "1 - 2"
- }
- },
- "node_modules/@nivo/core/node_modules/internmap": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
- "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==",
- "license": "ISC"
- },
- "node_modules/@nivo/legends": {
- "version": "0.99.0",
- "resolved": "https://registry.npmjs.org/@nivo/legends/-/legends-0.99.0.tgz",
- "integrity": "sha512-P16FjFqNceuTTZphINAh5p0RF0opu3cCKoWppe2aRD9IuVkvRm/wS5K1YwMCxDzKyKh5v0AuTlu9K6o3/hk8hA==",
- "license": "MIT",
- "dependencies": {
- "@nivo/colors": "0.99.0",
- "@nivo/core": "0.99.0",
- "@nivo/text": "0.99.0",
- "@nivo/theming": "0.99.0",
- "@types/d3-scale": "^4.0.8",
- "d3-scale": "^4.0.2"
- },
- "peerDependencies": {
- "react": "^16.14 || ^17.0 || ^18.0 || ^19.0"
- }
- },
- "node_modules/@nivo/line": {
- "version": "0.99.0",
- "resolved": "https://registry.npmjs.org/@nivo/line/-/line-0.99.0.tgz",
- "integrity": "sha512-bAqTXSjpnpcGMs341qWFUi7hJTqQiNoSeJHsYPuPS3icuXPcp3WETQH+zRZACeEF79ZigeOWCW+dzODgne1y9w==",
- "license": "MIT",
- "dependencies": {
- "@nivo/annotations": "0.99.0",
- "@nivo/axes": "0.99.0",
- "@nivo/colors": "0.99.0",
- "@nivo/core": "0.99.0",
- "@nivo/legends": "0.99.0",
- "@nivo/scales": "0.99.0",
- "@nivo/theming": "0.99.0",
- "@nivo/tooltip": "0.99.0",
- "@nivo/voronoi": "0.99.0",
- "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0",
- "@types/d3-shape": "^3.1.6",
- "d3-shape": "^3.2.0"
- },
- "peerDependencies": {
- "react": "^16.14 || ^17.0 || ^18.0 || ^19.0"
- }
- },
- "node_modules/@nivo/pie": {
- "version": "0.99.0",
- "resolved": "https://registry.npmjs.org/@nivo/pie/-/pie-0.99.0.tgz",
- "integrity": "sha512-zUbo8UdLndp2RMljrOqitAKKEnl7YypkJrOzjKLk8jQGU7qqUKtgFoJIPhiBsvNPs3xtX2KwgtS1+JKNTNns7A==",
- "license": "MIT",
- "dependencies": {
- "@nivo/arcs": "0.99.0",
- "@nivo/colors": "0.99.0",
- "@nivo/core": "0.99.0",
- "@nivo/legends": "0.99.0",
- "@nivo/theming": "0.99.0",
- "@nivo/tooltip": "0.99.0",
- "@types/d3-shape": "^3.1.6",
- "d3-shape": "^3.2.0"
- },
- "peerDependencies": {
- "react": "^16.14 || ^17.0 || ^18.0 || ^19.0"
- }
- },
- "node_modules/@nivo/scales": {
- "version": "0.99.0",
- "resolved": "https://registry.npmjs.org/@nivo/scales/-/scales-0.99.0.tgz",
- "integrity": "sha512-g/2K4L6L8si6E2BWAHtFVGahtDKbUcO6xHJtlIZMwdzaJc7yB16EpWLK8AfI/A42KadLhJSJqBK3mty+c7YZ+w==",
- "license": "MIT",
- "dependencies": {
- "@types/d3-interpolate": "^3.0.4",
- "@types/d3-scale": "^4.0.8",
- "@types/d3-time": "^1.1.1",
- "@types/d3-time-format": "^3.0.0",
- "d3-interpolate": "^3.0.1",
- "d3-scale": "^4.0.2",
- "d3-time": "^1.0.11",
- "d3-time-format": "^3.0.0",
- "lodash": "^4.17.21"
- }
- },
- "node_modules/@nivo/scales/node_modules/@types/d3-time": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-1.1.4.tgz",
- "integrity": "sha512-JIvy2HjRInE+TXOmIGN5LCmeO0hkFZx5f9FZ7kiN+D+YTcc8pptsiLiuHsvwxwC7VVKmJ2ExHUgNlAiV7vQM9g==",
- "license": "MIT"
- },
- "node_modules/@nivo/scales/node_modules/@types/d3-time-format": {
- "version": "3.0.4",
- "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-3.0.4.tgz",
- "integrity": "sha512-or9DiDnYI1h38J9hxKEsw513+KVuFbEVhl7qdxcaudoiqWWepapUen+2vAriFGexr6W5+P4l9+HJrB39GG+oRg==",
- "license": "MIT"
- },
- "node_modules/@nivo/scales/node_modules/d3-time": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz",
- "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@nivo/scales/node_modules/d3-time-format": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-3.0.0.tgz",
- "integrity": "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "d3-time": "1 - 2"
- }
- },
- "node_modules/@nivo/scatterplot": {
- "version": "0.99.0",
- "resolved": "https://registry.npmjs.org/@nivo/scatterplot/-/scatterplot-0.99.0.tgz",
- "integrity": "sha512-/fc0LPw2BdPgj+4cKqXlfSsQ9Z3EU0U4UBLDQ07GJCAkqA+IrufYGVVkAKRKyrLrG3sKFGBdZDinkp3LxuiwFg==",
- "license": "MIT",
- "dependencies": {
- "@nivo/annotations": "0.99.0",
- "@nivo/axes": "0.99.0",
- "@nivo/colors": "0.99.0",
- "@nivo/core": "0.99.0",
- "@nivo/legends": "0.99.0",
- "@nivo/scales": "0.99.0",
- "@nivo/theming": "0.99.0",
- "@nivo/tooltip": "0.99.0",
- "@nivo/voronoi": "0.99.0",
- "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0",
- "@types/d3-scale": "^4.0.8",
- "@types/d3-shape": "^3.1.6",
- "d3-scale": "^4.0.2",
- "d3-shape": "^3.2.0",
- "lodash": "^4.17.21"
- },
- "peerDependencies": {
- "react": "^16.14 || ^17.0 || ^18.0 || ^19.0"
- }
- },
- "node_modules/@nivo/text": {
- "version": "0.99.0",
- "resolved": "https://registry.npmjs.org/@nivo/text/-/text-0.99.0.tgz",
- "integrity": "sha512-ho3oZpAZApsJNjsIL5WJSAdg/wjzTBcwo1KiHBlRGUmD+yUWO8qp7V+mnYRhJchwygtRVALlPgZ/rlcW2Xr/MQ==",
- "license": "MIT",
- "dependencies": {
- "@nivo/core": "0.99.0",
- "@nivo/theming": "0.99.0",
- "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0"
- },
- "peerDependencies": {
- "react": "^16.14 || ^17.0 || ^18.0 || ^19.0"
- }
- },
- "node_modules/@nivo/theming": {
- "version": "0.99.0",
- "resolved": "https://registry.npmjs.org/@nivo/theming/-/theming-0.99.0.tgz",
- "integrity": "sha512-KvXlf0nqBzh/g2hAIV9bzscYvpq1uuO3TnFN3RDXGI72CrbbZFTGzprPju3sy/myVsauv+Bb+V4f5TZ0jkYKRg==",
- "license": "MIT",
- "dependencies": {
- "lodash": "^4.17.21"
- },
- "peerDependencies": {
- "react": "^16.14 || ^17.0 || ^18.0 || ^19.0"
- }
- },
- "node_modules/@nivo/tooltip": {
- "version": "0.99.0",
- "resolved": "https://registry.npmjs.org/@nivo/tooltip/-/tooltip-0.99.0.tgz",
- "integrity": "sha512-weoEGR3xAetV4k2P6k96cdamGzKQ5F2Pq+uyDaHr1P3HYArM879Pl+x+TkU0aWjP6wgUZPx/GOBiV1Hb1JxIqg==",
- "license": "MIT",
- "dependencies": {
- "@nivo/core": "0.99.0",
- "@nivo/theming": "0.99.0",
- "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0"
- },
- "peerDependencies": {
- "react": "^16.14 || ^17.0 || ^18.0 || ^19.0"
- }
- },
- "node_modules/@nivo/voronoi": {
- "version": "0.99.0",
- "resolved": "https://registry.npmjs.org/@nivo/voronoi/-/voronoi-0.99.0.tgz",
- "integrity": "sha512-KfmMdidbYzhiUCki1FG4X4nHEFT4loK8G5bMBnmCl9U+S78W+gvkfrgD2Aoqp/Q9yKQvr3Y8UcZKSFZnn3HgjQ==",
- "license": "MIT",
- "dependencies": {
- "@nivo/core": "0.99.0",
- "@nivo/theming": "0.99.0",
- "@nivo/tooltip": "0.99.0",
- "@types/d3-delaunay": "^6.0.4",
- "@types/d3-scale": "^4.0.8",
- "d3-delaunay": "^6.0.4",
- "d3-scale": "^4.0.2"
- },
- "peerDependencies": {
- "react": "^16.14 || ^17.0 || ^18.0 || ^19.0"
- }
- },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2456,7 +2073,7 @@
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz",
"integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==",
- "devOptional": true,
+ "dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.0"
@@ -2494,7 +2111,7 @@
"version": "6.19.2",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.2.tgz",
"integrity": "sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ==",
- "devOptional": true,
+ "dev": true,
"license": "Apache-2.0",
"dependencies": {
"c12": "3.1.0",
@@ -2507,14 +2124,14 @@
"version": "6.19.2",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.2.tgz",
"integrity": "sha512-lFnEZsLdFLmEVCVNdskLDCL8Uup41GDfU0LUfquw+ercJC8ODTuL0WNKgOKmYxCJVvFwf0OuZBzW99DuWmoH2A==",
- "devOptional": true,
+ "dev": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "6.19.2",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.2.tgz",
"integrity": "sha512-TTkJ8r+uk/uqczX40wb+ODG0E0icVsMgwCTyTHXehaEfb0uo80M9g1aW1tEJrxmFHeOZFXdI2sTA1j1AgcHi4A==",
- "devOptional": true,
+ "dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -2528,14 +2145,14 @@
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
"integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
- "devOptional": true,
+ "dev": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.19.2",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.2.tgz",
"integrity": "sha512-h4Ff4Pho+SR1S8XerMCC12X//oY2bG3Iug/fUnudfcXEUnIeRiBdXHFdGlGOgQ3HqKgosTEhkZMvGM9tWtYC+Q==",
- "devOptional": true,
+ "dev": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.2",
@@ -2547,7 +2164,7 @@
"version": "6.19.2",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.2.tgz",
"integrity": "sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==",
- "devOptional": true,
+ "dev": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.19.2"
@@ -3929,6 +3546,73 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
+ "node_modules/@react-aria/focus": {
+ "version": "3.21.4",
+ "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.4.tgz",
+ "integrity": "sha512-6gz+j9ip0/vFRTKJMl3R30MHopn4i19HqqLfSQfElxJD+r9hBnYG1Q6Wd/kl/WRR1+CALn2F+rn06jUnf5sT8Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@react-aria/interactions": "^3.27.0",
+ "@react-aria/utils": "^3.33.0",
+ "@react-types/shared": "^3.33.0",
+ "@swc/helpers": "^0.5.0",
+ "clsx": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
+ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@react-aria/interactions": {
+ "version": "3.27.0",
+ "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.27.0.tgz",
+ "integrity": "sha512-D27pOy+0jIfHK60BB26AgqjjRFOYdvVSkwC31b2LicIzRCSPOSP06V4gMHuGmkhNTF4+YWDi1HHYjxIvMeiSlA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@react-aria/ssr": "^3.9.10",
+ "@react-aria/utils": "^3.33.0",
+ "@react-stately/flags": "^3.1.2",
+ "@react-types/shared": "^3.33.0",
+ "@swc/helpers": "^0.5.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
+ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@react-aria/ssr": {
+ "version": "3.9.10",
+ "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz",
+ "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/helpers": "^0.5.0"
+ },
+ "engines": {
+ "node": ">= 12"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
+ "node_modules/@react-aria/utils": {
+ "version": "3.33.0",
+ "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.33.0.tgz",
+ "integrity": "sha512-yvz7CMH8d2VjwbSa5nGXqjU031tYhD8ddax95VzJsHSPyqHDEGfxul8RkhGV6oO7bVqZxVs6xY66NIgae+FHjw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@react-aria/ssr": "^3.9.10",
+ "@react-stately/flags": "^3.1.2",
+ "@react-stately/utils": "^3.11.0",
+ "@react-types/shared": "^3.33.0",
+ "@swc/helpers": "^0.5.0",
+ "clsx": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
+ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
+ }
+ },
"node_modules/@react-leaflet/core": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
@@ -3940,76 +3624,34 @@
"react-dom": "^19.0.0"
}
},
- "node_modules/@react-spring/animated": {
- "version": "10.0.3",
- "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-10.0.3.tgz",
- "integrity": "sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ==",
- "license": "MIT",
+ "node_modules/@react-stately/flags": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz",
+ "integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==",
+ "license": "Apache-2.0",
"dependencies": {
- "@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"
+ "@swc/helpers": "^0.5.0"
}
},
- "node_modules/@react-spring/core": {
- "version": "10.0.3",
- "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-10.0.3.tgz",
- "integrity": "sha512-D4DwNO68oohDf/0HG2G0Uragzb9IA1oXblxrd6MZAcBcUQG2EHUWXewjdECMPLNmQvlYVyyBRH6gPxXM5DX7DQ==",
- "license": "MIT",
+ "node_modules/@react-stately/utils": {
+ "version": "3.11.0",
+ "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.11.0.tgz",
+ "integrity": "sha512-8LZpYowJ9eZmmYLpudbo/eclIRnbhWIJZ994ncmlKlouNzKohtM8qTC6B1w1pwUbiwGdUoyzLuQbeaIor5Dvcw==",
+ "license": "Apache-2.0",
"dependencies": {
- "@react-spring/animated": "~10.0.3",
- "@react-spring/shared": "~10.0.3",
- "@react-spring/types": "~10.0.3"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/react-spring/donate"
+ "@swc/helpers": "^0.5.0"
},
"peerDependencies": {
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
- "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"
- },
+ "node_modules/@react-types/shared": {
+ "version": "3.33.0",
+ "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.33.0.tgz",
+ "integrity": "sha512-xuUpP6MyuPmJtzNOqF5pzFUIHH2YogyOQfUQHag54PRmWB7AbjuGWBUv0l1UDmz6+AbzAYGmDVAzcRDOu2PFpw==",
+ "license": "Apache-2.0",
"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"
+ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@remirror/core-constants": {
@@ -4402,7 +4044,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/@swc/helpers": {
@@ -4714,6 +4356,23 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/@tanstack/react-virtual": {
+ "version": "3.13.18",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz",
+ "integrity": "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/virtual-core": "3.13.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "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/@tanstack/store": {
"version": "0.7.7",
"resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.7.7.tgz",
@@ -4724,6 +4383,16 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
+ "node_modules/@tanstack/virtual-core": {
+ "version": "3.13.18",
+ "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz",
+ "integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
"node_modules/@tiptap/core": {
"version": "3.18.0",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.18.0.tgz",
@@ -4964,6 +4633,87 @@
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/@tremor/react": {
+ "version": "3.18.7",
+ "resolved": "https://registry.npmjs.org/@tremor/react/-/react-3.18.7.tgz",
+ "integrity": "sha512-nmqvf/1m0GB4LXc7v2ftdfSLoZhy5WLrhV6HNf0SOriE6/l8WkYeWuhQq8QsBjRi94mUIKLJ/VC3/Y/pj6VubQ==",
+ "license": "Apache 2.0",
+ "dependencies": {
+ "@floating-ui/react": "^0.19.2",
+ "@headlessui/react": "2.2.0",
+ "date-fns": "^3.6.0",
+ "react-day-picker": "^8.10.1",
+ "react-transition-state": "^2.1.2",
+ "recharts": "^2.13.3",
+ "tailwind-merge": "^2.5.2"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0",
+ "react-dom": ">=16.6.0"
+ }
+ },
+ "node_modules/@tremor/react/node_modules/@floating-ui/react": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.19.2.tgz",
+ "integrity": "sha512-JyNk4A0Ezirq8FlXECvRtQOX/iBe5Ize0W/pLkrZjfHW9GUV7Xnq6zm6fyZuQzaHHqEnVizmvlA96e1/CkZv+w==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react-dom": "^1.3.0",
+ "aria-hidden": "^1.1.3",
+ "tabbable": "^6.0.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@tremor/react/node_modules/@floating-ui/react-dom": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-1.3.0.tgz",
+ "integrity": "sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.2.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@tremor/react/node_modules/date-fns": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
+ "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
+ "node_modules/@tremor/react/node_modules/react-day-picker": {
+ "version": "8.10.1",
+ "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
+ "integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==",
+ "license": "MIT",
+ "funding": {
+ "type": "individual",
+ "url": "https://github.com/sponsors/gpbl"
+ },
+ "peerDependencies": {
+ "date-fns": "^2.28.0 || ^3.0.0",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
+ "node_modules/@tremor/react/node_modules/tailwind-merge": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
+ "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
"node_modules/@trpc/client": {
"version": "11.9.0",
"resolved": "https://registry.npmjs.org/@trpc/client/-/client-11.9.0.tgz",
@@ -5034,22 +4784,22 @@
"assertion-error": "^2.0.1"
}
},
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "license": "MIT"
+ },
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
- "node_modules/@types/d3-delaunay": {
- "version": "6.0.4",
- "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
- "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
- "license": "MIT"
- },
- "node_modules/@types/d3-format": {
- "version": "1.4.5",
- "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-1.4.5.tgz",
- "integrity": "sha512-mLxrC1MSWupOSncXN/HOlWUAAIffAEBaI4+PKy2uMPsKe4FNZlk7qrbTjmzJXITQQqBHivaks4Td18azgqnotA==",
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
@@ -5076,12 +4826,6 @@
"@types/d3-time": "*"
}
},
- "node_modules/@types/d3-scale-chromatic": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
- "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
- "license": "MIT"
- },
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
@@ -5097,10 +4841,10 @@
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
- "node_modules/@types/d3-time-format": {
- "version": "2.3.4",
- "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.3.4.tgz",
- "integrity": "sha512-xdDXbpVO74EvadI3UDxjxTdR6QIxm1FKzEA/+F8tL4GWWUg/hgvBqf6chql64U5A9ZUGWo7pEu4eNlyLwbKdhg==",
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/debug": {
@@ -5269,6 +5013,7 @@
"version": "19.2.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
"integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -5278,6 +5023,7 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -6391,7 +6137,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.3",
@@ -6584,7 +6330,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
@@ -6600,7 +6346,7 @@
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"consola": "^3.2.3"
@@ -6718,14 +6464,14 @@
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz",
"integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/consola": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
@@ -6833,14 +6579,11 @@
"node": ">=12"
}
},
- "node_modules/d3-delaunay": {
- "version": "6.0.4",
- "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
- "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
- "license": "ISC",
- "dependencies": {
- "delaunator": "5"
- },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
@@ -6891,19 +6634,6 @@
"node": ">=12"
}
},
- "node_modules/d3-scale-chromatic": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
- "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
- "license": "ISC",
- "dependencies": {
- "d3-color": "1 - 3",
- "d3-interpolate": "1 - 3"
- },
- "engines": {
- "node": ">=12"
- }
- },
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
@@ -6940,6 +6670,15 @@
"node": ">=12"
}
},
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -7034,6 +6773,12 @@
}
}
},
+ "node_modules/decimal.js-light": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+ "license": "MIT"
+ },
"node_modules/decode-named-character-reference": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@@ -7067,7 +6812,7 @@
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
- "devOptional": true,
+ "dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=16.0.0"
@@ -7112,18 +6857,9 @@
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
- "node_modules/delaunator": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
- "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
- "license": "ISC",
- "dependencies": {
- "robust-predicates": "^3.0.2"
- }
- },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -7146,7 +6882,7 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/detect-libc": {
@@ -7196,6 +6932,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/dom-helpers": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.8.7",
+ "csstype": "^3.0.2"
+ }
+ },
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
@@ -7210,7 +6956,7 @@
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
- "devOptional": true,
+ "dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@@ -7246,7 +6992,7 @@
"version": "3.18.4",
"resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz",
"integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
@@ -7270,7 +7016,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
@@ -7990,7 +7736,7 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/extend": {
@@ -8003,7 +7749,7 @@
"version": "3.23.2",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
- "devOptional": true,
+ "dev": true,
"funding": [
{
"type": "individual",
@@ -8433,7 +8179,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"citty": "^0.1.6",
@@ -11317,7 +11063,7 @@
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/nodemailer": {
@@ -11339,7 +11085,7 @@
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.4.tgz",
"integrity": "sha512-1TvCKjZyyklN+JJj2TS3P4uSQEInrM/HkkuSXsEzm1ApPgBffOn8gFguNnZf07r/1X6vlryfIqMUkJKQMzlZiw==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"citty": "^0.2.0",
@@ -11357,7 +11103,7 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.0.tgz",
"integrity": "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/oauth4webapi": {
@@ -11506,7 +11252,7 @@
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/openai": {
@@ -11699,7 +11445,7 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/pdf-parse": {
@@ -11738,7 +11484,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
- "devOptional": true,
+ "dev": true,
"license": "MIT"
},
"node_modules/performance-now": {
@@ -11771,7 +11517,7 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
"integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.2.2",
@@ -11783,7 +11529,7 @@
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz",
"integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==",
- "devOptional": true,
+ "dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.0"
@@ -11802,7 +11548,7 @@
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz",
"integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==",
- "devOptional": true,
+ "dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
@@ -11976,7 +11722,7 @@
"version": "6.19.2",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz",
"integrity": "sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==",
- "devOptional": true,
+ "dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -12296,7 +12042,7 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
- "devOptional": true,
+ "dev": true,
"funding": [
{
"type": "individual",
@@ -12362,7 +12108,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"dependencies": {
"defu": "^6.1.4",
@@ -12544,6 +12290,21 @@
}
}
},
+ "node_modules/react-smooth": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
+ "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-equals": "^5.0.1",
+ "prop-types": "^15.8.1",
+ "react-transition-group": "^4.4.5"
+ },
+ "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/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -12583,14 +12344,30 @@
"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==",
+ "node_modules/react-transition-group": {
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+ "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@babel/runtime": "^7.5.5",
+ "dom-helpers": "^5.0.1",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.6.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.6.0",
+ "react-dom": ">=16.6.0"
+ }
+ },
+ "node_modules/react-transition-state": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/react-transition-state/-/react-transition-state-2.3.3.tgz",
+ "integrity": "sha512-wsIyg07ohlWEAYDZHvuXh/DY7mxlcLb0iqVv2aMXJ0gwgPVKNWKhOyNyzuJy/tt/6urSq0WT6BBZ/tdpybaAsQ==",
"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"
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
}
},
"node_modules/readable-stream": {
@@ -12611,7 +12388,7 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
@@ -12621,6 +12398,50 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/recharts": {
+ "version": "2.15.4",
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
+ "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
+ "license": "MIT",
+ "dependencies": {
+ "clsx": "^2.0.0",
+ "eventemitter3": "^4.0.1",
+ "lodash": "^4.17.21",
+ "react-is": "^18.3.1",
+ "react-smooth": "^4.0.4",
+ "recharts-scale": "^0.4.4",
+ "tiny-invariant": "^1.3.1",
+ "victory-vendor": "^36.6.8"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/recharts-scale": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
+ "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
+ "license": "MIT",
+ "dependencies": {
+ "decimal.js-light": "^2.4.1"
+ }
+ },
+ "node_modules/recharts/node_modules/eventemitter3": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
+ "license": "MIT"
+ },
+ "node_modules/recharts/node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "license": "MIT"
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -12875,12 +12696,6 @@
"node": ">= 0.8.15"
}
},
- "node_modules/robust-predicates": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
- "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==",
- "license": "Unlicense"
- },
"node_modules/rollup": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz",
@@ -13681,6 +13496,12 @@
"readable-stream": "3"
}
},
+ "node_modules/tiny-invariant": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+ "license": "MIT"
+ },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -13692,7 +13513,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
- "devOptional": true,
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
@@ -13962,6 +13783,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -14356,6 +14178,28 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/victory-vendor": {
+ "version": "36.9.2",
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
+ "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
+ "license": "MIT AND ISC",
+ "dependencies": {
+ "@types/d3-array": "^3.0.3",
+ "@types/d3-ease": "^3.0.0",
+ "@types/d3-interpolate": "^3.0.1",
+ "@types/d3-scale": "^4.0.2",
+ "@types/d3-shape": "^3.1.0",
+ "@types/d3-time": "^3.0.0",
+ "@types/d3-timer": "^3.0.0",
+ "d3-array": "^3.1.6",
+ "d3-ease": "^3.0.1",
+ "d3-interpolate": "^3.0.1",
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.1.0",
+ "d3-time": "^3.0.0",
+ "d3-timer": "^3.0.1"
+ }
+ },
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
diff --git a/package.json b/package.json
index cdb6680..f764a96 100644
--- a/package.json
+++ b/package.json
@@ -31,11 +31,6 @@
"@hookform/resolvers": "^3.9.1",
"@mantine/core": "^8.3.13",
"@mantine/hooks": "^8.3.13",
- "@nivo/bar": "^0.99.0",
- "@nivo/core": "^0.99.0",
- "@nivo/line": "^0.99.0",
- "@nivo/pie": "^0.99.0",
- "@nivo/scatterplot": "^0.99.0",
"@notionhq/client": "^2.3.0",
"@prisma/client": "^6.19.2",
"@radix-ui/react-alert-dialog": "^1.1.4",
@@ -57,9 +52,9 @@
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.1.6",
- "@react-spring/web": "^10.0.3",
"@tailwindcss/postcss": "^4.1.18",
"@tanstack/react-query": "^5.62.0",
+ "@tremor/react": "^3.18.7",
"@trpc/client": "^11.0.0-rc.678",
"@trpc/react-query": "^11.0.0-rc.678",
"@trpc/server": "^11.0.0-rc.678",
diff --git a/src/app/(observer)/observer/loading.tsx b/src/app/(observer)/observer/loading.tsx
new file mode 100644
index 0000000..59f3c8c
--- /dev/null
+++ b/src/app/(observer)/observer/loading.tsx
@@ -0,0 +1,88 @@
+import { Skeleton } from '@/components/ui/skeleton'
+import { Card, CardContent, CardHeader } from '@/components/ui/card'
+
+export default function ObserverLoading() {
+ return (
+
+ {/* Header */}
+
+
+ {/* 6 stat tiles */}
+
+ {[...Array(6)].map((_, i) => (
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ {/* Pipeline */}
+
+
+
+
+
+
+ {[...Array(4)].map((_, i) => (
+
+ ))}
+
+
+
+
+ {/* 3-col middle row */}
+
+ {[...Array(3)].map((_, i) => (
+
+
+
+
+
+
+
+
+ ))}
+
+
+ {/* 2-col bottom row */}
+
+
+
+
+
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+
+
+
+
+
+
+ {[...Array(5)].map((_, i) => (
+
+
+
+
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/src/app/(observer)/observer/projects/loading.tsx b/src/app/(observer)/observer/projects/loading.tsx
new file mode 100644
index 0000000..05c2f9e
--- /dev/null
+++ b/src/app/(observer)/observer/projects/loading.tsx
@@ -0,0 +1,37 @@
+import { Skeleton } from '@/components/ui/skeleton'
+import { Card, CardContent, CardHeader } from '@/components/ui/card'
+
+export default function ObserverProjectsLoading() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {[...Array(8)].map((_, i) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/app/(observer)/observer/projects/page.tsx b/src/app/(observer)/observer/projects/page.tsx
new file mode 100644
index 0000000..3cd745c
--- /dev/null
+++ b/src/app/(observer)/observer/projects/page.tsx
@@ -0,0 +1,8 @@
+import { ObserverProjectsContent } from '@/components/observer/observer-projects-content'
+
+export const metadata = { title: 'Observer — Projects' }
+export const dynamic = 'force-dynamic'
+
+export default function ObserverProjectsPage() {
+ return
+}
diff --git a/src/app/(observer)/observer/reports/loading.tsx b/src/app/(observer)/observer/reports/loading.tsx
new file mode 100644
index 0000000..242b3c6
--- /dev/null
+++ b/src/app/(observer)/observer/reports/loading.tsx
@@ -0,0 +1,57 @@
+import { Skeleton } from '@/components/ui/skeleton'
+import { Card, CardContent, CardHeader } from '@/components/ui/card'
+
+export default function ObserverReportsLoading() {
+ return (
+
+ {/* Header */}
+
+
+
+
+
+ {/* Round selector */}
+
+
+ {/* Tab bar */}
+
+
+ {/* 3 stat tiles */}
+
+ {[...Array(3)].map((_, i) => (
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ {/* Chart skeleton */}
+
+
+
+
+
+
+
+
+
+ {/* Table skeleton */}
+
+
+
+
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/app/(observer)/observer/reports/page.tsx b/src/app/(observer)/observer/reports/page.tsx
index 776e349..df421d0 100644
--- a/src/app/(observer)/observer/reports/page.tsx
+++ b/src/app/(observer)/observer/reports/page.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useState, useEffect } from 'react'
+import { useState, useEffect, Suspense, useCallback } from 'react'
import { useSearchParams } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import {
@@ -30,33 +30,28 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { Button } from '@/components/ui/button'
import {
FileSpreadsheet,
BarChart3,
Users,
- ClipboardList,
- CheckCircle2,
TrendingUp,
GitCompare,
- UserCheck,
- Globe,
+ Download,
+ Clock,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
import {
ScoreDistributionChart,
EvaluationTimelineChart,
StatusBreakdownChart,
- JurorWorkloadChart,
- ProjectRankingsChart,
CriteriaScoresChart,
CrossStageComparisonChart,
- JurorConsistencyChart,
- DiversityMetricsChart,
} from '@/components/charts'
+import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
import { AnimatedCard } from '@/components/shared/animated-container'
-// Parse selection value: "all:programId" for edition-wide, or roundId
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
if (!value) return {}
if (value.startsWith('all:')) return { programId: value.slice(4) }
@@ -73,130 +68,114 @@ const ROUND_TYPE_LABELS: Record = {
DELIBERATION: 'Deliberation',
}
-function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
- const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true })
+type Stage = {
+ id: string
+ name: string
+ status: string
+ roundType: string
+ windowCloseAt: Date | null
+ _count: { projects: number; assignments: number }
+ programId: string
+ programName: string
+}
- const stages = programs?.flatMap(p =>
- ((p.stages || []) as { id: string; name: string; status: string; roundType: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map(s => ({
- ...s,
- programName: `${p.year} Edition`,
- }))
- ) || []
+function roundStatusLabel(status: string): string {
+ if (status === 'ROUND_ACTIVE') return 'Active'
+ if (status === 'ROUND_CLOSED') return 'Closed'
+ if (status === 'ROUND_DRAFT') return 'Draft'
+ if (status === 'ROUND_ARCHIVED') return 'Archived'
+ return status
+}
+function roundStatusVariant(status: string): 'default' | 'secondary' | 'outline' {
+ if (status === 'ROUND_ACTIVE') return 'default'
+ if (status === 'ROUND_CLOSED') return 'secondary'
+ return 'outline'
+}
+
+function ProgressTab({ selectedValue, stages, stagesLoading, selectedRound }: {
+ selectedValue: string | null
+ stages: Stage[]
+ stagesLoading: boolean
+ selectedRound: Stage | undefined
+}) {
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: overviewStats, isLoading: statsLoading } =
- trpc.analytics.getOverviewStats.useQuery(
- queryInput,
- { enabled: hasSelection }
- )
+ trpc.analytics.getOverviewStats.useQuery(queryInput, { enabled: hasSelection })
- if (isLoading) {
- return (
-
-
- {[...Array(4)].map((_, i) => (
-
-
-
-
-
-
-
-
-
- ))}
-
-
- )
- }
+ const { data: timeline, isLoading: timelineLoading } =
+ trpc.analytics.getEvaluationTimeline.useQuery(queryInput, { enabled: hasSelection })
- const totalProjects = overviewStats?.projectCount ?? 0
- const activeRounds = stages.filter((s) => s.status === 'ROUND_ACTIVE').length
- const totalPrograms = programs?.length || 0
+ const [csvOpen, setCsvOpen] = useState(false)
+ const [csvData, setCsvData] = useState<{ data: Record[]; columns: string[] } | undefined>()
+ const [csvLoading, setCsvLoading] = useState(false)
+
+ const handleRequestCsvData = useCallback(async () => {
+ setCsvLoading(true)
+ const columns = ['roundName', 'roundType', 'status', 'projects', 'assignments', 'completionRate']
+ const data = stages.map((s) => {
+ const assigned = s._count.assignments
+ const projects = s._count.projects
+ const rate = assigned > 0 && projects > 0 ? Math.round((assigned / projects) * 100) : 0
+ return {
+ roundName: s.name,
+ roundType: ROUND_TYPE_LABELS[s.roundType] || s.roundType,
+ status: roundStatusLabel(s.status),
+ projects,
+ assignments: assigned,
+ completionRate: rate,
+ }
+ })
+ const result = { data, columns }
+ setCsvData(result)
+ setCsvLoading(false)
+ return result
+ }, [stages])
return (
- {/* Quick Stats */}
-
-
-
-
-
-
-
Total Rounds
-
{stages.length}
-
- {activeRounds} active
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Total Projects
-
{totalProjects}
-
Across all rounds
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Active Rounds
-
{activeRounds}
-
Currently active
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Programs
-
{totalPrograms}
-
Total programs
-
-
-
-
-
-
-
-
+
+
+
Progress Overview
+
Evaluation progress across rounds
+
+
+ {selectedValue && !selectedValue.startsWith('all:') && (
+
+ )}
+
+
- {/* Round/edition-specific overview stats */}
+
+
+ {/* Stats tiles */}
{hasSelection && (
<>
{statsLoading ? (
-
- {[...Array(4)].map((_, i) => (
+
+ {[...Array(3)].map((_, i) => (
@@ -209,270 +188,405 @@ function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
))}
) : overviewStats ? (
-
-
{queryInput.programId ? 'Edition Overview' : 'Selected Round Details'}
-
-
-
- Projects
-
-
-
- {overviewStats.projectCount}
- In this round
+
+
+
+
+
+
+
Project Count
+
{overviewStats.projectCount}
+
In selection
+
+
+
+
+
+
-
-
- Assignments
-
-
-
- {overviewStats.assignmentCount}
-
- {overviewStats.jurorCount} jurors
-
+
+
+
+
+
+
Evaluation Count
+
{overviewStats.evaluationCount}
+
Submitted
+
+
+
+
+
+
-
-
- Evaluations
-
-
-
- {overviewStats.evaluationCount}
- Submitted
+
+
+
+
+
+
+
Completion Rate
+
{overviewStats.completionRate}%
+
+
+
+
+
+
+
-
-
-
- Completion
-
-
-
- {overviewStats.completionRate}%
-
-
-
-
+
) : null}
>
)}
- {/* Stages Table - Desktop */}
-
-
- Round Reports
- Progress overview for each round
-
-
-
-
-
- Round
- Program
- Projects
- Status
-
-
-
- {stages.map((stage) => (
-
-
-
-
{stage.name}
- {stage.windowCloseAt && (
-
- Ends: {formatDateOnly(stage.windowCloseAt)}
-
- )}
-
-
- {stage.programName}
- {stage._count?.projects || '-'}
-
-
- {stage.status === 'ROUND_ACTIVE' ? 'Active' : stage.status === 'ROUND_CLOSED' ? 'Closed' : stage.status}
-
-
-
- ))}
-
-
-
-
+ {/* Completion Timeline */}
+ {hasSelection && (
+ <>
+ {timelineLoading ? (
+
+ ) : timeline?.length ? (
+
+ ) : (
+
+
+ No evaluation timeline data available yet
+
+
+ )}
+ >
+ )}
- {/* Stages Cards - Mobile */}
-
-
Round Reports
- {stages.map((stage) => (
-
-
-
-
{stage.name}
-
- {stage.status === 'ROUND_ACTIVE' ? 'Active' : stage.status === 'ROUND_CLOSED' ? 'Closed' : stage.status}
-
-
- {stage.programName}
- {stage.windowCloseAt && (
-
- Ends: {formatDateOnly(stage.windowCloseAt)}
-
- )}
-
- {stage._count?.projects || 0} projects
-
+ {/* Round Breakdown Table - Desktop */}
+ {stagesLoading ? (
+
+ ) : (
+ <>
+
+
+ Round Breakdown
+ Progress overview for each round
+
+
+
+
+
+ Round
+ Type
+ Status
+ Projects
+ Assignments
+ Completion
+ Avg Days
+
+
+
+ {stages.map((stage) => {
+ const projects = stage._count.projects
+ const assignments = stage._count.assignments
+ const rate = assignments > 0 && projects > 0 ? Math.round((assignments / projects) * 100) : 0
+ return (
+
+
+
+
{stage.name}
+ {stage.windowCloseAt && (
+
+
+ {formatDateOnly(stage.windowCloseAt)}
+
+ )}
+
+
+
+
+ {ROUND_TYPE_LABELS[stage.roundType] || stage.roundType}
+
+
+
+
+ {roundStatusLabel(stage.status)}
+
+
+ {projects}
+ {assignments}
+
+
+
+ -
+
+ )
+ })}
+
+
- ))}
-
+
+ {/* Round Breakdown Cards - Mobile */}
+
+
Round Breakdown
+ {stages.map((stage) => {
+ const projects = stage._count.projects
+ const assignments = stage._count.assignments
+ const rate = assignments > 0 && projects > 0 ? Math.round((assignments / projects) * 100) : 0
+ return (
+
+
+
+
{stage.name}
+
+ {roundStatusLabel(stage.status)}
+
+
+
+
+ {ROUND_TYPE_LABELS[stage.roundType] || stage.roundType}
+
+
+
+
+
Projects
+
{projects}
+
+
+
Assignments
+
{assignments}
+
+
+
+
+ Completion
+ {rate}%
+
+
+
+ {stage.windowCloseAt && (
+
+
+ Closes: {formatDateOnly(stage.windowCloseAt)}
+
+ )}
+
+
+ )
+ })}
+
+ >
+ )}
)
}
-function AnalyticsTab({ selectedValue }: { selectedValue: string }) {
+function JurorsTab({ selectedValue }: { selectedValue: string }) {
+ const queryInput = parseSelection(selectedValue)
+ const hasSelection = !!queryInput.roundId || !!queryInput.programId
+
+ const { data: workload, isLoading: workloadLoading } =
+ trpc.analytics.getJurorWorkload.useQuery(queryInput, { enabled: hasSelection })
+
+ const { data: consistency, isLoading: consistencyLoading } =
+ trpc.analytics.getJurorConsistency.useQuery(queryInput, { enabled: hasSelection })
+
+ const [csvOpen, setCsvOpen] = useState(false)
+ const [csvData, setCsvData] = useState<{ data: Record
[]; columns: string[] } | undefined>()
+ const [csvLoading, setCsvLoading] = useState(false)
+
+ type WorkloadItem = { id: string; name: string; assigned: number; completed: number; completionRate: number }
+ type ConsistencyJuror = { userId: string; name: string; evaluationCount: number; averageScore: number; stddev: number; isOutlier: boolean }
+
+ const handleRequestCsvData = useCallback(async () => {
+ setCsvLoading(true)
+ const columns = ['name', 'assigned', 'completed', 'completionRate', 'avgScore', 'stddev', 'isOutlier']
+
+ const workloadMap = new Map()
+ if (workload) {
+ for (const w of (workload as unknown as WorkloadItem[])) {
+ workloadMap.set(w.id, w)
+ }
+ }
+
+ const jurors = (consistency as { overallAverage: number; jurors: ConsistencyJuror[] } | undefined)?.jurors ?? []
+ const data = jurors.map((j) => {
+ const w = workloadMap.get(j.userId)
+ return {
+ name: j.name,
+ assigned: w?.assigned ?? '-',
+ completed: w?.completed ?? '-',
+ completionRate: w ? `${w.completionRate}%` : '-',
+ avgScore: j.averageScore,
+ stddev: j.stddev,
+ isOutlier: j.isOutlier ? 'Yes' : 'No',
+ }
+ })
+
+ const result = { data, columns }
+ setCsvData(result)
+ setCsvLoading(false)
+ return result
+ }, [workload, consistency])
+
+ const isLoading = workloadLoading || consistencyLoading
+
+ type JurorRow = {
+ userId: string
+ name: string
+ assigned: number
+ completed: number
+ completionRate: number
+ averageScore: number
+ stddev: number
+ isOutlier: boolean
+ }
+
+ const jurors: JurorRow[] = (() => {
+ if (!consistency) return []
+ const workloadMap = new Map()
+ if (workload) {
+ for (const w of (workload as unknown as WorkloadItem[])) {
+ workloadMap.set(w.id, w)
+ }
+ }
+ const jurorList = (consistency as { overallAverage: number; jurors: ConsistencyJuror[] }).jurors ?? []
+ return jurorList
+ .map((j) => {
+ const w = workloadMap.get(j.userId)
+ return {
+ userId: j.userId,
+ name: j.name,
+ assigned: w?.assigned ?? 0,
+ completed: w?.completed ?? 0,
+ completionRate: w?.completionRate ?? 0,
+ averageScore: j.averageScore,
+ stddev: j.stddev,
+ isOutlier: j.isOutlier,
+ }
+ })
+ .sort((a, b) => b.assigned - a.assigned)
+ })()
+
+ return (
+
+
+
+
Juror Performance
+
Workload and scoring consistency per juror
+
+
+
+
+
+
+ {/* Juror Performance Table */}
+ {isLoading ? (
+
+ ) : jurors.length > 0 ? (
+
+
+
+
+
+ Juror
+ Assigned
+ Completed
+ Rate
+ Avg Score
+ Std Dev
+ Status
+
+
+
+ {jurors.map((j) => (
+
+ {j.name}
+ {j.assigned}
+ {j.completed}
+
+
+
+
{j.completionRate}%
+
+
+ {j.averageScore.toFixed(2)}
+ {j.stddev.toFixed(2)}
+
+ {j.isOutlier ? (
+ Outlier
+ ) : (
+ Normal
+ )}
+
+
+ ))}
+
+
+
+
+ ) : hasSelection ? (
+
+
+ No juror data available for this selection
+
+
+ ) : null}
+
+ {/* Heatmap placeholder */}
+
+
+
+ Juror scoring heatmap
+ Coming soon
+
+
+
+ )
+}
+
+function ScoresTab({ selectedValue }: { selectedValue: string }) {
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: scoreDistribution, isLoading: scoreLoading } =
- trpc.analytics.getScoreDistribution.useQuery(
- queryInput,
- { enabled: hasSelection }
- )
-
- const { data: timeline, isLoading: timelineLoading } =
- trpc.analytics.getEvaluationTimeline.useQuery(
- queryInput,
- { enabled: hasSelection }
- )
+ trpc.analytics.getScoreDistribution.useQuery(queryInput, { enabled: hasSelection })
const { data: statusBreakdown, isLoading: statusLoading } =
- trpc.analytics.getStatusBreakdown.useQuery(
- queryInput,
- { enabled: hasSelection }
- )
-
- const { data: jurorWorkload, isLoading: workloadLoading } =
- trpc.analytics.getJurorWorkload.useQuery(
- queryInput,
- { enabled: hasSelection }
- )
-
- const { data: projectRankings, isLoading: rankingsLoading } =
- trpc.analytics.getProjectRankings.useQuery(
- { ...queryInput, limit: 15 },
- { enabled: hasSelection }
- )
+ trpc.analytics.getStatusBreakdown.useQuery(queryInput, { enabled: hasSelection })
const { data: criteriaScores, isLoading: criteriaLoading } =
- trpc.analytics.getCriteriaScores.useQuery(
- queryInput,
- { enabled: hasSelection }
- )
+ trpc.analytics.getCriteriaScores.useQuery(queryInput, { enabled: hasSelection })
- return (
-
- {/* Row 1: Score Distribution & Status Breakdown */}
-
- {scoreLoading ? (
-
- ) : scoreDistribution ? (
-
- ) : null}
-
- {statusLoading ? (
-
- ) : statusBreakdown ? (
-
- ) : null}
-
-
- {/* Row 2: Evaluation Timeline */}
- {timelineLoading ? (
-
- ) : timeline?.length ? (
-
- ) : (
-
-
-
- No evaluation data available yet
-
-
-
- )}
-
- {/* Row 3: Criteria Scores */}
- {criteriaLoading ? (
-
- ) : criteriaScores?.length ? (
-
- ) : null}
-
- {/* Row 4: Juror Workload */}
- {workloadLoading ? (
-
- ) : jurorWorkload?.length ? (
-
- ) : (
-
-
-
- No juror assignments yet
-
-
-
- )}
-
- {/* Row 5: Project Rankings */}
- {rankingsLoading ? (
-
- ) : projectRankings?.length ? (
-
- ) : (
-
-
-
- No project scores available yet
-
-
-
- )}
-
- )
-}
-
-function CrossStageTab() {
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
- const stages = programs?.flatMap(p =>
- ((p.stages || []) as Array<{ id: string; name: string }>).map(s => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
- ) || []
+ const crossStageRounds = programs?.flatMap(p =>
+ ((p.stages || []) as Array<{ id: string; name: string }>).map(s => ({
+ id: s.id,
+ name: s.name,
+ programName: `${p.year} Edition`,
+ }))
+ ) ?? []
const [selectedRoundIds, setSelectedRoundIds] = useState([])
@@ -484,38 +598,131 @@ function CrossStageTab() {
const toggleRound = (roundId: string) => {
setSelectedRoundIds((prev) =>
- prev.includes(roundId)
- ? prev.filter((id) => id !== roundId)
- : [...prev, roundId]
+ prev.includes(roundId) ? prev.filter((id) => id !== roundId) : [...prev, roundId]
)
}
- if (programsLoading) return
+ const [csvOpen, setCsvOpen] = useState(false)
+ const [csvData, setCsvData] = useState<{ data: Record[]; columns: string[] } | undefined>()
+ const [csvLoading, setCsvLoading] = useState(false)
+
+ type CriterionItem = { criterionName: string; averageScore: number; count: number }
+
+ const handleRequestCsvData = useCallback(async () => {
+ setCsvLoading(true)
+ const columns = ['criterionName', 'averageScore', 'count']
+ const data = ((criteriaScores as CriterionItem[] | undefined) ?? []).map((c) => ({
+ criterionName: c.criterionName,
+ averageScore: c.averageScore,
+ count: c.count,
+ }))
+ const result = { data, columns }
+ setCsvData(result)
+ setCsvLoading(false)
+ return result
+ }, [criteriaScores])
return (
+
+
+
Scores & Analytics
+
Score distributions, criteria breakdown and cross-round comparison
+
+
+
+
+
+
+ {/* Score Distribution & Status Breakdown */}
+
+ {scoreLoading ? (
+
+ ) : scoreDistribution ? (
+
+ ) : hasSelection ? (
+
+
+ No score data available yet
+
+
+ ) : null}
+
+ {statusLoading ? (
+
+ ) : statusBreakdown ? (
+
+ ) : hasSelection ? (
+
+
+ No status data available yet
+
+
+ ) : null}
+
+
+ {/* Criteria Breakdown */}
+ {criteriaLoading ? (
+
+ ) : criteriaScores?.length ? (
+
+ ) : hasSelection ? (
+
+
+ No criteria score data available yet
+
+
+ ) : null}
+
+ {/* Cross-Round Comparison */}
- Select Rounds to Compare
- Choose at least 2 rounds
+
+
+ Cross-Round Comparison
+
+ Select at least 2 rounds to compare metrics
-
-
- {stages.map((stage) => (
- toggleRound(stage.id)}
- aria-label={stage.name}
- >
- {stage.name}
-
- ))}
-
+
+ {programsLoading ? (
+
+ ) : (
+
+ {crossStageRounds.map((stage) => (
+ toggleRound(stage.id)}
+ aria-label={stage.name}
+ >
+ {stage.name}
+
+ ))}
+
+ )}
{selectedRoundIds.length < 2 && (
-
+
Select at least 2 rounds to enable comparison
)}
@@ -526,8 +733,12 @@ function CrossStageTab() {
{comparison && (
} />
)}
@@ -535,77 +746,21 @@ function CrossStageTab() {
)
}
-function JurorConsistencyTab({ selectedValue }: { selectedValue: string }) {
- const queryInput = parseSelection(selectedValue)
- const hasSelection = !!queryInput.roundId || !!queryInput.programId
-
- const { data: consistency, isLoading } =
- trpc.analytics.getJurorConsistency.useQuery(
- queryInput,
- { enabled: hasSelection }
- )
-
- if (isLoading) return
-
- if (!consistency) return null
-
- return (
-
- }}
- />
- )
-}
-
-function DiversityTab({ selectedValue }: { selectedValue: string }) {
- const queryInput = parseSelection(selectedValue)
- const hasSelection = !!queryInput.roundId || !!queryInput.programId
-
- const { data: diversity, isLoading } =
- trpc.analytics.getDiversityMetrics.useQuery(
- queryInput,
- { enabled: hasSelection }
- )
-
- if (isLoading) return
-
- if (!diversity) return null
-
- return (
-
- )
-}
-
-export default function ObserverReportsPage() {
+function ReportsPageContent() {
const searchParams = useSearchParams()
const roundFromUrl = searchParams.get('round')
const [selectedValue, setSelectedValue] = useState(roundFromUrl)
const { data: programs, isLoading: stagesLoading } = trpc.program.list.useQuery({ includeStages: true })
- const stages = programs?.flatMap(p =>
+ const stages: Stage[] = programs?.flatMap(p =>
((p.stages || []) as { id: string; name: string; status: string; roundType: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map(s => ({
...s,
programId: p.id,
programName: `${p.year} Edition`,
}))
- ) || []
+ ) ?? []
- // Set default selected round — prefer URL param, then active round, then first
useEffect(() => {
if (stages.length && !selectedValue) {
const active = stages.find((s) => s.status === 'ROUND_ACTIVE')
@@ -613,20 +768,15 @@ export default function ObserverReportsPage() {
}
}, [stages.length, selectedValue])
- const hasSelection = !!selectedValue
const selectedRound = stages.find((s) => s.id === selectedValue)
return (
- {/* Header */}
Reports
-
- View evaluation progress and statistics
-
+
View evaluation progress and statistics
- {/* Stage Selector */}
{stagesLoading ? (
@@ -654,90 +804,57 @@ export default function ObserverReportsPage() {
)}
- {/* Tabs */}
-
-
-
-
-
- Overview
-
-
-
- Analytics
-
-
-
- Cross-Round
-
-
-
- Juror Consistency
-
-
-
- Diversity
-
-
- {selectedValue && !selectedValue.startsWith('all:') && (
-
- )}
-
+
+
+
+
+ Progress
+
+
+
+ Jurors
+
+
+
+ Scores & Analytics
+
+
-
-
+
+
-
- {hasSelection ? (
-
+
+ {selectedValue ? (
+
+ ) : (
+
+
+
+ Select a round
+
+ Choose a round or edition from the dropdown above to view juror data
+
+
+
+ )}
+
+
+
+ {selectedValue ? (
+
) : (
Select a round
- Choose a round or edition from the dropdown above to view analytics
-
-
-
- )}
-
-
-
-
-
-
-
- {hasSelection ? (
-
- ) : (
-
-
-
- Select a round
-
- Choose a round or edition above to view juror consistency metrics
-
-
-
- )}
-
-
-
- {hasSelection ? (
-
- ) : (
-
-
-
- Select a round
-
- Choose a round or edition above to view diversity metrics
+ Choose a round or edition from the dropdown above to view scores and analytics
@@ -747,3 +864,22 @@ export default function ObserverReportsPage() {
)
}
+
+export default function ObserverReportsPage() {
+ return (
+
+
+
+
+
+
+
+
+ }
+ >
+
+
+ )
+}
diff --git a/src/components/charts/chart-theme.ts b/src/components/charts/chart-theme.ts
index a225813..ae40a0a 100644
--- a/src/components/charts/chart-theme.ts
+++ b/src/components/charts/chart-theme.ts
@@ -1,5 +1,3 @@
-import type { PartialTheme } from '@nivo/theming'
-
// Brand colors from CLAUDE.md
export const BRAND_DARK_BLUE = '#053d57'
export const BRAND_RED = '#de0f1e'
@@ -73,50 +71,6 @@ function lerpColor(a: string, b: string, t: number): string {
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
diff --git a/src/components/charts/criteria-scores.tsx b/src/components/charts/criteria-scores.tsx
index 588b105..b1d1d93 100644
--- a/src/components/charts/criteria-scores.tsx
+++ b/src/components/charts/criteria-scores.tsx
@@ -1,8 +1,8 @@
'use client'
-import { ResponsiveBar } from '@nivo/bar'
+import { BarChart } from '@tremor/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { nivoTheme, scoreGradient } from './chart-theme'
+import { BRAND_TEAL } from './chart-theme'
interface CriteriaScoreData {
id: string
@@ -15,13 +15,6 @@ interface CriteriaScoresProps {
data: CriteriaScoreData[]
}
-type CriterionBarDatum = {
- criterion: string
- averageScore: number
- fullName: string
- count: number
-}
-
export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
if (!data?.length) return null
@@ -30,12 +23,10 @@ export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
? data.reduce((sum, d) => sum + d.averageScore, 0) / data.length
: 0
- const chartData: CriterionBarDatum[] = data.map((d) => ({
+ const chartData = data.map((d) => ({
criterion:
d.name.length > 25 ? d.name.substring(0, 25) + '...' : d.name,
- averageScore: d.averageScore,
- fullName: d.name,
- count: d.count,
+ 'Avg Score': parseFloat(d.averageScore.toFixed(2)),
}))
return (
@@ -49,55 +40,17 @@ export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
-
-
- scoreGradient(bar.data.averageScore as number)
- }
- valueScale={{ type: 'linear', max: 10 }}
- borderRadius={4}
- enableLabel={true}
- label={(d) => {
- const v = d.value
- return v != null ? Number(v).toFixed(1) : ''
- }}
- labelSkipHeight={12}
- labelTextColor="#ffffff"
- axisBottom={{
- tickRotation: -45,
- }}
- axisLeft={{
- legend: 'Score',
- legendPosition: 'middle',
- legendOffset: -40,
- }}
- margin={{ top: 20, right: 20, bottom: 80, left: 50 }}
- padding={0.25}
- tooltip={({ data: rowData }) => (
-
- {rowData.fullName}
-
- Average Score: {Number(rowData.averageScore).toFixed(2)}
-
- Ratings: {rowData.count}
-
- )}
- animate={true}
- />
-
+
)
diff --git a/src/components/charts/cross-round-comparison.tsx b/src/components/charts/cross-round-comparison.tsx
index 7420cc4..c748829 100644
--- a/src/components/charts/cross-round-comparison.tsx
+++ b/src/components/charts/cross-round-comparison.tsx
@@ -1,8 +1,8 @@
'use client'
-import { ResponsiveBar } from '@nivo/bar'
+import { BarChart } from '@tremor/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { nivoTheme, BRAND_COLORS } from './chart-theme'
+import { BRAND_COLORS } from './chart-theme'
interface StageComparison {
roundId: string
@@ -36,16 +36,14 @@ export function CrossStageComparisonChart({
round.roundName.length > 20
? round.roundName.slice(0, 20) + '...'
: round.roundName,
- projects: round.projectCount,
- evaluations: round.evaluationCount,
- completionRate: round.completionRate,
- avgScore: round.averageScore
+ Projects: round.projectCount,
+ Evaluations: round.evaluationCount,
+ 'Completion Rate': round.completionRate,
+ 'Avg Score': round.averageScore
? parseFloat(round.averageScore.toFixed(2))
: 0,
}))
- const sharedMargin = { top: 10, right: 10, bottom: 40, left: 40 }
-
return (
@@ -58,25 +56,16 @@ export function CrossStageComparisonChart({
Projects
-
-
-
+
@@ -87,25 +76,16 @@ export function CrossStageComparisonChart({
-
-
-
+
@@ -116,30 +96,18 @@ export function CrossStageComparisonChart({
-
- `${v}%`}
- margin={sharedMargin}
- padding={0.3}
- axisBottom={{
- tickRotation: -25,
- }}
- axisLeft={{
- format: (v) => `${v}%`,
- }}
- animate={true}
- />
-
+ `${v}%`}
+ className="h-[200px]"
+ rotateLabelX={{ angle: -25, xAxisHeight: 40 }}
+ />
@@ -150,26 +118,17 @@ export function CrossStageComparisonChart({
-
-
-
+
diff --git a/src/components/charts/diversity-metrics.tsx b/src/components/charts/diversity-metrics.tsx
index 78d2f22..fff8a26 100644
--- a/src/components/charts/diversity-metrics.tsx
+++ b/src/components/charts/diversity-metrics.tsx
@@ -1,10 +1,9 @@
'use client'
-import { ResponsivePie } from '@nivo/pie'
-import { ResponsiveBar } from '@nivo/bar'
+import { DonutChart, BarChart } from '@tremor/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
-import { nivoTheme, BRAND_COLORS } from './chart-theme'
+import { BRAND_COLORS } from './chart-theme'
interface DiversityData {
total: number
@@ -49,10 +48,10 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
)
}
- // Top countries for pie chart (max 10, others grouped)
+ // Top countries for donut chart (max 10, others grouped)
const topCountries = (data.byCountry || []).slice(0, 10)
const otherCountries = (data.byCountry || []).slice(10)
- const countryPieData = otherCountries.length > 0
+ const countryData = otherCountries.length > 0
? [...topCountries, {
country: 'Others',
count: otherCountries.reduce((sum, c) => sum + c.count, 0),
@@ -60,21 +59,19 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
}]
: topCountries
- const nivoPieData = countryPieData.map((c) => ({
- id: c.country === 'Others' ? 'Others' : c.country.toUpperCase(),
- label: getCountryName(c.country),
+ const donutData = countryData.map((c) => ({
+ name: getCountryName(c.country),
value: c.count,
}))
- // Pre-format category and ocean issue data for display
- const formattedCategories = (data.byCategory || []).slice(0, 10).map((c) => ({
+ const categoryData = (data.byCategory || []).slice(0, 10).map((c) => ({
category: formatLabel(c.category),
- count: c.count,
+ Count: c.count,
}))
- const formattedOceanIssues = (data.byOceanIssue || []).slice(0, 15).map((o) => ({
+ const oceanIssueData = (data.byOceanIssue || []).slice(0, 15).map((o) => ({
issue: formatLabel(o.issue),
- count: o.count,
+ Count: o.count,
}))
return (
@@ -114,45 +111,17 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
Geographic Distribution
-
- {nivoPieData.length > 0 ?
: (
-
No geographic data
- )}
-
+ {donutData.length > 0 ? (
+
+ ) : (
+ No geographic data
+ )}
@@ -162,29 +131,17 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
Competition Categories
- {formattedCategories.length > 0 ? (
-
-
-
+ {categoryData.length > 0 ? (
+
) : (
No category data
)}
@@ -193,38 +150,22 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
{/* Ocean Issues */}
- {formattedOceanIssues.length > 0 && (
+ {oceanIssueData.length > 0 && (
Ocean Issues Addressed
-
-
-
+
)}
diff --git a/src/components/charts/evaluation-timeline.tsx b/src/components/charts/evaluation-timeline.tsx
index a296b2c..e207ea9 100644
--- a/src/components/charts/evaluation-timeline.tsx
+++ b/src/components/charts/evaluation-timeline.tsx
@@ -1,8 +1,8 @@
'use client'
-import { ResponsiveLine } from '@nivo/line'
+import { AreaChart } from '@tremor/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { nivoTheme, BRAND_DARK_BLUE } from './chart-theme'
+import { BRAND_DARK_BLUE, BRAND_TEAL } from './chart-theme'
interface TimelineDataPoint {
date: string
@@ -17,26 +17,17 @@ interface EvaluationTimelineProps {
export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
if (!data?.length) return null
- const formattedData = data.map((d) => ({
- ...d,
- dateFormatted: new Date(d.date).toLocaleDateString('en-US', {
- month: 'short',
- day: 'numeric',
- }),
- }))
-
const totalEvaluations =
data.length > 0 ? data[data.length - 1].cumulative : 0
- const lineData = [
- {
- id: 'Cumulative Evaluations',
- data: formattedData.map((d) => ({
- x: d.dateFormatted,
- y: d.cumulative,
- })),
- },
- ]
+ const chartData = data.map((d) => ({
+ date: new Date(d.date).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ }),
+ Cumulative: d.cumulative,
+ Daily: d.daily,
+ }))
return (
@@ -49,57 +40,16 @@ export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
-
-
= 2 ? 'x' : false}
- sliceTooltip={({ slice }) => {
- const point = slice.points[0]
- if (!point) return null
- const dataItem = formattedData.find(
- (d) => d.dateFormatted === point.data.xFormatted
- )
- return (
-
-
{point.data.xFormatted}
-
Cumulative: {point.data.yFormatted}
- {dataItem &&
Daily: {dataItem.daily}
}
-
- )
- }}
- margin={{ top: 20, right: 20, bottom: 50, left: 60 }}
- axisBottom={{
- tickRotation: -45,
- legend: '',
- legendOffset: 36,
- }}
- axisLeft={{
- legend: 'Evaluations',
- legendOffset: -50,
- legendPosition: 'middle',
- }}
- yScale={{ type: 'linear', min: 0, max: 'auto' }}
- />
-
+
)
diff --git a/src/components/charts/juror-consistency.tsx b/src/components/charts/juror-consistency.tsx
index f1ee1ca..75a9ae9 100644
--- a/src/components/charts/juror-consistency.tsx
+++ b/src/components/charts/juror-consistency.tsx
@@ -1,11 +1,6 @@
'use client'
-import { ResponsiveScatterPlot } from '@nivo/scatterplot'
-import type {
- ScatterPlotDatum,
- ScatterPlotNodeProps,
-} from '@nivo/scatterplot'
-import { animated } from '@react-spring/web'
+import { ScatterChart } from '@tremor/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
@@ -17,7 +12,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { AlertTriangle } from 'lucide-react'
-import { nivoTheme, BRAND_DARK_BLUE, BRAND_RED } from './chart-theme'
+import { BRAND_DARK_BLUE, BRAND_RED } from './chart-theme'
interface JurorMetric {
userId: string
@@ -36,60 +31,6 @@ interface JurorConsistencyProps {
}
}
-interface JurorDatum extends ScatterPlotDatum {
- x: number
- y: number
- name: string
- evaluations: number
- isOutlier: boolean
-}
-
-function CustomNode({
- node,
- style,
- blendMode,
- isInteractive,
- onMouseEnter,
- onMouseMove,
- onMouseLeave,
- onClick,
-}: ScatterPlotNodeProps
) {
- const fillColor = node.data.isOutlier ? BRAND_RED : BRAND_DARK_BLUE
-
- return (
- s / 2)}
- fill={fillColor}
- fillOpacity={0.7}
- stroke={fillColor}
- strokeWidth={1}
- style={{ mixBlendMode: blendMode }}
- onMouseEnter={
- isInteractive && onMouseEnter
- ? (event) => onMouseEnter(node, event)
- : undefined
- }
- onMouseMove={
- isInteractive && onMouseMove
- ? (event) => onMouseMove(node, event)
- : undefined
- }
- onMouseLeave={
- isInteractive && onMouseLeave
- ? (event) => onMouseLeave(node, event)
- : undefined
- }
- onClick={
- isInteractive && onClick
- ? (event) => onClick(node, event)
- : undefined
- }
- />
- )
-}
-
export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
if (!data?.jurors?.length) {
return (
@@ -101,21 +42,17 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
)
}
- const scatterData = [
- {
- id: 'Jurors',
- data: data.jurors.map((j) => ({
- x: parseFloat(j.averageScore.toFixed(2)),
- y: parseFloat(j.stddev.toFixed(2)),
- name: j.name,
- evaluations: j.evaluationCount,
- isOutlier: j.isOutlier,
- })),
- },
- ]
-
const outlierCount = data.jurors.filter((j) => j.isOutlier).length
+ const scatterData = data.jurors.map((j) => ({
+ 'Average Score': parseFloat(j.averageScore.toFixed(2)),
+ 'Std Deviation': parseFloat(j.stddev.toFixed(2)),
+ category: j.isOutlier ? 'Outlier' : 'Normal',
+ name: j.name,
+ evaluations: j.evaluationCount,
+ size: Math.max(8, Math.min(20, j.evaluationCount * 2)),
+ }))
+
return (
{/* Scatter: Average Score vs Standard Deviation */}
@@ -134,60 +71,15 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
-
-
- data={scatterData}
- theme={nivoTheme}
- colors={[BRAND_DARK_BLUE]}
- xScale={{ type: 'linear', min: 0, max: 10 }}
- yScale={{ type: 'linear', min: 0, max: 'auto' }}
- axisBottom={{
- legend: 'Average Score',
- legendPosition: 'middle',
- legendOffset: 40,
- }}
- axisLeft={{
- legend: 'Std Deviation',
- legendPosition: 'middle',
- legendOffset: -50,
- }}
- useMesh={true}
- nodeSize={(node) =>
- Math.max(8, Math.min(20, node.data.evaluations * 2))
- }
- nodeComponent={CustomNode}
- margin={{ top: 20, right: 20, bottom: 60, left: 60 }}
- tooltip={({ node }) => (
-
-
{node.data.name}
-
Avg Score: {node.data.x}
-
Std Dev: {node.data.y}
-
Evaluations: {node.data.evaluations}
-
- )}
- markers={[
- {
- axis: 'x',
- value: data.overallAverage,
- lineStyle: {
- stroke: BRAND_RED,
- strokeWidth: 2,
- strokeDasharray: '6 4',
- },
- legend: `Avg: ${data.overallAverage.toFixed(1)}`,
- legendPosition: 'top',
- },
- ]}
- />
-
+
Dot size represents number of evaluations. Red dots indicate outlier
jurors (2+ points from mean).
diff --git a/src/components/charts/juror-workload.tsx b/src/components/charts/juror-workload.tsx
index 81282d6..1ad9d0b 100644
--- a/src/components/charts/juror-workload.tsx
+++ b/src/components/charts/juror-workload.tsx
@@ -1,8 +1,8 @@
'use client'
-import { ResponsiveBar, type ComputedDatum } from '@nivo/bar'
+import { BarChart } from '@tremor/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { nivoTheme } from './chart-theme'
+import { BRAND_DARK_BLUE } from './chart-theme'
interface JurorWorkloadData {
id: string
@@ -16,14 +16,6 @@ interface JurorWorkloadProps {
data: JurorWorkloadData[]
}
-type WorkloadBarDatum = {
- juror: string
- completed: number
- remaining: number
- completionRate: number
- fullName: string
-}
-
export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
if (!data?.length) return null
@@ -36,12 +28,10 @@ export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
(a, b) => b.completionRate - a.completionRate,
)
- const chartData: WorkloadBarDatum[] = sortedData.map((d) => ({
+ const chartData = 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,
+ Completed: d.completed,
+ Remaining: d.assigned - d.completed,
}))
return (
@@ -55,66 +45,17 @@ export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
-
-
) => {
- if (d.id === 'completed') {
- return `${d.data.completionRate}%`
- }
- return ''
- }}
- labelSkipWidth={40}
- labelTextColor={(d) => {
- const datum = d as unknown as { data: ComputedDatum }
- return datum.data.id === 'completed' ? '#ffffff' : '#374151'
- }}
- margin={{ top: 10, right: 30, bottom: 30, left: 160 }}
- padding={0.25}
- groupMode="stacked"
- tooltip={({ id, value, data: rowData }) => (
-
- {rowData.fullName}
-
- {id === 'completed' ? 'Completed' : 'Remaining'}: {value}
-
- Completion: {rowData.completionRate}%
-
- )}
- legends={[
- {
- dataFrom: 'keys',
- anchor: 'bottom',
- direction: 'row',
- translateY: 30,
- itemsSpacing: 20,
- itemWidth: 100,
- itemHeight: 18,
- symbolSize: 12,
- symbolShape: 'square',
- },
- ]}
- animate={true}
- />
-
+ />
)
diff --git a/src/components/charts/project-rankings.tsx b/src/components/charts/project-rankings.tsx
index 909ff9d..d643b82 100644
--- a/src/components/charts/project-rankings.tsx
+++ b/src/components/charts/project-rankings.tsx
@@ -1,8 +1,8 @@
'use client'
-import { ResponsiveBar } from '@nivo/bar'
+import { BarChart } from '@tremor/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { nivoTheme, scoreGradient } from './chart-theme'
+import { BRAND_TEAL } from './chart-theme'
interface ProjectRankingData {
id: string
@@ -18,14 +18,6 @@ interface ProjectRankingsProps {
limit?: number
}
-type RankingBarDatum = {
- project: string
- score: number
- fullTitle: string
- teamName: string
- evaluationCount: number
-}
-
export function ProjectRankingsChart({
data,
limit = 20,
@@ -37,21 +29,12 @@ export function ProjectRankingsChart({
if (!scoredData.length) return null
- const averageScore =
- scoredData.length > 0
- ? scoredData.reduce((sum, d) => sum + d.averageScore, 0) /
- scoredData.length
- : 0
-
const displayData = scoredData.slice(0, limit)
- const chartData: RankingBarDatum[] = displayData.map((d) => ({
+ const chartData = 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,
+ Score: parseFloat(d.averageScore.toFixed(2)),
}))
return (
@@ -65,75 +48,18 @@ export function ProjectRankingsChart({
-
-
scoreGradient(bar.data.score as number)}
- valueScale={{ type: 'linear', max: 10 }}
- borderRadius={4}
- enableLabel={true}
- label={(d) => {
- const v = d.value
- return v != null ? Number(v).toFixed(1) : ''
- }}
- labelSkipWidth={30}
- labelTextColor="#ffffff"
- margin={{ top: 10, right: 30, bottom: 30, left: 200 }}
- padding={0.2}
- markers={[
- {
- axis: 'x',
- value: averageScore,
- lineStyle: {
- stroke: '#6b7280',
- strokeWidth: 2,
- strokeDasharray: '6 4',
- },
- legend: `Avg: ${averageScore.toFixed(1)}`,
- legendPosition: 'top',
- textStyle: {
- fill: '#6b7280',
- fontSize: 11,
- },
- },
- ]}
- tooltip={({ data: rowData }) => (
-
- {rowData.fullTitle}
- {rowData.teamName && (
- <>
-
-
- {rowData.teamName}
-
- >
- )}
-
- Score: {Number(rowData.score).toFixed(2)}
-
- Evaluations: {rowData.evaluationCount}
-
- )}
- animate={true}
- />
-
+
)
diff --git a/src/components/charts/score-distribution.tsx b/src/components/charts/score-distribution.tsx
index f03fcd5..4018e08 100644
--- a/src/components/charts/score-distribution.tsx
+++ b/src/components/charts/score-distribution.tsx
@@ -1,8 +1,8 @@
'use client'
-import { ResponsiveBar } from '@nivo/bar'
+import { BarChart } from '@tremor/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { nivoTheme, scoreGradient } from './chart-theme'
+import { BRAND_TEAL } from './chart-theme'
interface ScoreDistributionProps {
data: { score: number; count: number }[]
@@ -19,7 +19,7 @@ export function ScoreDistributionChart({
const chartData = data.map((d) => ({
score: String(d.score),
- count: d.count,
+ Count: d.count,
}))
return (
@@ -33,32 +33,15 @@ export function ScoreDistributionChart({
-
- scoreGradient(Number(bar.indexValue))}
- borderRadius={4}
- enableLabel={true}
- labelSkipHeight={12}
- labelTextColor="#ffffff"
- axisBottom={{
- legend: 'Score',
- legendPosition: 'middle',
- legendOffset: 36,
- }}
- axisLeft={{
- legend: 'Count',
- legendPosition: 'middle',
- legendOffset: -40,
- }}
- margin={{ top: 20, right: 20, bottom: 50, left: 50 }}
- padding={0.2}
- animate={true}
- />
-
+
)
diff --git a/src/components/charts/status-breakdown.tsx b/src/components/charts/status-breakdown.tsx
index fe0da18..72f25dd 100644
--- a/src/components/charts/status-breakdown.tsx
+++ b/src/components/charts/status-breakdown.tsx
@@ -1,8 +1,8 @@
'use client'
-import { ResponsivePie } from '@nivo/pie'
+import { DonutChart } from '@tremor/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { nivoTheme, getStatusColor, formatStatus } from './chart-theme'
+import { getStatusColor, formatStatus } from './chart-theme'
interface StatusDataPoint {
status: string
@@ -18,13 +18,13 @@ export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
const total = data.reduce((sum, item) => sum + item.count, 0)
- const pieData = data.map((d) => ({
- id: d.status,
- label: formatStatus(d.status),
+ const chartData = data.map((d) => ({
+ name: formatStatus(d.status),
value: d.count,
- color: getStatusColor(d.status),
}))
+ const colors = data.map((d) => getStatusColor(d.status))
+
return (
@@ -36,43 +36,14 @@ export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
-
-
-
+
)
diff --git a/src/components/layouts/observer-nav.tsx b/src/components/layouts/observer-nav.tsx
index 56e3330..e58a561 100644
--- a/src/components/layouts/observer-nav.tsx
+++ b/src/components/layouts/observer-nav.tsx
@@ -1,6 +1,6 @@
'use client'
-import { BarChart3, Home } from 'lucide-react'
+import { BarChart3, Home, FolderKanban } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
interface ObserverNavProps {
@@ -14,6 +14,11 @@ export function ObserverNav({ user }: ObserverNavProps) {
href: '/observer',
icon: Home,
},
+ {
+ name: 'Projects',
+ href: '/observer/projects',
+ icon: FolderKanban,
+ },
{
name: 'Reports',
href: '/observer/reports',
diff --git a/src/components/observer/observer-dashboard-content.tsx b/src/components/observer/observer-dashboard-content.tsx
index 00239fd..89f2006 100644
--- a/src/components/observer/observer-dashboard-content.tsx
+++ b/src/components/observer/observer-dashboard-content.tsx
@@ -7,14 +7,20 @@ import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
- CardDescription,
CardHeader,
CardTitle,
+ CardDescription,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
-import { Input } from '@/components/ui/input'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
import {
Table,
TableBody,
@@ -23,538 +29,568 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select'
-import { Button } from '@/components/ui/button'
import { StatusBadge } from '@/components/shared/status-badge'
+import { AnimatedCard } from '@/components/shared/animated-container'
+import { GeographicSummaryCard } from '@/components/charts/geographic-summary-card'
import {
- FolderKanban,
ClipboardList,
- Users,
- CheckCircle2,
- Eye,
BarChart3,
- Search,
- ChevronLeft,
+ TrendingUp,
+ CheckCircle2,
+ Users,
+ Globe,
ChevronRight,
- ArrowUpDown,
- ArrowUp,
- ArrowDown,
+ Activity,
+ RefreshCw,
} from 'lucide-react'
import { cn } from '@/lib/utils'
-import { AnimatedCard } from '@/components/shared/animated-container'
-import { RoundTypeStatsCards } from '@/components/observer/round-type-stats'
-import { useDebouncedCallback } from 'use-debounce'
-const PER_PAGE_OPTIONS = [10, 20, 50]
+function relativeTime(date: Date | string): string {
+ const now = Date.now()
+ const then = new Date(date).getTime()
+ const diff = Math.floor((now - then) / 1000)
+ if (diff < 60) return `${diff}s ago`
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
+ return `${Math.floor(diff / 86400)}d ago`
+}
+
+function computeAvgScore(scoreDistribution: { label: string; count: number }[]): string {
+ const midpoints: Record = {
+ '9-10': 9.5,
+ '7-8': 7.5,
+ '5-6': 5.5,
+ '3-4': 3.5,
+ '1-2': 1.5,
+ }
+ let total = 0
+ let weightedSum = 0
+ for (const b of scoreDistribution) {
+ const mid = midpoints[b.label]
+ if (mid !== undefined) {
+ weightedSum += mid * b.count
+ total += b.count
+ }
+ }
+ if (total === 0) return '—'
+ return (weightedSum / total).toFixed(1)
+}
+
+const ACTIVITY_DOT_COLORS: Record = {
+ ROUND_ACTIVATED: 'bg-emerald-500',
+ ROUND_CLOSED: 'bg-slate-500',
+ EVALUATION_SUBMITTED: 'bg-blue-500',
+ ASSIGNMENT_CREATED: 'bg-violet-500',
+ PROJECT_ADVANCED: 'bg-teal-500',
+ PROJECT_REJECTED: 'bg-rose-500',
+ RESULT_LOCKED: 'bg-amber-500',
+}
+
+const STATUS_BADGE_VARIANT: Record = {
+ ROUND_ACTIVE: 'default',
+ ROUND_CLOSED: 'secondary',
+ ROUND_DRAFT: 'outline',
+ ROUND_ARCHIVED: 'secondary',
+}
export function ObserverDashboardContent({ userName }: { userName?: string }) {
- const [selectedRoundId, setSelectedRoundId] = useState('all')
- const [search, setSearch] = useState('')
- const [debouncedSearch, setDebouncedSearch] = useState('')
- const [statusFilter, setStatusFilter] = useState('all')
- const [sortBy, setSortBy] = useState<'title' | 'score' | 'evaluations'>('title')
- const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
- const [page, setPage] = useState(1)
- const [perPage, setPerPage] = useState(20)
+ const [selectedProgramId, setSelectedProgramId] = useState('')
+ const [selectedRoundId, setSelectedRoundId] = useState('')
- const debouncedSetSearch = useDebouncedCallback((value: string) => {
- setDebouncedSearch(value)
- setPage(1)
- }, 300)
-
- const handleSearchChange = (value: string) => {
- setSearch(value)
- debouncedSetSearch(value)
- }
-
- const handleRoundChange = (value: string) => {
- setSelectedRoundId(value)
- setPage(1)
- }
-
- const handleStatusChange = (value: string) => {
- setStatusFilter(value)
- setPage(1)
- }
-
- const handleSort = (column: 'title' | 'score' | 'evaluations') => {
- if (sortBy === column) {
- setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
- } else {
- setSortBy(column)
- setSortDir(column === 'title' ? 'asc' : 'desc')
- }
- setPage(1)
- }
-
- const SortIcon = ({ column }: { column: 'title' | 'score' | 'evaluations' }) => {
- if (sortBy !== column) return
- return sortDir === 'asc'
- ?
- :
- }
-
- // Fetch programs/rounds for the filter dropdown
- const { data: programs } = trpc.program.list.useQuery({ includeStages: true })
-
- const rounds = programs?.flatMap((p) =>
- (p.rounds ?? []).map((r: { id: string; name: string; status: string; roundType?: string }) => ({
- id: r.id,
- name: r.name,
- programName: `${p.year} Edition`,
- status: r.status,
- roundType: r.roundType,
- }))
- ) || []
-
- // Default to the active round
- useEffect(() => {
- if (rounds.length && selectedRoundId === 'all') {
- const active = rounds.find((r) => r.status === 'ROUND_ACTIVE')
- if (active) setSelectedRoundId(active.id)
- }
- }, [rounds.length]) // eslint-disable-line react-hooks/exhaustive-deps
-
- // Fetch dashboard stats
- const roundIdParam = selectedRoundId !== 'all' ? selectedRoundId : undefined
- const { data: stats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery(
- { roundId: roundIdParam }
+ const { data: programs } = trpc.program.list.useQuery(
+ { includeStages: true },
+ { refetchInterval: 30_000 },
)
- // Fetch projects
- const { data: projectsData, isLoading: projectsLoading } = trpc.analytics.getAllProjects.useQuery({
- roundId: roundIdParam,
- search: debouncedSearch || undefined,
- status: statusFilter !== 'all' ? statusFilter : undefined,
- sortBy,
- sortDir,
- page,
- perPage,
- })
+ useEffect(() => {
+ if (programs && programs.length > 0 && !selectedProgramId) {
+ const firstProgram = programs[0]
+ setSelectedProgramId(firstProgram.id)
+ const firstRound = (firstProgram.rounds ?? [])[0]
+ if (firstRound) setSelectedRoundId(firstRound.id)
+ }
+ }, [programs, selectedProgramId])
- // Recent rounds for jury completion (reuse existing programs data)
- const recentRounds = rounds.slice(0, 5)
+ const roundIdParam = selectedRoundId || undefined
+
+ const { data: stats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery(
+ { roundId: roundIdParam },
+ { refetchInterval: 30_000 },
+ )
+
+ const selectedProgram = programs?.find((p) => p.id === selectedProgramId)
+ const competitionId = (selectedProgram?.rounds ?? [])[0]?.competitionId as string | undefined
+
+ const { data: roundOverview, isLoading: overviewLoading } = trpc.analytics.getRoundCompletionOverview.useQuery(
+ { competitionId: competitionId! },
+ { enabled: !!competitionId, refetchInterval: 30_000 },
+ )
+
+ const { data: jurorWorkload } = trpc.analytics.getJurorWorkload.useQuery(
+ { programId: selectedProgramId || undefined },
+ { enabled: !!selectedProgramId, refetchInterval: 30_000 },
+ )
+
+ const { data: geoData } = trpc.analytics.getGeographicDistribution.useQuery(
+ { programId: selectedProgramId },
+ { enabled: !!selectedProgramId, refetchInterval: 30_000 },
+ )
+
+ const { data: projectsData } = trpc.analytics.getAllProjects.useQuery(
+ { perPage: 10 },
+ { refetchInterval: 30_000 },
+ )
+
+ const { data: activityFeed } = trpc.analytics.getActivityFeed.useQuery(
+ { limit: 10 },
+ { refetchInterval: 30_000 },
+ )
+
+ const countryCount = geoData ? geoData.length : 0
+
+ const avgScore = stats ? computeAvgScore(stats.scoreDistribution) : '—'
+
+ const topJurors = (jurorWorkload ?? []).slice(0, 5)
+
+ const scoreColors: Record = {
+ '9-10': '#053d57',
+ '7-8': '#1e7a8a',
+ '5-6': '#557f8c',
+ '3-4': '#c4453a',
+ '1-2': '#de0f1e',
+ }
+
+ const maxScoreCount = stats
+ ? Math.max(...stats.scoreDistribution.map((b) => b.count), 1)
+ : 1
return (
{/* Header */}
-
-
Dashboard
-
- Welcome, {userName || 'Observer'}
-
+
+
+
Dashboard
+
Welcome, {userName || 'Observer'}
+
+
+
+
+ Auto-refresh
+
+
+
- {/* Round Filter */}
-
-
-
-
-
- {/* Stats Grid */}
+ {/* Six Stat Tiles */}
{statsLoading ? (
-
- {[...Array(4)].map((_, i) => (
-
-
-
-
-
-
-
-
+
+ {[...Array(6)].map((_, i) => (
+
+
+
+
))}
) : stats ? (
-
- {/* Universal stats: Programs + Projects */}
-
-
-
-
-
-
-
Programs
-
{stats.programCount}
-
- {stats.activeRoundCount} active round{stats.activeRoundCount !== 1 ? 's' : ''}
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ {stats.projectCount}
+ Total Projects
+
+
-
-
-
-
-
-
Projects
-
{stats.projectCount}
-
- {selectedRoundId !== 'all' ? 'In selected round' : 'Across all rounds'}
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ {stats.activeRoundCount}
+ Active Rounds
+
+
- {/* Round-type-aware stats */}
- {selectedRoundId !== 'all' ? (
-
- ) : (
-
-
-
-
-
-
-
Jury Members
-
{stats.jurorCount}
-
Active members
-
-
-
-
-
-
-
-
+
+
+
+
+
+ {avgScore}
+ Avg Score
+
+
-
-
-
-
-
-
Evaluations
-
{stats.submittedEvaluations}
-
-
-
- {stats.completionRate}% completion rate
-
-
-
-
-
-
-
-
-
-
-
- )}
+
+
+
+
+
+ {stats.completionRate}%
+
+ Completion
+
+
+
+
+
+
+
+
+ {stats.jurorCount}
+ Active Jurors
+
+
+
+
+
+
+
+
+ {countryCount}
+ Countries
+
+
) : null}
- {/* Projects Table */}
-
-
-
-
-
-
-
- All Projects
-
-
- {projectsData ? `${projectsData.total} project${projectsData.total !== 1 ? 's' : ''} found` : 'Loading projects...'}
-
-
-
- {/* Search & Filter Bar */}
-
-
-
- handleSearchChange(e.target.value)}
- className="pl-10"
- />
-
-
-
-
-
- {projectsLoading ? (
-
- {[...Array(5)].map((_, i) => (
-
- ))}
-
- ) : projectsData && projectsData.projects.length > 0 ? (
- <>
- {/* Desktop Table */}
-
-
-
-
-
-
-
- Round
- Status
-
-
-
-
-
-
-
-
-
- {projectsData.projects.map((project) => (
- window.location.href = `/observer/projects/${project.id}`}>
-
- e.stopPropagation()}>
- {project.title}
-
-
-
-
- {project.roundName}
-
-
-
-
-
-
- {project.averageScore !== null
- ? project.averageScore.toFixed(2)
- : '-'}
-
-
- {project.evaluationCount}
-
-
- ))}
-
-
+ {/* Pipeline */}
+
+
+
+
+
+
-
- {/* Mobile Cards */}
-
- {projectsData.projects.map((project) => (
-
-
-
-
-
-
- {project.roundName}
+ Competition Pipeline
+
+ Round-by-round progression overview
+
+
+ {overviewLoading || !competitionId ? (
+
+ {[...Array(4)].map((_, i) => (
+
+ ))}
+
+ ) : roundOverview && roundOverview.rounds.length > 0 ? (
+
+ {roundOverview.rounds.map((round, idx) => (
+
+
+
+
+ {round.roundName}
+
+
+
+ {round.roundType.replace(/_/g, ' ')}
-
- Score: {project.averageScore !== null ? project.averageScore.toFixed(2) : '-'}
- {project.evaluationCount} eval{project.evaluationCount !== 1 ? 's' : ''}
-
+
+ {round.roundStatus === 'ROUND_ACTIVE'
+ ? 'Active'
+ : round.roundStatus === 'ROUND_CLOSED'
+ ? 'Closed'
+ : round.roundStatus === 'ROUND_DRAFT'
+ ? 'Draft'
+ : round.roundStatus === 'ROUND_ARCHIVED'
+ ? 'Archived'
+ : round.roundStatus}
+
+
+
+ {round.totalProjects} project{round.totalProjects !== 1 ? 's' : ''}
+
+
+
+
+ {round.completionRate}% complete
+
-
+ {idx < roundOverview.rounds.length - 1 && (
+
+ )}
+
))}
-
- {/* Pagination */}
- {projectsData.totalPages > 1 && (
-
-
- Page {projectsData.page} of {projectsData.totalPages}
-
-
-
-
-
-
- )}
- >
- ) : (
-
-
-
- {debouncedSearch || statusFilter !== 'all'
- ? 'No projects match your filters'
- : 'No projects found'}
-
-
- )}
-
-
+ ) : (
+ No round data available for this competition.
+ )}
+
+
- {/* Score Distribution */}
- {stats && stats.scoreDistribution.some((b) => b.count > 0) && (
-
-
-
-
-
-
-
- Score Distribution
-
- Distribution of global scores across evaluations
-
-
-
- {(() => {
- const maxCount = Math.max(...stats.scoreDistribution.map((b) => b.count), 1)
- // Score-based colors: high scores = brand dark blue, low = brand red
- const scoreColors: Record
= {
- '9-10': '#053d57',
- '7-8': '#1e7a8a',
- '5-6': '#557f8c',
- '3-4': '#c4453a',
- '1-2': '#de0f1e',
- }
- return stats.scoreDistribution.map((bucket) => (
-
-
{bucket.label}
-
-
0 ? (bucket.count / maxCount) * 100 : 0}%`,
- backgroundColor: scoreColors[bucket.label] || '#557f8c',
- }}
- />
+ {/* Middle Row */}
+
+ {/* Score Distribution */}
+
+
+
+
+
+
+
+ Score Distribution
+
+ Evaluation score buckets
+
+
+ {stats ? (
+
+ {stats.scoreDistribution.map((bucket) => (
+
+
+ {bucket.label}
+
+
+
0 ? (bucket.count / maxScoreCount) * 100 : 0}%`,
+ backgroundColor: scoreColors[bucket.label] ?? '#557f8c',
+ }}
+ />
+
+
+ {bucket.count}
+
-
{bucket.count}
-
- ))
- })()}
-
-
-
+ ))}
+
+ ) : (
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+ )}
+
+
- )}
- {/* Recent Rounds */}
- {recentRounds.length > 0 && (
-
-
-
-
-
-
-
- Recent Rounds
-
- Overview of the latest evaluation rounds
-
-
-
- {recentRounds.map((round) => (
-
-
-
-
{round.name}
- {round.roundType && (
-
- {round.roundType.replace(/_/g, ' ')}
-
- )}
-
- {round.status === 'ROUND_ACTIVE' ? 'Active' : round.status === 'ROUND_CLOSED' ? 'Closed' : round.status}
-
+ {/* Juror Workload */}
+
+
+
+
+
+
+
+ Juror Workload
+
+ Top 5 jurors by assignment
+
+
+ {topJurors.length > 0 ? (
+
+ {topJurors.map((juror) => (
+
+
+
+ {juror.name ?? 'Unknown'}
+
+
+ {juror.completionRate}%
+
+
+
+
+ {juror.completed} / {juror.assigned} evaluations
+
-
- {round.programName}
-
-
-
-
- ))}
-
-
-
+ ))}
+
+ ) : (
+
+ {[...Array(5)].map((_, i) => (
+
+
+
+
+ ))}
+
+ )}
+
+
- )}
+
+ {/* Project Origins */}
+
+ {selectedProgramId ? (
+
+ ) : (
+
+
+
+
+ Project Origins
+
+ Geographic distribution of projects
+
+
+
+
+
+ )}
+
+
+
+ {/* Bottom Row */}
+
+ {/* Recent Projects Table */}
+
+
+
+
+
+
+
+ Recent Projects
+
+ Latest project activity
+
+
+ {projectsData && projectsData.projects.length > 0 ? (
+ <>
+
+
+
+ Project
+ Status
+ Score
+
+
+
+ {projectsData.projects.map((project) => (
+
+
+
+ {project.title}
+
+ {project.teamName && (
+ {project.teamName}
+ )}
+
+
+
+
+
+ {project.averageScore !== null ? project.averageScore.toFixed(1) : '—'}
+
+
+ ))}
+
+
+
+
+ View All
+
+
+ >
+ ) : (
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+ )}
+
+
+
+
+ {/* Activity Feed */}
+
+
+
+
+
+ Activity Feed
+
+ Recent platform events
+
+
+ {activityFeed && activityFeed.length > 0 ? (
+
+ {activityFeed.map((item) => (
+
+
+
+
+
+ {item.eventType.replace(/_/g, ' ').toLowerCase().replace(/^\w/, (c) => c.toUpperCase())}
+
+ {item.entityType && (
+ — {item.entityType.replace(/_/g, ' ').toLowerCase()}
+ )}
+
+ {item.actorName && (
+
by {item.actorName}
+ )}
+
+
+ {relativeTime(item.createdAt)}
+
+
+ ))}
+
+ ) : (
+
+ {[...Array(6)].map((_, i) => (
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
)
}
diff --git a/src/components/observer/observer-project-detail.tsx b/src/components/observer/observer-project-detail.tsx
index 6a9f9a7..a93288a 100644
--- a/src/components/observer/observer-project-detail.tsx
+++ b/src/components/observer/observer-project-detail.tsx
@@ -1,6 +1,5 @@
'use client'
-import { useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
@@ -11,32 +10,18 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
-import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
+import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from '@/components/ui/table'
-import {
- Sheet,
- SheetContent,
- SheetDescription,
- SheetHeader,
- SheetTitle,
-} from '@/components/ui/sheet'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { FileViewer } from '@/components/shared/file-viewer'
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
import { UserAvatar } from '@/components/shared/user-avatar'
import { StatusBadge } from '@/components/shared/status-badge'
import { AnimatedCard } from '@/components/shared/animated-container'
import {
- ArrowLeft,
AlertCircle,
Users,
FileText,
@@ -50,8 +35,8 @@ import {
Waves,
GraduationCap,
Heart,
- Crown,
- Eye,
+ Clock,
+ Sparkles,
MessageSquare,
} from 'lucide-react'
import { formatDate, formatDateOnly } from '@/lib/utils'
@@ -62,8 +47,11 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
{ refetchInterval: 30_000 },
)
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const [selectedAssignment, setSelectedAssignment] = useState
(null)
+ const roundId = data?.assignments?.[0]?.roundId as string | undefined
+ const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
+ { roundId: roundId ?? '' },
+ { enabled: !!roundId },
+ )
if (isLoading) {
return
@@ -72,18 +60,24 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
if (!data) {
return (
-
+
/
+
+ Projects
+
+
Project Not Found
@@ -91,756 +85,12 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
)
}
- const { project, assignments, stats, competitionRounds, allRequirements } = data
-
- return (
-
- {/* Back */}
-
-
- {/* Header */}
-
-
-
-
-
- {project.title}
-
-
-
- {project.teamName && (
-
{project.teamName}
- )}
-
-
-
-
-
- {/* Stats */}
- {stats && (
-
-
-
-
-
- Average Score
-
-
-
-
-
-
-
- {stats.averageGlobalScore?.toFixed(1) || '-'}
-
-
- Range: {stats.minScore ?? '-'} - {stats.maxScore ?? '-'} ({stats.totalEvaluations} evaluation{stats.totalEvaluations !== 1 ? 's' : ''})
-
-
-
-
-
-
-
- Recommendations
-
-
-
-
-
-
-
- {stats.yesPercentage?.toFixed(0) || 0}%
-
-
- {stats.yesVotes} yes / {stats.noVotes} no
-
-
-
-
-
- )}
-
- {/* Project Info */}
-
-
-
-
-
-
-
- Project Information
-
-
-
- {/* Category & Ocean Issue badges */}
-
- {project.competitionCategory && (
-
-
- {project.competitionCategory === 'STARTUP'
- ? 'Start-up'
- : 'Business Concept'}
-
- )}
- {project.oceanIssue && (
-
-
- {project.oceanIssue.replace(/_/g, ' ')}
-
- )}
- {project.wantsMentorship && (
-
-
- Wants Mentorship
-
- )}
-
-
- {project.description && (
-
-
- Description
-
-
- {project.description}
-
-
- )}
-
- {/* Location & Institution */}
-
- {(project.country || project.geographicZone) && (
-
-
-
-
- Location
-
-
- {project.geographicZone || project.country}
-
-
-
- )}
- {project.institution && (
-
-
-
-
- Institution
-
-
{project.institution}
-
-
- )}
- {project.foundedAt && (
-
-
-
-
- Founded
-
-
{formatDateOnly(project.foundedAt)}
-
-
- )}
-
-
- {/* Submission URLs */}
- {(project.phase1SubmissionUrl || project.phase2SubmissionUrl) && (
-
-
- Submission Links
-
-
-
- )}
-
- {/* Expertise Tags */}
- {project.projectTags && project.projectTags.length > 0 && (
-
-
- Expertise Tags
-
-
- {project.projectTags.map((pt) => (
-
- {pt.tag.name}
- {pt.confidence < 1 && (
-
- {Math.round(pt.confidence * 100)}%
-
- )}
-
- ))}
-
-
- )}
-
-
-
- Created:{' '}
- {formatDateOnly(project.createdAt)}
-
-
- Updated:{' '}
- {formatDateOnly(project.updatedAt)}
-
-
-
-
-
-
- {/* Team Members */}
- {project.teamMembers && project.teamMembers.length > 0 && (
-
-
-
-
-
-
-
- Team Members ({project.teamMembers.length})
-
-
-
-
- {project.teamMembers.map(
- (member: {
- id: string
- role: string
- title: string | null
- user: {
- id: string
- name: string | null
- email: string
- avatarUrl?: string | null
- }
- }) => (
-
- {member.role === 'LEAD' ? (
-
-
-
- ) : (
-
- )}
-
-
-
- {member.user.name || 'Unnamed'}
-
-
- {member.role === 'LEAD'
- ? 'Lead'
- : member.role === 'ADVISOR'
- ? 'Advisor'
- : 'Member'}
-
-
-
- {member.user.email}
-
- {member.title && (
-
- {member.title}
-
- )}
-
-
- ),
- )}
-
-
-
-
- )}
-
- {/* Files Section */}
-
-
-
-
-
-
-
- Files
-
-
- Project documents organized by competition round
-
-
-
- {/* Requirements organized by round */}
- {competitionRounds.length > 0 && allRequirements.length > 0 ? (
- <>
- {competitionRounds.map((round) => {
- const roundRequirements = allRequirements.filter(
- (req) => req.roundId === round.id,
- )
- if (roundRequirements.length === 0) return null
-
- return (
-
-
-
- {round.name}
-
-
- {roundRequirements.length} requirement
- {roundRequirements.length !== 1 ? 's' : ''}
-
-
-
- {roundRequirements.map((req) => {
- const fulfilledFile = project.files?.find(
- (f) => f.requirementId === req.id,
- )
- const isFulfilled = !!fulfilledFile
-
- return (
-
-
- {isFulfilled ? (
-
- ) : (
-
- )}
-
-
-
- {req.name}
-
- {req.isRequired && (
-
- Required
-
- )}
-
- {req.description && (
-
- {req.description}
-
- )}
-
- {req.acceptedMimeTypes?.length > 0 && (
-
- {req.acceptedMimeTypes
- .map((mime) => {
- if (
- mime === 'application/pdf'
- )
- return 'PDF'
- if (mime === 'image/*')
- return 'Images'
- if (mime === 'video/*')
- return 'Video'
- if (
- mime.includes(
- 'wordprocessing',
- )
- )
- return 'Word'
- if (
- mime.includes('spreadsheet')
- )
- return 'Excel'
- if (
- mime.includes('presentation')
- )
- return 'PowerPoint'
- return (
- mime.split('/')[1] || mime
- )
- })
- .join(', ')}
-
- )}
- {req.maxSizeMB && (
-
- Max {req.maxSizeMB}MB
-
- )}
-
- {isFulfilled && fulfilledFile && (
-
- {fulfilledFile.fileName}
-
- )}
-
-
- {!isFulfilled && (
-
- Missing
-
- )}
-
- )
- },
- )}
-
-
- )
- },
- )}
-
- >
- ) : null}
-
- {/* All uploaded files viewer */}
- {project.files && project.files.length > 0 ? (
-
-
- {allRequirements.length > 0
- ? 'All Uploaded Files'
- : 'Uploaded Files'}
-
-
({
- id: f.id,
- fileName: f.fileName,
- fileType: f.fileType as
- | 'EXEC_SUMMARY'
- | 'PRESENTATION'
- | 'VIDEO'
- | 'OTHER'
- | 'BUSINESS_PLAN'
- | 'VIDEO_PITCH'
- | 'SUPPORTING_DOC',
- mimeType: f.mimeType,
- size: f.size,
- bucket: f.bucket,
- objectKey: f.objectKey,
- pageCount: f.pageCount,
- textPreview: f.textPreview,
- detectedLang: f.detectedLang,
- langConfidence: f.langConfidence,
- analyzedAt: f.analyzedAt
- ? String(f.analyzedAt)
- : null,
- requirementId: f.requirementId,
- requirement: f.requirement
- ? {
- id: f.requirement.id,
- name: f.requirement.name,
- description: f.requirement.description,
- isRequired: f.requirement.isRequired,
- }
- : null,
- }))}
- />
-
- ) : (
-
-
-
- No files uploaded yet
-
-
- )}
-
-
-
-
- {/* Jury Assignments & Evaluations */}
- {assignments && assignments.length > 0 && (
-
-
-
-
-
-
-
- Jury Evaluations
-
-
- {
- assignments.filter(
- (a) => a.evaluation?.status === 'SUBMITTED',
- ).length
- }{' '}
- of {assignments.length} evaluations completed
-
-
-
- {/* Desktop Table */}
-
-
-
-
- Juror
- Round
- Status
- Score
- Decision
-
-
-
-
- {assignments.map((assignment) => (
- {
- if (
- assignment.evaluation?.status === 'SUBMITTED'
- ) {
- setSelectedAssignment(assignment)
- }
- }}
- >
-
-
-
-
-
- {assignment.user.name || 'Unnamed'}
-
-
- {assignment.user.email}
-
-
-
-
-
-
- {assignment.round.name}
-
-
-
-
-
-
- {assignment.evaluation?.globalScore != null ? (
-
- {assignment.evaluation.globalScore}/10
-
- ) : (
- -
- )}
-
-
- {assignment.evaluation?.binaryDecision != null ? (
- assignment.evaluation.binaryDecision ? (
-
-
- Yes
-
- ) : (
-
-
- No
-
- )
- ) : (
- -
- )}
-
-
- {assignment.evaluation?.status === 'SUBMITTED' && (
-
- )}
-
-
- ),
- )}
-
-
-
-
- {/* Mobile Cards */}
-
- {assignments.map((assignment) => (
-
- ),
- )}
-
-
-
-
- )}
-
- {/* Evaluation Detail Sheet */}
-
{
- if (!open) setSelectedAssignment(null)
- }}
- />
-
- )
-}
-
-function EvaluationDetailSheet({
- assignment,
- open,
- onOpenChange,
-}: {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- assignment: any
- open: boolean
- onOpenChange: (open: boolean) => void
-}) {
- if (!assignment?.evaluation) return null
-
- const ev = assignment.evaluation
- const criterionScores = (ev.criterionScoresJson || {}) as Record<
- string,
- number | boolean | string
- >
- const hasScores = Object.keys(criterionScores).length > 0
-
- // Get evaluation form for criterion labels
- const roundId = assignment.roundId as string | undefined
- const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
- { roundId: roundId ?? '' },
- { enabled: !!roundId && open },
- )
+ const { project, assignments, stats, competitionRounds, allRequirements } =
+ data
const criteriaMap = new Map<
string,
- {
- label: string
- type: string
- trueLabel?: string
- falseLabel?: string
- }
+ { label: string; type: string; trueLabel?: string; falseLabel?: string }
>()
if (activeForm?.criteriaJson) {
for (const c of activeForm.criteriaJson as Array<{
@@ -859,191 +109,806 @@ function EvaluationDetailSheet({
}
}
- return (
-
-
-
-
-
- {assignment.user.name || assignment.user.email}
-
-
- {ev.submittedAt
- ? `Submitted ${formatDate(ev.submittedAt)}`
- : 'Evaluation details'}
-
-
+ // Compute per-criterion averages from all submitted evaluations
+ const criterionTotals = new Map()
+ for (const assignment of assignments) {
+ const ev = assignment.evaluation
+ if (ev?.status !== 'SUBMITTED') continue
+ const scores = (ev.criterionScoresJson || {}) as Record<
+ string,
+ number | boolean | string
+ >
+ for (const [key, value] of Object.entries(scores)) {
+ const meta = criteriaMap.get(key)
+ const type =
+ meta?.type ||
+ (typeof value === 'boolean'
+ ? 'boolean'
+ : typeof value === 'string'
+ ? 'text'
+ : 'numeric')
+ if (type !== 'numeric' && type !== 'section_header') continue
+ if (type === 'section_header') continue
+ if (typeof value !== 'number') continue
+ const existing = criterionTotals.get(key) || { sum: 0, count: 0 }
+ criterionTotals.set(key, {
+ sum: existing.sum + value,
+ count: existing.count + 1,
+ })
+ }
+ }
-
- {/* Global stats */}
-
-
-
Score
-
- {ev.globalScore != null ? `${ev.globalScore}/10` : '-'}
-
-
-
-
Decision
-
- {ev.binaryDecision != null ? (
- ev.binaryDecision ? (
-
-
- Yes
-
- ) : (
-
-
- No
-
- )
- ) : (
-
-
- )}
-
+ const criterionAverages = new Map
()
+ for (const [key, { sum, count }] of criterionTotals.entries()) {
+ if (count > 0) criterionAverages.set(key, sum / count)
+ }
+
+ return (
+
+ {/* Breadcrumb */}
+
+
+ {/* Project Header */}
+
+
+
+
+
+ {project.title}
+
+ {project.teamName && (
+
{project.teamName}
+ )}
+
+ {(project.country || project.geographicZone) && (
+
+
+ {project.country || project.geographicZone}
+
+ )}
+ {project.competitionCategory && (
+
+
+ {project.competitionCategory === 'STARTUP'
+ ? 'Start-up'
+ : 'Business Concept'}
+
+ )}
+ {project.oceanIssue && (
+
+
+ {project.oceanIssue.replace(/_/g, ' ')}
+
+ )}
+
+
- {/* Criterion Scores */}
- {hasScores && (
-
-
-
- Criterion Scores
-
-
- {Object.entries(criterionScores).map(([key, value]) => {
- const meta = criteriaMap.get(key)
- const label = meta?.label || key
- const type =
- meta?.type ||
- (typeof value === 'boolean'
- ? 'boolean'
- : typeof value === 'string'
- ? 'text'
- : 'numeric')
+ {/* Score card */}
+ {stats && (
+
+
+
+
+
+
+
+ {stats.averageGlobalScore != null
+ ? stats.averageGlobalScore.toFixed(1)
+ : '-'}
+
+
+ {stats.minScore ?? '-'} – {stats.maxScore ?? '-'} range
+
+
+
+ {stats.totalEvaluations} evaluation
+ {stats.totalEvaluations !== 1 ? 's' : ''}
+
+ {stats.yesPercentage != null && (
+
+ {stats.yesPercentage.toFixed(0)}% recommended
+
+ )}
+
+
+
+ )}
+
- if (type === 'section_header') return null
+
- if (type === 'boolean') {
- return (
-
-
{label}
- {value === true ? (
-
+
+ Overview
+
+ Evaluations
+ {assignments.length > 0 && (
+
+ {assignments.length}
+
+ )}
+
+ Files
+
+
+ {/* ── Overview Tab ── */}
+
+ {/* Criteria mini-cards */}
+ {criterionAverages.size > 0 && (
+
+
+
+
+
+
+
+ Criteria Averages
+
+
+ Averaged across all submitted evaluations
+
+
+
+
+ {Array.from(criterionAverages.entries()).map(
+ ([key, avg]) => {
+ const meta = criteriaMap.get(key)
+ const label = meta?.label || key
+ return (
+
-
- {meta?.trueLabel || 'Yes'}
-
- ) : (
-
-
- {meta?.falseLabel || 'No'}
-
+
+ {label}
+
+
+ {avg.toFixed(1)}
+
+
+
+ )
+ },
+ )}
+
+
+
+
+ )}
+
+ {/* AI Synthesis placeholder */}
+
+
+
+
+
+ AI synthesis will appear here when available
+
+
+
+
+
+ {/* Project Info */}
+
+
+
+
+
+
+
+ Project Information
+
+
+
+
+
+
Submitted
+
+ {formatDateOnly(project.createdAt)}
+
+
+
+
Category
+
+ {project.competitionCategory
+ ? project.competitionCategory === 'STARTUP'
+ ? 'Start-up'
+ : 'Business Concept'
+ : '-'}
+
+
+
+
Org Type
+
+ {project.institution || '-'}
+
+
+
+
Country
+
+ {project.country || project.geographicZone || '-'}
+
+
+
+
+
+
+
+ Last Updated
+
+
+ {formatDateOnly(project.updatedAt)}
+
+
+
+
+ {project.wantsMentorship && (
+
+
+
+ Wants Mentorship
+
+
+ )}
+
+ {/* Expertise Tags */}
+ {project.projectTags && project.projectTags.length > 0 && (
+
+
+ Expertise Tags
+
+
+ {project.projectTags.map((pt) => (
+
+ {pt.tag.name}
+ {pt.confidence < 1 && (
+
+ {Math.round(pt.confidence * 100)}%
+
+ )}
+
+ ))}
+
+
+ )}
+
+
+
+
+ {/* Round History */}
+ {competitionRounds.length > 0 && (
+
+
+
+
+
+
+
+ Round History
+
+
+
+
+ {competitionRounds.map((round, idx) => {
+ // Determine round status from assignments
+ const roundAssignments = assignments.filter(
+ (a) => a.roundId === round.id,
+ )
+ const hasInProgressAssignments = roundAssignments.some(
+ (a) => a.evaluation?.status === 'DRAFT',
+ )
+ const allSubmitted =
+ roundAssignments.length > 0 &&
+ roundAssignments.every(
+ (a) => a.evaluation?.status === 'SUBMITTED',
+ )
+ const isPast = idx < competitionRounds.length - 1 && allSubmitted
+ const isActive = hasInProgressAssignments || (!isPast && roundAssignments.length > 0 && !allSubmitted)
+ return (
+ -
+ {isPast || allSubmitted ? (
+
+ ) : isActive ? (
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+
{round.name}
+ {roundAssignments.length > 0 && (
+
+ {roundAssignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length}/{roundAssignments.length} evaluations
+
+ )}
+
+ {isActive && (
+
+ Active
+
+ )}
+
+ )
+ })}
+
+
+
+
+ )}
+
+
+ {/* ── Evaluations Tab ── */}
+
+ {assignments.length === 0 ? (
+
+
+
+
+ No jury assignments yet
+
+
+
+ ) : (
+ assignments.map((assignment) => {
+ const ev = assignment.evaluation
+ const isSubmitted = ev?.status === 'SUBMITTED'
+ const criterionScores = (ev?.criterionScoresJson || {}) as Record<
+ string,
+ number | boolean | string
+ >
+
+ return (
+
+
+
+
+
+
+
+
+ {assignment.user.name || 'Unnamed'}
+
+
+
+ {assignment.round.name}
+
+ {isSubmitted && ev.submittedAt && (
+
+ {formatDate(ev.submittedAt)}
+
+ )}
+
+
+
+ {isSubmitted && ev.globalScore != null && (
+
+
+ {ev.globalScore}/10
+
+
)}
- )
- }
+
+
+ {isSubmitted && ev ? (
+
+ {/* Criterion scores */}
+ {Object.keys(criterionScores).length > 0 && (
+
+ {Object.entries(criterionScores).map(
+ ([key, value]) => {
+ const meta = criteriaMap.get(key)
+ const label = meta?.label || key
+ const type =
+ meta?.type ||
+ (typeof value === 'boolean'
+ ? 'boolean'
+ : typeof value === 'string'
+ ? 'text'
+ : 'numeric')
+
+ if (type === 'section_header') return null
+
+ if (type === 'boolean') {
+ return (
+
+ {label}
+ {value === true ? (
+
+
+ {meta?.trueLabel || 'Yes'}
+
+ ) : (
+
+
+ {meta?.falseLabel || 'No'}
+
+ )}
+
+ )
+ }
+
+ if (type === 'text') {
+ return (
+
+
+ {label}
+
+
+ {typeof value === 'string'
+ ? value
+ : String(value)}
+
+
+ )
+ }
+
+ // Numeric
+ return (
+
+
+ {label}
+
+
+
+
+ {typeof value === 'number' ? value : '-'}
+
+
+
+ )
+ },
+ )}
+
+ )}
+
+ {/* Feedback */}
+ {ev.feedbackText && (
+
+
+
+ Feedback
+
+
+ {ev.feedbackText}
+
+
+ )}
+
+ {/* Binary decision */}
+ {ev.binaryDecision != null && (
+
+ {ev.binaryDecision ? (
+
+
+ Recommended
+
+ ) : (
+
+
+ Not recommended
+
+ )}
+
+ )}
+
+ ) : (
+
+
+
+ Evaluation pending
+
+
+ )}
+
+
+ )
+ })
+ )}
+
+
+ {/* ── Files Tab ── */}
+
+
+
+
+
+
+
+ Files
+
+
+ Project documents organized by competition round
+
+
+
+ {competitionRounds.length > 0 && allRequirements.length > 0 ? (
+ <>
+ {competitionRounds.map((round) => {
+ const roundRequirements = allRequirements.filter(
+ (req) => req.roundId === round.id,
+ )
+ if (roundRequirements.length === 0) return null
- if (type === 'text') {
return (
-
-
{label}
-
- {typeof value === 'string' ? value : String(value)}
+
+
+
+ {round.name}
+
+
+ {roundRequirements.length} requirement
+ {roundRequirements.length !== 1 ? 's' : ''}
+
+
+
+ {roundRequirements.map((req) => {
+ const fulfilledFile = project.files?.find(
+ (f) => f.requirementId === req.id,
+ )
+ const isFulfilled = !!fulfilledFile
+
+ return (
+
+
+ {isFulfilled ? (
+
+ ) : (
+
+ )}
+
+
+
+ {req.name}
+
+ {req.isRequired && (
+
+ Required
+
+ )}
+
+ {req.description && (
+
+ {req.description}
+
+ )}
+
+ {req.acceptedMimeTypes?.length > 0 && (
+
+ {req.acceptedMimeTypes
+ .map((mime) => {
+ if (mime === 'application/pdf')
+ return 'PDF'
+ if (mime === 'image/*')
+ return 'Images'
+ if (mime === 'video/*')
+ return 'Video'
+ if (
+ mime.includes('wordprocessing')
+ )
+ return 'Word'
+ if (mime.includes('spreadsheet'))
+ return 'Excel'
+ if (
+ mime.includes('presentation')
+ )
+ return 'PowerPoint'
+ return mime.split('/')[1] || mime
+ })
+ .join(', ')}
+
+ )}
+ {req.maxSizeMB && (
+
+ Max {req.maxSizeMB}MB
+
+ )}
+
+ {isFulfilled && fulfilledFile && (
+
+ {fulfilledFile.fileName}
+
+ )}
+
+
+ {!isFulfilled && (
+
+ Missing
+
+ )}
+
+ )
+ })}
)
- }
+ })}
+
+ >
+ ) : null}
- // Numeric
- return (
-
-
{label}
-
-
-
- {typeof value === 'number' ? value : '-'}
-
-
-
- )
- })}
-
-
- )}
-
- {/* Feedback Text */}
- {ev.feedbackText && (
-
-
-
- Feedback
-
-
- {ev.feedbackText}
-
-
- )}
-
-
-
+ {project.files && project.files.length > 0 ? (
+
+
+ {allRequirements.length > 0
+ ? 'All Uploaded Files'
+ : 'Uploaded Files'}
+
+
({
+ id: f.id,
+ fileName: f.fileName,
+ fileType: f.fileType as
+ | 'EXEC_SUMMARY'
+ | 'PRESENTATION'
+ | 'VIDEO'
+ | 'OTHER'
+ | 'BUSINESS_PLAN'
+ | 'VIDEO_PITCH'
+ | 'SUPPORTING_DOC',
+ mimeType: f.mimeType,
+ size: f.size,
+ bucket: f.bucket,
+ objectKey: f.objectKey,
+ pageCount: f.pageCount,
+ textPreview: f.textPreview,
+ detectedLang: f.detectedLang,
+ langConfidence: f.langConfidence,
+ analyzedAt: f.analyzedAt ? String(f.analyzedAt) : null,
+ requirementId: f.requirementId,
+ requirement: f.requirement
+ ? {
+ id: f.requirement.id,
+ name: f.requirement.name,
+ description: f.requirement.description,
+ isRequired: f.requirement.isRequired,
+ }
+ : null,
+ }))}
+ />
+
+ ) : (
+
+
+
+ No files uploaded yet
+
+
+ )}
+
+
+
+
+
)
}
function ProjectDetailSkeleton() {
return (
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
- {[1, 2].map((i) => (
-
-
-
-
-
-
-
-
- ))}
+
+
+
+
-
+
diff --git a/src/components/observer/observer-projects-content.tsx b/src/components/observer/observer-projects-content.tsx
new file mode 100644
index 0000000..f86109b
--- /dev/null
+++ b/src/components/observer/observer-projects-content.tsx
@@ -0,0 +1,487 @@
+'use client'
+
+import { useState, useCallback } from 'react'
+import Link from 'next/link'
+import type { Route } from 'next'
+import { useRouter } from 'next/navigation'
+import { trpc } from '@/lib/trpc/client'
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+ CardDescription,
+} from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Skeleton } from '@/components/ui/skeleton'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { StatusBadge } from '@/components/shared/status-badge'
+import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
+import { scoreGradient } from '@/components/charts/chart-theme'
+import {
+ Search,
+ ChevronLeft,
+ ChevronRight,
+ ArrowUpDown,
+ ArrowUp,
+ ArrowDown,
+ ClipboardList,
+ Download,
+ X,
+} from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { useDebouncedCallback } from 'use-debounce'
+
+
+export function ObserverProjectsContent() {
+ const router = useRouter()
+ const [search, setSearch] = useState('')
+ const [debouncedSearch, setDebouncedSearch] = useState('')
+ const [roundFilter, setRoundFilter] = useState('all')
+ const [statusFilter, setStatusFilter] = useState('all')
+ const [sortBy, setSortBy] = useState<'title' | 'score' | 'evaluations'>('title')
+ const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
+ const [page, setPage] = useState(1)
+ const [perPage] = useState(20)
+ const [csvOpen, setCsvOpen] = useState(false)
+ const [csvExportData, setCsvExportData] = useState<
+ { data: Record
[]; columns: string[] } | undefined
+ >(undefined)
+ const [csvLoading, setCsvLoading] = useState(false)
+
+ const debouncedSetSearch = useDebouncedCallback((value: string) => {
+ setDebouncedSearch(value)
+ setPage(1)
+ }, 300)
+
+ const handleSearchChange = (value: string) => {
+ setSearch(value)
+ debouncedSetSearch(value)
+ }
+
+ const handleRoundChange = (value: string) => {
+ setRoundFilter(value)
+ setPage(1)
+ }
+
+ const handleStatusChange = (value: string) => {
+ setStatusFilter(value)
+ 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 clearFilters = () => {
+ setSearch('')
+ setDebouncedSearch('')
+ setRoundFilter('all')
+ setStatusFilter('all')
+ setPage(1)
+ }
+
+ const activeFilterCount =
+ (debouncedSearch ? 1 : 0) +
+ (roundFilter !== 'all' ? 1 : 0) +
+ (statusFilter !== 'all' ? 1 : 0)
+
+ const { data: programs } = trpc.program.list.useQuery(
+ { includeStages: true },
+ { refetchInterval: 30_000 },
+ )
+
+ const rounds =
+ programs?.flatMap((p) =>
+ (p.rounds ?? []).map((r: { id: string; name: string; status: string; roundType?: string }) => ({
+ id: r.id,
+ name: r.name,
+ programName: `${p.year} Edition`,
+ status: r.status,
+ roundType: r.roundType,
+ })),
+ ) ?? []
+
+ const roundIdParam = roundFilter !== 'all' ? roundFilter : undefined
+
+ const { data: projectsData, isLoading: projectsLoading } =
+ trpc.analytics.getAllProjects.useQuery(
+ {
+ roundId: roundIdParam,
+ search: debouncedSearch || undefined,
+ status: statusFilter !== 'all' ? statusFilter : undefined,
+ sortBy,
+ sortDir,
+ page,
+ perPage,
+ },
+ { refetchInterval: 30_000 },
+ )
+
+ const handleRequestCsvData = useCallback(async () => {
+ setCsvLoading(true)
+ try {
+ const allData = await new Promise((resolve) => {
+ resolve(projectsData)
+ })
+
+ if (!allData?.projects) {
+ setCsvLoading(false)
+ return undefined
+ }
+
+ const rows = allData.projects.map((p) => ({
+ title: p.title,
+ teamName: p.teamName ?? '',
+ country: p.country ?? '',
+ roundName: p.roundName ?? '',
+ status: p.status,
+ averageScore: p.averageScore !== null ? p.averageScore.toFixed(2) : '',
+ evaluationCount: p.evaluationCount,
+ }))
+
+ const result = {
+ data: rows,
+ columns: ['title', 'teamName', 'country', 'roundName', 'status', 'averageScore', 'evaluationCount'],
+ }
+ setCsvExportData(result)
+ setCsvLoading(false)
+ return result
+ } catch {
+ setCsvLoading(false)
+ return undefined
+ }
+ }, [projectsData])
+
+ const SortIcon = ({ column }: { column: 'title' | 'score' | 'evaluations' }) => {
+ if (sortBy !== column)
+ return
+ return sortDir === 'asc' ? (
+
+ ) : (
+
+ )
+ }
+
+ return (
+
+
+
+
All Projects
+
+ {projectsData
+ ? `${projectsData.total} project${projectsData.total !== 1 ? 's' : ''} total`
+ : 'Loading projects...'}
+
+
+
+
+
+
+
+ Filters
+ {activeFilterCount > 0 && (
+
+ {activeFilterCount} active
+
+
+ )}
+
+
+
+
+
+ handleSearchChange(e.target.value)}
+ className="pl-10"
+ />
+
+
+
+
+
+
+
+ {projectsLoading ? (
+
+
+ {[...Array(8)].map((_, i) => (
+
+ ))}
+
+
+ ) : projectsData && projectsData.projects.length > 0 ? (
+ <>
+
+
+
+
+
+
+
+
+
+ Country
+ Round
+ Status
+
+
+
+
+
+
+
+
+
+
+ {projectsData.projects.map((project) => (
+ router.push(`/observer/projects/${project.id}`)}
+ >
+
+ e.stopPropagation()}
+ >
+ {project.title}
+
+ {project.teamName && (
+
+ {project.teamName}
+
+ )}
+
+
+ {project.country ?? '-'}
+
+
+
+ {project.roundName}
+
+
+
+
+
+
+ {project.averageScore !== null ? (
+
+
+ {project.averageScore.toFixed(1)}
+
+
+
+ ) : (
+ -
+ )}
+
+
+ {project.evaluationCount}
+
+
+ e.stopPropagation()}
+ >
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+ {projectsData.projects.map((project) => (
+
+
+
+
+
+
+ {project.title}
+
+ {project.teamName && (
+
+ {project.teamName}
+
+ )}
+
+
+
+
+
+ {project.roundName}
+
+
+
+ Score:{' '}
+ {project.averageScore !== null
+ ? project.averageScore.toFixed(1)
+ : '-'}
+
+
+ {project.evaluationCount} eval
+ {project.evaluationCount !== 1 ? 's' : ''}
+
+
+
+
+
+
+ ))}
+
+
+
+
+ Page {projectsData.page} of {projectsData.totalPages} ·{' '}
+ {projectsData.total} result{projectsData.total !== 1 ? 's' : ''}
+
+
+
+
+
+
+ >
+ ) : (
+
+
+
+ {activeFilterCount > 0 ? 'No projects match your filters' : 'No projects found'}
+
+ {activeFilterCount > 0 && (
+
+ )}
+
+ )}
+
+
+
+ )
+}
diff --git a/src/server/routers/analytics.ts b/src/server/routers/analytics.ts
index 5c73421..29ebbad 100644
--- a/src/server/routers/analytics.ts
+++ b/src/server/routers/analytics.ts
@@ -627,92 +627,6 @@ export const analyticsRouter = router({
return { total, byCountry, byCategory, byOceanIssue, byTag }
}),
- /**
- * Get year-over-year stats across all rounds in a program
- */
- getYearOverYear: observerProcedure
- .input(z.object({ programId: z.string() }))
- .query(async ({ ctx, input }) => {
- const competitions = await ctx.prisma.competition.findMany({
- where: { programId: input.programId },
- include: {
- rounds: {
- select: { id: true, name: true, createdAt: true },
- orderBy: { createdAt: 'asc' },
- },
- },
- orderBy: { createdAt: 'asc' },
- })
-
- const allRounds = competitions.flatMap((c) => c.rounds)
- const roundIds = allRounds.map((r) => r.id)
-
- if (roundIds.length === 0) return []
-
- // Batch: fetch assignments, evaluations, and distinct projects in 3 queries
- const [assignmentCounts, evaluations, projectAssignments] = await Promise.all([
- ctx.prisma.assignment.groupBy({
- by: ['roundId'],
- where: { roundId: { in: roundIds } },
- _count: true,
- }),
- ctx.prisma.evaluation.findMany({
- where: {
- assignment: { roundId: { in: roundIds } },
- status: 'SUBMITTED',
- },
- select: { globalScore: true, assignment: { select: { roundId: true } } },
- }),
- ctx.prisma.assignment.findMany({
- where: { roundId: { in: roundIds } },
- select: { roundId: true, projectId: true },
- distinct: ['roundId', 'projectId'],
- }),
- ])
-
- const assignmentCountMap = new Map(assignmentCounts.map((a) => [a.roundId, a._count]))
-
- // Group evaluation scores by round
- const scoresByRound = new Map()
- const evalCountByRound = new Map()
- for (const e of evaluations) {
- const rid = e.assignment.roundId
- evalCountByRound.set(rid, (evalCountByRound.get(rid) ?? 0) + 1)
- if (e.globalScore !== null) {
- if (!scoresByRound.has(rid)) scoresByRound.set(rid, [])
- scoresByRound.get(rid)!.push(e.globalScore)
- }
- }
-
- // Count distinct projects per round
- const projectsByRound = new Map()
- for (const pa of projectAssignments) {
- projectsByRound.set(pa.roundId, (projectsByRound.get(pa.roundId) ?? 0) + 1)
- }
-
- return allRounds.map((round) => {
- const scores = scoresByRound.get(round.id) ?? []
- const assignmentCount = assignmentCountMap.get(round.id) ?? 0
- const evaluationCount = evalCountByRound.get(round.id) ?? 0
- const completionRate = assignmentCount > 0
- ? Math.round((evaluationCount / assignmentCount) * 100)
- : 0
- const averageScore = scores.length > 0
- ? scores.reduce((a, b) => a + b, 0) / scores.length
- : null
-
- return {
- roundId: round.id,
- roundName: round.name,
- createdAt: round.createdAt,
- projectCount: projectsByRound.get(round.id) ?? 0,
- evaluationCount,
- completionRate,
- averageScore,
- }
- })
- }),
-
/**
* Get dashboard stats (optionally scoped to a round)
*/
@@ -875,61 +789,86 @@ export const analyticsRouter = router({
},
})
- // For each round, get assignment coverage and evaluation completion
- const roundOverviews = await Promise.all(
- rounds.map(async (round) => {
- const [
- projectRoundStates,
- totalAssignments,
- completedEvaluations,
- distinctJurors,
- ] = await Promise.all([
- ctx.prisma.projectRoundState.groupBy({
- by: ['state'],
- where: { roundId: round.id },
- _count: true,
- }),
- ctx.prisma.assignment.count({
- where: { roundId: round.id },
- }),
- ctx.prisma.evaluation.count({
- where: {
- assignment: { roundId: round.id },
- status: 'SUBMITTED',
- },
- }),
- ctx.prisma.assignment.groupBy({
- by: ['userId'],
- where: { roundId: round.id },
- }),
- ])
+ // Batch all queries by roundIds to avoid N+1
+ const roundIds = rounds.map((r) => r.id)
- const stateBreakdown = projectRoundStates.map((ps) => ({
- state: ps.state,
- count: ps._count,
- }))
+ const [
+ allProjectRoundStates,
+ allAssignmentCounts,
+ allCompletedEvals,
+ allDistinctJurors,
+ ] = await Promise.all([
+ ctx.prisma.projectRoundState.groupBy({
+ by: ['roundId', 'state'],
+ where: { roundId: { in: roundIds } },
+ _count: true,
+ }),
+ ctx.prisma.assignment.groupBy({
+ by: ['roundId'],
+ where: { roundId: { in: roundIds } },
+ _count: true,
+ }),
+ // groupBy on relation field not supported, use raw count per round
+ ctx.prisma.$queryRaw<{ roundId: string; count: bigint }[]>`
+ SELECT a."roundId", COUNT(e.id)::bigint as count
+ FROM "Evaluation" e
+ JOIN "Assignment" a ON e."assignmentId" = a.id
+ WHERE a."roundId" = ANY(${roundIds}) AND e.status = 'SUBMITTED'
+ GROUP BY a."roundId"
+ `,
+ ctx.prisma.assignment.groupBy({
+ by: ['roundId', 'userId'],
+ where: { roundId: { in: roundIds } },
+ }),
+ ])
- const totalProjects = projectRoundStates.reduce((sum, ps) => sum + ps._count, 0)
- const completionRate = totalAssignments > 0
- ? Math.round((completedEvaluations / totalAssignments) * 100)
- : 0
+ // Build lookup maps
+ const statesByRound = new Map()
+ for (const ps of allProjectRoundStates) {
+ const list = statesByRound.get(ps.roundId) || []
+ list.push({ state: ps.state, count: ps._count })
+ statesByRound.set(ps.roundId, list)
+ }
- return {
- roundId: round.id,
- roundName: round.name,
- roundType: round.roundType,
- roundStatus: round.status,
- sortOrder: round.sortOrder,
- totalProjects,
- stateBreakdown,
- totalAssignments,
- completedEvaluations,
- pendingEvaluations: totalAssignments - completedEvaluations,
- completionRate,
- jurorCount: distinctJurors.length,
- }
- })
- )
+ const assignmentCountByRound = new Map()
+ for (const ac of allAssignmentCounts) {
+ assignmentCountByRound.set(ac.roundId, ac._count)
+ }
+
+ const completedEvalsByRound = new Map()
+ for (const ce of allCompletedEvals) {
+ completedEvalsByRound.set(ce.roundId, Number(ce.count))
+ }
+
+ const jurorCountByRound = new Map()
+ for (const j of allDistinctJurors) {
+ jurorCountByRound.set(j.roundId, (jurorCountByRound.get(j.roundId) || 0) + 1)
+ }
+
+ const roundOverviews = rounds.map((round) => {
+ const stateBreakdown = statesByRound.get(round.id) || []
+ const totalProjects = stateBreakdown.reduce((sum, ps) => sum + ps.count, 0)
+ const totalAssignments = assignmentCountByRound.get(round.id) || 0
+ const completedEvaluations = completedEvalsByRound.get(round.id) || 0
+ const completionRate = totalAssignments > 0
+ ? Math.round((completedEvaluations / totalAssignments) * 100)
+ : 0
+
+ return {
+ roundId: round.id,
+ roundName: round.name,
+ roundType: round.roundType,
+ roundStatus: round.status,
+ sortOrder: round.sortOrder,
+ totalProjects,
+ stateBreakdown,
+ totalAssignments,
+ completedEvaluations,
+ pendingEvaluations: totalAssignments - completedEvaluations,
+ completionRate,
+ jurorCount: jurorCountByRound.get(round.id) || 0,
+ }
+ })
return {
competitionId: input.competitionId,
@@ -972,7 +911,7 @@ export const analyticsRouter = router({
const where: Record = {}
if (input.roundId) {
- where.assignments = { some: { roundId: input.roundId } }
+ where.projectRoundStates = { some: { roundId: input.roundId } }
}
if (input.status) {
@@ -1370,4 +1309,47 @@ export const analyticsRouter = router({
allRequirements,
}
}),
+
+ /**
+ * Activity feed — recent audit log entries for observer dashboard
+ */
+ getActivityFeed: observerProcedure
+ .input(z.object({ limit: z.number().min(1).max(50).default(10) }).optional())
+ .query(async ({ ctx, input }) => {
+ const limit = input?.limit ?? 10
+
+ const entries = await ctx.prisma.decisionAuditLog.findMany({
+ orderBy: { createdAt: 'desc' },
+ take: limit,
+ select: {
+ id: true,
+ eventType: true,
+ entityType: true,
+ entityId: true,
+ actorId: true,
+ detailsJson: true,
+ createdAt: true,
+ },
+ })
+
+ // Batch-fetch actor names
+ const actorIds = [...new Set(entries.map((e) => e.actorId).filter(Boolean))] as string[]
+ const actors = actorIds.length > 0
+ ? await ctx.prisma.user.findMany({
+ where: { id: { in: actorIds } },
+ select: { id: true, name: true },
+ })
+ : []
+ const actorMap = new Map(actors.map((a) => [a.id, a.name]))
+
+ return entries.map((entry) => ({
+ id: entry.id,
+ eventType: entry.eventType,
+ entityType: entry.entityType,
+ entityId: entry.entityId,
+ actorName: entry.actorId ? actorMap.get(entry.actorId) ?? null : null,
+ details: entry.detailsJson as Record | null,
+ createdAt: entry.createdAt,
+ }))
+ }),
})
diff --git a/src/server/routers/file.ts b/src/server/routers/file.ts
index e3d7d9a..858bd65 100644
--- a/src/server/routers/file.ts
+++ b/src/server/routers/file.ts
@@ -20,9 +20,9 @@ export const fileRouter = router({
})
)
.query(async ({ ctx, input }) => {
- const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
+ const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
- if (!isAdmin) {
+ if (!isAdminOrObserver) {
const file = await ctx.prisma.projectFile.findFirst({
where: { bucket: input.bucket, objectKey: input.objectKey },
select: {
@@ -283,9 +283,9 @@ export const fileRouter = router({
roundId: z.string().optional(),
}))
.query(async ({ ctx, input }) => {
- const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
+ const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
- if (!isAdmin) {
+ if (!isAdminOrObserver) {
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: input.projectId },
@@ -348,9 +348,9 @@ export const fileRouter = router({
roundId: z.string(),
}))
.query(async ({ ctx, input }) => {
- const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
+ const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
- if (!isAdmin) {
+ if (!isAdminOrObserver) {
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: input.projectId },
@@ -468,9 +468,9 @@ export const fileRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
- const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
+ const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
- if (!isAdmin) {
+ if (!isAdminOrObserver) {
// Check user has access to the project (assigned or team member)
const [assignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({
@@ -652,9 +652,9 @@ export const fileRouter = router({
})
)
.query(async ({ ctx, input }) => {
- const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
+ const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
- if (!isAdmin) {
+ if (!isAdminOrObserver) {
const [assignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: input.projectId },
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 00cb98a..60b8115 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -6,6 +6,7 @@ const config: Config = {
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
+ './node_modules/@tremor/**/*.{js,ts,jsx,tsx}',
],
theme: {
container: {