From 824b11875920aafead1d10b73e73f8a2c1705a0e Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 23 May 2026 23:00:32 +0300 Subject: [PATCH] refactor(webui): finish stats standalone handling in react - render the standalone notice directly in the React stats header - keep the legacy standalone sweep from hiding the stats control incorrectly - update the stats route test and header layout to match the new behavior --- webui/src/platform/shell/globals.d.ts | 6 - webui/src/routes/stats/-route.test.tsx | 250 ++++++------------ .../routes/stats/-ui/stats-page.module.css | 22 ++ webui/src/routes/stats/-ui/stats-page.tsx | 44 +-- webui/static/core.js | 1 + webui/static/shared-helpers.js | 1 + webui/static/shell-bridge.js | 1 - 7 files changed, 129 insertions(+), 196 deletions(-) diff --git a/webui/src/platform/shell/globals.d.ts b/webui/src/platform/shell/globals.d.ts index 5ee321c2..8b043665 100644 --- a/webui/src/platform/shell/globals.d.ts +++ b/webui/src/platform/shell/globals.d.ts @@ -45,12 +45,6 @@ declare global { ) => void; cancelSimilarArtistsLoad: () => void; showReactHost: (pageId: ShellPageId) => void; - navigateToArtistDetail: ( - artistId: string | number, - artistName: string, - sourceOverride?: string | null, - options?: Record, - ) => void; playLibraryTrack: ( track: { id: string | number; diff --git a/webui/src/routes/stats/-route.test.tsx b/webui/src/routes/stats/-route.test.tsx index bfc44343..ec251ae9 100644 --- a/webui/src/routes/stats/-route.test.tsx +++ b/webui/src/routes/stats/-route.test.tsx @@ -2,35 +2,10 @@ import { createMemoryHistory } from '@tanstack/react-router'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge'; - import { createAppQueryClient } from '@/app/query-client'; import { AppRouterProvider, createAppRouter } from '@/app/router'; - -function createResponse(body: unknown, ok = true, status = 200) { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }); -} - -function createShellBridge(overrides: Partial = {}): ShellBridge { - return { - getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: true })), - isPageAllowed: vi.fn(() => true), - getProfileHomePage: vi.fn<() => ShellPageId>(() => 'discover'), - resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'search'), - setActivePageChrome: vi.fn(), - activateLegacyPath: vi.fn(), - showReactHost: vi.fn(), - navigateToArtistDetail: vi.fn(), - playLibraryTrack: vi.fn(), - startStream: vi.fn(), - showLoadingOverlay: vi.fn(), - hideLoadingOverlay: vi.fn(), - ...overrides, - }; -} +import { HttpResponse, http, server } from '@/test/msw'; +import { createShellBridge } from '@/test/shell-bridge'; function renderStatsRoute(initialEntries = ['/stats']) { const queryClient = createAppQueryClient(); @@ -47,52 +22,50 @@ describe('stats route', () => { beforeEach(() => { window.SoulSyncWebShellBridge = createShellBridge(); window.showToast = vi.fn(); - vi.stubGlobal( - 'fetch', - vi.fn(async (input: RequestInfo | URL) => { - const url = input instanceof Request ? input.url : String(input); - if (url.includes('/api/stats/cached')) { - return createResponse({ - success: true, - overview: { - total_plays: 24, - total_time_ms: 6_600_000, - unique_artists: 3, - unique_albums: 4, - unique_tracks: 12, - }, - top_artists: [{ id: 7, name: 'Artist A', play_count: 10 }], - top_albums: [], - top_tracks: [], - timeline: [{ date: 'May 10', plays: 4 }], - genres: [{ genre: 'House', play_count: 10, percentage: 80 }], - recent: [{ title: 'Track A', artist: 'Artist A', played_at: '2026-05-14T08:00:00Z' }], - health: { total_tracks: 12, format_breakdown: { FLAC: 12 } }, - }); - } - if (url.includes('/api/listening-stats/status')) { - return createResponse({ stats: { last_poll: '2026-05-14 10:00:00' } }); - } - if (url.includes('/api/stats/db-storage')) { - return createResponse({ - success: true, - tables: [{ name: 'tracks', size: 2048 }], - total_file_size: 4096, - method: 'dbstat', - }); - } - if (url.includes('/api/stats/library-disk-usage')) { - return createResponse({ - success: true, - has_data: true, - total_bytes: 2048, - tracks_with_size: 12, - tracks_without_size: 0, - by_format: { flac: 2048 }, - }); - } - return createResponse({ success: true }); - }) as unknown as typeof fetch, + 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 }, + }), + ), ); }); @@ -107,52 +80,10 @@ describe('stats route', () => { }); it('still renders when listening stats status prefetch fails', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async (input: RequestInfo | URL) => { - const url = input instanceof Request ? input.url : String(input); - if (url.includes('/api/stats/cached')) { - return createResponse({ - success: true, - overview: { - total_plays: 24, - total_time_ms: 6_600_000, - unique_artists: 3, - unique_albums: 4, - unique_tracks: 12, - }, - top_artists: [{ id: 7, name: 'Artist A', play_count: 10 }], - top_albums: [], - top_tracks: [], - timeline: [{ date: 'May 10', plays: 4 }], - genres: [{ genre: 'House', play_count: 10, percentage: 80 }], - recent: [{ title: 'Track A', artist: 'Artist A', played_at: '2026-05-14T08:00:00Z' }], - health: { total_tracks: 12, format_breakdown: { FLAC: 12 } }, - }); - } - if (url.includes('/api/listening-stats/status')) { - return createResponse({ error: 'status unavailable' }, false, 500); - } - if (url.includes('/api/stats/db-storage')) { - return createResponse({ - success: true, - tables: [{ name: 'tracks', size: 2048 }], - total_file_size: 4096, - method: 'dbstat', - }); - } - if (url.includes('/api/stats/library-disk-usage')) { - return createResponse({ - success: true, - has_data: true, - total_bytes: 2048, - tracks_with_size: 12, - tracks_without_size: 0, - by_format: { flac: 2048 }, - }); - } - return createResponse({ success: true }); - }) as unknown as typeof fetch, + server.use( + http.get('/api/listening-stats/status', () => + HttpResponse.json({ error: 'status unavailable' }, { status: 500 }), + ), ); renderStatsRoute(); @@ -160,6 +91,22 @@ describe('stats route', () => { 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 () => { @@ -186,61 +133,16 @@ describe('stats route', () => { startStream: vi.fn(), }); - vi.stubGlobal( - 'fetch', - vi.fn(async (input: RequestInfo | URL) => { - const url = input instanceof Request ? input.url : String(input); - if (url.includes('/api/stats/cached')) { - return createResponse({ - success: true, - overview: { - total_plays: 24, - total_time_ms: 6_600_000, - unique_artists: 3, - unique_albums: 4, - unique_tracks: 12, - }, - top_artists: [{ id: 7, name: 'Artist A', play_count: 10 }], - top_albums: [], - top_tracks: [{ name: 'Track A', artist: 'Artist A', album: 'Album A', play_count: 3 }], - timeline: [{ date: 'May 10', plays: 4 }], - genres: [{ genre: 'House', play_count: 10, percentage: 80 }], - recent: [{ title: 'Track A', artist: 'Artist A', played_at: '2026-05-14T08:00:00Z' }], - health: { total_tracks: 12, format_breakdown: { FLAC: 12 } }, - }); - } - if (url.includes('/api/listening-stats/status')) { - return createResponse({ stats: { last_poll: '2026-05-14 10:00:00' } }); - } - if (url.includes('/api/stats/resolve-track')) { - return createResponse({ error: 'resolve unavailable' }, false, 500); - } - if (url.includes('/api/enhanced-search/stream-track')) { - return createResponse({ - success: true, - result: { stream_url: '/api/stream/1' }, - }); - } - if (url.includes('/api/stats/db-storage')) { - return createResponse({ - success: true, - tables: [{ name: 'tracks', size: 2048 }], - total_file_size: 4096, - method: 'dbstat', - }); - } - if (url.includes('/api/stats/library-disk-usage')) { - return createResponse({ - success: true, - has_data: true, - total_bytes: 2048, - tracks_with_size: 12, - tracks_without_size: 0, - by_format: { flac: 2048 }, - }); - } - return createResponse({ success: true }); - }) as unknown as typeof fetch, + 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(); diff --git a/webui/src/routes/stats/-ui/stats-page.module.css b/webui/src/routes/stats/-ui/stats-page.module.css index 051fbc03..17be6814 100644 --- a/webui/src/routes/stats/-ui/stats-page.module.css +++ b/webui/src/routes/stats/-ui/stats-page.module.css @@ -94,6 +94,9 @@ gap: 16px; position: relative; z-index: 1; + min-width: 0; + flex-wrap: wrap; + justify-content: flex-end; } .statsTimeRange { @@ -133,11 +136,25 @@ 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 { @@ -863,6 +880,11 @@ justify-content: space-between; } + .statsStandaloneNotice { + max-width: none; + text-align: left; + } + .headerIcon { width: 100px; height: 100px; diff --git a/webui/src/routes/stats/-ui/stats-page.tsx b/webui/src/routes/stats/-ui/stats-page.tsx index cf19f02e..63259a7d 100644 --- a/webui/src/routes/stats/-ui/stats-page.tsx +++ b/webui/src/routes/stats/-ui/stats-page.tsx @@ -15,7 +15,7 @@ import { import type { ShellBridge } from '@/platform/shell/bridge'; -import { useReactPageShell } from '@/platform/shell/route-controllers'; +import { useReactPageShell, useShellStatus } from '@/platform/shell/route-controllers'; import type { StatsAlbumRow, @@ -125,6 +125,8 @@ export function StatsPage() { 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({ @@ -166,20 +168,32 @@ export function StatsPage() { ))}
- - {lastSynced ? `Last synced: ${lastSynced}` : 'Not synced yet'} - - + {isStandalone ? ( + + Standalone mode: manual sync unavailable + + ) : ( + <> + + {lastSynced ? `Last synced: ${lastSynced}` : 'Not synced yet'} + + + + )}
diff --git a/webui/static/core.js b/webui/static/core.js index f1b047f6..8e272f10 100644 --- a/webui/static/core.js +++ b/webui/static/core.js @@ -515,6 +515,7 @@ function handleServiceStatusUpdate(data) { const isSoulsyncStandalone = data.media_server?.type === 'soulsync'; _isSoulsyncStandalone = isSoulsyncStandalone; document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => { + if (btn.id === 'stats-sync-btn') return; // React stats page owns this control now. if (isSoulsyncStandalone) { btn.dataset.hiddenByStandalone = '1'; btn.style.display = 'none'; diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index 3fc8be27..43c184f9 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -3199,6 +3199,7 @@ async function fetchAndUpdateServiceStatus() { const isSoulsyncStandalone2 = data.media_server?.type === 'soulsync'; _isSoulsyncStandalone = isSoulsyncStandalone2; document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => { + if (btn.id === 'stats-sync-btn') return; // React stats page owns this control now. if (isSoulsyncStandalone2) { btn.dataset.hiddenByStandalone = '1'; btn.style.display = 'none'; diff --git a/webui/static/shell-bridge.js b/webui/static/shell-bridge.js index c1ac1d1b..af9af731 100644 --- a/webui/static/shell-bridge.js +++ b/webui/static/shell-bridge.js @@ -168,7 +168,6 @@ window.SoulSyncWebShellBridge = { activateLegacyPath(pathname) { activateLegacyPath(pathname); }, - navigateToArtistDetail, cancelSimilarArtistsLoad() { if (typeof cancelSimilarArtistsLoad === 'function') { cancelSimilarArtistsLoad();