Public Access
feat(stats): add chart form pickers to dashboard and profile tiles
This commit is contained in:
1 parent
13b0d5e7e9
commit
de199a20b7
19 files changed
+507
-98
No files matched your search
@@ -5,8 +5,11 @@ import {Activity} from 'lucide-react';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import GameCover from './GameCover.jsx';
|
||||
import FitText from './FitText.jsx';
|
||||
import ChartPicker from './stats/ChartPicker.jsx';
|
||||
import GamePlayChart from './stats/GamePlayChart.jsx';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import {formatDay} from '../utils/format.js';
|
||||
import {chartProps, linePoints, playChart} from '../utils/statCharts.js';
|
||||
|
||||
const CHART_HEIGHT = 80;
|
||||
const BAR_WIDTH = 10;
|
||||
@@ -14,42 +17,66 @@ const BAR_GAP = 2;
|
||||
|
||||
/**
|
||||
* @param activity the answer of GET /api/library/activity
|
||||
* @param charts the chart form per group of the user
|
||||
* @param onChart `(group, form)`: shows the pickers
|
||||
*/
|
||||
export default function ActivityCard({activity}) {
|
||||
export default function ActivityCard({activity, charts = null, onChart = null}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
const {days, totalMinutes, recentGames} = activity;
|
||||
const max = Math.max(1, ...days.map((d) => d.minutes));
|
||||
const width = days.length * (BAR_WIDTH + BAR_GAP);
|
||||
const dayChart = chartProps(charts, onChart, 'ACTIVITY');
|
||||
const gameChart = chartProps(charts, onChart, 'ACTIVITY_GAMES');
|
||||
const line = dayChart.chart === 'LINE' && days.length > 1;
|
||||
const points = line ? linePoints(days.map((d) => d.minutes), width, CHART_HEIGHT) : [];
|
||||
const recent = recentGames.map((g) => ({appid: g.appid, name: g.name, minutes: g.playtime2Weeks}));
|
||||
const form = playChart(gameChart.chart, recent);
|
||||
const dayTitle = (d) => `${formatDay(d.day, i18n.language)}: ${formatPlaytime(d.minutes)}`;
|
||||
|
||||
return (
|
||||
<Card className="gap-5 p-5">
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4">
|
||||
<h2 className="flex items-center gap-2 font-semibold">
|
||||
<Activity className="size-4 text-primary"/>
|
||||
{t('DASHBOARD.ACTIVITY')}
|
||||
</h2>
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{t('DASHBOARD.ACTIVITY_TOTAL', {days: days.length, time: formatPlaytime(totalMinutes)})}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-x-3">
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{t('DASHBOARD.ACTIVITY_TOTAL', {days: days.length, time: formatPlaytime(totalMinutes)})}
|
||||
</span>
|
||||
{dayChart.onChart && <ChartPicker group="ACTIVITY" value={dayChart.chart} onChange={dayChart.onChart}/>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalMinutes === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('DASHBOARD.ACTIVITY_EMPTY')}</p>
|
||||
) : (
|
||||
<div>
|
||||
<svg viewBox={`0 0 ${width} ${CHART_HEIGHT}`} preserveAspectRatio="none" className="h-20 w-full"
|
||||
role="img" aria-label={t('DASHBOARD.ACTIVITY')}>
|
||||
{days.map((d, i) => {
|
||||
const height = (d.minutes / max) * CHART_HEIGHT;
|
||||
return (
|
||||
<rect key={d.day} data-testid="activity-bar" x={i * (BAR_WIDTH + BAR_GAP)}
|
||||
y={CHART_HEIGHT - height} width={BAR_WIDTH} height={height} rx={2}
|
||||
className="fill-primary">
|
||||
<title>{`${formatDay(d.day, i18n.language)}: ${formatPlaytime(d.minutes)}`}</title>
|
||||
</rect>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<div className="relative">
|
||||
<svg viewBox={`0 0 ${width} ${CHART_HEIGHT}`} preserveAspectRatio="none" className="h-20 w-full"
|
||||
role="img" aria-label={t('DASHBOARD.ACTIVITY')}>
|
||||
{line ? (
|
||||
<polyline points={points.map((p) => `${p.x},${p.y}`).join(' ')} fill="none"
|
||||
className="stroke-primary" strokeWidth={2} strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"/>
|
||||
) : days.map((d, i) => {
|
||||
const height = (d.minutes / max) * CHART_HEIGHT;
|
||||
return (
|
||||
<rect key={d.day} data-testid="activity-bar" x={i * (BAR_WIDTH + BAR_GAP)}
|
||||
y={CHART_HEIGHT - height} width={BAR_WIDTH} height={height} rx={2}
|
||||
className="fill-primary">
|
||||
<title>{dayTitle(d)}</title>
|
||||
</rect>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
{/* Dots are HTML over the stretched svg, so they stay round and can show a tooltip. */}
|
||||
{line && days.map((d, i) => (
|
||||
<span key={d.day} title={dayTitle(d)} data-testid="activity-dot"
|
||||
className="absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary"
|
||||
style={{left: `${(points[i].x / width) * 100}%`, top: `${(points[i].y / CHART_HEIGHT) * 100}%`}}/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
|
||||
<span>{formatDay(days[0].day, i18n.language)}</span>
|
||||
<span>{formatDay(days[days.length - 1].day, i18n.language)}</span>
|
||||
@@ -59,7 +86,14 @@ export default function ActivityCard({activity}) {
|
||||
|
||||
{recentGames.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium">{t('DASHBOARD.RECENTLY_PLAYED')}</span>
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3">
|
||||
<span className="text-sm font-medium">{t('DASHBOARD.RECENTLY_PLAYED')}</span>
|
||||
{gameChart.onChart && (
|
||||
<ChartPicker group="ACTIVITY_GAMES" value={gameChart.chart} onChange={gameChart.onChart}
|
||||
label={`${t('STATS.CHART.LABEL')}: ${t('DASHBOARD.RECENTLY_PLAYED')}`}/>
|
||||
)}
|
||||
</div>
|
||||
{form !== 'LIST' ? <GamePlayChart games={recent} chart={form} linked/> : (
|
||||
<ul className="space-y-2">
|
||||
{recentGames.map((g) => (
|
||||
<li key={g.appid}>
|
||||
@@ -73,6 +107,7 @@ export default function ActivityCard({activity}) {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, render, screen} from '@testing-library/react';
|
||||
import {cleanup, fireEvent, render, screen, within} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import ActivityCard from './ActivityCard.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
|
||||
const days = (minutes) => minutes.map((m, i) => ({day: `2026-09-${String(10 + i).padStart(2, '0')}`, minutes: m}));
|
||||
const renderCard = (activity) => render(<MemoryRouter><ActivityCard activity={activity}/></MemoryRouter>);
|
||||
const renderCard = (activity, props) => render(<MemoryRouter><ActivityCard activity={activity} {...props}/></MemoryRouter>);
|
||||
const recent = (n) => Array.from({length: n}, (_, i) => ({appid: 100 + i, name: `Game ${i}`, playtimeMinutes: 900,
|
||||
playtime2Weeks: 600 - i * 10, status: 'PLAYING'}));
|
||||
|
||||
describe('ActivityCard', () => {
|
||||
afterEach(cleanup);
|
||||
@@ -35,4 +37,81 @@ describe('ActivityCard', () => {
|
||||
|
||||
expect(screen.getByText('DASHBOARD.ACTIVITY_EMPTY')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('drawsALineForTheStoredLineForm', () => {
|
||||
const {container} = renderCard({days: days([0, 60, 120]), totalMinutes: 180, recentGames: []},
|
||||
{charts: {ACTIVITY: 'LINE'}});
|
||||
|
||||
expect(screen.queryAllByTestId('activity-bar')).toHaveLength(0);
|
||||
const line = container.querySelector('polyline');
|
||||
expect(line.getAttribute('vector-effect')).toBe('non-scaling-stroke');
|
||||
const dots = screen.getAllByTestId('activity-dot');
|
||||
expect(dots).toHaveLength(3);
|
||||
expect(dots[1].getAttribute('title')).toMatch(/: 1\.0h$/);
|
||||
expect(container.querySelectorAll('svg[role="img"] polyline')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('drawsBarsForALineWithOneDay', () => {
|
||||
renderCard({days: days([60]), totalMinutes: 60, recentGames: []}, {charts: {ACTIVITY: 'LINE'}});
|
||||
|
||||
expect(screen.getAllByTestId('activity-bar')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('showsNoPickerWithoutOnChart', () => {
|
||||
renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(1)});
|
||||
|
||||
expect(screen.queryByRole('group')).toBeNull();
|
||||
});
|
||||
|
||||
it('showsTwoPickersWithDifferentNamesWithOnChart', () => {
|
||||
const onChart = vi.fn();
|
||||
renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(2)}, {onChart});
|
||||
|
||||
const day = screen.getByRole('group', {name: 'STATS.CHART.LABEL'});
|
||||
expect(screen.getByRole('group', {name: 'STATS.CHART.LABEL: DASHBOARD.RECENTLY_PLAYED'})).toBeTruthy();
|
||||
fireEvent.click(within(day).getByRole('button', {name: 'STATS.CHART.LINE'}));
|
||||
|
||||
expect(onChart).toHaveBeenCalledWith('ACTIVITY', 'LINE');
|
||||
});
|
||||
|
||||
it('recentGamesPieLinksTheGamesAndOtherIsPlain', () => {
|
||||
const {container} = renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(7)},
|
||||
{charts: {ACTIVITY_GAMES: 'PIE'}});
|
||||
|
||||
expect(container.querySelectorAll('path.stroke-card')).toHaveLength(6);
|
||||
const links = screen.getAllByRole('link');
|
||||
expect(links).toHaveLength(5);
|
||||
expect(links[0].getAttribute('href')).toBe('/library/100');
|
||||
expect(screen.getByText('STATS.CHART.OTHER').closest('a,button')).toBeNull();
|
||||
expect(screen.getByText('9.8h')).toBeTruthy();
|
||||
expect(screen.getByText('STATS.TOP_GAMES.SHARE')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('recentGamesPieWithOneGameDrawsTheList', () => {
|
||||
const {container} = renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(1)},
|
||||
{charts: {ACTIVITY_GAMES: 'DONUT'}});
|
||||
|
||||
expect(container.querySelectorAll('path.stroke-card')).toHaveLength(0);
|
||||
expect(screen.getByRole('link', {name: /Game 0/}).getAttribute('href')).toBe('/library/100');
|
||||
});
|
||||
|
||||
it('recentGamesBarsHaveNoCovers', () => {
|
||||
const {container} = renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(2)},
|
||||
{charts: {ACTIVITY_GAMES: 'BARS'}});
|
||||
|
||||
expect(container.querySelectorAll('img')).toHaveLength(0);
|
||||
expect(screen.getByRole('link', {name: /Game 1/}).getAttribute('href')).toBe('/library/101');
|
||||
});
|
||||
|
||||
it('twoGamesWithTheSameNameGetTheirOwnSlice', () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const games = [{appid: 1, name: 'Prey', playtimeMinutes: 900, playtime2Weeks: 300, status: 'PLAYING'},
|
||||
{appid: 2, name: 'Prey', playtimeMinutes: 900, playtime2Weeks: 200, status: 'PLAYING'}];
|
||||
renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: games}, {charts: {ACTIVITY_GAMES: 'PIE'}});
|
||||
|
||||
const sameKey = error.mock.calls.some((args) => args.join(' ').includes('same key'));
|
||||
error.mockRestore();
|
||||
expect(sameKey).toBe(false);
|
||||
expect(screen.getAllByRole('link', {name: /Prey/}).map((a) => a.getAttribute('href'))).toEqual(['/library/1', '/library/2']);
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,22 @@ import {ArrowRight, Trophy} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import ChartPicker from './stats/ChartPicker.jsx';
|
||||
import GamePlayChart from './stats/GamePlayChart.jsx';
|
||||
import GameCover from './GameCover.jsx';
|
||||
import FitText from './FitText.jsx';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import {coverEffect} from '../utils/rewards.js';
|
||||
import {playChart} from '../utils/statCharts.js';
|
||||
|
||||
export default function MostPlayedCard({games, syncing, equipped}) {
|
||||
/**
|
||||
* @param chart LIST, BARS, PIE or DONUT
|
||||
* @param onChart `(form)`: shows the picker
|
||||
*/
|
||||
export default function MostPlayedCard({games, syncing, equipped, chart = 'LIST', onChart}) {
|
||||
const {t} = useTranslation();
|
||||
const mapped = games.map((g) => ({appid: g.appid, name: g.name, minutes: g.playtimeMinutes}));
|
||||
const form = playChart(chart, mapped);
|
||||
const maxMinutes = games[0]?.playtimeMinutes || 1;
|
||||
return (
|
||||
<Card className="gap-0 py-0">
|
||||
@@ -19,15 +28,20 @@ export default function MostPlayedCard({games, syncing, equipped}) {
|
||||
<Trophy className="size-4 text-amber-500"/>
|
||||
{t('DASHBOARD.MOST_PLAYED')}
|
||||
</h2>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/library">{t('DASHBOARD.SHOW_ALL')}<ArrowRight/></Link>
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-x-3">
|
||||
{onChart && <ChartPicker group="TOP_GAMES" value={chart} onChange={onChart}/>}
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/library">{t('DASHBOARD.SHOW_ALL')}<ArrowRight/></Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{games.length === 0 ? (
|
||||
<p className="px-5 py-10 text-center text-sm text-muted-foreground">
|
||||
{syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
|
||||
</p>
|
||||
) : form !== 'LIST' ? (
|
||||
<div className="px-5 py-4"><GamePlayChart games={mapped} chart={form} linked/></div>
|
||||
) : (
|
||||
<ol className="divide-y">
|
||||
{games.map((g, index) => (
|
||||
|
||||
@@ -8,11 +8,12 @@ import {STAT_CHARTS} from '../../utils/statCharts.js';
|
||||
*
|
||||
* @param value the shown form
|
||||
* @param onChange `(form)`
|
||||
* @param label the accessible name; the default fits a card with one picker
|
||||
*/
|
||||
export default function ChartPicker({group, value, onChange}) {
|
||||
export default function ChartPicker({group, value, onChange, label}) {
|
||||
const {t} = useTranslation();
|
||||
return (
|
||||
<div role="group" aria-label={t('STATS.CHART.LABEL')} className="flex flex-wrap">
|
||||
<div role="group" aria-label={label ?? t('STATS.CHART.LABEL')} className="flex flex-wrap">
|
||||
{STAT_CHARTS[group].map((form) => (
|
||||
<Button key={form} variant={value === form ? 'secondary' : 'ghost'} size="sm"
|
||||
className="h-7 px-2 max-sm:min-h-11" aria-pressed={value === form}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
import {DrillRow} from './StatDrill.jsx';
|
||||
import SliceChart from './SliceChart.jsx';
|
||||
import {formatPlaytime} from '../../utils/games.js';
|
||||
import {gameHref} from '../../utils/statGames.js';
|
||||
import {topSlices} from '../../utils/statCharts.js';
|
||||
|
||||
/**
|
||||
* Play time per game as bars without covers, a pie or a donut. The list form stays in each card.
|
||||
*
|
||||
* @param games `[{appid, name, minutes}]`
|
||||
* @param chart BARS, PIE or DONUT
|
||||
* @param linked the games are the viewer's own: rows and slices open the detail page
|
||||
*/
|
||||
export default function GamePlayChart({games, chart, linked}) {
|
||||
const {t} = useTranslation();
|
||||
const named = games.map((g) => ({...g, name: g.name ?? `App ${g.appid}`}));
|
||||
if (chart === 'BARS') {
|
||||
const max = Math.max(1, ...named.map((g) => g.minutes));
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{named.map((g) => (
|
||||
<li key={g.appid} className="text-sm">
|
||||
<DrillRow to={linked ? gameHref(g.appid) : undefined}>
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="truncate" title={g.name}>{g.name}</span>
|
||||
<span className="shrink-0 text-muted-foreground tabular-nums">{formatPlaytime(g.minutes)}</span>
|
||||
</div>
|
||||
<div aria-hidden="true" className="mt-1 h-2.5 rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary"
|
||||
style={{width: `${Math.max(2, (g.minutes / max) * 100)}%`}}/>
|
||||
</div>
|
||||
</DrillRow>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SliceChart slices={topSlices(named, (g) => g.minutes)} donut={chart === 'DONUT'}
|
||||
hrefOf={(slice) => (linked ? gameHref(slice.item.appid) : null)}
|
||||
valueText={(slice) => formatPlaytime(slice.value)} note={t('STATS.TOP_GAMES.SHARE')}/>
|
||||
);
|
||||
}
|
||||
@@ -10,15 +10,23 @@ import {sliceColors, slicePaths} from '../../utils/statCharts.js';
|
||||
const RADIUS = 50;
|
||||
const HOLE = 30;
|
||||
|
||||
const useSliceTarget = (slice, drillKey, label, linkOf) => useDrillTarget({
|
||||
to: slice.other ? null : linkOf?.(slice.name),
|
||||
params: slice.other ? null : {key: drillKey, arg: slice.name},
|
||||
title: slice.other ? '' : label(slice.name),
|
||||
count: slice.value,
|
||||
});
|
||||
// Names are not unique (two games can share one); a game slice has an app id.
|
||||
const sliceKey = (slice) => (slice.other ? 'other' : slice.item?.appid ?? slice.name);
|
||||
|
||||
function SlicePath({slice, drillKey, label, linkOf, d, color}) {
|
||||
const target = useSliceTarget(slice, drillKey, label, linkOf);
|
||||
const useSliceTarget = (slice, drillKey, label, linkOf, hrefOf) => {
|
||||
const target = useDrillTarget({
|
||||
to: slice.other ? null : linkOf?.(slice.name),
|
||||
params: slice.other ? null : {key: drillKey, arg: slice.name},
|
||||
title: slice.other ? '' : label(slice.name),
|
||||
count: slice.value,
|
||||
});
|
||||
if (!hrefOf) return target;
|
||||
const to = !slice.other && slice.value > 0 ? hrefOf(slice) : null;
|
||||
return to ? {to} : null;
|
||||
};
|
||||
|
||||
function SlicePath({slice, drillKey, label, linkOf, hrefOf, d, color}) {
|
||||
const target = useSliceTarget(slice, drillKey, label, linkOf, hrefOf);
|
||||
const navigate = useNavigate();
|
||||
if (!d) return null;
|
||||
return (
|
||||
@@ -27,18 +35,19 @@ function SlicePath({slice, drillKey, label, linkOf, d, color}) {
|
||||
);
|
||||
}
|
||||
|
||||
function SliceRow({slice, drillKey, label, linkOf, color}) {
|
||||
function SliceRow({slice, drillKey, label, linkOf, hrefOf, valueText, color}) {
|
||||
const {t} = useTranslation();
|
||||
const target = useSliceTarget(slice, drillKey, label, linkOf);
|
||||
const target = useSliceTarget(slice, drillKey, label, linkOf, hrefOf);
|
||||
const games = t('STATS.TASTE.GAMES', {count: slice.value});
|
||||
const text = slice.other ? t('STATS.CHART.OTHER') : label(slice.name);
|
||||
return (
|
||||
<li className="text-sm">
|
||||
<DrillRow onSelect={target?.onClick} to={target?.to} className="flex items-center gap-2">
|
||||
<span className={cn('size-3 shrink-0 rounded-sm', !color && 'bg-muted')}
|
||||
style={color ? {background: color} : undefined}/>
|
||||
<span className="min-w-0 flex-1 truncate">{slice.other ? t('STATS.CHART.OTHER') : label(slice.name)}</span>
|
||||
<span className="min-w-0 flex-1 truncate" title={text}>{text}</span>
|
||||
<span className="ml-auto shrink-0 whitespace-nowrap text-muted-foreground tabular-nums">
|
||||
{slice.item ? `${formatPlaytime(slice.item.minutes)} · ${games}` : games}
|
||||
{valueText ? valueText(slice) : slice.item ? `${formatPlaytime(slice.item.minutes)} · ${games}` : games}
|
||||
</span>
|
||||
</DrillRow>
|
||||
</li>
|
||||
@@ -52,12 +61,14 @@ function SliceRow({slice, drillKey, label, linkOf, color}) {
|
||||
* @param drillKey the key of the games request; the slice name is its argument
|
||||
* @param label shown name of a slice name
|
||||
* @param linkOf library URL of a slice name; the own views link there instead of opening the dialog
|
||||
* @param hrefOf `(slice) => url|null`: a slice links there instead of using `linkOf` or the dialog; never "other" or 0
|
||||
* @param valueText `(slice) => string`: the text at the right of a legend row
|
||||
* @param center shown in the hole of a donut
|
||||
* @param note a line under the legend
|
||||
*
|
||||
* A slice without games has a legend row only.
|
||||
*/
|
||||
export default function SliceChart({slices, donut = false, drillKey, label = (name) => name, linkOf, center, note}) {
|
||||
export default function SliceChart({slices, donut = false, drillKey, label = (name) => name, linkOf, hrefOf, valueText, center, note}) {
|
||||
const colors = sliceColors(slices);
|
||||
const paths = slicePaths(slices.map((slice) => slice.value), RADIUS, donut ? HOLE : 0);
|
||||
return (
|
||||
@@ -66,8 +77,8 @@ export default function SliceChart({slices, donut = false, drillKey, label = (na
|
||||
<div className="relative size-40 max-w-40 shrink-0">
|
||||
<svg viewBox={`0 0 ${2 * RADIUS} ${2 * RADIUS}`} aria-hidden="true" className="size-full">
|
||||
{slices.map((slice, index) => (
|
||||
<SlicePath key={slice.other ? 'other' : slice.name} slice={slice} drillKey={drillKey}
|
||||
label={label} linkOf={linkOf} d={paths[index]} color={colors[index]}/>
|
||||
<SlicePath key={sliceKey(slice)} slice={slice} drillKey={drillKey}
|
||||
label={label} linkOf={linkOf} hrefOf={hrefOf} d={paths[index]} color={colors[index]}/>
|
||||
))}
|
||||
</svg>
|
||||
{donut && center && (
|
||||
@@ -78,8 +89,9 @@ export default function SliceChart({slices, donut = false, drillKey, label = (na
|
||||
</div>
|
||||
<ul className="w-full min-w-0 flex-1 space-y-1">
|
||||
{slices.map((slice, index) => (
|
||||
<SliceRow key={slice.other ? 'other' : slice.name} slice={slice} drillKey={drillKey}
|
||||
label={label} linkOf={linkOf} color={colors[index]}/>
|
||||
<SliceRow key={sliceKey(slice)} slice={slice} drillKey={drillKey}
|
||||
label={label} linkOf={linkOf} hrefOf={hrefOf} valueText={valueText}
|
||||
color={colors[index]}/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -3,26 +3,35 @@ import {Trophy} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import StatsSection from './StatsSection.jsx';
|
||||
import ChartPicker from './ChartPicker.jsx';
|
||||
import GamePlayChart from './GamePlayChart.jsx';
|
||||
import GameRow from './GameRow.jsx';
|
||||
import {useStatDrill} from './StatDrill.jsx';
|
||||
import {formatPlaytime} from '../../utils/games.js';
|
||||
import {playChart} from '../../utils/statCharts.js';
|
||||
|
||||
export default function TopGamesStats({topGames, linked = true}) {
|
||||
/**
|
||||
* @param chart LIST, BARS, PIE or DONUT
|
||||
* @param onChart `(form)`: shows the picker
|
||||
*/
|
||||
export default function TopGamesStats({topGames, linked = true, chart = 'LIST', onChart}) {
|
||||
const {t} = useTranslation();
|
||||
const drill = useStatDrill();
|
||||
const max = Math.max(1, ...topGames.mostPlayed.map((g) => g.minutes));
|
||||
const form = playChart(chart, topGames.mostPlayed);
|
||||
return (
|
||||
<StatsSection title={t('STATS.TOP_GAMES.TITLE')} icon={Trophy}>
|
||||
<StatsSection title={t('STATS.TOP_GAMES.TITLE')} icon={Trophy}
|
||||
action={onChart && <ChartPicker group="TOP_GAMES" value={chart} onChange={onChart}/>}>
|
||||
{topGames.mostPlayed.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('STATS.TOP_GAMES.EMPTY')}</p>
|
||||
) : (
|
||||
) : form === 'LIST' ? (
|
||||
<ol className="space-y-2">
|
||||
{topGames.mostPlayed.map((g) => (
|
||||
<li key={g.appid}><GameRow game={g} value={formatPlaytime(g.minutes)} linked={linked}
|
||||
share={g.minutes / max}/></li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
) : <GamePlayChart games={topGames.mostPlayed} chart={form} linked={linked}/>}
|
||||
{drill && topGames.mostPlayed.length > 0 && (
|
||||
<Button variant="ghost" size="sm" className="self-start max-sm:min-h-11"
|
||||
onClick={() => drill.open(t('STATS.TOP_GAMES.TITLE'), {key: 'MOST_PLAYED'})}>
|
||||
|
||||
@@ -6,7 +6,7 @@ import TasteStats from './TasteStats.jsx';
|
||||
import HabitsStats from './HabitsStats.jsx';
|
||||
import FinishedStats from './FinishedStats.jsx';
|
||||
import PlatformStats from './PlatformStats.jsx';
|
||||
import {chartOf} from '../../utils/statCharts.js';
|
||||
import {chartProps} from '../../utils/statCharts.js';
|
||||
|
||||
/**
|
||||
* One stat group as a tile; null while its data is not there. A function, not a component: a tile grid leaves out a
|
||||
@@ -15,17 +15,18 @@ import {chartOf} from '../../utils/statCharts.js';
|
||||
* @param stats the answer of GET /api/library/stats, or the stats of a public profile
|
||||
* @param linked true for the user's own games: the dashboard and the owner on the profile
|
||||
* @param charts the chart form per group of the user whose stats these are
|
||||
* @param onChart `(group, form)`: shows the picker; null for visitors
|
||||
*/
|
||||
export default function statGroupTile(id, stats, linked = true, charts = null) {
|
||||
export default function statGroupTile(id, stats, linked = true, charts = null, onChart = null) {
|
||||
switch (id) {
|
||||
case 'STATS_OVERVIEW': return stats?.overview ? <OverviewStats overview={stats.overview}/> : null;
|
||||
case 'STATS_PILE': return stats?.pile ? <PileStats pile={stats.pile} linked={linked}/> : null;
|
||||
case 'STATS_TREND': return stats?.trend ? <TrendStats trend={stats.trend} chart={chartOf(charts, 'TREND')}/> : null;
|
||||
case 'STATS_TOP_GAMES': return stats?.topGames ? <TopGamesStats topGames={stats.topGames} linked={linked}/> : null;
|
||||
case 'STATS_TASTE': return stats?.taste ? <TasteStats taste={stats.taste} chart={chartOf(charts, 'TASTE')}/> : null;
|
||||
case 'STATS_TREND': return stats?.trend ? <TrendStats trend={stats.trend} {...chartProps(charts, onChart, 'TREND')}/> : null;
|
||||
case 'STATS_TOP_GAMES': return stats?.topGames ? <TopGamesStats topGames={stats.topGames} linked={linked} {...chartProps(charts, onChart, 'TOP_GAMES')}/> : null;
|
||||
case 'STATS_TASTE': return stats?.taste ? <TasteStats taste={stats.taste} {...chartProps(charts, onChart, 'TASTE')}/> : null;
|
||||
case 'STATS_HABITS': return stats?.habits ? <HabitsStats habits={stats.habits}/> : null;
|
||||
case 'STATS_FINISHED': return stats?.finished ? <FinishedStats finished={stats.finished} chart={chartOf(charts, 'FINISHED')}/> : null;
|
||||
case 'STATS_PLATFORMS': return stats?.platforms ? <PlatformStats platforms={stats.platforms} chart={chartOf(charts, 'PLATFORMS')}/> : null;
|
||||
case 'STATS_FINISHED': return stats?.finished ? <FinishedStats finished={stats.finished} {...chartProps(charts, onChart, 'FINISHED')}/> : null;
|
||||
case 'STATS_PLATFORMS': return stats?.platforms ? <PlatformStats platforms={stats.platforms} {...chartProps(charts, onChart, 'PLATFORMS')}/> : null;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user