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..8fd56a9 --- /dev/null +++ b/src/app/actions/annotation-stats.ts @@ -0,0 +1,198 @@ +'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); + + // 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 10bbb10..80777c3 100644 --- a/src/app/actions/calibration-scoring.ts +++ b/src/app/actions/calibration-scoring.ts @@ -5,41 +5,69 @@ import { experiment_calibration } from '@/lib/model/experiment_calibration'; import { experiment } from '@/lib/model/experiment'; import { participant } from '@/lib/model/participant'; import { eq, and } from 'drizzle-orm'; -import { auth } from '@/lib/auth'; +import { auth, requireAdmin } from '@/lib/auth'; import { headers } from 'next/headers'; /** - * Calculates dialect performance scores based on the participant's calibration answers. + * Calculates dialect performance scores based on calibration answers. * Score = (correctness × confidence) averaged per dialect * Range: -1 to 1, where: * 1 = perfect identification with full confidence * 0 = neutral (correct but unsure, or incorrect and unsure) * -1 = completely wrong with full confidence (penalizes false confidence) + * + * @param experimentId - The experiment ID + * @param userId - Optional user ID. If provided, requires admin privileges. If not provided, uses current user. */ export async function getDialectScoresFromCalibration( - experimentId: number + experimentId: number, + userId?: string ): Promise> { const session = await auth.api.getSession({ headers: await headers() }); if (!session) throw new Error('Nicht angemeldet'); - // Fetch calibration items and participant's answers + // Determine which user to fetch scores for + let targetUserId = userId; + if (targetUserId) { + // Admin access required for fetching other user's scores + const result = await requireAdmin(); + if (!result.authenticated || !result.admin) { + throw new Error('Unauthorized'); + } + } else { + // Use current user if no userId provided + targetUserId = session.user.id; + } + + // Fetch calibration items const calibrationItems = await db .select() .from(experiment_calibration) .where(eq(experiment_calibration.experimentId, experimentId)); + // Fetch participant record const participantRecord = await db .select() .from(participant) .where( and( eq(participant.experimentId, experimentId), - eq(participant.userId, session.user.id) + eq(participant.userId, targetUserId) ) ) .limit(1); - // Calculate dialect scores based on calibration performance + // Calculate dialect scores + return calculateDialectScores(calibrationItems, participantRecord); +} + +/** + * Helper function to calculate dialect scores from calibration items and answers + */ +function calculateDialectScores( + calibrationItems: typeof experiment_calibration.$inferSelect[], + participantRecord: (typeof participant.$inferSelect)[] +): Record { const dialectScores: Record = {}; if (participantRecord.length > 0 && participantRecord[0].calibrationAnswers) { @@ -89,6 +117,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 +174,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 +187,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/actions/participants.ts b/src/app/actions/participants.ts new file mode 100644 index 0000000..a94dae5 --- /dev/null +++ b/src/app/actions/participants.ts @@ -0,0 +1,452 @@ +'use server'; + +import db from '@/lib/db'; +import { participant } from '@/lib/model/participant'; +import { annotation } from '@/lib/model/annotation'; +import { experiment } from '@/lib/model/experiment'; +import { dataset } from '@/lib/model/dataset'; +import { dataset_entry } from '@/lib/model/dataset_entry'; +import { user } from '@/lib/model/auth-schema'; +import { and, eq, count } from 'drizzle-orm'; +import { requireAdmin } from '@/lib/auth'; +import { isParticipantCalibrationDone, getDialectScoresFromCalibration } from './calibration-scoring'; +import JSZip from 'jszip'; + +export interface ParticipantListItem { + id: number; + experimentId: number; + userId: string; + email: string; + completedOnboarding: boolean; + completedCalibration: boolean; + annotationCount: number; + createdAt: Date; + updatedAt: Date; +} + +export interface ParticipantDetail extends ParticipantListItem { + onboardingAnswers: any; + calibrationAnswers: any; + annotations: Array<{ + id: number; + datasetEntryId: number; + externalId: string; + fileName: string; + datasetId: number; + rating: number; + dialectLabel: string; + createdAt: Date; + }>; +} + +/** + * Get all participants for an experiment as a list + */ +export async function getParticipantsList(experimentId: number): Promise { + const result = await requireAdmin(); + if (!result.authenticated || !result.admin) { + throw new Error('Unauthorized'); + } + + try { + // Verify experiment exists + const exp = await db.select().from(experiment).where(eq(experiment.id, experimentId)).limit(1); + if (!exp || exp.length === 0) { + throw new Error('Experiment not found'); + } + + // Get all participants for this experiment + const participants = await db + .select({ + id: participant.id, + experimentId: participant.experimentId, + userId: participant.userId, + email: user.email, + calibrationAnswers: participant.calibrationAnswers, + onboardingAnswers: participant.onboardingAnswers, + createdAt: participant.createdAt, + updatedAt: participant.updatedAt, + }) + .from(participant) + .leftJoin(user, eq(participant.userId, user.id)) + .where(eq(participant.experimentId, experimentId)); + + // Build list with annotation counts + const participantsList: ParticipantListItem[] = []; + + for (const p of participants) { + // Count annotations for this participant + const annotationCounts = await db + .select({ count: count().as('count') }) + .from(annotation) + .where( + and( + eq(annotation.experimentId, experimentId), + eq(annotation.userId, p.userId) + ) + ); + + const annotationCount = annotationCounts[0]?.count || 0; + + // Use the proper calibration validation function + const completedCalibration = await isParticipantCalibrationDone(experimentId, p); + + participantsList.push({ + id: p.id, + experimentId: p.experimentId!, + userId: p.userId, + email: p.email || 'Unknown', + completedOnboarding: p.onboardingAnswers !== null && p.onboardingAnswers !== undefined, + completedCalibration, + annotationCount, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + }); + } + + // Sort by descending creation date + participantsList.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + + return participantsList; + } catch (error) { + console.error('Error fetching participants list:', error); + throw error; + } +} + +/** + * Get detailed information about a specific participant + */ +export async function getParticipantDetail( + experimentId: number, + userId: string +): Promise { + const result = await requireAdmin(); + if (!result.authenticated || !result.admin) { + throw new Error('Unauthorized'); + } + + try { + // Verify experiment exists + const exp = await db.select().from(experiment).where(eq(experiment.id, experimentId)).limit(1); + if (!exp || exp.length === 0) { + throw new Error('Experiment not found'); + } + + // Get participant + const participants = await db + .select({ + id: participant.id, + experimentId: participant.experimentId, + userId: participant.userId, + email: user.email, + calibrationAnswers: participant.calibrationAnswers, + onboardingAnswers: participant.onboardingAnswers, + createdAt: participant.createdAt, + updatedAt: participant.updatedAt, + }) + .from(participant) + .leftJoin(user, eq(participant.userId, user.id)) + .where( + and( + eq(participant.experimentId, experimentId), + eq(participant.userId, userId) + ) + ) + .limit(1); + + if (!participants || participants.length === 0) { + throw new Error('Participant not found'); + } + + const p = participants[0]; + + // Get all annotations for this participant + const participantAnnotations = await db + .select({ + id: annotation.id, + datasetEntryId: annotation.datasetEntryId, + externalId: dataset_entry.externalId, + fileName: dataset_entry.fileName, + datasetId: dataset_entry.datasetId, + rating: annotation.rating, + dialectLabel: annotation.dialectLabel, + createdAt: annotation.createdAt, + }) + .from(annotation) + .leftJoin(dataset_entry, eq(annotation.datasetEntryId, dataset_entry.id)) + .where( + and( + eq(annotation.experimentId, experimentId), + eq(annotation.userId, userId) + ) + ); + + // Use the proper calibration validation function + const completedCalibration = await isParticipantCalibrationDone(experimentId, p); + + return { + id: p.id, + experimentId: p.experimentId!, + userId: p.userId, + email: p.email || 'Unknown', + completedOnboarding: p.onboardingAnswers !== null && p.onboardingAnswers !== undefined, + completedCalibration, + onboardingAnswers: p.onboardingAnswers || {}, + calibrationAnswers: p.calibrationAnswers || {}, + annotationCount: participantAnnotations.length, + annotations: participantAnnotations.map(a => ({ + id: a.id, + datasetEntryId: a.datasetEntryId, + externalId: a.externalId || 'Unknown', fileName: a.fileName || 'Unknown', + datasetId: a.datasetId || 0, rating: a.rating, + dialectLabel: a.dialectLabel, + createdAt: a.createdAt, + })), + createdAt: p.createdAt, + updatedAt: p.updatedAt, + }; + } catch (error) { + console.error('Error fetching participant detail:', error); + throw error; + } +} + +/** + * Get calibration scores for a participant + */ +export async function getParticipantCalibrationScores( + experimentId: number, + userId: string +): Promise> { + const result = await requireAdmin(); + if (!result.authenticated || !result.admin) { + throw new Error('Unauthorized'); + } + + try { + // Verify experiment exists + const exp = await db.select().from(experiment).where(eq(experiment.id, experimentId)).limit(1); + if (!exp || exp.length === 0) { + throw new Error('Experiment not found'); + } + + // Verify participant exists + const participants = await db + .select() + .from(participant) + .where( + and( + eq(participant.experimentId, experimentId), + eq(participant.userId, userId) + ) + ) + .limit(1); + + if (!participants || participants.length === 0) { + throw new Error('Participant not found'); + } + + // Get calibration scores using the existing server action + const scores = await getDialectScoresFromCalibration(experimentId, userId); + return scores; + } catch (error) { + console.error('Error fetching participant calibration scores:', error); + throw error; + } +} + +/** + * Export participant data as JSON + * Includes participant info, onboarding, calibration, calibration scores, and annotations + */ +export async function exportParticipantDataAsJson( + experimentId: number, + userId: string +): Promise<{ + jsonData: string; + filename: string; +}> { + const result = await requireAdmin(); + if (!result.authenticated || !result.admin) { + throw new Error('Unauthorized'); + } + + try { + // Get participant detail which includes all basic info and annotations + const participantDetail = await getParticipantDetail(experimentId, userId); + + // Get calibration scores if calibration is completed + let calibrationScores: Record | null = null; + if (participantDetail.completedCalibration) { + try { + calibrationScores = await getParticipantCalibrationScores(experimentId, userId); + } catch (err) { + console.warn('Could not fetch calibration scores:', err); + } + } + + // Create comprehensive export object + const exportData = { + exportedAt: new Date().toISOString(), + participant: { + id: participantDetail.id, + userId: participantDetail.userId, + experimentId: participantDetail.experimentId, + createdAt: participantDetail.createdAt, + updatedAt: participantDetail.updatedAt, + }, + progress: { + completedOnboarding: participantDetail.completedOnboarding, + completedCalibration: participantDetail.completedCalibration, + totalAnnotations: participantDetail.annotationCount, + }, + onboarding: { + completed: participantDetail.completedOnboarding, + answers: participantDetail.onboardingAnswers, + }, + calibration: { + completed: participantDetail.completedCalibration, + answers: participantDetail.calibrationAnswers, + scores: calibrationScores, + }, + annotations: participantDetail.annotations.map((ann) => ({ + id: ann.id, + externalId: ann.externalId, + fileName: ann.fileName, + datasetId: ann.datasetId, + rating: ann.rating, + dialectLabel: ann.dialectLabel, + createdAt: ann.createdAt, + })), + }; + + // Convert to JSON string with pretty formatting + const jsonData = JSON.stringify(exportData, null, 2); + + // Generate filename with timestamp + const timestamp = new Date().toISOString().slice(0, 10); + const filename = `participant-${userId}-${timestamp}.json`; + + return { + jsonData, + filename, + }; + } catch (error) { + console.error('Error exporting participant data:', error); + throw error; + } +} + +/** + * Export all participant data from an experiment as a ZIP file + * Returns a base64-encoded ZIP archive and a filename + */ +export async function exportAllParticipantDataAsZip(experimentId: number): Promise<{ + base64Data: string; + filename: string; +}> { + const result = await requireAdmin(); + if (!result.authenticated || !result.admin) { + throw new Error('Unauthorized'); + } + + try { + // Verify experiment exists + const exp = await db + .select() + .from(experiment) + .where(eq(experiment.id, experimentId)) + .limit(1); + + if (!exp || exp.length === 0) { + throw new Error('Experiment not found'); + } + + // Get all participants for this experiment + const participants = await getParticipantsList(experimentId); + + if (participants.length === 0) { + throw new Error('No participants found in this experiment'); + } + + // Create ZIP archive + const zip = new JSZip(); + const participantsFolder = zip.folder('participants'); + + if (!participantsFolder) { + throw new Error('Failed to create ZIP folder structure'); + } + + // Export each participant's data + for (const p of participants) { + try { + const { jsonData, filename } = await exportParticipantDataAsJson(experimentId, p.userId); + participantsFolder.file(filename, jsonData); + } catch (err) { + console.warn(`Failed to export data for participant ${p.userId}:`, err); + // Continue with next participant if one fails + } + } + + // Fetch dataset information + const datasetInfo = await db + .select() + .from(dataset) + .where(eq(dataset.id, exp[0].datasetId)) + .limit(1); + + // Count total entries in the dataset + const totalEntries = await db + .select({ count: count().as('count') }) + .from(dataset_entry) + .where(eq(dataset_entry.datasetId, exp[0].datasetId)); + + // Create summary file with metadata + const summary = { + exportedAt: new Date().toISOString(), + experimentId: experimentId, + experimentName: exp[0].name, + dataset: datasetInfo.length > 0 ? { + id: datasetInfo[0].id, + name: datasetInfo[0].name, + description: datasetInfo[0].description, + totalEntries: totalEntries[0]?.count || 0, + createdAt: datasetInfo[0].createdAt, + } : null, + totalParticipants: participants.length, + participants: participants.map((p) => ({ + userId: p.userId, + email: p.email, + joinedAt: p.createdAt, + completedOnboarding: p.completedOnboarding, + completedCalibration: p.completedCalibration, + annotationCount: p.annotationCount, + })), + }; + + zip.file( + 'SUMMARY.json', + JSON.stringify(summary, null, 2), + ); + + // Generate ZIP buffer + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + + // Generate filename with timestamp + const timestamp = new Date().toISOString().slice(0, 10); + const sanitizedExperimentName = exp[0].name.replace(/[^a-z0-9]/gi, '_').toLowerCase(); + const filename = `experiment-${sanitizedExperimentName}-${timestamp}.zip`; + + // Convert buffer to base64 string for client transfer + const base64Data = buffer.toString('base64'); + + return { + base64Data, + filename, + }; + } catch (error) { + console.error('Error exporting all participant data:', error); + throw error; + } +} diff --git a/src/app/admin/datasets/[id]/entries/[entryId]/page.tsx b/src/app/admin/datasets/[id]/entries/[entryId]/page.tsx index f680b10..74cf58a 100644 --- a/src/app/admin/datasets/[id]/entries/[entryId]/page.tsx +++ b/src/app/admin/datasets/[id]/entries/[entryId]/page.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import db from '@/lib/db'; import { dataset_entry } from '@/lib/model/dataset_entry'; import { eq } from 'drizzle-orm'; -import AudioPlayer from '@/components/AudioPlayer'; +import WaveformPlayer from '@/components/WaveformPlayer'; import { requireAdmin } from '@/lib/auth'; import { redirect } from 'next/navigation'; @@ -114,7 +114,11 @@ export default async function DatasetEntryPage({ params }: DatasetEntryPageProps

Audio Player

- + {(() => { + const fileExtension = entry.fileName.substring(entry.fileName.lastIndexOf('.')); + const src = `/public/datasets/${datasetIdNum}/${entry.externalId}${fileExtension}`; + return ; + })()}
diff --git a/src/app/admin/experiments/[id]/page.tsx b/src/app/admin/experiments/[id]/page.tsx index b4f5941..cf3ac46 100644 --- a/src/app/admin/experiments/[id]/page.tsx +++ b/src/app/admin/experiments/[id]/page.tsx @@ -7,6 +7,8 @@ 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 ExportAllParticipantDataButton from '@/components/ExportAllParticipantDataButton'; import { requireAdmin } from '@/lib/auth'; import { redirect } from 'next/navigation'; @@ -67,15 +69,25 @@ export default async function ExperimentPage({ params }: ExperimentPageProps) { -
+
+ + View Participants + +
-
- Statistics -
+ +
); diff --git a/src/app/admin/experiments/[id]/participants/[userId]/page.tsx b/src/app/admin/experiments/[id]/participants/[userId]/page.tsx new file mode 100644 index 0000000..612bf5b --- /dev/null +++ b/src/app/admin/experiments/[id]/participants/[userId]/page.tsx @@ -0,0 +1,101 @@ +import Link from 'next/link'; +import { getParticipantDetail } from '@/app/actions/participants'; +import { requireAdmin } from '@/lib/auth'; +import { redirect } from 'next/navigation'; +import db from '@/lib/db'; +import { experiment } from '@/lib/model/experiment'; +import { eq } from 'drizzle-orm'; +import ParticipantDetailView from '@/components/ParticipantDetailView'; + +interface ParticipantDetailPageProps { + params: { + id: string; + userId: string; + }; +} + +export default async function ParticipantPage({ params }: ParticipantDetailPageProps) { + const result = await requireAdmin(); + + if (!result.authenticated) { + redirect("/user/sign-in"); + } + + if (!result.admin) { + redirect("/"); + } + + const { id, userId } = await params; + const experimentId = parseInt(id, 10); + + // Verify experiment exists + const experiments = await db + .select() + .from(experiment) + .where(eq(experiment.id, experimentId)); + + if (experiments.length === 0) { + return ( +
+
+ + ← Back to Experiments + +
+

Experiment not found

+
+
+
+ ); + } + + const exp = experiments[0]; + + // Get participant details + let participant; + try { + participant = await getParticipantDetail(experimentId, userId); + } catch (error) { + return ( +
+
+ + ← Back to Participants + +
+

