Merge pull request #590 from kettui/feat/react-migration-stats

Migrate stats page to React
This commit is contained in:
BoulderBadgeDad 2026-05-23 13:15:57 -07:00 committed by GitHub
commit e6c4cc3d87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 3898 additions and 2099 deletions

View file

@ -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/<route>/`.

View file

@ -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:
- `<route>-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

View file

@ -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.

View file

@ -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

View file

@ -6184,147 +6184,6 @@
</div>
</div>
<!-- Stats Page -->
<div class="page" id="stats-page">
<div class="stats-container">
<div class="stats-header">
<div class="stats-header-title">
<img src="/static/trans2.png" alt="Stats" class="page-header-icon">
<h1 class="header-title">Listening Stats</h1>
</div>
<div class="stats-header-controls">
<div class="stats-time-range" id="stats-time-range">
<button class="stats-range-btn active" data-range="7d">7 Days</button>
<button class="stats-range-btn" data-range="30d">30 Days</button>
<button class="stats-range-btn" data-range="12m">12 Months</button>
<button class="stats-range-btn" data-range="all">All Time</button>
</div>
<div class="stats-sync-controls">
<span class="stats-last-synced" id="stats-last-synced"></span>
<button class="stats-sync-btn" id="stats-sync-btn" onclick="triggerStatsSync()" title="Sync now"><span class="stats-sync-icon"></span></button>
</div>
</div>
</div>
<!-- Overview Cards -->
<div class="stats-overview" id="stats-overview">
<div class="stats-card">
<div class="stats-card-value" id="stats-total-plays">0</div>
<div class="stats-card-label">Total Plays</div>
</div>
<div class="stats-card">
<div class="stats-card-value" id="stats-listening-time">0h</div>
<div class="stats-card-label">Listening Time</div>
</div>
<div class="stats-card">
<div class="stats-card-value" id="stats-unique-artists">0</div>
<div class="stats-card-label">Artists</div>
</div>
<div class="stats-card">
<div class="stats-card-value" id="stats-unique-albums">0</div>
<div class="stats-card-label">Albums</div>
</div>
<div class="stats-card">
<div class="stats-card-value" id="stats-unique-tracks">0</div>
<div class="stats-card-label">Tracks</div>
</div>
</div>
<!-- Main Grid: Charts + Rankings -->
<div class="stats-main-grid">
<div class="stats-left-col">
<div class="stats-section-card">
<div class="stats-section-title">Listening Activity</div>
<div style="position:relative;height:220px;">
<canvas id="stats-timeline-chart"></canvas>
</div>
</div>
<div class="stats-section-card">
<div class="stats-section-title">Genre Breakdown</div>
<div class="stats-genre-chart-container">
<canvas id="stats-genre-chart"></canvas>
<div class="stats-genre-legend" id="stats-genre-legend"></div>
</div>
</div>
<div class="stats-section-card">
<div class="stats-section-title">Recently Played</div>
<div class="stats-recent-list" id="stats-recent-plays"></div>
</div>
</div>
<div class="stats-right-col">
<div class="stats-section-card">
<div class="stats-section-title">Top Artists</div>
<div class="stats-top-artists-visual" id="stats-top-artists-visual"></div>
<div class="stats-ranked-list" id="stats-top-artists"></div>
</div>
<div class="stats-section-card">
<div class="stats-section-title">Top Albums</div>
<div class="stats-ranked-list" id="stats-top-albums"></div>
</div>
<div class="stats-section-card">
<div class="stats-section-title">Top Tracks</div>
<div class="stats-ranked-list" id="stats-top-tracks"></div>
</div>
</div>
</div>
<!-- Library Health -->
<div class="stats-section-card stats-full-width">
<div class="stats-section-title">Library Health</div>
<div class="stats-health-grid" id="stats-library-health">
<div class="stats-health-item">
<div class="stats-health-label">Format Breakdown</div>
<div class="stats-format-bar" id="stats-format-bar"></div>
</div>
<div class="stats-health-item">
<div class="stats-health-value" id="stats-unplayed">0</div>
<div class="stats-health-label">Unplayed Tracks</div>
</div>
<div class="stats-health-item">
<div class="stats-health-value" id="stats-total-duration">0h</div>
<div class="stats-health-label">Total Duration</div>
</div>
<div class="stats-health-item">
<div class="stats-health-value" id="stats-total-tracks-count">0</div>
<div class="stats-health-label">Total Tracks</div>
</div>
</div>
<div class="stats-enrichment" id="stats-enrichment-coverage"></div>
</div>
<!-- Library Disk Usage -->
<div class="stats-section-card stats-full-width">
<div class="stats-section-title">Library Disk Usage</div>
<div class="stats-disk-usage-wrap" id="stats-disk-usage-wrap">
<div class="stats-disk-total-row">
<div class="stats-disk-total-value" id="stats-disk-total-value"></div>
<div class="stats-disk-total-meta" id="stats-disk-total-meta">Run a Deep Scan to populate</div>
</div>
<div class="stats-disk-formats" id="stats-disk-formats"></div>
</div>
</div>
<!-- Database Storage -->
<div class="stats-section-card stats-full-width">
<div class="stats-section-title">Database Storage</div>
<div class="stats-db-storage-wrap">
<div class="stats-db-chart-container">
<canvas id="stats-db-storage-chart"></canvas>
<div class="stats-db-total" id="stats-db-total"></div>
</div>
<div class="stats-db-legend" id="stats-db-legend"></div>
</div>
</div>
<!-- Empty State -->
<div class="stats-empty hidden" id="stats-empty">
<div class="stats-empty-icon">📊</div>
<h3>No Listening Data Yet</h3>
<p>Enable "Listening Stats" in Settings to start tracking your listening activity from your media server.</p>
</div>
</div>
</div>
<!-- Import Page -->
<div class="page" id="import-page">
<div class="import-page-container">
@ -8142,7 +8001,6 @@
</div>
<script src="{{ url_for('static', filename='vendor/socket.io.min.js', v=static_v) }}"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
{{ vite_assets('body')|safe }}
<script src="{{ url_for('static', filename='setup-wizard.js', v=static_v) }}"></script>
<!-- Split modules (was: script.js) — core.js must load first, init.js before shell-bridge.js -->

372
webui/package-lock.json generated
View file

@ -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",

View file

@ -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": {

View file

