diff --git a/webui/README.md b/webui/README.md index d32587c2..0074b850 100644 --- a/webui/README.md +++ b/webui/README.md @@ -79,6 +79,12 @@ webui/src/ test/ Shared test utilities and setup helpers ``` +Migration planning docs live under `webui/docs/migration/`. + +- keep the high-level route backlog there +- add one route-specific sketch per migration task +- keep migration notes close to the WebUI code rather than the repo root + ### Route Slices - Keep route-specific code inside `webui/src/routes//`. diff --git a/webui/docs/migration/README.md b/webui/docs/migration/README.md new file mode 100644 index 00000000..a7a9d5e3 --- /dev/null +++ b/webui/docs/migration/README.md @@ -0,0 +1,46 @@ +# WebUI Migration Docs + +This folder is the home for React migration planning work inside `webui`. + +## Purpose + +- Keep migration planning close to the code it describes. +- Separate WebUI migration docs from repo-level product or backend docs. +- Give each route migration a predictable place to live. + +## Current Docs + +- [page-migration-overview.md](./page-migration-overview.md) + - high-level route inventory + - migration waves + - cross-route risk assessment +- [stats-migration-plan.md](./stats-migration-plan.md) + - route-specific migration plan for `stats` + +## Naming Guidance + +- Keep one high-level backlog / sequencing doc: + - `page-migration-overview.md` +- Use one route-specific plan per migration task: + - `-migration-plan.md` + +Examples: + +- `search-migration-plan.md` +- `watchlist-migration-plan.md` +- `library-migration-plan.md` + +## Scope + +Use this folder for: + +- migration sequencing +- route-specific implementation sketches +- React ownership cutover notes +- shell handoff notes tied to WebUI page migrations + +Do not use this folder for: + +- generic product docs +- backend architecture notes unrelated to WebUI migration +- permanent user-facing documentation diff --git a/docs/webui-page-migration-analysis.md b/webui/docs/migration/page-migration-overview.md similarity index 87% rename from docs/webui-page-migration-analysis.md rename to webui/docs/migration/page-migration-overview.md index 0d123551..01c96a57 100644 --- a/docs/webui-page-migration-analysis.md +++ b/webui/docs/migration/page-migration-overview.md @@ -1,16 +1,17 @@ -# WebUI Page Migration Analysis +# WebUI Page Migration Overview -Snapshot date: 2026-05-02 +Snapshot date: 2026-05-14 ## Summary - The shell route manifest now has 18 page ids. -- `issues` is still the only React-owned route. +- `issues` and `stats` are now React-owned routes. - Since the last snapshot, the biggest changes are: - `downloads` was renamed into `search`. - The live queue became `active-downloads`. - `watchlist` and `wishlist` became full sidebar pages. - `tools` was split off from `dashboard`. - `artists` is no longer a route id. +- Since the last migration review, `stats` has been fully moved to React, the legacy stats HTML/JS/CSS path has been removed, and the global `Chart.js` import has been dropped in favor of route-local `Recharts`. - The shell is also more modular now. The old monolithic `script.js` has been split across `core.js`, `init.js`, `shared-helpers.js`, and feature modules such as `search.js`, `api-monitor.js`, `pages-extra.js`, `stats-automations.js`, and `wishlist-tools.js`. - Current profile compatibility still normalizes old `downloads` and `artists` references to `search`, so the docs and the route ids are not always using the same historical language. @@ -28,7 +29,7 @@ Snapshot date: 2026-05-02 - `webui/static/core.js` now holds a lot of the shared global state that used to live in the old monolith. - `webui/static/init.js` still owns page activation, permission gating, nav highlighting, legacy routing, and the `window.SoulSyncWebRouter` bridge. - `webui/static/shell-bridge.js` and the TanStack Router adapter still decide whether a route is handled by the React host or handed back to the legacy shell. -- `issues` remains the reference pattern for React-owned pages: route manifest ownership, shell bridge integration, route-local data loading, and detail-modal behavior all live in the React subtree. +- `issues` remains the reference pattern for interactive React-owned pages, while `stats` now complements it as the reference for data-heavy read-only routes with route-local charts and explicit shell handoffs. - The legacy shell is now spread across feature modules rather than one giant coordinator file, which makes the migration seams a little clearer than they were a month ago. ### Route and Compatibility Notes @@ -48,6 +49,7 @@ Snapshot date: 2026-05-02 - Socket/WebSocket and polling behavior remain the biggest migration risks for live pages. - The help system, tours, and helper annotations still reference some historical route names, so route-migration work should use the manifest as the source of truth. - Visual effects such as `particles.js` and `worker-orbs.js` remain shell-global. +- Route migrations should actively scan for emerging shared UI or shell patterns. Do not force abstractions on the first occurrence, but do document overlap and prefer extracting a shared primitive once a second route clearly needs the same behavior. ## Scoring Rubric Each page is scored from 1 to 5 on five axes: @@ -74,10 +76,10 @@ Rollups: | Page | Owner | Scores (R/S/A/C/T) | Effort | Risk | Recommended Wave | | --- | --- | --- | --- | --- | --- | -| `issues` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Wave 0 | +| `issues` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Completed | +| `stats` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Completed | | `help` | Legacy | 3 / 2 / 1 / 1 / 2 | Low | Low | Wave 1 | | `hydrabase` | Legacy | 2 / 2 / 2 / 2 / 2 | Low | Low | Wave 1 | -| `stats` | Legacy | 2 / 2 / 2 / 2 / 2 | Low | Low | Wave 1 | | `import` | Legacy | 3 / 3 / 3 / 2 / 3 | Medium | Medium | Wave 1 | | `search` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 2 | | `watchlist` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 3 | @@ -104,6 +106,13 @@ Rollups: - Key coupling: shell page gating, shell nav badge refresh, bridge-controlled chrome, React Query cache. - Why it stays first: it is already the canonical React route pattern and the migration baseline. +#### `stats` +- Current owner: React. +- Primary files: `webui/src/routes/stats/*`, `webui/src/platform/shell/*`. +- Main surface: listening stats, charts, ranked lists, disk usage, database storage visualization. +- Key coupling: query invalidation, shell handoffs for playback and artist navigation, route-local chart composition. +- Why it matters now: it is the first completed data-heavy read-only migration and the current reference for route-local charting, explicit shell bridge usage, and legacy cleanup after cutover. + ### Wave 1: Safest wins #### `help` @@ -120,19 +129,12 @@ Rollups: - Key coupling: profile gating and a small amount of shell state. - Recommendation: low-risk route with a narrow surface. -#### `stats` -- Current owner: Legacy. -- Primary files: `webui/index.html`, `webui/static/stats-automations.js`. -- Main surface: listening stats, charts, ranked lists, database storage visualization. -- Key coupling: chart rendering, some deep links back into library routes. -- Recommendation: early migration candidate with good parity-test potential. - #### `import` - Current owner: Legacy. - Primary files: `webui/index.html`, `webui/static/stats-automations.js`, `webui/static/helper.js`. - Main surface: staging files, album and singles matching, suggestion cards, processing queue. - Key coupling: settings-derived staging path assumptions and downstream library state. -- Recommendation: still bounded enough for an early wave, though more workflow-heavy than `help` or `stats`. +- Recommendation: still bounded enough for an early wave, though more workflow-heavy than `help` or `hydrabase`. ### Wave 2: Search split @@ -244,6 +246,7 @@ Rollups: - Recommendation: save this for the final wave with the other complex authoring surfaces. ## Platform Unlocks +- `stats` now provides the baseline for route-local charting, explicit shell-bridge interop from React, and the pattern of cleaning out legacy assets once parity is confirmed. - `search` likely unlocks reusable search-controller and download-launch primitives for the global search widget and other search entry points. - `watchlist` likely unlocks artist-card, per-artist config, and scan-status primitives for `discover` and `wishlist`. - `wishlist` likely unlocks queue/cycle visualization, live polling, and retry-state handling for `active-downloads` and sync-driven download flows. @@ -252,6 +255,7 @@ Rollups: - `library` + `artist-detail` still unlock entity-detail patterns, bulk actions, and file-management workflows. ## Why Earlier Waves Are Safer +- `stats` validated that bounded data pages can move early without needing broad shell rewrites, while also surfacing a healthy reminder to watch for emerging shared primitives during migration work. - Wave 1 routes are either mostly static or bounded data UIs with limited cross-route side effects. - Wave 2 adds the renamed search surface without dragging in the full queue history. - Wave 3 introduces the new watchlist/wishlist split, which is important but still narrower than discovery or library management. @@ -260,8 +264,9 @@ Rollups: - Waves 6-10 defer the broadest, most coupled, or most orchestration-heavy surfaces until the team has the most leverage. ## Final Recommendation -- Keep `issues` as the reference implementation and preserve the existing bridge contract. +- Keep `issues` and `stats` as the current React reference implementations, and preserve the explicit bridge contract between React routes and legacy shell behavior. - Treat `search`, `watchlist`, `wishlist`, `active-downloads`, and `tools` as the current route ids, and keep `downloads` and `artists` only as compatibility history. -- Migrate the safe routes first: `help`, `hydrabase`, `stats`, and `import`. +- Migrate the remaining safe legacy routes first: `help`, `hydrabase`, and `import`. +- During each migration, actively look for small reuse opportunities across route slices and shared UI primitives, but only extract once the overlap is clearly real. - Use `search` as the next meaningful proving ground now that the download queue has been split out. - Avoid pulling `settings`, `sync`, `library`, `artist-detail`, or `automations` forward unless there is a separate product priority strong enough to justify the added regression risk. diff --git a/webui/docs/migration/stats-migration-plan.md b/webui/docs/migration/stats-migration-plan.md new file mode 100644 index 00000000..bd582c2e --- /dev/null +++ b/webui/docs/migration/stats-migration-plan.md @@ -0,0 +1,408 @@ +# WebUI Stats Migration Plan + +Snapshot date: 2026-05-14 + +## Status + +- Completed on 2026-05-14. +- `stats` is now React-owned in the shell route manifest. +- The legacy stats HTML, JS, and CSS path has been removed. +- The global `Chart.js` import was removed and replaced with route-local `Recharts`. +- Legacy playback and artist-detail handoffs now go through the explicit shell bridge. +- A local seed script exists for realistic UI testing without production listening history: `tools/seed_stats_ui_scenarios.py`. + +## Goal + +- Migrate `stats` from the legacy shell to the React route host. +- Replace the global `Chart.js` CDN script with route-local React chart components. +- Use the `issues` route slice as the structural reference, but add a few stronger conventions for data-heavy read-only pages. + +## Why `stats` Is The Right Next Route + +- The route is shell-local today. +- The activation path is narrow. +- The page has real async data loading and interaction. +- The page is complex enough to validate query conventions, search-param state, and route-local chart components. +- The page does not currently drive broad shell-global workflows. + +This route has now validated those assumptions successfully. + +## Current Legacy Shape + +Page surface in `webui/index.html`: + +- Header + - time range buttons + - last synced label + - manual sync action +- Overview cards +- Left column + - listening activity chart + - genre breakdown chart + - recently played list +- Right column + - top artists + - top albums + - top tracks +- Full-width sections + - library health + - library disk usage + - database storage +- Empty state + +Legacy JS responsibilities in `webui/static/stats-automations.js`: + +- page initialization +- range switch handling +- data fetch orchestration +- formatting helpers +- chart instantiation and teardown +- ranked list rendering +- cross-page deep links into library / artist detail +- playback handoff for recent and top tracks + +Backend endpoints already split cleanly: + +- `GET /api/stats/cached` +- `GET /api/stats/db-storage` +- `GET /api/stats/library-disk-usage` +- `POST /api/listening-stats/sync` +- `GET /api/listening-stats/status` + +There are also narrower stats endpoints in the backend, but the current page already gets most of its main payload from the cached route. + +## Library Choice + +Recommended charting library: + +- `recharts` + +Reasoning: + +- React-native component model +- good fit for bar + doughnut-style dashboards +- easy to split into small route-local components +- easier to theme from CSS variables than raw imperative chart setup +- easier to test than a canvas-first imperative path + +Not recommended for this migration: + +- `react-chartjs-2` + - better for parity-only migration + - still keeps the mental model close to Chart.js +- `visx` + - stronger for bespoke visualization systems + - more work than this page needs + +## Proposed Route Slice + +```text +webui/src/routes/stats/ + route.tsx + -stats.types.ts + -stats.api.ts + -stats.helpers.ts + -stats.api.test.ts + -stats.helpers.test.ts + -ui/ + stats-page.tsx + stats-page.module.css + stats-header.tsx + stats-overview-cards.tsx + stats-empty-state.tsx + stats-ranked-list.tsx + stats-recent-plays.tsx + stats-library-health.tsx + stats-disk-usage.tsx + stats-activity-chart.tsx + stats-genre-chart.tsx + stats-db-storage-chart.tsx +``` + +## Proposed Route Responsibilities + +`route.tsx` + +- declare `/stats` +- validate search params +- gate route through `bridge.isPageAllowed('stats')` +- preload the shell context +- load the main cached stats payload plus listening-status payload +- optionally preload disk usage and db storage if we want zero-layout-shift first render + +`-stats.types.ts` + +- search param schema +- response payload types +- normalized display shapes +- chart row types + +`-stats.api.ts` + +- query keys +- fetchers for: + - cached stats + - listening status + - db storage + - library disk usage +- mutation helper for manual sync +- invalidation helpers + +`-stats.helpers.ts` + +- range labels +- numeric and duration formatters +- disk size formatters +- chart data shaping +- legend shaping +- safe fallbacks for empty server responses + +`-ui/stats-page.tsx` + +- page composition +- search-param driven range selection +- section layout +- empty-state branching + +## Search Params + +Use search params for state that should survive reloads and linking: + +- `range` + +Recommended values: + +- `7d` +- `30d` +- `12m` +- `all` + +This is the one clear page-state value worth encoding in the URL. Everything else can remain derived from server data. + +## Query Model + +Recommended split: + +- primary query: + - `statsCachedQueryOptions(profileId, range)` +- secondary queries: + - `statsListeningStatusQueryOptions(profileId)` + - `statsDbStorageQueryOptions(profileId)` + - `statsLibraryDiskUsageQueryOptions(profileId)` + +Why this split: + +- `cached` is the real page backbone +- `db-storage` and `library-disk-usage` are already separate in the backend +- they can render as progressively enhanced cards without blocking the whole route +- `listening-stats/status` updates the sync label and complements the sync mutation + +Recommended route-loader behavior: + +- always ensure: + - cached stats + - listening status +- optional: + - db storage + - disk usage + +If we want a snappier first migration, we should keep the last two as client-side `useQuery` calls rather than route-loader requirements. + +## Component Sketch + +`StatsPage` + +- calls `useReactPageShell('stats')` +- reads `range` from route search +- renders: + - `StatsHeader` + - `StatsOverviewCards` + - `StatsEmptyState` or main sections + +`StatsHeader` + +- range segmented control +- last synced text +- sync button mutation + +`StatsOverviewCards` + +- five summary cards + +`StatsActivityChart` + +- Recharts `BarChart` +- responsive container +- route-local tooltip +- accepts already-shaped rows + +`StatsGenreChart` + +- Recharts `PieChart` +- legend rendered in React markup beside the chart +- top-10 clipping stays in helpers + +`StatsDbStorageChart` + +- Recharts `PieChart` +- custom center label rendered in React +- legend list rendered beside chart + +`StatsRankedList` + +- shared component for artists / albums / tracks +- variant props for: + - artwork + - subtitle/meta + - count label + - optional play action + - optional artist-detail deep link + +`StatsRecentPlays` + +- simple list component +- play action + +`StatsLibraryHealth` + +- overview metrics +- format breakdown bar +- enrichment coverage rows + +`StatsDiskUsage` + +- total bytes row +- pending/deep-scan message +- per-format horizontal bars + +## Recharts Mapping + +Legacy Chart.js to React mapping: + +- listening activity + - from imperative `new Chart(... type: 'bar')` + - to `ResponsiveContainer` + `BarChart` + `Bar` + `XAxis` + `YAxis` + `Tooltip` +- genre breakdown + - from doughnut chart + - to `PieChart` + `Pie` + custom legend +- database storage + - from doughnut chart with center total overlay + - to `PieChart` + `Pie` + React-rendered center label + +Suggested chart convention: + +- keep all chart data shaping outside the chart components +- chart components should receive already-normalized rows and colors +- never read directly from raw server payloads inside Recharts markup + +## CSS Strategy + +Recommended first pass: + +- create `stats-page.module.css` +- port stats-specific selectors from `webui/static/style.css` +- keep class names semantically similar to reduce migration risk + +Suggested approach: + +- move only the selectors needed by the React route +- leave legacy stats selectors in place until the route flip is complete +- after the React route owns `stats`, remove unused legacy selectors in a cleanup pass + +Do not try to redesign the page during the migration. + +## Shell And Routing Changes + +When the route is ready: + +1. Add `webui/src/routes/stats/route.tsx` +2. Regenerate the TanStack route tree if needed +3. Change `stats` from `legacy` to `react` in `webui/src/platform/shell/route-manifest.ts` +4. Keep the legacy `stats-page` DOM in `webui/index.html` during the initial cutover if that reduces risk +5. Remove legacy activation from `webui/static/init.js` once React ownership is confirmed +6. Remove the global Chart.js script from `webui/index.html` + +## Incremental Migration Order + +Recommended order: + +1. Add types, API layer, and helpers +2. Build the React route with plain markup and no charts yet +3. Port overview, ranked lists, recent plays, and empty state +4. Port library health and disk usage +5. Port Recharts activity, genre, and db storage charts +6. Flip route ownership from legacy to React +7. Remove global Chart.js import +8. Delete or shrink legacy `stats` logic from `stats-automations.js` + +This order gives us a working React page before charting becomes the critical path. + +## Testing Sketch + +Unit tests: + +- `-stats.helpers.test.ts` + - range formatting + - duration formatting + - db storage grouping into `Other` + - genre top-10 shaping + - disk usage empty-state shaping + +API tests: + +- `-stats.api.test.ts` + - cached stats success / error + - listening status success / error + - db storage success / error + - disk usage success / error + - sync mutation success / error + +Route / component tests: + +- initial render for default `range=7d` +- changing range updates the URL and query key +- empty state renders when `overview.total_plays === 0` +- ranked artist click deep-links to library / artist detail +- track play action triggers the expected handoff +- sync action shows pending state and invalidates relevant queries + +Playwright is optional for the first pass. + +## Decisions To Keep Simple + +- Keep the existing page structure. +- Keep the current backend endpoint split. +- Keep the current time-range set. +- Reuse the existing shell deep-link behavior for library and playback. +- Use Recharts only inside `stats` first. + +## Follow-Up Opportunities + +- Extract shared chart colors into route-local constants or a small shared viz helper. +- Consider a tiny `components/charts/` layer only after a second React page needs charts. +- Revisit whether `stats/cached` should remain the primary page payload or whether the route should fan out to narrower endpoints later. +- Keep watching for overlap between route-local controls and shared UI primitives. The stats range selector is a good example of a pattern that should stay local for now, but should be reconsidered if another migrated route needs the same segmented-control behavior. + +## Recommendation + +The first implementation should optimize for: + +- parity +- clear route-local boundaries +- removal of global `Chart.js` +- reusable data/query conventions + +It should not optimize for: + +- visual redesign +- a cross-app chart abstraction +- backend reshaping + +## Outcome + +- The route now serves as the reference for data-heavy read-only React pages. +- The migration proved out route-local charts, route-search state, explicit shell-bridge interop, and post-cutover legacy cleanup. +- The work also reinforced a migration guideline for future routes: + - prefer local implementation on first use + - actively note overlap with shared primitives + - extract only once the second clear consumer appears diff --git a/webui/index.html b/webui/index.html index 6df47a2a..7ff29dc5 100644 --- a/webui/index.html +++ b/webui/index.html @@ -6184,147 +6184,6 @@ - -
-
-
-
- Stats -