Participant not found

+
+
+
+ ); + } + + return ( +
+
+
+ + ← Back to Participants + +
+
+

{exp.name}

+

Participant Data

+
+
+
+ + +
+
+ ); +} diff --git a/src/app/admin/experiments/[id]/participants/page.tsx b/src/app/admin/experiments/[id]/participants/page.tsx new file mode 100644 index 0000000..ab603f7 --- /dev/null +++ b/src/app/admin/experiments/[id]/participants/page.tsx @@ -0,0 +1,83 @@ +import Link from 'next/link'; +import { getParticipantsList } from '@/app/actions/participants'; +import { requireAdmin } from '@/lib/auth'; +import { redirect } from 'next/navigation'; +import db from '@/lib/db'; +import { experiment } from '@/lib/model/experiment'; +import { eq } from 'drizzle-orm'; +import ParticipantsList from '@/components/ParticipantsList'; + +interface ParticipantsPageProps { + params: { + id: string; + }; +} + +export default async function ParticipantsPage({ params }: ParticipantsPageProps) { + const result = await requireAdmin(); + + if (!result.authenticated) { + redirect("/user/sign-in"); + } + + if (!result.admin) { + redirect("/"); + } + + const { id } = await params; + const experimentId = parseInt(id, 10); + + // Verify experiment exists + const experiments = await db + .select() + .from(experiment) + .where(eq(experiment.id, experimentId)); + + if (experiments.length === 0) { + return ( +
+
+ + ← Back to Experiments + +
+

Experiment not found

+
+
+
+ ); + } + + const exp = experiments[0]; + + // Get participants + const participants = await getParticipantsList(experimentId); + + return ( +
+
+
+
+ + ← Back to Experiment + +

