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:
parent
d0245e3e16
commit
824b118759
7 changed files with 129 additions and 196 deletions
6
webui/src/platform/shell/globals.d.ts
vendored
6
webui/src/platform/shell/globals.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -168,7 +168,6 @@ window.SoulSyncWebShellBridge = {
|
|||
activateLegacyPath(pathname) {
|
||||
activateLegacyPath(pathname);
|
||||
},
|
||||
navigateToArtistDetail,
|
||||
cancelSimilarArtistsLoad() {
|
||||
if (typeof cancelSimilarArtistsLoad === 'function') {
|
||||
cancelSimilarArtistsLoad();
|
||||
|
|
|
|||
Loading…
Reference in a new issue