diff --git a/web/src/App.tsx b/web/src/App.tsx index e30929d..c8029be 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -11,6 +11,7 @@ import ItemPage from './pages/ItemPage' import ScanPage from './pages/ScanPage' import AddPage from './pages/AddPage' import SettingsPage from './pages/SettingsPage' +import StatsPage from './pages/StatsPage' import QueuePage from './pages/QueuePage' function Gate({ children }: { children: ReactNode }) { @@ -45,6 +46,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/web/src/pages/StatsPage.tsx b/web/src/pages/StatsPage.tsx new file mode 100644 index 0000000..53271d6 --- /dev/null +++ b/web/src/pages/StatsPage.tsx @@ -0,0 +1,111 @@ +import { useEffect, useState } from 'react' +import { api } from '../api.js' +import type { Stats } from '../types.js' + +function Bar({ name, count, max }: { name: string; count: number; max: number }) { + return ( +
+ {name} +
+
+
+ {count} +
+ ) +} + +function Card({ value, label }: { value: string | number; label: string }) { + return ( +
+

{value}

+

{label}

+
+ ) +} + +export default function StatsPage() { + const [stats, setStats] = useState(null) + const [error, setError] = useState(false) + + useEffect(() => { + void api + .getStats() + .then(setStats) + .catch(() => setError(true)) + }, []) + + if (error) return

Could not load stats.

+ if (!stats) return

Loading…

+ + const maxOf = (rows: { count: number }[]) => Math.max(1, ...rows.map((r) => r.count)) + const maxMonth = Math.max(1, ...stats.addedByMonth.map((m) => m.count)) + const ratioPct = Math.round(stats.ripRatio * 100) + + return ( +
+
+ + + + +
+ +
+
+

{ratioPct}% ripped

+
+ +
+

Formats

+ {stats.formats.length === 0 &&

No data yet.

} + {stats.formats.map((f) => ( + + ))} +
+ +
+

Genres

+ {stats.topGenres.map((g) => ( + + ))} +
+ +
+

Top artists

+ {stats.topArtists.map((a) => ( + + ))} +
+ +
+

Decades

+ {stats.decades.map((d) => ( + + ))} +
+ +
+

Added per month

+
+ {stats.addedByMonth.map((m) => ( +
+
0 ? '4px' : '1px', background: m.count > 0 ? '#34d399' : '#262626' }} + /> + {m.month.slice(5)} +
+ ))} +
+
+
+ ) +} diff --git a/web/test/stats.test.tsx b/web/test/stats.test.tsx new file mode 100644 index 0000000..46944b8 --- /dev/null +++ b/web/test/stats.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor, within } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import StatsPage from '../src/pages/StatsPage.js' +import type { Stats } from '../src/types.js' + +vi.mock('../src/api.js', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, api: { ...actual.api, getStats: vi.fn() } } +}) +import { api } from '../src/api.js' + +const stats: Stats = { + totals: { items: 4, ripped: 1, notRipped: 3, onLoan: 1 }, + ripRatio: 0.25, + formats: [ + { name: 'CD', count: 3 }, + { name: 'Vinyl', count: 1 }, + ], + decades: [{ name: '1990s', count: 4 }], + topGenres: [ + { name: 'Electronic', count: 3 }, + { name: 'Downtempo', count: 2 }, + ], + topArtists: [{ name: 'Massive Attack', count: 2 }], + addedByMonth: [ + { month: '2025-10', count: 0 }, + { month: '2025-11', count: 0 }, + { month: '2025-12', count: 0 }, + { month: '2026-01', count: 0 }, + { month: '2026-02', count: 0 }, + { month: '2026-03', count: 0 }, + { month: '2026-04', count: 0 }, + { month: '2026-05', count: 0 }, + { month: '2026-06', count: 0 }, + { month: '2026-07', count: 0 }, + { month: '2026-08', count: 4 }, + { month: '2026-09', count: 0 }, + ], +} + +beforeEach(() => { + vi.mocked(api.getStats).mockReset() + vi.mocked(api.getStats).mockResolvedValue(stats as never) +}) + +describe('StatsPage', () => { + it('renders totals and rip ratio', async () => { + render( + + + + ) + expect((await screen.findAllByText('4')).length).toBeGreaterThanOrEqual(1) + expect(screen.getByText(/25% ripped/i)).toBeTruthy() + const onLoanLabel = screen.getByText('on loan') + expect(within(onLoanLabel.parentElement as HTMLElement).getByText('1')).toBeTruthy() + }) + + it('renders format, genre, artist and month bars', async () => { + render( + + + + ) + expect(await screen.findByText('CD')).toBeTruthy() + expect(screen.getByText('Electronic')).toBeTruthy() + expect(screen.getByText('Massive Attack')).toBeTruthy() + expect(screen.getByText('1990s')).toBeTruthy() + // month bars: 12 bars rendered + expect(document.querySelectorAll('[data-bar]').length).toBeGreaterThanOrEqual(12) + }) + + it('error state on failure', async () => { + vi.mocked(api.getStats).mockRejectedValue(new TypeError('fetch failed')) + render( + + + + ) + expect(await screen.findByText(/could not load stats/i)).toBeTruthy() + }) +})