Compare commits

...
3 Commits
Author SHA1 Message Date
lobo de199a20b7 feat(stats): add chart form pickers to dashboard and profile tiles
Build, Test & Push Frontend / docker (push) Successful in 23s
Build, Test & Push Frontend / quality-check (push) Successful in 31s
2026-10-04 21:06:21 +02:00
lobo 13b0d5e7e9 feat(admin): add user role dialog
Admins change a user's role and see the change log. The last admin
gets a clear error when deleting their own account.
2026-10-04 20:09:22 +02:00
lobo e5091de50e feat(sync): follow the visit sync on the dashboard
Show the "Syncing with Steam" badge while a visit sync runs, then
reload the sync changes banner and the library.
2026-10-04 19:05:23 +02:00
32 changed files with 993 additions and 119 deletions

No files matched your search

+2 -1
View File
@@ -119,7 +119,8 @@ To Be Played is split into three repositories:
a repaired cover comes back as `imageUrl`. a repaired cover comes back as `imageUrl`.
- German and English, dark mode, mobile layout, session-expired message instead of a silent logout. - German and English, dark mode, mobile layout, session-expired message instead of a silent logout.
- **Admin** (PLATFORM_ADMIN only, `/admin/users`): search the users, block, unblock and delete them (the email is - **Admin** (PLATFORM_ADMIN only, `/admin/users`): search the users, block, unblock and delete them (the email is
typed to confirm a delete). "View as" in the menu switches the own session to User or Auditor and back; a badge typed to confirm a delete), and set the role of another user in the "Roles" dialog, which also lists the role
changes. "View as" in the menu switches the own session to User or Auditor and back; a badge
shows the lower role. Auth checks every right on the server. Registrations that wait show "Waits" with Accept shows the lower role. Auth checks every right on the server. Registrations that wait show "Waits" with Accept
and Deny (a "Waiting only" filter lists just them); the menu shows how many wait, loaded again on every page change and Deny (a "Waiting only" filter lists just them); the menu shows how many wait, loaded again on every page change
and menu opening. A request whose address is not confirmed shows "Not confirmed" and only a Delete button. The login tells a waiting user that the admin has not decided yet. and menu opening. A request whose address is not confirmed shows "Not confirmed" and only a Delete button. The login tells a waiting user that the admin has not decided yet.
+15 -2
View File
@@ -166,7 +166,8 @@
"DONE": "Dein Konto ist gelöscht.", "DONE": "Dein Konto ist gelöscht.",
"WRONG_PASSWORD": "Das Passwort ist falsch.", "WRONG_PASSWORD": "Das Passwort ist falsch.",
"TOO_MANY": "Zu viele falsche Passwörter. Bitte in {{minutes}} Minuten erneut versuchen.", "TOO_MANY": "Zu viele falsche Passwörter. Bitte in {{minutes}} Minuten erneut versuchen.",
"ERROR": "Das Konto konnte nicht gelöscht werden. Bitte später erneut versuchen." "ERROR": "Das Konto konnte nicht gelöscht werden. Bitte später erneut versuchen.",
"LAST_ADMIN": "Du bist der letzte Admin. Mache zuerst einen anderen Nutzer zum Admin."
}, },
"PROFILE_TILES_HINT": "Wähle, was dein Profil zeigt und wo: öffne dein Profil und klicke auf \"Anpassen\".", "PROFILE_TILES_HINT": "Wähle, was dein Profil zeigt und wo: öffne dein Profil und klicke auf \"Anpassen\".",
"FAVORITE_PLATFORM": "Lieblingsplattform", "FAVORITE_PLATFORM": "Lieblingsplattform",
@@ -503,6 +504,7 @@
}, },
"TOP_GAMES": { "TOP_GAMES": {
"TITLE": "Top-Spiele", "TITLE": "Top-Spiele",
"SHARE": "Anteil an der Spielzeit dieser Spiele",
"EMPTY": "Noch keine Spielzeit.", "EMPTY": "Noch keine Spielzeit.",
"LOYAL": "Treuestes Spiel", "LOYAL": "Treuestes Spiel",
"MONTHS_one": "in {{count}} Monat gespielt", "MONTHS_one": "in {{count}} Monat gespielt",
@@ -647,7 +649,18 @@
"PASSWORD_RESET_TEXT": "Die Person bekommt eine Mail mit einem Link, um ein neues Passwort zu wählen (1 Stunde gültig). Du siehst das Passwort nie.", "PASSWORD_RESET_TEXT": "Die Person bekommt eine Mail mit einem Link, um ein neues Passwort zu wählen (1 Stunde gültig). Du siehst das Passwort nie.",
"DONE_PASSWORD_RESET": "Reset-Mail gesendet.", "DONE_PASSWORD_RESET": "Reset-Mail gesendet.",
"ERROR_NOT_PENDING": "Diese Registrierung ist schon entschieden.", "ERROR_NOT_PENDING": "Diese Registrierung ist schon entschieden.",
"ERROR_TARGET_IS_PENDING": "Nimm die Registrierung erst an oder lehne sie ab." "ERROR_TARGET_IS_PENDING": "Nimm die Registrierung erst an oder lehne sie ab.",
"ROLES": "Rollen",
"ROLES_TITLE": "Rollen von {{email}}",
"ROLES_TEXT": "Die Änderung wirkt nach etwa 30 Sekunden. Der Nutzer muss die Seite eventuell neu laden.",
"SAVE_ROLE": "Speichern",
"DONE_ROLE": "Rolle gespeichert.",
"ROLE_HISTORY": "Änderungen",
"ROLE_HISTORY_EMPTY": "Noch keine Rollenänderungen.",
"ROLE_HISTORY_ERROR": "Die Änderungen konnten nicht geladen werden.",
"ERROR_ROLE_CHANGED": "Die Rolle hat sich inzwischen geändert. Die Liste wird neu geladen.",
"ERROR_ACTOR_NOT_ADMIN": "Du bist kein Admin mehr.",
"ERROR_TARGET_IS_BLOCKED": "Entsperre den Nutzer zuerst."
}, },
"CORRECTION": { "CORRECTION": {
"OPEN": "Daten korrigieren", "OPEN": "Daten korrigieren",
+15 -2
View File
@@ -166,7 +166,8 @@
"DONE": "Your account is deleted.", "DONE": "Your account is deleted.",
"WRONG_PASSWORD": "The password is wrong.", "WRONG_PASSWORD": "The password is wrong.",
"TOO_MANY": "Too many wrong passwords. Please try again in {{minutes}} minutes.", "TOO_MANY": "Too many wrong passwords. Please try again in {{minutes}} minutes.",
"ERROR": "The account could not be deleted. Please try again later." "ERROR": "The account could not be deleted. Please try again later.",
"LAST_ADMIN": "You are the last admin. Make another user admin first."
}, },
"PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\".", "PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\".",
"FAVORITE_PLATFORM": "Favorite platform", "FAVORITE_PLATFORM": "Favorite platform",
@@ -503,6 +504,7 @@
}, },
"TOP_GAMES": { "TOP_GAMES": {
"TITLE": "Top games", "TITLE": "Top games",
"SHARE": "Share of the play time of these games",
"EMPTY": "No playtime yet.", "EMPTY": "No playtime yet.",
"LOYAL": "Most loyal game", "LOYAL": "Most loyal game",
"MONTHS_one": "played in {{count}} month", "MONTHS_one": "played in {{count}} month",
@@ -647,7 +649,18 @@
"PASSWORD_RESET_TEXT": "The user gets a mail with a link to choose a new password (valid 1 hour). You never see the password.", "PASSWORD_RESET_TEXT": "The user gets a mail with a link to choose a new password (valid 1 hour). You never see the password.",
"DONE_PASSWORD_RESET": "Reset mail sent.", "DONE_PASSWORD_RESET": "Reset mail sent.",
"ERROR_NOT_PENDING": "This registration is decided already.", "ERROR_NOT_PENDING": "This registration is decided already.",
"ERROR_TARGET_IS_PENDING": "Accept or deny the registration first." "ERROR_TARGET_IS_PENDING": "Accept or deny the registration first.",
"ROLES": "Roles",
"ROLES_TITLE": "Roles of {{email}}",
"ROLES_TEXT": "The change works within about 30 seconds. The user may need to reload the page.",
"SAVE_ROLE": "Save",
"DONE_ROLE": "Role saved.",
"ROLE_HISTORY": "Changes",
"ROLE_HISTORY_EMPTY": "No role changes yet.",
"ROLE_HISTORY_ERROR": "The changes could not be loaded.",
"ERROR_ROLE_CHANGED": "The role changed in the meantime. The list is loaded again.",
"ERROR_ACTOR_NOT_ADMIN": "You are no admin any more.",
"ERROR_TARGET_IS_BLOCKED": "Unblock the user first."
}, },
"CORRECTION": { "CORRECTION": {
"OPEN": "Correct data", "OPEN": "Correct data",
+54 -19
View File
@@ -5,8 +5,11 @@ import {Activity} from 'lucide-react';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx'; import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx'; import FitText from './FitText.jsx';
import ChartPicker from './stats/ChartPicker.jsx';
import GamePlayChart from './stats/GamePlayChart.jsx';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import {formatDay} from '../utils/format.js'; import {formatDay} from '../utils/format.js';
import {chartProps, linePoints, playChart} from '../utils/statCharts.js';
const CHART_HEIGHT = 80; const CHART_HEIGHT = 80;
const BAR_WIDTH = 10; const BAR_WIDTH = 10;
@@ -14,42 +17,66 @@ const BAR_GAP = 2;
/** /**
* @param activity the answer of GET /api/library/activity * @param activity the answer of GET /api/library/activity
* @param charts the chart form per group of the user
* @param onChart `(group, form)`: shows the pickers
*/ */
export default function ActivityCard({activity}) { export default function ActivityCard({activity, charts = null, onChart = null}) {
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
const {days, totalMinutes, recentGames} = activity; const {days, totalMinutes, recentGames} = activity;
const max = Math.max(1, ...days.map((d) => d.minutes)); const max = Math.max(1, ...days.map((d) => d.minutes));
const width = days.length * (BAR_WIDTH + BAR_GAP); const width = days.length * (BAR_WIDTH + BAR_GAP);
const dayChart = chartProps(charts, onChart, 'ACTIVITY');
const gameChart = chartProps(charts, onChart, 'ACTIVITY_GAMES');
const line = dayChart.chart === 'LINE' && days.length > 1;
const points = line ? linePoints(days.map((d) => d.minutes), width, CHART_HEIGHT) : [];
const recent = recentGames.map((g) => ({appid: g.appid, name: g.name, minutes: g.playtime2Weeks}));
const form = playChart(gameChart.chart, recent);
const dayTitle = (d) => `${formatDay(d.day, i18n.language)}: ${formatPlaytime(d.minutes)}`;
return ( return (
<Card className="gap-5 p-5"> <Card className="gap-5 p-5">
<div className="flex items-baseline justify-between gap-4"> <div className="flex flex-wrap items-baseline justify-between gap-x-4">
<h2 className="flex items-center gap-2 font-semibold"> <h2 className="flex items-center gap-2 font-semibold">
<Activity className="size-4 text-primary"/> <Activity className="size-4 text-primary"/>
{t('DASHBOARD.ACTIVITY')} {t('DASHBOARD.ACTIVITY')}
</h2> </h2>
<span className="text-sm text-muted-foreground tabular-nums"> <div className="flex flex-wrap items-center gap-x-3">
{t('DASHBOARD.ACTIVITY_TOTAL', {days: days.length, time: formatPlaytime(totalMinutes)})} <span className="text-sm text-muted-foreground tabular-nums">
</span> {t('DASHBOARD.ACTIVITY_TOTAL', {days: days.length, time: formatPlaytime(totalMinutes)})}
</span>
{dayChart.onChart && <ChartPicker group="ACTIVITY" value={dayChart.chart} onChange={dayChart.onChart}/>}
</div>
</div> </div>
{totalMinutes === 0 ? ( {totalMinutes === 0 ? (
<p className="text-sm text-muted-foreground">{t('DASHBOARD.ACTIVITY_EMPTY')}</p> <p className="text-sm text-muted-foreground">{t('DASHBOARD.ACTIVITY_EMPTY')}</p>
) : ( ) : (
<div> <div>
<svg viewBox={`0 0 ${width} ${CHART_HEIGHT}`} preserveAspectRatio="none" className="h-20 w-full" <div className="relative">
role="img" aria-label={t('DASHBOARD.ACTIVITY')}> <svg viewBox={`0 0 ${width} ${CHART_HEIGHT}`} preserveAspectRatio="none" className="h-20 w-full"
{days.map((d, i) => { role="img" aria-label={t('DASHBOARD.ACTIVITY')}>
const height = (d.minutes / max) * CHART_HEIGHT; {line ? (
return ( <polyline points={points.map((p) => `${p.x},${p.y}`).join(' ')} fill="none"
<rect key={d.day} data-testid="activity-bar" x={i * (BAR_WIDTH + BAR_GAP)} className="stroke-primary" strokeWidth={2} strokeLinejoin="round"
y={CHART_HEIGHT - height} width={BAR_WIDTH} height={height} rx={2} vectorEffect="non-scaling-stroke"/>
className="fill-primary"> ) : days.map((d, i) => {
<title>{`${formatDay(d.day, i18n.language)}: ${formatPlaytime(d.minutes)}`}</title> const height = (d.minutes / max) * CHART_HEIGHT;
</rect> return (
); <rect key={d.day} data-testid="activity-bar" x={i * (BAR_WIDTH + BAR_GAP)}
})} y={CHART_HEIGHT - height} width={BAR_WIDTH} height={height} rx={2}
</svg> className="fill-primary">
<title>{dayTitle(d)}</title>
</rect>
);
})}
</svg>
{/* Dots are HTML over the stretched svg, so they stay round and can show a tooltip. */}
{line && days.map((d, i) => (
<span key={d.day} title={dayTitle(d)} data-testid="activity-dot"
className="absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary"
style={{left: `${(points[i].x / width) * 100}%`, top: `${(points[i].y / CHART_HEIGHT) * 100}%`}}/>
))}
</div>
<div className="mt-1 flex justify-between text-xs text-muted-foreground"> <div className="mt-1 flex justify-between text-xs text-muted-foreground">
<span>{formatDay(days[0].day, i18n.language)}</span> <span>{formatDay(days[0].day, i18n.language)}</span>
<span>{formatDay(days[days.length - 1].day, i18n.language)}</span> <span>{formatDay(days[days.length - 1].day, i18n.language)}</span>
@@ -59,7 +86,14 @@ export default function ActivityCard({activity}) {
{recentGames.length > 0 && ( {recentGames.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<span className="text-sm font-medium">{t('DASHBOARD.RECENTLY_PLAYED')}</span> <div className="flex flex-wrap items-center justify-between gap-x-3">
<span className="text-sm font-medium">{t('DASHBOARD.RECENTLY_PLAYED')}</span>
{gameChart.onChart && (
<ChartPicker group="ACTIVITY_GAMES" value={gameChart.chart} onChange={gameChart.onChart}
label={`${t('STATS.CHART.LABEL')}: ${t('DASHBOARD.RECENTLY_PLAYED')}`}/>
)}
</div>
{form !== 'LIST' ? <GamePlayChart games={recent} chart={form} linked/> : (
<ul className="space-y-2"> <ul className="space-y-2">
{recentGames.map((g) => ( {recentGames.map((g) => (
<li key={g.appid}> <li key={g.appid}>
@@ -73,6 +107,7 @@ export default function ActivityCard({activity}) {
</li> </li>
))} ))}
</ul> </ul>
)}
</div> </div>
)} )}
</Card> </Card>
+81 -2
View File
@@ -1,13 +1,15 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest'; import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react'; import {cleanup, fireEvent, render, screen, within} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom'; import {MemoryRouter} from 'react-router-dom';
import ActivityCard from './ActivityCard.jsx'; import ActivityCard from './ActivityCard.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const days = (minutes) => minutes.map((m, i) => ({day: `2026-09-${String(10 + i).padStart(2, '0')}`, minutes: m})); const days = (minutes) => minutes.map((m, i) => ({day: `2026-09-${String(10 + i).padStart(2, '0')}`, minutes: m}));
const renderCard = (activity) => render(<MemoryRouter><ActivityCard activity={activity}/></MemoryRouter>); const renderCard = (activity, props) => render(<MemoryRouter><ActivityCard activity={activity} {...props}/></MemoryRouter>);
const recent = (n) => Array.from({length: n}, (_, i) => ({appid: 100 + i, name: `Game ${i}`, playtimeMinutes: 900,
playtime2Weeks: 600 - i * 10, status: 'PLAYING'}));
describe('ActivityCard', () => { describe('ActivityCard', () => {
afterEach(cleanup); afterEach(cleanup);
@@ -35,4 +37,81 @@ describe('ActivityCard', () => {
expect(screen.getByText('DASHBOARD.ACTIVITY_EMPTY')).toBeTruthy(); expect(screen.getByText('DASHBOARD.ACTIVITY_EMPTY')).toBeTruthy();
}); });
it('drawsALineForTheStoredLineForm', () => {
const {container} = renderCard({days: days([0, 60, 120]), totalMinutes: 180, recentGames: []},
{charts: {ACTIVITY: 'LINE'}});
expect(screen.queryAllByTestId('activity-bar')).toHaveLength(0);
const line = container.querySelector('polyline');
expect(line.getAttribute('vector-effect')).toBe('non-scaling-stroke');
const dots = screen.getAllByTestId('activity-dot');
expect(dots).toHaveLength(3);
expect(dots[1].getAttribute('title')).toMatch(/: 1\.0h$/);
expect(container.querySelectorAll('svg[role="img"] polyline')).toHaveLength(1);
});
it('drawsBarsForALineWithOneDay', () => {
renderCard({days: days([60]), totalMinutes: 60, recentGames: []}, {charts: {ACTIVITY: 'LINE'}});
expect(screen.getAllByTestId('activity-bar')).toHaveLength(1);
});
it('showsNoPickerWithoutOnChart', () => {
renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(1)});
expect(screen.queryByRole('group')).toBeNull();
});
it('showsTwoPickersWithDifferentNamesWithOnChart', () => {
const onChart = vi.fn();
renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(2)}, {onChart});
const day = screen.getByRole('group', {name: 'STATS.CHART.LABEL'});
expect(screen.getByRole('group', {name: 'STATS.CHART.LABEL: DASHBOARD.RECENTLY_PLAYED'})).toBeTruthy();
fireEvent.click(within(day).getByRole('button', {name: 'STATS.CHART.LINE'}));
expect(onChart).toHaveBeenCalledWith('ACTIVITY', 'LINE');
});
it('recentGamesPieLinksTheGamesAndOtherIsPlain', () => {
const {container} = renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(7)},
{charts: {ACTIVITY_GAMES: 'PIE'}});
expect(container.querySelectorAll('path.stroke-card')).toHaveLength(6);
const links = screen.getAllByRole('link');
expect(links).toHaveLength(5);
expect(links[0].getAttribute('href')).toBe('/library/100');
expect(screen.getByText('STATS.CHART.OTHER').closest('a,button')).toBeNull();
expect(screen.getByText('9.8h')).toBeTruthy();
expect(screen.getByText('STATS.TOP_GAMES.SHARE')).toBeTruthy();
});
it('recentGamesPieWithOneGameDrawsTheList', () => {
const {container} = renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(1)},
{charts: {ACTIVITY_GAMES: 'DONUT'}});
expect(container.querySelectorAll('path.stroke-card')).toHaveLength(0);
expect(screen.getByRole('link', {name: /Game 0/}).getAttribute('href')).toBe('/library/100');
});
it('recentGamesBarsHaveNoCovers', () => {
const {container} = renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(2)},
{charts: {ACTIVITY_GAMES: 'BARS'}});
expect(container.querySelectorAll('img')).toHaveLength(0);
expect(screen.getByRole('link', {name: /Game 1/}).getAttribute('href')).toBe('/library/101');
});
it('twoGamesWithTheSameNameGetTheirOwnSlice', () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const games = [{appid: 1, name: 'Prey', playtimeMinutes: 900, playtime2Weeks: 300, status: 'PLAYING'},
{appid: 2, name: 'Prey', playtimeMinutes: 900, playtime2Weeks: 200, status: 'PLAYING'}];
renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: games}, {charts: {ACTIVITY_GAMES: 'PIE'}});
const sameKey = error.mock.calls.some((args) => args.join(' ').includes('same key'));
error.mockRestore();
expect(sameKey).toBe(false);
expect(screen.getAllByRole('link', {name: /Prey/}).map((a) => a.getAttribute('href'))).toEqual(['/library/1', '/library/2']);
});
}); });
+2
View File
@@ -48,6 +48,8 @@ export default function DeleteAccountCard({email}) {
} }
if (response.status === 403) { if (response.status === 403) {
toast.error(t('SETTINGS.DELETE_ACCOUNT.WRONG_PASSWORD')); toast.error(t('SETTINGS.DELETE_ACCOUNT.WRONG_PASSWORD'));
} else if (response.status === 409) {
toast.error(t('SETTINGS.DELETE_ACCOUNT.LAST_ADMIN'));
} else if (response.status === 429) { } else if (response.status === 429) {
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60); const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
toast.error(t('SETTINGS.DELETE_ACCOUNT.TOO_MANY', {minutes})); toast.error(t('SETTINGS.DELETE_ACCOUNT.TOO_MANY', {minutes}));
+12
View File
@@ -78,6 +78,18 @@ describe('DeleteAccountCard', () => {
expect(localStorage.getItem('tbp_session')).toBe('1'); expect(localStorage.getItem('tbp_session')).toBe('1');
}); });
it('tells the last admin to make another admin first and stays on the page', async () => {
deleteAnswers(json(409, {code: 'LAST_ADMIN'}));
renderCard();
openAndFill(EMAIL, 'secret');
fireEvent.click(confirmButton());
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.DELETE_ACCOUNT.LAST_ADMIN'));
expect(confirmButton()).toBeTruthy();
expect(localStorage.getItem('tbp_session')).toBe('1');
});
it('shows the lock message after too many wrong passwords', async () => { it('shows the lock message after too many wrong passwords', async () => {
deleteAnswers({...json(429, {}), headers: new Headers({'Retry-After': '900'})}); deleteAnswers({...json(429, {}), headers: new Headers({'Retry-After': '900'})});
renderCard(); renderCard();
+18 -4
View File
@@ -4,13 +4,22 @@ import {ArrowRight, Trophy} from 'lucide-react';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import ChartPicker from './stats/ChartPicker.jsx';
import GamePlayChart from './stats/GamePlayChart.jsx';
import GameCover from './GameCover.jsx'; import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx'; import FitText from './FitText.jsx';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import {coverEffect} from '../utils/rewards.js'; import {coverEffect} from '../utils/rewards.js';
import {playChart} from '../utils/statCharts.js';
export default function MostPlayedCard({games, syncing, equipped}) { /**
* @param chart LIST, BARS, PIE or DONUT
* @param onChart `(form)`: shows the picker
*/
export default function MostPlayedCard({games, syncing, equipped, chart = 'LIST', onChart}) {
const {t} = useTranslation(); const {t} = useTranslation();
const mapped = games.map((g) => ({appid: g.appid, name: g.name, minutes: g.playtimeMinutes}));
const form = playChart(chart, mapped);
const maxMinutes = games[0]?.playtimeMinutes || 1; const maxMinutes = games[0]?.playtimeMinutes || 1;
return ( return (
<Card className="gap-0 py-0"> <Card className="gap-0 py-0">
@@ -19,15 +28,20 @@ export default function MostPlayedCard({games, syncing, equipped}) {
<Trophy className="size-4 text-amber-500"/> <Trophy className="size-4 text-amber-500"/>
{t('DASHBOARD.MOST_PLAYED')} {t('DASHBOARD.MOST_PLAYED')}
</h2> </h2>
<Button asChild variant="ghost" size="sm"> <div className="flex flex-wrap items-center gap-x-3">
<Link to="/library">{t('DASHBOARD.SHOW_ALL')}<ArrowRight/></Link> {onChart && <ChartPicker group="TOP_GAMES" value={chart} onChange={onChart}/>}
</Button> <Button asChild variant="ghost" size="sm">
<Link to="/library">{t('DASHBOARD.SHOW_ALL')}<ArrowRight/></Link>
</Button>
</div>
</div> </div>
{games.length === 0 ? ( {games.length === 0 ? (
<p className="px-5 py-10 text-center text-sm text-muted-foreground"> <p className="px-5 py-10 text-center text-sm text-muted-foreground">
{syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')} {syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
</p> </p>
) : form !== 'LIST' ? (
<div className="px-5 py-4"><GamePlayChart games={mapped} chart={form} linked/></div>
) : ( ) : (
<ol className="divide-y"> <ol className="divide-y">
{games.map((g, index) => ( {games.map((g, index) => (
+122
View File
@@ -0,0 +1,122 @@
import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Loader2} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {changeUserRole, fetchRoleChanges, REFUSAL_KEY, ROLES} from '../utils/admin.js';
import {formatDate} from '../utils/format.js';
function RoleDialogContent({user, onClose, onChanged}) {
const {t, i18n} = useTranslation();
const [chosen, setChosen] = useState(user.role);
const [busy, setBusy] = useState(false);
// null while loading, 'error' when it failed
const [changes, setChanges] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetchRoleChanges(user.id, {signal: controller.signal})
.then(async (response) => {
if (controller.signal.aborted || response.status === 401) return;
setChanges(response.ok ? (await response.json()).results : 'error');
})
.catch((err) => {
if (controller.signal.aborted) return;
console.error('Role changes error:', err);
setChanges('error');
});
return () => controller.abort();
}, [user.id]);
const save = async () => {
setBusy(true);
try {
const response = await changeUserRole(user.id, chosen, user.role);
if (response.status === 401) return;
if (response.ok) {
toast.success(t('ADMIN.DONE_ROLE'));
onChanged();
onClose();
return;
}
const data = await response.json().catch(() => ({}));
toast.error(t(REFUSAL_KEY[data.code] ?? 'ADMIN.ERROR'));
if (data.code === 'ROLE_CHANGED') {
// The dialog holds the old user; reopen it from the reloaded list.
onChanged();
onClose();
}
} catch (err) {
console.error('Role change error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle>{t('ADMIN.ROLES_TITLE', {email: user.email})}</DialogTitle>
<DialogDescription>{t('ADMIN.ROLES_TEXT')}</DialogDescription>
</DialogHeader>
<div className="flex flex-wrap gap-2">
{ROLES.map((role) => (
<Button key={role} type="button" variant={chosen === role ? 'default' : 'outline'}
aria-pressed={chosen === role} onClick={() => setChosen(role)}>
{t(`ROLES.${role}`)}
</Button>
))}
</div>
<div className="space-y-2">
<p className="text-sm font-medium">{t('ADMIN.ROLE_HISTORY')}</p>
{changes === null && <Loader2 className="size-4 animate-spin text-muted-foreground"/>}
{changes === 'error' && <p className="text-sm text-destructive">{t('ADMIN.ROLE_HISTORY_ERROR')}</p>}
{Array.isArray(changes) && changes.length === 0 && (
<p className="text-sm text-muted-foreground">{t('ADMIN.ROLE_HISTORY_EMPTY')}</p>
)}
{Array.isArray(changes) && changes.length > 0 && (
<ul className="max-h-48 space-y-1 overflow-y-auto text-sm">
{changes.map((change, index) => (
<li key={index} className="flex flex-wrap gap-x-2 text-muted-foreground">
<span>{formatDate(change.changedAt, i18n.language)}</span>
<span>{change.actorEmail ?? `#${change.actorUserId}`}</span>
<span>{`${t(`ROLES.${change.oldRole}`)} → ${t(`ROLES.${change.newRole}`)}`}</span>
</li>
))}
</ul>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t('ADMIN.CANCEL')}</Button>
</DialogClose>
<Button type="button" disabled={busy || chosen === user.role} onClick={save}>
{busy && <Loader2 className="animate-spin"/>}
{t('ADMIN.SAVE_ROLE')}
</Button>
</DialogFooter>
</DialogContent>
);
}
/**
* Sets the role of another user and shows its role changes. Open while `user` is set.
*/
export default function UserRoleDialog({user, onClose, onChanged}) {
return (
<Dialog open={!!user} onOpenChange={(open) => { if (!open) onClose(); }}>
{user && <RoleDialogContent user={user} onClose={onClose} onChanged={onChanged}/>}
</Dialog>
);
}
+95
View File
@@ -0,0 +1,95 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {toast} from 'sonner';
import UserRoleDialog from './UserRoleDialog.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const USER = {id: 7, email: 'player@example.com', role: 'USER'};
const LOG = {results: [{actorUserId: 1, actorEmail: 'admin@example.com', oldRole: 'USER', newRole: 'AUDITOR',
changedAt: '2026-10-01T10:00:00Z'}]};
const withRoutes = (routes) => fetchRoutes({'GET /api/admin/users/7/role-changes': json(200, LOG), ...routes});
const renderDialog = () => {
const onClose = vi.fn();
const onChanged = vi.fn();
render(<UserRoleDialog user={USER} onClose={onClose} onChanged={onChanged}/>);
return {onClose, onChanged};
};
const save = () => screen.getByRole('button', {name: 'ADMIN.SAVE_ROLE'});
const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT');
describe('UserRoleDialog', () => {
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
withRoutes({'PUT /api/admin/users/7/role': json(204)});
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
it('shows the change log of the user', async () => {
renderDialog();
expect(await screen.findByText('admin@example.com')).toBeTruthy();
expect(screen.getByText('ROLES.USER → ROLES.AUDITOR')).toBeTruthy();
});
it('says so when the log is empty or cannot be loaded', async () => {
withRoutes({'GET /api/admin/users/7/role-changes': json(200, {results: []})});
renderDialog();
expect(await screen.findByText('ADMIN.ROLE_HISTORY_EMPTY')).toBeTruthy();
cleanup();
withRoutes({'GET /api/admin/users/7/role-changes': json(500, {})});
renderDialog();
expect(await screen.findByText('ADMIN.ROLE_HISTORY_ERROR')).toBeTruthy();
});
it('starts at the current role and cannot save an unchanged role', () => {
renderDialog();
expect(screen.getByRole('button', {name: 'ROLES.USER'}).getAttribute('aria-pressed')).toBe('true');
expect(save().disabled).toBe(true);
});
it('saves the chosen role with the role it saw, then reloads and closes', async () => {
const {onChanged, onClose} = renderDialog();
fireEvent.click(screen.getByRole('button', {name: 'ROLES.AUDITOR'}));
fireEvent.click(save());
await waitFor(() => expect(onChanged).toHaveBeenCalled());
expect(JSON.parse(puts()[0][1].body)).toEqual({role: 'AUDITOR', expectedRole: 'USER'});
expect(toast.success).toHaveBeenCalledWith('ADMIN.DONE_ROLE');
expect(onClose).toHaveBeenCalled();
});
it('shows why Auth refuses', async () => {
withRoutes({'PUT /api/admin/users/7/role': json(409, {code: 'ACTOR_NOT_ADMIN'})});
const {onChanged} = renderDialog();
fireEvent.click(screen.getByRole('button', {name: 'ROLES.AUDITOR'}));
fireEvent.click(save());
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('ADMIN.ERROR_ACTOR_NOT_ADMIN'));
expect(onChanged).not.toHaveBeenCalled();
});
it('loads the list again when the role changed in the meantime', async () => {
withRoutes({'PUT /api/admin/users/7/role': json(409, {code: 'ROLE_CHANGED'})});
const {onChanged} = renderDialog();
fireEvent.click(screen.getByRole('button', {name: 'ROLES.AUDITOR'}));
fireEvent.click(save());
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('ADMIN.ERROR_ROLE_CHANGED'));
expect(onChanged).toHaveBeenCalled();
});
});
+3 -2
View File
@@ -8,11 +8,12 @@ import {STAT_CHARTS} from '../../utils/statCharts.js';
* *
* @param value the shown form * @param value the shown form
* @param onChange `(form)` * @param onChange `(form)`
* @param label the accessible name; the default fits a card with one picker
*/ */
export default function ChartPicker({group, value, onChange}) { export default function ChartPicker({group, value, onChange, label}) {
const {t} = useTranslation(); const {t} = useTranslation();
return ( return (
<div role="group" aria-label={t('STATS.CHART.LABEL')} className="flex flex-wrap"> <div role="group" aria-label={label ?? t('STATS.CHART.LABEL')} className="flex flex-wrap">
{STAT_CHARTS[group].map((form) => ( {STAT_CHARTS[group].map((form) => (
<Button key={form} variant={value === form ? 'secondary' : 'ghost'} size="sm" <Button key={form} variant={value === form ? 'secondary' : 'ghost'} size="sm"
className="h-7 px-2 max-sm:min-h-11" aria-pressed={value === form} className="h-7 px-2 max-sm:min-h-11" aria-pressed={value === form}
+45
View File
@@ -0,0 +1,45 @@
import {useTranslation} from 'react-i18next';
import {DrillRow} from './StatDrill.jsx';
import SliceChart from './SliceChart.jsx';
import {formatPlaytime} from '../../utils/games.js';
import {gameHref} from '../../utils/statGames.js';
import {topSlices} from '../../utils/statCharts.js';
/**
* Play time per game as bars without covers, a pie or a donut. The list form stays in each card.
*
* @param games `[{appid, name, minutes}]`
* @param chart BARS, PIE or DONUT
* @param linked the games are the viewer's own: rows and slices open the detail page
*/
export default function GamePlayChart({games, chart, linked}) {
const {t} = useTranslation();
const named = games.map((g) => ({...g, name: g.name ?? `App ${g.appid}`}));
if (chart === 'BARS') {
const max = Math.max(1, ...named.map((g) => g.minutes));
return (
<ul className="space-y-2">
{named.map((g) => (
<li key={g.appid} className="text-sm">
<DrillRow to={linked ? gameHref(g.appid) : undefined}>
<div className="flex justify-between gap-2">
<span className="truncate" title={g.name}>{g.name}</span>
<span className="shrink-0 text-muted-foreground tabular-nums">{formatPlaytime(g.minutes)}</span>
</div>
<div aria-hidden="true" className="mt-1 h-2.5 rounded-full bg-muted">
<div className="h-full rounded-full bg-primary"
style={{width: `${Math.max(2, (g.minutes / max) * 100)}%`}}/>
</div>
</DrillRow>
</li>
))}
</ul>
);
}
return (
<SliceChart slices={topSlices(named, (g) => g.minutes)} donut={chart === 'DONUT'}
hrefOf={(slice) => (linked ? gameHref(slice.item.appid) : null)}
valueText={(slice) => formatPlaytime(slice.value)} note={t('STATS.TOP_GAMES.SHARE')}/>
);
}
+29 -17
View File
@@ -10,15 +10,23 @@ import {sliceColors, slicePaths} from '../../utils/statCharts.js';
const RADIUS = 50; const RADIUS = 50;
const HOLE = 30; const HOLE = 30;
const useSliceTarget = (slice, drillKey, label, linkOf) => useDrillTarget({ // Names are not unique (two games can share one); a game slice has an app id.
to: slice.other ? null : linkOf?.(slice.name), const sliceKey = (slice) => (slice.other ? 'other' : slice.item?.appid ?? 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, linkOf, d, color}) { const useSliceTarget = (slice, drillKey, label, linkOf, hrefOf) => {
const target = useSliceTarget(slice, drillKey, label, linkOf); const target = useDrillTarget({
to: slice.other ? null : linkOf?.(slice.name),
params: slice.other ? null : {key: drillKey, arg: slice.name},
title: slice.other ? '' : label(slice.name),
count: slice.value,
});
if (!hrefOf) return target;
const to = !slice.other && slice.value > 0 ? hrefOf(slice) : null;
return to ? {to} : null;
};
function SlicePath({slice, drillKey, label, linkOf, hrefOf, d, color}) {
const target = useSliceTarget(slice, drillKey, label, linkOf, hrefOf);
const navigate = useNavigate(); const navigate = useNavigate();
if (!d) return null; if (!d) return null;
return ( return (
@@ -27,18 +35,19 @@ function SlicePath({slice, drillKey, label, linkOf, d, color}) {
); );
} }
function SliceRow({slice, drillKey, label, linkOf, color}) { function SliceRow({slice, drillKey, label, linkOf, hrefOf, valueText, color}) {
const {t} = useTranslation(); const {t} = useTranslation();
const target = useSliceTarget(slice, drillKey, label, linkOf); const target = useSliceTarget(slice, drillKey, label, linkOf, hrefOf);
const games = t('STATS.TASTE.GAMES', {count: slice.value}); const games = t('STATS.TASTE.GAMES', {count: slice.value});
const text = slice.other ? t('STATS.CHART.OTHER') : label(slice.name);
return ( return (
<li className="text-sm"> <li className="text-sm">
<DrillRow onSelect={target?.onClick} to={target?.to} className="flex items-center gap-2"> <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')} <span className={cn('size-3 shrink-0 rounded-sm', !color && 'bg-muted')}
style={color ? {background: color} : undefined}/> style={color ? {background: color} : undefined}/>
<span className="min-w-0 flex-1 truncate">{slice.other ? t('STATS.CHART.OTHER') : label(slice.name)}</span> <span className="min-w-0 flex-1 truncate" title={text}>{text}</span>
<span className="ml-auto shrink-0 whitespace-nowrap text-muted-foreground tabular-nums"> <span className="ml-auto shrink-0 whitespace-nowrap text-muted-foreground tabular-nums">
{slice.item ? `${formatPlaytime(slice.item.minutes)} · ${games}` : games} {valueText ? valueText(slice) : slice.item ? `${formatPlaytime(slice.item.minutes)} · ${games}` : games}
</span> </span>
</DrillRow> </DrillRow>
</li> </li>
@@ -52,12 +61,14 @@ function SliceRow({slice, drillKey, label, linkOf, color}) {
* @param drillKey the key of the games request; the slice name is its argument * @param drillKey the key of the games request; the slice name is its argument
* @param label shown name of a slice name * @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 linkOf library URL of a slice name; the own views link there instead of opening the dialog
* @param hrefOf `(slice) => url|null`: a slice links there instead of using `linkOf` or the dialog; never "other" or 0
* @param valueText `(slice) => string`: the text at the right of a legend row
* @param center shown in the hole of a donut * @param center shown in the hole of a donut
* @param note a line under the legend * @param note a line under the legend
* *
* A slice without games has a legend row only. * A slice without games has a legend row only.
*/ */
export default function SliceChart({slices, donut = false, drillKey, label = (name) => name, linkOf, center, note}) { export default function SliceChart({slices, donut = false, drillKey, label = (name) => name, linkOf, hrefOf, valueText, center, note}) {
const colors = sliceColors(slices); const colors = sliceColors(slices);
const paths = slicePaths(slices.map((slice) => slice.value), RADIUS, donut ? HOLE : 0); const paths = slicePaths(slices.map((slice) => slice.value), RADIUS, donut ? HOLE : 0);
return ( return (
@@ -66,8 +77,8 @@ export default function SliceChart({slices, donut = false, drillKey, label = (na
<div className="relative size-40 max-w-40 shrink-0"> <div className="relative size-40 max-w-40 shrink-0">
<svg viewBox={`0 0 ${2 * RADIUS} ${2 * RADIUS}`} aria-hidden="true" className="size-full"> <svg viewBox={`0 0 ${2 * RADIUS} ${2 * RADIUS}`} aria-hidden="true" className="size-full">
{slices.map((slice, index) => ( {slices.map((slice, index) => (
<SlicePath key={slice.other ? 'other' : slice.name} slice={slice} drillKey={drillKey} <SlicePath key={sliceKey(slice)} slice={slice} drillKey={drillKey}
label={label} linkOf={linkOf} d={paths[index]} color={colors[index]}/> label={label} linkOf={linkOf} hrefOf={hrefOf} d={paths[index]} color={colors[index]}/>
))} ))}
</svg> </svg>
{donut && center && ( {donut && center && (
@@ -78,8 +89,9 @@ export default function SliceChart({slices, donut = false, drillKey, label = (na
</div> </div>
<ul className="w-full min-w-0 flex-1 space-y-1"> <ul className="w-full min-w-0 flex-1 space-y-1">
{slices.map((slice, index) => ( {slices.map((slice, index) => (
<SliceRow key={slice.other ? 'other' : slice.name} slice={slice} drillKey={drillKey} <SliceRow key={sliceKey(slice)} slice={slice} drillKey={drillKey}
label={label} linkOf={linkOf} color={colors[index]}/> label={label} linkOf={linkOf} hrefOf={hrefOf} valueText={valueText}
color={colors[index]}/>
))} ))}
</ul> </ul>
</div> </div>
+13 -4
View File
@@ -3,26 +3,35 @@ import {Trophy} from 'lucide-react';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import StatsSection from './StatsSection.jsx'; import StatsSection from './StatsSection.jsx';
import ChartPicker from './ChartPicker.jsx';
import GamePlayChart from './GamePlayChart.jsx';
import GameRow from './GameRow.jsx'; import GameRow from './GameRow.jsx';
import {useStatDrill} from './StatDrill.jsx'; import {useStatDrill} from './StatDrill.jsx';
import {formatPlaytime} from '../../utils/games.js'; import {formatPlaytime} from '../../utils/games.js';
import {playChart} from '../../utils/statCharts.js';
export default function TopGamesStats({topGames, linked = true}) { /**
* @param chart LIST, BARS, PIE or DONUT
* @param onChart `(form)`: shows the picker
*/
export default function TopGamesStats({topGames, linked = true, chart = 'LIST', onChart}) {
const {t} = useTranslation(); const {t} = useTranslation();
const drill = useStatDrill(); const drill = useStatDrill();
const max = Math.max(1, ...topGames.mostPlayed.map((g) => g.minutes)); const max = Math.max(1, ...topGames.mostPlayed.map((g) => g.minutes));
const form = playChart(chart, topGames.mostPlayed);
return ( return (
<StatsSection title={t('STATS.TOP_GAMES.TITLE')} icon={Trophy}> <StatsSection title={t('STATS.TOP_GAMES.TITLE')} icon={Trophy}
action={onChart && <ChartPicker group="TOP_GAMES" value={chart} onChange={onChart}/>}>
{topGames.mostPlayed.length === 0 ? ( {topGames.mostPlayed.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('STATS.TOP_GAMES.EMPTY')}</p> <p className="text-sm text-muted-foreground">{t('STATS.TOP_GAMES.EMPTY')}</p>
) : ( ) : form === 'LIST' ? (
<ol className="space-y-2"> <ol className="space-y-2">
{topGames.mostPlayed.map((g) => ( {topGames.mostPlayed.map((g) => (
<li key={g.appid}><GameRow game={g} value={formatPlaytime(g.minutes)} linked={linked} <li key={g.appid}><GameRow game={g} value={formatPlaytime(g.minutes)} linked={linked}
share={g.minutes / max}/></li> share={g.minutes / max}/></li>
))} ))}
</ol> </ol>
)} ) : <GamePlayChart games={topGames.mostPlayed} chart={form} linked={linked}/>}
{drill && topGames.mostPlayed.length > 0 && ( {drill && topGames.mostPlayed.length > 0 && (
<Button variant="ghost" size="sm" className="self-start max-sm:min-h-11" <Button variant="ghost" size="sm" className="self-start max-sm:min-h-11"
onClick={() => drill.open(t('STATS.TOP_GAMES.TITLE'), {key: 'MOST_PLAYED'})}> onClick={() => drill.open(t('STATS.TOP_GAMES.TITLE'), {key: 'MOST_PLAYED'})}>
+8 -7
View File
@@ -6,7 +6,7 @@ import TasteStats from './TasteStats.jsx';
import HabitsStats from './HabitsStats.jsx'; import HabitsStats from './HabitsStats.jsx';
import FinishedStats from './FinishedStats.jsx'; import FinishedStats from './FinishedStats.jsx';
import PlatformStats from './PlatformStats.jsx'; import PlatformStats from './PlatformStats.jsx';
import {chartOf} from '../../utils/statCharts.js'; import {chartProps} from '../../utils/statCharts.js';
/** /**
* One stat group as a tile; null while its data is not there. A function, not a component: a tile grid leaves out a * One stat group as a tile; null while its data is not there. A function, not a component: a tile grid leaves out a
@@ -15,17 +15,18 @@ import {chartOf} from '../../utils/statCharts.js';
* @param stats the answer of GET /api/library/stats, or the stats of a public profile * @param stats the answer of GET /api/library/stats, or the stats of a public profile
* @param linked true for the user's own games: the dashboard and the owner on the profile * @param linked true for the user's own games: the dashboard and the owner on the profile
* @param charts the chart form per group of the user whose stats these are * @param charts the chart form per group of the user whose stats these are
* @param onChart `(group, form)`: shows the picker; null for visitors
*/ */
export default function statGroupTile(id, stats, linked = true, charts = null) { export default function statGroupTile(id, stats, linked = true, charts = null, onChart = null) {
switch (id) { switch (id) {
case 'STATS_OVERVIEW': return stats?.overview ? <OverviewStats overview={stats.overview}/> : null; case 'STATS_OVERVIEW': return stats?.overview ? <OverviewStats overview={stats.overview}/> : null;
case 'STATS_PILE': return stats?.pile ? <PileStats pile={stats.pile} linked={linked}/> : null; case 'STATS_PILE': return stats?.pile ? <PileStats pile={stats.pile} linked={linked}/> : null;
case 'STATS_TREND': return stats?.trend ? <TrendStats trend={stats.trend} chart={chartOf(charts, 'TREND')}/> : null; case 'STATS_TREND': return stats?.trend ? <TrendStats trend={stats.trend} {...chartProps(charts, onChart, 'TREND')}/> : null;
case 'STATS_TOP_GAMES': return stats?.topGames ? <TopGamesStats topGames={stats.topGames} linked={linked}/> : null; case 'STATS_TOP_GAMES': return stats?.topGames ? <TopGamesStats topGames={stats.topGames} linked={linked} {...chartProps(charts, onChart, 'TOP_GAMES')}/> : null;
case 'STATS_TASTE': return stats?.taste ? <TasteStats taste={stats.taste} chart={chartOf(charts, 'TASTE')}/> : null; case 'STATS_TASTE': return stats?.taste ? <TasteStats taste={stats.taste} {...chartProps(charts, onChart, 'TASTE')}/> : null;
case 'STATS_HABITS': return stats?.habits ? <HabitsStats habits={stats.habits}/> : null; case 'STATS_HABITS': return stats?.habits ? <HabitsStats habits={stats.habits}/> : null;
case 'STATS_FINISHED': return stats?.finished ? <FinishedStats finished={stats.finished} chart={chartOf(charts, 'FINISHED')}/> : null; case 'STATS_FINISHED': return stats?.finished ? <FinishedStats finished={stats.finished} {...chartProps(charts, onChart, 'FINISHED')}/> : null;
case 'STATS_PLATFORMS': return stats?.platforms ? <PlatformStats platforms={stats.platforms} chart={chartOf(charts, 'PLATFORMS')}/> : null; case 'STATS_PLATFORMS': return stats?.platforms ? <PlatformStats platforms={stats.platforms} {...chartProps(charts, onChart, 'PLATFORMS')}/> : null;
default: return null; default: return null;
} }
} }
+1 -1
View File
@@ -15,7 +15,7 @@ async function loadInto(path, set, pick = (data) => data) {
export default function useDashboardData() { export default function useDashboardData() {
const {reload: reloadUser} = useCurrentUserContext(); const {reload: reloadUser} = useCurrentUserContext();
const [steamData, setSteamData] = useState({count: 0, hasSteamId: false}); const [steamData, setSteamData] = useState({count: 0, hasSteamId: false, syncStarted: false});
const [topGames, setTopGames] = useState([]); const [topGames, setTopGames] = useState([]);
const [playing, setPlaying] = useState([]); const [playing, setPlaying] = useState([]);
const [summary, setSummary] = useState(null); const [summary, setSummary] = useState(null);
+33
View File
@@ -0,0 +1,33 @@
import {useRef} from 'react';
import useSaveResponse from './useSaveResponse.js';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {saveStatChart, STAT_CHARTS} from '../utils/statCharts.js';
/**
* The chart forms of the signed-in user and `change(group, form)`: shows the form at once, saves null for the
* default and puts the old form back when the save fails.
*/
export default function useStatCharts() {
const {user, updateUser} = useCurrentUserContext();
const runSave = useSaveResponse();
const charts = user?.settings?.statCharts;
const setChart = (group, form) => updateUser((u) => {
const next = {...u.settings?.statCharts};
if (form) next[group] = form;
else delete next[group];
return {...u, settings: {...u.settings, statCharts: next}};
});
const latest = useRef({});
const change = async (group, form) => {
const before = charts?.[group] ?? null;
const sent = form === STAT_CHARTS[group][0] ? null : form;
const id = (latest.current[group] ?? 0) + 1;
latest.current[group] = id;
setChart(group, sent);
// A failed older save must not undo a newer choice.
if (!await runSave(() => saveStatChart(group, sent)) && latest.current[group] === id) setChart(group, before);
};
return {charts, change};
}
+12 -12
View File
@@ -1,6 +1,6 @@
import {useCallback, useEffect, useState} from 'react'; import {useCallback, useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {Ban, Check, ChevronLeft, ChevronRight, KeyRound, Loader2, Search, ShieldCheck, Trash2, X} from 'lucide-react'; import {Ban, Check, ChevronLeft, ChevronRight, KeyRound, Loader2, Search, ShieldCheck, Trash2, UserCog, X} from 'lucide-react';
import {toast} from 'sonner'; import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
@@ -18,21 +18,13 @@ import {
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label'; import {Label} from '@/components/ui/label';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import {decideUser, deleteUnconfirmedUser, deleteUser, fetchUsers, sendPasswordReset, setUserBlocked} from '../utils/admin.js'; import UserRoleDialog from '../components/UserRoleDialog.jsx';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {decideUser, deleteUnconfirmedUser, deleteUser, fetchUsers, REFUSAL_KEY, sendPasswordReset, setUserBlocked} from '../utils/admin.js';
import {formatDate} from '../utils/format.js'; import {formatDate} from '../utils/format.js';
import useAbortPrevious from '../hooks/useAbortPrevious.js'; import useAbortPrevious from '../hooks/useAbortPrevious.js';
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
const REFUSAL_KEY = {
SELF_ACTION: 'ADMIN.ERROR_SELF_ACTION',
TARGET_IS_ADMIN: 'ADMIN.ERROR_TARGET_IS_ADMIN',
ALREADY_DELETED: 'ADMIN.ERROR_ALREADY_DELETED',
NOT_PENDING: 'ADMIN.ERROR_NOT_PENDING',
TARGET_IS_PENDING: 'ADMIN.ERROR_TARGET_IS_PENDING',
TARGET_IS_UNCONFIRMED: 'ADMIN.ERROR_TARGET_IS_UNCONFIRMED',
NOT_UNCONFIRMED: 'ADMIN.ERROR_NOT_UNCONFIRMED',
NOT_ACTIVE: 'ADMIN.ERROR_NOT_ACTIVE',
};
const formatDateOrNull = (value, locale) => (value ? formatDate(value, locale) : null); const formatDateOrNull = (value, locale) => (value ? formatDate(value, locale) : null);
@@ -54,6 +46,8 @@ export default function AdminUsersPage() {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// Only the newest load may show its answer: a slow answer to an older search must not replace the list. // Only the newest load may show its answer: a slow answer to an older search must not replace the list.
const nextSignal = useAbortPrevious(); const nextSignal = useAbortPrevious();
const ownId = useCurrentUserContext().user?.id;
const [roleUser, setRoleUser] = useState(null);
const load = useCallback(async () => { const load = useCallback(async () => {
const signal = nextSignal(); const signal = nextSignal();
@@ -223,6 +217,11 @@ export default function AdminUsersPage() {
</Button> </Button>
</div> </div>
)} )}
{user.status === 'ACTIVE' && !user.blocked && !user.deletedAt && user.id !== ownId && (
<Button type="button" variant="outline" size="sm" onClick={() => setRoleUser(user)}>
<UserCog/>{t('ADMIN.ROLES')}
</Button>
)}
{user.role !== 'PLATFORM_ADMIN' && !user.deletedAt && user.status !== 'PENDING' && user.status !== 'UNCONFIRMED' && ( {user.role !== 'PLATFORM_ADMIN' && !user.deletedAt && user.status !== 'PENDING' && user.status !== 'UNCONFIRMED' && (
<div className="flex gap-2"> <div className="flex gap-2">
{user.status === 'ACTIVE' && !user.blocked && ( {user.status === 'ACTIVE' && !user.blocked && (
@@ -289,6 +288,7 @@ export default function AdminUsersPage() {
</DialogContent> </DialogContent>
)} )}
</Dialog> </Dialog>
<UserRoleDialog user={roleUser} onClose={() => setRoleUser(null)} onChanged={load}/>
</PageContainer> </PageContainer>
); );
} }
+62
View File
@@ -9,6 +9,7 @@ import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
vi.mock('../context/CurrentUserContext.jsx', () => ({useCurrentUserContext: () => ({user: {id: 1}})}));
const PLAYER = { const PLAYER = {
id: 7, email: 'player@example.com', displayName: 'Player', role: 'USER', registrationDate: '2026-09-01T10:00:00', id: 7, email: 'player@example.com', displayName: 'Player', role: 'USER', registrationDate: '2026-09-01T10:00:00',
@@ -187,6 +188,67 @@ describe('AdminUsersPage', () => {
expect(calls('DELETE')[0][0]).toMatch(/\/api\/admin\/users\/10\/unconfirmed$/); expect(calls('DELETE')[0][0]).toMatch(/\/api\/admin\/users\/10\/unconfirmed$/);
}); });
it('offers the roles of an active user and of another admin, not of the own, blocked, deleted or waiting rows', async () => {
withRoutes({
'GET /api/admin/users': json(200, page([ADMIN, {...ADMIN, id: 2, email: 'admin2@example.com'}, PLAYER,
{...PLAYER, id: 11, email: 'blocked@example.com', blocked: true}, DENIED, NEWCOMER, UNCONFIRMED])),
});
renderPage();
await screen.findByText('player@example.com');
expect(screen.getAllByRole('button', {name: 'ADMIN.ROLES'})).toHaveLength(2);
});
it('saves a new role from the dialog and loads the list again', async () => {
withRoutes({
'GET /api/admin/users/7/role-changes': json(200, {results: []}),
'PUT /api/admin/users/7/role': json(204),
});
renderPage();
await screen.findByText('player@example.com');
fireEvent.click(screen.getByRole('button', {name: 'ADMIN.ROLES'}));
fireEvent.click(await screen.findByRole('button', {name: 'ROLES.AUDITOR'}));
fireEvent.click(screen.getByRole('button', {name: 'ADMIN.SAVE_ROLE'}));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN.DONE_ROLE'));
expect(JSON.parse(calls('PUT')[0][1].body)).toEqual({role: 'AUDITOR', expectedRole: 'USER'});
await waitFor(() => expect(calls('GET').filter(([url]) => url.endsWith('/api/admin/users') || url.includes('/api/admin/users?'))).toHaveLength(2));
});
it('afterRoleChangedTheNextSaveSendsTheReloadedRole', async () => {
let lists = 0;
let puts = 0;
withRoutes({
'GET /api/admin/users': () => {
lists += 1;
return json(200, page([ADMIN, lists === 1 ? PLAYER : {...PLAYER, role: 'AUDITOR'}]));
},
'GET /api/admin/users/7/role-changes': json(200, {results: []}),
'PUT /api/admin/users/7/role': () => {
puts += 1;
return puts === 1 ? json(409, {code: 'ROLE_CHANGED'}) : json(204);
},
});
renderPage();
await screen.findByText('player@example.com');
fireEvent.click(screen.getByRole('button', {name: 'ADMIN.ROLES'}));
fireEvent.click(await screen.findByRole('button', {name: 'ROLES.PLATFORM_ADMIN'}));
fireEvent.click(screen.getByRole('button', {name: 'ADMIN.SAVE_ROLE'}));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('ADMIN.ERROR_ROLE_CHANGED'));
await waitFor(() => expect(lists).toBe(2));
if (!screen.queryByRole('button', {name: 'ADMIN.SAVE_ROLE'})) {
fireEvent.click(screen.getByRole('button', {name: 'ADMIN.ROLES'}));
}
fireEvent.click(await screen.findByRole('button', {name: 'ROLES.PLATFORM_ADMIN'}));
fireEvent.click(screen.getByRole('button', {name: 'ADMIN.SAVE_ROLE'}));
await waitFor(() => expect(calls('PUT')).toHaveLength(2));
expect(JSON.parse(calls('PUT')[1][1].body)).toEqual({role: 'PLATFORM_ADMIN', expectedRole: 'AUDITOR'});
});
it('says no access to a user without the admin role', async () => { it('says no access to a user without the admin role', async () => {
withRoutes({'GET /api/admin/users': json(403, {})}); withRoutes({'GET /api/admin/users': json(403, {})});
renderPage(); renderPage();
+12 -5
View File
@@ -1,4 +1,4 @@
import {useState} from 'react'; import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {toast} from 'sonner'; import {toast} from 'sonner';
import {RefreshCw} from 'lucide-react'; import {RefreshCw} from 'lucide-react';
@@ -23,6 +23,8 @@ import PageContainer from '../components/PageContainer.jsx';
import useSteamProfile from '../hooks/useSteamProfile.js'; import useSteamProfile from '../hooks/useSteamProfile.js';
import useUserStats from '../hooks/useUserStats.js'; import useUserStats from '../hooks/useUserStats.js';
import useSaveResponse from '../hooks/useSaveResponse.js'; import useSaveResponse from '../hooks/useSaveResponse.js';
import useStatCharts from '../hooks/useStatCharts.js';
import {chartProps} from '../utils/statCharts.js';
import TileGrid from '../components/TileGrid.jsx'; import TileGrid from '../components/TileGrid.jsx';
import {StatDrillProvider} from '../components/stats/StatDrill.jsx'; import {StatDrillProvider} from '../components/stats/StatDrill.jsx';
import statGroupTile from '../components/stats/statGroupTile.jsx'; import statGroupTile from '../components/stats/statGroupTile.jsx';
@@ -42,6 +44,7 @@ const Dashboard = () => {
const [shuffling, setShuffling] = useState(false); const [shuffling, setShuffling] = useState(false);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const runSave = useSaveResponse(); const runSave = useSaveResponse();
const {charts, change} = useStatCharts();
const layout = normalizeLayout(userData?.settings?.dashboardTiles, DASHBOARD_TILES, DEFAULT_DASHBOARD); const layout = normalizeLayout(userData?.settings?.dashboardTiles, DASHBOARD_TILES, DEFAULT_DASHBOARD);
// The stat groups of the shown stat tiles, and TREND for the pile card and the "pile grew" hint. Waits for the // The stat groups of the shown stat tiles, and TREND for the pile card and the "pile grew" hint. Waits for the
// saved layout in /me. // saved layout in /me.
@@ -71,7 +74,11 @@ const Dashboard = () => {
stats.reload(); stats.reload();
}, },
}); });
const forceSync = useForceSync(sync.watch); const {watch} = sync;
const forceSync = useForceSync(watch);
useEffect(() => {
if (steamData.syncStarted) watch();
}, [steamData, watch]);
if (isLoading) { if (isLoading) {
return <DashboardSkeleton/>; return <DashboardSkeleton/>;
@@ -98,7 +105,7 @@ const Dashboard = () => {
case 'GAME_COUNT': return showLibrary && <SteamGameCount targetCount={steamData.count}/>; case 'GAME_COUNT': return showLibrary && <SteamGameCount targetCount={steamData.count}/>;
case 'MOST_PLAYED': case 'MOST_PLAYED':
return showLibrary && <MostPlayedCard games={topGames.slice(0, TOP_GAMES[size])} syncing={sync.isRunning} return showLibrary && <MostPlayedCard games={topGames.slice(0, TOP_GAMES[size])} syncing={sync.isRunning}
equipped={equipped}/>; equipped={equipped} {...chartProps(charts, change, 'TOP_GAMES')}/>;
case 'PLAYING': case 'PLAYING':
return showLibrary && ( return showLibrary && (
<CurrentlyPlayingCard games={playing.slice(0, PLAYING_GAMES[size])} syncing={sync.isRunning} wide={size === 'L'}/> <CurrentlyPlayingCard games={playing.slice(0, PLAYING_GAMES[size])} syncing={sync.isRunning} wide={size === 'L'}/>
@@ -117,9 +124,9 @@ const Dashboard = () => {
</Button> </Button>
</Card> </Card>
); );
case 'ACTIVITY': return hasSteamId && activity && <ActivityCard activity={activity}/>; case 'ACTIVITY': return hasSteamId && activity && <ActivityCard activity={activity} charts={charts} onChart={change}/>;
case 'ACHIEVEMENTS': return hasSteamId && achievements && <AchievementsCard summary={achievements}/>; case 'ACHIEVEMENTS': return hasSteamId && achievements && <AchievementsCard summary={achievements}/>;
default: return statGroupTile(id, stats.stats, true, userData?.settings?.statCharts); default: return statGroupTile(id, stats.stats, true, charts, change);
} }
}; };
+65 -4
View File
@@ -1,12 +1,15 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, 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 Dashboard from './Dashboard.jsx'; import Dashboard from './Dashboard.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx'; import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})})); const watchSync = vi.hoisted(() => vi.fn());
vi.mock('../hooks/useLibrarySync.js', () => ({
default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false, watch: watchSync}),
}));
vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})})); vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
const avatar = 'https://avatars.steamstatic.com/abc_full.jpg'; const avatar = 'https://avatars.steamstatic.com/abc_full.jpg';
@@ -42,6 +45,7 @@ beforeEach(() => {
}); });
afterEach(() => { afterEach(() => {
watchSync.mockClear();
cleanup(); cleanup();
localStorage.clear(); localStorage.clear();
}); });
@@ -109,7 +113,47 @@ describe('Dashboard', () => {
expect(screen.getByRole('button', {name: /2024/})).toBeTruthy(); expect(screen.getByRole('button', {name: /2024/})).toBeTruthy();
expect(screen.getByText('2023: 0')).toBeTruthy(); expect(screen.getByText('2023: 0')).toBeTruthy();
expect(screen.queryByRole('button', {name: /2023/})).toBeNull(); expect(screen.queryByRole('button', {name: /2023/})).toBeNull();
expect(screen.queryByRole('group', {name: 'STATS.CHART.LABEL'})).toBeNull(); expect(screen.getByRole('group', {name: 'STATS.CHART.LABEL'})).toBeTruthy();
});
const putBodies = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body));
it('aTasteTilePickerSavesAndRedraws', async () => {
fetchRoutes({
...dashboardRoutes({dashboardTiles: [{id: 'STATS_TASTE', size: 'M'}]}),
'GET /api/library/stats': json(200, {trend: null, taste: {genres: [{name: 'Shooter', games: 2, minutes: 1200},
{name: 'Puzzle', games: 1, minutes: 300}], moods: []}}),
'PUT /api/users/settings/stat-chart': json(204, {}),
});
const {container} = renderDashboard();
await screen.findByText('STATS.TASTE.TITLE');
fireEvent.click(within(screen.getByRole('group', {name: 'STATS.CHART.LABEL'})).getByRole('button', {name: 'STATS.CHART.PIE'}));
await waitFor(() => expect(container.querySelectorAll('path.stroke-card').length).toBeGreaterThan(0));
expect(putBodies()).toEqual([{group: 'TASTE', chart: 'PIE'}]);
});
it('theMostPlayedCardShowsTheTopGamesForm', async () => {
const container = mockDashboard({dashboardTiles: [{id: 'MOST_PLAYED', size: 'L'}], statCharts: {TOP_GAMES: 'DONUT'}});
await screen.findByRole('group', {name: 'STATS.CHART.LABEL'});
expect(container.querySelectorAll('path.stroke-card')).toHaveLength(6);
const card = container.querySelector('[data-tile="MOST_PLAYED"]');
expect(within(card).getAllByRole('link', {name: /Game/})).toHaveLength(5);
expect(within(card).getByText('STATS.CHART.OTHER').closest('a,button')).toBeNull();
});
it('theActivityPickerSavesTheForm', async () => {
fetchRoutes({...dashboardRoutes(), 'PUT /api/users/settings/stat-chart': json(204, {})});
const {container} = renderDashboard();
await screen.findByText('DASHBOARD.ACTIVITY');
const card = container.querySelector('[data-tile="ACTIVITY"]');
fireEvent.click(within(card).getByRole('button', {name: 'STATS.CHART.LINE'}));
await waitFor(() => expect(container.querySelector('polyline')).not.toBeNull());
expect(putBodies()).toEqual([{group: 'ACTIVITY', chart: 'LINE'}]);
}); });
it('leaves out a stat tile whose data did not come', async () => { it('leaves out a stat tile whose data did not come', async () => {
@@ -177,5 +221,22 @@ describe('Dashboard', () => {
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy(); expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
}); });
});
it('watches the sync the server started for this visit', async () => {
fetchRoutes({
...dashboardRoutes(),
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true, syncStarted: true}),
});
renderDashboard();
await waitFor(() => expect(watchSync).toHaveBeenCalledTimes(1));
});
it('does not watch a sync when the server started none', async () => {
fetchRoutes(dashboardRoutes());
renderDashboard();
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
expect(watchSync).not.toHaveBeenCalled();
});
});
+64
View File
@@ -0,0 +1,64 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, screen} from '@testing-library/react';
import Dashboard from './Dashboard.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const empty = {content: [], total: 0, byStatus: {}};
const wait = (ms) => act(() => vi.advanceTimersByTimeAsync(ms));
const calls = (path) => fetch.mock.calls.filter(([url]) => new URL(url, 'http://x').pathname.endsWith(path)).length;
beforeEach(() => {
vi.useFakeTimers({shouldAdvanceTime: true});
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
vi.useRealTimers();
localStorage.clear();
});
describe('Dashboard sync on a visit', () => {
it('showsTheSyncingBadgeDuringTheVisitSyncAndReloadsBannerAndLibraryAfterIt', async () => {
let syncStatus = {lastSyncStatus: 'OK', lastSyncedAt: '2026-10-03T10:00:00Z'};
let syncChanges = json(204, {});
fetchRoutes({
'GET /api/library/summary': json(200, empty),
'GET /api/hints': json(200, {results: []}),
'GET /api/news': json(200, {results: []}),
'GET /api/library/sync-changes': () => syncChanges,
'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000'}),
'GET /api/library/games': json(200, empty),
'GET /api/library/stats': json(200, {trend: null}),
'GET /api/library/steam-profile': json(200, {personaName: 'Lobo', avatarUrl: null}),
'GET /api/library/activity': json(200, {totalMinutes: 0, days: [], recentGames: []}),
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true, syncStarted: true}),
'GET /api/library/sync-status': () => json(200, syncStatus),
'GET /api/library/achievements/summary': json(200, {unlocked: 0, startedGames: 0, perfectGames: 0,
averageCompletion: 0, pendingGames: 0, hiddenGames: 0, closest: []}),
});
renderWithProviders(<Dashboard/>, {entries: ['/dashboard']});
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
expect(screen.queryByText('LIBRARY.SYNCING')).toBeNull();
syncStatus = {lastSyncStatus: 'RUNNING', lastSyncedAt: '2026-10-03T10:00:00Z'};
await wait(3000);
expect(screen.getByText('LIBRARY.SYNCING')).toBeTruthy();
const gamesBefore = calls('/api/library/games');
const changesBefore = calls('/api/library/sync-changes');
syncStatus = {lastSyncStatus: 'OK', lastSyncedAt: '2026-10-04T08:00:00Z'};
syncChanges = json(200, {syncedAt: '2026-10-04T08:00:00Z', startedBy: 'LOGIN', seen: false, importedGames: 0,
changes: [{kind: 'ADDED', appid: 1, name: 'New game', amount: 0}]});
await wait(3000);
expect(screen.queryByText('LIBRARY.SYNCING')).toBeNull();
expect(await screen.findByText('SYNC_CHANGES.ADDED:1')).toBeTruthy();
expect(calls('/api/library/sync-changes')).toBeGreaterThan(changesBefore);
expect(calls('/api/library/games')).toBeGreaterThan(gamesBefore);
});
});
+4 -1
View File
@@ -19,6 +19,7 @@ import {normalizeProfileStyle} from '../utils/profileStyle.js';
import {DEFAULT_PROFILE, PROFILE_TILES, PROFILE_TITLES, normalizeLayout, saveTiles} from '../utils/tiles.js'; import {DEFAULT_PROFILE, PROFILE_TILES, PROFILE_TITLES, normalizeLayout, saveTiles} from '../utils/tiles.js';
import useCurrentUser from '../hooks/useCurrentUser.js'; import useCurrentUser from '../hooks/useCurrentUser.js';
import useSaveResponse from '../hooks/useSaveResponse.js'; import useSaveResponse from '../hooks/useSaveResponse.js';
import useStatCharts from '../hooks/useStatCharts.js';
import HeroBanner from '../components/HeroBanner.jsx'; import HeroBanner from '../components/HeroBanner.jsx';
import PageBackground from '../components/PageBackground.jsx'; import PageBackground from '../components/PageBackground.jsx';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
@@ -46,6 +47,7 @@ export default function PublicProfilePage() {
const [reloads, setReloads] = useState(0); const [reloads, setReloads] = useState(0);
const viewer = useCurrentUser(useSession().active); const viewer = useCurrentUser(useSession().active);
const runSave = useSaveResponse(); const runSave = useSaveResponse();
const own = useStatCharts();
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -144,7 +146,8 @@ export default function PublicProfilePage() {
&& <Playing games={library.playing.slice(0, PLAYING_GAMES[size])} compact={size === 'S'}/>; && <Playing games={library.playing.slice(0, PLAYING_GAMES[size])} compact={size === 'S'}/>;
case 'FINISHED': case 'FINISHED':
return library.finished?.length > 0 && <Finished games={library.finished.slice(0, FINISHED_GAMES[size])} equipped={equipped}/>; return library.finished?.length > 0 && <Finished games={library.finished.slice(0, FINISHED_GAMES[size])} equipped={equipped}/>;
default: return statGroupTile(id, library.stats, owner, profile.style?.statCharts); default: return statGroupTile(id, library.stats, owner, (owner ? own.charts : null) ?? profile.style?.statCharts,
owner ? own.change : null);
} }
}; };
// Core sends only the data of the shown tiles (the ticket says which), so a new tile needs a new load. // Core sends only the data of the shown tiles (the ticket says which), so a new tile needs a new load.
+48
View File
@@ -408,6 +408,54 @@ describe('PublicProfilePage', () => {
} }
}); });
const tasteStats = {pile: null, trend: null, finished: null, habits: null, topGames: null, platforms: null,
taste: {genres: [{name: 'Shooter', games: 2, minutes: 1200}, {name: 'Puzzle', games: 1, minutes: 300}], moods: []}};
it('theOwnerGetsThePickerOnATasteTile', async () => {
localStorage.setItem('tbp_session', '1');
library.stats = tasteStats;
try {
mockBackends({me: {displayName: 'lobo', role: 'USER', settings: {}}, style: {tiles: [{id: 'STATS_TASTE', size: 'M'}]},
extra: {'PUT /api/users/settings/stat-chart': json(204, {})}});
const {container} = renderPage();
fireEvent.click(await screen.findByRole('button', {name: 'STATS.CHART.DONUT'}));
await waitFor(() => expect(container.querySelectorAll('path.stroke-card').length).toBeGreaterThan(0));
const bodies = fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body));
expect(bodies).toEqual([{group: 'TASTE', chart: 'DONUT'}]);
} finally {
delete library.stats;
localStorage.clear();
}
});
const twoGameStats = {...topGamesStats, topGames: {mostLoyal: null, mostPlayed: [
{appid: 620, name: 'Portal 2', imageUrl: null, minutes: 900}, {appid: 220, name: 'Half-Life 2', imageUrl: null, minutes: 300}]}};
it('topGamesPieLinksForTheOwnerAndIsPlainForAVisitor', async () => {
localStorage.setItem('tbp_session', '1');
library.stats = twoGameStats;
try {
const style = {tiles: [{id: 'STATS_TOP_GAMES', size: 'M'}], statCharts: {TOP_GAMES: 'PIE'}};
mockBackends({me: {displayName: 'lobo', role: 'USER', settings: {statCharts: {TOP_GAMES: 'PIE'}}}, style});
const {unmount} = renderPage();
expect((await screen.findByRole('link', {name: /Portal 2/})).getAttribute('href')).toBe('/library/620');
unmount();
cleanup();
localStorage.clear();
mockBackends({style});
renderPage();
await screen.findByText('Portal 2');
expect(screen.queryByRole('link', {name: /Portal 2/})).toBeNull();
expect(screen.queryByRole('button', {name: /Portal 2/})).toBeNull();
} finally {
delete library.stats;
localStorage.clear();
}
});
it('showsBarsForAFormTheProfileDoesNotKnow', async () => { it('showsBarsForAFormTheProfileDoesNotKnow', async () => {
library.stats = platformStats; library.stats = platformStats;
try { try {
+9 -31
View File
@@ -1,4 +1,3 @@
import {useRef} from 'react';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {RefreshCw} from 'lucide-react'; import {RefreshCw} from 'lucide-react';
@@ -15,37 +14,16 @@ import FinishedStats from '../components/stats/FinishedStats.jsx';
import PlatformStats from '../components/stats/PlatformStats.jsx'; import PlatformStats from '../components/stats/PlatformStats.jsx';
import {StatDrillProvider} from '../components/stats/StatDrill.jsx'; import {StatDrillProvider} from '../components/stats/StatDrill.jsx';
import useUserStats from '../hooks/useUserStats.js'; import useUserStats from '../hooks/useUserStats.js';
import useSaveResponse from '../hooks/useSaveResponse.js'; import useStatCharts from '../hooks/useStatCharts.js';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; import {chartProps} from '../utils/statCharts.js';
import {chartOf, saveStatChart, STAT_CHARTS} from '../utils/statCharts.js';
const ALL_GROUPS = []; const ALL_GROUPS = [];
export default function StatsPage() { export default function StatsPage() {
const {t} = useTranslation(); const {t} = useTranslation();
const {stats, failed, reload} = useUserStats(ALL_GROUPS); const {stats, failed, reload} = useUserStats(ALL_GROUPS);
const {user, updateUser} = useCurrentUserContext(); const {charts, change} = useStatCharts();
const runSave = useSaveResponse(); const props = (group) => chartProps(charts, change, group);
const charts = user?.settings?.statCharts;
// The new form shows at once; a failed save puts only this group back.
const setChart = (group, form) => updateUser((u) => {
const next = {...u.settings?.statCharts};
if (form) next[group] = form;
else delete next[group];
return {...u, settings: {...u.settings, statCharts: next}};
});
const latest = useRef({});
const change = async (group, form) => {
const before = charts?.[group] ?? null;
const sent = form === STAT_CHARTS[group][0] ? null : form;
const id = (latest.current[group] ?? 0) + 1;
latest.current[group] = id;
setChart(group, sent);
// A failed older save must not undo a newer choice.
if (!await runSave(() => saveStatChart(group, sent)) && latest.current[group] === id) setChart(group, before);
};
const chartProps = (group) => ({chart: chartOf(charts, group), onChart: (form) => change(group, form)});
return ( return (
<StatDrillProvider source="own"> <StatDrillProvider source="own">
@@ -73,12 +51,12 @@ export default function StatsPage() {
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
<div className="md:col-span-2"><OverviewStats overview={stats.overview}/></div> <div className="md:col-span-2"><OverviewStats overview={stats.overview}/></div>
<PileStats pile={stats.pile}/> <PileStats pile={stats.pile}/>
<TrendStats trend={stats.trend} {...chartProps('TREND')}/> <TrendStats trend={stats.trend} {...props('TREND')}/>
<TopGamesStats topGames={stats.topGames}/> <TopGamesStats topGames={stats.topGames} {...props('TOP_GAMES')}/>
<TasteStats taste={stats.taste} {...chartProps('TASTE')}/> <TasteStats taste={stats.taste} {...props('TASTE')}/>
<PlatformStats platforms={stats.platforms} {...chartProps('PLATFORMS')}/> <PlatformStats platforms={stats.platforms} {...props('PLATFORMS')}/>
<HabitsStats habits={stats.habits}/> <HabitsStats habits={stats.habits}/>
<FinishedStats finished={stats.finished} {...chartProps('FINISHED')}/> <FinishedStats finished={stats.finished} {...props('FINISHED')}/>
</div> </div>
)} )}
</PageContainer> </PageContainer>
+44 -2
View File
@@ -292,10 +292,10 @@ describe('StatsPage', () => {
await screen.findByText('STATS.PLATFORMS.TITLE'); await screen.findByText('STATS.PLATFORMS.TITLE');
expect(slices('STATS.PLATFORMS.TITLE')).toHaveLength(0); expect(slices('STATS.PLATFORMS.TITLE')).toHaveLength(0);
for (const title of ['PLATFORMS', 'TASTE', 'FINISHED', 'TREND']) { for (const title of ['PLATFORMS', 'TASTE', 'FINISHED', 'TREND', 'TOP_GAMES']) {
expect(within(picker(`STATS.${title}.TITLE`)).getAllByRole('button').length).toBeGreaterThan(1); expect(within(picker(`STATS.${title}.TITLE`)).getAllByRole('button').length).toBeGreaterThan(1);
} }
expect(within(section('STATS.TOP_GAMES.TITLE')).queryByRole('group', {name: 'STATS.CHART.LABEL'})).toBeNull(); expect(within(picker('STATS.TOP_GAMES.TITLE')).getByRole('button', {name: 'STATS.CHART.LIST'}).getAttribute('aria-pressed')).toBe('true');
expect(within(picker('STATS.PLATFORMS.TITLE')).getByRole('button', {name: 'STATS.CHART.BARS'}).getAttribute('aria-pressed')).toBe('true'); expect(within(picker('STATS.PLATFORMS.TITLE')).getByRole('button', {name: 'STATS.CHART.BARS'}).getAttribute('aria-pressed')).toBe('true');
}); });
@@ -315,6 +315,48 @@ describe('StatsPage', () => {
expect(putBodies().at(-1)).toEqual({group: 'PLATFORMS', chart: null}); expect(putBodies().at(-1)).toEqual({group: 'PLATFORMS', chart: null});
}); });
const twoGames = () => stats({topGames: {
mostPlayed: [{appid: 620, name: 'Portal 2', imageUrl: null, minutes: 900},
{appid: 220, name: 'Half-Life 2', imageUrl: null, minutes: 300}],
mostLoyal: {appid: 620, name: 'Portal 2', imageUrl: null, months: 3, minutes: 900}}});
it('topGamesPieLinksEachGameAndShowsTheShareNote', async () => {
answerStats(json(200, twoGames()));
renderPage();
await screen.findByText('STATS.TOP_GAMES.TITLE');
fireEvent.click(within(picker('STATS.TOP_GAMES.TITLE')).getByRole('button', {name: 'STATS.CHART.PIE'}));
await waitFor(() => expect(slices('STATS.TOP_GAMES.TITLE')).toHaveLength(2));
expect(putBodies()).toEqual([{group: 'TOP_GAMES', chart: 'PIE'}]);
const card = section('STATS.TOP_GAMES.TITLE');
expect(within(card).getByRole('link', {name: /Half-Life 2/}).getAttribute('href')).toBe('/library/220');
expect(within(card).getByRole('link', {name: /Half-Life 2/}).textContent).toContain('5.0h');
expect(within(card).getByText('STATS.TOP_GAMES.SHARE')).toBeTruthy();
expect(within(card).getByText('STATS.TOP_GAMES.LOYAL')).toBeTruthy();
});
it('topGamesPieWithOneGameDrawsTheList', async () => {
answerStats(json(200, stats()), {TOP_GAMES: 'PIE'});
renderPage();
await screen.findByText('STATS.TOP_GAMES.TITLE');
expect(slices('STATS.TOP_GAMES.TITLE')).toHaveLength(0);
expect(section('STATS.TOP_GAMES.TITLE').querySelectorAll('img').length).toBeGreaterThan(0);
});
it('topGamesBarsHaveNoCoversAndLinkTheGame', async () => {
answerStats(json(200, twoGames()), {TOP_GAMES: 'BARS'});
renderPage();
await screen.findByText('STATS.TOP_GAMES.TITLE');
const card = section('STATS.TOP_GAMES.TITLE');
const bars = within(card).getAllByRole('link', {name: /Half-Life 2/});
expect(bars[0].getAttribute('href')).toBe('/library/220');
expect(bars[0].querySelector('img')).toBeNull();
expect(bars[0].querySelectorAll('.bg-primary')).toHaveLength(1);
});
it('showsTheStoredFormOfTheUser', async () => { it('showsTheStoredFormOfTheUser', async () => {
answerStats(json(200, stats()), {PLATFORMS: 'DONUT', TREND: 'BARS'}); answerStats(json(200, stats()), {PLATFORMS: 'DONUT', TREND: 'BARS'});
renderPage(); renderPage();
+31
View File
@@ -4,6 +4,20 @@ const AUTH = () => import.meta.env.VITE_API_URL;
export const ROLES = ['USER', 'AUDITOR', 'PLATFORM_ADMIN']; export const ROLES = ['USER', 'AUDITOR', 'PLATFORM_ADMIN'];
export const REFUSAL_KEY = {
SELF_ACTION: 'ADMIN.ERROR_SELF_ACTION',
TARGET_IS_ADMIN: 'ADMIN.ERROR_TARGET_IS_ADMIN',
ACTOR_NOT_ADMIN: 'ADMIN.ERROR_ACTOR_NOT_ADMIN',
ROLE_CHANGED: 'ADMIN.ERROR_ROLE_CHANGED',
TARGET_IS_BLOCKED: 'ADMIN.ERROR_TARGET_IS_BLOCKED',
ALREADY_DELETED: 'ADMIN.ERROR_ALREADY_DELETED',
NOT_PENDING: 'ADMIN.ERROR_NOT_PENDING',
TARGET_IS_PENDING: 'ADMIN.ERROR_TARGET_IS_PENDING',
TARGET_IS_UNCONFIRMED: 'ADMIN.ERROR_TARGET_IS_UNCONFIRMED',
NOT_UNCONFIRMED: 'ADMIN.ERROR_NOT_UNCONFIRMED',
NOT_ACTIVE: 'ADMIN.ERROR_NOT_ACTIVE',
};
/** /**
* GET /api/admin/users: {content, page, size, totalElements, totalPages}. PLATFORM_ADMIN only (403 otherwise). * GET /api/admin/users: {content, page, size, totalElements, totalPages}. PLATFORM_ADMIN only (403 otherwise).
* *
@@ -34,6 +48,23 @@ export const setUserBlocked = (userId, blocked) =>
body: JSON.stringify({blocked}), body: JSON.stringify({blocked}),
}); });
/**
* Sets the role of another user. `expectedRole` is the role the admin saw. 204, or 409 with code SELF_ACTION,
* ACTOR_NOT_ADMIN, ROLE_CHANGED, TARGET_IS_BLOCKED, ALREADY_DELETED, TARGET_IS_PENDING or TARGET_IS_UNCONFIRMED.
*/
export const changeUserRole = (userId, role, expectedRole) =>
apiFetch(`${AUTH()}/api/admin/users/${userId}/role`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({role, expectedRole}),
});
/**
* GET the role changes of a user, newest first: {results: [{actorUserId, actorEmail, oldRole, newRole, changedAt}]}.
* `actorEmail` is null after the actor is purged.
*/
export const fetchRoleChanges = (userId, init) => apiFetch(`${AUTH()}/api/admin/users/${userId}/role-changes`, init);
/** /**
* Hard delete of a request whose address is not confirmed: 204, or 409 NOT_UNCONFIRMED for any other status. * Hard delete of a request whose address is not confirmed: 204, or 409 NOT_UNCONFIRMED for any other status.
*/ */
+25 -1
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {describe, expect, it} from 'vitest'; import {describe, expect, it} from 'vitest';
import {refreshSession} from './api.js'; import {refreshSession} from './api.js';
import {viewAs} from './admin.js'; import {changeUserRole, fetchRoleChanges, viewAs} from './admin.js';
import {fetchRoutes, json} from '../test/fetchRoutes.js'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
describe('viewAs', () => { describe('viewAs', () => {
@@ -25,3 +25,27 @@ describe('viewAs', () => {
await switching; await switching;
}); });
}); });
describe('role changes', () => {
it('changeUserRole puts the new and the expected role', async () => {
fetchRoutes({'PUT /api/admin/users/7/role': json(204)});
await changeUserRole(7, 'AUDITOR', 'USER');
const [url, init] = fetch.mock.calls[0];
expect(url).toMatch(/\/api\/admin\/users\/7\/role$/);
expect(init.method).toBe('PUT');
expect(JSON.parse(init.body)).toEqual({role: 'AUDITOR', expectedRole: 'USER'});
});
it('fetchRoleChanges gets the log of the user', async () => {
fetchRoutes({'GET /api/admin/users/7/role-changes': json(200, {results: []})});
const signal = new AbortController().signal;
await fetchRoleChanges(7, {signal});
const [url, init] = fetch.mock.calls[0];
expect(url).toMatch(/\/api\/admin\/users\/7\/role-changes$/);
expect(init.signal).toBe(signal);
});
});
+21
View File
@@ -8,6 +8,9 @@ export const STAT_CHARTS = {
TASTE: ['BARS', 'PIE', 'DONUT', 'LIST'], TASTE: ['BARS', 'PIE', 'DONUT', 'LIST'],
FINISHED: ['BARS', 'LINE', 'LIST'], FINISHED: ['BARS', 'LINE', 'LIST'],
TREND: ['LINE', 'BARS'], TREND: ['LINE', 'BARS'],
TOP_GAMES: ['LIST', 'BARS', 'PIE', 'DONUT'],
ACTIVITY: ['BARS', 'LINE'],
ACTIVITY_GAMES: ['LIST', 'BARS', 'PIE', 'DONUT'],
}; };
/** /**
@@ -21,6 +24,24 @@ export const chartOf = (charts, group) => {
return forms.includes(stored) ? stored : forms[0]; return forms.includes(stored) ? stored : forms[0];
}; };
/**
* The `chart` and `onChart` props of a stat view.
*
* @param onChart `(group, form)`, or null: the view shows no picker
*/
export const chartProps = (charts, onChart, group) => ({
chart: chartOf(charts, group),
onChart: onChart ? (form) => onChart(group, form) : undefined,
});
/**
* The form to draw for play time per game: a pie or donut of fewer than two games with play time is a list.
*
* @param games `[{minutes}]`
*/
export const playChart = (chart, games) =>
(chart === 'PIE' || chart === 'DONUT') && games.filter((g) => g.minutes > 0).length < 2 ? 'LIST' : chart;
/** /**
* The slices of a pie: the items with a value, biggest first. More than `keep + 1` items: the `keep` biggest and * The slices of a pie: the items with a value, biggest first. More than `keep + 1` items: the `keep` biggest and
* one "other" slice with the rest. * one "other" slice with the rest.
+43 -1
View File
@@ -1,5 +1,5 @@
import {describe, expect, it} from 'vitest'; import {describe, expect, it} from 'vitest';
import {chartOf, linePoints, sliceColors, slicePaths, statusSlices, topSlices, trendBars, yearSeries} from './statCharts.js'; import {chartOf, chartProps, linePoints, playChart, sliceColors, slicePaths, statusSlices, topSlices, trendBars, yearSeries} from './statCharts.js';
import {GAME_STATUSES} from './limits.js'; import {GAME_STATUSES} from './limits.js';
describe('chartOf', () => { describe('chartOf', () => {
@@ -19,6 +19,48 @@ describe('chartOf', () => {
}); });
}); });
describe('chartOf for the play time groups', () => {
it('falls back to the first form', () => {
expect(chartOf(null, 'TOP_GAMES')).toBe('LIST');
expect(chartOf({TOP_GAMES: 'RADAR'}, 'TOP_GAMES')).toBe('LIST');
expect(chartOf({}, 'ACTIVITY')).toBe('BARS');
expect(chartOf({ACTIVITY: 'LINE'}, 'ACTIVITY')).toBe('LINE');
expect(chartOf({ACTIVITY_GAMES: 'PIE'}, 'ACTIVITY_GAMES')).toBe('PIE');
expect(chartOf({ACTIVITY_GAMES: 'LINE'}, 'ACTIVITY_GAMES')).toBe('LIST');
});
});
describe('chartProps', () => {
it('has no onChart without a handler', () => {
expect(chartProps({TASTE: 'PIE'}, null, 'TASTE')).toEqual({chart: 'PIE', onChart: undefined});
});
it('calls the handler with the group and the form', () => {
const calls = [];
const props = chartProps(null, (group, form) => calls.push([group, form]), 'TREND');
props.onChart('BARS');
expect(props.chart).toBe('LINE');
expect(calls).toEqual([['TREND', 'BARS']]);
});
});
describe('playChart', () => {
const game = (minutes) => ({appid: 1, name: 'G', minutes});
it('draws the list when a pie has fewer than two games with play time', () => {
expect(playChart('PIE', [game(10)])).toBe('LIST');
expect(playChart('DONUT', [game(0), game(10)])).toBe('LIST');
});
it('keeps the form otherwise', () => {
expect(playChart('PIE', [game(5), game(10)])).toBe('PIE');
expect(playChart('BARS', [])).toBe('BARS');
expect(playChart('LIST', [game(10)])).toBe('LIST');
});
});
describe('topSlices', () => { describe('topSlices', () => {
const item = (name, games) => ({name, games}); const item = (name, games) => ({name, games});
const games = (i) => i.games; const games = (i) => i.games;
+1 -1
View File
@@ -28,7 +28,7 @@ export const markSyncChangesSeen = (syncedAt) =>
/** /**
* What the dashboard shows of a result: null (nothing), `{mode: 'nothing'}` for a manual sync that changed nothing, or * What the dashboard shows of a result: null (nothing), `{mode: 'nothing'}` for a manual sync that changed nothing, or
* `{mode: 'changes', imported, added, removed, minutes, achievements, moves}`. A result the user saw, and an empty * `{mode: 'changes', imported, added, removed, minutes, achievements, moves}`. A result the user saw, and an empty
* result of an automatic sync, show nothing. * result of an automatic or a visit sync, show nothing.
*/ */
export function bannerOf(result) { export function bannerOf(result) {
if (!result || result.seen) return null; if (!result || result.seen) return null;
+4
View File
@@ -16,6 +16,10 @@ describe('bannerOf', () => {
expect(bannerOf(result())).toBeNull(); expect(bannerOf(result())).toBeNull();
}); });
it('showsNothingForAnEmptyLoginSync', () => {
expect(bannerOf(result({startedBy: 'LOGIN'}))).toBeNull();
});
it('saysNothingChangedForAnEmptyManualSync', () => { it('saysNothingChangedForAnEmptyManualSync', () => {
expect(bannerOf(result({startedBy: 'MANUAL'}))).toEqual({mode: 'nothing'}); expect(bannerOf(result({startedBy: 'MANUAL'}))).toEqual({mode: 'nothing'});
}); });