11 KiB
WebUI Stats Migration Plan
Snapshot date: 2026-05-14
Status
- Completed on 2026-05-14.
statsis now React-owned in the shell route manifest.- The legacy stats HTML, JS, and CSS path has been removed.
- The global
Chart.jsimport was removed and replaced with route-localRecharts. - 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
statsfrom the legacy shell to the React route host. - Replace the global
Chart.jsCDN script with route-local React chart components. - Use the
issuesroute 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/cachedGET /api/stats/db-storageGET /api/stats/library-disk-usagePOST /api/listening-stats/syncGET /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
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:
7d30d12mall
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:
cachedis the real page backbonedb-storageandlibrary-disk-usageare already separate in the backend- they can render as progressively enhanced cards without blocking the whole route
listening-stats/statusupdates 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
rangefrom route search - renders:
StatsHeaderStatsOverviewCardsStatsEmptyStateor 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
- from imperative
- 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:
- Add
webui/src/routes/stats/route.tsx - Regenerate the TanStack route tree if needed
- Change
statsfromlegacytoreactinwebui/src/platform/shell/route-manifest.ts - Keep the legacy
stats-pageDOM inwebui/index.htmlduring the initial cutover if that reduces risk - Remove legacy activation from
webui/static/init.jsonce React ownership is confirmed - Remove the global Chart.js script from
webui/index.html
Incremental Migration Order
Recommended order:
- Add types, API layer, and helpers
- Build the React route with plain markup and no charts yet
- Port overview, ranked lists, recent plays, and empty state
- Port library health and disk usage
- Port Recharts activity, genre, and db storage charts
- Flip route ownership from legacy to React
- Remove global Chart.js import
- Delete or shrink legacy
statslogic fromstats-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
statsfirst.
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/cachedshould 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