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
This commit is contained in:
parent
f6c6fc8579
commit
b24152c74b
15 changed files with 3002 additions and 7 deletions
|
|
@ -8142,7 +8142,6 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='vendor/socket.io.min.js', v=static_v) }}"></script>
|
<script src="{{ url_for('static', filename='vendor/socket.io.min.js', v=static_v) }}"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
|
||||||
{{ vite_assets('body')|safe }}
|
{{ vite_assets('body')|safe }}
|
||||||
<script src="{{ url_for('static', filename='setup-wizard.js', v=static_v) }}"></script>
|
<script src="{{ url_for('static', filename='setup-wizard.js', v=static_v) }}"></script>
|
||||||
<!-- Split modules (was: script.js) — core.js must load first, init.js before shell-bridge.js -->
|
<!-- Split modules (was: script.js) — core.js must load first, init.js before shell-bridge.js -->
|
||||||
|
|
|
||||||
372
webui/package-lock.json
generated
372
webui/package-lock.json
generated
|
|
@ -14,6 +14,7 @@
|
||||||
"ky": "^2.0.2",
|
"ky": "^2.0.2",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"zod": "^4.4.2"
|
"zod": "^4.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -1697,6 +1698,42 @@
|
||||||
"node": ">=18"
|
"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": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.0-rc.17",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
|
"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",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
"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"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/devtools-event-client": {
|
"node_modules/@tanstack/devtools-event-client": {
|
||||||
|
|
@ -2412,6 +2454,69 @@
|
||||||
"assertion-error": "^2.0.1"
|
"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": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
|
|
@ -2473,6 +2578,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
|
||||||
|
|
@ -2954,6 +3065,127 @@
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/data-urls": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||||
|
|
@ -2993,6 +3225,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/dequal": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||||
|
|
@ -3058,6 +3296,16 @@
|
||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
"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": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
|
|
@ -3078,6 +3326,12 @@
|
||||||
"@types/estree": "^1.0.0"
|
"@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": {
|
"node_modules/expect-type": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
"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": "^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": {
|
"node_modules/indent-string": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
||||||
|
|
@ -3238,6 +3502,15 @@
|
||||||
"node": ">=8"
|
"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": {
|
"node_modules/is-binary-path": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
|
|
@ -4116,10 +4389,32 @@
|
||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
|
|
@ -4146,6 +4441,36 @@
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
|
|
@ -4160,6 +4485,21 @@
|
||||||
"node": ">=8"
|
"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": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
|
@ -4423,6 +4763,12 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"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"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.10",
|
"version": "8.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"ky": "^2.0.2",
|
"ky": "^2.0.2",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"zod": "^4.4.2"
|
"zod": "^4.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
22
webui/src/platform/shell/globals.d.ts
vendored
22
webui/src/platform/shell/globals.d.ts
vendored
|
|
@ -46,6 +46,28 @@ declare global {
|
||||||
cancelSimilarArtistsLoad: () => void;
|
cancelSimilarArtistsLoad: () => void;
|
||||||
showReactHost: (pageId: ShellPageId) => void;
|
showReactHost: (pageId: ShellPageId) => void;
|
||||||
};
|
};
|
||||||
|
navigateToArtistDetail?: (
|
||||||
|
artistId: string | number,
|
||||||
|
artistName: string,
|
||||||
|
sourceOverride?: string | null,
|
||||||
|
options?: Record<string, unknown>,
|
||||||
|
) => 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<void>;
|
||||||
|
startStream?: (searchResult: Record<string, unknown>) => void | Promise<void>;
|
||||||
|
showLoadingOverlay?: (message?: string) => void;
|
||||||
|
hideLoadingOverlay?: () => void;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export const shellRouteManifest: readonly ShellRouteDefinition[] = [
|
||||||
{ pageId: 'library', path: '/library', kind: 'legacy' },
|
{ pageId: 'library', path: '/library', kind: 'legacy' },
|
||||||
{ pageId: 'tools', path: '/tools', kind: 'legacy' },
|
{ pageId: 'tools', path: '/tools', kind: 'legacy' },
|
||||||
{ pageId: 'artist-detail', path: '/artist-detail', 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: 'settings', path: '/settings', kind: 'legacy' },
|
||||||
{ pageId: 'issues', path: '/issues', kind: 'react' },
|
{ pageId: 'issues', path: '/issues', kind: 'react' },
|
||||||
{ pageId: 'help', path: '/help', kind: 'legacy' },
|
{ pageId: 'help', path: '/help', kind: 'legacy' },
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as SplatRouteImport } from './routes/$'
|
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 IssuesRouteRouteImport } from './routes/issues/route'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as ArtistDetailSourceIdRouteImport } from './routes/artist-detail/$source/$id'
|
import { Route as ArtistDetailSourceIdRouteImport } from './routes/artist-detail/$source/$id'
|
||||||
|
|
@ -19,6 +20,11 @@ const SplatRoute = SplatRouteImport.update({
|
||||||
path: '/$',
|
path: '/$',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const StatsRouteRoute = StatsRouteRouteImport.update({
|
||||||
|
id: '/stats',
|
||||||
|
path: '/stats',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const IssuesRouteRoute = IssuesRouteRouteImport.update({
|
const IssuesRouteRoute = IssuesRouteRouteImport.update({
|
||||||
id: '/issues',
|
id: '/issues',
|
||||||
path: '/issues',
|
path: '/issues',
|
||||||
|
|
@ -38,12 +44,14 @@ const ArtistDetailSourceIdRoute = ArtistDetailSourceIdRouteImport.update({
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/issues': typeof IssuesRouteRoute
|
'/issues': typeof IssuesRouteRoute
|
||||||
|
'/stats': typeof StatsRouteRoute
|
||||||
'/$': typeof SplatRoute
|
'/$': typeof SplatRoute
|
||||||
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
|
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/issues': typeof IssuesRouteRoute
|
'/issues': typeof IssuesRouteRoute
|
||||||
|
'/stats': typeof StatsRouteRoute
|
||||||
'/$': typeof SplatRoute
|
'/$': typeof SplatRoute
|
||||||
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
|
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
|
||||||
}
|
}
|
||||||
|
|
@ -51,20 +59,28 @@ export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/issues': typeof IssuesRouteRoute
|
'/issues': typeof IssuesRouteRoute
|
||||||
|
'/stats': typeof StatsRouteRoute
|
||||||
'/$': typeof SplatRoute
|
'/$': typeof SplatRoute
|
||||||
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
|
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/issues' | '/$' | '/artist-detail/$source/$id'
|
fullPaths: '/' | '/issues' | '/stats' | '/$' | '/artist-detail/$source/$id'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/issues' | '/$' | '/artist-detail/$source/$id'
|
to: '/' | '/issues' | '/stats' | '/$' | '/artist-detail/$source/$id'
|
||||||
id: '__root__' | '/' | '/issues' | '/$' | '/artist-detail/$source/$id'
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/issues'
|
||||||
|
| '/stats'
|
||||||
|
| '/$'
|
||||||
|
| '/artist-detail/$source/$id'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
IssuesRouteRoute: typeof IssuesRouteRoute
|
IssuesRouteRoute: typeof IssuesRouteRoute
|
||||||
|
StatsRouteRoute: typeof StatsRouteRoute
|
||||||
SplatRoute: typeof SplatRoute
|
SplatRoute: typeof SplatRoute
|
||||||
ArtistDetailSourceIdRoute: typeof ArtistDetailSourceIdRoute
|
ArtistDetailSourceIdRoute: typeof ArtistDetailSourceIdRoute
|
||||||
}
|
}
|
||||||
|
|
@ -78,6 +94,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof SplatRouteImport
|
preLoaderRoute: typeof SplatRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/stats': {
|
||||||
|
id: '/stats'
|
||||||
|
path: '/stats'
|
||||||
|
fullPath: '/stats'
|
||||||
|
preLoaderRoute: typeof StatsRouteRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/issues': {
|
'/issues': {
|
||||||
id: '/issues'
|
id: '/issues'
|
||||||
path: '/issues'
|
path: '/issues'
|
||||||
|
|
@ -105,6 +128,7 @@ declare module '@tanstack/react-router' {
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
IssuesRouteRoute: IssuesRouteRoute,
|
IssuesRouteRoute: IssuesRouteRoute,
|
||||||
|
StatsRouteRoute: StatsRouteRoute,
|
||||||
SplatRoute: SplatRoute,
|
SplatRoute: SplatRoute,
|
||||||
ArtistDetailSourceIdRoute: ArtistDetailSourceIdRoute,
|
ArtistDetailSourceIdRoute: ArtistDetailSourceIdRoute,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
126
webui/src/routes/stats/-route.test.tsx
Normal file
126
webui/src/routes/stats/-route.test.tsx
Normal file
|
|
@ -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> = {}): 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(<AppRouterProvider router={router} queryClient={queryClient} />),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
103
webui/src/routes/stats/-stats.api.test.ts
Normal file
103
webui/src/routes/stats/-stats.api.test.ts
Normal file
|
|
@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
124
webui/src/routes/stats/-stats.api.ts
Normal file
124
webui/src/routes/stats/-stats.api.ts
Normal file
|
|
@ -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<StatsCachedPayload> {
|
||||||
|
const payload = await readJson<StatsCachedPayload>(
|
||||||
|
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<ListeningStatsStatus> {
|
||||||
|
return await readJson<ListeningStatsStatus>(apiClient.get('listening-stats/status'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStatsDbStorage(): Promise<StatsDbStoragePayload> {
|
||||||
|
const payload = await readJson<StatsDbStoragePayload>(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<StatsLibraryDiskUsagePayload> {
|
||||||
|
const payload = await readJson<StatsLibraryDiskUsagePayload>(
|
||||||
|
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<void> {
|
||||||
|
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<StatsResolveTrackPayload['track'] | null> {
|
||||||
|
const payload = await readJson<StatsResolveTrackPayload>(
|
||||||
|
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<Record<string, unknown> | null> {
|
||||||
|
const payload = await readJson<StatsStreamTrackPayload>(
|
||||||
|
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 });
|
||||||
|
}
|
||||||
67
webui/src/routes/stats/-stats.helpers.test.ts
Normal file
67
webui/src/routes/stats/-stats.helpers.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
162
webui/src/routes/stats/-stats.helpers.ts
Normal file
162
webui/src/routes/stats/-stats.helpers.ts
Normal file
|
|
@ -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<StatsOverview> | 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$/, '');
|
||||||
|
}
|
||||||
148
webui/src/routes/stats/-stats.types.ts
Normal file
148
webui/src/routes/stats/-stats.types.ts
Normal file
|
|
@ -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<typeof statsSearchSchema>;
|
||||||
|
|
||||||
|
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<string, number>;
|
||||||
|
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<StatsOverview>;
|
||||||
|
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<string, number>;
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
916
webui/src/routes/stats/-ui/stats-page.module.css
Normal file
916
webui/src/routes/stats/-ui/stats-page.module.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
907
webui/src/routes/stats/-ui/stats-page.tsx
Normal file
907
webui/src/routes/stats/-ui/stats-page.tsx
Normal file
|
|
@ -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<number | null>(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 (
|
||||||
|
<div className={styles.statsContainer} data-testid="stats-page">
|
||||||
|
<header className={styles.statsHeader}>
|
||||||
|
<div className={styles.statsHeaderTitle}>
|
||||||
|
<img src="/static/trans2.png" alt="Stats" className={styles.headerIcon} />
|
||||||
|
<h1 className={styles.headerTitle}>Listening Stats</h1>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsHeaderControls}>
|
||||||
|
<div className={styles.statsTimeRange} role="tablist" aria-label="Listening stats range">
|
||||||
|
{(['7d', '30d', '12m', 'all'] as const).map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
className={`${styles.statsRangeButton} ${
|
||||||
|
range === option ? styles.statsRangeButtonActive : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => onRangeChange(option)}
|
||||||
|
>
|
||||||
|
{getStatsRangeLabel(option)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsSyncControls}>
|
||||||
|
<span className={styles.statsLastSynced}>
|
||||||
|
{lastSynced ? `Last synced: ${lastSynced}` : 'Not synced yet'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.statsSyncButton} ${syncing ? styles.statsSyncButtonSyncing : ''}`}
|
||||||
|
onClick={() => syncMutation.mutate()}
|
||||||
|
disabled={syncing}
|
||||||
|
aria-label="Sync listening stats"
|
||||||
|
title="Sync now"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">↻</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{cachedStatsQuery.isPending ? (
|
||||||
|
<SectionLoadingState />
|
||||||
|
) : cachedStatsQuery.error ? (
|
||||||
|
<SectionErrorState message={getErrorMessage(cachedStatsQuery.error)} />
|
||||||
|
) : hasData ? (
|
||||||
|
<>
|
||||||
|
<OverviewCards overview={overview} />
|
||||||
|
<div className={styles.statsMainGrid}>
|
||||||
|
<div className={styles.statsLeftCol}>
|
||||||
|
<StatsSectionCard title="Listening Activity">
|
||||||
|
<div className={styles.chartContainer}>
|
||||||
|
<StatsActivityChart timeline={cachedStats?.timeline ?? []} />
|
||||||
|
</div>
|
||||||
|
</StatsSectionCard>
|
||||||
|
<StatsSectionCard title="Genre Breakdown">
|
||||||
|
<div className={styles.statsGenreChartContainer}>
|
||||||
|
<div className={styles.statsGenreChartWrap}>
|
||||||
|
<StatsGenreChart genres={cachedStats?.genres ?? []} />
|
||||||
|
</div>
|
||||||
|
<StatsGenreLegend genres={cachedStats?.genres ?? []} />
|
||||||
|
</div>
|
||||||
|
</StatsSectionCard>
|
||||||
|
<StatsSectionCard title="Recently Played">
|
||||||
|
<StatsRecentPlays tracks={cachedStats?.recent ?? []} onPlay={playStatsTrack} />
|
||||||
|
</StatsSectionCard>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsRightCol}>
|
||||||
|
<StatsSectionCard title="Top Artists">
|
||||||
|
<TopArtistsVisual
|
||||||
|
artists={cachedStats?.top_artists ?? []}
|
||||||
|
onArtistSelect={openArtistDetail}
|
||||||
|
/>
|
||||||
|
<StatsRankedArtists
|
||||||
|
artists={cachedStats?.top_artists ?? []}
|
||||||
|
onArtistSelect={openArtistDetail}
|
||||||
|
/>
|
||||||
|
</StatsSectionCard>
|
||||||
|
<StatsSectionCard title="Top Albums">
|
||||||
|
<StatsRankedAlbums
|
||||||
|
albums={cachedStats?.top_albums ?? []}
|
||||||
|
onArtistSelect={openArtistDetail}
|
||||||
|
/>
|
||||||
|
</StatsSectionCard>
|
||||||
|
<StatsSectionCard title="Top Tracks">
|
||||||
|
<StatsRankedTracks
|
||||||
|
tracks={cachedStats?.top_tracks ?? []}
|
||||||
|
onArtistSelect={openArtistDetail}
|
||||||
|
onPlay={playStatsTrack}
|
||||||
|
/>
|
||||||
|
</StatsSectionCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StatsSectionCard title="Library Health" fullWidth>
|
||||||
|
<StatsLibraryHealth health={cachedStats?.health ?? {}} />
|
||||||
|
</StatsSectionCard>
|
||||||
|
|
||||||
|
<StatsSectionCard title="Library Disk Usage" fullWidth>
|
||||||
|
<StatsDiskUsage payload={diskUsageQuery.data} error={diskUsageQuery.error} />
|
||||||
|
</StatsSectionCard>
|
||||||
|
|
||||||
|
<StatsSectionCard title="Database Storage" fullWidth>
|
||||||
|
<StatsDbStorage payload={dbStorageQuery.data} error={dbStorageQuery.error} />
|
||||||
|
</StatsSectionCard>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<StatsEmptyState />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={styles.statsOverview}>
|
||||||
|
{cards.map((card) => (
|
||||||
|
<div key={card.label} className={styles.statsCard}>
|
||||||
|
<div className={styles.statsCardValue}>{card.value}</div>
|
||||||
|
<div className={styles.statsCardLabel}>{card.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatsSectionCard({
|
||||||
|
children,
|
||||||
|
fullWidth = false,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
title: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className={`${styles.statsSectionCard} ${fullWidth ? styles.statsFullWidth : ''}`}>
|
||||||
|
<div className={styles.statsSectionTitle}>{title}</div>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatsActivityChart({ timeline }: { timeline: Array<{ date: string; plays: number }> }) {
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={timeline} margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
|
||||||
|
<CartesianGrid stroke="rgba(255,255,255,0.04)" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fill: 'rgba(255,255,255,0.3)', fontSize: 10 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fill: 'rgba(255,255,255,0.3)', fontSize: 10 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
allowDecimals={false}
|
||||||
|
width={28}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={STATS_TOOLTIP_STYLE}
|
||||||
|
wrapperStyle={STATS_TOOLTIP_WRAPPER_STYLE}
|
||||||
|
cursor={STATS_CHART_CURSOR}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="plays"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
fill="rgba(var(--accent-rgb), 0.55)"
|
||||||
|
stroke="rgba(var(--accent-rgb), 0.8)"
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatsGenreChart({
|
||||||
|
genres,
|
||||||
|
}: {
|
||||||
|
genres: Array<{ genre: string; play_count: number; percentage: number }>;
|
||||||
|
}) {
|
||||||
|
const topGenres = genres.slice(0, 10);
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width={180} height={180}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={topGenres}
|
||||||
|
dataKey="play_count"
|
||||||
|
nameKey="genre"
|
||||||
|
innerRadius={52}
|
||||||
|
outerRadius={84}
|
||||||
|
paddingAngle={2}
|
||||||
|
stroke="transparent"
|
||||||
|
>
|
||||||
|
{topGenres.map((genre, index) => (
|
||||||
|
<Cell key={genre.genre} fill={STATS_GENRE_COLORS[index % STATS_GENRE_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip contentStyle={STATS_TOOLTIP_STYLE} wrapperStyle={STATS_TOOLTIP_WRAPPER_STYLE} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatsGenreLegend({
|
||||||
|
genres,
|
||||||
|
}: {
|
||||||
|
genres: Array<{ genre: string; play_count: number; percentage: number }>;
|
||||||
|
}) {
|
||||||
|
const topGenres = genres.slice(0, 10);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.statsGenreLegend}>
|
||||||
|
{topGenres.map((genre, index) => (
|
||||||
|
<div key={genre.genre} className={styles.statsGenreLegendItem}>
|
||||||
|
<span
|
||||||
|
className={styles.statsGenreDot}
|
||||||
|
style={{ background: STATS_GENRE_COLORS[index % STATS_GENRE_COLORS.length] }}
|
||||||
|
/>
|
||||||
|
<span>{genre.genre}</span>
|
||||||
|
<span className={styles.statsGenrePct}>{genre.percentage}%</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TopArtistsVisual({
|
||||||
|
artists,
|
||||||
|
onArtistSelect,
|
||||||
|
}: {
|
||||||
|
artists: StatsArtistRow[];
|
||||||
|
onArtistSelect: (artistId: string | number, artistName: string) => void;
|
||||||
|
}) {
|
||||||
|
const topArtists = getTopArtistBubbles(artists);
|
||||||
|
if (topArtists.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.statsTopArtistsVisual}>
|
||||||
|
<div className={styles.statsArtistBubbles}>
|
||||||
|
{topArtists.map(({ artist, percent, size }) => {
|
||||||
|
const isClickable = artist.id !== null && artist.id !== undefined;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${artist.name}-${artist.id ?? 'unknown'}`}
|
||||||
|
type="button"
|
||||||
|
className={styles.statsArtistBubble}
|
||||||
|
onClick={() => {
|
||||||
|
if (isClickable) {
|
||||||
|
onArtistSelect(artist.id as string | number, artist.name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!isClickable}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={styles.statsBubbleImage}
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
backgroundImage: artist.image_url ? `url(${artist.image_url})` : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!artist.image_url ? (
|
||||||
|
<span className={styles.statsBubbleInitial}>{artist.name[0] ?? '?'}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsBubbleBarContainer}>
|
||||||
|
<div className={styles.statsBubbleBar} style={{ width: `${percent}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsBubbleName}>{artist.name}</div>
|
||||||
|
<div className={styles.statsBubbleCount}>
|
||||||
|
{formatCompactNumber(artist.play_count)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatsRankedArtists({
|
||||||
|
artists,
|
||||||
|
onArtistSelect,
|
||||||
|
}: {
|
||||||
|
artists: StatsArtistRow[];
|
||||||
|
onArtistSelect: (artistId: string | number, artistName: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={styles.statsRankedList}>
|
||||||
|
{artists.length === 0 ? <EmptyListState message="No data yet" /> : null}
|
||||||
|
{artists.map((artist, index) => (
|
||||||
|
<div key={`${artist.name}-${artist.id ?? index}`} className={styles.statsRankedItem}>
|
||||||
|
<span className={styles.statsRankedNum}>{index + 1}</span>
|
||||||
|
{artist.image_url ? (
|
||||||
|
<img className={styles.statsRankedImage} src={artist.image_url} alt="" />
|
||||||
|
) : (
|
||||||
|
<div className={styles.statsRankedImageFallback} />
|
||||||
|
)}
|
||||||
|
<div className={styles.statsRankedInfo}>
|
||||||
|
<div className={styles.statsRankedName}>
|
||||||
|
{artist.id ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.statsArtistLink}
|
||||||
|
onClick={() => onArtistSelect(artist.id as string | number, artist.name)}
|
||||||
|
>
|
||||||
|
{artist.name}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
artist.name
|
||||||
|
)}
|
||||||
|
{artist.soul_id && !String(artist.soul_id).startsWith('soul_unnamed_') ? (
|
||||||
|
<img src="/static/trans2.png" className={styles.statsSoulIdBadge} alt="SoulID" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsRankedMeta}>
|
||||||
|
{artist.global_listeners
|
||||||
|
? `${formatCompactNumber(artist.global_listeners)} global listeners`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={styles.statsRankedCount}>
|
||||||
|
{formatCompactNumber(artist.play_count)} plays
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatsRankedAlbums({
|
||||||
|
albums,
|
||||||
|
onArtistSelect,
|
||||||
|
}: {
|
||||||
|
albums: StatsAlbumRow[];
|
||||||
|
onArtistSelect: (artistId: string | number, artistName: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={styles.statsRankedList}>
|
||||||
|
{albums.length === 0 ? <EmptyListState message="No data yet" /> : null}
|
||||||
|
{albums.map((album, index) => (
|
||||||
|
<div key={`${album.name}-${index}`} className={styles.statsRankedItem}>
|
||||||
|
<span className={styles.statsRankedNum}>{index + 1}</span>
|
||||||
|
{album.image_url ? (
|
||||||
|
<img className={styles.statsRankedImage} src={album.image_url} alt="" />
|
||||||
|
) : (
|
||||||
|
<div className={styles.statsRankedImageFallback} />
|
||||||
|
)}
|
||||||
|
<div className={styles.statsRankedInfo}>
|
||||||
|
<div className={styles.statsRankedName}>{album.name}</div>
|
||||||
|
<div className={styles.statsRankedMeta}>
|
||||||
|
{album.artist_id ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.statsArtistLink}
|
||||||
|
onClick={() =>
|
||||||
|
onArtistSelect(album.artist_id as string | number, album.artist || '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{album.artist || ''}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
album.artist || ''
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={styles.statsRankedCount}>
|
||||||
|
{formatCompactNumber(album.play_count)} plays
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatsRankedTracks({
|
||||||
|
tracks,
|
||||||
|
onArtistSelect,
|
||||||
|
onPlay,
|
||||||
|
}: {
|
||||||
|
tracks: StatsTrackRow[];
|
||||||
|
onArtistSelect: (artistId: string | number, artistName: string) => void;
|
||||||
|
onPlay: (track: { title: string; artist: string; album: string }) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={styles.statsRankedList}>
|
||||||
|
{tracks.length === 0 ? <EmptyListState message="No data yet" /> : null}
|
||||||
|
{tracks.map((track, index) => (
|
||||||
|
<div key={`${track.name}-${index}`} className={styles.statsRankedItem}>
|
||||||
|
<span className={styles.statsRankedNum}>{index + 1}</span>
|
||||||
|
{track.image_url ? (
|
||||||
|
<img className={styles.statsRankedImage} src={track.image_url} alt="" />
|
||||||
|
) : (
|
||||||
|
<div className={styles.statsRankedImageFallback} />
|
||||||
|
)}
|
||||||
|
<div className={styles.statsRankedInfo}>
|
||||||
|
<div className={styles.statsRankedName}>{track.name}</div>
|
||||||
|
<div className={styles.statsRankedMeta}>
|
||||||
|
{track.artist_id ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.statsArtistLink}
|
||||||
|
onClick={() =>
|
||||||
|
onArtistSelect(track.artist_id as string | number, track.artist || '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{track.artist || ''}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
track.artist || ''
|
||||||
|
)}
|
||||||
|
{track.album ? ` · ${track.album}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.statsPlayButton} ${styles.statsPlayButtonSmall}`}
|
||||||
|
onClick={() =>
|
||||||
|
void onPlay({
|
||||||
|
title: track.name,
|
||||||
|
artist: track.artist || '',
|
||||||
|
album: track.album || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
title="Play"
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</button>
|
||||||
|
<span className={styles.statsRankedCount}>
|
||||||
|
{formatCompactNumber(track.play_count)} plays
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatsRecentPlays({
|
||||||
|
tracks,
|
||||||
|
onPlay,
|
||||||
|
}: {
|
||||||
|
tracks: StatsRecentTrack[];
|
||||||
|
onPlay: (track: { title: string; artist: string; album: string }) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={styles.statsRecentList}>
|
||||||
|
{tracks.length === 0 ? <EmptyListState message="No recent plays" /> : null}
|
||||||
|
{tracks.map((track, index) => (
|
||||||
|
<div key={`${track.title}-${track.played_at ?? index}`} className={styles.statsRecentItem}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.statsPlayButton} ${styles.statsPlayButtonSmall}`}
|
||||||
|
onClick={() =>
|
||||||
|
void onPlay({
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artist || '',
|
||||||
|
album: track.album || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
title="Play"
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</button>
|
||||||
|
<span className={styles.statsRecentTitle}>{track.title}</span>
|
||||||
|
<span className={styles.statsRecentArtist}>{track.artist || ''}</span>
|
||||||
|
<span className={styles.statsRecentTime}>{formatRelativePlayedAt(track.played_at)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, string> = {
|
||||||
|
FLAC: '#3b82f6',
|
||||||
|
MP3: '#f97316',
|
||||||
|
Opus: '#a855f7',
|
||||||
|
AAC: '#14b8a6',
|
||||||
|
OGG: '#eab308',
|
||||||
|
WAV: '#ec4899',
|
||||||
|
Other: '#555555',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.statsHealthGrid}>
|
||||||
|
<div className={`${styles.statsHealthItem} ${styles.statsHealthItemWide}`}>
|
||||||
|
<div className={styles.statsHealthLabel}>Format Breakdown</div>
|
||||||
|
<div className={styles.statsFormatBar}>
|
||||||
|
{formatEntries.map(([format, count]) => {
|
||||||
|
const percentage = ((count / formatTotal) * 100).toFixed(1);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={format}
|
||||||
|
className={styles.statsFormatSegment}
|
||||||
|
style={{
|
||||||
|
flex: count,
|
||||||
|
background: formatColors[format] || formatColors.Other,
|
||||||
|
}}
|
||||||
|
title={`${format}: ${count} tracks (${percentage}%)`}
|
||||||
|
>
|
||||||
|
{Number(percentage) > 8 ? format : ''}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsHealthItem}>
|
||||||
|
<div className={styles.statsHealthValue}>
|
||||||
|
{formatCompactNumber(health.unplayed_count)} ({health.unplayed_percentage || 0}%)
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsHealthLabel}>Unplayed Tracks</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsHealthItem}>
|
||||||
|
<div className={styles.statsHealthValue}>
|
||||||
|
{formatTotalDuration(health.total_duration_ms)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsHealthLabel}>Total Duration</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsHealthItem}>
|
||||||
|
<div className={styles.statsHealthValue}>{formatCompactNumber(totalTracks)}</div>
|
||||||
|
<div className={styles.statsHealthLabel}>Total Tracks</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsEnrichment}>
|
||||||
|
{STATS_ENRICHMENT_SERVICES.map((service) => {
|
||||||
|
const percent = health.enrichment_coverage?.[service.key] || 0;
|
||||||
|
return (
|
||||||
|
<div key={service.key} className={styles.statsEnrichItem}>
|
||||||
|
<span className={styles.statsEnrichName}>{service.label}</span>
|
||||||
|
<div className={styles.statsEnrichBar}>
|
||||||
|
<div
|
||||||
|
className={styles.statsEnrichFill}
|
||||||
|
style={{ width: `${percent}%`, background: service.color }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={styles.statsEnrichPct}>{percent}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatsDiskUsage({
|
||||||
|
error,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
error: unknown;
|
||||||
|
payload: StatsLibraryDiskUsagePayload | undefined;
|
||||||
|
}) {
|
||||||
|
if (error) {
|
||||||
|
return <SectionSubtleError message={getErrorMessage(error)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={styles.statsDiskUsageWrap}>
|
||||||
|
<div className={styles.statsDiskTotalRow}>
|
||||||
|
<div className={styles.statsDiskTotalValue}>
|
||||||
|
{hasData ? formatBytes(payload?.total_bytes) : '—'}
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsDiskTotalMeta}>
|
||||||
|
{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'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsDiskFormats}>
|
||||||
|
{formats.map(([format, bytes]) => {
|
||||||
|
const width = Math.max(2, Math.round((bytes / max) * 100));
|
||||||
|
return (
|
||||||
|
<div key={format} className={styles.statsDiskFormatRow}>
|
||||||
|
<span className={styles.statsDiskFormatName}>{format.toUpperCase()}</span>
|
||||||
|
<div className={styles.statsDiskFormatBar}>
|
||||||
|
<div className={styles.statsDiskFormatFill} style={{ width: `${width}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className={styles.statsDiskFormatSize}>{formatBytes(bytes)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatsDbStorage({
|
||||||
|
error,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
error: unknown;
|
||||||
|
payload: StatsDbStoragePayload | undefined;
|
||||||
|
}) {
|
||||||
|
if (error) {
|
||||||
|
return <SectionSubtleError message={getErrorMessage(error)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tables = groupDbStorageTables(payload?.tables ?? []);
|
||||||
|
const method = payload?.method;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.statsDbStorageWrap}>
|
||||||
|
<div className={styles.statsDbChartContainer}>
|
||||||
|
<ResponsiveContainer width={180} height={180}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={tables}
|
||||||
|
dataKey="size"
|
||||||
|
nameKey="name"
|
||||||
|
innerRadius={52}
|
||||||
|
outerRadius={84}
|
||||||
|
paddingAngle={2}
|
||||||
|
stroke="transparent"
|
||||||
|
>
|
||||||
|
{tables.map((table, index) => (
|
||||||
|
<Cell
|
||||||
|
key={table.name}
|
||||||
|
fill={STATS_DB_STORAGE_COLORS[index % STATS_DB_STORAGE_COLORS.length]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={STATS_TOOLTIP_STYLE}
|
||||||
|
wrapperStyle={STATS_TOOLTIP_WRAPPER_STYLE}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className={styles.statsDbTotal}>
|
||||||
|
<div className={styles.statsDbTotalValue}>
|
||||||
|
{formatDbStorageValue(payload?.total_file_size || 0, method)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsDbTotalLabel}>Total Size</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.statsDbLegend}>
|
||||||
|
{tables.map((table, index) => (
|
||||||
|
<div key={table.name} className={styles.statsDbLegendItem}>
|
||||||
|
<span
|
||||||
|
className={styles.statsDbLegendDot}
|
||||||
|
style={{
|
||||||
|
background: STATS_DB_STORAGE_COLORS[index % STATS_DB_STORAGE_COLORS.length],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className={styles.statsDbLegendName}>{table.name}</span>
|
||||||
|
<span className={styles.statsDbLegendSize}>
|
||||||
|
{formatDbStorageValue(table.size, method)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatsEmptyState() {
|
||||||
|
return (
|
||||||
|
<div className={styles.statsEmpty}>
|
||||||
|
<div className={styles.statsEmptyIcon}>📊</div>
|
||||||
|
<h3>No Listening Data Yet</h3>
|
||||||
|
<p>
|
||||||
|
Enable "Listening Stats" in Settings to start tracking your listening activity
|
||||||
|
from your media server.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionLoadingState() {
|
||||||
|
return <div className={styles.statsLoading}>Loading listening stats...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionErrorState({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.statsEmpty}>
|
||||||
|
<h3>Failed to load listening stats</h3>
|
||||||
|
<p>{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionSubtleError({ message }: { message: string }) {
|
||||||
|
return <div className={styles.statsSubtleError}>{message}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyListState({ message }: { message: string }) {
|
||||||
|
return <div className={styles.emptyListState}>{message}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
28
webui/src/routes/stats/route.tsx
Normal file
28
webui/src/routes/stats/route.tsx
Normal file
|
|
@ -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,
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue