diff --git a/src/components/AchievementsCard.jsx b/src/components/AchievementsCard.jsx index f7d6014..4914fe5 100644 --- a/src/components/AchievementsCard.jsx +++ b/src/components/AchievementsCard.jsx @@ -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}) {
- {stats.map(([value, label, drill]) => ( + {stats.map(([value, label, drill, meter]) => (
{value}
+ {meter && }
{label}
))} diff --git a/src/components/AchievementsCard.test.jsx b/src/components/AchievementsCard.test.jsx index d8cfac7..8bfa611 100644 --- a/src/components/AchievementsCard.test.jsx +++ b/src/components/AchievementsCard.test.jsx @@ -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})); diff --git a/src/components/PileOfShameCard.jsx b/src/components/PileOfShameCard.jsx index b7bebe6..2d4277c 100644 --- a/src/components/PileOfShameCard.jsx +++ b/src/components/PileOfShameCard.jsx @@ -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 && (
- {GAME_STATUSES.map((s) => ( -
+ {slices.map((s, i) => s.value > 0 && ( +
))}
    - {GAME_STATUSES.map((s) => ( + {slices.map(({name: s, value}, i) => (
  • - + {t(`BACKLOG.STATUS.${s}`)} - - {summary.byStatus[s]} + + {value}
  • ))} diff --git a/src/components/PileOfShameCard.test.jsx b/src/components/PileOfShameCard.test.jsx index ffb5850..ef8ec9d 100644 --- a/src/components/PileOfShameCard.test.jsx +++ b/src/components/PileOfShameCard.test.jsx @@ -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})}); diff --git a/src/components/stats/GameRow.jsx b/src/components/stats/GameRow.jsx index 5fc8d31..5904007 100644 --- a/src/components/stats/GameRow.jsx +++ b/src/components/stats/GameRow.jsx @@ -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 = ; const content = ( <> - + {share == null ? {name} : ( +
    + {name} + + )} {value} ); diff --git a/src/components/stats/HabitsStats.jsx b/src/components/stats/HabitsStats.jsx index 527be0d..0d12871 100644 --- a/src/components/stats/HabitsStats.jsx +++ b/src/components/stats/HabitsStats.jsx @@ -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 (
    @@ -21,9 +22,9 @@ export default function HabitsStats({habits}) { )} - {habits.underOneHourShare != null && ( - )} diff --git a/src/components/stats/OverviewStats.jsx b/src/components/stats/OverviewStats.jsx index a9b144f..f0a6832 100644 --- a/src/components/stats/OverviewStats.jsx +++ b/src/components/stats/OverviewStats.jsx @@ -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 ( -
    - + {!empty && } +
    + {empty && ( + + )}
    -
      - {GAME_STATUSES.map((s) => ( -
    • - {t(`BACKLOG.STATUS.${s}`)}{' '} - - {n(overview.byStatus[s] ?? 0)} - -
    • - ))} -
    ); } diff --git a/src/components/stats/SliceChart.jsx b/src/components/stats/SliceChart.jsx index 1e12176..8e314b0 100644 --- a/src/components/stats/SliceChart.jsx +++ b/src/components/stats/SliceChart.jsx @@ -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 ( - + 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 (
  • - - + + {slice.other ? t('STATS.CHART.OTHER') : label(slice.name)} {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 (
    - +
    + + {donut && center && ( +
    +
    {center}
    +
    + )} +
      {slices.map((slice, index) => ( + label={label} linkOf={linkOf} color={colors[index]}/> ))}
    diff --git a/src/components/stats/StatDrill.jsx b/src/components/stats/StatDrill.jsx index 04fdc52..e186ff9 100644 --- a/src/components/stats/StatDrill.jsx +++ b/src/components/stats/StatDrill.jsx @@ -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 {children}; return onSelect ? ( - + ) :
    {children}
    ; } diff --git a/src/components/stats/StatMeter.jsx b/src/components/stats/StatMeter.jsx new file mode 100644 index 0000000..a4ad9fb --- /dev/null +++ b/src/components/stats/StatMeter.jsx @@ -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 ( +
    +
    +
    + ); +} diff --git a/src/components/stats/StatMeter.test.jsx b/src/components/stats/StatMeter.test.jsx new file mode 100644 index 0000000..ae36529 --- /dev/null +++ b/src/components/stats/StatMeter.test.jsx @@ -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(); + + 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(); + expect(bar().style.width).toBe('100%'); + + rerender(); + expect(bar().style.width).toBe('0%'); + }); +}); diff --git a/src/components/stats/StatTile.jsx b/src/components/stats/StatTile.jsx index ccef8d6..a3f7610 100644 --- a/src/components/stats/StatTile.jsx +++ b/src/components/stats/StatTile.jsx @@ -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 = ( <>
    {value}
    {label}
    + {meter && } {hint &&
    {hint}
    } ); diff --git a/src/components/stats/StatusDonut.jsx b/src/components/stats/StatusDonut.jsx new file mode 100644 index 0000000..453a5f2 --- /dev/null +++ b/src/components/stats/StatusDonut.jsx @@ -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 = ( +
    + + {n(total)} + + {games} +
    + ); + return ( + t(`BACKLOG.STATUS.${s}`)} + linkOf={libraryLink} center={center}/> + ); +} diff --git a/src/components/stats/TopGamesStats.jsx b/src/components/stats/TopGamesStats.jsx index ac8f6b4..4937ad3 100644 --- a/src/components/stats/TopGamesStats.jsx +++ b/src/components/stats/TopGamesStats.jsx @@ -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 ( {topGames.mostPlayed.length === 0 ? ( @@ -17,7 +18,8 @@ export default function TopGamesStats({topGames, linked = true}) { ) : (
      {topGames.mostPlayed.map((g) => ( -
    1. +
    2. ))}
    )} diff --git a/src/pages/PublicProfilePage.jsx b/src/pages/PublicProfilePage.jsx index 42a0a50..aef8484 100644 --- a/src/pages/PublicProfilePage.jsx +++ b/src/pages/PublicProfilePage.jsx @@ -9,11 +9,10 @@ import {Skeleton} from '@/components/ui/skeleton'; import {cn} from '@/lib/utils'; import GameCover from '../components/GameCover.jsx'; import FitText from '../components/FitText.jsx'; -import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx'; +import GameStatusBadge from '../components/GameStatusBadge.jsx'; import {formatDay} from '../utils/format.js'; import {backgroundEffect, bannerEffect, coverEffect, equippedAccent, equippedSkin, lookClass} from '../utils/rewards.js'; import PointsBadge from '../components/PointsBadge.jsx'; -import {GAME_STATUSES} from '../utils/limits.js'; import {formatPlaytime} from '../utils/games.js'; import useSession from '../hooks/useSession.js'; import {normalizeProfileStyle} from '../utils/profileStyle.js'; @@ -25,6 +24,8 @@ import PageBackground from '../components/PageBackground.jsx'; import PageContainer from '../components/PageContainer.jsx'; import TileGrid from '../components/TileGrid.jsx'; import {StatCount, StatDrillProvider} from '../components/stats/StatDrill.jsx'; +import StatMeter from '../components/stats/StatMeter.jsx'; +import StatusDonut from '../components/stats/StatusDonut.jsx'; import statGroupTile from '../components/stats/statGroupTile.jsx'; // Games per tile size; Core sends at most 8 playing and 12 finished games. @@ -132,7 +133,12 @@ export default function PublicProfilePage() { case 'SUMMARY': return library.totalGames != null && ; case 'STATUS': return library.byStatus && Object.values(library.byStatus).some((count) => count > 0) - && ; + && ( + + + + ); case 'PLAYING': return library.playing?.length > 0 && ; @@ -195,21 +201,24 @@ function Summary({library, compact}) { [Clock, formatPlaytime(library.totalPlaytimeMinutes), t('PROFILE.PLAYTIME')], [CircleCheck, (library.finishedGames ?? 0).toLocaleString(i18n.language), t('PROFILE.FINISHED'), 'FINISHED_ALL', library.finishedGames], ]; - if (library.averageCompletion != null) { + const average = library.averageCompletion != null ? `${library.averageCompletion.toLocaleString(i18n.language)} %` : null; + if (average) { stats.push( [Trophy, (library.perfectGames ?? 0).toLocaleString(i18n.language), t('PROFILE.PERFECT_GAMES'), 'PERFECT', library.perfectGames], - [Percent, `${library.averageCompletion.toLocaleString(i18n.language)} %`, t('PROFILE.AVERAGE_COMPLETION')], + [Percent, average, t('PROFILE.AVERAGE_COMPLETION'), undefined, undefined, + {percent: library.averageCompletion, valueText: average}], ); } return ( 3 && !compact ? 'grid-cols-2 sm:grid-cols-5' : 'grid-cols-3')}> - {stats.map(([Icon, value, label, key, count]) => ( + {stats.map(([Icon, value, label, key, count, meter]) => (
    0 ? {key} : null} title={label} label={`${label}: ${value}`}>{value}
    + {meter && }
    {label}
    ))} @@ -217,32 +226,6 @@ function Summary({library, compact}) { ); } -function StatusBar({byStatus}) { - const {t} = useTranslation(); - const total = GAME_STATUSES.reduce((sum, s) => sum + (byStatus[s] ?? 0), 0); - return ( - -
    - {GAME_STATUSES.map((s) => ( -
    - ))} -
    -
      - {GAME_STATUSES.map((s) => ( -
    • - - {t(`BACKLOG.STATUS.${s}`)} - 0 ? {key: 'STATUS', arg: s} : null} title={t(`BACKLOG.STATUS.${s}`)} - label={`${t(`BACKLOG.STATUS.${s}`)}: ${byStatus[s] ?? 0}`}> - {byStatus[s] ?? 0} - -
    • - ))} -
    - - ); -} - function Playing({games, compact}) { const {t} = useTranslation(); return ( diff --git a/src/pages/PublicProfilePage.test.jsx b/src/pages/PublicProfilePage.test.jsx index 37d6250..b304aa7 100644 --- a/src/pages/PublicProfilePage.test.jsx +++ b/src/pages/PublicProfilePage.test.jsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import {afterEach, describe, expect, it, vi} from 'vitest'; -import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react'; +import {cleanup, fireEvent, screen, waitFor, within} from '@testing-library/react'; import PublicProfilePage from './PublicProfilePage.jsx'; import {fetchRoutes, json} from '../test/fetchRoutes.js'; import {renderWithProviders} from '../test/renderWithProviders.jsx'; @@ -98,7 +98,7 @@ describe('PublicProfilePage', () => { expect(fetch.mock.calls[1][1].headers['X-Profile-Ticket']).toBe('the-ticket'); expect(screen.getByText('Lobo')).toBeTruthy(); expect(screen.getByText('Half-Life 2')).toBeTruthy(); - expect(screen.getByText('233')).toBeTruthy(); + expect(screen.getAllByText('233')).toHaveLength(2); expect(screen.queryByText('PROFILE.PERFECT_GAMES')).toBeNull(); }); @@ -267,7 +267,7 @@ describe('PublicProfilePage', () => { content: [{appid: 1, name: 'Backlogged', status: 'BACKLOG', playtimeMinutes: 0}], page: 0, size: 40, totalElements: 1, totalPages: 1})}}); const {container} = renderPage(); - fireEvent.click(await screen.findByRole('button', {name: 'BACKLOG.STATUS.BACKLOG: 142'})); + fireEvent.click(await screen.findByRole('button', {name: /BACKLOG\.STATUS\.BACKLOG/})); expect(await screen.findByText('Backlogged')).toBeTruthy(); const call = fetch.mock.calls.find(([url]) => url.includes('/stats/games')); @@ -293,6 +293,55 @@ describe('PublicProfilePage', () => { const gamesRoute = {'GET /api/public/profiles/library/stats/games': json(200, { content: [{appid: 1, name: 'Backlogged', status: 'BACKLOG', playtimeMinutes: 0}], page: 0, size: 40, totalElements: 1, totalPages: 1})}; + it('drawsTheStatusTileAsADonut', async () => { + mockBackends(); + const {container} = renderPage(); + + await screen.findByText('Portal 2'); + + expect(container.querySelectorAll('[data-tile="STATUS"] path.stroke-card')).toHaveLength(5); + }); + + it('showsTheTotalInTheDonutAsTextWithoutTheSummarySection', async () => { + mockBackends({answer: {...library, totalGames: null}}); + const {container} = renderPage(); + + await screen.findByText('Portal 2'); + const tile = container.querySelector('[data-tile="STATUS"]'); + + expect(within(tile).getByText('233')).toBeTruthy(); + expect(within(tile).queryByRole('button', {name: /STATS\.OVERVIEW\.GAMES/})).toBeNull(); + }); + + it('opensAllGamesFromTheDonutTotalWhenTheSummaryIsShared', async () => { + mockBackends({extra: gamesRoute}); + const {container} = renderPage(); + + await screen.findByText('Portal 2'); + fireEvent.click(within(container.querySelector('[data-tile="STATUS"]')).getByRole('button', {name: 'STATS.OVERVIEW.GAMES: 233'})); + + await screen.findByText('Backlogged'); + expect(fetch.mock.calls.find(([url]) => url.includes('/stats/games'))[0]).toContain('key=TOTAL'); + }); + + it('showsTheAverageCompletionAsAMeter', async () => { + mockBackends({answer: {...library, averageCompletion: 71.3, perfectGames: 2}}); + renderPage(); + + const meter = await screen.findByRole('meter', {name: 'PROFILE.AVERAGE_COMPLETION'}); + + expect(meter.getAttribute('aria-valuetext')).toBe('71,3 %'); + }); + + it('showsNoMeterWithoutAnAverageCompletion', async () => { + mockBackends(); + renderPage(); + + await screen.findByText('PROFILE.GAMES'); + + expect(screen.queryByRole('meter')).toBeNull(); + }); + const topGamesStats = {pile: null, trend: null, taste: null, finished: null, habits: null, topGames: {mostPlayed: [{appid: 620, name: 'Portal 2', imageUrl: null, minutes: 900}], mostLoyal: null}}; @@ -302,7 +351,7 @@ describe('PublicProfilePage', () => { mockBackends({me: {displayName: 'lobo', role: 'USER'}, extra: gamesRoute}); renderPage(); - fireEvent.click(await screen.findByRole('button', {name: 'BACKLOG.STATUS.BACKLOG: 142'})); + fireEvent.click(await screen.findByRole('button', {name: /BACKLOG\.STATUS\.BACKLOG/})); expect((await screen.findByRole('link', {name: /Backlogged/})).getAttribute('href')).toBe('/library/1'); } finally { diff --git a/src/pages/StatsPage.test.jsx b/src/pages/StatsPage.test.jsx index c0dc982..db0bf90 100644 --- a/src/pages/StatsPage.test.jsx +++ b/src/pages/StatsPage.test.jsx @@ -114,7 +114,7 @@ describe('StatsPage', () => { answerStats(json(200, stats())); renderPage(); - const link = await screen.findByRole('link', {name: 'BACKLOG.STATUS.BACKLOG: 5'}); + const link = await screen.findByRole('link', {name: /BACKLOG\.STATUS\.BACKLOG/}); expect(link.getAttribute('href')).toBe('/library?hideDuplicates=true&status=BACKLOG'); }); @@ -140,11 +140,11 @@ describe('StatsPage', () => { await screen.findByText('STATS.PILE.GAMES'); - for (const name of [/STATS\.PILE\.GAMES/, /STATS\.PILE\.NORMAL/, /STATS\.HABITS\.UNDER_ONE_HOUR/, /BACKLOG\.STATUS\.DROPPED: 0/]) { + for (const name of [/STATS\.PILE\.GAMES/, /STATS\.PILE\.NORMAL/, /STATS\.HABITS\.UNDER_ONE_HOUR/, /BACKLOG\.STATUS\.DROPPED/]) { expect(screen.queryByRole('button', {name})).toBeNull(); expect(screen.queryByRole('link', {name})).toBeNull(); } - expect(screen.getByRole('link', {name: 'BACKLOG.STATUS.PLAYING: 3'})).toBeTruthy(); + expect(screen.getByRole('link', {name: /BACKLOG\.STATUS\.PLAYING/})).toBeTruthy(); }); it('leavesTheMoonTripsAlone', async () => { @@ -156,6 +156,129 @@ describe('StatsPage', () => { expect(screen.queryByRole('button', {name: /STATS.HABITS.MOON/})).toBeNull(); }); + describe('overview donut', () => { + const overview = () => screen.getByText('STATS.OVERVIEW.TITLE').closest('[data-slot="card"]'); + const paths = () => overview().querySelectorAll('path.stroke-card'); + + it('drawsOneColoredSlicePerStatus', async () => { + answerStats(json(200, stats())); + renderPage(); + + await screen.findByText('STATS.OVERVIEW.TITLE'); + + expect(paths()).toHaveLength(5); + expect(paths()[0].getAttribute('style')).toContain('var(--chart-1)'); + expect(paths()[4].getAttribute('style')).toContain('var(--chart-5)'); + }); + + it('putsTheTotalInTheHoleAsALink', async () => { + answerStats(json(200, stats())); + renderPage(); + + const link = await within(await screen.findByText('STATS.OVERVIEW.TITLE').then(overview)) + .findByRole('link', {name: 'STATS.OVERVIEW.GAMES: 12'}); + + expect(link.getAttribute('href')).toBe('/library?hideDuplicates=true'); + }); + + it('linksALegendRowToTheStatusLibrary', async () => { + answerStats(json(200, stats())); + renderPage(); + + await screen.findByText('STATS.OVERVIEW.TITLE'); + + expect(within(overview()).getByRole('link', {name: /BACKLOG\.STATUS\.BACKLOG/}).getAttribute('href')) + .toBe('/library?hideDuplicates=true&status=BACKLOG'); + }); + + it('leavesAStatusWithoutGamesWithoutSliceAndTarget', async () => { + const base = stats(); + answerStats(json(200, stats({overview: {...base.overview, byStatus: {...base.overview.byStatus, DROPPED: 0}}}))); + renderPage(); + + await screen.findByText('STATS.OVERVIEW.TITLE'); + + expect(paths()).toHaveLength(4); + const row = within(overview()).getByText('BACKLOG.STATUS.DROPPED'); + expect(row.closest('a, button')).toBeNull(); + }); + + it('opensTheStatusLibraryOnASliceClick', async () => { + answerStats(json(200, stats())); + const {router} = renderPage(); + await screen.findByText('STATS.OVERVIEW.TITLE'); + + fireEvent.click(paths()[1]); + + await waitFor(() => expect(router.state.location.pathname + router.state.location.search) + .toBe('/library?hideDuplicates=true&status=PLAYING')); + }); + + it('showsTheGamesTileInsteadOfTheDonutForAnEmptyLibrary', async () => { + const base = stats(); + answerStats(json(200, stats({overview: {...base.overview, totalGames: 0, playedGames: 0, neverStarted: 0, + byStatus: {BACKLOG: 0, PLAYING: 0, FINISHED: 0, COMPLETED: 0, DROPPED: 0}}}))); + renderPage(); + + await screen.findByText('STATS.OVERVIEW.TITLE'); + + expect(paths()).toHaveLength(0); + expect(within(overview()).getByText('STATS.OVERVIEW.GAMES')).toBeTruthy(); + expect(within(overview()).queryByRole('link')).toBeNull(); + }); + }); + + it('usesThePaletteAndNotTheAccentInAPickedPie', async () => { + answerStats(json(200, stats()), {PLATFORMS: 'PIE'}); + renderPage(); + + const card = (await screen.findByText('STATS.PLATFORMS.TITLE')).closest('[data-slot="card"]'); + await waitFor(() => expect(card.querySelectorAll('path.stroke-card')).toHaveLength(2)); + const [first, second] = card.querySelectorAll('path.stroke-card'); + + expect(first.getAttribute('style')).toContain('var(--chart-1)'); + expect(second.getAttribute('style')).toContain('var(--chart-2)'); + }); + + it('showsTheUnderOneHourShareAsAMeterThatStaysAButton', async () => { + answerStats(json(200, stats())); + renderPage(); + + const meter = (await screen.findAllByRole('meter', {hidden: true}))[0]; + + expect(meter.getAttribute('aria-valuenow')).toBe('13'); + expect(meter.getAttribute('aria-label')).toBe('STATS.HABITS.UNDER_ONE_HOUR'); + expect(meter.getAttribute('aria-hidden')).toBe('true'); + expect(screen.getByRole('button', {name: /STATS\.HABITS\.UNDER_ONE_HOUR/})).toBeTruthy(); + }); + + it('keepsTheMeterOfAnUnderOneHourShareWithoutGamesButDropsTheButton', async () => { + const base = stats(); + answerStats(json(200, stats({habits: {...base.habits, underOneHourGames: 0, underOneHourShare: 0}}))); + renderPage(); + + expect(await screen.findByRole('meter', {name: 'STATS.HABITS.UNDER_ONE_HOUR'})).toBeTruthy(); + expect(screen.queryByRole('button', {name: /STATS\.HABITS\.UNDER_ONE_HOUR/})).toBeNull(); + }); + + it('drawsABarUnderEachMostPlayedGame', async () => { + fetchRoutes({ + 'GET /api/library/stats': json(200, stats()), + 'GET /api/users/me': me(), + 'GET /api/library/stats/games': json(200, {content: [], page: 0, size: 40, totalElements: 0, totalPages: 0}), + }); + renderPage(); + + const card = (await screen.findByText('STATS.TOP_GAMES.TITLE')).closest('[data-slot="card"]'); + const [ranked, loyal] = within(card).getAllByRole('link', {name: /Portal 2/}); + + expect(ranked.getAttribute('href')).toBe('/library/620'); + expect(ranked.querySelectorAll('[aria-hidden="true"] > .bg-primary')).toHaveLength(1); + expect(loyal.querySelectorAll('[aria-hidden="true"] > .bg-primary')).toHaveLength(0); + fireEvent.click(within(card).getByRole('button', {name: 'STATS.DRILL.SHOW_ALL'})); + expect(await screen.findByRole('dialog')).toBeTruthy(); + }); + describe('chart forms', () => { const section = (title) => screen.getByText(title).closest('[data-slot="card"]'); const picker = (title) => within(section(title)).getByRole('group', {name: 'STATS.CHART.LABEL'}); diff --git a/src/style.css b/src/style.css index 52a0555..b5633b8 100644 --- a/src/style.css +++ b/src/style.css @@ -54,6 +54,25 @@ --ring: oklch(0.541 0.281 293.009); } +/* Chart palette: the same for every skin and accent; the order keeps neighbor slices color-blind safe. */ +:root { + --chart-1: #2a78d6; + --chart-2: #eb6834; + --chart-3: #199e70; + --chart-4: #c98500; + --chart-5: #4a3aa7; + --chart-6: #e34948; +} + +.dark { + --chart-1: #3987e5; + --chart-2: #d95926; + --chart-3: #199e70; + --chart-4: #c98500; + --chart-5: #b955b9; + --chart-6: #e66767; +} + /* * Appearance presets (utils/appearance.js). Set as data attributes on ; the settings page also sets them on * its preview buttons, so each button shows its own preset. Violet repeats the defaults above for that reason. diff --git a/src/utils/statCharts.js b/src/utils/statCharts.js index aeaec90..e9f6bb4 100644 --- a/src/utils/statCharts.js +++ b/src/utils/statCharts.js @@ -1,4 +1,5 @@ import {apiFetch} from './api.js'; +import {GAME_STATUSES} from './limits.js'; import {dayNumber} from './stats.js'; // The chart forms of each stat group, the first is the default. Auth checks the same list. @@ -36,6 +37,27 @@ export const topSlices = (items, valueOf, keep = 5) => { return [...slices.slice(0, keep), {name: null, value: rest, item: null, other: true}]; }; +export const SLICE_COLORS = ['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)', 'var(--chart-6)']; + +/** + * The fill of each slice. The colors come in a fixed order and repeat after the last one; only slices with games + * take one. A slice without games gets null, the "other" slice gray. + * + * @returns `(string|null)[]`, one per slice + */ +export const sliceColors = (slices) => { + let next = 0; + return slices.map((slice) => { + if (slice.value <= 0) return null; + if (slice.other) return 'var(--muted-foreground)'; + return SLICE_COLORS[next++ % SLICE_COLORS.length]; + }); +}; + +/** One slice per game status in the fixed status order; a status without games stays in, with value 0. */ +export const statusSlices = (byStatus) => + GAME_STATUSES.map((s) => ({name: s, value: byStatus?.[s] ?? 0, item: null, other: false})); + const round = (n) => Math.round(n * 1000) / 1000; /** diff --git a/src/utils/statCharts.test.js b/src/utils/statCharts.test.js index 4dc6083..072b134 100644 --- a/src/utils/statCharts.test.js +++ b/src/utils/statCharts.test.js @@ -1,5 +1,6 @@ import {describe, expect, it} from 'vitest'; -import {chartOf, linePoints, slicePaths, topSlices, trendBars, yearSeries} from './statCharts.js'; +import {chartOf, linePoints, sliceColors, slicePaths, statusSlices, topSlices, trendBars, yearSeries} from './statCharts.js'; +import {GAME_STATUSES} from './limits.js'; describe('chartOf', () => { it('gives the stored form of the group', () => { @@ -92,3 +93,38 @@ describe('trendBars', () => { expect(trendBars([], 100, 40)).toEqual([]); }); }); + +describe('sliceColors', () => { + const slice = (value, other = false) => ({name: other ? null : 'x', value, item: null, other}); + + it('gives the colors in a fixed order', () => { + expect(sliceColors([slice(3), slice(2), slice(1)])).toEqual(['var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)']); + }); + + it('gives a slice without games no color and no slot', () => { + expect(sliceColors([slice(3), slice(0), slice(1)])).toEqual(['var(--chart-1)', null, 'var(--chart-2)']); + }); + + it('keeps the other slice gray and out of the slots', () => { + expect(sliceColors([slice(3), slice(2, true), slice(1)])).toEqual(['var(--chart-1)', 'var(--muted-foreground)', 'var(--chart-2)']); + }); + + it('starts again after the sixth color', () => { + const colors = sliceColors(Array.from({length: 7}, () => slice(1))); + expect(colors[6]).toBe('var(--chart-1)'); + expect(colors[5]).toBe('var(--chart-6)'); + }); +}); + +describe('statusSlices', () => { + it('has one slice per status in the fixed order', () => { + const slices = statusSlices({PLAYING: 2, BACKLOG: 5}); + expect(slices.map((s) => s.name)).toEqual(GAME_STATUSES); + expect(slices.map((s) => s.value)).toEqual(GAME_STATUSES.map((s) => ({BACKLOG: 5, PLAYING: 2})[s] ?? 0)); + expect(slices.every((s) => !s.other && s.item === null)).toBe(true); + }); + + it('gives five empty slices without data', () => { + expect(statusSlices(null).map((s) => s.value)).toEqual([0, 0, 0, 0, 0]); + }); +});