@ -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<T>(promise: ResponsePromise<T>): Promise<T> {
try {
return await promise.json<T>();

View file

@ -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> = {}): 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(() => {

View file

@ -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<typeof window.SoulSyncWebShellBridge>;
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<typeof window.SoulSyncWebShellBridge>;
});
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();
});
});

View file

@ -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<typeof window.SoulSyncWebShellBridge>;
@ -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 });

View file

@ -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<void>;
startStream: (searchResult: Record<string, unknown>) => void | Promise<void>;
showLoadingOverlay: (message?: string) => void;
hideLoadingOverlay: () => void;
};
}
}

View file

@ -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();

View file

@ -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');
});
});

View file

@ -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;
}

View file

@ -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 },
});
});
});

View file

@ -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<ShellStatusPayload> {
return await readJson<ShellStatusPayload>(shellClient.get('status'));
}
export function shellStatusQueryOptions() {
return queryOptions({
queryKey: ['shell', 'status'] as const,
queryFn: fetchShellStatus,
staleTime: 30_000,
retry: false,
});
}

View file

@ -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,
}

View file

@ -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<AppRouterContext>()({
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: () => (
<>

View file

@ -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> = {}): 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<typeof vi.fn>;
const cancelSimilarArtistsLoad = window.SoulSyncWebShellBridge
?.cancelSimilarArtistsLoad as ReturnType<typeof vi.fn>;
cancelSimilarArtistsLoad.mockClear();
unmount();

View file

@ -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> = {}): 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 });

View file

@ -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(<AppRouterProvider router={router} queryClient={queryClient} />),
};
}
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'));
});
});

View file

@ -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',
});
});
});

View file

