feat: stats page with css bar charts and rip-ratio donut
This commit is contained in:
@@ -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() {
|
||||
<Route path="/scan" element={<ScanPage />} />
|
||||
<Route path="/add" element={<AddPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/stats" element={<StatsPage />} />
|
||||
<Route path="/queue" element={<QueuePage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/library" replace />} />
|
||||
|
||||
111
web/src/pages/StatsPage.tsx
Normal file
111
web/src/pages/StatsPage.tsx
Normal 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
83
web/test/stats.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user