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 (
+
+ )
+}
+
+function Card({ value, label }: { value: string | number; label: string }) {
+ return (
+
+ )
+}
+
+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 (
+
+
+
+
+
+
+
+
+
+
+
+ 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()
+ })
+})