From 8e3ee07d9f76598288f0db0712c883a18e8817b1 Mon Sep 17 00:00:00 2001 From: averel10 Date: Fri, 3 Apr 2026 07:55:56 +0200 Subject: [PATCH] feat: added simple statistics to backend to track progress on experiment --- package-lock.json | 405 +++++++++++++++++++++- package.json | 1 + src/app/actions/annotation-stats.ts | 199 +++++++++++ src/app/actions/calibration-scoring.ts | 56 +-- src/app/actions/experiment.ts | 109 +++++- src/app/admin/experiments/[id]/page.tsx | 10 +- src/components/AnnotationDistribution.tsx | 162 +++++++++ src/components/ExperimentStatistics.tsx | 189 ++++++++++ 8 files changed, 1105 insertions(+), 26 deletions(-) create mode 100644 src/app/actions/annotation-stats.ts create mode 100644 src/components/AnnotationDistribution.tsx create mode 100644 src/components/ExperimentStatistics.tsx diff --git a/package-lock.json b/package-lock.json index 3d28a90..3a05df6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "jszip": "^3.10.1", "next": "16.1.6", "postcss": "^8.5.6", + "recharts": "^3.8.1", "tailwindcss": "^4.2.1" }, "devDependencies": { @@ -2411,12 +2412,54 @@ "integrity": "sha512-cID+rzOEb38VyMsx5LwJMEY4NGIrWCNpKu/0ImbeooQ2Px7TI+kOt7cm0NelxUzF2V41UVVXAmYjANZQtCu1/Q==", "license": "Apache-2.0" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -2702,6 +2745,69 @@ "@types/node": "*" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.35", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", @@ -2726,7 +2832,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2742,6 +2848,12 @@ "@types/node": "*" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -3850,6 +3962,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4072,9 +4193,130 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "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/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -4101,6 +4343,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/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -4378,6 +4626,16 @@ "node": ">=10.13.0" } }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -4449,6 +4707,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -4692,6 +4956,16 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -4704,6 +4978,15 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -5960,6 +6243,36 @@ "react": "^19.2.4" } }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -6008,12 +6321,63 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/regexp-to-ast": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz", "integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==", "license": "MIT" }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -6471,6 +6835,12 @@ "b4a": "^1.6.4" } }, + "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/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -6554,12 +6924,43 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", diff --git a/package.json b/package.json index 7df81c6..e9784a6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "jszip": "^3.10.1", "next": "16.1.6", "postcss": "^8.5.6", + "recharts": "^3.8.1", "tailwindcss": "^4.2.1" }, "devDependencies": { diff --git a/src/app/actions/annotation-stats.ts b/src/app/actions/annotation-stats.ts new file mode 100644 index 0000000..3ebf0f1 --- /dev/null +++ b/src/app/actions/annotation-stats.ts @@ -0,0 +1,199 @@ +'use server'; + +import db from '@/lib/db'; +import { annotation } from '@/lib/model/annotation'; +import { dataset_entry } from '@/lib/model/dataset_entry'; +import { experiment } from '@/lib/model/experiment'; +import { eq, and, count, gt, isNull } from 'drizzle-orm'; +import { requireAdmin } from '@/lib/auth'; + +export type DistributionDimension = 'dialect' | 'utteranceId' | 'model' | 'speaker'; + +export interface DistributionItem { + label: string; + count: number; + entryId?: number; + fileName?: string; + durationMs?: number | null; +} + +export interface DistributionData { + dimension: DistributionDimension; + items: DistributionItem[]; + total: number; +} + +/** + * Fetches annotation distribution data grouped by the specified dimension. + * Includes entries with 0 annotations and sample entry information for playback. + */ +export async function getAnnotationDistribution( + experimentId: number, + dimension: DistributionDimension +): Promise { + const result = await requireAdmin(); + if (!result.authenticated || !result.admin) { + throw new Error('Unauthorized'); + } + + try { + // Get experiment to verify it exists and get dataset + const exp = await db + .select() + .from(experiment) + .where(eq(experiment.id, experimentId)) + .limit(1); + + if (exp.length === 0) { + throw new Error('Experiment not found'); + } + + const datasetId = exp[0].datasetId; + + // Map dimension to database field + const dimensionFieldMap = { + dialect: dataset_entry.dialect, + utteranceId: dataset_entry.utteranceId, + model: dataset_entry.modelName, + speaker: dataset_entry.speakerId, + }; + + const dimensionField = dimensionFieldMap[dimension]; + + // First, get all unique dimension values from dataset_entry for this dataset + const allDimensionValues = await db + .selectDistinct({ dimensionValue: dimensionField }) + .from(dataset_entry) + .where(eq(dataset_entry.datasetId, datasetId)); + + // Now get annotation counts for each dimension value + const items: DistributionItem[] = []; + let total = 0; + + for (const dimValueObj of allDimensionValues) { + const dimValue = dimValueObj.dimensionValue; + + // Count annotations for this dimension value in this experiment + const annotationCountResult = await db + .select({ total: count().as('total') }) + .from(annotation) + .innerJoin( + dataset_entry, + eq(annotation.datasetEntryId, dataset_entry.id) + ) + .where( + and( + eq(annotation.experimentId, experimentId), + dimValue === null ? isNull(dimensionField) : eq(dimensionField, dimValue) + ) + ); + + const annotationCount = annotationCountResult[0]?.total ? parseInt(annotationCountResult[0].total as any) : 0; + total += annotationCount; + + // Get a sample entry for this dimension group (any entry, even if not annotated) + const sampleEntry = await db + .select({ + id: dataset_entry.id, + fileName: dataset_entry.fileName, + durationMs: dataset_entry.durationMs, + }) + .from(dataset_entry) + .where( + and( + eq(dataset_entry.datasetId, datasetId), + dimValue === null ? isNull(dimensionField) : eq(dimensionField, dimValue) + ) + ) + .limit(1); + + items.push({ + label: dimValue || 'Unknown', + count: annotationCount, + entryId: sampleEntry[0]?.id, + fileName: sampleEntry[0]?.fileName, + durationMs: sampleEntry[0]?.durationMs, + }); + } + + // Sort by count descending + items.sort((a, b) => b.count - a.count); + + return { + dimension, + items, + total, + }; + } catch (error) { + console.error('Error fetching annotation distribution:', error); + throw new Error('Failed to fetch annotation distribution'); + } +} + +export interface AnnotatedSample { + id: number; + fileName: string; + durationMs: number | null; + annotationCount: number; +} + +/** + * Gets all annotated samples for an experiment with their annotation counts. + */ +export async function getAnnotatedSamples( + experimentId: number +): Promise { + const result = await requireAdmin(); + if (!result.authenticated || !result.admin) { + throw new Error('Unauthorized'); + } + + try { + // First get the dataset for this experiment + const expData = await db + .select({ datasetId: experiment.datasetId }) + .from(experiment) + .where(eq(experiment.id, experimentId)) + .limit(1); + + if (expData.length === 0) { + throw new Error('Experiment not found'); + } + + const datasetId = expData[0].datasetId; + + // Get all dataset entries that have annotations for this experiment + // with their annotation counts + const samples = await db + .select({ + id: dataset_entry.id, + fileName: dataset_entry.fileName, + durationMs: dataset_entry.durationMs, + annotationCount: count(annotation.id).as('annotationCount'), + }) + .from(dataset_entry) + .leftJoin( + annotation, + and( + eq(dataset_entry.id, annotation.datasetEntryId), + eq(annotation.experimentId, experimentId) + ) + ) + .where(eq(dataset_entry.datasetId, datasetId)) + .groupBy(dataset_entry.id) + .having((group) => gt(count(annotation.id), 0)); + + // Type assertion for the count + return samples + .map((s) => ({ + id: s.id, + fileName: s.fileName, + durationMs: s.durationMs, + annotationCount: parseInt(s.annotationCount as any) || 0, + })) + .sort((a, b) => b.annotationCount - a.annotationCount); + } catch (error) { + console.error('Error fetching annotated samples:', error); + throw new Error('Failed to fetch annotated samples'); + } +} diff --git a/src/app/actions/calibration-scoring.ts b/src/app/actions/calibration-scoring.ts index 4935706..b309f3e 100644 --- a/src/app/actions/calibration-scoring.ts +++ b/src/app/actions/calibration-scoring.ts @@ -89,6 +89,36 @@ export async function isCalibrationDone(experimentId: number): Promise const session = await auth.api.getSession({ headers: await headers() }); if (!session) throw new Error('Nicht angemeldet'); + // Check if participant exists for this user and experiment + const participantRecord = await db + .select() + .from(participant) + .where( + and( + eq(participant.experimentId, experimentId), + eq(participant.userId, session.user.id) + ) + ) + .limit(1); + + // If no participant record exists, calibration is not done + if (participantRecord.length === 0) { + return false; + } + + // Use the shared validation logic + return isParticipantCalibrationDone(experimentId, participantRecord[0]); +} + +/** + * Checks if a participant has completed calibration for an experiment. + * This is a helper function for admin statistics that validates all calibration answers are complete. + * Returns true if calibration is completed for this participant. + */ +export async function isParticipantCalibrationDone( + experimentId: number, + participantRecord: typeof participant.$inferSelect +): Promise { // Check if calibration is enabled for this experiment const exp = await db .select() @@ -116,26 +146,12 @@ export async function isCalibrationDone(experimentId: number): Promise return true; } - // Check if participant exists for this user and experiment - const participantRecord = await db - .select() - .from(participant) - .where( - and( - eq(participant.experimentId, experimentId), - eq(participant.userId, session.user.id) - ) - ) - .limit(1); - - // If no participant record exists, calibration is not done - if (participantRecord.length === 0) { - return false; - } - // Check if calibration answers are filled - const calibrationAnswers = participantRecord[0].calibrationAnswers as Record | null; - + const calibrationAnswers = participantRecord.calibrationAnswers as Record< + number, + { dialectLabel: string; confidence: number } + > | null; + if (!calibrationAnswers) { return false; } @@ -143,7 +159,7 @@ export async function isCalibrationDone(experimentId: number): Promise // Verify that all calibration items have complete answers for (const item of calibrationItems) { const answer = calibrationAnswers[item.id]; - + // Check if answer exists and has both required fields if (!answer || !answer.dialectLabel || !answer.confidence) { return false; diff --git a/src/app/actions/experiment.ts b/src/app/actions/experiment.ts index 89f9954..f9fc64e 100644 --- a/src/app/actions/experiment.ts +++ b/src/app/actions/experiment.ts @@ -5,9 +5,11 @@ import { experiment } from '@/lib/model/experiment'; import { experiment_calibration } from '@/lib/model/experiment_calibration'; import { annotation } from '@/lib/model/annotation'; import { participant } from '@/lib/model/participant'; -import { and, eq } from 'drizzle-orm'; +import { dataset_entry } from '@/lib/model/dataset_entry'; +import { and, eq, count, sql, isNotNull } from 'drizzle-orm'; import { revalidatePath } from 'next/cache'; import { auth, requireAdmin } from '@/lib/auth'; +import { isParticipantCalibrationDone } from './calibration-scoring'; import { headers } from 'next/headers'; export async function createExperiment({ @@ -183,3 +185,108 @@ export async function clearExperimentData(id: number) { throw new Error('Failed to clear experiment data'); } } + +export async function getExperimentStatistics(id: number) { + const result = await requireAdmin(); + if (!result.authenticated || !result.admin) { + throw new Error('Unauthorized'); + } + + try { + // Get the experiment with dataset + const exp = await db + .select() + .from(experiment) + .where(eq(experiment.id, id)) + .limit(1); + + if (!exp || exp.length === 0) { + throw new Error('Experiment not found'); + } + + const datasetId = exp[0].datasetId; + + // Get all participants + const allParticipants = await db + .select() + .from(participant) + .where(eq(participant.experimentId, id)); + + // Count onboarding completion (simple check for non-null) + const completedOnboarding = allParticipants.filter( + (p) => p.onboardingAnswers !== null && p.onboardingAnswers !== undefined + ).length; + + // Check calibration completion using the proper validation + let completedCalibration = 0; + for (const p of allParticipants) { + if (await isParticipantCalibrationDone(id, p)) { + completedCalibration++; + } + } + + // Count total dataset entries + const totalEntriesResult = await db + .select({ total: count().as('total') }) + .from(dataset_entry) + .where(eq(dataset_entry.datasetId, datasetId)); + + const totalEntries = totalEntriesResult[0]?.total || 0; + + // Get annotation statistics + const annotationStats = await db + .select({ + total: count().as('total'), + entriesWithAnnotations: count(sql`DISTINCT ${annotation.datasetEntryId}`).as('entriesWithAnnotations'), + }) + .from(annotation) + .where(eq(annotation.experimentId, id)); + + // Get per-entry annotation counts + const perEntryAnnotations = await db + .select({ + datasetEntryId: annotation.datasetEntryId, + annotationCount: count().as('annotationCount'), + }) + .from(annotation) + .where(eq(annotation.experimentId, id)) + .groupBy(annotation.datasetEntryId); + + // Calculate statistics + const totalAnnotations = annotationStats[0]?.total || 0; + const entriesWithAnnotations = annotationStats[0]?.entriesWithAnnotations || 0; + const entriesWithoutAnnotations = Math.max(0, totalEntries - entriesWithAnnotations); + const averageAnnotationsPerEntry = totalEntries > 0 ? (totalAnnotations / totalEntries).toFixed(2) : '0'; + + // Find max and min annotations per entry + let maxAnnotationsPerEntry = 0; + let minAnnotationsPerEntry = 0; + if (perEntryAnnotations.length > 0) { + const counts = perEntryAnnotations.map(p => p.annotationCount || 0); + maxAnnotationsPerEntry = Math.max(...counts); + minAnnotationsPerEntry = Math.min(...counts); + } + + return { + participants: { + total: allParticipants.length, + completedOnboarding, + completedCalibration, + }, + entries: { + total: totalEntries, + }, + annotations: { + total: totalAnnotations, + entriesWithAnnotations, + entriesWithoutAnnotations, + averagePerEntry: parseFloat(averageAnnotationsPerEntry), + maxPerEntry: maxAnnotationsPerEntry, + minPerEntry: minAnnotationsPerEntry, + }, + }; + } catch (error) { + console.error('Error fetching experiment statistics:', error); + throw new Error('Failed to fetch experiment statistics'); + } +} diff --git a/src/app/admin/experiments/[id]/page.tsx b/src/app/admin/experiments/[id]/page.tsx index b4f5941..52146f1 100644 --- a/src/app/admin/experiments/[id]/page.tsx +++ b/src/app/admin/experiments/[id]/page.tsx @@ -7,6 +7,7 @@ import DeleteExperimentButton from '@/components/DeleteExperimentButton'; import ClearExperimentDataButton from '@/components/ClearExperimentDataButton'; import UploadCalibrationModal from '@/components/UploadCalibrationModal'; import CalibrationListModal from '@/components/CalibrationListModal'; +import ExperimentStatistics from '@/components/ExperimentStatistics'; import { requireAdmin } from '@/lib/auth'; import { redirect } from 'next/navigation'; @@ -73,9 +74,12 @@ export default async function ExperimentPage({ params }: ExperimentPageProps) { -
- Statistics -
+ + ); diff --git a/src/components/AnnotationDistribution.tsx b/src/components/AnnotationDistribution.tsx new file mode 100644 index 0000000..1166e2b --- /dev/null +++ b/src/components/AnnotationDistribution.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { getAnnotationDistribution, DistributionDimension, DistributionData, DistributionItem, getAnnotatedSamples, AnnotatedSample } from '@/app/actions/annotation-stats'; +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; +import WaveformPlayer from './WaveformPlayer'; + +interface AnnotationDistributionProps { + experimentId: number; +} + +export default function AnnotationDistribution({ experimentId }: AnnotationDistributionProps) { + const [dimension, setDimension] = useState('dialect'); + const [data, setData] = useState(null); + const [samples, setSamples] = useState([]); + const [loading, setLoading] = useState(true); + const [samplesLoading, setSamplesLoading] = useState(true); + const [error, setError] = useState(null); + const [samplesError, setSamplesError] = useState(null); + + const dimensions: { value: DistributionDimension; label: string }[] = [ + { value: 'dialect', label: 'Dialect' }, + { value: 'utteranceId', label: 'Utterance ID' }, + { value: 'model', label: 'Model' }, + { value: 'speaker', label: 'Speaker' }, + ]; + + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true); + const result = await getAnnotationDistribution(experimentId, dimension); + setData(result); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load distribution'); + setData(null); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [experimentId, dimension]); + + useEffect(() => { + const fetchSamples = async () => { + try { + setSamplesLoading(true); + const result = await getAnnotatedSamples(experimentId); + setSamples(result); + setSamplesError(null); + } catch (err) { + setSamplesError(err instanceof Error ? err.message : 'Failed to load samples'); + setSamples([]); + } finally { + setSamplesLoading(false); + } + }; + + fetchSamples(); + }, [experimentId]); + + // Transform data for recharts + const chartData = data?.items.map((item) => ({ + name: item.label, + percentage: data.total > 0 ? ((item.count / data.total) * 100).toFixed(1) : 0, + ...item, + })) || []; + + return ( +
+
+