@ -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<StatsCachedPayload> {
try {
const payload = await readJson<StatsCachedPayload>(
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<ListeningStatsStatus> {
return await readJson<ListeningStatsStatus>(apiClient.get('listening-stats/status'));
}
export async function fetchStatsDbStorage(): Promise<StatsDbStoragePayload> {
const payload = await readJson<StatsDbStoragePayload>(apiClient.get('stats/db-storage'));
if (!payload.success) {
throw new Error(payload.error || 'Failed to load database storage');
}
return payload;
}
export async function fetchStatsLibraryDiskUsage(): Promise<StatsLibraryDiskUsagePayload> {
const payload = await readJson<StatsLibraryDiskUsagePayload>(
apiClient.get('stats/library-disk-usage'),
);
if (!payload.success) {
throw new Error(payload.error || 'Failed to load library disk usage');
}
return payload;
}
export async function triggerListeningStatsSync(): Promise<void> {
const payload = await readJson<{ success: boolean; error?: string }>(
apiClient.post('listening-stats/sync'),
);
if (!payload.success) {
throw new Error(payload.error || 'Sync failed');
}
}
export async function resolveStatsTrack(
title: string,
artist: string,
): Promise<StatsResolveTrackPayload['track'] | null> {
const payload = await readJson<StatsResolveTrackPayload>(
apiClient.post('stats/resolve-track', {
json: { title, artist },
}),
);
if (!payload.success) return null;
return payload.track ?? null;
}
export async function streamStatsTrack(
title: string,
artist: string,
album: string,
): Promise<Record<string, unknown> | null> {
const payload = await readJson<StatsStreamTrackPayload>(
apiClient.post('enhanced-search/stream-track', {
json: {
track_name: title,
artist_name: artist,
album_name: album,
duration_ms: 0,
},
}),
);
if (!payload.success) {
throw new Error(payload.error || 'Track not found in library or any source');
}
return payload.result ?? null;
}
export function statsCachedQueryOptions(range: StatsRange) {
return queryOptions({
queryKey: [...STATS_QUERY_KEY, 'cached', range],
queryFn: () => fetchStatsCached(range),
});
}
export function listeningStatsStatusQueryOptions() {
return queryOptions({
queryKey: [...STATS_QUERY_KEY, 'listening-status'],
queryFn: fetchListeningStatsStatus,
});
}
export function statsDbStorageQueryOptions() {
return queryOptions({
queryKey: [...STATS_QUERY_KEY, 'db-storage'],
queryFn: fetchStatsDbStorage,
});
}
export function statsLibraryDiskUsageQueryOptions() {
return queryOptions({
queryKey: [...STATS_QUERY_KEY, 'library-disk-usage'],
queryFn: fetchStatsLibraryDiskUsage,
});
}
export function invalidateStatsQueries(queryClient: QueryClient) {
return queryClient.invalidateQueries({ queryKey: STATS_QUERY_KEY });
}

View file

@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import {
formatBytes,
formatDbStorageValue,
formatListeningTime,
formatRelativePlayedAt,
getTopArtistBubbles,
groupDbStorageTables,
hasStatsData,
} from './-stats.helpers';
import { statsSearchSchema } from './-stats.types';
describe('statsSearchSchema', () => {
it('falls back to 7d for unknown ranges', () => {
expect(statsSearchSchema.parse({ range: 'bad' })).toEqual({ range: '7d' });
});
it('keeps known ranges', () => {
expect(statsSearchSchema.parse({ range: '12m' })).toEqual({ range: '12m' });
});
});
describe('stats helpers', () => {
it('detects whether the page has listening data', () => {
expect(hasStatsData({ total_plays: 0 })).toBe(false);
expect(hasStatsData({ total_plays: 4 })).toBe(true);
});
it('formats listening time and bytes', () => {
expect(formatListeningTime(3_900_000)).toBe('1h 5m');
expect(formatBytes(2_097_152)).toBe('2.00 MB');
});
it('formats relative recent-play times', () => {
const now = new Date('2026-05-14T12:00:00.000Z').getTime();
expect(formatRelativePlayedAt('2026-05-14T11:15:00.000Z', now)).toBe('45m ago');
expect(formatRelativePlayedAt('2026-05-14T08:00:00.000Z', now)).toBe('4h ago');
});
it('groups db storage rows into Other after the top eight', () => {
const grouped = groupDbStorageTables(
Array.from({ length: 10 }, (_, index) => ({
name: `table_${index + 1}`,
size: index + 1,
})),
);
expect(grouped).toHaveLength(9);
expect(grouped.at(-1)).toEqual({ name: 'Other', size: 19 });
});
it('formats db storage by method', () => {
expect(formatDbStorageValue(2_097_152, 'dbstat')).toBe('2.0 MB');
expect(formatDbStorageValue(1240, 'rowcount')).toBe('1,240 rows');
});
it('shapes top artist bubbles from the highest-play artist', () => {
const bubbles = getTopArtistBubbles([
{ name: 'A', play_count: 20 },
{ name: 'B', play_count: 10 },
]);
expect(bubbles[0]?.percent).toBe(100);
expect(bubbles[1]?.percent).toBe(50);
});
});

View file

@ -0,0 +1,162 @@
import type {
StatsArtistRow,
StatsCachedPayload,
StatsDbStorageTable,
StatsHealth,
StatsOverview,
StatsRange,
} from './-stats.types';
export const EMPTY_STATS_OVERVIEW: StatsOverview = {
total_plays: 0,
total_time_ms: 0,
unique_artists: 0,
unique_albums: 0,
unique_tracks: 0,
};
export const EMPTY_STATS_PAYLOAD: Required<
Pick<
StatsCachedPayload,
'overview' | 'top_artists' | 'top_albums' | 'top_tracks' | 'timeline' | 'genres' | 'recent'
>
> & { health: StatsHealth } = {
overview: EMPTY_STATS_OVERVIEW,
top_artists: [],
top_albums: [],
top_tracks: [],
timeline: [],
genres: [],
recent: [],
health: {},
};
export const STATS_GENRE_COLORS = [
'#1db954',
'#1ed760',
'#4ade80',
'#7c3aed',
'#a855f7',
'#ec4899',
'#f43f5e',
'#f97316',
'#eab308',
'#06b6d4',
] as const;
export const STATS_DB_STORAGE_COLORS = [
'#3b82f6',
'#f97316',
'#a855f7',
'#14b8a6',
'#eab308',
'#ec4899',
'#6366f1',
'#22c55e',
'#555555',
] as const;
export const STATS_ENRICHMENT_SERVICES = [
{ key: 'spotify', label: 'Spotify', color: '#1db954' },
{ key: 'musicbrainz', label: 'MusicBrainz', color: '#ba55d3' },
{ key: 'deezer', label: 'Deezer', color: '#a238ff' },
{ key: 'lastfm', label: 'Last.fm', color: '#d51007' },
{ key: 'itunes', label: 'iTunes', color: '#fc3c44' },
{ key: 'audiodb', label: 'AudioDB', color: '#1a9fff' },
{ key: 'genius', label: 'Genius', color: '#ffff64' },
{ key: 'tidal', label: 'Tidal', color: '#00ffff' },
{ key: 'qobuz', label: 'Qobuz', color: '#4285f4' },
] as const;
export function getStatsRangeLabel(range: StatsRange): string {
switch (range) {
case '7d':
return '7 Days';
case '30d':
return '30 Days';
case '12m':
return '12 Months';
case 'all':
return 'All Time';
}
}
export function hasStatsData(overview: Partial<StatsOverview> | undefined): boolean {
return (overview?.total_plays ?? 0) > 0;
}
export function formatCompactNumber(value: number | null | undefined): string {
if (!value) return '0';
if (value >= 1_000_000) return `${stripTrailingZero((value / 1_000_000).toFixed(1))}M`;
if (value >= 1_000) return `${stripTrailingZero((value / 1_000).toFixed(1))}K`;
return value.toLocaleString();
}
export function formatListeningTime(totalMs: number | null | undefined): string {
if (!totalMs) return '0h';
const hours = Math.floor(totalMs / 3_600_000);
const minutes = Math.floor((totalMs % 3_600_000) / 60_000);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}
export function formatTotalDuration(totalMs: number | null | undefined): string {
if (!totalMs) return '0h';
return `${Math.floor(totalMs / 3_600_000)}h`;
}
export function formatRelativePlayedAt(
dateStr: string | null | undefined,
now = Date.now(),
): string {
if (!dateStr) return '';
const diff = now - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60_000);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return `${Math.floor(days / 30)}mo ago`;
}
export function formatBytes(value: number | null | undefined): string {
if (!value || value <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let index = 0;
let next = value;
while (next >= 1024 && index < units.length - 1) {
next /= 1024;
index += 1;
}
return `${next.toFixed(next < 10 ? 2 : 1)} ${units[index]}`;
}
export function groupDbStorageTables(tables: StatsDbStorageTable[]): StatsDbStorageTable[] {
const top = tables.slice(0, 8);
const rest = tables.slice(8);
const restSize = rest.reduce((sum, table) => sum + table.size, 0);
return restSize > 0 ? [...top, { name: 'Other', size: restSize }] : top;
}
export function formatDbStorageValue(size: number, method: string | null | undefined): string {
if (method === 'dbstat') {
if (size > 1_048_576) return `${(size / 1_048_576).toFixed(1)} MB`;
return `${Math.round(size / 1024)} KB`;
}
return `${size.toLocaleString()} rows`;
}
export function getTopArtistBubbles(artists: StatsArtistRow[]) {
const top = artists.slice(0, 5);
const maxPlays = top[0]?.play_count || 1;
return top.map((artist, index) => ({
artist,
percent: Math.round((artist.play_count / maxPlays) * 100),
size: 44 + (4 - index) * 6,
}));
}
function stripTrailingZero(value: string): string {
return value.replace(/\.0$/, '');
}

View file

@ -0,0 +1,148 @@
import { z } from 'zod';
export const STATS_RANGE_VALUES = ['7d', '30d', '12m', 'all'] as const;
export type StatsRange = (typeof STATS_RANGE_VALUES)[number];
export const statsSearchSchema = z.object({
range: z.enum(STATS_RANGE_VALUES).default('7d').catch('7d'),
});
export type StatsSearch = z.infer<typeof statsSearchSchema>;
export interface StatsOverview {
total_plays: number;
total_time_ms: number;
unique_artists: number;
unique_albums: number;
unique_tracks: number;
}
export interface StatsArtistRow {
id?: string | number | null;
name: string;
image_url?: string | null;
play_count: number;
global_listeners?: number | null;
soul_id?: string | null;
}
export interface StatsAlbumRow {
name: string;
artist?: string | null;
artist_id?: string | number | null;
image_url?: string | null;
play_count: number;
}
export interface StatsTrackRow {
name: string;
artist?: string | null;
artist_id?: string | number | null;
album?: string | null;
image_url?: string | null;
play_count: number;
}
export interface StatsTimelineRow {
date: string;
plays: number;
}
export interface StatsGenreRow {
genre: string;
play_count: number;
percentage: number;
}
export interface StatsEnrichmentCoverage {
spotify?: number;
musicbrainz?: number;
deezer?: number;
lastfm?: number;
itunes?: number;
audiodb?: number;
genius?: number;
tidal?: number;
qobuz?: number;
}
export interface StatsHealth {
total_tracks?: number;
unplayed_count?: number;
unplayed_percentage?: number;
total_duration_ms?: number;
format_breakdown?: Record<string, number>;
enrichment_coverage?: StatsEnrichmentCoverage;
}
export interface StatsRecentTrack {
title: string;
artist?: string | null;
album?: string | null;
played_at?: string | null;
}
export interface StatsCachedPayload {
success: boolean;
overview?: Partial<StatsOverview>;
top_artists?: StatsArtistRow[];
top_albums?: StatsAlbumRow[];
top_tracks?: StatsTrackRow[];
timeline?: StatsTimelineRow[];
genres?: StatsGenreRow[];
recent?: StatsRecentTrack[];
health?: StatsHealth;
error?: string;
}
export interface ListeningStatsStatus {
stats?: {
last_poll?: string | null;
};
error?: string;
}
export interface StatsDbStorageTable {
name: string;
size: number;
}
export interface StatsDbStoragePayload {
success: boolean;
tables?: StatsDbStorageTable[];
total_file_size?: number;
method?: string;
error?: string;
}
export interface StatsLibraryDiskUsagePayload {
success: boolean;
has_data?: boolean;
total_bytes?: number;
tracks_with_size?: number;
tracks_without_size?: number;
by_format?: Record<string, number>;
error?: string;
}
export interface StatsResolveTrackPayload {
success: boolean;
error?: string;
track?: {
id: string | number;
title: string;
file_path: string;
bitrate?: string | number | null;
artist_id?: string | number | null;
album_id?: string | number | null;
image_url?: string | null;
album_title?: string | null;
artist_name?: string | null;
};
}
export interface StatsStreamTrackPayload {
success: boolean;
error?: string;
result?: Record<string, unknown>;
}

View file

@ -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;
}
}

View file

@ -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<number | null>(null);
const [syncing, setSyncing] = useState(false);
const cachedStatsQuery = useQuery({
...statsCachedQueryOptions(range),
});
const listeningStatusQuery = useQuery({
...listeningStatsStatusQueryOptions(),
});
const dbStorageQuery = useQuery({
...statsDbStorageQueryOptions(),
});
const diskUsageQuery = useQuery({
...statsLibraryDiskUsageQueryOptions(),
});
useEffect(() => {
return () => {
if (syncTimeoutRef.current) {
window.clearTimeout(syncTimeoutRef.current);
}
};
}, []);
const syncMutation = useMutation({
mutationFn: triggerListeningStatsSync,
onMutate: () => {
setSyncing(true);
},
onSuccess: () => {
window.showToast?.('Syncing listening data...', 'info');
syncTimeoutRef.current = window.setTimeout(() => {
void invalidateStatsQueries(queryClient);
setSyncing(false);
window.showToast?.('Listening stats updated', 'success');
}, 5000);
},
onError: (error) => {
setSyncing(false);
window.showToast?.(error instanceof Error ? error.message : 'Sync failed', 'error');
},
});
const cachedStats = cachedStatsQuery.data;
const overview = cachedStats?.overview ?? EMPTY_STATS_OVERVIEW;
const hasData = hasStatsData(overview);
const lastSynced = listeningStatusQuery.data?.stats?.last_poll ?? null;
const 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 (
<div id="stats-container" className={styles.statsContainer} data-testid="stats-page">
<header className={styles.statsHeader}>
<div className={styles.statsHeaderTitle}>
<img src="/static/trans2.png" alt="Stats" className={styles.headerIcon} />
<h1 className={styles.headerTitle}>Listening Stats</h1>
</div>
<div className={styles.statsHeaderControls}>
<div
id="stats-time-range"
className={styles.statsTimeRange}
role="tablist"
aria-label="Listening stats range"
>
{(['7d', '30d', '12m', 'all'] as const).map((option) => (
<button
key={option}
type="button"
className={`${styles.statsRangeButton} ${
range === option ? styles.statsRangeButtonActive : ''
}`}
onClick={() => onRangeChange(option)}
>
{getStatsRangeLabel(option)}
</button>
))}
</div>
<div className={styles.statsSyncControls}>
{isStandalone ? (
<span
className={styles.statsStandaloneNotice}
role="note"
title="SoulSync standalone does not use an external media server, so manual listening stats sync is unavailable."
>
Standalone mode: manual sync unavailable
</span>
) : (
<>
<span className={styles.statsLastSynced}>
{lastSynced ? `Last synced: ${lastSynced}` : 'Not synced yet'}
</span>
<button
id="stats-sync-btn"
type="button"
className={`${styles.statsSyncButton} ${syncing ? styles.statsSyncButtonSyncing : ''}`}
onClick={() => syncMutation.mutate()}
disabled={syncing}
aria-label="Sync listening stats"
title="Sync now"
>
<span aria-hidden="true"></span>
</button>
</>
)}
</div>
</div>
</header>
{cachedStatsQuery.isPending ? (
<SectionLoadingState />
) : cachedStatsQuery.error ? (
<SectionErrorState message={getErrorMessage(cachedStatsQuery.error)} />
) : hasData ? (
<>
<OverviewCards overview={overview} />
<div className={styles.statsMainGrid}>
<div className={styles.statsLeftCol}>
<StatsSectionCard title="Listening Activity">
<div id="stats-timeline-chart" className={styles.chartContainer}>
<StatsActivityChart timeline={cachedStats?.timeline ?? []} />
</div>
</StatsSectionCard>
<StatsSectionCard title="Genre Breakdown">
<div className={styles.statsGenreChartContainer}>
<div id="stats-genre-chart" className={styles.statsGenreChartWrap}>
<StatsGenreChart genres={cachedStats?.genres ?? []} />
</div>
<StatsGenreLegend genres={cachedStats?.genres ?? []} />
</div>
</StatsSectionCard>
<StatsSectionCard title="Recently Played">
<StatsRecentPlays
tracks={cachedStats?.recent ?? []}
onPlay={(track) => playStatsTrack(bridge, track)}
/>
</StatsSectionCard>
</div>
<div className={styles.statsRightCol}>
<StatsSectionCard title="Top Artists">
<TopArtistsVisual
artists={cachedStats?.top_artists ?? []}
onArtistSelect={(artistId, artistName) => openArtistDetail(artistId, artistName)}
/>
<StatsRankedArtists
artists={cachedStats?.top_artists ?? []}
onArtistSelect={(artistId, artistName) => openArtistDetail(artistId, artistName)}
/>
</StatsSectionCard>
<StatsSectionCard title="Top Albums">
<StatsRankedAlbums
albums={cachedStats?.top_albums ?? []}
onArtistSelect={(artistId, artistName) => openArtistDetail(artistId, artistName)}
/>
</StatsSectionCard>
<StatsSectionCard title="Top Tracks">
<StatsRankedTracks
tracks={cachedStats?.top_tracks ?? []}
onArtistSelect={(artistId, artistName) => openArtistDetail(artistId, artistName)}
onPlay={(track) => playStatsTrack(bridge, track)}
/>
</StatsSectionCard>
</div>
</div>
<StatsSectionCard title="Library Health" fullWidth>
<StatsLibraryHealth health={cachedStats?.health ?? {}} />
</StatsSectionCard>
<StatsSectionCard title="Library Disk Usage" fullWidth>
<StatsDiskUsage payload={diskUsageQuery.data} error={diskUsageQuery.error} />
</StatsSectionCard>
<StatsSectionCard title="Database Storage" fullWidth>
<StatsDbStorage payload={dbStorageQuery.data} error={dbStorageQuery.error} />
</StatsSectionCard>
</>
) : (
<StatsEmptyState />
)}
</div>
);
}
function OverviewCards({
overview,
}: {
overview: Partial<{
total_plays: number;
total_time_ms: number;
unique_artists: number;
unique_albums: number;
unique_tracks: number;
}>;
}) {
const cards = [
{ label: 'Total Plays', value: formatCompactNumber(overview.total_plays) },
{ label: 'Listening Time', value: formatListeningTime(overview.total_time_ms) },
{ label: 'Artists', value: formatCompactNumber(overview.unique_artists) },
{ label: 'Albums', value: formatCompactNumber(overview.unique_albums) },
{ label: 'Tracks', value: formatCompactNumber(overview.unique_tracks) },
];
return (
<div id="stats-overview" className={styles.statsOverview}>
{cards.map((card) => (
<div key={card.label} className={styles.statsCard}>
<div className={styles.statsCardValue}>{card.value}</div>
<div className={styles.statsCardLabel}>{card.label}</div>
</div>
))}
</div>
);
}
function StatsSectionCard({
children,
fullWidth = false,
title,
}: {
children: ReactNode;
fullWidth?: boolean;
title: string;
}) {
return (
<section className={`${styles.statsSectionCard} ${fullWidth ? styles.statsFullWidth : ''}`}>
<div className={styles.statsSectionTitle}>{title}</div>
{children}
</section>
);
}
function StatsActivityChart({ timeline }: { timeline: Array<{ date: string; plays: number }> }) {
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={timeline} margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
<CartesianGrid stroke="rgba(255,255,255,0.04)" vertical={false} />
<XAxis
dataKey="date"
tick={{ fill: 'rgba(255,255,255,0.3)', fontSize: 10 }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fill: 'rgba(255,255,255,0.3)', fontSize: 10 }}
axisLine={false}
tickLine={false}
allowDecimals={false}
width={28}
/>
<Tooltip
contentStyle={STATS_TOOLTIP_STYLE}
wrapperStyle={STATS_TOOLTIP_WRAPPER_STYLE}
cursor={STATS_CHART_CURSOR}
/>
<Bar
dataKey="plays"
radius={[4, 4, 0, 0]}
fill="rgba(var(--accent-rgb), 0.55)"
stroke="rgba(var(--accent-rgb), 0.8)"
/>
</BarChart>
</ResponsiveContainer>
);
}
function StatsGenreChart({
genres,
}: {
genres: Array<{ genre: string; play_count: number; percentage: number }>;
}) {
const topGenres = genres.slice(0, 10).map((genre, index) => ({
...genre,
fill: STATS_GENRE_COLORS[index % STATS_GENRE_COLORS.length],
}));
return (
<ResponsiveContainer width={180} height={180}>
<PieChart>
<Pie
data={topGenres}
dataKey="play_count"
nameKey="genre"
innerRadius={52}
outerRadius={84}
paddingAngle={2}
stroke="transparent"
/>
<Tooltip contentStyle={STATS_TOOLTIP_STYLE} wrapperStyle={STATS_TOOLTIP_WRAPPER_STYLE} />
</PieChart>
</ResponsiveContainer>
);
}
function StatsGenreLegend({
genres,
}: {
genres: Array<{ genre: string; play_count: number; percentage: number }>;
}) {
const topGenres = genres.slice(0, 10);
return (
<div className={styles.statsGenreLegend}>
{topGenres.map((genre, index) => (
<div key={genre.genre} className={styles.statsGenreLegendItem}>
<span
className={styles.statsGenreDot}
style={{ background: STATS_GENRE_COLORS[index % STATS_GENRE_COLORS.length] }}
/>
<span>{genre.genre}</span>
<span className={styles.statsGenrePct}>{genre.percentage}%</span>
</div>
))}
</div>
);
}
function TopArtistsVisual({
artists,
onArtistSelect,
}: {
artists: StatsArtistRow[];
onArtistSelect: (artistId: string | number, artistName: string) => void;
}) {
const topArtists = getTopArtistBubbles(artists);
if (topArtists.length === 0) return null;
return (
<div className={styles.statsTopArtistsVisual}>
<div className={styles.statsArtistBubbles}>
{topArtists.map(({ artist, percent, size }) => {
const isClickable = artist.id !== null && artist.id !== undefined;
return (
<button
key={`${artist.name}-${artist.id ?? 'unknown'}`}
type="button"
className={styles.statsArtistBubble}
onClick={() => {
if (isClickable) {
onArtistSelect(artist.id as string | number, artist.name);
}
}}
disabled={!isClickable}
>
<div
className={styles.statsBubbleImage}
style={{
width: size,
height: size,
backgroundImage: artist.image_url ? `url(${artist.image_url})` : undefined,
}}
>
{!artist.image_url ? (
<span className={styles.statsBubbleInitial}>{artist.name[0] ?? '?'}</span>
) : null}
</div>
<div className={styles.statsBubbleBarContainer}>
<div className={styles.statsBubbleBar} style={{ width: `${percent}%` }} />
</div>
<div className={styles.statsBubbleName}>{artist.name}</div>
<div className={styles.statsBubbleCount}>
{formatCompactNumber(artist.play_count)}
</div>
</button>
);
})}
</div>
</div>
);
}
function StatsRankedArtists({
artists,
onArtistSelect,
}: {
artists: StatsArtistRow[];
onArtistSelect: (artistId: string | number, artistName: string) => void;
}) {
return (
<div id="stats-top-artists" className={styles.statsRankedList}>
{artists.length === 0 ? <EmptyListState message="No data yet" /> : null}
{artists.map((artist, index) => (
<div key={`${artist.name}-${artist.id ?? index}`} className={styles.statsRankedItem}>
<span className={styles.statsRankedNum}>{index + 1}</span>
{artist.image_url ? (
<img className={styles.statsRankedImage} src={artist.image_url} alt="" />
) : (
<div className={styles.statsRankedImageFallback} />
)}
<div className={styles.statsRankedInfo}>
<div className={styles.statsRankedName}>
{artist.id ? (
<button
type="button"
className={styles.statsArtistLink}
onClick={() => onArtistSelect(artist.id as string | number, artist.name)}
>
{artist.name}
</button>
) : (
artist.name
)}
{artist.soul_id && !String(artist.soul_id).startsWith('soul_unnamed_') ? (
<img src="/static/trans2.png" className={styles.statsSoulIdBadge} alt="SoulID" />
) : null}
</div>
<div className={styles.statsRankedMeta}>
{artist.global_listeners
? `${formatCompactNumber(artist.global_listeners)} global listeners`
: ''}
</div>
</div>
<span className={styles.statsRankedCount}>
{formatCompactNumber(artist.play_count)} plays
</span>
</div>
))}
</div>
);
}
function StatsRankedAlbums({
albums,
onArtistSelect,
}: {
albums: StatsAlbumRow[];
onArtistSelect: (artistId: string | number, artistName: string) => void;
}) {
return (
<div id="stats-top-albums" className={styles.statsRankedList}>
{albums.length === 0 ? <EmptyListState message="No data yet" /> : null}
{albums.map((album, index) => (
<div key={`${album.name}-${index}`} className={styles.statsRankedItem}>
<span className={styles.statsRankedNum}>{index + 1}</span>
{album.image_url ? (
<img className={styles.statsRankedImage} src={album.image_url} alt="" />
) : (
<div className={styles.statsRankedImageFallback} />
)}
<div className={styles.statsRankedInfo}>
<div className={styles.statsRankedName}>{album.name}</div>
<div className={styles.statsRankedMeta}>
{album.artist_id ? (
<button
type="button"
className={styles.statsArtistLink}
onClick={() =>
onArtistSelect(album.artist_id as string | number, album.artist || '')
}
>
{album.artist || ''}
</button>
) : (
album.artist || ''
)}
</div>
</div>
<span className={styles.statsRankedCount}>
{formatCompactNumber(album.play_count)} plays
</span>
</div>
))}
</div>
);
}
function StatsRankedTracks({
tracks,
onArtistSelect,
onPlay,
}: {
tracks: StatsTrackRow[];
onArtistSelect: (artistId: string | number, artistName: string) => void;
onPlay: (track: { title: string; artist: string; album: string }) => Promise<void>;
}) {
return (
<div id="stats-top-tracks" className={styles.statsRankedList}>
{tracks.length === 0 ? <EmptyListState message="No data yet" /> : null}
{tracks.map((track, index) => (
<div key={`${track.name}-${index}`} className={styles.statsRankedItem}>
<span className={styles.statsRankedNum}>{index + 1}</span>
{track.image_url ? (
<img className={styles.statsRankedImage} src={track.image_url} alt="" />
) : (
<div className={styles.statsRankedImageFallback} />
)}
<div className={styles.statsRankedInfo}>
<div className={styles.statsRankedName}>{track.name}</div>
<div className={styles.statsRankedMeta}>
{track.artist_id ? (
<button
type="button"
className={styles.statsArtistLink}
onClick={() =>
onArtistSelect(track.artist_id as string | number, track.artist || '')
}
>
{track.artist || ''}
</button>
) : (
track.artist || ''
)}
{track.album ? ` · ${track.album}` : ''}
</div>
</div>
<button
type="button"
className={`${styles.statsPlayButton} ${styles.statsPlayButtonSmall}`}
onClick={() =>
void onPlay({
title: track.name,
artist: track.artist || '',
album: track.album || '',
})
}
title="Play"
>
</button>
<span className={styles.statsRankedCount}>
{formatCompactNumber(track.play_count)} plays
</span>
</div>
))}
</div>
);
}
function StatsRecentPlays({
tracks,
onPlay,
}: {
tracks: StatsRecentTrack[];
onPlay: (track: { title: string; artist: string; album: string }) => Promise<void>;
}) {
return (
<div id="stats-recent-plays" className={styles.statsRecentList}>
{tracks.length === 0 ? <EmptyListState message="No recent plays" /> : null}
{tracks.map((track, index) => (
<div
key={`${track.title}-${track.artist ?? ''}-${track.album ?? ''}-${track.played_at ?? ''}-${index}`}
className={styles.statsRecentItem}
>
<button
type="button"
className={`${styles.statsPlayButton} ${styles.statsPlayButtonSmall}`}
onClick={() =>
void onPlay({
title: track.title,
artist: track.artist || '',
album: track.album || '',
})
}
title="Play"
>
</button>
<span className={styles.statsRecentTitle}>{track.title}</span>
<span className={styles.statsRecentArtist}>{track.artist || ''}</span>
<span className={styles.statsRecentTime}>{formatRelativePlayedAt(track.played_at)}</span>
</div>
))}
</div>
);
}
function StatsLibraryHealth({ health }: { health: StatsHealth }) {
const totalTracks = health.total_tracks ?? 0;
const formatEntries = Object.entries(health.format_breakdown ?? {});
const formatTotal = formatEntries.reduce((sum, [, count]) => sum + count, 0) || 1;
const formatColors: Record<string, string> = {
FLAC: '#3b82f6',
MP3: '#f97316',
Opus: '#a855f7',
AAC: '#14b8a6',
OGG: '#eab308',
WAV: '#ec4899',
Other: '#555555',
};
return (
<div id="stats-library-health">
<div className={styles.statsHealthGrid}>
<div className={`${styles.statsHealthItem} ${styles.statsHealthItemWide}`}>
<div className={styles.statsHealthLabel}>Format Breakdown</div>
<div className={styles.statsFormatBar}>
{formatEntries.map(([format, count]) => {
const percentage = ((count / formatTotal) * 100).toFixed(1);
return (
<div
key={format}
className={styles.statsFormatSegment}
style={{
flex: count,
background: formatColors[format] || formatColors.Other,
}}
title={`${format}: ${count} tracks (${percentage}%)`}
>
{Number(percentage) > 8 ? format : ''}
</div>
);
})}
</div>
</div>
<div className={styles.statsHealthItem}>
<div className={styles.statsHealthValue}>
{formatCompactNumber(health.unplayed_count)} ({health.unplayed_percentage || 0}%)
</div>
<div className={styles.statsHealthLabel}>Unplayed Tracks</div>
</div>
<div className={styles.statsHealthItem}>
<div className={styles.statsHealthValue}>
{formatTotalDuration(health.total_duration_ms)}
</div>
<div className={styles.statsHealthLabel}>Total Duration</div>
</div>
<div className={styles.statsHealthItem}>
<div className={styles.statsHealthValue}>{formatCompactNumber(totalTracks)}</div>
<div className={styles.statsHealthLabel}>Total Tracks</div>
</div>
</div>
<div id="stats-enrichment-coverage" className={styles.statsEnrichment}>
{STATS_ENRICHMENT_SERVICES.map((service) => {
const percent = health.enrichment_coverage?.[service.key] || 0;
return (
<div key={service.key} className={styles.statsEnrichItem}>
<span className={styles.statsEnrichName}>{service.label}</span>
<div className={styles.statsEnrichBar}>
<div
className={styles.statsEnrichFill}
style={{ width: `${percent}%`, background: service.color }}
/>
</div>
<span className={styles.statsEnrichPct}>{percent}%</span>
</div>
);
})}
</div>
</div>
);
}
function StatsDiskUsage({
error,
payload,
}: {
error: unknown;
payload: StatsLibraryDiskUsagePayload | undefined;
}) {
if (error) {
return <SectionSubtleError message={getErrorMessage(error)} />;
}
const hasData = payload?.has_data && !!payload.total_bytes;
const formats = Object.entries(payload?.by_format ?? {}).sort((a, b) => b[1] - a[1]);
const max = formats[0]?.[1] || 1;
const tracksWithSize = payload?.tracks_with_size || 0;
const tracksWithoutSize = payload?.tracks_without_size || 0;
return (
<div className={styles.statsDiskUsageWrap}>
<div className={styles.statsDiskTotalRow}>
<div className={styles.statsDiskTotalValue}>
{hasData ? formatBytes(payload?.total_bytes) : '—'}
</div>
<div className={styles.statsDiskTotalMeta}>
{hasData
? `${tracksWithSize.toLocaleString()} tracks measured${
tracksWithoutSize > 0
? ` (+${tracksWithoutSize.toLocaleString()} pending next Deep Scan)`
: ''
}`
: tracksWithoutSize > 0
? `Run a Deep Scan to populate (${tracksWithoutSize.toLocaleString()} tracks pending)`
: 'No tracks in library yet'}
</div>
</div>
<div className={styles.statsDiskFormats}>
{formats.map(([format, bytes]) => {
const width = Math.max(2, Math.round((bytes / max) * 100));
return (
<div key={format} className={styles.statsDiskFormatRow}>
<span className={styles.statsDiskFormatName}>{format.toUpperCase()}</span>
<div className={styles.statsDiskFormatBar}>
<div className={styles.statsDiskFormatFill} style={{ width: `${width}%` }} />
</div>
<span className={styles.statsDiskFormatSize}>{formatBytes(bytes)}</span>
</div>
);
})}
</div>
</div>
);
}
function StatsDbStorage({
error,
payload,
}: {
error: unknown;
payload: StatsDbStoragePayload | undefined;
}) {
if (error) {
return <SectionSubtleError message={getErrorMessage(error)} />;
}
const tables = groupDbStorageTables(payload?.tables ?? []).map((table, index) => ({
...table,
fill: STATS_DB_STORAGE_COLORS[index % STATS_DB_STORAGE_COLORS.length],
}));
const method = payload?.method;
return (
<div className={styles.statsDbStorageWrap}>
<div id="stats-db-storage-chart" className={styles.statsDbChartContainer}>
<ResponsiveContainer width={180} height={180}>
<PieChart>
<Pie
data={tables}
dataKey="size"
nameKey="name"
innerRadius={52}
outerRadius={84}
paddingAngle={2}
stroke="transparent"
/>
<Tooltip
contentStyle={STATS_TOOLTIP_STYLE}
wrapperStyle={STATS_TOOLTIP_WRAPPER_STYLE}
/>
</PieChart>
</ResponsiveContainer>
<div className={styles.statsDbTotal}>
<div className={styles.statsDbTotalValue}>
{formatDbStorageValue(payload?.total_file_size || 0, method)}
</div>
<div className={styles.statsDbTotalLabel}>Total Size</div>
</div>
</div>
<div className={styles.statsDbLegend}>
{tables.map((table, index) => (
<div key={table.name} className={styles.statsDbLegendItem}>
<span
className={styles.statsDbLegendDot}
style={{
background: STATS_DB_STORAGE_COLORS[index % STATS_DB_STORAGE_COLORS.length],
}}
/>
<span className={styles.statsDbLegendName}>{table.name}</span>
<span className={styles.statsDbLegendSize}>
{formatDbStorageValue(table.size, method)}
</span>
</div>
))}
</div>
</div>
);
}
function StatsEmptyState() {
return (
<div className={styles.statsEmpty}>
<div className={styles.statsEmptyIcon}>📊</div>
<h3>No Listening Data Yet</h3>
<p>
Enable &quot;Listening Stats&quot; in Settings to start tracking your listening activity
from your media server.
</p>
</div>
);
}
function SectionLoadingState() {
return <div className={styles.statsLoading}>Loading listening stats...</div>;
}
function SectionErrorState({ message }: { message: string }) {
return (
<div className={styles.statsEmpty}>
<h3>Failed to load listening stats</h3>
<p>{message}</p>
</div>
);
}
function SectionSubtleError({ message }: { message: string }) {
return <div className={styles.statsSubtleError}>{message}</div>;
}
function EmptyListState({ message }: { message: string }) {
return <div className={styles.emptyListState}>{message}</div>;
}
async function 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';
}