Listening Stats

-
-
-
- - - - -
-
- - -
-
-
- - -
-
-
0
-
Total Plays
-
-
-
0h
-
Listening Time
-
-
-
0
-
Artists
-
-
-
0
-
Albums
-
-
-
0
-
Tracks
-
-
- - -
-
-
-
Listening Activity
-
- -
-
-
-
Genre Breakdown
-
- -
-
-
-
-
Recently Played
-
-
-
-
-
-
Top Artists
-
-
-
-
-
Top Albums
-
-
-
-
Top Tracks
-
-
-
-
- - -
-
Library Health
-
-
-
Format Breakdown
-
-
-
-
0
-
Unplayed Tracks
-
-
-
0h
-
Total Duration
-
-
-
0
-
Total Tracks
-
-
-
-
- - -
-
Library Disk Usage
-
-
-
โ€”
-
Run a Deep Scan to populate
-
-
-
-
- - -
-
Database Storage
-
-
- -
-
-
-
-
- - - -
-
-
@@ -8142,7 +8001,6 @@
- {{ vite_assets('body')|safe }} diff --git a/webui/package-lock.json b/webui/package-lock.json index da9dea53..23b81ab1 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -14,6 +14,7 @@ "ky": "^2.0.2", "react": "^19.2.5", "react-dom": "^19.2.5", + "recharts": "^3.8.1", "zod": "^4.4.2" }, "devDependencies": { @@ -1697,6 +1698,42 @@ "node": ">=18" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", @@ -1983,7 +2020,12 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, "node_modules/@tanstack/devtools-event-client": { @@ -2412,6 +2454,69 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2473,6 +2578,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -2954,6 +3065,127 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -2993,6 +3225,12 @@ "dev": true, "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3058,6 +3296,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-toolkit": { + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3078,6 +3326,12 @@ "@types/estree": "^1.0.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -3228,6 +3482,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -3238,6 +3502,15 @@ "node": ">=8" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -4116,10 +4389,32 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, "license": "MIT", "peer": true }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -4146,6 +4441,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -4160,6 +4485,21 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4423,6 +4763,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -4656,6 +5002,28 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "8.0.10", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", diff --git a/webui/package.json b/webui/package.json index 2e076f38..56123f51 100644 --- a/webui/package.json +++ b/webui/package.json @@ -20,6 +20,7 @@ "ky": "^2.0.2", "react": "^19.2.5", "react-dom": "^19.2.5", + "recharts": "^3.8.1", "zod": "^4.4.2" }, "devDependencies": { diff --git a/webui/src/app/api-client.ts b/webui/src/app/api-client.ts index fa54158e..73541181 100644 --- a/webui/src/app/api-client.ts +++ b/webui/src/app/api-client.ts @@ -6,12 +6,21 @@ const apiBaseUrl = typeof globalThis.location === 'object' ? new URL('/api/', globalThis.location.origin).toString() : 'http://localhost/api/'; +const shellBaseUrl = + typeof globalThis.location === 'object' + ? new URL('/', globalThis.location.origin).toString() + : 'http://localhost/'; export const apiClient = ky.create({ baseUrl: apiBaseUrl, retry: 0, }); +export const shellClient = ky.create({ + baseUrl: shellBaseUrl, + retry: 0, +}); + export async function readJson(promise: ResponsePromise): Promise { try { return await promise.json(); diff --git a/webui/src/app/router.test.tsx b/webui/src/app/router.test.tsx index 63295b4b..f2d1933c 100644 --- a/webui/src/app/router.test.tsx +++ b/webui/src/app/router.test.tsx @@ -4,31 +4,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SHELL_PROFILE_CONTEXT_CHANGED_EVENT, - type ShellBridge, type ShellProfileContext, type ShellPageId, } from '@/platform/shell/bridge'; +import { HttpResponse, http, server } from '@/test/msw'; +import { createShellBridge } from '@/test/shell-bridge'; import { createAppQueryClient } from './query-client'; import { AppRouterProvider, createAppRouter } from './router'; -function mockIssuesFetch() { - return vi.fn(async (input: RequestInfo | URL) => { - const url = input instanceof Request ? input.url : String(input); - - if (url.includes('/api/issues/counts')) { - return new Response( - JSON.stringify({ +describe('createAppRouter', () => { + beforeEach(() => { + server.use( + http.get('/api/issues/counts', () => + HttpResponse.json({ success: true, counts: { open: 2, in_progress: 1, resolved: 0, dismissed: 0, total: 3 }, }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); - } - - if (url.includes('/api/issues?')) { - return new Response( - JSON.stringify({ + ), + http.get('/api/issues', () => + HttpResponse.json({ success: true, total: 1, issues: [ @@ -44,32 +39,8 @@ function mockIssuesFetch() { }, ], }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ); - } - - throw new Error(`Unexpected fetch request: ${url}`); - }); -} - -function createShellBridge(overrides: Partial = {}): ShellBridge { - return { - getCurrentProfileContext: vi.fn(() => ({ profileId: 1, isAdmin: false })), - isPageAllowed: vi.fn(() => true), - getProfileHomePage: vi.fn<() => ShellPageId>(() => 'discover'), - resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'search'), - setActivePageChrome: vi.fn(), - activateLegacyPath: vi.fn(), - navigateToArtistDetail: vi.fn(), - cancelSimilarArtistsLoad: vi.fn(), - showReactHost: vi.fn(), - ...overrides, - }; -} - -describe('createAppRouter', () => { - beforeEach(() => { - vi.stubGlobal('fetch', mockIssuesFetch()); + ), + ); }); afterEach(() => { diff --git a/webui/src/platform/shell/bridge.test.ts b/webui/src/platform/shell/bridge.test.ts index 9682bbe7..80b363c2 100644 --- a/webui/src/platform/shell/bridge.test.ts +++ b/webui/src/platform/shell/bridge.test.ts @@ -1,8 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createShellBridge } from '@/test/shell-bridge'; + import type { ShellProfileContext } from './bridge'; -import { SHELL_PROFILE_CONTEXT_CHANGED_EVENT, bindWindowWebRouter, waitForShellContext } from './bridge'; +import { + SHELL_PROFILE_CONTEXT_CHANGED_EVENT, + bindWindowWebRouter, + waitForShellContext, +} from './bridge'; describe('waitForShellContext', () => { beforeEach(() => { @@ -10,16 +16,7 @@ describe('waitForShellContext', () => { }); it('resolves immediately when the shell already has a profile', async () => { - window.SoulSyncWebShellBridge = { - getProfileHomePage: vi.fn(() => 'discover'), - isPageAllowed: vi.fn(() => true), - activateLegacyPath: vi.fn(), - getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: true })), - resolveLegacyPath: vi.fn(() => 'issues'), - setActivePageChrome: vi.fn(), - cancelSimilarArtistsLoad: vi.fn(), - showReactHost: vi.fn(), - } as NonNullable; + window.SoulSyncWebShellBridge = createShellBridge(); await expect(waitForShellContext()).resolves.toEqual({ bridge: window.SoulSyncWebShellBridge, @@ -32,16 +29,9 @@ describe('waitForShellContext', () => { it('waits for the legacy shell to publish profile context', async () => { const getCurrentProfileContext = vi.fn<() => ShellProfileContext | null>(() => null); - window.SoulSyncWebShellBridge = { - getProfileHomePage: vi.fn(() => 'discover'), - isPageAllowed: vi.fn(() => true), - activateLegacyPath: vi.fn(), + window.SoulSyncWebShellBridge = createShellBridge({ getCurrentProfileContext, - resolveLegacyPath: vi.fn(() => 'issues'), - setActivePageChrome: vi.fn(), - cancelSimilarArtistsLoad: vi.fn(), - showReactHost: vi.fn(), - } as NonNullable; + }); const contextPromise = waitForShellContext(); @@ -96,7 +86,9 @@ describe('bindWindowWebRouter', () => { bindWindowWebRouter({ navigate } as never); - await expect(window.SoulSyncWebRouter?.navigateToPage('artist-detail', {} as never)).resolves.toBe(false); + await expect( + window.SoulSyncWebRouter?.navigateToPage('artist-detail', {} as never), + ).resolves.toBe(false); expect(navigate).not.toHaveBeenCalled(); }); }); diff --git a/webui/src/platform/shell/bridge.ts b/webui/src/platform/shell/bridge.ts index 1c9683f5..cd56f64a 100644 --- a/webui/src/platform/shell/bridge.ts +++ b/webui/src/platform/shell/bridge.ts @@ -1,5 +1,7 @@ import type { AnyRouter } from '@tanstack/react-router'; +import type { ShellStatusPayload } from './status'; + import { getShellRouteByPageId, normalizeShellPath, @@ -17,6 +19,7 @@ export interface ShellProfileContext { export interface ShellContext { bridge: ShellBridge; profile: ShellProfileContext; + status?: ShellStatusPayload | null; } export type ShellBridge = NonNullable; @@ -95,7 +98,8 @@ export function bindWindowWebRouter(router: AnyRouter) { let href: `/${string}` = route.path; if (pageId === 'artist-detail' && options?.artistId) { const source = options.artistSource ? String(options.artistSource) : 'library'; - href = `/artist-detail/${encodeURIComponent(source)}/${encodeURIComponent(String(options.artistId))}` as `/${string}`; + href = + `/artist-detail/${encodeURIComponent(source)}/${encodeURIComponent(String(options.artistId))}` as `/${string}`; } await router.navigate({ href, replace: options?.replace === true }); diff --git a/webui/src/platform/shell/globals.d.ts b/webui/src/platform/shell/globals.d.ts index 65d5b471..8b043665 100644 --- a/webui/src/platform/shell/globals.d.ts +++ b/webui/src/platform/shell/globals.d.ts @@ -45,6 +45,22 @@ declare global { ) => void; cancelSimilarArtistsLoad: () => void; showReactHost: (pageId: ShellPageId) => void; + playLibraryTrack: ( + track: { + id: string | number; + title: string; + file_path: string; + bitrate?: string | number | null; + artist_id?: string | number | null; + album_id?: string | number | null; + _stats_image?: string | null; + }, + albumTitle: string, + artistName: string, + ) => void | Promise; + startStream: (searchResult: Record) => void | Promise; + showLoadingOverlay: (message?: string) => void; + hideLoadingOverlay: () => void; }; } } diff --git a/webui/src/platform/shell/route-controllers.tsx b/webui/src/platform/shell/route-controllers.tsx index a08d3c66..309ed1d1 100644 --- a/webui/src/platform/shell/route-controllers.tsx +++ b/webui/src/platform/shell/route-controllers.tsx @@ -21,6 +21,10 @@ export function useProfile() { return useShellContext().profile; } +export function useShellStatus() { + return useShellContext().status ?? null; +} + export function LegacyRouteController({ pathname }: { pathname: string }) { const bridge = useShellBridge(); diff --git a/webui/src/platform/shell/route-manifest.test.ts b/webui/src/platform/shell/route-manifest.test.ts index 369f7b3d..2727c255 100644 --- a/webui/src/platform/shell/route-manifest.test.ts +++ b/webui/src/platform/shell/route-manifest.test.ts @@ -6,6 +6,7 @@ import { normalizeShellPath, reactShellRoutes, resolveLegacyShellPageFromPath, + resolveShellNavPage, resolveShellPageFromPath, shellRouteManifest, } from './route-manifest'; @@ -41,8 +42,9 @@ describe('shellRouteManifest', () => { it('tracks whether a route is rendered by React or the legacy shell', () => { expect(getShellRouteByPageId('issues')?.kind).toBe('react'); + expect(getShellRouteByPageId('stats')?.kind).toBe('react'); expect(getShellRouteByPageId('discover')?.kind).toBe('legacy'); - expect(reactShellRoutes.map((route) => route.pageId)).toEqual(['issues']); + expect(reactShellRoutes.map((route) => route.pageId)).toEqual(['stats', 'issues']); expect(legacyShellRoutes.some((route) => route.pageId === 'dashboard')).toBe(true); }); @@ -55,4 +57,9 @@ describe('shellRouteManifest', () => { expect(resolveLegacyShellPageFromPath('/issues')).toBeNull(); expect(resolveLegacyShellPageFromPath('/does-not-exist')).toBeNull(); }); + + it('maps contextual pages to the nav chrome they belong under', () => { + expect(resolveShellNavPage('artist-detail')).toBe('library'); + expect(resolveShellNavPage('stats')).toBe('stats'); + }); }); diff --git a/webui/src/platform/shell/route-manifest.ts b/webui/src/platform/shell/route-manifest.ts index 469d46d1..bbcd7d09 100644 --- a/webui/src/platform/shell/route-manifest.ts +++ b/webui/src/platform/shell/route-manifest.ts @@ -42,7 +42,7 @@ export const shellRouteManifest: readonly ShellRouteDefinition[] = [ { pageId: 'library', path: '/library', kind: 'legacy' }, { pageId: 'tools', path: '/tools', kind: 'legacy' }, { pageId: 'artist-detail', path: '/artist-detail', kind: 'legacy' }, - { pageId: 'stats', path: '/stats', kind: 'legacy' }, + { pageId: 'stats', path: '/stats', kind: 'react' }, { pageId: 'settings', path: '/settings', kind: 'legacy' }, { pageId: 'issues', path: '/issues', kind: 'react' }, { pageId: 'help', path: '/help', kind: 'legacy' }, @@ -92,3 +92,8 @@ export function resolveLegacyShellPageFromPath(pathname: string): ShellPageId | const route = getShellRouteByPath(pathname); return route?.kind === 'legacy' ? route.pageId : null; } + +export function resolveShellNavPage(pageId: ShellPageId): ShellPageId | '' { + if (pageId === 'artist-detail') return 'library'; + return pageId; +} diff --git a/webui/src/platform/shell/status.test.ts b/webui/src/platform/shell/status.test.ts new file mode 100644 index 00000000..68c1bf6f --- /dev/null +++ b/webui/src/platform/shell/status.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { HttpResponse, http, server } from '@/test/msw'; + +import { fetchShellStatus } from './status'; + +describe('shell status', () => { + it('fetches the shell status payload', async () => { + server.use( + http.get('/status', () => + HttpResponse.json({ media_server: { type: 'plex', connected: true } }), + ), + ); + + await expect(fetchShellStatus()).resolves.toEqual({ + media_server: { type: 'plex', connected: true }, + }); + }); +}); diff --git a/webui/src/platform/shell/status.ts b/webui/src/platform/shell/status.ts new file mode 100644 index 00000000..47b53321 --- /dev/null +++ b/webui/src/platform/shell/status.ts @@ -0,0 +1,25 @@ +import { queryOptions } from '@tanstack/react-query'; + +import { readJson, shellClient } from '@/app/api-client'; + +export interface ShellStatusMediaServer { + type?: string | null; + connected?: boolean | null; +} + +export interface ShellStatusPayload { + media_server?: ShellStatusMediaServer | null; +} + +export async function fetchShellStatus(): Promise { + return await readJson(shellClient.get('status')); +} + +export function shellStatusQueryOptions() { + return queryOptions({ + queryKey: ['shell', 'status'] as const, + queryFn: fetchShellStatus, + staleTime: 30_000, + retry: false, + }); +} diff --git a/webui/src/routeTree.gen.ts b/webui/src/routeTree.gen.ts index ca94e35b..addc76fb 100644 --- a/webui/src/routeTree.gen.ts +++ b/webui/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SplatRouteImport } from './routes/$' +import { Route as StatsRouteRouteImport } from './routes/stats/route' import { Route as IssuesRouteRouteImport } from './routes/issues/route' import { Route as IndexRouteImport } from './routes/index' import { Route as ArtistDetailSourceIdRouteImport } from './routes/artist-detail/$source/$id' @@ -19,6 +20,11 @@ const SplatRoute = SplatRouteImport.update({ path: '/$', getParentRoute: () => rootRouteImport, } as any) +const StatsRouteRoute = StatsRouteRouteImport.update({ + id: '/stats', + path: '/stats', + getParentRoute: () => rootRouteImport, +} as any) const IssuesRouteRoute = IssuesRouteRouteImport.update({ id: '/issues', path: '/issues', @@ -38,12 +44,14 @@ const ArtistDetailSourceIdRoute = ArtistDetailSourceIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/issues': typeof IssuesRouteRoute + '/stats': typeof StatsRouteRoute '/$': typeof SplatRoute '/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/issues': typeof IssuesRouteRoute + '/stats': typeof StatsRouteRoute '/$': typeof SplatRoute '/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute } @@ -51,20 +59,28 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/issues': typeof IssuesRouteRoute + '/stats': typeof StatsRouteRoute '/$': typeof SplatRoute '/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/issues' | '/$' | '/artist-detail/$source/$id' + fullPaths: '/' | '/issues' | '/stats' | '/$' | '/artist-detail/$source/$id' fileRoutesByTo: FileRoutesByTo - to: '/' | '/issues' | '/$' | '/artist-detail/$source/$id' - id: '__root__' | '/' | '/issues' | '/$' | '/artist-detail/$source/$id' + to: '/' | '/issues' | '/stats' | '/$' | '/artist-detail/$source/$id' + id: + | '__root__' + | '/' + | '/issues' + | '/stats' + | '/$' + | '/artist-detail/$source/$id' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute IssuesRouteRoute: typeof IssuesRouteRoute + StatsRouteRoute: typeof StatsRouteRoute SplatRoute: typeof SplatRoute ArtistDetailSourceIdRoute: typeof ArtistDetailSourceIdRoute } @@ -78,6 +94,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SplatRouteImport parentRoute: typeof rootRouteImport } + '/stats': { + id: '/stats' + path: '/stats' + fullPath: '/stats' + preLoaderRoute: typeof StatsRouteRouteImport + parentRoute: typeof rootRouteImport + } '/issues': { id: '/issues' path: '/issues' @@ -105,6 +128,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, IssuesRouteRoute: IssuesRouteRoute, + StatsRouteRoute: StatsRouteRoute, SplatRoute: SplatRoute, ArtistDetailSourceIdRoute: ArtistDetailSourceIdRoute, } diff --git a/webui/src/routes/__root.tsx b/webui/src/routes/__root.tsx index d46454e1..44575f1c 100644 --- a/webui/src/routes/__root.tsx +++ b/webui/src/routes/__root.tsx @@ -3,13 +3,18 @@ import { Outlet, createRootRouteWithContext } from '@tanstack/react-router'; import type { AppRouterContext } from '@/app/router'; import { waitForShellContext } from '@/platform/shell/bridge'; +import { shellStatusQueryOptions } from '@/platform/shell/status'; import { IssueDomainHost } from './issues/-ui/issue-domain-host'; export const Route = createRootRouteWithContext()({ - beforeLoad: async () => { - const shell = await waitForShellContext(); - return { shell }; + beforeLoad: async ({ context }) => { + const [shell, status] = await Promise.all([ + waitForShellContext(), + context.queryClient.fetchQuery(shellStatusQueryOptions()).catch(() => undefined), + ]); + + return { shell: { ...shell, status } }; }, component: () => ( <> diff --git a/webui/src/routes/artist-detail/-route.test.tsx b/webui/src/routes/artist-detail/-route.test.tsx index 232ff944..c1426ba9 100644 --- a/webui/src/routes/artist-detail/-route.test.tsx +++ b/webui/src/routes/artist-detail/-route.test.tsx @@ -2,25 +2,9 @@ import { createMemoryHistory } from '@tanstack/react-router'; import { render, waitFor } from '@testing-library/react'; import { afterEach, 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 createShellBridge(overrides: Partial = {}): ShellBridge { - return { - getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: false })), - isPageAllowed: vi.fn(() => true), - getProfileHomePage: vi.fn<() => ShellPageId>(() => 'discover'), - resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'artist-detail'), - setActivePageChrome: vi.fn(), - activateLegacyPath: vi.fn(), - navigateToArtistDetail: vi.fn(), - cancelSimilarArtistsLoad: vi.fn(), - showReactHost: vi.fn(), - ...overrides, - }; -} +import { createShellBridge } from '@/test/shell-bridge'; function renderArtistDetailRoute(initialEntries = ['/artist-detail/library/42']) { const queryClient = createAppQueryClient(); @@ -87,7 +71,8 @@ describe('artist-detail route', () => { ); }); - const cancelSimilarArtistsLoad = window.SoulSyncWebShellBridge?.cancelSimilarArtistsLoad as ReturnType; + const cancelSimilarArtistsLoad = window.SoulSyncWebShellBridge + ?.cancelSimilarArtistsLoad as ReturnType; cancelSimilarArtistsLoad.mockClear(); unmount(); diff --git a/webui/src/routes/issues/-route.test.tsx b/webui/src/routes/issues/-route.test.tsx index 7bbc0353..7a0abdcd 100644 --- a/webui/src/routes/issues/-route.test.tsx +++ b/webui/src/routes/issues/-route.test.tsx @@ -2,10 +2,9 @@ import { createMemoryHistory } from '@tanstack/react-router'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, 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'; +import { createShellBridge } from '@/test/shell-bridge'; function createResponse(body: unknown, ok = true, status = 200) { return new Response(JSON.stringify(body), { @@ -14,21 +13,6 @@ function createResponse(body: unknown, ok = true, status = 200) { }); } -function createShellBridge(overrides: Partial = {}): ShellBridge { - return { - getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: true })), - isPageAllowed: vi.fn(() => true), - getProfileHomePage: vi.fn<() => ShellPageId>(() => 'discover'), - resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'search'), - setActivePageChrome: vi.fn(), - activateLegacyPath: vi.fn(), - navigateToArtistDetail: vi.fn(), - cancelSimilarArtistsLoad: vi.fn(), - showReactHost: vi.fn(), - ...overrides, - }; -} - function renderIssuesRoute(initialEntries = ['/issues']) { const queryClient = createAppQueryClient(); const history = createMemoryHistory({ initialEntries }); diff --git a/webui/src/routes/stats/-route.test.tsx b/webui/src/routes/stats/-route.test.tsx new file mode 100644 index 00000000..ec251ae9 --- /dev/null +++ b/webui/src/routes/stats/-route.test.tsx @@ -0,0 +1,169 @@ +import { createMemoryHistory } from '@tanstack/react-router'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createAppQueryClient } from '@/app/query-client'; +import { AppRouterProvider, createAppRouter } from '@/app/router'; +import { HttpResponse, http, server } from '@/test/msw'; +import { createShellBridge } from '@/test/shell-bridge'; + +function renderStatsRoute(initialEntries = ['/stats']) { + const queryClient = createAppQueryClient(); + const history = createMemoryHistory({ initialEntries }); + const router = createAppRouter({ history, queryClient }); + + return { + history, + ...render(), + }; +} + +describe('stats route', () => { + beforeEach(() => { + window.SoulSyncWebShellBridge = createShellBridge(); + window.showToast = vi.fn(); + server.use( + http.get('/api/stats/cached', () => + HttpResponse.json({ + 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 } }, + }), + ), + http.get('/api/listening-stats/status', () => + HttpResponse.json({ stats: { last_poll: '2026-05-14 10:00:00' } }), + ), + http.get('/status', () => + HttpResponse.json({ media_server: { type: 'plex', connected: true } }), + ), + http.get('/api/stats/db-storage', () => + HttpResponse.json({ + success: true, + tables: [{ name: 'tracks', size: 2048 }], + total_file_size: 4096, + method: 'dbstat', + }), + ), + http.get('/api/stats/library-disk-usage', () => + HttpResponse.json({ + success: true, + has_data: true, + total_bytes: 2048, + tracks_with_size: 12, + tracks_without_size: 0, + by_format: { flac: 2048 }, + }), + ), + ); + }); + + 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('still renders when listening stats status prefetch fails', async () => { + server.use( + http.get('/api/listening-stats/status', () => + HttpResponse.json({ error: 'status unavailable' }, { status: 500 }), + ), + ); + + renderStatsRoute(); + + await waitFor(() => expect(screen.getByTestId('stats-page')).toBeInTheDocument()); + expect(await screen.findByText('Listening Stats')).toBeInTheDocument(); + expect(screen.getByText('Not synced yet')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Sync listening stats' })).toBeInTheDocument(); + }); + + it('shows an explicit standalone notice instead of the sync button', async () => { + server.use( + http.get('/status', () => + HttpResponse.json({ media_server: { type: 'soulsync', connected: true } }), + ), + ); + + renderStatsRoute(); + + await waitFor(() => expect(screen.getByTestId('stats-page')).toBeInTheDocument()); + expect(await screen.findByText('Listening Stats')).toBeInTheDocument(); + expect(screen.getByText('Standalone mode: manual sync unavailable')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Sync listening stats' })).not.toBeInTheDocument(); + }); + + 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('hands artist detail navigation directly to the shell bridge', async () => { + renderStatsRoute(); + + fireEvent.click(await screen.findByRole('button', { name: 'Artist A' })); + + expect(window.SoulSyncWebShellBridge?.navigateToArtistDetail).toHaveBeenCalledWith( + 7, + 'Artist A', + ); + }); + + it('falls back to streaming when track resolution fails', async () => { + window.SoulSyncWebShellBridge = createShellBridge({ + startStream: vi.fn(), + }); + + server.use( + http.post('/api/stats/resolve-track', () => + HttpResponse.json({ error: 'resolve unavailable' }, { status: 500 }), + ), + http.post('/api/enhanced-search/stream-track', () => + HttpResponse.json({ + success: true, + result: { stream_url: '/api/stream/1' }, + }), + ), + ); + + renderStatsRoute(); + + fireEvent.click((await screen.findAllByTitle('Play'))[0]); + + await waitFor(() => + expect(window.SoulSyncWebShellBridge?.startStream).toHaveBeenCalledWith({ + stream_url: '/api/stream/1', + }), + ); + expect(window.SoulSyncWebShellBridge?.playLibraryTrack).not.toHaveBeenCalled(); + }); + + it('redirects back home when the page is not allowed', async () => { + window.SoulSyncWebShellBridge = createShellBridge({ + isPageAllowed: vi.fn((pageId) => pageId !== 'stats'), + }); + + const { history } = renderStatsRoute(['/stats']); + + await waitFor(() => expect(history.location.pathname).toBe('/discover')); + }); +}); diff --git a/webui/src/routes/stats/-stats.api.test.ts b/webui/src/routes/stats/-stats.api.test.ts new file mode 100644 index 00000000..ab85853e --- /dev/null +++ b/webui/src/routes/stats/-stats.api.test.ts @@ -0,0 +1,143 @@ +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('returns an empty payload when cached stats are not available yet', async () => { + server.use( + http.get('/api/stats/cached', () => + HttpResponse.json({ success: false, error: 'Listening stats not synced yet' }), + ), + ); + + await expect(fetchStatsCached('7d')).resolves.toMatchObject({ + success: true, + overview: { total_plays: 0 }, + top_artists: [], + top_albums: [], + top_tracks: [], + timeline: [], + genres: [], + recent: [], + health: {}, + }); + }); + + it('returns an empty payload when the server reports a cache miss as an HTTP error', async () => { + server.use( + http.get('/api/stats/cached', () => + HttpResponse.json({ error: 'No cached stats available yet' }, { status: 500 }), + ), + ); + + await expect(fetchStatsCached('7d')).resolves.toMatchObject({ + success: true, + overview: { total_plays: 0 }, + top_artists: [], + top_albums: [], + top_tracks: [], + timeline: [], + genres: [], + recent: [], + health: {}, + }); + }); + + it('surfaces db storage and disk usage errors', async () => { + server.use( + http.get('/api/stats/db-storage', () => + HttpResponse.json({ error: 'db unavailable' }, { status: 500 }), + ), + http.get('/api/stats/library-disk-usage', () => + HttpResponse.json({ error: 'disk unavailable' }, { status: 500 }), + ), + ); + + await expect(fetchStatsDbStorage()).rejects.toThrow('db unavailable'); + await expect(fetchStatsLibraryDiskUsage()).rejects.toThrow('disk unavailable'); + }); + + it('reads listening status and triggers manual sync', async () => { + server.use( + http.get('/api/listening-stats/status', () => + HttpResponse.json({ stats: { last_poll: '2026-05-14 10:00:00' } }), + ), + http.post('/api/listening-stats/sync', () => HttpResponse.json({ success: true })), + ); + + await expect(fetchListeningStatsStatus()).resolves.toEqual({ + stats: { last_poll: '2026-05-14 10:00:00' }, + }); + await expect(triggerListeningStatsSync()).resolves.toBeUndefined(); + }); + + it('resolves and streams tracks through the stats playback helpers', async () => { + server.use( + http.post('/api/stats/resolve-track', async ({ request }) => { + await expect(request.json()).resolves.toEqual({ + title: 'Track', + artist: 'Artist', + }); + return HttpResponse.json({ + success: true, + track: { id: 1, title: 'Track', file_path: '/music/track.flac' }, + }); + }), + http.post('/api/enhanced-search/stream-track', async ({ request }) => { + await expect(request.json()).resolves.toEqual({ + track_name: 'Track', + artist_name: 'Artist', + album_name: 'Album', + duration_ms: 0, + }); + return HttpResponse.json({ + success: true, + result: { stream_url: '/api/stream/1' }, + }); + }), + ); + + await expect(resolveStatsTrack('Track', 'Artist')).resolves.toMatchObject({ + id: 1, + title: 'Track', + }); + await expect(streamStatsTrack('Track', 'Artist', 'Album')).resolves.toEqual({ + stream_url: '/api/stream/1', + }); + }); +}); diff --git a/webui/src/routes/stats/-stats.api.ts b/webui/src/routes/stats/-stats.api.ts new file mode 100644 index 00000000..e0514233 --- /dev/null +++ b/webui/src/routes/stats/-stats.api.ts @@ -0,0 +1,157 @@ +import { queryOptions, type QueryClient } from '@tanstack/react-query'; +import { HTTPError } from 'ky'; + +import { apiClient, readJson } from '@/app/api-client'; + +import type { + ListeningStatsStatus, + StatsCachedPayload, + StatsDbStoragePayload, + StatsLibraryDiskUsagePayload, + StatsRange, + StatsResolveTrackPayload, + StatsStreamTrackPayload, +} from './-stats.types'; + +import { EMPTY_STATS_PAYLOAD } from './-stats.helpers'; + +export const STATS_QUERY_KEY = ['stats'] as const; + +const NO_STATS_YET_PATTERNS = [ + /not synced/i, + /no listening stats/i, + /no cached stats/i, + /cache miss/i, + /stats cache.*(missing|empty|not found)/i, +] as const; + +function isNoStatsYetMessage(message: string | undefined): boolean { + if (!message) return false; + return NO_STATS_YET_PATTERNS.some((pattern) => pattern.test(message)); +} + +function getEmptyStatsPayload(): StatsCachedPayload { + return { + success: true, + ...EMPTY_STATS_PAYLOAD, + }; +} + +export async function fetchStatsCached(range: StatsRange): Promise { + try { + const payload = await readJson( + apiClient.get('stats/cached', { + searchParams: { range }, + }), + ); + if (!payload.success) { + if (isNoStatsYetMessage(payload.error)) { + return getEmptyStatsPayload(); + } + throw new Error(payload.error || 'Failed to load listening stats'); + } + return payload; + } catch (error) { + if (error instanceof HTTPError && isNoStatsYetMessage(error.message)) { + return getEmptyStatsPayload(); + } + throw error; + } +} + +export async function fetchListeningStatsStatus(): Promise { + return await readJson(apiClient.get('listening-stats/status')); +} + +export async function fetchStatsDbStorage(): Promise { + const payload = await readJson(apiClient.get('stats/db-storage')); + if (!payload.success) { + throw new Error(payload.error || 'Failed to load database storage'); + } + return payload; +} + +export async function fetchStatsLibraryDiskUsage(): Promise { + const payload = await readJson( + apiClient.get('stats/library-disk-usage'), + ); + if (!payload.success) { + throw new Error(payload.error || 'Failed to load library disk usage'); + } + return payload; +} + +export async function triggerListeningStatsSync(): Promise { + const payload = await readJson<{ success: boolean; error?: string }>( + apiClient.post('listening-stats/sync'), + ); + if (!payload.success) { + throw new Error(payload.error || 'Sync failed'); + } +} + +export async function resolveStatsTrack( + title: string, + artist: string, +): Promise { + const payload = await readJson( + apiClient.post('stats/resolve-track', { + json: { title, artist }, + }), + ); + if (!payload.success) return null; + return payload.track ?? null; +} + +export async function streamStatsTrack( + title: string, + artist: string, + album: string, +): Promise | null> { + const payload = await readJson( + apiClient.post('enhanced-search/stream-track', { + json: { + track_name: title, + artist_name: artist, + album_name: album, + duration_ms: 0, + }, + }), + ); + if (!payload.success) { + throw new Error(payload.error || 'Track not found in library or any source'); + } + return payload.result ?? null; +} + +export function statsCachedQueryOptions(range: StatsRange) { + return queryOptions({ + queryKey: [...STATS_QUERY_KEY, 'cached', range], + queryFn: () => fetchStatsCached(range), + }); +} + +export function listeningStatsStatusQueryOptions() { + return queryOptions({ + queryKey: [...STATS_QUERY_KEY, 'listening-status'], + queryFn: fetchListeningStatsStatus, + }); +} + +export function statsDbStorageQueryOptions() { + return queryOptions({ + queryKey: [...STATS_QUERY_KEY, 'db-storage'], + queryFn: fetchStatsDbStorage, + }); +} + +export function statsLibraryDiskUsageQueryOptions() { + return queryOptions({ + queryKey: [...STATS_QUERY_KEY, 'library-disk-usage'], + queryFn: fetchStatsLibraryDiskUsage, + }); +} + +export function invalidateStatsQueries(queryClient: QueryClient) { + return queryClient.invalidateQueries({ queryKey: STATS_QUERY_KEY }); +} diff --git a/webui/src/routes/stats/-stats.helpers.test.ts b/webui/src/routes/stats/-stats.helpers.test.ts new file mode 100644 index 00000000..0814546f --- /dev/null +++ b/webui/src/routes/stats/-stats.helpers.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { + formatBytes, + formatDbStorageValue, + formatListeningTime, + formatRelativePlayedAt, + getTopArtistBubbles, + groupDbStorageTables, + hasStatsData, +} from './-stats.helpers'; +import { statsSearchSchema } from './-stats.types'; + +describe('statsSearchSchema', () => { + it('falls back to 7d for unknown ranges', () => { + expect(statsSearchSchema.parse({ range: 'bad' })).toEqual({ range: '7d' }); + }); + + it('keeps known ranges', () => { + expect(statsSearchSchema.parse({ range: '12m' })).toEqual({ range: '12m' }); + }); +}); + +describe('stats helpers', () => { + it('detects whether the page has listening data', () => { + expect(hasStatsData({ total_plays: 0 })).toBe(false); + expect(hasStatsData({ total_plays: 4 })).toBe(true); + }); + + it('formats listening time and bytes', () => { + expect(formatListeningTime(3_900_000)).toBe('1h 5m'); + expect(formatBytes(2_097_152)).toBe('2.00 MB'); + }); + + it('formats relative recent-play times', () => { + const now = new Date('2026-05-14T12:00:00.000Z').getTime(); + expect(formatRelativePlayedAt('2026-05-14T11:15:00.000Z', now)).toBe('45m ago'); + expect(formatRelativePlayedAt('2026-05-14T08:00:00.000Z', now)).toBe('4h ago'); + }); + + it('groups db storage rows into Other after the top eight', () => { + const grouped = groupDbStorageTables( + Array.from({ length: 10 }, (_, index) => ({ + name: `table_${index + 1}`, + size: index + 1, + })), + ); + + expect(grouped).toHaveLength(9); + expect(grouped.at(-1)).toEqual({ name: 'Other', size: 19 }); + }); + + it('formats db storage by method', () => { + expect(formatDbStorageValue(2_097_152, 'dbstat')).toBe('2.0 MB'); + expect(formatDbStorageValue(1240, 'rowcount')).toBe('1,240 rows'); + }); + + it('shapes top artist bubbles from the highest-play artist', () => { + const bubbles = getTopArtistBubbles([ + { name: 'A', play_count: 20 }, + { name: 'B', play_count: 10 }, + ]); + + expect(bubbles[0]?.percent).toBe(100); + expect(bubbles[1]?.percent).toBe(50); + }); +}); diff --git a/webui/src/routes/stats/-stats.helpers.ts b/webui/src/routes/stats/-stats.helpers.ts new file mode 100644 index 00000000..d20d9742 --- /dev/null +++ b/webui/src/routes/stats/-stats.helpers.ts @@ -0,0 +1,162 @@ +import type { + StatsArtistRow, + StatsCachedPayload, + StatsDbStorageTable, + StatsHealth, + StatsOverview, + StatsRange, +} from './-stats.types'; + +export const EMPTY_STATS_OVERVIEW: StatsOverview = { + total_plays: 0, + total_time_ms: 0, + unique_artists: 0, + unique_albums: 0, + unique_tracks: 0, +}; + +export const EMPTY_STATS_PAYLOAD: Required< + Pick< + StatsCachedPayload, + 'overview' | 'top_artists' | 'top_albums' | 'top_tracks' | 'timeline' | 'genres' | 'recent' + > +> & { health: StatsHealth } = { + overview: EMPTY_STATS_OVERVIEW, + top_artists: [], + top_albums: [], + top_tracks: [], + timeline: [], + genres: [], + recent: [], + health: {}, +}; + +export const STATS_GENRE_COLORS = [ + '#1db954', + '#1ed760', + '#4ade80', + '#7c3aed', + '#a855f7', + '#ec4899', + '#f43f5e', + '#f97316', + '#eab308', + '#06b6d4', +] as const; + +export const STATS_DB_STORAGE_COLORS = [ + '#3b82f6', + '#f97316', + '#a855f7', + '#14b8a6', + '#eab308', + '#ec4899', + '#6366f1', + '#22c55e', + '#555555', +] as const; + +export const STATS_ENRICHMENT_SERVICES = [ + { key: 'spotify', label: 'Spotify', color: '#1db954' }, + { key: 'musicbrainz', label: 'MusicBrainz', color: '#ba55d3' }, + { key: 'deezer', label: 'Deezer', color: '#a238ff' }, + { key: 'lastfm', label: 'Last.fm', color: '#d51007' }, + { key: 'itunes', label: 'iTunes', color: '#fc3c44' }, + { key: 'audiodb', label: 'AudioDB', color: '#1a9fff' }, + { key: 'genius', label: 'Genius', color: '#ffff64' }, + { key: 'tidal', label: 'Tidal', color: '#00ffff' }, + { key: 'qobuz', label: 'Qobuz', color: '#4285f4' }, +] as const; + +export function getStatsRangeLabel(range: StatsRange): string { + switch (range) { + case '7d': + return '7 Days'; + case '30d': + return '30 Days'; + case '12m': + return '12 Months'; + case 'all': + return 'All Time'; + } +} + +export function hasStatsData(overview: Partial | undefined): boolean { + return (overview?.total_plays ?? 0) > 0; +} + +export function formatCompactNumber(value: number | null | undefined): string { + if (!value) return '0'; + if (value >= 1_000_000) return `${stripTrailingZero((value / 1_000_000).toFixed(1))}M`; + if (value >= 1_000) return `${stripTrailingZero((value / 1_000).toFixed(1))}K`; + return value.toLocaleString(); +} + +export function formatListeningTime(totalMs: number | null | undefined): string { + if (!totalMs) return '0h'; + const hours = Math.floor(totalMs / 3_600_000); + const minutes = Math.floor((totalMs % 3_600_000) / 60_000); + return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; +} + +export function formatTotalDuration(totalMs: number | null | undefined): string { + if (!totalMs) return '0h'; + return `${Math.floor(totalMs / 3_600_000)}h`; +} + +export function formatRelativePlayedAt( + dateStr: string | null | undefined, + now = Date.now(), +): string { + if (!dateStr) return ''; + const diff = now - new Date(dateStr).getTime(); + const minutes = Math.floor(diff / 60_000); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + return `${Math.floor(days / 30)}mo ago`; +} + +export function formatBytes(value: number | null | undefined): string { + if (!value || value <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let index = 0; + let next = value; + while (next >= 1024 && index < units.length - 1) { + next /= 1024; + index += 1; + } + return `${next.toFixed(next < 10 ? 2 : 1)} ${units[index]}`; +} + +export function groupDbStorageTables(tables: StatsDbStorageTable[]): StatsDbStorageTable[] { + const top = tables.slice(0, 8); + const rest = tables.slice(8); + const restSize = rest.reduce((sum, table) => sum + table.size, 0); + return restSize > 0 ? [...top, { name: 'Other', size: restSize }] : top; +} + +export function formatDbStorageValue(size: number, method: string | null | undefined): string { + if (method === 'dbstat') { + if (size > 1_048_576) return `${(size / 1_048_576).toFixed(1)} MB`; + return `${Math.round(size / 1024)} KB`; + } + return `${size.toLocaleString()} rows`; +} + +export function getTopArtistBubbles(artists: StatsArtistRow[]) { + const top = artists.slice(0, 5); + const maxPlays = top[0]?.play_count || 1; + + return top.map((artist, index) => ({ + artist, + percent: Math.round((artist.play_count / maxPlays) * 100), + size: 44 + (4 - index) * 6, + })); +} + +function stripTrailingZero(value: string): string { + return value.replace(/\.0$/, ''); +} diff --git a/webui/src/routes/stats/-stats.types.ts b/webui/src/routes/stats/-stats.types.ts new file mode 100644 index 00000000..c00cd25a --- /dev/null +++ b/webui/src/routes/stats/-stats.types.ts @@ -0,0 +1,148 @@ +import { z } from 'zod'; + +export const STATS_RANGE_VALUES = ['7d', '30d', '12m', 'all'] as const; +export type StatsRange = (typeof STATS_RANGE_VALUES)[number]; + +export const statsSearchSchema = z.object({ + range: z.enum(STATS_RANGE_VALUES).default('7d').catch('7d'), +}); + +export type StatsSearch = z.infer; + +export interface StatsOverview { + total_plays: number; + total_time_ms: number; + unique_artists: number; + unique_albums: number; + unique_tracks: number; +} + +export interface StatsArtistRow { + id?: string | number | null; + name: string; + image_url?: string | null; + play_count: number; + global_listeners?: number | null; + soul_id?: string | null; +} + +export interface StatsAlbumRow { + name: string; + artist?: string | null; + artist_id?: string | number | null; + image_url?: string | null; + play_count: number; +} + +export interface StatsTrackRow { + name: string; + artist?: string | null; + artist_id?: string | number | null; + album?: string | null; + image_url?: string | null; + play_count: number; +} + +export interface StatsTimelineRow { + date: string; + plays: number; +} + +export interface StatsGenreRow { + genre: string; + play_count: number; + percentage: number; +} + +export interface StatsEnrichmentCoverage { + spotify?: number; + musicbrainz?: number; + deezer?: number; + lastfm?: number; + itunes?: number; + audiodb?: number; + genius?: number; + tidal?: number; + qobuz?: number; +} + +export interface StatsHealth { + total_tracks?: number; + unplayed_count?: number; + unplayed_percentage?: number; + total_duration_ms?: number; + format_breakdown?: Record; + enrichment_coverage?: StatsEnrichmentCoverage; +} + +export interface StatsRecentTrack { + title: string; + artist?: string | null; + album?: string | null; + played_at?: string | null; +} + +export interface StatsCachedPayload { + success: boolean; + overview?: Partial; + top_artists?: StatsArtistRow[]; + top_albums?: StatsAlbumRow[]; + top_tracks?: StatsTrackRow[]; + timeline?: StatsTimelineRow[]; + genres?: StatsGenreRow[]; + recent?: StatsRecentTrack[]; + health?: StatsHealth; + error?: string; +} + +export interface ListeningStatsStatus { + stats?: { + last_poll?: string | null; + }; + error?: string; +} + +export interface StatsDbStorageTable { + name: string; + size: number; +} + +export interface StatsDbStoragePayload { + success: boolean; + tables?: StatsDbStorageTable[]; + total_file_size?: number; + method?: string; + error?: string; +} + +export interface StatsLibraryDiskUsagePayload { + success: boolean; + has_data?: boolean; + total_bytes?: number; + tracks_with_size?: number; + tracks_without_size?: number; + by_format?: Record; + error?: string; +} + +export interface StatsResolveTrackPayload { + success: boolean; + error?: string; + track?: { + id: string | number; + title: string; + file_path: string; + bitrate?: string | number | null; + artist_id?: string | number | null; + album_id?: string | number | null; + image_url?: string | null; + album_title?: string | null; + artist_name?: string | null; + }; +} + +export interface StatsStreamTrackPayload { + success: boolean; + error?: string; + result?: Record; +} diff --git a/webui/src/routes/stats/-ui/stats-page.module.css b/webui/src/routes/stats/-ui/stats-page.module.css new file mode 100644 index 00000000..17be6814 --- /dev/null +++ b/webui/src/routes/stats/-ui/stats-page.module.css @@ -0,0 +1,938 @@ +.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; + min-width: 0; + flex-wrap: wrap; + justify-content: flex-end; +} + +.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; + min-width: 0; +} + +.statsLastSynced { + flex: 1 1 auto; + min-width: 0; + font-size: 0.72rem; + color: rgba(255, 255, 255, 0.3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.statsStandaloneNotice { + max-width: 260px; + font-size: 0.72rem; + line-height: 1.35; + color: rgba(255, 255, 255, 0.38); + text-align: right; +} + +.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; + } + + .statsStandaloneNotice { + max-width: none; + text-align: left; + } + + .headerIcon { + width: 100px; + height: 100px; + } + + .statsOverview { + grid-template-columns: repeat(2, 1fr); + gap: 8px; + } + + .statsCard { + padding: 12px 10px; + border-radius: 10px; + } + + .statsCardValue { + font-size: 18px; + } + + .statsCardLabel { + font-size: 9px; + } + + .statsMainGrid { + grid-template-columns: 1fr; + gap: 14px; + } + + .statsLeftCol, + .statsRightCol { + gap: 14px; + } + + .statsSectionCard { + padding: 12px; + } + + .chartContainer { + height: 160px; + } + + .statsGenreChartContainer, + .statsDbStorageWrap { + flex-direction: column; + align-items: center; + } + + .statsHealthGrid { + grid-template-columns: 1fr 1fr; + } +} diff --git a/webui/src/routes/stats/-ui/stats-page.tsx b/webui/src/routes/stats/-ui/stats-page.tsx new file mode 100644 index 00000000..63259a7d --- /dev/null +++ b/webui/src/routes/stats/-ui/stats-page.tsx @@ -0,0 +1,929 @@ +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, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import type { ShellBridge } from '@/platform/shell/bridge'; + +import { useReactPageShell, useShellStatus } 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() { + const bridge = useReactPageShell('stats'); + + const navigate = useNavigate({ from: Route.fullPath }); + const queryClient = useQueryClient(); + const { range } = Route.useSearch(); + const syncTimeoutRef = useRef(null); + const [syncing, setSyncing] = useState(false); + + const cachedStatsQuery = useQuery({ + ...statsCachedQueryOptions(range), + }); + const listeningStatusQuery = useQuery({ + ...listeningStatsStatusQueryOptions(), + }); + const dbStorageQuery = useQuery({ + ...statsDbStorageQueryOptions(), + }); + const diskUsageQuery = useQuery({ + ...statsLibraryDiskUsageQueryOptions(), + }); + + useEffect(() => { + return () => { + if (syncTimeoutRef.current) { + window.clearTimeout(syncTimeoutRef.current); + } + }; + }, []); + + const syncMutation = useMutation({ + mutationFn: triggerListeningStatsSync, + onMutate: () => { + setSyncing(true); + }, + onSuccess: () => { + window.showToast?.('Syncing listening data...', 'info'); + syncTimeoutRef.current = window.setTimeout(() => { + void invalidateStatsQueries(queryClient); + setSyncing(false); + window.showToast?.('Listening stats updated', 'success'); + }, 5000); + }, + onError: (error) => { + setSyncing(false); + window.showToast?.(error instanceof Error ? error.message : 'Sync failed', 'error'); + }, + }); + + const cachedStats = cachedStatsQuery.data; + const overview = cachedStats?.overview ?? EMPTY_STATS_OVERVIEW; + const hasData = hasStatsData(overview); + const lastSynced = listeningStatusQuery.data?.stats?.last_poll ?? null; + const shellStatus = useShellStatus(); + const isStandalone = shellStatus?.media_server?.type === 'soulsync'; + + const onRangeChange = (nextRange: StatsRange) => { + void navigate({ + to: Route.fullPath, + search: { range: nextRange }, + replace: true, + }); + }; + + const openArtistDetail = (artistId: string | number, artistName: string) => { + bridge.navigateToArtistDetail(artistId, artistName); + }; + + return ( +
+
+
+ Stats +