Annotation Distribution

+ + {/* Dimension Selector */} +
+ + +
+
+ + {loading && ( +
+ Loading distribution data... +
+ )} + + {error && ( +
+ Error: {error} +
+ )} + + {data && !loading && ( +
+ {/* Chart Section */} +
+ {chartData.length > 0 ? ( + + + + + + { + if (name === 'count') return [value, 'Count']; + return [value, name]; + }} + content={({ active, payload }) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+

{data.name}

+

Count: {data.count}

+

Percentage: {data.percentage}%

+
+ ); + } + return null; + }} + /> + + +
+
+ ) : ( +
+ No annotations found for this distribution +
+ )} +
+
+ )} + +
+ ); +} diff --git a/src/components/ExperimentStatistics.tsx b/src/components/ExperimentStatistics.tsx new file mode 100644 index 0000000..705eef6 --- /dev/null +++ b/src/components/ExperimentStatistics.tsx @@ -0,0 +1,189 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { getExperimentStatistics } from '@/app/actions/experiment'; +import AnnotationDistribution from './AnnotationDistribution'; + +interface ExperimentStatisticsProps { + experimentId: number; + onboardingEnabled: boolean; + calibrationEnabled: boolean; +} + +interface Statistics { + participants: { + total: number; + completedOnboarding: number; + completedCalibration: number; + }; + entries: { + total: number; + }; + annotations: { + total: number; + entriesWithAnnotations: number; + entriesWithoutAnnotations: number; + averagePerEntry: number; + maxPerEntry: number; + minPerEntry: number; + }; +} + +export default function ExperimentStatistics({ + experimentId, + onboardingEnabled, + calibrationEnabled, +}: ExperimentStatisticsProps) { + const [statistics, setStatistics] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchStatistics = async () => { + try { + const stats = await getExperimentStatistics(experimentId); + setStatistics(stats); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load statistics'); + } finally { + setLoading(false); + } + }; + + fetchStatistics(); + }, [experimentId]); + + if (loading) { + return ( +
+
Loading statistics...
+
+ ); + } + + if (error) { + return ( +
+
Error: {error}
+
+ ); + } + + if (!statistics) { + return ( +
+
No statistics available
+
+ ); + } + + return ( +
+

Statistics

+ + {/* Participants Section */} +
+

Participants

+
+
+
Total Participants
+
+ {statistics.participants.total} +
+
+ + {onboardingEnabled && ( +
+
Completed Onboarding
+
+ {statistics.participants.completedOnboarding} +
+
+ {statistics.participants.total > 0 + ? ( + (statistics.participants.completedOnboarding / + statistics.participants.total) * + 100 + ).toFixed(1) + : 0} + % +
+
+ )} + + {calibrationEnabled && ( +
+
Completed Calibration
+
+ {statistics.participants.completedCalibration} +
+
+ {statistics.participants.total > 0 + ? ( + (statistics.participants.completedCalibration / + statistics.participants.total) * + 100 + ).toFixed(1) + : 0} + % +
+
+ )} +
+
+ + {/* Annotations Section */} +
+

Sample Annotations

+
+
+ Total Samples + {statistics.entries.total} +
+ +
+ Total Annotations + {statistics.annotations.total} +
+ +
+ Samples with Annotations + + {statistics.annotations.entriesWithAnnotations} + + ({statistics.entries.total > 0 + ? ( + (statistics.annotations.entriesWithAnnotations / + statistics.entries.total) * + 100 + ).toFixed(1) + : 0} + %) + + +
+ +
+ Samples without Annotations + + {statistics.annotations.entriesWithoutAnnotations} + +
+ +
+ Average Annotations per Sample + + {statistics.annotations.averagePerEntry.toFixed(2)} + +
+
+
+ + {/* Annotation Distribution Diagram */} +
+ +
+
+ ); +}