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
This commit is contained in:
Antti Kettunen 2026-05-23 23:00:32 +03:00
parent d0245e3e16
commit 824b118759
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
7 changed files with 129 additions and 196 deletions

View file

@ -45,12 +45,6 @@ declare global {
) => void;
cancelSimilarArtistsLoad: () => void;
showReactHost: (pageId: ShellPageId) => void;
navigateToArtistDetail: (
artistId: string | number,
artistName: string,
sourceOverride?: string | null,
options?: Record<string, unknown>,
) => void;
playLibraryTrack: (
track: {
id: string | number;

View file

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

View file

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

View file

@ -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() {
))}
</div>
<div className={styles.statsSyncControls}>
<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>
{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>

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

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