Listening Stats

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

No Listening Data Yet

+

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

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

Failed to load listening stats

+

{message}

+
+ ); +} + +function SectionSubtleError({ message }: { message: string }) { + return
{message}
; +} + +function EmptyListState({ message }: { message: string }) { + return
{message}
; +} + +async function playStatsTrack( + bridge: ShellBridge, + track: { title: string; artist: string; album: string }, +) { + try { + const resolvedTrack = await resolveStatsTrack(track.title, track.artist); + if (resolvedTrack) { + await bridge.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; + } + } catch { + // Library resolve is best-effort; fall through to stream lookup on failure. + } + + bridge.showLoadingOverlay(`Searching for ${track.title}...`); + try { + const streamResult = await streamStatsTrack(track.title, track.artist, track.album); + bridge.hideLoadingOverlay(); + + if (streamResult) { + await bridge.startStream(streamResult); + return; + } + + window.showToast?.('Track not found in library or any source', 'error'); + } catch (error) { + bridge.hideLoadingOverlay(); + window.showToast?.(getErrorMessage(error), 'error'); + } +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Unknown error'; +} diff --git a/webui/src/routes/stats/route.tsx b/webui/src/routes/stats/route.tsx new file mode 100644 index 00000000..358d6f36 --- /dev/null +++ b/webui/src/routes/stats/route.tsx @@ -0,0 +1,33 @@ +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 + .fetchQuery({ + ...listeningStatsStatusQueryOptions(), + retry: false, + }) + .catch(() => undefined), + ]); + }, + component: StatsPage, +}); diff --git a/webui/src/test/shell-bridge.ts b/webui/src/test/shell-bridge.ts new file mode 100644 index 00000000..0c0cbe4b --- /dev/null +++ b/webui/src/test/shell-bridge.ts @@ -0,0 +1,24 @@ +import { vi } from 'vitest'; + +import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge'; + +export function createShellBridge(overrides: Partial = {}): ShellBridge { + const bridge: ShellBridge = { + 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(), + cancelSimilarArtistsLoad: vi.fn(), + navigateToArtistDetail: vi.fn(), + playLibraryTrack: vi.fn(), + showReactHost: vi.fn(), + startStream: vi.fn(), + showLoadingOverlay: vi.fn(), + hideLoadingOverlay: vi.fn(), + }; + + Object.assign(bridge, overrides); + return bridge; +} diff --git a/webui/static/core.js b/webui/static/core.js index f1b047f6..8e272f10 100644 --- a/webui/static/core.js +++ b/webui/static/core.js @@ -515,6 +515,7 @@ function handleServiceStatusUpdate(data) { const isSoulsyncStandalone = data.media_server?.type === 'soulsync'; _isSoulsyncStandalone = isSoulsyncStandalone; document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => { + if (btn.id === 'stats-sync-btn') return; // React stats page owns this control now. if (isSoulsyncStandalone) { btn.dataset.hiddenByStandalone = '1'; btn.style.display = 'none'; diff --git a/webui/static/helper.js b/webui/static/helper.js index 14aaa032..045c5802 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -1632,7 +1632,7 @@ const HELPER_CONTENT = { // โ”€โ”€โ”€ STATS PAGE โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - '.stats-container': { + '#stats-container': { title: 'Listening Stats', description: 'Analytics dashboard showing your listening activity, top artists/albums/tracks, genre breakdown, library health, and storage usage. Data syncs from your media server.', }, diff --git a/webui/static/init.js b/webui/static/init.js index 2f7c1239..6b760b0c 100644 --- a/webui/static/init.js +++ b/webui/static/init.js @@ -2420,9 +2420,6 @@ async function loadPageData(pageId) { loadApiKeys(); loadBlacklistCount(); break; - case 'stats': - initializeStatsPage(); - break; case 'import': initializeImportPage(); break; diff --git a/webui/static/library.js b/webui/static/library.js index e9d4186e..7a4cae2a 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -1512,6 +1512,70 @@ const _TOP_TRACKS_SOURCE_LABELS = { lastfm: 'Popular on Last.fm', }; +async function playTrackByMetadata(title, artist, album = '') { + // 1. Try the library first โ€” fastest and best quality if owned. + try { + const resp = await fetch('/api/stats/resolve-track', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, artist }), + }); + const data = await resp.json(); + if (data.success && data.track) { + const track = data.track; + playLibraryTrack( + { + id: track.id, + title: track.title, + file_path: track.file_path, + bitrate: track.bitrate, + artist_id: track.artist_id, + album_id: track.album_id, + _stats_image: track.image_url || null, + }, + track.album_title || album || '', + track.artist_name || artist || '', + ); + return; + } + } catch (e) { + console.debug('Library resolve failed, will try streaming fallback:', e); + } + + // 2. Library miss โ€” fall back to streaming via the enhanced-search streamer. + if (typeof showLoadingOverlay === 'function') { + showLoadingOverlay(`Searching for ${title}...`); + } + try { + const streamResp = await fetch('/api/enhanced-search/stream-track', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + track_name: title, + artist_name: artist, + album_name: album, + duration_ms: 0, + }), + }); + const streamData = await streamResp.json(); + if (typeof hideLoadingOverlay === 'function') hideLoadingOverlay(); + + if (streamData.success && streamData.result) { + if (typeof startStream === 'function') { + await startStream(streamData.result); + } else { + showToast('Streaming not available', 'error'); + } + } else { + showToast(streamData.error || 'Track not found in library or any source', 'error'); + } + } catch (e) { + if (typeof hideLoadingOverlay === 'function') hideLoadingOverlay(); + showToast('Failed to play track', 'error'); + console.error('Stream fallback failed:', e); + } +} + async function _loadArtistTopTracks(artistName) { const sidebar = document.getElementById('artist-hero-sidebar'); const container = document.getElementById('hero-top-tracks'); @@ -1576,7 +1640,7 @@ async function _loadArtistTopTracks(artistName) { const playBtn = e.target.closest('.hero-top-track-play'); if (playBtn) { e.stopPropagation(); - playStatsTrack(playBtn.dataset.track, playBtn.dataset.artist, ''); + playTrackByMetadata(playBtn.dataset.track, playBtn.dataset.artist, ''); return; } const dlBtn = e.target.closest('.hero-top-track-download'); @@ -1632,7 +1696,7 @@ async function _loadArtistTopTracks(artistName) { const btn = e.target.closest('.hero-top-track-play'); if (btn) { e.stopPropagation(); - playStatsTrack(btn.dataset.track, btn.dataset.artist, ''); + playTrackByMetadata(btn.dataset.track, btn.dataset.artist, ''); } }; sidebar.style.display = ''; diff --git a/webui/static/mobile.css b/webui/static/mobile.css index 8f292d98..043ea773 100644 --- a/webui/static/mobile.css +++ b/webui/static/mobile.css @@ -2750,323 +2750,6 @@ } } -/* ====================================== - STATS PAGE โ€” Mobile - ====================================== */ - -@media (max-width: 768px) { - .stats-container { - padding: 12px !important; - margin: 8px !important; - gap: 14px; - border-radius: 16px; - } - - .stats-header { - flex-direction: column; - align-items: flex-start; - padding: 12px !important; - margin: -12px -12px 0 -12px !important; - gap: 10px; - border-top-left-radius: 16px; - border-top-right-radius: 16px; - } - - .stats-header-title { - gap: 8px; - } - - .stats-header-title .page-header-icon { - width: 24px; - height: 24px; - } - - .stats-header-title h1 { - font-size: 18px; - } - - .stats-header-controls { - width: 100%; - flex-direction: column; - align-items: flex-start !important; - gap: 8px; - } - - .stats-time-range { - display: flex; - flex-wrap: wrap; - gap: 6px; - width: 100%; - } - - .stats-range-btn { - padding: 6px 12px; - font-size: 11px; - flex: 1; - text-align: center; - } - - .stats-sync-controls { - width: 100%; - justify-content: space-between; - } - - .stats-last-synced { - font-size: 11px; - } - - .stats-sync-btn { - padding: 6px 12px; - font-size: 12px; - } - - /* Overview cards โ€” 2 columns */ - .stats-overview { - grid-template-columns: repeat(2, 1fr) !important; - gap: 8px; - } - - .stats-card { - padding: 12px 10px; - border-radius: 10px; - } - - .stats-card-value { - font-size: 18px !important; - } - - .stats-card-label { - font-size: 9px; - letter-spacing: 0.04em; - } - - /* Main grid โ€” single column */ - .stats-main-grid { - grid-template-columns: 1fr !important; - gap: 14px; - } - - .stats-left-col, - .stats-right-col { - width: 100% !important; - gap: 14px; - } - - .stats-section-card { - padding: 12px; - border-radius: 12px; - } - - .stats-section-title { - font-size: 13px; - margin-bottom: 10px; - } - - /* Timeline chart โ€” shrink height */ - .stats-section-card > div[style*="height:220px"], - .stats-section-card > div[style*="height: 220px"] { - height: 160px !important; - } - - /* Genre chart โ€” stack vertically, shrink canvas */ - .stats-genre-chart-container { - flex-direction: column !important; - align-items: center; - gap: 12px; - } - - .stats-genre-chart-container canvas { - width: 130px !important; - height: 130px !important; - } - - .stats-genre-legend { - width: 100%; - } - - .stats-genre-legend-item { - font-size: 11px; - } - - /* Top artists bubbles */ - .stats-artist-bubbles { - gap: 8px; - flex-wrap: wrap; - justify-content: center; - } - - .stats-artist-bubble { - max-width: 60px; - } - - .stats-bubble-img { - width: 44px !important; - height: 44px !important; - } - - .stats-bubble-name { - font-size: 9px; - } - - .stats-bubble-count { - font-size: 9px; - } - - .stats-bubble-bar-container { - height: 3px; - } - - /* Ranked lists */ - .stats-ranked-item { - padding: 8px 8px; - gap: 6px; - } - - .stats-ranked-num { - font-size: 11px; - min-width: 18px; - } - - .stats-ranked-img { - width: 32px; - height: 32px; - border-radius: 6px; - } - - .stats-ranked-info { - min-width: 0; - } - - .stats-ranked-name { - font-size: 12px; - } - - .stats-ranked-meta { - font-size: 10px; - } - - .stats-ranked-count { - font-size: 10px; - white-space: nowrap; - } - - .stats-play-btn { - width: 24px; - height: 24px; - font-size: 8px; - opacity: 1; - } - - .stats-play-btn-sm { - width: 20px; - height: 20px; - font-size: 7px; - opacity: 1; - } - - /* Recent plays */ - .stats-recent-item { - padding: 6px 8px; - gap: 6px; - } - - .stats-recent-title { - font-size: 12px; - } - - .stats-recent-artist { - font-size: 11px; - } - - .stats-recent-time { - font-size: 10px; - } - - /* Library health */ - .stats-full-width { - padding: 12px; - } - - .stats-health-grid { - grid-template-columns: 1fr !important; - gap: 10px; - } - - .stats-health-label { - font-size: 11px; - } - - .stats-health-value { - font-size: 16px; - } - - .stats-format-bar { - min-height: 20px; - border-radius: 6px; - } - - .stats-format-segment { - font-size: 9px; - } - - /* Enrichment coverage bars */ - .stats-enrichment { - gap: 6px; - } - - .stats-enrich-item { - gap: 6px; - } - - .stats-enrich-name { - font-size: 10px; - min-width: 55px; - } - - .stats-enrich-pct { - font-size: 10px; - min-width: 28px; - } - - /* DB storage chart */ - .stats-db-storage-wrap { - flex-direction: column; - align-items: center; - gap: 12px; - } - - .stats-db-chart-container { - width: 140px; - height: 140px; - } - - .stats-db-chart-container canvas { - width: 140px !important; - height: 140px !important; - } - - .stats-db-total-value { - font-size: 16px; - } - - .stats-db-legend { - width: 100%; - } - - /* Empty state */ - .stats-empty-icon { - font-size: 40px; - } - - .stats-empty h3 { - font-size: 16px; - } - - .stats-empty p { - font-size: 13px; - } -} - /* ====================================== ARTIST ENRICHMENT RINGS โ€” Mobile ====================================== */ diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index 3fc8be27..43c184f9 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -3199,6 +3199,7 @@ async function fetchAndUpdateServiceStatus() { const isSoulsyncStandalone2 = data.media_server?.type === 'soulsync'; _isSoulsyncStandalone = isSoulsyncStandalone2; document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => { + if (btn.id === 'stats-sync-btn') return; // React stats page owns this control now. if (isSoulsyncStandalone2) { btn.dataset.hiddenByStandalone = '1'; btn.style.display = 'none'; diff --git a/webui/static/shell-bridge.js b/webui/static/shell-bridge.js index 7db84a76..af9af731 100644 --- a/webui/static/shell-bridge.js +++ b/webui/static/shell-bridge.js @@ -168,7 +168,6 @@ window.SoulSyncWebShellBridge = { activateLegacyPath(pathname) { activateLegacyPath(pathname); }, - navigateToArtistDetail, cancelSimilarArtistsLoad() { if (typeof cancelSimilarArtistsLoad === 'function') { cancelSimilarArtistsLoad(); @@ -177,6 +176,21 @@ window.SoulSyncWebShellBridge = { showReactHost(pageId) { showReactHost(pageId); }, + navigateToArtistDetail(artistId, artistName, sourceOverride, options) { + return navigateToArtistDetail(artistId, artistName, sourceOverride, options); + }, + playLibraryTrack(track, albumTitle, artistName) { + return playLibraryTrack(track, albumTitle, artistName); + }, + startStream(searchResult) { + return startStream(searchResult); + }, + showLoadingOverlay(message) { + return showLoadingOverlay(message); + }, + hideLoadingOverlay() { + return hideLoadingOverlay(); + }, }; function _handleShellLinkClick(event) { diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 6ed96413..06772113 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -21,614 +21,6 @@ const importPageState = { _albumLookup: {}, // { albumId: { id, name, artist, source } } }; -// =============================== -// STATS PAGE -// =============================== - -let _statsRange = '7d'; -let _statsTimelineChart = null; -let _statsGenreChart = null; -let _statsDbStorageChart = null; -let _statsInitialized = false; - -function initializeStatsPage() { - if (_statsInitialized) { - loadStatsData(); - return; - } - _statsInitialized = true; - - // Time range buttons - const rangeContainer = document.getElementById('stats-time-range'); - if (rangeContainer) { - rangeContainer.addEventListener('click', (e) => { - const btn = e.target.closest('.stats-range-btn'); - if (!btn) return; - _statsRange = btn.dataset.range; - rangeContainer.querySelectorAll('.stats-range-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - loadStatsData(); - }); - } - - loadStatsData(); - _updateStatsLastSynced(); -} - -async function triggerStatsSync() { - const btn = document.getElementById('stats-sync-btn'); - if (btn) btn.classList.add('syncing'); - - try { - const resp = await fetch('/api/listening-stats/sync', { method: 'POST' }); - const data = await resp.json(); - if (data.success) { - showToast('Syncing listening data...', 'info'); - // Wait a few seconds for the sync to complete, then reload - setTimeout(async () => { - await loadStatsData(); - _updateStatsLastSynced(); - if (btn) btn.classList.remove('syncing'); - showToast('Listening stats updated', 'success'); - }, 5000); - } else { - showToast(data.error || 'Sync failed', 'error'); - if (btn) btn.classList.remove('syncing'); - } - } catch (e) { - showToast('Sync failed', 'error'); - if (btn) btn.classList.remove('syncing'); - } -} - -async function _updateStatsLastSynced() { - const el = document.getElementById('stats-last-synced'); - if (!el) return; - try { - const resp = await fetch('/api/listening-stats/status'); - const data = await resp.json(); - if (data.stats && data.stats.last_poll) { - el.textContent = `Last synced: ${data.stats.last_poll}`; - } else { - el.textContent = 'Not synced yet'; - } - } catch { - el.textContent = ''; - } -} - -async function loadStatsData() { - // Show loading state - document.querySelectorAll('.stats-card-value').forEach(el => el.style.opacity = '0.3'); - - // Single cached endpoint โ€” instant response - let data; - try { - const resp = await fetch(`/api/stats/cached?range=${_statsRange}`); - data = await resp.json(); - } catch { - data = {}; - } - - if (!data.success) { - // Cache not available โ€” show empty state, user should hit Sync - data = { - overview: {}, top_artists: [], top_albums: [], top_tracks: [], - timeline: [], genres: [], recent: [], health: {} - }; - } - - const overview = data.overview || {}; - const emptyEl = document.getElementById('stats-empty'); - const hasData = (overview.total_plays || 0) > 0; - - if (emptyEl) { - emptyEl.classList.toggle('hidden', hasData); - } - // Hide main content sections when no data - const mainSections = document.querySelectorAll('.stats-overview, .stats-main-grid, .stats-full-width'); - mainSections.forEach(el => el.style.display = hasData ? '' : 'none'); - - // Overview cards - const _fmt = (n) => { - if (!n) return '0'; - if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; - if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K'; - return n.toLocaleString(); - }; - const _fmtTime = (ms) => { - if (!ms) return '0h'; - const hours = Math.floor(ms / 3600000); - const mins = Math.floor((ms % 3600000) / 60000); - if (hours > 0) return `${hours}h ${mins}m`; - return `${mins}m`; - }; - - // Restore opacity - document.querySelectorAll('.stats-card-value').forEach(el => el.style.opacity = '1'); - - _setText('stats-total-plays', _fmt(overview.total_plays)); - _setText('stats-listening-time', _fmtTime(overview.total_time_ms)); - _setText('stats-unique-artists', _fmt(overview.unique_artists)); - _setText('stats-unique-albums', _fmt(overview.unique_albums)); - _setText('stats-unique-tracks', _fmt(overview.unique_tracks)); - - // Top Artists โ€” visual bubbles - _renderTopArtistsVisual(data.top_artists || []); - - // Top Artists โ€” ranked list - _renderRankedList('stats-top-artists', data.top_artists || [], (item, i) => ` -
- ${i + 1} - ${item.image_url ? `` : ''} -
-
${item.id ? `${_esc(item.name)}` : _esc(item.name)}${item.soul_id && !String(item.soul_id).startsWith('soul_unnamed_') ? ' ' : ''}
-
${item.global_listeners ? _fmt(item.global_listeners) + ' global listeners' : ''}
-
- ${_fmt(item.play_count)} plays -
- `); - - // Top Albums - _renderRankedList('stats-top-albums', data.top_albums || [], (item, i) => ` -
- ${i + 1} - ${item.image_url ? `` : ''} -
-
${_esc(item.name)}
-
${item.artist_id ? `${_esc(item.artist || '')}` : _esc(item.artist || '')}
-
- ${_fmt(item.play_count)} plays -
- `); - - // Top Tracks - _renderRankedList('stats-top-tracks', data.top_tracks || [], (item, i) => ` -
- ${i + 1} - ${item.image_url ? `` : ''} -
-
${_esc(item.name)}
-
${item.artist_id ? `${_esc(item.artist || '')}` : _esc(item.artist || '')}${item.album ? ' ยท ' + _esc(item.album) : ''}
-
- - ${_fmt(item.play_count)} plays -
- `); - - // Timeline chart - _renderTimelineChart(data.timeline || []); - - // Genre chart - _renderGenreChart(data.genres || []); - - // Library health - _renderLibraryHealth(data.health || {}); - - // DB storage chart (separate fetch โ€” not part of cached stats) - _loadDbStorageChart(); - // Library disk usage (separate fetch โ€” populated by deep scan) - _loadLibraryDiskUsage(); - - // Recent plays - _renderRecentPlays(data.recent || []); -} - -function _renderTopArtistsVisual(artists) { - const el = document.getElementById('stats-top-artists-visual'); - if (!el || !artists.length) { if (el) el.innerHTML = ''; return; } - - const top5 = artists.slice(0, 5); - const maxPlays = top5[0]?.play_count || 1; - const _fmt = (n) => { - if (!n) return '0'; - if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; - if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K'; - return n.toString(); - }; - - el.innerHTML = `
- ${top5.map((a, i) => { - const pct = Math.round((a.play_count / maxPlays) * 100); - const size = 44 + (4 - i) * 6; // Largest first: 68, 62, 56, 50, 44 - return ` -
- ${!a.image_url ? `${(a.name || '?')[0]}` : ''} -
-
-
-
-
${_esc(a.name)}
-
${_fmt(a.play_count)}
-
`; - }).join('')} -
`; -} - -function _setText(id, text) { - const el = document.getElementById(id); - if (el) el.textContent = text; -} - -function _renderRankedList(containerId, items, template) { - const el = document.getElementById(containerId); - if (!el) return; - el.innerHTML = items.length - ? items.map((item, i) => template(item, i)).join('') - : '
No data yet
'; -} - -function _renderTimelineChart(data) { - const canvas = document.getElementById('stats-timeline-chart'); - if (!canvas || typeof Chart === 'undefined') return; - - if (_statsTimelineChart) _statsTimelineChart.destroy(); - - _statsTimelineChart = new Chart(canvas, { - type: 'bar', - data: { - labels: data.map(d => d.date), - datasets: [{ - label: 'Plays', - data: data.map(d => d.plays), - backgroundColor: `rgba(${getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '29,185,84'}, 0.5)`, - borderColor: `rgba(${getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '29,185,84'}, 0.8)`, - borderWidth: 1, - borderRadius: 4, - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { legend: { display: false } }, - scales: { - x: { grid: { display: false }, ticks: { color: 'rgba(255,255,255,0.3)', font: { size: 10 }, maxTicksLimit: 12 } }, - y: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: 'rgba(255,255,255,0.3)', font: { size: 10 } }, beginAtZero: true }, - } - } - }); -} - -function _renderGenreChart(data) { - const canvas = document.getElementById('stats-genre-chart'); - const legend = document.getElementById('stats-genre-legend'); - if (!canvas || typeof Chart === 'undefined') return; - - if (_statsGenreChart) _statsGenreChart.destroy(); - - const colors = [ - '#1db954', '#1ed760', '#4ade80', '#7c3aed', '#a855f7', - '#ec4899', '#f43f5e', '#f97316', '#eab308', '#06b6d4', - '#3b82f6', '#6366f1', '#14b8a6', '#84cc16', '#f59e0b', - ]; - - const top = data.slice(0, 10); - - _statsGenreChart = new Chart(canvas, { - type: 'doughnut', - data: { - labels: top.map(g => g.genre), - datasets: [{ - data: top.map(g => g.play_count), - backgroundColor: colors.slice(0, top.length), - borderWidth: 0, - hoverOffset: 6, - }] - }, - options: { - responsive: true, - maintainAspectRatio: true, - cutout: '65%', - plugins: { legend: { display: false } }, - } - }); - - if (legend) { - legend.innerHTML = top.map((g, i) => ` -
- - ${g.genre} - ${g.percentage}% -
- `).join(''); - } -} - -function _renderLibraryHealth(data) { - if (!data || !data.total_tracks) return; - - const _fmt = (n) => { - if (!n) return '0'; - if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'; - if (n >= 1000) return (n / 1000).toFixed(1) + 'K'; - return n.toLocaleString(); - }; - - _setText('stats-unplayed', `${_fmt(data.unplayed_count)} (${data.unplayed_percentage || 0}%)`); - _setText('stats-total-duration', data.total_duration_ms ? `${Math.floor(data.total_duration_ms / 3600000)}h` : '0h'); - _setText('stats-total-tracks-count', _fmt(data.total_tracks)); - - // Format bar - const bar = document.getElementById('stats-format-bar'); - if (bar && data.format_breakdown) { - const total = Object.values(data.format_breakdown).reduce((s, v) => s + v, 0) || 1; - const fmtColors = { FLAC: '#3b82f6', MP3: '#f97316', Opus: '#a855f7', AAC: '#14b8a6', OGG: '#eab308', WAV: '#ec4899', Other: '#555' }; - - bar.innerHTML = Object.entries(data.format_breakdown).map(([fmt, count]) => { - const pct = (count / total * 100).toFixed(1); - return `
${pct > 8 ? fmt : ''}
`; - }).join(''); - } - - // Enrichment coverage - const enrichEl = document.getElementById('stats-enrichment-coverage'); - if (enrichEl && data.enrichment_coverage) { - const ec = data.enrichment_coverage; - const services = [ - { name: 'Spotify', pct: ec.spotify || 0, color: '#1db954' }, - { name: 'MusicBrainz', pct: ec.musicbrainz || 0, color: '#ba55d3' }, - { name: 'Deezer', pct: ec.deezer || 0, color: '#a238ff' }, - { name: 'Last.fm', pct: ec.lastfm || 0, color: '#d51007' }, - { name: 'iTunes', pct: ec.itunes || 0, color: '#fc3c44' }, - { name: 'AudioDB', pct: ec.audiodb || 0, color: '#1a9fff' }, - { name: 'Genius', pct: ec.genius || 0, color: '#ffff64' }, - { name: 'Tidal', pct: ec.tidal || 0, color: '#00ffff' }, - { name: 'Qobuz', pct: ec.qobuz || 0, color: '#4285f4' }, - ]; - enrichEl.innerHTML = services.map(s => ` -
- ${s.name} -
- ${s.pct}% -
- `).join(''); - } -} - -async function _loadDbStorageChart() { - try { - const resp = await fetch('/api/stats/db-storage'); - const data = await resp.json(); - if (!data.success || !data.tables || !data.tables.length) return; - _renderDbStorageChart(data.tables, data.total_file_size, data.method); - } catch (e) { - console.debug('DB storage chart load failed:', e); - } -} - -async function _loadLibraryDiskUsage() { - try { - const resp = await fetch('/api/stats/library-disk-usage'); - const data = await resp.json(); - if (!data.success) return; - _renderLibraryDiskUsage(data); - } catch (e) { - console.debug('Library disk usage load failed:', e); - } -} - -function _formatBytes(n) { - if (!n || n <= 0) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB', 'TB']; - let i = 0; - let v = n; - while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } - return `${v.toFixed(v < 10 ? 2 : 1)} ${units[i]}`; -} - -function _renderLibraryDiskUsage(data) { - const totalEl = document.getElementById('stats-disk-total-value'); - const metaEl = document.getElementById('stats-disk-total-meta'); - const formatsEl = document.getElementById('stats-disk-formats'); - if (!totalEl || !metaEl || !formatsEl) return; - - if (!data.has_data || !data.total_bytes) { - totalEl.textContent = 'โ€”'; - metaEl.textContent = data.tracks_without_size > 0 - ? `Run a Deep Scan to populate (${data.tracks_without_size.toLocaleString()} tracks pending)` - : 'No tracks in library yet'; - formatsEl.innerHTML = ''; - return; - } - - totalEl.textContent = _formatBytes(data.total_bytes); - - const withSize = data.tracks_with_size || 0; - const withoutSize = data.tracks_without_size || 0; - const trackBits = `${withSize.toLocaleString()} tracks measured`; - const pendingBits = withoutSize > 0 - ? ` (+${withoutSize.toLocaleString()} pending next Deep Scan)` - : ''; - metaEl.textContent = trackBits + pendingBits; - - // Per-format bars sorted by size descending. Skip if no breakdown. - const formats = Object.entries(data.by_format || {}).sort((a, b) => b[1] - a[1]); - if (!formats.length) { formatsEl.innerHTML = ''; return; } - - const max = formats[0][1] || 1; - formatsEl.innerHTML = formats.map(([ext, bytes]) => { - const pct = Math.max(2, Math.round((bytes / max) * 100)); - return ` -
- ${ext.toUpperCase()} -
-
-
- ${_formatBytes(bytes)} -
- `; - }).join(''); -} - -function _renderDbStorageChart(tables, totalFileSize, method) { - const canvas = document.getElementById('stats-db-storage-chart'); - if (!canvas || typeof Chart === 'undefined') return; - - if (_statsDbStorageChart) _statsDbStorageChart.destroy(); - - // Top 8 tables, group rest as "Other" - const top = tables.slice(0, 8); - const rest = tables.slice(8); - const restSize = rest.reduce((s, t) => s + t.size, 0); - if (restSize > 0) top.push({ name: 'Other', size: restSize }); - - const colors = ['#3b82f6', '#f97316', '#a855f7', '#14b8a6', '#eab308', '#ec4899', '#6366f1', '#22c55e', '#555']; - - _statsDbStorageChart = new Chart(canvas, { - type: 'doughnut', - data: { - labels: top.map(t => t.name), - datasets: [{ - data: top.map(t => t.size), - backgroundColor: colors.slice(0, top.length), - borderWidth: 0, - hoverOffset: 4, - }], - }, - options: { - responsive: false, - cutout: '65%', - plugins: { - legend: { display: false }, - tooltip: { - callbacks: { - label: (ctx) => { - const val = ctx.parsed; - if (method === 'dbstat') { - if (val > 1048576) return ` ${(val / 1048576).toFixed(1)} MB`; - return ` ${(val / 1024).toFixed(0)} KB`; - } - return ` ${val.toLocaleString()} rows`; - } - } - } - }, - }, - }); - - // Center label โ€” total file size - const totalEl = document.getElementById('stats-db-total'); - if (totalEl) { - let sizeStr; - if (totalFileSize > 1073741824) sizeStr = (totalFileSize / 1073741824).toFixed(2) + ' GB'; - else if (totalFileSize > 1048576) sizeStr = (totalFileSize / 1048576).toFixed(1) + ' MB'; - else sizeStr = (totalFileSize / 1024).toFixed(0) + ' KB'; - totalEl.innerHTML = `
${sizeStr}
Total Size
`; - } - - // Legend - const legendEl = document.getElementById('stats-db-legend'); - if (legendEl) { - legendEl.innerHTML = top.map((t, i) => { - let sizeLabel; - if (method === 'dbstat') { - if (t.size > 1048576) sizeLabel = (t.size / 1048576).toFixed(1) + ' MB'; - else sizeLabel = (t.size / 1024).toFixed(0) + ' KB'; - } else { - sizeLabel = t.size.toLocaleString() + ' rows'; - } - return `
- - ${t.name} - ${sizeLabel} -
`; - }).join(''); - } -} - -async function playStatsTrack(title, artist, album) { - // 1. Try the library first โ€” fastest and best quality if owned. - try { - const resp = await fetch('/api/stats/resolve-track', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title, artist }), - }); - const data = await resp.json(); - if (data.success && data.track) { - const t = data.track; - playLibraryTrack({ - id: t.id, - title: t.title, - file_path: t.file_path, - bitrate: t.bitrate, - artist_id: t.artist_id, - album_id: t.album_id, - _stats_image: t.image_url || null, - }, t.album_title || album || '', t.artist_name || artist || ''); - return; - } - } catch (e) { - console.debug('Library resolve failed, will try streaming fallback:', e); - } - - // 2. Library miss โ€” fall back to streaming via the enhanced-search streamer - // (Soulseek โ†’ YouTube โ†’ other configured sources, same pipeline used by - // the search results' play button). - if (typeof showLoadingOverlay === 'function') { - showLoadingOverlay(`Searching for ${title}...`); - } - try { - const streamResp = await fetch('/api/enhanced-search/stream-track', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - track_name: title, - artist_name: artist, - album_name: album || '', - duration_ms: 0, - }), - }); - const streamData = await streamResp.json(); - if (typeof hideLoadingOverlay === 'function') hideLoadingOverlay(); - - if (streamData.success && streamData.result) { - if (typeof startStream === 'function') { - await startStream(streamData.result); - } else { - showToast('Streaming not available', 'error'); - } - } else { - showToast(streamData.error || 'Track not found in library or any source', 'error'); - } - } catch (e) { - if (typeof hideLoadingOverlay === 'function') hideLoadingOverlay(); - showToast('Failed to play track', 'error'); - console.error('Stream fallback failed:', e); - } -} - -function _renderRecentPlays(tracks) { - const el = document.getElementById('stats-recent-plays'); - if (!el) return; - - if (!tracks.length) { - el.innerHTML = '
No recent plays
'; - return; - } - - const _ago = (dateStr) => { - if (!dateStr) return ''; - const diff = Date.now() - new Date(dateStr).getTime(); - const mins = Math.floor(diff / 60000); - if (mins < 60) return `${mins}m ago`; - const hours = Math.floor(mins / 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`; - }; - - el.innerHTML = tracks.map(t => ` -
- - ${_esc(t.title)} - ${_esc(t.artist || '')} - ${_ago(t.played_at)} -
- `).join(''); -} - // --- Initialization --- function initializeImportPage() { diff --git a/webui/static/style.css b/webui/static/style.css index bbdc9371..a6ef10e9 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -40072,887 +40072,6 @@ div.artist-hero-badge { IMPORT PAGE (full page, replaces modal) ======================================== */ -/* ============================================================================ - STATS PAGE - ============================================================================ */ - -/* Stats page uses dashboard-container pattern for consistency */ -.stats-container { - display: flex; - flex-direction: column; - gap: 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); - margin: 20px; - 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); -} - -/* Header uses same pattern as .dashboard-header */ -.stats-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 20px 24px; - margin: -28px -24px 0 -24px; - position: relative; - overflow: hidden; - flex-wrap: wrap; - gap: 16px; - background: linear-gradient(180deg, - rgba(var(--accent-rgb), 0.10) 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; -} - -.stats-header::before { - content: ''; - position: absolute; - top: 0; - left: -100%; - width: 50%; - height: 100%; - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.03), transparent); - animation: stats-header-sweep 12s ease-in-out infinite; -} - -@keyframes stats-header-sweep { - 0%, 100% { left: -100%; } - 50% { left: 150%; } -} - -.stats-header-title { - display: flex; - align-items: center; - gap: 14px; -} - -.stats-header-title h1 { - font-size: 28px; - font-weight: 700; - color: #fff; - margin: 0; - font-family: 'SF Pro Display', -apple-system, sans-serif; -} - -/* Time range pills */ -.stats-time-range { - display: flex; - gap: 4px; - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 10px; - padding: 3px; -} - -.stats-range-btn { - padding: 7px 16px; - border: none; - border-radius: 8px; - background: transparent; - color: rgba(255, 255, 255, 0.5); - font-size: 0.82em; - font-weight: 600; - cursor: pointer; - transition: all 0.2s; - font-family: inherit; -} - -.stats-range-btn:hover { - color: rgba(255, 255, 255, 0.8); - background: rgba(255, 255, 255, 0.04); -} - -.stats-range-btn.active { - background: rgb(var(--accent-rgb)); - color: #fff; - box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.3); -} - -.stats-header-controls { - display: flex; - align-items: center; - gap: 16px; -} - -.stats-sync-controls { - display: flex; - align-items: center; - gap: 8px; -} - -.stats-last-synced { - font-size: 0.72em; - color: rgba(255, 255, 255, 0.3); -} - -.stats-sync-btn { - 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; - font-family: inherit; - display: flex; - align-items: center; - justify-content: center; -} - -.stats-sync-btn:hover { - background: rgba(255, 255, 255, 0.08); - color: #fff; - border-color: rgba(255, 255, 255, 0.15); -} - -.stats-sync-btn.syncing { - pointer-events: none; - color: transparent; - position: relative; -} - -.stats-sync-btn.syncing::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: stats-spin 0.8s linear infinite; -} - -@keyframes stats-spin { - to { transform: rotate(360deg); } -} - -/* Overview cards */ -.stats-overview { - display: grid; - grid-template-columns: repeat(5, 1fr); - gap: 14px; -} - -.stats-card { - 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 cubic-bezier(0.4, 0, 0.2, 1); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.06); -} - -.stats-card::before { - content: ''; - position: absolute; - top: 0; - left: 20%; - right: 20%; - height: 2px; - background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb), 0.5), transparent); - border-radius: 2px; - transition: all 0.3s; -} - -.stats-card:hover { - transform: translateY(-3px); - border-color: rgba(var(--accent-rgb), 0.2); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4), 0 0 20px rgba(var(--accent-rgb), 0.08); -} - -.stats-card:hover::before { - left: 10%; - right: 10%; - height: 3px; - box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.4); -} - -.stats-card-value { - font-size: 2em; - font-weight: 700; - color: #fff; - line-height: 1.2; - margin-bottom: 6px; - font-family: 'SF Pro Display', -apple-system, sans-serif; -} - -.stats-card-label { - font-size: 0.78em; - color: rgba(255, 255, 255, 0.45); - text-transform: uppercase; - letter-spacing: 0.06em; - font-weight: 600; -} - -/* Main grid */ -.stats-main-grid { - display: grid; - grid-template-columns: 1fr 360px; - gap: 20px; - min-width: 0; -} - -.stats-left-col, .stats-right-col { - display: flex; - flex-direction: column; - gap: 20px; - min-width: 0; -} - -.stats-two-col { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 20px; -} - -/* Section cards */ -.stats-section-card { - 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; - min-width: 0; - overflow: hidden; -} - -.stats-section-card:hover { - border-color: rgba(255, 255, 255, 0.1); -} - -.stats-full-width { - /* No extra margin โ€” container handles it */ -} - -.stats-section-title { - font-size: 0.78em; - 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); -} - -/* Genre chart */ -.stats-genre-chart-container { - display: flex; - align-items: center; - gap: 24px; -} - -.stats-genre-chart-container canvas { - width: 180px !important; - height: 180px !important; - flex-shrink: 0; -} - -.stats-genre-legend { - flex: 1; - display: flex; - flex-direction: column; - gap: 6px; -} - -.stats-genre-legend-item { - display: flex; - align-items: center; - gap: 8px; - font-size: 0.82em; - color: rgba(255, 255, 255, 0.7); -} - -.stats-genre-dot { - width: 10px; - height: 10px; - border-radius: 3px; - flex-shrink: 0; -} - -.stats-genre-pct { - margin-left: auto; - color: rgba(255, 255, 255, 0.4); - font-variant-numeric: tabular-nums; -} - -/* Top artists visual bubbles */ -.stats-top-artists-visual { - margin-bottom: 16px; - padding-bottom: 14px; - border-bottom: 1px solid rgba(255, 255, 255, 0.04); -} - -.stats-artist-bubbles { - display: flex; - justify-content: space-around; - align-items: flex-end; - gap: 8px; -} - -.stats-artist-bubble { - display: flex; - flex-direction: column; - align-items: center; - gap: 6px; - min-width: 0; - flex: 1; - transition: transform 0.2s; -} - -.stats-artist-bubble:hover { - transform: translateY(-3px); -} - -.stats-bubble-img { - 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; - transition: border-color 0.2s, box-shadow 0.2s; -} - -.stats-artist-bubble:hover .stats-bubble-img { - border-color: rgba(var(--accent-rgb), 0.5); - box-shadow: 0 4px 20px rgba(var(--accent-rgb), 0.2); -} - -.stats-bubble-img span { - font-size: 1.2em; - font-weight: 700; - color: rgba(255, 255, 255, 0.4); -} - -.stats-bubble-bar-container { - width: 100%; - height: 3px; - background: rgba(255, 255, 255, 0.06); - border-radius: 2px; - overflow: hidden; -} - -.stats-bubble-bar { - height: 100%; - background: linear-gradient(90deg, rgb(var(--accent-rgb)), rgba(var(--accent-rgb), 0.4)); - border-radius: 2px; - transition: width 0.5s ease; -} - -.stats-bubble-name { - font-size: 0.7em; - color: rgba(255, 255, 255, 0.7); - font-weight: 500; - text-align: center; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 100%; -} - -.stats-bubble-count { - font-size: 0.65em; - color: rgba(var(--accent-rgb), 0.7); - font-weight: 600; -} - -/* Ranked lists */ -.stats-ranked-list { - display: flex; - flex-direction: column; - gap: 4px; - max-height: 280px; - overflow-y: auto; - scrollbar-width: thin; - scrollbar-color: rgba(255, 255, 255, 0.1) transparent; -} - -.stats-ranked-item { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 10px; - border-radius: 8px; - transition: background 0.15s; - cursor: default; -} - -.stats-ranked-item:hover { - background: rgba(255, 255, 255, 0.04); -} - -.stats-ranked-num { - font-size: 0.75em; - color: rgba(255, 255, 255, 0.25); - font-weight: 700; - width: 18px; - text-align: right; - flex-shrink: 0; -} - -.stats-ranked-img { - width: 36px; - height: 36px; - border-radius: 6px; - object-fit: cover; - flex-shrink: 0; - background: rgba(255, 255, 255, 0.05); -} - -.stats-ranked-info { - flex: 1; - min-width: 0; -} - -.stats-ranked-name { - font-size: 0.88em; - color: rgba(255, 255, 255, 0.85); - font-weight: 500; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.stats-ranked-meta { - font-size: 0.72em; - color: rgba(255, 255, 255, 0.4); -} - -.stats-ranked-count { - font-size: 0.78em; - color: rgba(var(--accent-rgb), 0.8); - font-weight: 600; - flex-shrink: 0; - font-variant-numeric: tabular-nums; -} - -.stats-artist-link { - color: inherit; - text-decoration: none; - cursor: pointer; - transition: color 0.15s; -} - -.stats-artist-link:hover { - color: rgb(var(--accent-rgb)); -} - -/* Play buttons */ -.stats-play-btn { - 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; - opacity: 0; -} - -.stats-ranked-item:hover .stats-play-btn, -.stats-recent-item:hover .stats-play-btn { - opacity: 1; -} - -.stats-play-btn:hover { - background: rgb(var(--accent-rgb)); - color: #fff; - transform: scale(1.1); -} - -.stats-play-btn-sm { - width: 22px; - height: 22px; - font-size: 8px; -} - -/* Library health */ -.stats-health-grid { - display: grid; - grid-template-columns: 2fr 1fr 1fr 1fr; - gap: 16px; - align-items: start; -} - -.stats-health-item { - text-align: center; -} - -.stats-health-item:first-child { - text-align: left; -} - -.stats-health-value { - font-size: 1.6em; - font-weight: 700; - color: #fff; - line-height: 1.2; - margin-bottom: 4px; -} - -.stats-health-label { - font-size: 0.75em; - color: rgba(255, 255, 255, 0.4); - text-transform: uppercase; - letter-spacing: 0.04em; - font-weight: 600; -} - -/* Format breakdown bar */ -.stats-format-bar { - display: flex; - height: 28px; - border-radius: 8px; - overflow: hidden; - margin-top: 8px; - background: rgba(255, 255, 255, 0.04); -} - -.stats-format-segment { - display: flex; - align-items: center; - justify-content: center; - font-size: 0.68em; - font-weight: 600; - color: #fff; - white-space: nowrap; - min-width: 30px; - transition: flex 0.5s ease; -} - -/* Recent plays */ -.stats-recent-list { - 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; -} - -.stats-recent-item { - display: flex; - align-items: center; - gap: 10px; - padding: 6px 8px; - border-radius: 6px; -} - -.stats-recent-item:hover { - background: rgba(255, 255, 255, 0.03); -} - -.stats-recent-title { - flex: 1; - font-size: 0.85em; - color: rgba(255, 255, 255, 0.8); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.stats-recent-artist { - font-size: 0.78em; - color: rgba(255, 255, 255, 0.4); - flex-shrink: 0; -} - -.stats-recent-time { - font-size: 0.72em; - color: rgba(255, 255, 255, 0.25); - flex-shrink: 0; - min-width: 65px; - text-align: right; -} - -/* Enrichment coverage */ -.stats-enrichment { - display: flex; - gap: 16px; - margin-top: 16px; - padding-top: 14px; - border-top: 1px solid rgba(255, 255, 255, 0.04); - flex-wrap: wrap; -} - -.stats-enrich-item { - flex: 1; - min-width: 120px; - display: flex; - align-items: center; - gap: 8px; -} - -.stats-enrich-name { - font-size: 0.72em; - color: rgba(255, 255, 255, 0.45); - min-width: 70px; - font-weight: 500; -} - -.stats-enrich-bar { - flex: 1; - height: 4px; - background: rgba(255, 255, 255, 0.06); - border-radius: 2px; - overflow: hidden; -} - -.stats-enrich-fill { - height: 100%; - border-radius: 2px; - transition: width 0.5s ease; -} - -.stats-enrich-pct { - font-size: 0.72em; - color: rgba(255, 255, 255, 0.4); - font-variant-numeric: tabular-nums; - min-width: 30px; - text-align: right; -} - -/* Library Disk Usage */ -.stats-disk-usage-wrap { - display: flex; - flex-direction: column; - gap: 14px; - margin-top: 8px; -} - -.stats-disk-total-row { - display: flex; - align-items: baseline; - gap: 16px; - flex-wrap: wrap; -} - -.stats-disk-total-value { - font-size: 28px; - font-weight: 700; - color: rgb(var(--accent-rgb)); -} - -.stats-disk-total-meta { - font-size: 12px; - color: rgba(255, 255, 255, 0.55); -} - -.stats-disk-formats { - display: flex; - flex-direction: column; - gap: 6px; -} - -.stats-disk-format-row { - display: grid; - grid-template-columns: 60px 1fr 80px; - align-items: center; - gap: 10px; - font-size: 12px; -} - -.stats-disk-format-name { - font-weight: 600; - color: rgba(255, 255, 255, 0.8); -} - -.stats-disk-format-bar { - height: 8px; - background: rgba(255, 255, 255, 0.05); - border-radius: 4px; - overflow: hidden; -} - -.stats-disk-format-fill { - height: 100%; - background: linear-gradient(90deg, - rgb(var(--accent-rgb)) 0%, - rgba(var(--accent-rgb), 0.6) 100%); - border-radius: 4px; -} - -.stats-disk-format-size { - text-align: right; - color: rgba(255, 255, 255, 0.55); - font-variant-numeric: tabular-nums; -} - -/* Database Storage Chart */ -.stats-db-storage-wrap { - display: flex; - align-items: center; - gap: 24px; - margin-top: 8px; -} - -.stats-db-chart-container { - position: relative; - width: 180px; - height: 180px; - flex-shrink: 0; -} - -.stats-db-chart-container canvas { - width: 180px !important; - height: 180px !important; -} - -.stats-db-total { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - pointer-events: none; -} - -.stats-db-total-value { - font-size: 20px; - font-weight: 700; - color: rgba(255, 255, 255, 0.85); -} - -.stats-db-total-label { - font-size: 10px; - color: rgba(255, 255, 255, 0.4); - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.stats-db-legend { - flex: 1; - display: flex; - flex-direction: column; - gap: 5px; - min-width: 0; -} - -.stats-db-legend-item { - display: flex; - align-items: center; - gap: 8px; - font-size: 12px; - color: rgba(255, 255, 255, 0.6); -} - -.stats-db-legend-dot { - width: 10px; - height: 10px; - border-radius: 3px; - flex-shrink: 0; -} - -.stats-db-legend-name { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.stats-db-legend-size { - font-variant-numeric: tabular-nums; - color: rgba(255, 255, 255, 0.4); - font-size: 11px; -} - -/* Stats empty state */ -.stats-empty { - text-align: center; - padding: 80px 20px; - color: rgba(255, 255, 255, 0.5); -} - -.stats-empty-icon { - font-size: 48px; - margin-bottom: 16px; -} - -.stats-empty h3 { - font-size: 1.2em; - color: rgba(255, 255, 255, 0.7); - margin-bottom: 8px; -} - -.stats-empty p { - font-size: 0.88em; - max-width: 400px; - margin: 0 auto; - line-height: 1.5; -} - -/* Mobile responsive */ -@media (max-width: 768px) { - .stats-container { - margin: 10px; - padding: 16px; - } - .stats-overview { - grid-template-columns: repeat(2, 1fr); - } - .stats-main-grid { - grid-template-columns: 1fr; - } - .stats-health-grid { - grid-template-columns: 1fr 1fr; - } - .stats-genre-chart-container { - flex-direction: column; - } - .stats-two-col { - grid-template-columns: 1fr; - } - .stats-genre-chart-container canvas { - width: 150px !important; - height: 150px !important; - } - .stats-header { - flex-direction: column; - align-items: flex-start; - padding: 16px; - } - .stats-header-controls { - flex-direction: column; - align-items: flex-start; - gap: 10px; - width: 100%; - } - .stats-card-value { - font-size: 1.5em; - } -} - /* ============================================================================ IMPORT PAGE ============================================================================ */ diff --git a/webui/tests/issues.smoke.spec.ts b/webui/tests/shell-routes.smoke.spec.ts similarity index 80% rename from webui/tests/issues.smoke.spec.ts rename to webui/tests/shell-routes.smoke.spec.ts index ebac18f5..f2854c0c 100644 --- a/webui/tests/issues.smoke.spec.ts +++ b/webui/tests/shell-routes.smoke.spec.ts @@ -1,6 +1,11 @@ import { expect, test, type Page } from '@playwright/test'; -import { shellRouteManifest, type ShellPageId } from '../src/platform/shell/route-manifest'; +import { + getShellRouteByPageId, + resolveShellNavPage, + shellRouteManifest, + type ShellPageId, +} from '../src/platform/shell/route-manifest'; async function selectProfile(page: Page, baseURL: string, profileId = 1) { const response = await page.request.post(new URL('/api/profiles/select', baseURL).toString(), { @@ -11,29 +16,24 @@ async function selectProfile(page: Page, baseURL: string, profileId = 1) { } async function waitForShellRoute(page: Page, pageId: string) { - if (pageId === 'issues') { + const route = getShellRouteByPageId(pageId as ShellPageId); + + if (route?.kind === 'react') { await expect .poll(async () => page.evaluate(() => document.querySelector('.page.active')?.id ?? ''), { timeout: 15000, }) .toBe('webui-react-root'); - await expect(page.getByTestId('issues-board')).toBeVisible({ timeout: 15000 }); return; } await expect - .poll(async () => - page.evaluate(() => document.querySelector('.page.active')?.id ?? ''), - ) + .poll(async () => page.evaluate(() => document.querySelector('.page.active')?.id ?? '')) .toBe(`${pageId}-page`); } function getExpectedNavPage(pageId: ShellPageId): string { - if (pageId === 'artist-detail') { - return 'library'; - } - - return pageId; + return resolveShellNavPage(pageId); } async function expectNavHighlight(page: Page, pageId: ShellPageId) { @@ -51,15 +51,19 @@ async function verifyIssuesRoute(page: Page) { await expect(page.getByTestId('issues-board')).toContainText('Issues'); } -function expectedUrlPattern(path: string): RegExp { - if (path === '/issues') { +function expectedUrlPattern(path: string, pageId: ShellPageId): RegExp { + if (pageId === 'issues') { return /\/issues(?:\?status=open&category=all)?$/; } + if (pageId === 'stats') { + return /\/stats(?:\?range=7d)?$/; + } + return new RegExp(`${path.replace('/', '\\/')}$`); } -test('direct load activates all known top-level routes', async ({ page, baseURL }) => { +test('direct load activates all known shell routes', async ({ page, baseURL }) => { if (!baseURL) { test.skip(); return; @@ -70,9 +74,11 @@ test('direct load activates all known top-level routes', async ({ page, baseURL for (const route of shellRouteManifest) { const routePage = await page.context().newPage(); try { - await routePage.goto(new URL(route.path, baseURL).toString(), { waitUntil: 'domcontentloaded' }); + await routePage.goto(new URL(route.path, baseURL).toString(), { + waitUntil: 'domcontentloaded', + }); await waitForShellRoute(routePage, route.pageId); - await expect(routePage).toHaveURL(expectedUrlPattern(route.path)); + await expect(routePage).toHaveURL(expectedUrlPattern(route.path, route.pageId)); await expectNavHighlight(routePage, route.pageId); if (route.pageId === 'issues') { @@ -84,7 +90,7 @@ test('direct load activates all known top-level routes', async ({ page, baseURL } }); -test('browser history restores top-level routes', async ({ page, baseURL }) => { +test('browser history restores shell routes', async ({ page, baseURL }) => { if (!baseURL) { test.skip(); return; @@ -101,7 +107,9 @@ test('browser history restores top-level routes', async ({ page, baseURL }) => { await page.getByRole('link', { name: 'Issues' }).click(); await expect .poll(async () => - page.evaluate(() => (window as typeof window & { __spaNavMarker?: string }).__spaNavMarker ?? null), + page.evaluate( + () => (window as typeof window & { __spaNavMarker?: string }).__spaNavMarker ?? null, + ), ) .toBe('persist'); await waitForShellRoute(page, 'issues'); diff --git a/webui/vitest.setup.ts b/webui/vitest.setup.ts index 2b86553f..c99c9e8d 100644 --- a/webui/vitest.setup.ts +++ b/webui/vitest.setup.ts @@ -1,12 +1,20 @@ import '@testing-library/jest-dom/vitest'; -import { afterAll, afterEach, beforeAll, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, vi } from 'vitest'; -import { server } from './src/test/msw'; +import { HttpResponse, http, server } from './src/test/msw'; beforeAll(() => { server.listen({ onUnhandledRequest: 'error' }); }); +beforeEach(() => { + server.use( + http.get('/status', () => + HttpResponse.json({ media_server: { type: 'plex', connected: true } }), + ), + ); +}); + afterEach(() => { server.resetHandlers(); });