View file

@ -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,
});

View file

@ -0,0 +1,24 @@
import { vi } from 'vitest';
import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge';
export function createShellBridge(overrides: Partial<ShellBridge> = {}): 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;
}

View file

@ -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';

View file

@ -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.',
},

View file

@ -2420,9 +2420,6 @@ async function loadPageData(pageId) {
loadApiKeys();
loadBlacklistCount();
break;
case 'stats':
initializeStatsPage();
break;
case 'import':
initializeImportPage();
break;

View file

@ -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 = '';

View file

@ -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
====================================== */

View file

@ -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';

View file

@ -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) {

View file

@ -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) => `
<div class="stats-ranked-item">
<span class="stats-ranked-num">${i + 1}</span>
${item.image_url ? `<img class="stats-ranked-img" src="${item.image_url}" alt="" onerror="this.style.display='none'">` : ''}
<div class="stats-ranked-info">
<div class="stats-ranked-name">${item.id ? `<a class="stats-artist-link" href="${buildArtistDetailPath(item.id)}">${_esc(item.name)}</a>` : _esc(item.name)}${item.soul_id && !String(item.soul_id).startsWith('soul_unnamed_') ? ' <img src="/static/trans2.png" style="width:12px;height:12px;vertical-align:middle;opacity:0.5;" title="SoulID">' : ''}</div>
<div class="stats-ranked-meta">${item.global_listeners ? _fmt(item.global_listeners) + ' global listeners' : ''}</div>
</div>
<span class="stats-ranked-count">${_fmt(item.play_count)} plays</span>
</div>
`);
// Top Albums
_renderRankedList('stats-top-albums', data.top_albums || [], (item, i) => `
<div class="stats-ranked-item">
<span class="stats-ranked-num">${i + 1}</span>
${item.image_url ? `<img class="stats-ranked-img" src="${item.image_url}" alt="" onerror="this.style.display='none'">` : ''}
<div class="stats-ranked-info">
<div class="stats-ranked-name">${_esc(item.name)}</div>
<div class="stats-ranked-meta">${item.artist_id ? `<a class="stats-artist-link" href="${buildArtistDetailPath(item.artist_id)}">${_esc(item.artist || '')}</a>` : _esc(item.artist || '')}</div>
</div>
<span class="stats-ranked-count">${_fmt(item.play_count)} plays</span>
</div>
`);
// Top Tracks
_renderRankedList('stats-top-tracks', data.top_tracks || [], (item, i) => `
<div class="stats-ranked-item">
<span class="stats-ranked-num">${i + 1}</span>
${item.image_url ? `<img class="stats-ranked-img" src="${item.image_url}" alt="" onerror="this.style.display='none'">` : ''}
<div class="stats-ranked-info">
<div class="stats-ranked-name">${_esc(item.name)}</div>
<div class="stats-ranked-meta">${item.artist_id ? `<a class="stats-artist-link" href="${buildArtistDetailPath(item.artist_id)}">${_esc(item.artist || '')}</a>` : _esc(item.artist || '')}${item.album ? ' · ' + _esc(item.album) : ''}</div>
</div>
<button class="stats-play-btn" onclick="event.stopPropagation();playStatsTrack('${_esc(item.name).replace(/'/g, "\\'")}','${_esc(item.artist || '').replace(/'/g, "\\'")}','${_esc(item.album || '').replace(/'/g, "\\'")}')" title="Play"></button>
<span class="stats-ranked-count">${_fmt(item.play_count)} plays</span>
</div>
`);
// 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 = `<div class="stats-artist-bubbles">
${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 class="stats-artist-bubble" href="${a.id ? buildArtistDetailPath(a.id, a.source || null) : '#'}" style="cursor:${a.id ? 'pointer' : 'default'};text-decoration:none;color:inherit;">
<div class="stats-bubble-img" style="width:${size}px;height:${size}px;${a.image_url ? `background-image:url('${a.image_url}')` : ''}">
${!a.image_url ? `<span>${(a.name || '?')[0]}</span>` : ''}
</div>
<div class="stats-bubble-bar-container">
<div class="stats-bubble-bar" style="width:${pct}%"></div>
</div>
<div class="stats-bubble-name">${_esc(a.name)}</div>
<div class="stats-bubble-count">${_fmt(a.play_count)}</div>
</a>`;
}).join('')}
</div>`;
}
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('')
: '<div style="color:rgba(255,255,255,0.3);font-size:0.85em;padding:12px;">No data yet</div>';
}
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) => `
<div class="stats-genre-legend-item">
<span class="stats-genre-dot" style="background:${colors[i]}"></span>
<span>${g.genre}</span>
<span class="stats-genre-pct">${g.percentage}%</span>
</div>
`).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 `<div class="stats-format-segment" style="flex:${count};background:${fmtColors[fmt] || '#555'}" title="${fmt}: ${count} tracks (${pct}%)">${pct > 8 ? fmt : ''}</div>`;
}).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 => `
<div class="stats-enrich-item">
<span class="stats-enrich-name">${s.name}</span>
<div class="stats-enrich-bar"><div class="stats-enrich-fill" style="width:${s.pct}%;background:${s.color}"></div></div>
<span class="stats-enrich-pct">${s.pct}%</span>
</div>
`).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 `
<div class="stats-disk-format-row">
<span class="stats-disk-format-name">${ext.toUpperCase()}</span>
<div class="stats-disk-format-bar">
<div class="stats-disk-format-fill" style="width:${pct}%"></div>
</div>
<span class="stats-disk-format-size">${_formatBytes(bytes)}</span>
</div>
`;
}).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 = `<div class="stats-db-total-value">${sizeStr}</div><div class="stats-db-total-label">Total Size</div>`;
}
// 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 `<div class="stats-db-legend-item">
<span class="stats-db-legend-dot" style="background:${colors[i]}"></span>
<span class="stats-db-legend-name">${t.name}</span>
<span class="stats-db-legend-size">${sizeLabel}</span>
</div>`;
}).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 = '<div style="color:rgba(255,255,255,0.3);font-size:0.85em;padding:12px;">No recent plays</div>';
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 => `
<div class="stats-recent-item">
<button class="stats-play-btn stats-play-btn-sm" onclick="event.stopPropagation();playStatsTrack('${_esc(t.title).replace(/'/g, "\\'")}','${_esc(t.artist || '').replace(/'/g, "\\'")}','${_esc(t.album || '').replace(/'/g, "\\'")}')" title="Play"></button>
<span class="stats-recent-title">${_esc(t.title)}</span>
<span class="stats-recent-artist">${_esc(t.artist || '')}</span>
<span class="stats-recent-time">${_ago(t.played_at)}</span>
</div>
`).join('');
}
// --- Initialization ---
function initializeImportPage() {

View file

@ -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
============================================================================ */

View file

@ -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');

View file

@ -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();
});