Public Access
feat(stats): status donut, meters and fixed chart colors
Pie and donut charts use one fixed color-blind safe palette in every skin and accent. The overview and the profile status tile show a status donut with the total in the middle. Habits and achievements get meter bars, top games get share bars, the pile of shame bar uses the palette.
This commit is contained in:
1 parent
dabb0c941c
commit
8aa3181c37
20 files changed
+468
-110
No files matched your search
@@ -6,6 +6,7 @@ import {Card} from '@/components/ui/card';
|
||||
import GameCover from './GameCover.jsx';
|
||||
import FitText from './FitText.jsx';
|
||||
import {StatCount} from './stats/StatDrill.jsx';
|
||||
import StatMeter from './stats/StatMeter.jsx';
|
||||
|
||||
/**
|
||||
* @param summary the answer of GET /api/library/achievements/summary
|
||||
@@ -20,12 +21,13 @@ export default function AchievementsCard({summary}) {
|
||||
// A private profile keeps games pending; the privacy hint explains that better than the pending count.
|
||||
const steamHidesAll = hidden > 0 && started === 0;
|
||||
|
||||
const average = summary.averageCompletion != null ? `${summary.averageCompletion.toLocaleString(i18n.language)} %` : '–';
|
||||
const stats = [
|
||||
[(summary.unlocked ?? 0).toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.UNLOCKED')],
|
||||
[perfect.toLocaleString(i18n.language), t('DASHBOARD.ACHIEVEMENTS.PERFECT'),
|
||||
{params: {key: 'PERFECT'}, count: perfect}],
|
||||
[summary.averageCompletion != null ? `${summary.averageCompletion.toLocaleString(i18n.language)} %` : '–',
|
||||
t('DASHBOARD.ACHIEVEMENTS.AVERAGE')],
|
||||
[average, t('DASHBOARD.ACHIEVEMENTS.AVERAGE'), undefined,
|
||||
summary.averageCompletion != null ? {percent: summary.averageCompletion, valueText: average} : null],
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -36,11 +38,12 @@ export default function AchievementsCard({summary}) {
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{stats.map(([value, label, drill]) => (
|
||||
{stats.map(([value, label, drill, meter]) => (
|
||||
<div key={label} className="rounded-lg bg-muted/50 p-3 text-center">
|
||||
<div className="text-xl md:text-2xl font-bold tabular-nums">
|
||||
<StatCount {...drill} title={label} label={`${label}: ${value}`}>{value}</StatCount>
|
||||
</div>
|
||||
{meter && <StatMeter {...meter} label={label}/>}
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -31,6 +31,22 @@ describe('AchievementsCard', () => {
|
||||
expect(screen.queryByText(/DASHBOARD.ACHIEVEMENTS.PENDING/)).toBeNull();
|
||||
});
|
||||
|
||||
it('showsTheAverageCompletionAsAMeter', () => {
|
||||
renderCard(summary());
|
||||
|
||||
const meter = screen.getByRole('meter', {name: 'DASHBOARD.ACHIEVEMENTS.AVERAGE'});
|
||||
|
||||
expect(meter.getAttribute('aria-valuenow')).toBe('71');
|
||||
expect(meter.getAttribute('aria-valuetext')).toBe('71,3 %');
|
||||
});
|
||||
|
||||
it('showsNoMeterWithoutAnAverage', () => {
|
||||
renderCard(summary({averageCompletion: null}));
|
||||
|
||||
expect(screen.queryByRole('meter')).toBeNull();
|
||||
expect(screen.getByText('–')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('says how many games are still syncing', () => {
|
||||
renderCard(summary({pendingGames: 7}));
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@ import {Dices, Inbox, Shuffle} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import {cn} from '@/lib/utils';
|
||||
import GameCover from './GameCover.jsx';
|
||||
import PileTrend from './stats/PileTrend.jsx';
|
||||
import {StatCount} from './stats/StatDrill.jsx';
|
||||
import {STATUS_META} from './GameStatusBadge.jsx';
|
||||
import {backlogShare} from '../utils/backlog.js';
|
||||
import {GAME_STATUSES} from '../utils/limits.js';
|
||||
import {sliceColors, statusSlices} from '../utils/statCharts.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import {libraryLink} from '../utils/statGames.js';
|
||||
|
||||
@@ -20,6 +20,8 @@ export default function PileOfShameCard({summary, trend, syncing, onShuffle, shu
|
||||
const {t, i18n} = useTranslation();
|
||||
const backlog = summary.byStatus?.BACKLOG ?? 0;
|
||||
const suggestion = summary.suggestion;
|
||||
const slices = statusSlices(summary.byStatus);
|
||||
const colors = sliceColors(slices);
|
||||
|
||||
if (syncing && summary.total === 0) {
|
||||
return (
|
||||
@@ -64,22 +66,20 @@ export default function PileOfShameCard({summary, trend, syncing, onShuffle, shu
|
||||
{summary.total > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex h-2.5 overflow-hidden rounded-full bg-muted">
|
||||
{GAME_STATUSES.map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className={STATUS_META[s].bar}
|
||||
style={{width: `${(summary.byStatus[s] / summary.total) * 100}%`}}
|
||||
/>
|
||||
{slices.map((s, i) => s.value > 0 && (
|
||||
<div key={s.name} className="border-card [&:not(:last-child)]:border-r-2"
|
||||
style={{width: `${(s.value / summary.total) * 100}%`, background: colors[i]}}/>
|
||||
))}
|
||||
</div>
|
||||
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
{GAME_STATUSES.map((s) => (
|
||||
{slices.map(({name: s, value}, i) => (
|
||||
<li key={s} className="flex items-center gap-1.5">
|
||||
<span className={`size-2 rounded-full ${STATUS_META[s].bar}`}/>
|
||||
<span className={cn('size-2 rounded-full', !colors[i] && 'bg-muted')}
|
||||
style={colors[i] ? {background: colors[i]} : undefined}/>
|
||||
{t(`BACKLOG.STATUS.${s}`)}
|
||||
<StatCount to={libraryLink(s)} count={summary.byStatus[s]}
|
||||
label={`${t(`BACKLOG.STATUS.${s}`)}: ${summary.byStatus[s]}`}>
|
||||
<span className="tabular-nums text-foreground">{summary.byStatus[s]}</span>
|
||||
<StatCount to={libraryLink(s)} count={value}
|
||||
label={`${t(`BACKLOG.STATUS.${s}`)}: ${value}`}>
|
||||
<span className="tabular-nums text-foreground">{value}</span>
|
||||
</StatCount>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -75,6 +75,16 @@ describe('PileOfShameCard', () => {
|
||||
expect(screen.getByRole('link', {name: 'BACKLOG.STATUS.PLAYING: 3'})).toBeTruthy();
|
||||
});
|
||||
|
||||
it('colorsTheStatusBarLikeTheDonutAndSkipsStatusesWithoutGames', () => {
|
||||
const {container} = renderCard({summary: summary(2)});
|
||||
|
||||
const segments = [...container.querySelector('.h-2\\.5').children];
|
||||
|
||||
expect(segments).toHaveLength(2);
|
||||
expect(segments[0].getAttribute('style')).toContain('var(--chart-1)');
|
||||
expect(segments[1].getAttribute('style')).toContain('var(--chart-2)');
|
||||
});
|
||||
|
||||
it('shows the backlog hours when IGDB knows some', () => {
|
||||
renderCard({summary: summary(2, {backlogMinutes: 7440})});
|
||||
|
||||
|
||||
@@ -6,12 +6,22 @@ import {gameHref} from '../../utils/statGames.js';
|
||||
/**
|
||||
* A game with its cover and a value on the right; links to the game page when the games are the viewer's own, plain
|
||||
* otherwise.
|
||||
*
|
||||
* @param share 0 to 1: a bar under the name, as long as this share
|
||||
*/
|
||||
export default function GameRow({game, value, linked = true}) {
|
||||
export default function GameRow({game, value, linked = true, share}) {
|
||||
const name = <FitText text={game.name ?? `App ${game.appid}`}/>;
|
||||
const content = (
|
||||
<>
|
||||
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-16 shrink-0"/>
|
||||
<span className="@container min-w-0 flex-1 text-sm font-medium"><FitText text={game.name ?? `App ${game.appid}`}/></span>
|
||||
{share == null ? <span className="@container min-w-0 flex-1 text-sm font-medium">{name}</span> : (
|
||||
<div className="@container min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium">{name}</span>
|
||||
<div aria-hidden="true" className="mt-1.5 h-1.5 rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary" style={{width: `${Math.max(2, share * 100)}%`}}/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<span className="flex items-center gap-2 text-sm text-muted-foreground tabular-nums">{value}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ export default function HabitsStats({habits}) {
|
||||
const day = habits.longestDay
|
||||
? formatDay(habits.longestDay.day, i18n.language, {dateStyle: 'medium'})
|
||||
: null;
|
||||
const underOneHour = habits.underOneHourShare != null && formatPercent(habits.underOneHourShare, i18n.language);
|
||||
return (
|
||||
<StatsSection title={t('STATS.HABITS.TITLE')} icon={Rocket}>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
@@ -21,9 +22,9 @@ export default function HabitsStats({habits}) {
|
||||
<StatTile label={t('STATS.HABITS.LONGEST_DAY')} value={formatPlaytime(habits.longestDay.minutes)}
|
||||
hint={day} drill={{params: {key: 'LONGEST_DAY'}}}/>
|
||||
)}
|
||||
{habits.underOneHourShare != null && (
|
||||
<StatTile label={t('STATS.HABITS.UNDER_ONE_HOUR')}
|
||||
value={formatPercent(habits.underOneHourShare, i18n.language)}
|
||||
{underOneHour && (
|
||||
<StatTile label={t('STATS.HABITS.UNDER_ONE_HOUR')} value={underOneHour}
|
||||
meter={{percent: habits.underOneHourShare * 100, valueText: underOneHour}}
|
||||
hint={t('STATS.HABITS.UNDER_ONE_HOUR_HINT', {count: habits.underOneHourGames})}
|
||||
drill={{params: {key: 'UNDER_ONE_HOUR'}, count: habits.underOneHourGames}}/>
|
||||
)}
|
||||
|
||||
@@ -3,37 +3,29 @@ import {Gamepad2} from 'lucide-react';
|
||||
|
||||
import StatsSection from './StatsSection.jsx';
|
||||
import StatTile from './StatTile.jsx';
|
||||
import {GAME_STATUSES} from '../../utils/limits.js';
|
||||
import {StatCount} from './StatDrill.jsx';
|
||||
import StatusDonut from './StatusDonut.jsx';
|
||||
import {formatPlaytime} from '../../utils/games.js';
|
||||
import {libraryLink} from '../../utils/statGames.js';
|
||||
import {cn} from '@/lib/utils';
|
||||
|
||||
export default function OverviewStats({overview}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
const n = (value) => value.toLocaleString(i18n.language);
|
||||
const empty = overview.totalGames === 0;
|
||||
return (
|
||||
<StatsSection title={t('STATS.OVERVIEW.TITLE')} icon={Gamepad2}>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatTile label={t('STATS.OVERVIEW.GAMES')} value={n(overview.totalGames)}
|
||||
drill={{to: libraryLink(), params: {key: 'TOTAL'}, count: overview.totalGames}}/>
|
||||
{!empty && <StatusDonut byStatus={overview.byStatus}/>}
|
||||
<div className={cn('grid grid-cols-2 gap-3', empty ? 'sm:grid-cols-4' : 'sm:grid-cols-3')}>
|
||||
{empty && (
|
||||
<StatTile label={t('STATS.OVERVIEW.GAMES')} value={n(overview.totalGames)}
|
||||
drill={{to: libraryLink(), params: {key: 'TOTAL'}, count: overview.totalGames}}/>
|
||||
)}
|
||||
<StatTile label={t('STATS.OVERVIEW.PLAYTIME')} value={formatPlaytime(overview.totalPlaytimeMinutes)}/>
|
||||
<StatTile label={t('STATS.OVERVIEW.PLAYED')} value={n(overview.playedGames)}
|
||||
drill={{params: {key: 'PLAYED'}, count: overview.playedGames}}/>
|
||||
<StatTile label={t('STATS.OVERVIEW.NEVER_STARTED')} value={n(overview.neverStarted)}
|
||||
drill={{params: {key: 'NEVER_STARTED'}, count: overview.neverStarted}}/>
|
||||
</div>
|
||||
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
||||
{GAME_STATUSES.map((s) => (
|
||||
<li key={s}>
|
||||
{t(`BACKLOG.STATUS.${s}`)}{' '}
|
||||
<StatCount to={libraryLink(s)} params={{key: 'STATUS', arg: s}} title={t(`BACKLOG.STATUS.${s}`)}
|
||||
count={overview.byStatus[s] ?? 0}
|
||||
label={`${t(`BACKLOG.STATUS.${s}`)}: ${n(overview.byStatus[s] ?? 0)}`}>
|
||||
<span className="tabular-nums text-foreground">{n(overview.byStatus[s] ?? 0)}</span>
|
||||
</StatCount>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</StatsSection>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,41 @@
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
import {cn} from '@/lib/utils';
|
||||
|
||||
import {DrillRow, useDrillTarget} from './StatDrill.jsx';
|
||||
import {formatPlaytime} from '../../utils/games.js';
|
||||
import {slicePaths} from '../../utils/statCharts.js';
|
||||
import {sliceColors, 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({
|
||||
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,
|
||||
});
|
||||
|
||||
function SlicePath({slice, drillKey, label, d, index}) {
|
||||
const target = useSliceTarget(slice, drillKey, label);
|
||||
function SlicePath({slice, drillKey, label, linkOf, d, color}) {
|
||||
const target = useSliceTarget(slice, drillKey, label, linkOf);
|
||||
const navigate = useNavigate();
|
||||
if (!d) return null;
|
||||
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}/>
|
||||
<path d={d} fillRule="evenodd" onClick={target && (target.onClick ?? (() => navigate(target.to)))}
|
||||
className={cn('stroke-card', target && 'cursor-pointer')} style={{fill: color}} strokeWidth={1.5}/>
|
||||
);
|
||||
}
|
||||
|
||||
function SliceRow({slice, drillKey, label, index}) {
|
||||
function SliceRow({slice, drillKey, label, linkOf, color}) {
|
||||
const {t} = useTranslation();
|
||||
const target = useSliceTarget(slice, drillKey, label);
|
||||
const target = useSliceTarget(slice, drillKey, label, linkOf);
|
||||
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)}}/>
|
||||
<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="ml-auto shrink-0 whitespace-nowrap text-muted-foreground tabular-nums">
|
||||
{slice.item ? `${formatPlaytime(slice.item.minutes)} · ${games}` : games}
|
||||
@@ -50,23 +51,35 @@ function SliceRow({slice, drillKey, label, index}) {
|
||||
* @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 linkOf library URL of a slice name; the own views link there instead of opening the dialog
|
||||
* @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, note}) {
|
||||
export default function SliceChart({slices, donut = false, drillKey, label = (name) => name, linkOf, center, note}) {
|
||||
const colors = sliceColors(slices);
|
||||
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>
|
||||
<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]}/>
|
||||
))}
|
||||
</svg>
|
||||
{donut && center && (
|
||||
<div className="pointer-events-none absolute inset-0 grid place-items-center">
|
||||
<div className="pointer-events-auto">{center}</div>
|
||||
</div>
|
||||
)}
|
||||
</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} index={index}/>
|
||||
label={label} linkOf={linkOf} color={colors[index]}/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -60,13 +60,13 @@ export function useTagDrill() {
|
||||
return (key) => drill && ((tag, text) => drill.open(text, {key, arg: tag.name}));
|
||||
}
|
||||
|
||||
/** A bar row: a button when it has `onSelect`. */
|
||||
export function DrillRow({onSelect, className, children}) {
|
||||
const ROW_CLASS = 'block min-h-11 w-full rounded-sm text-left hover:text-primary focus-visible:ring-2 focus-visible:ring-ring';
|
||||
|
||||
/** A bar row: a link when it has `to`, a button when it has `onSelect`. */
|
||||
export function DrillRow({onSelect, to, className, children}) {
|
||||
if (to) return <Link to={to} className={cn(ROW_CLASS, className)}>{children}</Link>;
|
||||
return onSelect ? (
|
||||
<button type="button" onClick={onSelect}
|
||||
className={cn('block min-h-11 w-full rounded-sm text-left hover:text-primary focus-visible:ring-2 focus-visible:ring-ring', className)}>
|
||||
{children}
|
||||
</button>
|
||||
<button type="button" onClick={onSelect} className={cn(ROW_CLASS, className)}>{children}</button>
|
||||
) : <div className={className}>{children}</div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* A share as a bar.
|
||||
*
|
||||
* @param percent 0 to 100, kept inside
|
||||
* @param label the accessible name
|
||||
* @param valueText the text shown for the value, e.g. "71,3 %"
|
||||
* @param decorative hide it from assistive tech, when the parent already names the value
|
||||
*/
|
||||
export default function StatMeter({percent, label, valueText, decorative}) {
|
||||
const clamped = Math.min(100, Math.max(0, percent));
|
||||
return (
|
||||
<div role="meter" aria-label={label} aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(clamped)}
|
||||
aria-valuetext={valueText} aria-hidden={decorative || undefined} className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary" style={{width: `${clamped}%`}}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it} from 'vitest';
|
||||
import {cleanup, render, screen} from '@testing-library/react';
|
||||
import StatMeter from './StatMeter.jsx';
|
||||
|
||||
describe('StatMeter', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
const bar = () => screen.getByRole('meter', {name: 'Share'}).firstChild;
|
||||
|
||||
it('exposesTheRoundedValueAndTheShownText', () => {
|
||||
render(<StatMeter percent={71.3} label="Share" valueText="71,3 %"/>);
|
||||
|
||||
const meter = screen.getByRole('meter', {name: 'Share'});
|
||||
expect(meter.getAttribute('aria-valuenow')).toBe('71');
|
||||
expect(meter.getAttribute('aria-valuetext')).toBe('71,3 %');
|
||||
expect(bar().style.width).toBe('71.3%');
|
||||
});
|
||||
|
||||
it('keepsThePercentBetween0And100', () => {
|
||||
const {rerender} = render(<StatMeter percent={130} label="Share" valueText="130 %"/>);
|
||||
expect(bar().style.width).toBe('100%');
|
||||
|
||||
rerender(<StatMeter percent={-5} label="Share" valueText="-5 %"/>);
|
||||
expect(bar().style.width).toBe('0%');
|
||||
});
|
||||
});
|
||||
@@ -2,18 +2,21 @@ import {Link} from 'react-router-dom';
|
||||
|
||||
import {cn} from '@/lib/utils';
|
||||
import {useDrillTarget} from './StatDrill.jsx';
|
||||
import StatMeter from './StatMeter.jsx';
|
||||
|
||||
const CLICKABLE = 'block w-full text-left transition-colors hover:border-primary focus-visible:ring-2 focus-visible:ring-ring';
|
||||
|
||||
/**
|
||||
* @param drill `{to, params, title}` of StatCount: the tile is a link or button when it has a target here
|
||||
* @param meter `{percent, valueText}`: a bar under the value
|
||||
*/
|
||||
export default function StatTile({label, value, hint, drill}) {
|
||||
export default function StatTile({label, value, hint, drill, meter}) {
|
||||
const target = useDrillTarget({title: label, ...drill});
|
||||
const body = (
|
||||
<>
|
||||
<div className="text-2xl font-bold tracking-tight tabular-nums">{value}</div>
|
||||
<div className="text-sm text-muted-foreground">{label}</div>
|
||||
{meter && <StatMeter {...meter} label={label} decorative={!!target}/>}
|
||||
{hint && <div className="mt-1 text-xs text-muted-foreground">{hint}</div>}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
import SliceChart from './SliceChart.jsx';
|
||||
import {StatCount} from './StatDrill.jsx';
|
||||
import {libraryLink} from '../../utils/statGames.js';
|
||||
import {statusSlices} from '../../utils/statCharts.js';
|
||||
|
||||
/**
|
||||
* The games per status as a donut with the total in the hole.
|
||||
*
|
||||
* @param byStatus status -> games
|
||||
* @param totalParams games request of the total; null when the total is not clickable
|
||||
*/
|
||||
export default function StatusDonut({byStatus, totalParams = {key: 'TOTAL'}}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
const slices = statusSlices(byStatus);
|
||||
const total = slices.reduce((sum, slice) => sum + slice.value, 0);
|
||||
const n = (value) => value.toLocaleString(i18n.language);
|
||||
const games = t('STATS.OVERVIEW.GAMES');
|
||||
const center = (
|
||||
<div className="text-center">
|
||||
<StatCount to={libraryLink()} params={totalParams} count={total} title={games} label={`${games}: ${n(total)}`}>
|
||||
<span className="text-xl font-bold tabular-nums">{n(total)}</span>
|
||||
</StatCount>
|
||||
<span className="block text-xs text-muted-foreground">{games}</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<SliceChart slices={slices} donut drillKey="STATUS" label={(s) => t(`BACKLOG.STATUS.${s}`)}
|
||||
linkOf={libraryLink} center={center}/>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {formatPlaytime} from '../../utils/games.js';
|
||||
export default function TopGamesStats({topGames, linked = true}) {
|
||||
const {t} = useTranslation();
|
||||
const drill = useStatDrill();
|
||||
const max = Math.max(1, ...topGames.mostPlayed.map((g) => g.minutes));
|
||||
return (
|
||||
<StatsSection title={t('STATS.TOP_GAMES.TITLE')} icon={Trophy}>
|
||||
{topGames.mostPlayed.length === 0 ? (
|
||||
@@ -17,7 +18,8 @@ export default function TopGamesStats({topGames, linked = true}) {
|
||||
) : (
|
||||
<ol className="space-y-2">
|
||||
{topGames.mostPlayed.map((g) => (
|
||||
<li key={g.appid}><GameRow game={g} value={formatPlaytime(g.minutes)} linked={linked}/></li>
|
||||
<li key={g.appid}><GameRow game={g} value={formatPlaytime(g.minutes)} linked={linked}
|
||||
share={g.minutes / max}/></li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user