Public Access
feat(stats): add chart form selection and pie, donut, line charts
Tag rows with 0 games are no longer clickable.
This commit is contained in:
1 parent
8b94a25844
commit
e054e13a59
21 files changed
+815
-56
No files matched your search
@@ -0,0 +1,25 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {STAT_CHARTS} from '../../utils/statCharts.js';
|
||||
|
||||
/**
|
||||
* The chart forms of a stat group as buttons.
|
||||
*
|
||||
* @param value the shown form
|
||||
* @param onChange `(form)`
|
||||
*/
|
||||
export default function ChartPicker({group, value, onChange}) {
|
||||
const {t} = useTranslation();
|
||||
return (
|
||||
<div role="group" aria-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}
|
||||
onClick={() => onChange(form)}>
|
||||
{t(`STATS.CHART.${form}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,15 @@ import {useTranslation} from 'react-i18next';
|
||||
import {Flag} from 'lucide-react';
|
||||
|
||||
import StatsSection from './StatsSection.jsx';
|
||||
import ChartPicker from './ChartPicker.jsx';
|
||||
import YearLine from './YearLine.jsx';
|
||||
import {DrillRow, StatCount, useStatDrill} from './StatDrill.jsx';
|
||||
|
||||
/**
|
||||
* @param onSelect `(year)`: the rows are buttons
|
||||
* @param bars false: a plain list, without the bars
|
||||
*/
|
||||
export function YearBars({perYear, onSelect}) {
|
||||
export function YearBars({perYear, onSelect, bars = true}) {
|
||||
const max = Math.max(1, ...perYear.map((y) => y.games));
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
@@ -15,10 +18,12 @@ export function YearBars({perYear, onSelect}) {
|
||||
<li key={y.year} className="text-sm">
|
||||
<DrillRow onSelect={onSelect && (() => onSelect(y.year))} className="flex items-center gap-3">
|
||||
<span className="w-12 tabular-nums">{y.year}</span>
|
||||
<div className="h-2 flex-1 rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary"
|
||||
style={{width: `${(y.games / max) * 100}%`}}/>
|
||||
</div>
|
||||
{bars ? (
|
||||
<div className="h-2 flex-1 rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary"
|
||||
style={{width: `${(y.games / max) * 100}%`}}/>
|
||||
</div>
|
||||
) : <span className="flex-1"/>}
|
||||
<span className="w-8 text-right tabular-nums">{y.games}</span>
|
||||
</DrillRow>
|
||||
</li>
|
||||
@@ -27,16 +32,24 @@ export function YearBars({perYear, onSelect}) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function FinishedStats({finished}) {
|
||||
/**
|
||||
* @param chart BARS, LINE or LIST; a line needs two years, fewer draw the bars
|
||||
* @param onChart `(form)`: shows the picker
|
||||
*/
|
||||
export default function FinishedStats({finished, chart = 'BARS', onChart}) {
|
||||
const {t} = useTranslation();
|
||||
const drill = useStatDrill();
|
||||
const title = t('STATS.FINISHED.TITLE');
|
||||
return (
|
||||
<StatsSection title={t('STATS.FINISHED.TITLE')} icon={Flag}>
|
||||
<StatsSection title={title} icon={Flag}
|
||||
action={onChart && <ChartPicker group="FINISHED" value={chart} onChange={onChart}/>}>
|
||||
{finished.perYear.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('STATS.FINISHED.EMPTY')}</p>
|
||||
) : chart === 'LINE' && finished.perYear.length >= 2 ? (
|
||||
<YearLine perYear={finished.perYear} title={title}/>
|
||||
) : (
|
||||
<YearBars perYear={finished.perYear}
|
||||
onSelect={drill && ((year) => drill.open(`${t('STATS.FINISHED.TITLE')}: ${year}`, {key: 'FINISHED_YEAR', arg: year}))}/>
|
||||
<YearBars perYear={finished.perYear} bars={chart !== 'LIST'}
|
||||
onSelect={drill && ((year) => drill.open(`${title}: ${year}`, {key: 'FINISHED_YEAR', arg: year}))}/>
|
||||
)}
|
||||
{finished.finishedBeforeTracking > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -2,6 +2,7 @@ import {useTranslation} from 'react-i18next';
|
||||
import {StatCount} from './StatDrill.jsx';
|
||||
import {formatDay} from '../../utils/format.js';
|
||||
import {trendPoints} from '../../utils/stats.js';
|
||||
import {trendBars} from '../../utils/statCharts.js';
|
||||
|
||||
const WIDTH = 300;
|
||||
const HEIGHT = 60;
|
||||
@@ -10,8 +11,9 @@ const HEIGHT = 60;
|
||||
* The pile size over the last 90 days and the moves of this month.
|
||||
*
|
||||
* @param trend the TREND group of GET /api/library/stats
|
||||
* @param chart LINE or BARS; both are one click target, the days are not
|
||||
*/
|
||||
export default function PileTrend({trend}) {
|
||||
export default function PileTrend({trend, chart = 'LINE'}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
const {days, thisMonth} = trend;
|
||||
return (
|
||||
@@ -32,10 +34,24 @@ export default function PileTrend({trend}) {
|
||||
label={t('STATS.DRILL.TREND_MOVES')} className="flex w-full no-underline">
|
||||
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} preserveAspectRatio="none" className="h-16 w-full"
|
||||
role="img" aria-label={t('STATS.TREND.TITLE')}>
|
||||
<polyline data-testid="pile-trend" points={trendPoints(days, WIDTH, HEIGHT)} fill="none"
|
||||
strokeWidth={2} vectorEffect="non-scaling-stroke" className="stroke-rose-500"/>
|
||||
{chart === 'BARS' ? trendBars(days, WIDTH, HEIGHT).map((bar) => (
|
||||
<rect key={bar.x} x={bar.x} y={bar.y} width={bar.w} height={bar.h} className="fill-rose-500"/>
|
||||
)) : (
|
||||
<polyline data-testid="pile-trend" points={trendPoints(days, WIDTH, HEIGHT)} fill="none"
|
||||
strokeWidth={2} vectorEffect="non-scaling-stroke" className="stroke-rose-500"/>
|
||||
)}
|
||||
</svg>
|
||||
</StatCount>
|
||||
<table className="sr-only">
|
||||
<tbody>
|
||||
{days.map((d) => (
|
||||
<tr key={d.day}>
|
||||
<td>{formatDay(d.day, i18n.language)}</td>
|
||||
<td>{d.backlogGames}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
|
||||
<span>{formatDay(days[0].day, i18n.language)}: {days[0].backlogGames}</span>
|
||||
<span>{formatDay(days[days.length - 1].day, i18n.language)}: {days[days.length - 1].backlogGames}</span>
|
||||
|
||||
@@ -2,18 +2,30 @@ import {useTranslation} from 'react-i18next';
|
||||
import {MonitorSmartphone} from 'lucide-react';
|
||||
|
||||
import StatsSection from './StatsSection.jsx';
|
||||
import ChartPicker from './ChartPicker.jsx';
|
||||
import SliceChart from './SliceChart.jsx';
|
||||
import {TagBars} from './TasteStats.jsx';
|
||||
import {useTagDrill} from './StatDrill.jsx';
|
||||
import {topSlices} from '../../utils/statCharts.js';
|
||||
|
||||
export default function PlatformStats({platforms}) {
|
||||
/**
|
||||
* @param chart BARS, PIE, DONUT or LIST
|
||||
* @param onChart `(form)`: shows the picker
|
||||
*/
|
||||
export default function PlatformStats({platforms, chart = 'BARS', onChart}) {
|
||||
const {t} = useTranslation();
|
||||
const select = useTagDrill();
|
||||
const slices = chart === 'PIE' || chart === 'DONUT';
|
||||
return (
|
||||
<StatsSection title={t('STATS.PLATFORMS.TITLE')} icon={MonitorSmartphone}>
|
||||
<StatsSection title={t('STATS.PLATFORMS.TITLE')} icon={MonitorSmartphone}
|
||||
action={onChart && <ChartPicker group="PLATFORMS" value={chart} onChange={onChart}/>}>
|
||||
{platforms.perPlatform.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('STATS.PLATFORMS.EMPTY')}</p>
|
||||
) : slices ? (
|
||||
<SliceChart slices={topSlices(platforms.perPlatform, (p) => p.games)} donut={chart === 'DONUT'}
|
||||
drillKey="PLATFORM"/>
|
||||
) : (
|
||||
<TagBars tags={platforms.perPlatform} onSelect={select('PLATFORM')}/>
|
||||
<TagBars tags={platforms.perPlatform} onSelect={select('PLATFORM')} bars={chart !== 'LIST'}/>
|
||||
)}
|
||||
</StatsSection>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
import {DrillRow, useDrillTarget} from './StatDrill.jsx';
|
||||
import {formatPlaytime} from '../../utils/games.js';
|
||||
import {slicePaths} from '../../utils/statCharts.js';
|
||||
|
||||
const RADIUS = 50;
|
||||
const HOLE = 30;
|
||||
// Opacity steps of the accent, so every skin and dark mode work.
|
||||
const OPACITY = [1, 0.8, 0.62, 0.46, 0.32];
|
||||
|
||||
const opacityOf = (slice, index) => (slice.other ? 1 : OPACITY[index] ?? 0.2);
|
||||
|
||||
const useSliceTarget = (slice, drillKey, label) => useDrillTarget({
|
||||
params: slice.other ? null : {key: drillKey, arg: slice.name},
|
||||
title: slice.other ? '' : label(slice.name),
|
||||
count: slice.value,
|
||||
});
|
||||
|
||||
function SlicePath({slice, drillKey, label, d, index}) {
|
||||
const target = useSliceTarget(slice, drillKey, label);
|
||||
return (
|
||||
<path d={d} fillRule="evenodd" onClick={target?.onClick}
|
||||
className={`${slice.other ? 'fill-muted-foreground' : 'fill-primary'} stroke-card ${target ? 'cursor-pointer' : ''}`}
|
||||
fillOpacity={opacityOf(slice, index)} strokeWidth={1.5}/>
|
||||
);
|
||||
}
|
||||
|
||||
function SliceRow({slice, drillKey, label, index}) {
|
||||
const {t} = useTranslation();
|
||||
const target = useSliceTarget(slice, drillKey, label);
|
||||
const games = t('STATS.TASTE.GAMES', {count: slice.value});
|
||||
return (
|
||||
<li className="text-sm">
|
||||
<DrillRow onSelect={target?.onClick} className="flex items-center gap-2">
|
||||
<span className={`size-3 shrink-0 rounded-sm ${slice.other ? 'bg-muted-foreground' : 'bg-primary'}`}
|
||||
style={{opacity: opacityOf(slice, index)}}/>
|
||||
<span className="min-w-0 flex-1 truncate">{slice.other ? t('STATS.CHART.OTHER') : label(slice.name)}</span>
|
||||
<span className="ml-auto shrink-0 whitespace-nowrap text-muted-foreground tabular-nums">
|
||||
{slice.item ? `${formatPlaytime(slice.item.minutes)} · ${games}` : games}
|
||||
</span>
|
||||
</DrillRow>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A pie or donut with a legend. The legend rows do what the slices do, so the keyboard reaches every slice.
|
||||
*
|
||||
* @param slices from `topSlices`, sized by `value`
|
||||
* @param drillKey the key of the games request; the slice name is its argument
|
||||
* @param label shown name of a slice name
|
||||
* @param note a line under the legend
|
||||
*/
|
||||
export default function SliceChart({slices, donut = false, drillKey, label = (name) => name, note}) {
|
||||
const paths = slicePaths(slices.map((slice) => slice.value), RADIUS, donut ? HOLE : 0);
|
||||
return (
|
||||
<div className="@container space-y-2">
|
||||
<div className="flex flex-col items-center gap-4 @sm:flex-row">
|
||||
<svg viewBox={`0 0 ${2 * RADIUS} ${2 * RADIUS}`} aria-hidden="true" className="size-40 max-w-40 shrink-0">
|
||||
{slices.map((slice, index) => (
|
||||
<SlicePath key={slice.other ? 'other' : slice.name} slice={slice} drillKey={drillKey}
|
||||
label={label} d={paths[index]} index={index}/>
|
||||
))}
|
||||
</svg>
|
||||
<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} index={index}/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{note && <p className="text-xs text-muted-foreground">{note}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
import {Card} from '@/components/ui/card';
|
||||
|
||||
export default function StatsSection({title, icon: Icon, children}) {
|
||||
/**
|
||||
* @param action shown at the right of the title, e.g. a ChartPicker
|
||||
*/
|
||||
export default function StatsSection({title, icon: Icon, action, children}) {
|
||||
return (
|
||||
<Card className="gap-4 p-5">
|
||||
<h2 className="flex items-center gap-2 font-semibold">
|
||||
<Icon className="size-4 text-primary"/>
|
||||
{title}
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="flex items-center gap-2 font-semibold">
|
||||
<Icon className="size-4 text-primary"/>
|
||||
{title}
|
||||
</h2>
|
||||
{action && <div className="ml-auto font-normal">{action}</div>}
|
||||
</div>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -2,31 +2,37 @@ import {useTranslation} from 'react-i18next';
|
||||
import {Palette} from 'lucide-react';
|
||||
|
||||
import StatsSection from './StatsSection.jsx';
|
||||
import ChartPicker from './ChartPicker.jsx';
|
||||
import SliceChart from './SliceChart.jsx';
|
||||
import {DrillRow, useTagDrill} from './StatDrill.jsx';
|
||||
import {formatPlaytime} from '../../utils/games.js';
|
||||
import {genreKey} from '../../utils/stats.js';
|
||||
import {topSlices} from '../../utils/statCharts.js';
|
||||
|
||||
/**
|
||||
* @param onSelect `(tag, shownLabel)`: the rows are buttons
|
||||
* @param onSelect `(tag, shownLabel)`: the rows are buttons, except a row with 0 games
|
||||
* @param bars false: a plain list, without the bars
|
||||
*/
|
||||
export function TagBars({tags, label = (name) => name, onSelect}) {
|
||||
export function TagBars({tags, label = (name) => name, onSelect, bars = true}) {
|
||||
const {t} = useTranslation();
|
||||
const max = Math.max(1, ...tags.map((tag) => tag.minutes));
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{tags.map((tag) => (
|
||||
<li key={tag.name} className="text-sm">
|
||||
<DrillRow onSelect={onSelect && (() => onSelect(tag, label(tag.name)))}>
|
||||
<DrillRow onSelect={onSelect && tag.games > 0 && (() => onSelect(tag, label(tag.name)))}>
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="truncate">{label(tag.name)}</span>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatPlaytime(tag.minutes)} · {t('STATS.TASTE.GAMES', {count: tag.games})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1.5 rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary"
|
||||
style={{width: `${Math.max(2, (tag.minutes / max) * 100)}%`}}/>
|
||||
</div>
|
||||
{bars && (
|
||||
<div className="mt-1 h-1.5 rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary"
|
||||
style={{width: `${Math.max(2, (tag.minutes / max) * 100)}%`}}/>
|
||||
</div>
|
||||
)}
|
||||
</DrillRow>
|
||||
</li>
|
||||
))}
|
||||
@@ -34,27 +40,41 @@ export function TagBars({tags, label = (name) => name, onSelect}) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function TasteStats({taste}) {
|
||||
/**
|
||||
* @param chart BARS, PIE, DONUT or LIST; the pie and donut size a slice by games, and a game can count in several
|
||||
* @param onChart `(form)`: shows the picker
|
||||
*/
|
||||
export default function TasteStats({taste, chart = 'BARS', onChart}) {
|
||||
const {t} = useTranslation();
|
||||
const select = useTagDrill();
|
||||
const action = onChart && <ChartPicker group="TASTE" value={chart} onChange={onChart}/>;
|
||||
if (taste.genres.length === 0 && taste.moods.length === 0) {
|
||||
return (
|
||||
<StatsSection title={t('STATS.TASTE.TITLE')} icon={Palette}>
|
||||
<StatsSection title={t('STATS.TASTE.TITLE')} icon={Palette} action={action}>
|
||||
<p className="text-sm text-muted-foreground">{t('STATS.TASTE.EMPTY')}</p>
|
||||
</StatsSection>
|
||||
);
|
||||
}
|
||||
const tagChart = (tags, drillKey, label) => {
|
||||
if (chart !== 'PIE' && chart !== 'DONUT') {
|
||||
return <TagBars tags={tags} onSelect={select(drillKey)} label={label} bars={chart !== 'LIST'}/>;
|
||||
}
|
||||
const slices = topSlices(tags, (tag) => tag.games);
|
||||
return slices.length > 0 && (
|
||||
<SliceChart slices={slices} donut={chart === 'DONUT'} drillKey={drillKey} label={label}
|
||||
note={t('STATS.CHART.OVERLAP')}/>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<StatsSection title={t('STATS.TASTE.TITLE')} icon={Palette}>
|
||||
<StatsSection title={t('STATS.TASTE.TITLE')} icon={Palette} action={action}>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium">{t('STATS.TASTE.GENRES')}</span>
|
||||
<TagBars tags={taste.genres} onSelect={select('GENRE')}
|
||||
label={(name) => t(`STATS.GENRE.${genreKey(name)}`, {defaultValue: name})}/>
|
||||
{tagChart(taste.genres, 'GENRE', (name) => t(`STATS.GENRE.${genreKey(name)}`, {defaultValue: name}))}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium">{t('STATS.TASTE.MOODS')}</span>
|
||||
<TagBars tags={taste.moods} onSelect={select('MOOD')} label={(name) => t(`PLAY_NEXT.MOOD.${name}`, {defaultValue: name})}/>
|
||||
{tagChart(taste.moods, 'MOOD', (name) => t(`PLAY_NEXT.MOOD.${name}`, {defaultValue: name}))}
|
||||
</div>
|
||||
</div>
|
||||
</StatsSection>
|
||||
|
||||
@@ -2,13 +2,19 @@ import {useTranslation} from 'react-i18next';
|
||||
import {TrendingDown} from 'lucide-react';
|
||||
|
||||
import StatsSection from './StatsSection.jsx';
|
||||
import ChartPicker from './ChartPicker.jsx';
|
||||
import PileTrend from './PileTrend.jsx';
|
||||
|
||||
export default function TrendStats({trend}) {
|
||||
/**
|
||||
* @param chart LINE or BARS
|
||||
* @param onChart `(form)`: shows the picker
|
||||
*/
|
||||
export default function TrendStats({trend, chart = 'LINE', onChart}) {
|
||||
const {t} = useTranslation();
|
||||
return (
|
||||
<StatsSection title={t('STATS.TREND.TITLE')} icon={TrendingDown}>
|
||||
<PileTrend trend={trend}/>
|
||||
<StatsSection title={t('STATS.TREND.TITLE')} icon={TrendingDown}
|
||||
action={onChart && <ChartPicker group="TREND" value={chart} onChange={onChart}/>}>
|
||||
<PileTrend trend={trend} chart={chart}/>
|
||||
</StatsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
import {StatCount, useDrillTarget} from './StatDrill.jsx';
|
||||
import {linePoints, yearSeries} from '../../utils/statCharts.js';
|
||||
|
||||
const WIDTH = 300;
|
||||
const HEIGHT = 80;
|
||||
|
||||
function YearPoint({point, year, games, title}) {
|
||||
const target = useDrillTarget({params: {key: 'FINISHED_YEAR', arg: year}, title: `${title}: ${year}`, count: games});
|
||||
return (
|
||||
<circle cx={point.x} cy={point.y} r={4} onClick={target?.onClick}
|
||||
className={`fill-primary stroke-card ${target ? 'cursor-pointer' : ''}`} strokeWidth={1.5}/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finished games per year as a line. The year labels under it do what the points do.
|
||||
*
|
||||
* @param perYear `[{year, games}]`
|
||||
* @param title the group title, the start of the dialog title
|
||||
*/
|
||||
export default function YearLine({perYear, title}) {
|
||||
const {t} = useTranslation();
|
||||
const series = yearSeries(perYear);
|
||||
const points = linePoints(series.map((y) => y.games), WIDTH, HEIGHT);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<svg viewBox={`-8 -8 ${WIDTH + 16} ${HEIGHT + 16}`} aria-hidden="true" className="h-24 w-full">
|
||||
<polyline points={points.map((p) => `${p.x},${p.y}`).join(' ')} fill="none" strokeWidth={2}
|
||||
className="stroke-primary" strokeLinejoin="round"/>
|
||||
{series.map((y, i) => (
|
||||
<YearPoint key={y.year} point={points[i]} year={y.year} games={y.games} title={title}/>
|
||||
))}
|
||||
</svg>
|
||||
<ul className="flex flex-wrap justify-between gap-x-3 gap-y-1 text-xs tabular-nums">
|
||||
{series.map((y) => (
|
||||
<li key={y.year}>
|
||||
<StatCount params={{key: 'FINISHED_YEAR', arg: y.year}} count={y.games} title={`${title}: ${y.year}`}
|
||||
label={`${y.year}: ${t('STATS.TASTE.GAMES', {count: y.games})}`}>
|
||||
{y.year}: {y.games}
|
||||
</StatCount>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +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';
|
||||
|
||||
/**
|
||||
* One stat group as a tile; null while its data is not there. A function, not a component: a tile grid leaves out a
|
||||
@@ -13,17 +14,18 @@ import PlatformStats from './PlatformStats.jsx';
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
export default function statGroupTile(id, stats, linked = true) {
|
||||
export default function statGroupTile(id, stats, linked = true, charts = 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}/> : 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}/> : null;
|
||||
case 'STATS_TASTE': return stats?.taste ? <TasteStats taste={stats.taste} chart={chartOf(charts, 'TASTE')}/> : null;
|
||||
case 'STATS_HABITS': return stats?.habits ? <HabitsStats habits={stats.habits}/> : null;
|
||||
case 'STATS_FINISHED': return stats?.finished ? <FinishedStats finished={stats.finished}/> : null;
|
||||
case 'STATS_PLATFORMS': return stats?.platforms ? <PlatformStats platforms={stats.platforms}/> : 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;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user