From 8125ca6567a5918efd8763f7f8d06510e080ed8d Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 20 Feb 2026 21:45:01 +0100 Subject: [PATCH] Observer platform redesign Phase 4: migrate charts to Tremor, redesign all pages - Migrate 9 chart components from Nivo to @tremor/react (BarChart, AreaChart, DonutChart, ScatterChart) - Remove @nivo/*, @react-spring/web dependencies (45 packages removed) - Redesign dashboard: 6 stat tiles, competition pipeline, score distribution, juror workload, activity feed - Add new /observer/projects page with search, filters, sorting, pagination, CSV export - Restructure reports page from 5 tabs to 3 (Progress, Jurors, Scores & Analytics) with per-tab CSV export - Redesign project detail: breadcrumb nav, score card header, 3-tab layout (Overview/Evaluations/Files) - Update loading skeletons to match new layouts Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 998 ++++------ package.json | 7 +- src/app/(observer)/observer/loading.tsx | 88 + .../(observer)/observer/projects/loading.tsx | 37 + src/app/(observer)/observer/projects/page.tsx | 8 + .../(observer)/observer/reports/loading.tsx | 57 + src/app/(observer)/observer/reports/page.tsx | 1166 ++++++----- src/components/charts/chart-theme.ts | 46 - src/components/charts/criteria-scores.tsx | 77 +- .../charts/cross-round-comparison.tsx | 139 +- src/components/charts/diversity-metrics.tsx | 145 +- src/components/charts/evaluation-timeline.tsx | 90 +- src/components/charts/juror-consistency.tsx | 148 +- src/components/charts/juror-workload.tsx | 89 +- src/components/charts/project-rankings.tsx | 106 +- src/components/charts/score-distribution.tsx | 41 +- src/components/charts/status-breakdown.tsx | 57 +- src/components/layouts/observer-nav.tsx | 7 +- .../observer/observer-dashboard-content.tsx | 1014 +++++----- .../observer/observer-project-detail.tsx | 1725 ++++++++--------- .../observer/observer-projects-content.tsx | 487 +++++ src/server/routers/analytics.ts | 260 ++- src/server/routers/file.ts | 20 +- tailwind.config.ts | 1 + 24 files changed, 3412 insertions(+), 3401 deletions(-) create mode 100644 src/app/(observer)/observer/loading.tsx create mode 100644 src/app/(observer)/observer/projects/loading.tsx create mode 100644 src/app/(observer)/observer/projects/page.tsx create mode 100644 src/app/(observer)/observer/reports/loading.tsx create mode 100644 src/components/observer/observer-projects-content.tsx 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} + +
+ + {rate}% +
+
+ - +
+ ) + })} +
+
- ))} -
+ + {/* 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.title}

- -
-
- - {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 -

-
- {project.phase1SubmissionUrl && ( - - )} - {project.phase2SubmissionUrl && ( - - )} -
-
- )} - - {/* 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 || '-'} +

+
+
+

Budget

+

-

+
+
+

Duration

+

-

+
+
+

AI Score

+

-

+
+
+

+ 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 ( +
  1. + {isPast || allSubmitted ? ( + + ) : isActive ? ( + + + + + + + ) : ( + + )} +
    +

    {round.name}

    + {roundAssignments.length > 0 && ( +

    + {roundAssignments.filter((a) => a.evaluation?.status === 'SUBMITTED').length}/{roundAssignments.length} evaluations +

    + )} +
    + {isActive && ( + + Active + + )} +
  2. + ) + })} +
+
+
+
+ )} +
+ + {/* ── 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: {