1
0

feat: stats page with css bar charts and rip-ratio donut

This commit is contained in:
2026-09-03 23:08:09 +02:00
parent fc5797d929
commit b33399c865
3 changed files with 196 additions and 0 deletions

View File

@@ -11,6 +11,7 @@ import ItemPage from './pages/ItemPage'
import ScanPage from './pages/ScanPage' import ScanPage from './pages/ScanPage'
import AddPage from './pages/AddPage' import AddPage from './pages/AddPage'
import SettingsPage from './pages/SettingsPage' import SettingsPage from './pages/SettingsPage'
import StatsPage from './pages/StatsPage'
import QueuePage from './pages/QueuePage' import QueuePage from './pages/QueuePage'
function Gate({ children }: { children: ReactNode }) { function Gate({ children }: { children: ReactNode }) {
@@ -45,6 +46,7 @@ export default function App() {
<Route path="/scan" element={<ScanPage />} /> <Route path="/scan" element={<ScanPage />} />
<Route path="/add" element={<AddPage />} /> <Route path="/add" element={<AddPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/stats" element={<StatsPage />} />
<Route path="/queue" element={<QueuePage />} /> <Route path="/queue" element={<QueuePage />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/library" replace />} /> <Route path="*" element={<Navigate to="/library" replace />} />

111
web/src/pages/StatsPage.tsx Normal file
View File

@@ -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 (
<div className="flex items-center gap-2 text-sm">
<span className="w-28 shrink-0 truncate text-neutral-300">{name}</span>
<div className="h-2.5 flex-1 overflow-hidden rounded-full bg-neutral-800">
<div className="h-full rounded-full bg-emerald-500" style={{ width: `${max === 0 ? 0 : (count / max) * 100}%` }} />
</div>
<span className="w-8 shrink-0 text-right text-neutral-500">{count}</span>
</div>
)
}
function Card({ value, label }: { value: string | number; label: string }) {
return (
<div className="rounded-xl border border-neutral-800 bg-neutral-900 p-4 text-center">
<p className="text-2xl font-semibold">{value}</p>
<p className="text-xs text-neutral-400">{label}</p>
</div>
)
}
export default function StatsPage() {
const [stats, setStats] = useState<Stats | null>(null)
const [error, setError] = useState(false)
useEffect(() => {
void api
.getStats()
.then(setStats)
.catch(() => setError(true))
}, [])
if (error) return <p className="py-8 text-center text-sm text-red-400">Could not load stats.</p>
if (!stats) return <p className="py-8 text-center text-sm text-neutral-400">Loading</p>
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 (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card value={stats.totals.items} label="in collection" />
<Card value={stats.totals.ripped} label="ripped" />
<Card value={stats.totals.notRipped} label="not ripped" />
<Card value={stats.totals.onLoan} label="on loan" />
</div>
<div className="flex items-center gap-4 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
<div
className="size-20 shrink-0 rounded-full"
style={{
background: `conic-gradient(#34d399 ${ratioPct}%, #262626 ${ratioPct}% 100%)`,
}}
role="img"
aria-label={`rip ratio ${ratioPct}%`}
/>
<p className="text-sm text-neutral-300">{ratioPct}% ripped</p>
</div>
<section className="space-y-2 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-neutral-400">Formats</h2>
{stats.formats.length === 0 && <p className="text-sm text-neutral-500">No data yet.</p>}
{stats.formats.map((f) => (
<Bar key={f.name} name={f.name} count={f.count} max={maxOf(stats.formats)} />
))}
</section>
<section className="space-y-2 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-neutral-400">Genres</h2>
{stats.topGenres.map((g) => (
<Bar key={g.name} name={g.name} count={g.count} max={maxOf(stats.topGenres)} />
))}
</section>
<section className="space-y-2 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-neutral-400">Top artists</h2>
{stats.topArtists.map((a) => (
<Bar key={a.name} name={a.name} count={a.count} max={maxOf(stats.topArtists)} />
))}
</section>
<section className="space-y-2 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-neutral-400">Decades</h2>
{stats.decades.map((d) => (
<Bar key={d.name} name={d.name} count={d.count} max={maxOf(stats.decades)} />
))}
</section>
<section className="space-y-2 rounded-xl border border-neutral-800 bg-neutral-900 p-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-neutral-400">Added per month</h2>
<div className="flex h-24 items-end gap-1">
{stats.addedByMonth.map((m) => (
<div key={m.month} className="flex flex-1 flex-col items-center gap-1" title={`${m.month}: ${m.count}`}>
<div
data-bar
className="w-full rounded-t bg-emerald-500"
style={{ height: `${(m.count / maxMonth) * 100}%`, minHeight: m.count > 0 ? '4px' : '1px', background: m.count > 0 ? '#34d399' : '#262626' }}
/>
<span className="text-[10px] text-neutral-500">{m.month.slice(5)}</span>
</div>
))}
</div>
</section>
</div>
)
}

83
web/test/stats.test.tsx Normal file
View File

@@ -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<typeof import('../src/api.js')>()
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(
<MemoryRouter>
<StatsPage />
</MemoryRouter>
)
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(
<MemoryRouter>
<StatsPage />
</MemoryRouter>
)
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(
<MemoryRouter>
<StatsPage />
</MemoryRouter>
)
expect(await screen.findByText(/could not load stats/i)).toBeTruthy()
})
})