From b24152c74b33cfc23eed2552a9d4fb35b3c536c6 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Thu, 14 May 2026 16:45:19 +0300 Subject: [PATCH] feat(webui): migrate stats page to react - move the stats route onto the React shell with Recharts-based visualizations - remove the global Chart.js include and add a local stats seed script for easier testing - keep parity coverage with route, API, and helper tests while preserving the legacy page layout --- webui/index.html | 1 - webui/package-lock.json | 372 ++++++- webui/package.json | 1 + webui/src/platform/shell/globals.d.ts | 22 + webui/src/platform/shell/route-manifest.ts | 2 +- webui/src/routeTree.gen.ts | 30 +- webui/src/routes/stats/-route.test.tsx | 126 +++ webui/src/routes/stats/-stats.api.test.ts | 103 ++ webui/src/routes/stats/-stats.api.ts | 124 +++ webui/src/routes/stats/-stats.helpers.test.ts | 67 ++ webui/src/routes/stats/-stats.helpers.ts | 162 ++++ webui/src/routes/stats/-stats.types.ts | 148 +++ .../routes/stats/-ui/stats-page.module.css | 916 ++++++++++++++++++ webui/src/routes/stats/-ui/stats-page.tsx | 907 +++++++++++++++++ webui/src/routes/stats/route.tsx | 28 + 15 files changed, 3002 insertions(+), 7 deletions(-) create mode 100644 webui/src/routes/stats/-route.test.tsx create mode 100644 webui/src/routes/stats/-stats.api.test.ts create mode 100644 webui/src/routes/stats/-stats.api.ts create mode 100644 webui/src/routes/stats/-stats.helpers.test.ts create mode 100644 webui/src/routes/stats/-stats.helpers.ts create mode 100644 webui/src/routes/stats/-stats.types.ts create mode 100644 webui/src/routes/stats/-ui/stats-page.module.css create mode 100644 webui/src/routes/stats/-ui/stats-page.tsx create mode 100644 webui/src/routes/stats/route.tsx diff --git a/webui/index.html b/webui/index.html index 6df47a2a..3d4c6b63 100644 --- a/webui/index.html +++ b/webui/index.html @@ -8142,7 +8142,6 @@ - {{ vite_assets('body')|safe }} diff --git a/webui/package-lock.json b/webui/package-lock.json index da9dea53..23b81ab1 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -14,6 +14,7 @@ "ky": "^2.0.2", "react": "^19.2.5", "react-dom": "^19.2.5", + "recharts": "^3.8.1", "zod": "^4.4.2" }, "devDependencies": { @@ -1697,6 +1698,42 @@ "node": ">=18" } }, + "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.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", @@ -1983,7 +2020,12 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "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/@tanstack/devtools-event-client": { @@ -2412,6 +2454,69 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-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/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2473,6 +2578,12 @@ "dev": true, "license": "MIT" }, + "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/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -2954,6 +3065,127 @@ "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-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -2993,6 +3225,12 @@ "dev": true, "license": "MIT" }, + "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/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3058,6 +3296,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3078,6 +3326,12 @@ "@types/estree": "^1.0.0" } }, + "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/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -3228,6 +3482,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "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/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -3238,6 +3502,15 @@ "node": ">=8" } }, + "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-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -4116,10 +4389,32 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, "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/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -4146,6 +4441,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "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/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -4160,6 +4485,21 @@ "node": ">=8" } }, + "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/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4423,6 +4763,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -4656,6 +5002,28 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "8.0.10", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", diff --git a/webui/package.json b/webui/package.json index 2e076f38..56123f51 100644 --- a/webui/package.json +++ b/webui/package.json @@ -20,6 +20,7 @@ "ky": "^2.0.2", "react": "^19.2.5", "react-dom": "^19.2.5", + "recharts": "^3.8.1", "zod": "^4.4.2" }, "devDependencies": { diff --git a/webui/src/platform/shell/globals.d.ts b/webui/src/platform/shell/globals.d.ts index 65d5b471..2e254c24 100644 --- a/webui/src/platform/shell/globals.d.ts +++ b/webui/src/platform/shell/globals.d.ts @@ -46,6 +46,28 @@ declare global { cancelSimilarArtistsLoad: () => void; showReactHost: (pageId: ShellPageId) => void; }; + navigateToArtistDetail?: ( + artistId: string | number, + artistName: string, + sourceOverride?: string | null, + options?: Record, + ) => void; + playLibraryTrack?: ( + track: { + id: string | number; + title: string; + file_path: string; + bitrate?: string | number | null; + artist_id?: string | number | null; + album_id?: string | number | null; + _stats_image?: string | null; + }, + albumTitle: string, + artistName: string, + ) => void | Promise; + startStream?: (searchResult: Record) => void | Promise; + showLoadingOverlay?: (message?: string) => void; + hideLoadingOverlay?: () => void; } } diff --git a/webui/src/platform/shell/route-manifest.ts b/webui/src/platform/shell/route-manifest.ts index 469d46d1..39686325 100644 --- a/webui/src/platform/shell/route-manifest.ts +++ b/webui/src/platform/shell/route-manifest.ts @@ -42,7 +42,7 @@ export const shellRouteManifest: readonly ShellRouteDefinition[] = [ { pageId: 'library', path: '/library', kind: 'legacy' }, { pageId: 'tools', path: '/tools', kind: 'legacy' }, { pageId: 'artist-detail', path: '/artist-detail', kind: 'legacy' }, - { pageId: 'stats', path: '/stats', kind: 'legacy' }, + { pageId: 'stats', path: '/stats', kind: 'react' }, { pageId: 'settings', path: '/settings', kind: 'legacy' }, { pageId: 'issues', path: '/issues', kind: 'react' }, { pageId: 'help', path: '/help', kind: 'legacy' }, diff --git a/webui/src/routeTree.gen.ts b/webui/src/routeTree.gen.ts index ca94e35b..addc76fb 100644 --- a/webui/src/routeTree.gen.ts +++ b/webui/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SplatRouteImport } from './routes/$' +import { Route as StatsRouteRouteImport } from './routes/stats/route' import { Route as IssuesRouteRouteImport } from './routes/issues/route' import { Route as IndexRouteImport } from './routes/index' import { Route as ArtistDetailSourceIdRouteImport } from './routes/artist-detail/$source/$id' @@ -19,6 +20,11 @@ const SplatRoute = SplatRouteImport.update({ path: '/$', getParentRoute: () => rootRouteImport, } as any) +const StatsRouteRoute = StatsRouteRouteImport.update({ + id: '/stats', + path: '/stats', + getParentRoute: () => rootRouteImport, +} as any) const IssuesRouteRoute = IssuesRouteRouteImport.update({ id: '/issues', path: '/issues', @@ -38,12 +44,14 @@ const ArtistDetailSourceIdRoute = ArtistDetailSourceIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/issues': typeof IssuesRouteRoute + '/stats': typeof StatsRouteRoute '/$': typeof SplatRoute '/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/issues': typeof IssuesRouteRoute + '/stats': typeof StatsRouteRoute '/$': typeof SplatRoute '/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute } @@ -51,20 +59,28 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/issues': typeof IssuesRouteRoute + '/stats': typeof StatsRouteRoute '/$': typeof SplatRoute '/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/issues' | '/$' | '/artist-detail/$source/$id' + fullPaths: '/' | '/issues' | '/stats' | '/$' | '/artist-detail/$source/$id' fileRoutesByTo: FileRoutesByTo - to: '/' | '/issues' | '/$' | '/artist-detail/$source/$id' - id: '__root__' | '/' | '/issues' | '/$' | '/artist-detail/$source/$id' + to: '/' | '/issues' | '/stats' | '/$' | '/artist-detail/$source/$id' + id: + | '__root__' + | '/' + | '/issues' + | '/stats' + | '/$' + | '/artist-detail/$source/$id' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute IssuesRouteRoute: typeof IssuesRouteRoute + StatsRouteRoute: typeof StatsRouteRoute SplatRoute: typeof SplatRoute ArtistDetailSourceIdRoute: typeof ArtistDetailSourceIdRoute } @@ -78,6 +94,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SplatRouteImport parentRoute: typeof rootRouteImport } + '/stats': { + id: '/stats' + path: '/stats' + fullPath: '/stats' + preLoaderRoute: typeof StatsRouteRouteImport + parentRoute: typeof rootRouteImport + } '/issues': { id: '/issues' path: '/issues' @@ -105,6 +128,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, IssuesRouteRoute: IssuesRouteRoute, + StatsRouteRoute: StatsRouteRoute, SplatRoute: SplatRoute, ArtistDetailSourceIdRoute: ArtistDetailSourceIdRoute, } diff --git a/webui/src/routes/stats/-route.test.tsx b/webui/src/routes/stats/-route.test.tsx new file mode 100644 index 00000000..1b447aa7 --- /dev/null +++ b/webui/src/routes/stats/-route.test.tsx @@ -0,0 +1,126 @@ +import { createMemoryHistory } from '@tanstack/react-router'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge'; + +import { createAppQueryClient } from '@/app/query-client'; +import { AppRouterProvider, createAppRouter } from '@/app/router'; + +function createResponse(body: unknown, ok = true, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function createShellBridge(overrides: Partial = {}): ShellBridge { + return { + getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: true })), + isPageAllowed: vi.fn(() => true), + getProfileHomePage: vi.fn<() => ShellPageId>(() => 'discover'), + resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'search'), + setActivePageChrome: vi.fn(), + activateLegacyPath: vi.fn(), + showReactHost: vi.fn(), + ...overrides, + }; +} + +function renderStatsRoute(initialEntries = ['/stats']) { + const queryClient = createAppQueryClient(); + const history = createMemoryHistory({ initialEntries }); + const router = createAppRouter({ history, queryClient }); + + return { + history, + ...render(), + }; +} + +describe('stats route', () => { + beforeEach(() => { + window.SoulSyncWebShellBridge = createShellBridge(); + window.navigateToArtistDetail = vi.fn(); + window.playLibraryTrack = vi.fn(); + window.startStream = vi.fn(); + window.showLoadingOverlay = vi.fn(); + window.hideLoadingOverlay = vi.fn(); + window.showToast = vi.fn(); + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = input instanceof Request ? input.url : String(input); + if (url.includes('/api/stats/cached')) { + return createResponse({ + success: true, + overview: { + total_plays: 24, + total_time_ms: 6_600_000, + unique_artists: 3, + unique_albums: 4, + unique_tracks: 12, + }, + top_artists: [{ id: 7, name: 'Artist A', play_count: 10 }], + top_albums: [], + top_tracks: [], + timeline: [{ date: 'May 10', plays: 4 }], + genres: [{ genre: 'House', play_count: 10, percentage: 80 }], + recent: [{ title: 'Track A', artist: 'Artist A', played_at: '2026-05-14T08:00:00Z' }], + health: { total_tracks: 12, format_breakdown: { FLAC: 12 } }, + }); + } + if (url.includes('/api/listening-stats/status')) { + return createResponse({ stats: { last_poll: '2026-05-14 10:00:00' } }); + } + if (url.includes('/api/stats/db-storage')) { + return createResponse({ + success: true, + tables: [{ name: 'tracks', size: 2048 }], + total_file_size: 4096, + method: 'dbstat', + }); + } + if (url.includes('/api/stats/library-disk-usage')) { + return createResponse({ + success: true, + has_data: true, + total_bytes: 2048, + tracks_with_size: 12, + tracks_without_size: 0, + by_format: { flac: 2048 }, + }); + } + return createResponse({ success: true }); + }) as unknown as typeof fetch, + ); + }); + + it('renders the stats page through the app router', async () => { + renderStatsRoute(); + + await waitFor(() => expect(screen.getByTestId('stats-page')).toBeInTheDocument()); + expect(await screen.findByText('Listening Stats')).toBeInTheDocument(); + expect(screen.getByText('24')).toBeInTheDocument(); + expect(window.SoulSyncWebShellBridge?.showReactHost).toHaveBeenCalledWith('stats'); + expect(window.SoulSyncWebShellBridge?.setActivePageChrome).toHaveBeenCalledWith('stats'); + }); + + it('stores the time range in route search state', async () => { + const { history } = renderStatsRoute(); + + fireEvent.click(await screen.findByRole('button', { name: '30 Days' })); + + await waitFor(() => expect(history.location.search).toContain('range=30d')); + }); + + it('redirects back home when the page is not allowed', async () => { + window.SoulSyncWebShellBridge = createShellBridge({ + isPageAllowed: vi.fn((pageId) => pageId !== 'stats'), + }); + + const { history } = renderStatsRoute(['/stats']); + + await waitFor(() => expect(history.location.pathname).toBe('/discover')); + }); +}); diff --git a/webui/src/routes/stats/-stats.api.test.ts b/webui/src/routes/stats/-stats.api.test.ts new file mode 100644 index 00000000..9a91b93f --- /dev/null +++ b/webui/src/routes/stats/-stats.api.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; + +import { HttpResponse, http, server } from '@/test/msw'; + +import { + fetchListeningStatsStatus, + fetchStatsCached, + fetchStatsDbStorage, + fetchStatsLibraryDiskUsage, + resolveStatsTrack, + streamStatsTrack, + triggerListeningStatsSync, +} from './-stats.api'; + +describe('stats api', () => { + it('fetches the cached stats payload for a range', async () => { + server.use( + http.get('/api/stats/cached', ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get('range')).toBe('30d'); + + return HttpResponse.json({ + success: true, + overview: { total_plays: 12 }, + top_artists: [], + top_albums: [], + top_tracks: [], + timeline: [], + genres: [], + recent: [], + health: {}, + }); + }), + ); + + await expect(fetchStatsCached('30d')).resolves.toMatchObject({ + overview: { total_plays: 12 }, + }); + }); + + it('surfaces db storage and disk usage errors', async () => { + server.use( + http.get('/api/stats/db-storage', () => + HttpResponse.json({ error: 'db unavailable' }, { status: 500 }), + ), + http.get('/api/stats/library-disk-usage', () => + HttpResponse.json({ error: 'disk unavailable' }, { status: 500 }), + ), + ); + + await expect(fetchStatsDbStorage()).rejects.toThrow('db unavailable'); + await expect(fetchStatsLibraryDiskUsage()).rejects.toThrow('disk unavailable'); + }); + + it('reads listening status and triggers manual sync', async () => { + server.use( + http.get('/api/listening-stats/status', () => + HttpResponse.json({ stats: { last_poll: '2026-05-14 10:00:00' } }), + ), + http.post('/api/listening-stats/sync', () => HttpResponse.json({ success: true })), + ); + + await expect(fetchListeningStatsStatus()).resolves.toEqual({ + stats: { last_poll: '2026-05-14 10:00:00' }, + }); + await expect(triggerListeningStatsSync()).resolves.toBeUndefined(); + }); + + it('resolves and streams tracks through the stats playback helpers', async () => { + server.use( + http.post('/api/stats/resolve-track', async ({ request }) => { + await expect(request.json()).resolves.toEqual({ + title: 'Track', + artist: 'Artist', + }); + return HttpResponse.json({ + success: true, + track: { id: 1, title: 'Track', file_path: '/music/track.flac' }, + }); + }), + http.post('/api/enhanced-search/stream-track', async ({ request }) => { + await expect(request.json()).resolves.toEqual({ + track_name: 'Track', + artist_name: 'Artist', + album_name: 'Album', + duration_ms: 0, + }); + return HttpResponse.json({ + success: true, + result: { stream_url: '/api/stream/1' }, + }); + }), + ); + + await expect(resolveStatsTrack('Track', 'Artist')).resolves.toMatchObject({ + id: 1, + title: 'Track', + }); + await expect(streamStatsTrack('Track', 'Artist', 'Album')).resolves.toEqual({ + stream_url: '/api/stream/1', + }); + }); +}); diff --git a/webui/src/routes/stats/-stats.api.ts b/webui/src/routes/stats/-stats.api.ts new file mode 100644 index 00000000..c8e69d9e --- /dev/null +++ b/webui/src/routes/stats/-stats.api.ts @@ -0,0 +1,124 @@ +import { queryOptions, type QueryClient } from '@tanstack/react-query'; + +import { apiClient, readJson } from '@/app/api-client'; + +import type { + ListeningStatsStatus, + StatsCachedPayload, + StatsDbStoragePayload, + StatsLibraryDiskUsagePayload, + StatsRange, + StatsResolveTrackPayload, + StatsStreamTrackPayload, +} from './-stats.types'; + +export const STATS_QUERY_KEY = ['stats'] as const; + +export async function fetchStatsCached(range: StatsRange): Promise { + const payload = await readJson( + apiClient.get('stats/cached', { + searchParams: { range }, + }), + ); + if (!payload.success) { + throw new Error(payload.error || 'Failed to load listening stats'); + } + return payload; +} + +export async function fetchListeningStatsStatus(): Promise { + return await readJson(apiClient.get('listening-stats/status')); +} + +export async function fetchStatsDbStorage(): Promise { + const payload = await readJson(apiClient.get('stats/db-storage')); + if (!payload.success) { + throw new Error(payload.error || 'Failed to load database storage'); + } + return payload; +} + +export async function fetchStatsLibraryDiskUsage(): Promise { + const payload = await readJson( + apiClient.get('stats/library-disk-usage'), + ); + if (!payload.success) { + throw new Error(payload.error || 'Failed to load library disk usage'); + } + return payload; +} + +export async function triggerListeningStatsSync(): Promise { + const payload = await readJson<{ success: boolean; error?: string }>( + apiClient.post('listening-stats/sync'), + ); + if (!payload.success) { + throw new Error(payload.error || 'Sync failed'); + } +} + +export async function resolveStatsTrack( + title: string, + artist: string, +): Promise { + const payload = await readJson( + apiClient.post('stats/resolve-track', { + json: { title, artist }, + }), + ); + if (!payload.success) return null; + return payload.track ?? null; +} + +export async function streamStatsTrack( + title: string, + artist: string, + album: string, +): Promise | null> { + const payload = await readJson( + apiClient.post('enhanced-search/stream-track', { + json: { + track_name: title, + artist_name: artist, + album_name: album, + duration_ms: 0, + }, + }), + ); + if (!payload.success) { + throw new Error(payload.error || 'Track not found in library or any source'); + } + return payload.result ?? null; +} + +export function statsCachedQueryOptions(range: StatsRange) { + return queryOptions({ + queryKey: [...STATS_QUERY_KEY, 'cached', range], + queryFn: () => fetchStatsCached(range), + }); +} + +export function listeningStatsStatusQueryOptions() { + return queryOptions({ + queryKey: [...STATS_QUERY_KEY, 'listening-status'], + queryFn: fetchListeningStatsStatus, + }); +} + +export function statsDbStorageQueryOptions() { + return queryOptions({ + queryKey: [...STATS_QUERY_KEY, 'db-storage'], + queryFn: fetchStatsDbStorage, + }); +} + +export function statsLibraryDiskUsageQueryOptions() { + return queryOptions({ + queryKey: [...STATS_QUERY_KEY, 'library-disk-usage'], + queryFn: fetchStatsLibraryDiskUsage, + }); +} + +export function invalidateStatsQueries(queryClient: QueryClient) { + return queryClient.invalidateQueries({ queryKey: STATS_QUERY_KEY }); +} diff --git a/webui/src/routes/stats/-stats.helpers.test.ts b/webui/src/routes/stats/-stats.helpers.test.ts new file mode 100644 index 00000000..0814546f --- /dev/null +++ b/webui/src/routes/stats/-stats.helpers.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { + formatBytes, + formatDbStorageValue, + formatListeningTime, + formatRelativePlayedAt, + getTopArtistBubbles, + groupDbStorageTables, + hasStatsData, +} from './-stats.helpers'; +import { statsSearchSchema } from './-stats.types'; + +describe('statsSearchSchema', () => { + it('falls back to 7d for unknown ranges', () => { + expect(statsSearchSchema.parse({ range: 'bad' })).toEqual({ range: '7d' }); + }); + + it('keeps known ranges', () => { + expect(statsSearchSchema.parse({ range: '12m' })).toEqual({ range: '12m' }); + }); +}); + +describe('stats helpers', () => { + it('detects whether the page has listening data', () => { + expect(hasStatsData({ total_plays: 0 })).toBe(false); + expect(hasStatsData({ total_plays: 4 })).toBe(true); + }); + + it('formats listening time and bytes', () => { + expect(formatListeningTime(3_900_000)).toBe('1h 5m'); + expect(formatBytes(2_097_152)).toBe('2.00 MB'); + }); + + it('formats relative recent-play times', () => { + const now = new Date('2026-05-14T12:00:00.000Z').getTime(); + expect(formatRelativePlayedAt('2026-05-14T11:15:00.000Z', now)).toBe('45m ago'); + expect(formatRelativePlayedAt('2026-05-14T08:00:00.000Z', now)).toBe('4h ago'); + }); + + it('groups db storage rows into Other after the top eight', () => { + const grouped = groupDbStorageTables( + Array.from({ length: 10 }, (_, index) => ({ + name: `table_${index + 1}`, + size: index + 1, + })), + ); + + expect(grouped).toHaveLength(9); + expect(grouped.at(-1)).toEqual({ name: 'Other', size: 19 }); + }); + + it('formats db storage by method', () => { + expect(formatDbStorageValue(2_097_152, 'dbstat')).toBe('2.0 MB'); + expect(formatDbStorageValue(1240, 'rowcount')).toBe('1,240 rows'); + }); + + it('shapes top artist bubbles from the highest-play artist', () => { + const bubbles = getTopArtistBubbles([ + { name: 'A', play_count: 20 }, + { name: 'B', play_count: 10 }, + ]); + + expect(bubbles[0]?.percent).toBe(100); + expect(bubbles[1]?.percent).toBe(50); + }); +}); diff --git a/webui/src/routes/stats/-stats.helpers.ts b/webui/src/routes/stats/-stats.helpers.ts new file mode 100644 index 00000000..d20d9742 --- /dev/null +++ b/webui/src/routes/stats/-stats.helpers.ts @@ -0,0 +1,162 @@ +import type { + StatsArtistRow, + StatsCachedPayload, + StatsDbStorageTable, + StatsHealth, + StatsOverview, + StatsRange, +} from './-stats.types'; + +export const EMPTY_STATS_OVERVIEW: StatsOverview = { + total_plays: 0, + total_time_ms: 0, + unique_artists: 0, + unique_albums: 0, + unique_tracks: 0, +}; + +export const EMPTY_STATS_PAYLOAD: Required< + Pick< + StatsCachedPayload, + 'overview' | 'top_artists' | 'top_albums' | 'top_tracks' | 'timeline' | 'genres' | 'recent' + > +> & { health: StatsHealth } = { + overview: EMPTY_STATS_OVERVIEW, + top_artists: [], + top_albums: [], + top_tracks: [], + timeline: [], + genres: [], + recent: [], + health: {}, +}; + +export const STATS_GENRE_COLORS = [ + '#1db954', + '#1ed760', + '#4ade80', + '#7c3aed', + '#a855f7', + '#ec4899', + '#f43f5e', + '#f97316', + '#eab308', + '#06b6d4', +] as const; + +export const STATS_DB_STORAGE_COLORS = [ + '#3b82f6', + '#f97316', + '#a855f7', + '#14b8a6', + '#eab308', + '#ec4899', + '#6366f1', + '#22c55e', + '#555555', +] as const; + +export const STATS_ENRICHMENT_SERVICES = [ + { key: 'spotify', label: 'Spotify', color: '#1db954' }, + { key: 'musicbrainz', label: 'MusicBrainz', color: '#ba55d3' }, + { key: 'deezer', label: 'Deezer', color: '#a238ff' }, + { key: 'lastfm', label: 'Last.fm', color: '#d51007' }, + { key: 'itunes', label: 'iTunes', color: '#fc3c44' }, + { key: 'audiodb', label: 'AudioDB', color: '#1a9fff' }, + { key: 'genius', label: 'Genius', color: '#ffff64' }, + { key: 'tidal', label: 'Tidal', color: '#00ffff' }, + { key: 'qobuz', label: 'Qobuz', color: '#4285f4' }, +] as const; + +export function getStatsRangeLabel(range: StatsRange): string { + switch (range) { + case '7d': + return '7 Days'; + case '30d': + return '30 Days'; + case '12m': + return '12 Months'; + case 'all': + return 'All Time'; + } +} + +export function hasStatsData(overview: Partial | undefined): boolean { + return (overview?.total_plays ?? 0) > 0; +} + +export function formatCompactNumber(value: number | null | undefined): string { + if (!value) return '0'; + if (value >= 1_000_000) return `${stripTrailingZero((value / 1_000_000).toFixed(1))}M`; + if (value >= 1_000) return `${stripTrailingZero((value / 1_000).toFixed(1))}K`; + return value.toLocaleString(); +} + +export function formatListeningTime(totalMs: number | null | undefined): string { + if (!totalMs) return '0h'; + const hours = Math.floor(totalMs / 3_600_000); + const minutes = Math.floor((totalMs % 3_600_000) / 60_000); + return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; +} + +export function formatTotalDuration(totalMs: number | null | undefined): string { + if (!totalMs) return '0h'; + return `${Math.floor(totalMs / 3_600_000)}h`; +} + +export function formatRelativePlayedAt( + dateStr: string | null | undefined, + now = Date.now(), +): string { + if (!dateStr) return ''; + const diff = now - new Date(dateStr).getTime(); + const minutes = Math.floor(diff / 60_000); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + return `${Math.floor(days / 30)}mo ago`; +} + +export function formatBytes(value: number | null | undefined): string { + if (!value || value <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let index = 0; + let next = value; + while (next >= 1024 && index < units.length - 1) { + next /= 1024; + index += 1; + } + return `${next.toFixed(next < 10 ? 2 : 1)} ${units[index]}`; +} + +export function groupDbStorageTables(tables: StatsDbStorageTable[]): StatsDbStorageTable[] { + const top = tables.slice(0, 8); + const rest = tables.slice(8); + const restSize = rest.reduce((sum, table) => sum + table.size, 0); + return restSize > 0 ? [...top, { name: 'Other', size: restSize }] : top; +} + +export function formatDbStorageValue(size: number, method: string | null | undefined): string { + if (method === 'dbstat') { + if (size > 1_048_576) return `${(size / 1_048_576).toFixed(1)} MB`; + return `${Math.round(size / 1024)} KB`; + } + return `${size.toLocaleString()} rows`; +} + +export function getTopArtistBubbles(artists: StatsArtistRow[]) { + const top = artists.slice(0, 5); + const maxPlays = top[0]?.play_count || 1; + + return top.map((artist, index) => ({ + artist, + percent: Math.round((artist.play_count / maxPlays) * 100), + size: 44 + (4 - index) * 6, + })); +} + +function stripTrailingZero(value: string): string { + return value.replace(/\.0$/, ''); +} diff --git a/webui/src/routes/stats/-stats.types.ts b/webui/src/routes/stats/-stats.types.ts new file mode 100644 index 00000000..c00cd25a --- /dev/null +++ b/webui/src/routes/stats/-stats.types.ts @@ -0,0 +1,148 @@ +import { z } from 'zod'; + +export const STATS_RANGE_VALUES = ['7d', '30d', '12m', 'all'] as const; +export type StatsRange = (typeof STATS_RANGE_VALUES)[number]; + +export const statsSearchSchema = z.object({ + range: z.enum(STATS_RANGE_VALUES).default('7d').catch('7d'), +}); + +export type StatsSearch = z.infer; + +export interface StatsOverview { + total_plays: number; + total_time_ms: number; + unique_artists: number; + unique_albums: number; + unique_tracks: number; +} + +export interface StatsArtistRow { + id?: string | number | null; + name: string; + image_url?: string | null; + play_count: number; + global_listeners?: number | null; + soul_id?: string | null; +} + +export interface StatsAlbumRow { + name: string; + artist?: string | null; + artist_id?: string | number | null; + image_url?: string | null; + play_count: number; +} + +export interface StatsTrackRow { + name: string; + artist?: string | null; + artist_id?: string | number | null; + album?: string | null; + image_url?: string | null; + play_count: number; +} + +export interface StatsTimelineRow { + date: string; + plays: number; +} + +export interface StatsGenreRow { + genre: string; + play_count: number; + percentage: number; +} + +export interface StatsEnrichmentCoverage { + spotify?: number; + musicbrainz?: number; + deezer?: number; + lastfm?: number; + itunes?: number; + audiodb?: number; + genius?: number; + tidal?: number; + qobuz?: number; +} + +export interface StatsHealth { + total_tracks?: number; + unplayed_count?: number; + unplayed_percentage?: number; + total_duration_ms?: number; + format_breakdown?: Record; + enrichment_coverage?: StatsEnrichmentCoverage; +} + +export interface StatsRecentTrack { + title: string; + artist?: string | null; + album?: string | null; + played_at?: string | null; +} + +export interface StatsCachedPayload { + success: boolean; + overview?: Partial; + top_artists?: StatsArtistRow[]; + top_albums?: StatsAlbumRow[]; + top_tracks?: StatsTrackRow[]; + timeline?: StatsTimelineRow[]; + genres?: StatsGenreRow[]; + recent?: StatsRecentTrack[]; + health?: StatsHealth; + error?: string; +} + +export interface ListeningStatsStatus { + stats?: { + last_poll?: string | null; + }; + error?: string; +} + +export interface StatsDbStorageTable { + name: string; + size: number; +} + +export interface StatsDbStoragePayload { + success: boolean; + tables?: StatsDbStorageTable[]; + total_file_size?: number; + method?: string; + error?: string; +} + +export interface StatsLibraryDiskUsagePayload { + success: boolean; + has_data?: boolean; + total_bytes?: number; + tracks_with_size?: number; + tracks_without_size?: number; + by_format?: Record; + error?: string; +} + +export interface StatsResolveTrackPayload { + success: boolean; + error?: string; + track?: { + id: string | number; + title: string; + file_path: string; + bitrate?: string | number | null; + artist_id?: string | number | null; + album_id?: string | number | null; + image_url?: string | null; + album_title?: string | null; + artist_name?: string | null; + }; +} + +export interface StatsStreamTrackPayload { + success: boolean; + error?: string; + result?: Record; +} diff --git a/webui/src/routes/stats/-ui/stats-page.module.css b/webui/src/routes/stats/-ui/stats-page.module.css new file mode 100644 index 00000000..051fbc03 --- /dev/null +++ b/webui/src/routes/stats/-ui/stats-page.module.css @@ -0,0 +1,916 @@ +.statsContainer { + margin: 20px; + padding: 28px 24px 30px; + overflow: hidden; + background: linear-gradient(135deg, rgba(20, 20, 20, 0.55) 0%, rgba(12, 12, 12, 0.62) 100%); + border-radius: 24px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-top: 1px solid rgba(255, 255, 255, 0.12); + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.3), + 0 4px 16px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.statsHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin: -28px -24px 20px; + padding: 20px 24px; + min-height: 176px; + background: linear-gradient( + 180deg, + rgba(var(--accent-rgb), 0.1) 0%, + rgba(var(--accent-rgb), 0.04) 40%, + transparent 100% + ); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + border-top-left-radius: 24px; + border-top-right-radius: 24px; + position: relative; + overflow: hidden; + flex-wrap: wrap; +} + +.statsHeader::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(var(--accent-rgb), 0.08) 50%, + transparent 100% + ); + transform: translateX(-100%); + animation: headerSweep 12s ease-in-out infinite; +} + +@keyframes headerSweep { + 50% { + transform: translateX(100%); + } + 100% { + transform: translateX(100%); + } +} + +.statsHeaderTitle { + display: flex; + align-items: center; + gap: 14px; + position: relative; + z-index: 1; +} + +.headerIcon { + width: 176px; + height: 176px; + object-fit: contain; + flex-shrink: 0; + filter: drop-shadow(0 2px 8px rgba(var(--accent-rgb), 0.3)); + transition: + transform 0.3s ease, + filter 0.3s ease; +} + +.headerIcon:hover { + transform: scale(1.08) rotate(-3deg); + filter: drop-shadow(0 4px 14px rgba(var(--accent-rgb), 0.5)); +} + +.headerTitle { + margin: 0; + font-size: 28px; + font-weight: 700; + color: #fff; +} + +.statsHeaderControls { + display: flex; + align-items: center; + gap: 16px; + position: relative; + z-index: 1; +} + +.statsTimeRange { + display: flex; + gap: 4px; + padding: 3px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.statsRangeButton { + padding: 7px 16px; + border: none; + border-radius: 8px; + background: transparent; + color: rgba(255, 255, 255, 0.5); + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + font-family: inherit; +} + +.statsRangeButton:hover { + color: rgba(255, 255, 255, 0.8); + background: rgba(255, 255, 255, 0.04); +} + +.statsRangeButtonActive { + background: rgb(var(--accent-rgb)); + color: #fff; + box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.3); +} + +.statsSyncControls { + display: flex; + align-items: center; + gap: 8px; +} + +.statsLastSynced { + font-size: 0.72rem; + color: rgba(255, 255, 255, 0.3); +} + +.statsSyncButton { + width: 32px; + height: 32px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.5); + font-size: 16px; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.statsSyncButton:hover { + background: rgba(255, 255, 255, 0.08); + color: #fff; + border-color: rgba(255, 255, 255, 0.15); +} + +.statsSyncButtonSyncing { + pointer-events: none; + color: transparent; + position: relative; +} + +.statsSyncButtonSyncing::after { + content: ''; + position: absolute; + width: 14px; + height: 14px; + border: 2px solid rgba(var(--accent-rgb), 0.2); + border-top-color: rgba(var(--accent-rgb), 0.8); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.statsOverview { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 14px; + margin-bottom: 20px; +} + +.statsCard { + background: linear-gradient(135deg, rgba(20, 20, 20, 0.95) 0%, rgba(12, 12, 12, 0.98) 100%); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + padding: 20px; + text-align: center; + position: relative; + overflow: hidden; + transition: all 0.3s ease; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); +} + +.statsCard::before { + content: ''; + position: absolute; + top: 0; + left: 20%; + right: 20%; + height: 2px; + background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.5), transparent); +} + +.statsCard:hover { + transform: translateY(-3px); + border-color: rgba(var(--accent-rgb), 0.2); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} + +.statsCardValue { + font-size: 2rem; + font-weight: 700; + color: #fff; + line-height: 1.2; + margin-bottom: 6px; +} + +.statsCardLabel { + font-size: 0.78rem; + color: rgba(255, 255, 255, 0.45); + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 600; +} + +.statsMainGrid { + display: grid; + grid-template-columns: 1fr 360px; + gap: 20px; + min-width: 0; + margin-bottom: 20px; +} + +.statsLeftCol, +.statsRightCol { + display: flex; + flex-direction: column; + gap: 20px; + min-width: 0; +} + +.statsSectionCard { + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 14px; + padding: 20px; + transition: border-color 0.2s ease; + min-width: 0; + overflow: hidden; + margin-bottom: 20px; +} + +.statsSectionCard:hover { + border-color: rgba(255, 255, 255, 0.1); +} + +.statsSectionTitle { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: rgba(255, 255, 255, 0.4); + margin-bottom: 16px; + padding-bottom: 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.chartContainer { + position: relative; + height: 220px; +} + +.statsGenreChartContainer { + display: flex; + align-items: center; + gap: 24px; +} + +.statsGenreChartWrap { + width: 180px; + height: 180px; + flex-shrink: 0; +} + +.statsGenreLegend { + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; +} + +.statsGenreLegendItem { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.82rem; + color: rgba(255, 255, 255, 0.7); +} + +.statsGenreDot, +.statsDbLegendDot { + width: 10px; + height: 10px; + border-radius: 3px; + flex-shrink: 0; +} + +.statsGenrePct { + margin-left: auto; + color: rgba(255, 255, 255, 0.4); + font-variant-numeric: tabular-nums; +} + +.statsTopArtistsVisual { + margin-bottom: 16px; + padding-bottom: 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); +} + +.statsArtistBubbles { + display: flex; + justify-content: space-around; + align-items: flex-end; + gap: 8px; +} + +.statsArtistBubble { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + min-width: 0; + flex: 1; + background: transparent; + border: none; + padding: 0; + cursor: pointer; + transition: transform 0.2s ease; +} + +.statsArtistBubble:disabled { + cursor: default; +} + +.statsArtistBubble:hover { + transform: translateY(-3px); +} + +.statsBubbleImage { + border-radius: 50%; + background-size: cover; + background-position: center; + background-color: rgba(255, 255, 255, 0.06); + border: 2px solid rgba(var(--accent-rgb), 0.2); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.statsBubbleInitial { + font-size: 1.2rem; + font-weight: 700; + color: rgba(255, 255, 255, 0.4); +} + +.statsBubbleBarContainer { + width: 100%; + height: 3px; + background: rgba(255, 255, 255, 0.06); + border-radius: 2px; + overflow: hidden; +} + +.statsBubbleBar { + height: 100%; + background: linear-gradient(90deg, rgb(var(--accent-rgb)), rgba(var(--accent-rgb), 0.4)); + border-radius: 2px; +} + +.statsBubbleName { + font-size: 0.7rem; + color: rgba(255, 255, 255, 0.7); + font-weight: 500; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.statsBubbleCount { + font-size: 0.65rem; + color: rgba(var(--accent-rgb), 0.7); + font-weight: 600; +} + +.statsRankedList, +.statsRecentList { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 300px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.1) transparent; +} + +.statsRankedItem, +.statsRecentItem { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 8px; + transition: background 0.15s ease; +} + +.statsRankedItem:hover, +.statsRecentItem:hover { + background: rgba(255, 255, 255, 0.04); +} + +.statsRankedNum { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.25); + font-weight: 700; + width: 18px; + text-align: right; + flex-shrink: 0; +} + +.statsRankedImage, +.statsRankedImageFallback { + width: 36px; + height: 36px; + border-radius: 6px; + flex-shrink: 0; + background: rgba(255, 255, 255, 0.05); + object-fit: cover; +} + +.statsRankedInfo { + flex: 1; + min-width: 0; +} + +.statsRankedName { + font-size: 0.88rem; + color: rgba(255, 255, 255, 0.85); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + align-items: center; + gap: 6px; +} + +.statsRankedMeta { + font-size: 0.72rem; + color: rgba(255, 255, 255, 0.4); +} + +.statsRankedCount { + font-size: 0.78rem; + color: rgba(var(--accent-rgb), 0.8); + font-weight: 600; + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} + +.statsArtistLink { + color: inherit; + text-decoration: none; + background: transparent; + border: none; + padding: 0; + cursor: pointer; + font: inherit; +} + +.statsArtistLink:hover { + color: rgb(var(--accent-rgb)); +} + +.statsSoulIdBadge { + width: 12px; + height: 12px; + opacity: 0.5; +} + +.statsPlayButton { + width: 28px; + height: 28px; + border-radius: 50%; + border: none; + background: rgba(var(--accent-rgb), 0.15); + color: rgb(var(--accent-rgb)); + font-size: 10px; + cursor: pointer; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + opacity: 0; +} + +.statsRankedItem:hover .statsPlayButton, +.statsRecentItem:hover .statsPlayButton { + opacity: 1; +} + +.statsPlayButton:hover { + background: rgb(var(--accent-rgb)); + color: #fff; + transform: scale(1.1); +} + +.statsPlayButtonSmall { + width: 22px; + height: 22px; + font-size: 8px; +} + +.statsRecentTitle { + flex: 1; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.8); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.statsRecentArtist { + font-size: 0.78rem; + color: rgba(255, 255, 255, 0.4); + flex-shrink: 0; +} + +.statsRecentTime { + font-size: 0.72rem; + color: rgba(255, 255, 255, 0.25); + flex-shrink: 0; + min-width: 65px; + text-align: right; +} + +.statsHealthGrid { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + gap: 16px; + align-items: start; +} + +.statsHealthItem { + text-align: center; +} + +.statsHealthItemWide { + text-align: left; +} + +.statsHealthValue { + font-size: 1.6rem; + font-weight: 700; + color: #fff; + line-height: 1.2; + margin-bottom: 4px; +} + +.statsHealthLabel { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.4); + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 600; +} + +.statsFormatBar { + display: flex; + height: 28px; + border-radius: 8px; + overflow: hidden; + margin-top: 8px; + background: rgba(255, 255, 255, 0.04); +} + +.statsFormatSegment { + display: flex; + align-items: center; + justify-content: center; + font-size: 0.68rem; + font-weight: 600; + color: #fff; + white-space: nowrap; + min-width: 30px; +} + +.statsEnrichment { + display: flex; + gap: 16px; + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid rgba(255, 255, 255, 0.04); + flex-wrap: wrap; +} + +.statsEnrichItem { + flex: 1; + min-width: 120px; + display: flex; + align-items: center; + gap: 8px; +} + +.statsEnrichName { + font-size: 0.72rem; + color: rgba(255, 255, 255, 0.45); + min-width: 70px; + font-weight: 500; +} + +.statsEnrichBar { + flex: 1; + height: 4px; + background: rgba(255, 255, 255, 0.06); + border-radius: 2px; + overflow: hidden; +} + +.statsEnrichFill { + height: 100%; + border-radius: 2px; +} + +.statsEnrichPct { + font-size: 0.72rem; + color: rgba(255, 255, 255, 0.4); + font-variant-numeric: tabular-nums; + min-width: 30px; + text-align: right; +} + +.statsDiskUsageWrap { + display: flex; + flex-direction: column; + gap: 14px; + margin-top: 8px; +} + +.statsDiskTotalRow { + display: flex; + align-items: baseline; + gap: 16px; + flex-wrap: wrap; +} + +.statsDiskTotalValue { + font-size: 28px; + font-weight: 700; + color: rgb(var(--accent-rgb)); +} + +.statsDiskTotalMeta { + font-size: 12px; + color: rgba(255, 255, 255, 0.55); +} + +.statsDiskFormats { + display: flex; + flex-direction: column; + gap: 6px; +} + +.statsDiskFormatRow { + display: grid; + grid-template-columns: 60px 1fr 80px; + align-items: center; + gap: 10px; + font-size: 12px; +} + +.statsDiskFormatName { + font-weight: 600; + color: rgba(255, 255, 255, 0.8); +} + +.statsDiskFormatBar { + height: 8px; + background: rgba(255, 255, 255, 0.05); + border-radius: 4px; + overflow: hidden; +} + +.statsDiskFormatFill { + height: 100%; + background: linear-gradient(90deg, rgb(var(--accent-rgb)) 0%, rgba(var(--accent-rgb), 0.6) 100%); + border-radius: 4px; +} + +.statsDiskFormatSize { + text-align: right; + color: rgba(255, 255, 255, 0.55); + font-variant-numeric: tabular-nums; +} + +.statsDbStorageWrap { + display: flex; + align-items: center; + gap: 24px; + margin-top: 8px; +} + +.statsDbChartContainer { + position: relative; + width: 180px; + height: 180px; + flex-shrink: 0; + isolation: isolate; +} + +.statsDbTotal { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + pointer-events: none; + z-index: 1; +} + +.statsDbTotalValue { + font-size: 20px; + font-weight: 700; + color: rgba(255, 255, 255, 0.85); + text-align: center; +} + +.statsDbTotalLabel { + font-size: 10px; + color: rgba(255, 255, 255, 0.4); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.statsDbLegend { + flex: 1; + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; +} + +.statsDbLegendItem { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: rgba(255, 255, 255, 0.6); +} + +.statsDbLegendName { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.statsDbLegendSize { + font-variant-numeric: tabular-nums; + color: rgba(255, 255, 255, 0.4); + font-size: 11px; +} + +.statsEmpty, +.statsLoading { + text-align: center; + padding: 80px 20px; + color: rgba(255, 255, 255, 0.5); +} + +.statsEmptyIcon { + font-size: 48px; + margin-bottom: 16px; +} + +.statsEmpty h3 { + font-size: 1.2rem; + color: rgba(255, 255, 255, 0.7); + margin-bottom: 8px; +} + +.statsEmpty p, +.statsSubtleError, +.emptyListState { + font-size: 0.88rem; + line-height: 1.5; + color: rgba(255, 255, 255, 0.5); +} + +.emptyListState { + padding: 12px; +} + +.statsSubtleError { + padding: 12px 0; +} + +@media (max-width: 768px) { + .statsContainer { + margin: 8px; + padding: 12px; + border-radius: 16px; + } + + .statsHeader { + flex-direction: column; + align-items: flex-start; + padding: 12px; + margin: -12px -12px 20px; + min-height: 0; + gap: 10px; + border-top-left-radius: 16px; + border-top-right-radius: 16px; + } + + .statsHeaderControls { + width: 100%; + flex-direction: column; + align-items: stretch; + } + + .statsTimeRange { + width: 100%; + } + + .statsRangeButton { + padding: 6px 12px; + font-size: 11px; + flex: 1; + text-align: center; + } + + .statsSyncControls { + width: 100%; + justify-content: space-between; + } + + .headerIcon { + width: 100px; + height: 100px; + } + + .statsOverview { + grid-template-columns: repeat(2, 1fr); + gap: 8px; + } + + .statsCard { + padding: 12px 10px; + border-radius: 10px; + } + + .statsCardValue { + font-size: 18px; + } + + .statsCardLabel { + font-size: 9px; + } + + .statsMainGrid { + grid-template-columns: 1fr; + gap: 14px; + } + + .statsLeftCol, + .statsRightCol { + gap: 14px; + } + + .statsSectionCard { + padding: 12px; + } + + .chartContainer { + height: 160px; + } + + .statsGenreChartContainer, + .statsDbStorageWrap { + flex-direction: column; + align-items: center; + } + + .statsHealthGrid { + grid-template-columns: 1fr 1fr; + } +} diff --git a/webui/src/routes/stats/-ui/stats-page.tsx b/webui/src/routes/stats/-ui/stats-page.tsx new file mode 100644 index 00000000..1b162df6 --- /dev/null +++ b/webui/src/routes/stats/-ui/stats-page.tsx @@ -0,0 +1,907 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import { type ReactNode, useEffect, useRef, useState } from 'react'; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import { useReactPageShell } from '@/platform/shell/route-controllers'; + +import type { + StatsAlbumRow, + StatsArtistRow, + StatsDbStoragePayload, + StatsHealth, + StatsLibraryDiskUsagePayload, + StatsRange, + StatsRecentTrack, + StatsTrackRow, +} from '../-stats.types'; + +import { + invalidateStatsQueries, + listeningStatsStatusQueryOptions, + resolveStatsTrack, + statsCachedQueryOptions, + statsDbStorageQueryOptions, + statsLibraryDiskUsageQueryOptions, + streamStatsTrack, + triggerListeningStatsSync, +} from '../-stats.api'; +import { + EMPTY_STATS_OVERVIEW, + formatBytes, + formatCompactNumber, + formatDbStorageValue, + formatListeningTime, + formatRelativePlayedAt, + formatTotalDuration, + getStatsRangeLabel, + getTopArtistBubbles, + groupDbStorageTables, + hasStatsData, + STATS_DB_STORAGE_COLORS, + STATS_ENRICHMENT_SERVICES, + STATS_GENRE_COLORS, +} from '../-stats.helpers'; +import { Route } from '../route'; +import styles from './stats-page.module.css'; + +const STATS_TOOLTIP_STYLE = { + background: 'rgba(12, 12, 12, 0.96)', + border: '1px solid rgba(255,255,255,0.08)', + borderRadius: 10, + color: '#fff', +} as const; + +const STATS_TOOLTIP_WRAPPER_STYLE = { + zIndex: 3, +} as const; + +const STATS_CHART_CURSOR = { + fill: 'rgba(var(--accent-rgb), 0.12)', +} as const; + +export function StatsPage() { + useReactPageShell('stats'); + + const navigate = useNavigate({ from: Route.fullPath }); + const queryClient = useQueryClient(); + const { range } = Route.useSearch(); + const syncTimeoutRef = useRef(null); + const [syncing, setSyncing] = useState(false); + + const cachedStatsQuery = useQuery({ + ...statsCachedQueryOptions(range), + }); + const listeningStatusQuery = useQuery({ + ...listeningStatsStatusQueryOptions(), + }); + const dbStorageQuery = useQuery({ + ...statsDbStorageQueryOptions(), + }); + const diskUsageQuery = useQuery({ + ...statsLibraryDiskUsageQueryOptions(), + }); + + useEffect(() => { + return () => { + if (syncTimeoutRef.current) { + window.clearTimeout(syncTimeoutRef.current); + } + }; + }, []); + + const syncMutation = useMutation({ + mutationFn: triggerListeningStatsSync, + onMutate: () => { + setSyncing(true); + }, + onSuccess: () => { + window.showToast?.('Syncing listening data...', 'info'); + syncTimeoutRef.current = window.setTimeout(() => { + void invalidateStatsQueries(queryClient); + setSyncing(false); + window.showToast?.('Listening stats updated', 'success'); + }, 5000); + }, + onError: (error) => { + setSyncing(false); + window.showToast?.(error instanceof Error ? error.message : 'Sync failed', 'error'); + }, + }); + + const cachedStats = cachedStatsQuery.data; + const overview = cachedStats?.overview ?? EMPTY_STATS_OVERVIEW; + const hasData = hasStatsData(overview); + const lastSynced = listeningStatusQuery.data?.stats?.last_poll ?? null; + + const onRangeChange = (nextRange: StatsRange) => { + void navigate({ + to: Route.fullPath, + search: { range: nextRange }, + replace: true, + }); + }; + + return ( +
+
+
+ Stats +

Listening Stats

+
+
+
+ {(['7d', '30d', '12m', 'all'] as const).map((option) => ( + + ))} +
+
+ + {lastSynced ? `Last synced: ${lastSynced}` : 'Not synced yet'} + + +
+
+
+ + {cachedStatsQuery.isPending ? ( + + ) : cachedStatsQuery.error ? ( + + ) : hasData ? ( + <> + +
+
+ +
+ +
+
+ +
+
+ +
+ +
+
+ + + +
+
+ + + + + + + + + + +
+
+ + + + + + + + + + + + + + ) : ( + + )} +
+ ); +} + +function OverviewCards({ + overview, +}: { + overview: Partial<{ + total_plays: number; + total_time_ms: number; + unique_artists: number; + unique_albums: number; + unique_tracks: number; + }>; +}) { + const cards = [ + { label: 'Total Plays', value: formatCompactNumber(overview.total_plays) }, + { label: 'Listening Time', value: formatListeningTime(overview.total_time_ms) }, + { label: 'Artists', value: formatCompactNumber(overview.unique_artists) }, + { label: 'Albums', value: formatCompactNumber(overview.unique_albums) }, + { label: 'Tracks', value: formatCompactNumber(overview.unique_tracks) }, + ]; + + return ( +
+ {cards.map((card) => ( +
+
{card.value}
+
{card.label}
+
+ ))} +
+ ); +} + +function StatsSectionCard({ + children, + fullWidth = false, + title, +}: { + children: ReactNode; + fullWidth?: boolean; + title: string; +}) { + return ( +
+
{title}
+ {children} +
+ ); +} + +function StatsActivityChart({ timeline }: { timeline: Array<{ date: string; plays: number }> }) { + return ( + + + + + + + + + + ); +} + +function StatsGenreChart({ + genres, +}: { + genres: Array<{ genre: string; play_count: number; percentage: number }>; +}) { + const topGenres = genres.slice(0, 10); + return ( + + + + {topGenres.map((genre, index) => ( + + ))} + + + + + ); +} + +function StatsGenreLegend({ + genres, +}: { + genres: Array<{ genre: string; play_count: number; percentage: number }>; +}) { + const topGenres = genres.slice(0, 10); + + return ( +
+ {topGenres.map((genre, index) => ( +
+ + {genre.genre} + {genre.percentage}% +
+ ))} +
+ ); +} + +function TopArtistsVisual({ + artists, + onArtistSelect, +}: { + artists: StatsArtistRow[]; + onArtistSelect: (artistId: string | number, artistName: string) => void; +}) { + const topArtists = getTopArtistBubbles(artists); + if (topArtists.length === 0) return null; + + return ( +
+
+ {topArtists.map(({ artist, percent, size }) => { + const isClickable = artist.id !== null && artist.id !== undefined; + return ( + + ); + })} +
+
+ ); +} + +function StatsRankedArtists({ + artists, + onArtistSelect, +}: { + artists: StatsArtistRow[]; + onArtistSelect: (artistId: string | number, artistName: string) => void; +}) { + return ( +
+ {artists.length === 0 ? : null} + {artists.map((artist, index) => ( +
+ {index + 1} + {artist.image_url ? ( + + ) : ( +
+ )} +
+
+ {artist.id ? ( + + ) : ( + artist.name + )} + {artist.soul_id && !String(artist.soul_id).startsWith('soul_unnamed_') ? ( + SoulID + ) : null} +
+
+ {artist.global_listeners + ? `${formatCompactNumber(artist.global_listeners)} global listeners` + : ''} +
+
+ + {formatCompactNumber(artist.play_count)} plays + +
+ ))} +
+ ); +} + +function StatsRankedAlbums({ + albums, + onArtistSelect, +}: { + albums: StatsAlbumRow[]; + onArtistSelect: (artistId: string | number, artistName: string) => void; +}) { + return ( +
+ {albums.length === 0 ? : null} + {albums.map((album, index) => ( +
+ {index + 1} + {album.image_url ? ( + + ) : ( +
+ )} +
+
{album.name}
+
+ {album.artist_id ? ( + + ) : ( + album.artist || '' + )} +
+
+ + {formatCompactNumber(album.play_count)} plays + +
+ ))} +
+ ); +} + +function StatsRankedTracks({ + tracks, + onArtistSelect, + onPlay, +}: { + tracks: StatsTrackRow[]; + onArtistSelect: (artistId: string | number, artistName: string) => void; + onPlay: (track: { title: string; artist: string; album: string }) => Promise; +}) { + return ( +
+ {tracks.length === 0 ? : null} + {tracks.map((track, index) => ( +
+ {index + 1} + {track.image_url ? ( + + ) : ( +
+ )} +
+
{track.name}
+
+ {track.artist_id ? ( + + ) : ( + track.artist || '' + )} + {track.album ? ` · ${track.album}` : ''} +
+
+ + + {formatCompactNumber(track.play_count)} plays + +
+ ))} +
+ ); +} + +function StatsRecentPlays({ + tracks, + onPlay, +}: { + tracks: StatsRecentTrack[]; + onPlay: (track: { title: string; artist: string; album: string }) => Promise; +}) { + return ( +
+ {tracks.length === 0 ? : null} + {tracks.map((track, index) => ( +
+ + {track.title} + {track.artist || ''} + {formatRelativePlayedAt(track.played_at)} +
+ ))} +
+ ); +} + +function StatsLibraryHealth({ health }: { health: StatsHealth }) { + const totalTracks = health.total_tracks ?? 0; + const formatEntries = Object.entries(health.format_breakdown ?? {}); + const formatTotal = formatEntries.reduce((sum, [, count]) => sum + count, 0) || 1; + const formatColors: Record = { + FLAC: '#3b82f6', + MP3: '#f97316', + Opus: '#a855f7', + AAC: '#14b8a6', + OGG: '#eab308', + WAV: '#ec4899', + Other: '#555555', + }; + + return ( + <> +
+
+
Format Breakdown
+
+ {formatEntries.map(([format, count]) => { + const percentage = ((count / formatTotal) * 100).toFixed(1); + return ( +
+ {Number(percentage) > 8 ? format : ''} +
+ ); + })} +
+
+
+
+ {formatCompactNumber(health.unplayed_count)} ({health.unplayed_percentage || 0}%) +
+
Unplayed Tracks
+
+
+
+ {formatTotalDuration(health.total_duration_ms)} +
+
Total Duration
+
+
+
{formatCompactNumber(totalTracks)}
+
Total Tracks
+
+
+
+ {STATS_ENRICHMENT_SERVICES.map((service) => { + const percent = health.enrichment_coverage?.[service.key] || 0; + return ( +
+ {service.label} +
+
+
+ {percent}% +
+ ); + })} +
+ + ); +} + +function StatsDiskUsage({ + error, + payload, +}: { + error: unknown; + payload: StatsLibraryDiskUsagePayload | undefined; +}) { + if (error) { + return ; + } + + const hasData = payload?.has_data && !!payload.total_bytes; + const formats = Object.entries(payload?.by_format ?? {}).sort((a, b) => b[1] - a[1]); + const max = formats[0]?.[1] || 1; + const tracksWithSize = payload?.tracks_with_size || 0; + const tracksWithoutSize = payload?.tracks_without_size || 0; + + return ( +
+
+
+ {hasData ? formatBytes(payload?.total_bytes) : '—'} +
+
+ {hasData + ? `${tracksWithSize.toLocaleString()} tracks measured${ + tracksWithoutSize > 0 + ? ` (+${tracksWithoutSize.toLocaleString()} pending next Deep Scan)` + : '' + }` + : tracksWithoutSize > 0 + ? `Run a Deep Scan to populate (${tracksWithoutSize.toLocaleString()} tracks pending)` + : 'No tracks in library yet'} +
+
+
+ {formats.map(([format, bytes]) => { + const width = Math.max(2, Math.round((bytes / max) * 100)); + return ( +
+ {format.toUpperCase()} +
+
+
+ {formatBytes(bytes)} +
+ ); + })} +
+
+ ); +} + +function StatsDbStorage({ + error, + payload, +}: { + error: unknown; + payload: StatsDbStoragePayload | undefined; +}) { + if (error) { + return ; + } + + const tables = groupDbStorageTables(payload?.tables ?? []); + const method = payload?.method; + + return ( +
+
+ + + + {tables.map((table, index) => ( + + ))} + + + + +
+
+ {formatDbStorageValue(payload?.total_file_size || 0, method)} +
+
Total Size
+
+
+
+ {tables.map((table, index) => ( +
+ + {table.name} + + {formatDbStorageValue(table.size, method)} + +
+ ))} +
+
+ ); +} + +function StatsEmptyState() { + return ( +
+
📊
+

No Listening Data Yet

+

+ Enable "Listening Stats" in Settings to start tracking your listening activity + from your media server. +

+
+ ); +} + +function SectionLoadingState() { + return
Loading listening stats...
; +} + +function SectionErrorState({ message }: { message: string }) { + return ( +
+

Failed to load listening stats

+

{message}

+
+ ); +} + +function SectionSubtleError({ message }: { message: string }) { + return
{message}
; +} + +function EmptyListState({ message }: { message: string }) { + return
{message}
; +} + +async function openArtistDetail(artistId: string | number, artistName: string) { + await window.SoulSyncWebRouter?.navigateToPage('library'); + window.setTimeout(() => { + window.navigateToArtistDetail?.(artistId, artistName); + }, 300); +} + +async function playStatsTrack(track: { title: string; artist: string; album: string }) { + const resolvedTrack = await resolveStatsTrack(track.title, track.artist); + if (resolvedTrack) { + void window.playLibraryTrack?.( + { + id: resolvedTrack.id, + title: resolvedTrack.title, + file_path: resolvedTrack.file_path, + bitrate: resolvedTrack.bitrate, + artist_id: resolvedTrack.artist_id, + album_id: resolvedTrack.album_id, + _stats_image: resolvedTrack.image_url || null, + }, + resolvedTrack.album_title || track.album, + resolvedTrack.artist_name || track.artist, + ); + return; + } + + window.showLoadingOverlay?.(`Searching for ${track.title}...`); + try { + const streamResult = await streamStatsTrack(track.title, track.artist, track.album); + window.hideLoadingOverlay?.(); + + if (streamResult) { + if (typeof window.startStream === 'function') { + await window.startStream(streamResult); + } else { + window.showToast?.('Streaming not available', 'error'); + } + return; + } + + window.showToast?.('Track not found in library or any source', 'error'); + } catch (error) { + window.hideLoadingOverlay?.(); + window.showToast?.(getErrorMessage(error), 'error'); + } +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Unknown error'; +} diff --git a/webui/src/routes/stats/route.tsx b/webui/src/routes/stats/route.tsx new file mode 100644 index 00000000..364eb651 --- /dev/null +++ b/webui/src/routes/stats/route.tsx @@ -0,0 +1,28 @@ +import { createFileRoute, redirect } from '@tanstack/react-router'; + +import { getProfileHomePath } from '@/platform/shell/bridge'; + +import { listeningStatsStatusQueryOptions, statsCachedQueryOptions } from './-stats.api'; +import { statsSearchSchema } from './-stats.types'; +import { StatsPage } from './-ui/stats-page'; + +export const Route = createFileRoute('/stats')({ + validateSearch: statsSearchSchema, + beforeLoad: ({ context }) => { + const { bridge } = context.shell; + + if (!bridge.isPageAllowed('stats')) { + throw redirect({ href: getProfileHomePath(bridge), replace: true }); + } + }, + loaderDeps: ({ search }) => ({ + range: search.range, + }), + loader: async ({ context, deps }) => { + await Promise.all([ + context.queryClient.ensureQueryData(statsCachedQueryOptions(deps.range)), + context.queryClient.ensureQueryData(listeningStatsStatusQueryOptions()), + ]); + }, + component: StatsPage, +});