Public Access
feat(stats): add stats page and pile trend
Show the stat groups on /stats, the pile trend on the dashboard card, one switch per group in the settings, and the shared groups on the public profile.
This commit is contained in:
1 parent
83bbc2730d
commit
9d1c7bb40d
31 files changed
+978
-11
No files matched your search
@@ -12,6 +12,7 @@ import GameLibrary from './pages/GameLibrary.jsx';
|
||||
import GameDetail from './pages/GameDetail.jsx';
|
||||
import WishlistPage from './pages/WishlistPage.jsx';
|
||||
import PlayNextPage from './pages/PlayNextPage.jsx';
|
||||
import StatsPage from './pages/StatsPage.jsx';
|
||||
import PublicProfilePage from './pages/PublicProfilePage.jsx';
|
||||
import AppHeader from './components/AppHeader.jsx';
|
||||
import {Toaster} from '@/components/ui/sonner';
|
||||
@@ -67,6 +68,7 @@ function App() {
|
||||
<Route path="/library/:appid" element={<GameDetail/>}/>
|
||||
<Route path="/wishlist" element={<WishlistPage/>}/>
|
||||
<Route path="/play-next" element={<PlayNextPage/>}/>
|
||||
<Route path="/stats" element={<StatsPage/>}/>
|
||||
<Route path="/u/:name" element={<PublicProfilePage/>}/>
|
||||
<Route path="/settings" element={<SettingsPage/>}/>
|
||||
<Route path="/impressum" element={<ImprintPage/>}/>
|
||||
|
||||
@@ -4,15 +4,12 @@ import {Activity} from 'lucide-react';
|
||||
|
||||
import {Card} from '@/components/ui/card';
|
||||
import GameCover from './GameCover.jsx';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import {formatDay, formatPlaytime} from '../utils/games.js';
|
||||
|
||||
const CHART_HEIGHT = 80;
|
||||
const BAR_WIDTH = 10;
|
||||
const BAR_GAP = 2;
|
||||
|
||||
const formatDay = (day, locale) =>
|
||||
new Date(`${day}T12:00:00`).toLocaleDateString(locale, {day: 'numeric', month: 'short'});
|
||||
|
||||
/**
|
||||
* @param activity the answer of GET /api/library/activity
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Link, NavLink, useLocation} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Dices, Languages, LayoutDashboard, Library, LogOut, Moon, Settings, Sun} from 'lucide-react';
|
||||
import {ChartColumn, Dices, Languages, LayoutDashboard, Library, LogOut, Moon, Settings, Sun} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {
|
||||
@@ -22,6 +22,7 @@ const NAV_ITEMS = [
|
||||
// The wishlist is a tab of the library.
|
||||
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist']},
|
||||
{to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices},
|
||||
{to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn},
|
||||
];
|
||||
|
||||
const navLinkClass = (isActive) => cn(
|
||||
|
||||
@@ -5,11 +5,15 @@ import {Dices, Inbox, Shuffle} from 'lucide-react';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import GameCover from './GameCover.jsx';
|
||||
import PileTrend from './stats/PileTrend.jsx';
|
||||
import {STATUS_META} from './GameStatusBadge.jsx';
|
||||
import {backlogShare, GAME_STATUSES} from '../utils/backlog.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
|
||||
export default function PileOfShameCard({summary, syncing, onShuffle, shuffling}) {
|
||||
/**
|
||||
* @param trend the TREND group of GET /api/library/stats; the card shows no trend while it is missing
|
||||
*/
|
||||
export default function PileOfShameCard({summary, trend, syncing, onShuffle, shuffling}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
const backlog = summary.byStatus?.BACKLOG ?? 0;
|
||||
const suggestion = summary.suggestion;
|
||||
@@ -44,6 +48,8 @@ export default function PileOfShameCard({summary, syncing, onShuffle, shuffling}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{trend && <PileTrend trend={trend}/>}
|
||||
|
||||
{summary.total > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex h-2.5 overflow-hidden rounded-full bg-muted">
|
||||
|
||||
@@ -42,6 +42,21 @@ describe('PileOfShameCard', () => {
|
||||
expect(onShuffle).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows the pile trend and the moves of this month once they are loaded', () => {
|
||||
const trend = {days: [{day: '2026-09-26', backlogGames: 3}, {day: '2026-09-27', backlogGames: 2}],
|
||||
thisMonth: {added: 1, done: 2}};
|
||||
renderCard({summary: summary(2), trend});
|
||||
|
||||
expect(screen.getByText('STATS.TREND.THIS_MONTH')).toBeTruthy();
|
||||
expect(screen.getByTestId('pile-trend')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows no trend while it is not loaded', () => {
|
||||
renderCard({summary: summary(2)});
|
||||
|
||||
expect(screen.queryByText('STATS.TREND.THIS_MONTH')).toBeNull();
|
||||
});
|
||||
|
||||
it('says the backlog is empty when there is no suggestion', () => {
|
||||
renderCard({summary: summary(0)});
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Flag} from 'lucide-react';
|
||||
|
||||
import StatsSection from './StatsSection.jsx';
|
||||
|
||||
export default function FinishedStats({finished}) {
|
||||
const {t} = useTranslation();
|
||||
const max = Math.max(1, ...finished.perYear.map((y) => y.games));
|
||||
return (
|
||||
<StatsSection title={t('STATS.FINISHED.TITLE')} icon={Flag}>
|
||||
{finished.perYear.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('STATS.FINISHED.EMPTY')}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{finished.perYear.map((y) => (
|
||||
<li key={y.year} className="flex items-center gap-3 text-sm">
|
||||
<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>
|
||||
<span className="w-8 text-right tabular-nums">{y.games}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{finished.finishedBeforeTracking > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('STATS.FINISHED.BEFORE', {count: finished.finishedBeforeTracking})}
|
||||
</p>
|
||||
)}
|
||||
</StatsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {Link} from 'react-router-dom';
|
||||
import GameCover from '../GameCover.jsx';
|
||||
|
||||
/**
|
||||
* A game with its cover and a value on the right; links to the game page only in the user's own library.
|
||||
*/
|
||||
export default function GameRow({game, value, linked = true}) {
|
||||
const content = (
|
||||
<>
|
||||
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-16 shrink-0"/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{game.name ?? `App ${game.appid}`}</span>
|
||||
<span className="text-sm text-muted-foreground tabular-nums">{value}</span>
|
||||
</>
|
||||
);
|
||||
return linked ? (
|
||||
<Link to={`/library/${game.appid}`} className="flex items-center gap-3 hover:text-primary transition-colors">
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">{content}</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Rocket} from 'lucide-react';
|
||||
|
||||
import StatsSection from './StatsSection.jsx';
|
||||
import StatTile from './StatTile.jsx';
|
||||
import {formatPlaytime} from '../../utils/games.js';
|
||||
import {formatDecimal, formatPercent} from '../../utils/stats.js';
|
||||
|
||||
export default function HabitsStats({habits}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
const day = habits.longestDay
|
||||
? new Date(`${habits.longestDay.day}T12:00:00`).toLocaleDateString(i18n.language, {dateStyle: 'medium'})
|
||||
: null;
|
||||
return (
|
||||
<StatsSection title={t('STATS.HABITS.TITLE')} icon={Rocket}>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<StatTile label={t('STATS.HABITS.MOON')} value={formatDecimal(habits.moonTrips, i18n.language)}
|
||||
hint={t('STATS.HABITS.MOON_HINT')}/>
|
||||
{habits.longestDay && (
|
||||
<StatTile label={t('STATS.HABITS.LONGEST_DAY')} value={formatPlaytime(habits.longestDay.minutes)}
|
||||
hint={day}/>
|
||||
)}
|
||||
{habits.underOneHourShare != null && (
|
||||
<StatTile label={t('STATS.HABITS.UNDER_ONE_HOUR')}
|
||||
value={formatPercent(habits.underOneHourShare, i18n.language)}
|
||||
hint={t('STATS.HABITS.UNDER_ONE_HOUR_HINT', {count: habits.underOneHourGames})}/>
|
||||
)}
|
||||
</div>
|
||||
</StatsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Gamepad2} from 'lucide-react';
|
||||
|
||||
import StatsSection from './StatsSection.jsx';
|
||||
import StatTile from './StatTile.jsx';
|
||||
import {GAME_STATUSES} from '../../utils/backlog.js';
|
||||
import {formatPlaytime} from '../../utils/games.js';
|
||||
|
||||
export default function OverviewStats({overview}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
const n = (value) => value.toLocaleString(i18n.language);
|
||||
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)}/>
|
||||
<StatTile label={t('STATS.OVERVIEW.PLAYTIME')} value={formatPlaytime(overview.totalPlaytimeMinutes)}/>
|
||||
<StatTile label={t('STATS.OVERVIEW.PLAYED')} value={n(overview.playedGames)}/>
|
||||
<StatTile label={t('STATS.OVERVIEW.NEVER_STARTED')} value={n(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}`)}{' '}
|
||||
<span className="tabular-nums text-foreground">{n(overview.byStatus[s] ?? 0)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</StatsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Inbox} from 'lucide-react';
|
||||
|
||||
import StatsSection from './StatsSection.jsx';
|
||||
import StatTile from './StatTile.jsx';
|
||||
import GameRow from './GameRow.jsx';
|
||||
import {formatPlaytime} from '../../utils/games.js';
|
||||
import {formatDecimal} from '../../utils/stats.js';
|
||||
|
||||
export default function PileStats({pile, linked = true}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
const since = pile.oldestUnplayed
|
||||
? new Date(pile.oldestUnplayed.inLibrarySince).toLocaleDateString(i18n.language, {dateStyle: 'medium'})
|
||||
: null;
|
||||
return (
|
||||
<StatsSection title={t('STATS.PILE.TITLE')} icon={Inbox}>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<StatTile label={t('STATS.PILE.GAMES')} value={pile.backlogGames.toLocaleString(i18n.language)}/>
|
||||
<StatTile label={t('STATS.PILE.NORMAL')} value={formatPlaytime(pile.normalMinutes)}
|
||||
hint={t('STATS.PILE.KNOWN', {count: pile.gamesWithTimeToBeat})}/>
|
||||
<StatTile label={t('STATS.PILE.COMPLETIONIST')} value={formatPlaytime(pile.completionistMinutes)}/>
|
||||
</div>
|
||||
{pile.normalMinutes > 0 && (
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>
|
||||
{pile.paceWeeks == null
|
||||
? t('STATS.PILE.PACE_NONE')
|
||||
: t('STATS.PILE.PACE', {
|
||||
weeks: formatDecimal(pile.paceWeeks, i18n.language),
|
||||
perWeek: formatPlaytime(pile.minutesPerWeek),
|
||||
})}
|
||||
</li>
|
||||
<li>{t('STATS.PILE.WORK_YEARS', {years: formatDecimal(pile.workYears, i18n.language, 2)})}</li>
|
||||
</ul>
|
||||
)}
|
||||
{pile.oldestUnplayed && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium">{t('STATS.PILE.OLDEST')}</span>
|
||||
<GameRow game={pile.oldestUnplayed} value={t('STATS.PILE.SINCE', {date: since})} linked={linked}/>
|
||||
</div>
|
||||
)}
|
||||
</StatsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {formatDay} from '../../utils/games.js';
|
||||
import {trendPoints} from '../../utils/stats.js';
|
||||
|
||||
const WIDTH = 300;
|
||||
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
|
||||
*/
|
||||
export default function PileTrend({trend}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
const {days, thisMonth} = trend;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm">
|
||||
{t('STATS.TREND.THIS_MONTH', {added: thisMonth.added, done: thisMonth.done})}
|
||||
</p>
|
||||
{days.length > 1 ? (
|
||||
<div>
|
||||
<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"/>
|
||||
</svg>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">{t('STATS.TREND.STARTS')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {Switch} from '@/components/ui/switch';
|
||||
import {PROFILE_STAT_GROUPS} from '../../utils/stats.js';
|
||||
|
||||
/**
|
||||
* One switch per stat group the public profile can show.
|
||||
*
|
||||
* @param disabled the profile is private
|
||||
* @param saving a save runs; the switches wait for it
|
||||
* @param onChange gets the whole new list of groups
|
||||
*/
|
||||
export default function ProfileStatsSettings({selected, disabled, saving, onChange}) {
|
||||
const {t} = useTranslation();
|
||||
const toggle = (group) => onChange(selected.includes(group)
|
||||
? selected.filter((g) => g !== group)
|
||||
: PROFILE_STAT_GROUPS.filter((g) => g === group || selected.includes(g)));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-medium">{t('SETTINGS.PROFILE_STATS')}</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{disabled ? t('SETTINGS.PROFILE_STATS_NEEDS_PUBLIC') : t('SETTINGS.PROFILE_STATS_HINT')}
|
||||
</p>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{PROFILE_STAT_GROUPS.map((group) => (
|
||||
<li key={group} className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor={`profileStats-${group}`}>{t(`STATS.${group}.TITLE`)}</Label>
|
||||
<Switch id={`profileStats-${group}`} checked={selected.includes(group)}
|
||||
disabled={disabled || saving}
|
||||
onCheckedChange={() => toggle(group)}/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export default function StatTile({label, value, hint}) {
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="text-2xl font-bold tracking-tight tabular-nums">{value}</div>
|
||||
<div className="text-sm text-muted-foreground">{label}</div>
|
||||
{hint && <div className="mt-1 text-xs text-muted-foreground">{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {Card} from '@/components/ui/card';
|
||||
|
||||
export default function StatsSection({title, icon: Icon, 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>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Palette} from 'lucide-react';
|
||||
|
||||
import StatsSection from './StatsSection.jsx';
|
||||
import {formatPlaytime} from '../../utils/games.js';
|
||||
import {genreKey} from '../../utils/stats.js';
|
||||
|
||||
function TagBars({tags, label}) {
|
||||
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">
|
||||
<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>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TasteStats({taste}) {
|
||||
const {t} = useTranslation();
|
||||
if (taste.genres.length === 0 && taste.moods.length === 0) {
|
||||
return (
|
||||
<StatsSection title={t('STATS.TASTE.TITLE')} icon={Palette}>
|
||||
<p className="text-sm text-muted-foreground">{t('STATS.TASTE.EMPTY')}</p>
|
||||
</StatsSection>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<StatsSection title={t('STATS.TASTE.TITLE')} icon={Palette}>
|
||||
<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}
|
||||
label={(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} label={(name) => t(`PLAY_NEXT.MOOD.${name}`, {defaultValue: name})}/>
|
||||
</div>
|
||||
</div>
|
||||
</StatsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Trophy} from 'lucide-react';
|
||||
|
||||
import StatsSection from './StatsSection.jsx';
|
||||
import GameRow from './GameRow.jsx';
|
||||
import {formatPlaytime} from '../../utils/games.js';
|
||||
|
||||
export default function TopGamesStats({topGames, linked = true}) {
|
||||
const {t} = useTranslation();
|
||||
return (
|
||||
<StatsSection title={t('STATS.TOP_GAMES.TITLE')} icon={Trophy}>
|
||||
{topGames.mostPlayed.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('STATS.TOP_GAMES.EMPTY')}</p>
|
||||
) : (
|
||||
<ol className="space-y-2">
|
||||
{topGames.mostPlayed.map((g) => (
|
||||
<li key={g.appid}><GameRow game={g} value={formatPlaytime(g.minutes)} linked={linked}/></li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{topGames.mostLoyal && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium">{t('STATS.TOP_GAMES.LOYAL')}</span>
|
||||
<GameRow game={topGames.mostLoyal} linked={linked}
|
||||
value={t('STATS.TOP_GAMES.MONTHS', {count: topGames.mostLoyal.months})}/>
|
||||
</div>
|
||||
)}
|
||||
</StatsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {TrendingDown} from 'lucide-react';
|
||||
|
||||
import StatsSection from './StatsSection.jsx';
|
||||
import PileTrend from './PileTrend.jsx';
|
||||
|
||||
export default function TrendStats({trend}) {
|
||||
const {t} = useTranslation();
|
||||
return (
|
||||
<StatsSection title={t('STATS.TREND.TITLE')} icon={TrendingDown}>
|
||||
<PileTrend trend={trend}/>
|
||||
</StatsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
import {statsUrl} from '../utils/stats.js';
|
||||
|
||||
/**
|
||||
* GET /api/library/stats for the given groups (none = all). stats is null while loading and after an error.
|
||||
*
|
||||
* @param onUnauthorized called on a 401, e.g. useSessionExpired
|
||||
*/
|
||||
export default function useUserStats(groups, onUnauthorized, enabled = true) {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const key = groups.join(',');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setFailed(false);
|
||||
try {
|
||||
const response = await apiFetch(statsUrl(key));
|
||||
if (response.status === 401) {
|
||||
onUnauthorized();
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
setFailed(true);
|
||||
return;
|
||||
}
|
||||
setStats(await response.json());
|
||||
} catch (err) {
|
||||
console.error('Stats load error:', err);
|
||||
setFailed(true);
|
||||
}
|
||||
}, [key, onUnauthorized]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) load();
|
||||
}, [enabled, load]);
|
||||
|
||||
return {stats, failed, reload: load};
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import PageContainer from '../components/PageContainer.jsx';
|
||||
import HeroArt from '../components/HeroArt.jsx';
|
||||
import SteamAvatar from '../components/SteamAvatar.jsx';
|
||||
import useSteamProfile from '../hooks/useSteamProfile.js';
|
||||
import useUserStats from '../hooks/useUserStats.js';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
@@ -35,6 +36,7 @@ const PLAYING_GAMES = 4;
|
||||
const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING';
|
||||
|
||||
const JSON_HEADERS = {'Content-Type': 'application/json'};
|
||||
const TREND_GROUPS = ['TREND'];
|
||||
|
||||
const Dashboard = () => {
|
||||
const {t} = useTranslation();
|
||||
@@ -51,6 +53,8 @@ const Dashboard = () => {
|
||||
const [shuffling, setShuffling] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const pileTrend = useUserStats(TREND_GROUPS, expireSession, hasSession());
|
||||
const reloadTrend = pileTrend.reload;
|
||||
|
||||
const fetchSteamCount = useCallback(async () => {
|
||||
try {
|
||||
@@ -168,7 +172,8 @@ const Dashboard = () => {
|
||||
fetchSummary();
|
||||
fetchActivity();
|
||||
fetchAchievements();
|
||||
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
|
||||
reloadTrend();
|
||||
}, [fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements, reloadTrend]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserData = async () => {
|
||||
@@ -344,7 +349,7 @@ const Dashboard = () => {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<CurrentlyPlayingCard games={playing} syncing={sync.isRunning}/>
|
||||
{summary && (
|
||||
<PileOfShameCard summary={summary} syncing={sync.isRunning}
|
||||
<PileOfShameCard summary={summary} trend={pileTrend.stats?.trend} syncing={sync.isRunning}
|
||||
onShuffle={shuffleSuggestion} shuffling={shuffling}/>
|
||||
)}
|
||||
{!summary && summaryFailed && (
|
||||
|
||||
@@ -10,6 +10,12 @@ import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
|
||||
import {GAME_STATUSES} from '../utils/backlog.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import PileStats from '../components/stats/PileStats.jsx';
|
||||
import TrendStats from '../components/stats/TrendStats.jsx';
|
||||
import TopGamesStats from '../components/stats/TopGamesStats.jsx';
|
||||
import TasteStats from '../components/stats/TasteStats.jsx';
|
||||
import HabitsStats from '../components/stats/HabitsStats.jsx';
|
||||
import FinishedStats from '../components/stats/FinishedStats.jsx';
|
||||
|
||||
/**
|
||||
* Public, no login. Auth returns a short-lived ticket for a public profile; Core returns the data for it.
|
||||
@@ -181,6 +187,22 @@ export default function PublicProfilePage() {
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{library.stats && <SharedStats stats={library.stats}/>}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Only the groups the user shares come back; the game rows do not link, the library pages are private.
|
||||
function SharedStats({stats}) {
|
||||
return (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{stats.pile && <PileStats pile={stats.pile} linked={false}/>}
|
||||
{stats.trend && <TrendStats trend={stats.trend}/>}
|
||||
{stats.topGames && <TopGamesStats topGames={stats.topGames} linked={false}/>}
|
||||
{stats.taste && <TasteStats taste={stats.taste}/>}
|
||||
{stats.habits && <HabitsStats habits={stats.habits}/>}
|
||||
{stats.finished && <FinishedStats finished={stats.finished}/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -62,6 +62,18 @@ describe('PublicProfilePage', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('shows only the stat groups the user shares, without links', async () => {
|
||||
library.stats = {pile: null, trend: null, topGames: null, taste: null, finished: null,
|
||||
habits: {moonTrips: 2.5, longestDay: null, underOneHourGames: 0, underOneHourShare: null}};
|
||||
mockBackends();
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('STATS.HABITS.TITLE')).toBeTruthy();
|
||||
expect(screen.queryByText('STATS.PILE.TITLE')).toBeNull();
|
||||
expect(screen.queryByText('STATS.TOP_GAMES.TITLE')).toBeNull();
|
||||
delete library.stats;
|
||||
});
|
||||
|
||||
it('says the profile is unknown or private on a 404', async () => {
|
||||
mockBackends({profileStatus: 404});
|
||||
renderPage();
|
||||
|
||||
@@ -16,12 +16,14 @@ import {Skeleton} from '@/components/ui/skeleton';
|
||||
import {Switch} from '@/components/ui/switch';
|
||||
import FormField from '../components/FormField.jsx';
|
||||
import SteamAccountCard from '../components/SteamAccountCard.jsx';
|
||||
import ProfileStatsSettings from '../components/stats/ProfileStatsSettings.jsx';
|
||||
import {settingsSchema} from '../utils/validation.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
||||
import {ACCENTS, CORNERS} from '../utils/appearance.js';
|
||||
import {coverUrl} from '../utils/games.js';
|
||||
import {saveProfileStats} from '../utils/stats.js';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {apiFetch, refreshSession} from '../utils/api.js';
|
||||
import {hasSession, markSession} from '../utils/auth.js';
|
||||
@@ -59,6 +61,7 @@ export default function SettingsPage() {
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(null);
|
||||
const [savingProfileStats, setSavingProfileStats] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Read once: removing the parameter below must not load the profile again.
|
||||
const [steamOutcome] = useState(() => searchParams.get('steam'));
|
||||
@@ -197,6 +200,31 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Shown at once; the switches wait for the answer, so the saves reach Auth one after the other.
|
||||
const handleProfileStatsChange = async (groups) => {
|
||||
const previous = profile?.settings?.profileStats ?? [];
|
||||
const showProfileStats = (next) => setProfile((p) => ({...p, settings: {...p.settings, profileStats: next}}));
|
||||
showProfileStats(groups);
|
||||
setSavingProfileStats(true);
|
||||
try {
|
||||
const response = await saveProfileStats(groups);
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
showProfileStats(previous);
|
||||
toast.error(t('SETTINGS.ERROR_SAVE'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Profile stats save error:', err);
|
||||
showProfileStats(previous);
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
} finally {
|
||||
setSavingProfileStats(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyProfileLink = async (link) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
@@ -429,6 +457,9 @@ export default function SettingsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ProfileStatsSettings selected={profile?.settings?.profileStats ?? []}
|
||||
disabled={!publicProfile} saving={savingProfileStats}
|
||||
onChange={handleProfileStatsChange}/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -55,6 +55,57 @@ describe('SettingsPage public profile', () => {
|
||||
await waitFor(() => expect(screen.queryByText(/\/u\/Lobo$/)).toBeNull());
|
||||
});
|
||||
|
||||
it('saves the stat groups the public profile shows, in a fixed order', async () => {
|
||||
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: ['TREND']}}));
|
||||
|
||||
fireEvent.click(await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'}));
|
||||
|
||||
await waitFor(() => expect(puts()).toHaveLength(1));
|
||||
expect(puts()[0]).toEqual({groups: ['PILE', 'TREND']});
|
||||
const [url] = fetch.mock.calls.find(([, init]) => init?.method === 'PUT');
|
||||
expect(url).toMatch(/\/api\/users\/settings\/profile-stats$/);
|
||||
await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.PILE.TITLE'}).getAttribute('aria-checked'))
|
||||
.toBe('true'));
|
||||
});
|
||||
|
||||
it('waits for a save before the next switch click', async () => {
|
||||
const answers = [];
|
||||
const me = profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: []}});
|
||||
globalThis.fetch = vi.fn((url, init) => (init?.method === 'PUT'
|
||||
? new Promise((resolve) => answers.push(() => resolve({ok: true, status: 204, json: async () => ({})})))
|
||||
: Promise.resolve({ok: true, status: 200, json: async () => me})));
|
||||
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'}));
|
||||
await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.TASTE.TITLE'}).disabled).toBe(true));
|
||||
answers.forEach((answer) => answer());
|
||||
await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.TASTE.TITLE'}).disabled).toBe(false));
|
||||
fireEvent.click(screen.getByRole('switch', {name: 'STATS.TASTE.TITLE'}));
|
||||
|
||||
await waitFor(() => expect(puts()).toHaveLength(2));
|
||||
expect(puts()[1]).toEqual({groups: ['PILE', 'TASTE']});
|
||||
});
|
||||
|
||||
it('switches the group back when the save fails', async () => {
|
||||
const me = profile({settings: {useDarkTheme: true, language: 'de', publicProfile: true, profileStats: []}});
|
||||
globalThis.fetch = vi.fn(async (url, init) => (init?.method === 'PUT'
|
||||
? {ok: false, status: 500, json: async () => ({})}
|
||||
: {ok: true, status: 200, json: async () => me}));
|
||||
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'}));
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('switch', {name: 'STATS.PILE.TITLE'}).getAttribute('aria-checked'))
|
||||
.toBe('false'));
|
||||
});
|
||||
|
||||
it('offers the stat groups only while the profile is public', async () => {
|
||||
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: false}}));
|
||||
|
||||
expect((await screen.findByRole('switch', {name: 'STATS.PILE.TITLE'})).disabled).toBe(true);
|
||||
expect(screen.getByText('SETTINGS.PROFILE_STATS_NEEDS_PUBLIC')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('needs a display name first', async () => {
|
||||
renderPage(profile({displayName: null, settings: {useDarkTheme: true, language: 'de', publicProfile: false}}));
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {useEffect} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {RefreshCw} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import OverviewStats from '../components/stats/OverviewStats.jsx';
|
||||
import PileStats from '../components/stats/PileStats.jsx';
|
||||
import TrendStats from '../components/stats/TrendStats.jsx';
|
||||
import TopGamesStats from '../components/stats/TopGamesStats.jsx';
|
||||
import TasteStats from '../components/stats/TasteStats.jsx';
|
||||
import HabitsStats from '../components/stats/HabitsStats.jsx';
|
||||
import FinishedStats from '../components/stats/FinishedStats.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import useUserStats from '../hooks/useUserStats.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
|
||||
const ALL_GROUPS = [];
|
||||
|
||||
export default function StatsPage() {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const loggedIn = hasSession();
|
||||
const {stats, failed, reload} = useUserStats(ALL_GROUPS, expireSession, loggedIn);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loggedIn) navigate('/login');
|
||||
}, [loggedIn, navigate]);
|
||||
|
||||
return (
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{t('STATS.TITLE')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('STATS.SUBTITLE')}</p>
|
||||
</div>
|
||||
|
||||
{failed && (
|
||||
<div className="flex flex-col items-center gap-3 py-12 text-sm text-muted-foreground">
|
||||
{t('STATS.ERROR_LOAD')}
|
||||
<Button variant="outline" size="sm" onClick={reload}><RefreshCw/>{t('DASHBOARD.RETRY')}</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!stats && !failed && (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Skeleton className="h-48 rounded-xl"/>
|
||||
<Skeleton className="h-48 rounded-xl"/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="md:col-span-2"><OverviewStats overview={stats.overview}/></div>
|
||||
<PileStats pile={stats.pile}/>
|
||||
<TrendStats trend={stats.trend}/>
|
||||
<TopGamesStats topGames={stats.topGames}/>
|
||||
<TasteStats taste={stats.taste}/>
|
||||
<HabitsStats habits={stats.habits}/>
|
||||
<FinishedStats finished={stats.finished}/>
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import StatsPage from './StatsPage.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
|
||||
const json = (body, status = 200) => ({ok: status < 400, status, json: async () => body});
|
||||
|
||||
const stats = (extra = {}) => ({
|
||||
overview: {totalGames: 12, totalPlaytimeMinutes: 6000, playedGames: 8, neverStarted: 4,
|
||||
byStatus: {BACKLOG: 5, PLAYING: 3, FINISHED: 2, COMPLETED: 1, DROPPED: 1}},
|
||||
pile: {backlogGames: 5, normalMinutes: 4200, completionistMinutes: 9000, gamesWithTimeToBeat: 4,
|
||||
minutesPerWeek: 420, paceWeeks: 10, workYears: 0.03,
|
||||
oldestUnplayed: {appid: 220, name: 'Half-Life 2', imageUrl: null, inLibrarySince: '2026-01-01T00:00:00Z'}},
|
||||
topGames: {mostPlayed: [{appid: 620, name: 'Portal 2', imageUrl: null, minutes: 900}],
|
||||
mostLoyal: {appid: 620, name: 'Portal 2', imageUrl: null, months: 3, minutes: 900}},
|
||||
taste: {genres: [{name: 'Shooter', games: 2, minutes: 1200}], moods: [{name: 'CHILL', games: 1, minutes: 900}]},
|
||||
habits: {moonTrips: 0.5, longestDay: {day: '2026-07-01', minutes: 200}, underOneHourGames: 1, underOneHourShare: 0.125},
|
||||
finished: {perYear: [{year: 2026, games: 2}], finishedBeforeTracking: 1},
|
||||
trend: {days: [{day: '2026-09-26', backlogGames: 6, neverPlayed: 4, backlogMinutes: 5000},
|
||||
{day: '2026-09-27', backlogGames: 5, neverPlayed: 4, backlogMinutes: 4200}], thisMonth: {added: 1, done: 2}},
|
||||
...extra,
|
||||
});
|
||||
|
||||
const renderPage = () => render(<MemoryRouter><PreferencesProvider><StatsPage/></PreferencesProvider></MemoryRouter>);
|
||||
|
||||
describe('StatsPage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('asks for every group once and shows each of them', async () => {
|
||||
globalThis.fetch = vi.fn(async () => json(stats()));
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('STATS.OVERVIEW.TITLE')).toBeTruthy();
|
||||
expect(fetch).toHaveBeenCalledOnce();
|
||||
expect(fetch.mock.calls[0][0]).toMatch(/\/api\/library\/stats$/);
|
||||
for (const title of ['PILE', 'TREND', 'TOP_GAMES', 'TASTE', 'HABITS', 'FINISHED']) {
|
||||
expect(screen.getByText(`STATS.${title}.TITLE`)).toBeTruthy();
|
||||
}
|
||||
expect(screen.getAllByText('Portal 2')).toHaveLength(2);
|
||||
expect(screen.getByText('Half-Life 2')).toBeTruthy();
|
||||
expect(screen.getByText('STATS.PILE.PACE')).toBeTruthy();
|
||||
expect(screen.getByText('STATS.FINISHED.BEFORE:1')).toBeTruthy();
|
||||
expect(screen.getByTestId('pile-trend')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows the empty texts of a new library', async () => {
|
||||
const empty = stats();
|
||||
globalThis.fetch = vi.fn(async () => json({
|
||||
...empty,
|
||||
pile: {...empty.pile, paceWeeks: null, oldestUnplayed: null},
|
||||
topGames: {mostPlayed: [], mostLoyal: null},
|
||||
taste: {genres: [], moods: []},
|
||||
habits: {moonTrips: 0, longestDay: null, underOneHourGames: 0, underOneHourShare: null},
|
||||
finished: {perYear: [], finishedBeforeTracking: 0},
|
||||
trend: {days: [empty.trend.days[1]], thisMonth: {added: 0, done: 0}},
|
||||
}));
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('STATS.PILE.PACE_NONE')).toBeTruthy();
|
||||
expect(screen.getByText('STATS.TOP_GAMES.EMPTY')).toBeTruthy();
|
||||
expect(screen.getByText('STATS.TASTE.EMPTY')).toBeTruthy();
|
||||
expect(screen.getByText('STATS.FINISHED.EMPTY')).toBeTruthy();
|
||||
expect(screen.getByText('STATS.TREND.STARTS')).toBeTruthy();
|
||||
expect(screen.queryByText('STATS.HABITS.LONGEST_DAY')).toBeNull();
|
||||
expect(screen.queryByTestId('pile-trend')).toBeNull();
|
||||
});
|
||||
|
||||
it('offers a retry when the stats could not be loaded', async () => {
|
||||
globalThis.fetch = vi.fn(async () => json({}, 500));
|
||||
renderPage();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: /DASHBOARD.RETRY/}));
|
||||
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,10 @@ export const formatPlaytime = (minutes) => {
|
||||
return hours >= 10 ? `${Math.round(hours)}h` : `${hours.toFixed(1)}h`;
|
||||
};
|
||||
|
||||
// A LocalDate from Core ("2026-09-27") as a short day; noon, so no time zone moves it to the day before.
|
||||
export const formatDay = (day, locale) =>
|
||||
new Date(`${day}T12:00:00`).toLocaleDateString(locale, {day: 'numeric', month: 'short'});
|
||||
|
||||
// 460x215 store header. Not every app has one; callers need an onError fallback.
|
||||
export const coverUrl = (appid) => `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`;
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import {apiFetch} from './api.js';
|
||||
|
||||
// The groups a user can show on the public profile; the overview numbers are always there.
|
||||
export const PROFILE_STAT_GROUPS = ['PILE', 'TREND', 'TOP_GAMES', 'TASTE', 'HABITS', 'FINISHED'];
|
||||
|
||||
/**
|
||||
* PUT /api/users/settings/profile-stats on Auth. The caller handles the response status.
|
||||
*/
|
||||
export const saveProfileStats = (groups) =>
|
||||
apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings/profile-stats`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({groups}),
|
||||
});
|
||||
|
||||
/**
|
||||
* @param groups comma separated stat groups of Core (StatGroup); empty for all
|
||||
*/
|
||||
export const statsUrl = (groups) => {
|
||||
const query = groups ? `?groups=${groups}` : '';
|
||||
return `${import.meta.env.VITE_CORE_API_URL}/api/library/stats${query}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* The i18n key of an IGDB genre name, e.g. "Role-playing (RPG)" -> "ROLE_PLAYING_RPG". Callers fall back to the
|
||||
* name, so a genre IGDB adds later still shows.
|
||||
*/
|
||||
export const genreKey = (name) => name.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_|_$/g, '');
|
||||
|
||||
export const formatDecimal = (value, locale, digits = 1) =>
|
||||
value.toLocaleString(locale, {minimumFractionDigits: 0, maximumFractionDigits: digits});
|
||||
|
||||
export const formatPercent = (share, locale) =>
|
||||
share.toLocaleString(locale, {style: 'percent', maximumFractionDigits: 0});
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const dayNumber = (day) => Math.round(Date.parse(`${day}T00:00:00Z`) / DAY_MS);
|
||||
|
||||
/**
|
||||
* Points of an SVG polyline for the pile per day, oldest left. x follows the date, so days without a snapshot
|
||||
* keep their width. The y axis starts at 0, so a small change looks small. One day gives a flat line over the
|
||||
* whole width.
|
||||
*/
|
||||
export const trendPoints = (days, width, height) => {
|
||||
if (days.length === 0) return '';
|
||||
const max = Math.max(1, ...days.map((d) => d.backlogGames));
|
||||
const y = (d) => height - (d.backlogGames / max) * height;
|
||||
if (days.length === 1) return `0,${y(days[0])} ${width},${y(days[0])}`;
|
||||
const first = dayNumber(days[0].day);
|
||||
const span = dayNumber(days[days.length - 1].day) - first;
|
||||
return days.map((d) => `${((dayNumber(d.day) - first) / span) * width},${y(d)}`).join(' ');
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {genreKey, trendPoints} from './stats.js';
|
||||
|
||||
describe('genreKey', () => {
|
||||
it('turns an IGDB genre name into an i18n key', () => {
|
||||
expect(genreKey('Role-playing (RPG)')).toBe('ROLE_PLAYING_RPG');
|
||||
expect(genreKey("Hack and slash/Beat 'em up")).toBe('HACK_AND_SLASH_BEAT_EM_UP');
|
||||
expect(genreKey('Card & Board Game')).toBe('CARD_BOARD_GAME');
|
||||
});
|
||||
});
|
||||
|
||||
describe('trendPoints', () => {
|
||||
const day = (date, backlogGames) => ({day: date, backlogGames});
|
||||
|
||||
it('spreads the days over the width, the biggest pile at the top', () => {
|
||||
expect(trendPoints([day('2026-09-01', 10), day('2026-09-02', 5), day('2026-09-03', 0)], 100, 40))
|
||||
.toBe('0,0 50,20 100,40');
|
||||
});
|
||||
|
||||
it('keeps the width of days without a snapshot', () => {
|
||||
expect(trendPoints([day('2026-09-01', 4), day('2026-09-04', 4), day('2026-09-05', 4)], 100, 40))
|
||||
.toBe('0,0 75,0 100,0');
|
||||
});
|
||||
|
||||
it('draws one day as a flat line', () => {
|
||||
expect(trendPoints([day('2026-09-01', 3)], 100, 40)).toBe('0,0 100,0');
|
||||
});
|
||||
|
||||
it('draws an empty pile at the bottom and nothing without days', () => {
|
||||
expect(trendPoints([day('2026-09-01', 0), day('2026-09-02', 0)], 100, 40)).toBe('0,40 100,40');
|
||||
expect(trendPoints([], 100, 40)).toBe('');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user