Participants

+

{exp.name}

+
+
+
{participants.length}
+
Total Participants
+
+
+ + +
+
+ ); +} diff --git a/src/components/AnnotationDistribution.tsx b/src/components/AnnotationDistribution.tsx new file mode 100644 index 0000000..291d8f9 --- /dev/null +++ b/src/components/AnnotationDistribution.tsx @@ -0,0 +1,228 @@ +'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'; + +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, + })) || []; + + // Calculate per-sample distribution (histogram of annotation counts) + const sampleDistribution: { annotationCount: number; sampleCount: number; percentage: string }[] = []; + const countMap = new Map(); + + samples.forEach((sample) => { + const count = sample.annotationCount; + countMap.set(count, (countMap.get(count) || 0) + 1); + }); + + // Convert to sorted array with percentages + const totalSamples = samples.length; + Array.from(countMap.entries()) + .map(([count, sampleCount]) => ({ + annotationCount: count, + sampleCount, + percentage: totalSamples > 0 ? ((sampleCount / totalSamples) * 100).toFixed(1) : '0', + })) + .sort((a, b) => a.annotationCount - b.annotationCount) + .forEach((item) => sampleDistribution.push(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 +
+ )} +
+
+ )} + + {/* Sample Annotation Distribution Chart */} + {!samplesLoading && sampleDistribution.length > 0 && ( +
+

Sample Annotation Coverage

+
+ + + + + + { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+

+ {data.annotationCount} annotation{data.annotationCount !== 1 ? 's' : ''} +

+

{data.percentage}% of samples

+

({data.sampleCount} sample{data.sampleCount !== 1 ? 's' : ''})

+
+ ); + } + return null; + }} + /> + + +
+
+
+
+ )} + +
+ ); +} diff --git a/src/components/AnnotationViews/SingleChoiceBinaryEntryView.tsx b/src/components/AnnotationViews/SingleChoiceBinaryEntryView.tsx index 2ceb9db..d5507da 100644 --- a/src/components/AnnotationViews/SingleChoiceBinaryEntryView.tsx +++ b/src/components/AnnotationViews/SingleChoiceBinaryEntryView.tsx @@ -59,8 +59,8 @@ export default function SingleChoiceBinaryEntryView({ {/* Waveform player */} setFullyPlayed(true)} + playMode='pause' /> {/* Must-listen hint */} diff --git a/src/components/AnnotationViews/SingleChoiceEntryView.tsx b/src/components/AnnotationViews/SingleChoiceEntryView.tsx index d66a23c..498e6f3 100644 --- a/src/components/AnnotationViews/SingleChoiceEntryView.tsx +++ b/src/components/AnnotationViews/SingleChoiceEntryView.tsx @@ -60,8 +60,8 @@ export default function SingleChoiceEntryView({ {/* Waveform player */} setFullyPlayed(true)} + playMode='pause' /> {/* Must-listen hint */} diff --git a/src/components/AudioPlayer.tsx b/src/components/AudioPlayer.tsx deleted file mode 100644 index 2302819..0000000 --- a/src/components/AudioPlayer.tsx +++ /dev/null @@ -1,83 +0,0 @@ -'use client'; - -import { useState, useRef, useEffect } from 'react'; -import { useAudio } from './AudioProvider'; - -interface AudioPlayerProps { - fileName: string; - datasetId: number; - externalId?: string; -} - -export default function AudioPlayer({ fileName, datasetId, externalId }: AudioPlayerProps) { - const [isPlaying, setIsPlaying] = useState(false); - const audioRef = useRef(null); - const { currentAudioId, setCurrentAudio } = useAudio(); - const audioId = `${datasetId}-${externalId}`; - - // Extract file extension from original fileName and construct URL using externalId - const fileExtension = fileName.substring(fileName.lastIndexOf('.')); - const audioPath = `/public/datasets/${datasetId}/${externalId}${fileExtension}`; - - // Stop playing if another audio started - useEffect(() => { - if (currentAudioId !== audioId && audioRef.current && isPlaying) { - audioRef.current.pause(); - audioRef.current.currentTime = 0; - setIsPlaying(false); - } - }, [currentAudioId, audioId, isPlaying]); - - const handlePlay = () => { - if (audioRef.current) { - setCurrentAudio(audioId); - audioRef.current.currentTime = 0; - audioRef.current.play(); - setIsPlaying(true); - } - }; - - const handleStop = () => { - if (audioRef.current) { - audioRef.current.pause(); - audioRef.current.currentTime = 0; - setCurrentAudio(null); - setIsPlaying(false); - } - }; - - const handlePlaybackEnd = () => { - setIsPlaying(false); - setCurrentAudio(null); - }; - - return ( -
-
- ); -} diff --git a/src/components/AudioProvider.tsx b/src/components/AudioProvider.tsx index 25f2e94..c152d4e 100644 --- a/src/components/AudioProvider.tsx +++ b/src/components/AudioProvider.tsx @@ -1,19 +1,39 @@ 'use client'; -import React, { createContext, useContext, useState, ReactNode } from 'react'; +import React, { createContext, useContext, useState, ReactNode, useCallback } from 'react'; interface AudioContextType { currentAudioId: string | null; setCurrentAudio: (id: string | null) => void; + playerCount: number; + registerPlayer: (id: string) => void; + unregisterPlayer: (id: string) => void; } const AudioContext = createContext(undefined); export function AudioProvider({ children }: { children: ReactNode }) { const [currentAudioId, setCurrentAudioId] = useState(null); + const [playerCount, setPlayerCount] = useState(0); + + const registerPlayer = useCallback((id: string) => { + setPlayerCount((prev) => prev + 1); + }, []); + + const unregisterPlayer = useCallback((id: string) => { + setPlayerCount((prev) => Math.max(0, prev - 1)); + }, []); return ( - + {children} ); diff --git a/src/components/CalibrationListModal.tsx b/src/components/CalibrationListModal.tsx index c3c3f83..3f3ed29 100644 --- a/src/components/CalibrationListModal.tsx +++ b/src/components/CalibrationListModal.tsx @@ -184,7 +184,7 @@ export default function CalibrationListModal({ experimentId }: CalibrationListMo <> diff --git a/src/components/CalibrationViews/CalibrationEntryView.tsx b/src/components/CalibrationViews/CalibrationEntryView.tsx index 5537c64..397c95e 100644 --- a/src/components/CalibrationViews/CalibrationEntryView.tsx +++ b/src/components/CalibrationViews/CalibrationEntryView.tsx @@ -81,8 +81,8 @@ export default function CalibrationEntryView({ {/* Waveform player */} setFullyPlayed(true)} + playMode='pause' /> {/* Must-listen hint */} diff --git a/src/components/DatasetEntriesList.tsx b/src/components/DatasetEntriesList.tsx index fa33d7c..9e56b28 100644 --- a/src/components/DatasetEntriesList.tsx +++ b/src/components/DatasetEntriesList.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; -import AudioPlayer from './AudioPlayer'; +import WaveformPlayer from './WaveformPlayer'; import { getDatasetEntries } from '@/app/actions/get-dataset-entries'; import { getFilterOptions } from '@/app/actions/get-filter-options'; import { downloadFilteredEntriesAsZip, downloadFilteredEntriesAsCsv } from '@/lib/download-utils'; @@ -514,7 +514,11 @@ export default function DatasetEntriesList({ {entries.map((entry) => ( - + {(() => { + const fileExtension = entry.fileName.substring(entry.fileName.lastIndexOf('.')); + const src = `/public/datasets/${datasetId}/${entry.externalId}${fileExtension}`; + return ; + })()} (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 */} +
+ +
+
+ ); +} diff --git a/src/components/ExportAllParticipantDataButton.tsx b/src/components/ExportAllParticipantDataButton.tsx new file mode 100644 index 0000000..293ee24 --- /dev/null +++ b/src/components/ExportAllParticipantDataButton.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { exportAllParticipantDataAsZip } from '@/app/actions/participants'; +import { useState } from 'react'; + +interface ExportAllParticipantDataButtonProps { + experimentId: number; +} + +export default function ExportAllParticipantDataButton({ + experimentId, +}: ExportAllParticipantDataButtonProps) { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleExport = async () => { + setIsLoading(true); + setError(null); + + try { + const { base64Data, filename } = await exportAllParticipantDataAsZip(experimentId); + + // Convert base64 back to Blob + const binaryString = atob(base64Data); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + const blob = new Blob([bytes], { type: 'application/zip' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to export data'); + console.error('Export error:', err); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + {error &&
{error}
} +
+ ); +} diff --git a/src/components/ExportParticipantDataButton.tsx b/src/components/ExportParticipantDataButton.tsx new file mode 100644 index 0000000..c08145c --- /dev/null +++ b/src/components/ExportParticipantDataButton.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { exportParticipantDataAsJson } from '@/app/actions/participants'; +import { useState } from 'react'; + +interface ExportParticipantDataButtonProps { + experimentId: number; + userId: string; +} + +export default function ExportParticipantDataButton({ + experimentId, + userId, +}: ExportParticipantDataButtonProps) { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleExport = async () => { + setIsLoading(true); + setError(null); + + try { + const { jsonData, filename } = await exportParticipantDataAsJson(experimentId, userId); + + // Create blob and trigger download + const blob = new Blob([jsonData], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to export data'); + console.error('Export error:', err); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + {error &&
{error}
} +
+ ); +} diff --git a/src/components/ParticipantDetailView.tsx b/src/components/ParticipantDetailView.tsx new file mode 100644 index 0000000..dd7d88a --- /dev/null +++ b/src/components/ParticipantDetailView.tsx @@ -0,0 +1,187 @@ +'use client'; + +import { ParticipantDetail, getParticipantCalibrationScores } from '@/app/actions/participants'; +import { useEffect, useState } from 'react'; +import CalibrationScoresDisplay from './CalibrationScoresDisplay'; +import WaveformPlayer from './WaveformPlayer'; +import ExportParticipantDataButton from './ExportParticipantDataButton'; + +interface ParticipantDetailViewProps { + participant: ParticipantDetail; + experimentId: number; +} + +interface CalibrationScores { + [dialectLabel: string]: number; +} + +export default function ParticipantDetailView({ participant, experimentId }: ParticipantDetailViewProps) { + const [selectedAnnotation, setSelectedAnnotation] = useState<(typeof participant.annotations)[0] | null>( + participant.annotations.length > 0 ? participant.annotations[0] : null + ); + const [calibrationScores, setCalibrationScores] = useState(null); + const [scoresLoading, setScoresLoading] = useState(false); + const [scoresError, setScoresError] = useState(null); + + useEffect(() => { + if (participant.completedCalibration) { + const fetchScores = async () => { + setScoresLoading(true); + setScoresError(null); + try { + const scores = await getParticipantCalibrationScores(experimentId, participant.userId); + setCalibrationScores(scores); + } catch (err) { + setScoresError(err instanceof Error ? err.message : 'Failed to load calibration scores'); + } finally { + setScoresLoading(false); + } + }; + fetchScores(); + } + }, [experimentId, participant.userId, participant.completedCalibration]); + + return ( +
+ {/* Header Card */} +
+
+

{participant.userId}

+ +
+
+
+
Status
+
Joined
+
+ {new Date(participant.createdAt).toLocaleDateString()} +
+
+
+
Onboarding
+
+ {participant.completedOnboarding ? '✓ Completed' : '○ Pending'} +
+
+
+
Calibration
+
+ {participant.completedCalibration ? '✓ Completed' : '○ Pending'} +
+
+
+
Annotations
+
{participant.annotationCount}
+
+
+
+ + {/* Onboarding Data */} + {participant.completedOnboarding && ( +
+

Onboarding Data

+
+ {JSON.stringify(participant.onboardingAnswers, null, 2)} +
+
+ )} + + {/* Calibration Data */} + {participant.completedCalibration && ( +
+

Calibration Data

+
+ {JSON.stringify(participant.calibrationAnswers, null, 2)} +
+
+ )} + + {/* Calibration Scores */} + {participant.completedCalibration && ( +
+

Calibration Scores

+ {scoresLoading &&
Loading scores...
} + {scoresError &&
Error: {scoresError}
} + {calibrationScores && Object.keys(calibrationScores).length > 0 ? ( + + ) : ( +
No calibration scores available
+ )} +
+ )} + + {/* Annotations */} + {participant.annotationCount > 0 && ( +
+ {/* Annotations List */} +
+

Annotations ({participant.annotationCount})

+
+ {participant.annotations.map((annotation) => ( + + ))} +
+
+ + {/* Selected Annotation Details */} + {selectedAnnotation && ( +
+

Details

+
+
+
External ID
+
{selectedAnnotation.externalId}
+
+
+
Rating
+
{selectedAnnotation.rating}
+
+
+
Dialect
+
+ {selectedAnnotation.dialectLabel} +
+
+
+
Timestamp
+
{new Date(selectedAnnotation.createdAt).toLocaleString()}
+
+
+
Audio
+ {(() => { + const fileExtension = selectedAnnotation.fileName.substring(selectedAnnotation.fileName.lastIndexOf('.')); + const src = `/public/datasets/${selectedAnnotation.datasetId}/${selectedAnnotation.externalId}${fileExtension}`; + return ; + })()} +
+
+
+ )} +
+ )} + + {participant.annotationCount === 0 && ( +
+

No annotations yet

+
+ )} +
+ ); +} diff --git a/src/components/ParticipantsList.tsx b/src/components/ParticipantsList.tsx new file mode 100644 index 0000000..1fd6ce7 --- /dev/null +++ b/src/components/ParticipantsList.tsx @@ -0,0 +1,163 @@ +'use client'; + +import Link from 'next/link'; +import { ParticipantListItem } from '@/app/actions/participants'; +import { useState, useMemo } from 'react'; + +interface ParticipantsListProps { + experimentId: number; + participants: ParticipantListItem[]; +} + +type SortField = 'userId' | 'email' | 'createdAt' | 'annotationCount'; +type SortOrder = 'asc' | 'desc'; + +export default function ParticipantsList({ experimentId, participants }: ParticipantsListProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [sortField, setSortField] = useState('createdAt'); + const [sortOrder, setSortOrder] = useState('desc'); + + const handleSort = (field: SortField) => { + if (sortField === field) { + setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); + } else { + setSortField(field); + setSortOrder('asc'); + } + }; + + const getSortIndicator = (field: SortField) => { + if (sortField !== field) return ' ↑↓'; + return sortOrder === 'asc' ? ' ↑' : ' ↓'; + }; + + const filteredAndSortedParticipants = useMemo(() => { + let filtered = participants.filter((p) => { + const query = searchQuery.toLowerCase(); + return ( + p.userId.toLowerCase().includes(query) || + p.email.toLowerCase().includes(query) + ); + }); + + filtered.sort((a, b) => { + let aValue: any = a[sortField]; + let bValue: any = b[sortField]; + + if (sortField === 'createdAt') { + aValue = new Date(aValue).getTime(); + bValue = new Date(bValue).getTime(); + } + + const comparison = aValue < bValue ? -1 : aValue > bValue ? 1 : 0; + return sortOrder === 'asc' ? comparison : -comparison; + }); + + return filtered; + }, [participants, searchQuery, sortField, sortOrder]); + + if (participants.length === 0) { + return ( +
+

No participants yet

+
+ ); + } + + return ( +
+ {/* Search Bar */} +
+ setSearchQuery(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ + {/* Table */} +
+
+ + + + + + + + + + + + + + {filteredAndSortedParticipants.map((participant) => ( + + + + + + + + + + ))} + +
handleSort('userId')}> + User ID{getSortIndicator('userId')} + handleSort('email')}> + Email{getSortIndicator('email')} + OnboardingCalibration handleSort('annotationCount')}> + Annotations{getSortIndicator('annotationCount')} + handleSort('createdAt')}> + Joined{getSortIndicator('createdAt')} + Action
{participant.userId}{participant.email} + + {participant.completedOnboarding ? '✓ Done' : '○ Pending'} + + + + {participant.completedCalibration ? '✓ Done' : '○ Pending'} + + + + {participant.annotationCount} + + + {new Date(participant.createdAt).toLocaleDateString()} + + + View + +
+
+ {filteredAndSortedParticipants.length === 0 && ( +
+ No participants match your search +
+ )} +
+
+ ); +} diff --git a/src/components/UpdateDatasetMetadataModal.tsx b/src/components/UpdateDatasetMetadataModal.tsx index 6ed4e14..8ef275e 100644 --- a/src/components/UpdateDatasetMetadataModal.tsx +++ b/src/components/UpdateDatasetMetadataModal.tsx @@ -219,7 +219,7 @@ export default function UpdateDatasetMetadataModal({ <> diff --git a/src/components/UploadCalibrationModal.tsx b/src/components/UploadCalibrationModal.tsx index 7c1aa0e..e5e50af 100644 --- a/src/components/UploadCalibrationModal.tsx +++ b/src/components/UploadCalibrationModal.tsx @@ -219,7 +219,7 @@ export default function UploadCalibrationModal({ <> diff --git a/src/components/UploadDatasetEntriesModal.tsx b/src/components/UploadDatasetEntriesModal.tsx index 3e6ed6d..fba44ba 100644 --- a/src/components/UploadDatasetEntriesModal.tsx +++ b/src/components/UploadDatasetEntriesModal.tsx @@ -219,7 +219,7 @@ export default function UploadDatasetEntriesModal({ <> diff --git a/src/components/WaveformPlayer.tsx b/src/components/WaveformPlayer.tsx index 34b43e5..1d4e26e 100644 --- a/src/components/WaveformPlayer.tsx +++ b/src/components/WaveformPlayer.tsx @@ -1,38 +1,54 @@ 'use client'; -import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import { useEffect, useRef, useState, useCallback } from 'react'; import { useAudio } from './AudioProvider'; interface WaveformPlayerProps { + // Audio source URL (required) src: string; - durationMs?: number | null; + + // Audio playback options onPlay?: () => void; - onFullyPlayed: () => void; + onFullyPlayed?: () => void; + + // Display options + showWaveform?: boolean; // defaults to true; if false, shows simple player button + playMode?: 'pause' | 'stop'; // defaults to 'pause'; determines play/pause vs play/stop behavior } const BAR_COUNT = 120; export default function WaveformPlayer({ src, - durationMs, onPlay, onFullyPlayed, + showWaveform = true, + playMode = 'pause', }: WaveformPlayerProps) { - const { currentAudioId, setCurrentAudio } = useAudio(); + const { currentAudioId, setCurrentAudio, playerCount, registerPlayer, unregisterPlayer } = useAudio(); // Generate unique ID for this player instance - const playerId = useMemo(() => Math.random().toString(36).slice(2), []); + const playerId = useRef(Math.random().toString(36).slice(2)).current; const isActive = currentAudioId === playerId; + const isOnlyPlayer = playerCount === 1; const audioRef = useRef(null); const canvasRef = useRef(null); const rafRef = useRef(0); + const fullyPlayedRef = useRef(false); const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); - const [duration, setDuration] = useState(durationMs ? durationMs / 1000 : 0); + const [duration, setDuration] = useState(0); const [peaks, setPeaks] = useState([]); - const fullyPlayedRef = useRef(false); + + // Register/unregister player on mount/unmount + useEffect(() => { + registerPlayer(playerId); + return () => { + unregisterPlayer(playerId); + }; + }, [playerId, registerPlayer, unregisterPlayer]); // Stop if another player became active useEffect(() => { @@ -43,12 +59,11 @@ export default function WaveformPlayer({ } }, [isActive, isPlaying]); - // Reset state when src changes and cancel playback - + // Reset state when src changes useEffect(() => { setPeaks([]); setCurrentTime(0); - setDuration(durationMs ? durationMs / 1000 : 0); + setDuration(0); fullyPlayedRef.current = false; if (audioRef.current) { audioRef.current.pause(); @@ -56,10 +71,91 @@ export default function WaveformPlayer({ } cancelAnimationFrame(rafRef.current); setIsPlaying(false); - }, [src, durationMs]); + }, [src]); - // Decode audio and generate waveform peaks + const tick = useCallback(() => { + const audio = audioRef.current; + if (!audio) return; + setCurrentTime(audio.currentTime); + rafRef.current = requestAnimationFrame(tick); + }, []); + + const handlePlay = useCallback(() => { + const audio = audioRef.current; + if (!audio) return; + setCurrentAudio(playerId); + onPlay?.(); + // Only reset to start if in stop mode; in pause mode, resume from paused position + if (playMode === 'stop') { + audio.currentTime = 0; + } + audio.play(); + setIsPlaying(true); + rafRef.current = requestAnimationFrame(tick); + }, [playerId, onPlay, tick, playMode]); + + const handlePause = useCallback(() => { + const audio = audioRef.current; + if (!audio) return; + audio.pause(); + setIsPlaying(false); + cancelAnimationFrame(rafRef.current); + }, []); + + const handleStop = useCallback(() => { + const audio = audioRef.current; + if (!audio) return; + audio.pause(); + audio.currentTime = 0; + setCurrentAudio(null); + setIsPlaying(false); + cancelAnimationFrame(rafRef.current); + }, []); + + const handleEnded = useCallback(() => { + setIsPlaying(false); + cancelAnimationFrame(rafRef.current); + if (!fullyPlayedRef.current) { + fullyPlayedRef.current = true; + onFullyPlayed?.(); + } + }, [onFullyPlayed]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.code === 'Space' && isOnlyPlayer) { + e.preventDefault(); + if (isPlaying) { + if (playMode === 'stop') { + handleStop(); + } else { + handlePause(); + } + } else { + handlePlay(); + } + } + }, + [isOnlyPlayer, isPlaying, playMode, handlePlay, handlePause, handleStop] + ); + + const handleLoadedMetadata = useCallback(() => { + if (audioRef.current) setDuration(audioRef.current.duration); + }, []); + + // Add spacebar listener when only one player exists useEffect(() => { + if (!isOnlyPlayer) return; + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [isOnlyPlayer, handleKeyDown]); + + // Decode audio and generate waveform peaks (only if showWaveform is true) + useEffect(() => { + if (!showWaveform) return; + let cancelled = false; (async () => { try { @@ -87,10 +183,17 @@ export default function WaveformPlayer({ } })(); return () => { cancelled = true; }; + }, [src, showWaveform]); + + // Reset peaks when src changes + useEffect(() => { + setPeaks([]); }, [src]); // Draw waveform on canvas whenever peaks or playback position change useEffect(() => { + if (!showWaveform) return; + const canvas = canvasRef.current; if (!canvas || peaks.length === 0) return; const ctx = canvas.getContext('2d'); @@ -110,45 +213,7 @@ export default function WaveformPlayer({ ctx.roundRect(x, y, barW, barH, 2); ctx.fill(); }); - }, [peaks, currentTime, duration]); - - const tick = useCallback(() => { - const audio = audioRef.current; - if (!audio) return; - setCurrentTime(audio.currentTime); - rafRef.current = requestAnimationFrame(tick); - }, []); - - const handlePlay = () => { - const audio = audioRef.current; - if (!audio) return; - setCurrentAudio(playerId); - onPlay?.(); - audio.play(); - setIsPlaying(true); - rafRef.current = requestAnimationFrame(tick); - }; - - const handlePause = () => { - const audio = audioRef.current; - if (!audio) return; - audio.pause(); - setIsPlaying(false); - cancelAnimationFrame(rafRef.current); - }; - - const handleEnded = () => { - setIsPlaying(false); - cancelAnimationFrame(rafRef.current); - if (!fullyPlayedRef.current) { - fullyPlayedRef.current = true; - onFullyPlayed?.(); - } - }; - - const handleLoadedMetadata = () => { - if (audioRef.current) setDuration(audioRef.current.duration); - }; + }, [peaks, currentTime, duration, showWaveform]); const fmt = (s: number) => { const m = Math.floor(s / 60); @@ -156,6 +221,58 @@ export default function WaveformPlayer({ return `${m}:${sec.toString().padStart(2, '0')}`; }; + // Simple player button (no waveform) + if (!showWaveform) { + return ( +
+
+
+
+ ); + } + + // Waveform player with visualization return (