feat(hint): targets, automatic hints and formatting

Target fields on the admin hints page. The dashboard shows the four
automatic hints after admin hints when their condition is true, using
data Core already serves. HintText renders **bold** and safe links (app
path or https) as React elements, never HTML, in hints and news.
Auto-close without tick hides for this session, X for 7 days, tick + X
for good.
This commit is contained in:
lobo committed 2026-10-02 14:36:49 +02:00
1 parent ae904d946a
commit 8598024bce
14 files changed
+578 -53

No files matched your search

+7 -1
View File
@@ -118,7 +118,13 @@ To Be Played is split into three repositories:
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.
- **Dashboard hints**: news from the admin at the bottom of the dashboard, one at a time. A "Dismiss" hint closes - **Dashboard hints**: news from the admin at the bottom of the dashboard, one at a time. A "Dismiss" hint closes
after 30 s or with X and comes back in the next browser session unless "Do not show again" is ticked; an after 30 s or with X and comes back in the next browser session unless "Do not show again" is ticked; an
"Accept" hint stays until "OK". Admins write, preview and end hints on `/admin/hints` (Auth stores them). "Accept" hint stays until "OK". Admins write, preview and end hints on `/admin/hints` (Auth stores them). A hint can
target groups (without Steam, role and higher, new users, empty library; all must match). Four automatic hints
(no Steam, empty library, pile grew this month, open link queue for auditors) show after the admin hints while
their condition is true; the Frontend checks it with Core's library summary, stats and link queue. X hides an
automatic hint for 7 days, "Do not show again" for good, the 30 s auto close only for this browser session.
Admins turn each on or off on `/admin/hints`. Hint and news text may use `**bold**` and `[text](url)` (an app
path or https; anything else stays plain text); `HintText` renders React elements, never HTML.
- **What's new**: a dialog on the dashboard shows the news entries the user has not seen yet, newest first. "Got it" - **What's new**: a dialog on the dashboard shows the news entries the user has not seen yet, newest first. "Got it"
marks them seen in Auth; closing the dialog any other way shows them again on the next dashboard visit. `/whats-new` marks them seen in Auth; closing the dialog any other way shows them again on the next dashboard visit. `/whats-new`
(menu) lists all entries and marks them seen. Admins publish, preview, edit and delete entries on `/admin/news`. (menu) lists all entries and marks them seen. Admins publish, preview, edit and delete entries on `/admin/news`.
+29 -4
View File
@@ -739,13 +739,19 @@
"CLOSE": "Schließen", "CLOSE": "Schließen",
"DO_NOT_SHOW_AGAIN": "Nicht mehr anzeigen", "DO_NOT_SHOW_AGAIN": "Nicht mehr anzeigen",
"OK": "OK", "OK": "OK",
"SAVE_FAILED": "Der Hinweis konnte nicht gespeichert werden." "SAVE_FAILED": "Der Hinweis konnte nicht gespeichert werden.",
"AUTO_NO_STEAM": "[Verknüpfe dein Steam-Konto](/settings), damit deine Bibliothek abgeglichen wird.",
"AUTO_EMPTY_LIBRARY": "Deine Bibliothek ist noch leer. [Importiere deine Spiele](/import).",
"AUTO_PILE_GREW_one": "Dein Stapel ist diesen Monat um **{{count}}** Spiel gewachsen. [Zum Verlauf](/stats).",
"AUTO_PILE_GREW_other": "Dein Stapel ist diesen Monat um **{{count}}** Spiele gewachsen. [Zum Verlauf](/stats).",
"AUTO_LINK_QUEUE_one": "**{{count}}** Paar wartet in der [Verknüpfungs-Warteschlange](/audit/links).",
"AUTO_LINK_QUEUE_other": "**{{count}}** Paare warten in der [Verknüpfungs-Warteschlange](/audit/links)."
}, },
"ADMIN_HINTS": { "ADMIN_HINTS": {
"NEW": "Neuer Hinweis", "NEW": "Neuer Hinweis",
"TEXT_EN": "Text (Englisch)", "TEXT_EN": "Text (Englisch)",
"TEXT_DE": "Text (Deutsch, optional)", "TEXT_DE": "Text (Deutsch, optional)",
"TEXT_HELP": "Nur Text, höchstens 500 Zeichen. Ohne deutschen Text sehen deutsche Nutzer den englischen.", "TEXT_HELP": "Nur Text, höchstens 500 Zeichen. Mit **fett** und [Text](/pfad) oder [Text](https://...) für Links. Ohne deutschen Text sehen deutsche Nutzer den englischen.",
"KIND": "Art", "KIND": "Art",
"KIND_DISMISS": "Schließen: geht nach 30 s oder mit X zu", "KIND_DISMISS": "Schließen: geht nach 30 s oder mit X zu",
"KIND_ACCEPT": "Bestätigen: bleibt, bis der Nutzer OK klickt", "KIND_ACCEPT": "Bestätigen: bleibt, bis der Nutzer OK klickt",
@@ -767,7 +773,26 @@
"STATUS_ENDED": "Beendet", "STATUS_ENDED": "Beendet",
"RANGE": "{{start}} bis {{end}}", "RANGE": "{{start}} bis {{end}}",
"DONE_COUNT_one": "Von {{count}} Nutzer für immer geschlossen", "DONE_COUNT_one": "Von {{count}} Nutzer für immer geschlossen",
"DONE_COUNT_other": "Von {{count}} Nutzern für immer geschlossen" "DONE_COUNT_other": "Von {{count}} Nutzern für immer geschlossen",
"TARGET": "Wer es sieht",
"TARGET_NO_STEAM": "Nur Nutzer ohne verknüpftes Steam-Konto",
"TARGET_EMPTY_LIBRARY": "Nur Nutzer mit leerer Bibliothek",
"TARGET_ROLE": "Rolle (diese Rolle und höher)",
"TARGET_ROLE_ALL": "Alle Rollen",
"TARGET_NEW_DAYS": "Nur Nutzer, die sich in den letzten N Tagen registriert haben (leer: egal)",
"TARGET_HELP": "Alle angehakten Gruppen müssen zutreffen. Nichts angehakt: alle Nutzer.",
"TARGET_ALL": "Alle Nutzer",
"TARGET_SUMMARY_NO_STEAM": "Ohne Steam",
"TARGET_SUMMARY_ROLE": "{{role}} und höher",
"TARGET_SUMMARY_NEW_one": "Registriert am letzten Tag",
"TARGET_SUMMARY_NEW_other": "Registriert in den letzten {{count}} Tagen",
"TARGET_SUMMARY_EMPTY_LIBRARY": "Leere Bibliothek",
"AUTO_TITLE": "Automatische Hinweise",
"AUTO_NO_STEAM": "Kein Steam-Konto verknüpft",
"AUTO_EMPTY_LIBRARY": "Leere Bibliothek",
"AUTO_PILE_GREW": "Der Stapel ist diesen Monat gewachsen",
"AUTO_LINK_QUEUE": "Offene Paare in der Verknüpfungs-Warteschlange (Auditoren)",
"AUTO_SAVED": "Gespeichert."
}, },
"NEWS": { "NEWS": {
"TITLE": "Neuigkeiten", "TITLE": "Neuigkeiten",
@@ -806,7 +831,7 @@
"TITLE_DE": "Titel (Deutsch, optional)", "TITLE_DE": "Titel (Deutsch, optional)",
"TEXT_EN": "Text (Englisch)", "TEXT_EN": "Text (Englisch)",
"TEXT_DE": "Text (Deutsch, optional)", "TEXT_DE": "Text (Deutsch, optional)",
"TEXT_HELP": "Nur Text. Titel höchstens 100, Text höchstens 2000 Zeichen. Ohne deutschen Text sehen deutsche Nutzer den englischen.", "TEXT_HELP": "Nur Text. Titel höchstens 100, Text höchstens 2000 Zeichen. Im Text geht **fett** und [Text](/pfad) oder [Text](https://...). Ohne deutschen Text sehen deutsche Nutzer den englischen.",
"PREVIEW": "Vorschau", "PREVIEW": "Vorschau",
"CREATE": "Veröffentlichen", "CREATE": "Veröffentlichen",
"SAVE": "Speichern", "SAVE": "Speichern",
+29 -4
View File
@@ -739,13 +739,19 @@
"CLOSE": "Close", "CLOSE": "Close",
"DO_NOT_SHOW_AGAIN": "Do not show again", "DO_NOT_SHOW_AGAIN": "Do not show again",
"OK": "OK", "OK": "OK",
"SAVE_FAILED": "The hint could not be saved." "SAVE_FAILED": "The hint could not be saved.",
"AUTO_NO_STEAM": "[Link your Steam account](/settings) to sync your library.",
"AUTO_EMPTY_LIBRARY": "Your library is still empty. [Import your games](/import).",
"AUTO_PILE_GREW_one": "Your pile grew by **{{count}}** game this month. [See the trend](/stats).",
"AUTO_PILE_GREW_other": "Your pile grew by **{{count}}** games this month. [See the trend](/stats).",
"AUTO_LINK_QUEUE_one": "**{{count}}** pair waits in the [link queue](/audit/links).",
"AUTO_LINK_QUEUE_other": "**{{count}}** pairs wait in the [link queue](/audit/links)."
}, },
"ADMIN_HINTS": { "ADMIN_HINTS": {
"NEW": "New hint", "NEW": "New hint",
"TEXT_EN": "Text (English)", "TEXT_EN": "Text (English)",
"TEXT_DE": "Text (German, optional)", "TEXT_DE": "Text (German, optional)",
"TEXT_HELP": "Plain text, max 500 characters. Without a German text, German users see the English one.", "TEXT_HELP": "Plain text, max 500 characters. Use **bold** and [text](/path) or [text](https://...) for links. Without a German text, German users see the English one.",
"KIND": "Type", "KIND": "Type",
"KIND_DISMISS": "Dismiss: closes after 30 s or with X", "KIND_DISMISS": "Dismiss: closes after 30 s or with X",
"KIND_ACCEPT": "Accept: stays until the user clicks OK", "KIND_ACCEPT": "Accept: stays until the user clicks OK",
@@ -767,7 +773,26 @@
"STATUS_ENDED": "Ended", "STATUS_ENDED": "Ended",
"RANGE": "{{start}} to {{end}}", "RANGE": "{{start}} to {{end}}",
"DONE_COUNT_one": "Closed for good by {{count}} user", "DONE_COUNT_one": "Closed for good by {{count}} user",
"DONE_COUNT_other": "Closed for good by {{count}} users" "DONE_COUNT_other": "Closed for good by {{count}} users",
"TARGET": "Who sees it",
"TARGET_NO_STEAM": "Only users without a linked Steam account",
"TARGET_EMPTY_LIBRARY": "Only users with an empty library",
"TARGET_ROLE": "Role (this role and higher)",
"TARGET_ROLE_ALL": "All roles",
"TARGET_NEW_DAYS": "Only users registered in the last N days (empty: any)",
"TARGET_HELP": "All ticked groups must match. Nothing ticked: all users.",
"TARGET_ALL": "All users",
"TARGET_SUMMARY_NO_STEAM": "Without Steam",
"TARGET_SUMMARY_ROLE": "{{role}} and higher",
"TARGET_SUMMARY_NEW_one": "Registered in the last day",
"TARGET_SUMMARY_NEW_other": "Registered in the last {{count}} days",
"TARGET_SUMMARY_EMPTY_LIBRARY": "Empty library",
"AUTO_TITLE": "Automatic hints",
"AUTO_NO_STEAM": "No Steam account linked",
"AUTO_EMPTY_LIBRARY": "Empty library",
"AUTO_PILE_GREW": "The pile grew this month",
"AUTO_LINK_QUEUE": "Open pairs in the link queue (auditors)",
"AUTO_SAVED": "Saved."
}, },
"NEWS": { "NEWS": {
"TITLE": "What's new", "TITLE": "What's new",
@@ -806,7 +831,7 @@
"TITLE_DE": "Title (German, optional)", "TITLE_DE": "Title (German, optional)",
"TEXT_EN": "Text (English)", "TEXT_EN": "Text (English)",
"TEXT_DE": "Text (German, optional)", "TEXT_DE": "Text (German, optional)",
"TEXT_HELP": "Plain text. Title max 100, text max 2000 characters. Without a German text, German users see the English one.", "TEXT_HELP": "Plain text. Title max 100, text max 2000 characters. Use **bold** and [text](/path) or [text](https://...) in the text. Without a German text, German users see the English one.",
"PREVIEW": "Preview", "PREVIEW": "Preview",
"CREATE": "Publish", "CREATE": "Publish",
"SAVE": "Save", "SAVE": "Save",
+61 -18
View File
@@ -1,9 +1,11 @@
import {useCallback, useEffect, useState} from 'react'; import {useCallback, useEffect, useMemo, useState} from 'react';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {toast} from 'sonner'; import {toast} from 'sonner';
import HintCard from './HintCard.jsx'; import HintCard from './HintCard.jsx';
import {fetchHints, markHintDone} from '../utils/hints.js'; import {AUTO_HINTS, fetchHints, hideAutoHint, markHintDone, pileGrowth, snoozeAutoHint} from '../utils/hints.js';
import {canCorrect} from '../utils/corrections.js';
import {fetchLinkQueue} from '../utils/links.js';
export const AUTO_CLOSE_MS = 30_000; export const AUTO_CLOSE_MS = 30_000;
const CLOSED_KEY = 'tbp_closed_hints'; const CLOSED_KEY = 'tbp_closed_hints';
@@ -26,34 +28,75 @@ const rememberClosed = (hintId) => {
}; };
/** /**
* The admin hints on the dashboard, one at a time, newest first. A DISMISS hint closed without "Do not show again" * The hints on the dashboard, one at a time: the admin hints newest first, then the automatic ones. A DISMISS hint
* comes back in the next browser session. * closed by the 30 s timer without "Do not show again" comes back in the next browser session. The X on an automatic
* hint hides it for 7 days, "Do not show again" for good.
*
* @param user the current user
* @param summary Core's library summary; undefined while loading, null when it failed
* @param trend the TREND group of the stats; undefined while loading, null when it failed
*/ */
export default function DashboardHint() { export default function DashboardHint({user, summary, trend}) {
const {t} = useTranslation(); const {t} = useTranslation();
const [hints, setHints] = useState([]); const [data, setData] = useState({hints: [], automatic: []});
const [closed, setClosed] = useState(closedIds);
const [hideForever, setHideForever] = useState(false); const [hideForever, setHideForever] = useState(false);
const hint = hints[0]; const [linkCount, setLinkCount] = useState();
const wantsLinkQueue = data.automatic.includes('LINK_QUEUE') && canCorrect(user);
useEffect(() => { useEffect(() => {
// A failed load shows nothing, not an error. // A failed load shows nothing, not an error.
fetchHints() fetchHints()
.then((response) => (response.ok ? response.json() : null)) .then((response) => (response.ok ? response.json() : null))
.then((data) => { .then((body) => setData({hints: body?.results ?? [], automatic: body?.automatic ?? []}))
const closed = closedIds();
setHints((data?.results ?? []).filter((hint) => !closed.includes(hint.id)));
})
.catch(() => undefined); .catch(() => undefined);
}, []); }, []);
useEffect(() => {
if (!wantsLinkQueue) return;
// The queue is an AUDITOR page: a failure or 403 counts as an empty queue.
fetchLinkQueue({page: 0, size: 20})
.then((response) => (response.ok ? response.json() : null))
.then((body) => setLinkCount(body?.totalElements ?? 0))
.catch(() => setLinkCount(0));
}, [wantsLinkQueue]);
const hint = useMemo(() => {
const {hints, automatic} = data;
const links = wantsLinkQueue ? linkCount : 0;
const needsSummary = hints.some((h) => h.emptyLibraryOnly) || automatic.includes('EMPTY_LIBRARY');
// Show nothing until every fact the cards need has settled, so a later fact does not swap the card.
if ((needsSummary && summary === undefined) || (automatic.includes('PILE_GREW') && trend === undefined)
|| (automatic.includes('NO_STEAM') && !user) || links === undefined) {
return undefined;
}
const libraryEmpty = summary?.total === 0;
const growth = pileGrowth(trend);
const condition = {NO_STEAM: !user?.steamId, EMPTY_LIBRARY: libraryEmpty, LINK_QUEUE: links > 0, PILE_GREW: growth > 0};
const count = {LINK_QUEUE: links, PILE_GREW: growth};
return [
...hints.filter((h) => !h.emptyLibraryOnly || libraryEmpty),
...AUTO_HINTS.filter((key) => automatic.includes(key) && condition[key]).map((key) => ({
id: `auto:${key}`, auto: key, kind: 'DISMISS', text: t(`HINT.AUTO_${key}`, {count: count[key]}),
})),
].find((h) => !closed.includes(h.id));
}, [data, closed, summary, trend, user, linkCount, wantsLinkQueue, t]);
// A failed save only shows an error: the hint comes back in the next browser session. // A failed save only shows an error: the hint comes back in the next browser session.
const close = useCallback(async (done) => { const close = useCallback(async (forever, byUser) => {
rememberClosed(hint.id); rememberClosed(hint.id);
setHints((current) => current.slice(1)); setClosed((current) => [...current, hint.id]);
setHideForever(false); setHideForever(false);
if (!done) return; let save = null;
if (hint.auto) {
if (forever) save = () => hideAutoHint(hint.auto);
else if (byUser) save = () => snoozeAutoHint(hint.auto);
} else if (forever) {
save = () => markHintDone(hint.id);
}
if (!save) return;
try { try {
const response = await markHintDone(hint.id); const response = await save();
if (!response.ok) toast.error(t('HINT.SAVE_FAILED')); if (!response.ok) toast.error(t('HINT.SAVE_FAILED'));
} catch { } catch {
toast.error(t('INFO.ERROR.NETWORK_FAILURE')); toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
@@ -62,7 +105,7 @@ export default function DashboardHint() {
useEffect(() => { useEffect(() => {
if (hint?.kind !== 'DISMISS') return undefined; if (hint?.kind !== 'DISMISS') return undefined;
const timer = setTimeout(() => close(hideForever), AUTO_CLOSE_MS); const timer = setTimeout(() => close(hideForever, false), AUTO_CLOSE_MS);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [hint, hideForever, close]); }, [hint, hideForever, close]);
@@ -71,8 +114,8 @@ export default function DashboardHint() {
// The next card shows under the same pointer: the second click of a double click must not close it unseen. // The next card shows under the same pointer: the second click of a double click must not close it unseen.
return ( return (
<HintCard key={hint.id} hint={hint} hideForever={hideForever} onHideForeverChange={setHideForever} <HintCard key={hint.id} hint={hint} hideForever={hideForever} onHideForeverChange={setHideForever}
onClose={(event) => event.detail < 2 && close(hideForever)} onClose={(event) => event.detail < 2 && close(hideForever, true)}
onAccept={(event) => event.detail < 2 && close(true)} onAccept={(event) => event.detail < 2 && close(true, true)}
className="fixed inset-x-4 bottom-4 z-50 mx-auto max-w-md"/> className="fixed inset-x-4 bottom-4 z-50 mx-auto max-w-md"/>
); );
} }
+178 -6
View File
@@ -1,9 +1,10 @@
// @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 {act, cleanup, fireEvent, render, screen} from '@testing-library/react'; import {act, cleanup, fireEvent, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner'; import {toast} from 'sonner';
import DashboardHint, {AUTO_CLOSE_MS} from './DashboardHint.jsx'; import DashboardHint, {AUTO_CLOSE_MS} from './DashboardHint.jsx';
import {hintText} from '../utils/hints.js'; import {hintText, pileGrowth} from '../utils/hints.js';
import {fetchRoutes, json} from '../test/fetchRoutes.js'; 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'));
@@ -12,14 +13,29 @@ vi.mock('sonner', () => ({toast: {error: vi.fn()}}));
const DISMISS = {id: 2, textEn: 'Server update tonight', textDe: 'Heute Nacht Update', kind: 'DISMISS'}; const DISMISS = {id: 2, textEn: 'Server update tonight', textDe: 'Heute Nacht Update', kind: 'DISMISS'};
const ACCEPT = {id: 1, textEn: 'Link your Steam account', textDe: null, kind: 'ACCEPT'}; const ACCEPT = {id: 1, textEn: 'Link your Steam account', textDe: null, kind: 'ACCEPT'};
const hintRoutes = (list, done = json(204)) => fetchRoutes({ const USER = {steamId: '1', activeRole: 'USER'};
const AUDITOR = {steamId: '1', activeRole: 'AUDITOR'};
const NO_TREND = {days: []};
const GREW = {days: [{day: '2026-10-01', backlogGames: 10}, {day: '2026-10-02', backlogGames: 12}]};
const hintRoutes = (list, done = json(204), more = {}) => fetchRoutes({
'GET /api/hints': list, 'PUT /api/hints/1/done': done, 'PUT /api/hints/2/done': done, 'GET /api/hints': list, 'PUT /api/hints/1/done': done, 'PUT /api/hints/2/done': done,
'PUT /api/hints/auto/NO_STEAM/snooze': done, 'PUT /api/hints/auto/NO_STEAM/done': done, ...more,
}); });
const doneCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([url]) => url); const doneCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([url]) => url);
const mount = (props = {}) => render(
<MemoryRouter><DashboardHint user={USER} summary={{total: 5}} trend={NO_TREND} {...props}/></MemoryRouter>);
const showHints = async (...hints) => { const showHints = async (...hints) => {
hintRoutes(json(200, {results: hints})); hintRoutes(json(200, {results: hints}));
render(<DashboardHint/>); mount();
await act(async () => undefined);
};
const showAuto = async (automatic, props = {}, results = [], more = {}) => {
hintRoutes(json(200, {results, automatic}), json(204), more);
mount(props);
await act(async () => undefined); await act(async () => undefined);
}; };
@@ -50,7 +66,7 @@ describe('DashboardHint', () => {
fireEvent.click(screen.getByLabelText('HINT.CLOSE')); fireEvent.click(screen.getByLabelText('HINT.CLOSE'));
cleanup(); cleanup();
render(<DashboardHint/>); mount();
await act(async () => undefined); await act(async () => undefined);
expect(screen.queryByText(DISMISS.textDe)).toBeNull(); expect(screen.queryByText(DISMISS.textDe)).toBeNull();
@@ -92,7 +108,7 @@ describe('DashboardHint', () => {
it('shows an error when the answer is not stored', async () => { it('shows an error when the answer is not stored', async () => {
hintRoutes(json(200, {results: [ACCEPT]}), json(500)); hintRoutes(json(200, {results: [ACCEPT]}), json(500));
render(<DashboardHint/>); mount();
await act(async () => undefined); await act(async () => undefined);
await act(async () => fireEvent.click(screen.getByText('HINT.OK'))); await act(async () => fireEvent.click(screen.getByText('HINT.OK')));
@@ -113,7 +129,7 @@ describe('DashboardHint', () => {
it('shows nothing when the hints do not load', async () => { it('shows nothing when the hints do not load', async () => {
hintRoutes(json(500)); hintRoutes(json(500));
render(<DashboardHint/>); mount();
await act(async () => undefined); await act(async () => undefined);
expect(screen.queryByRole('status')).toBeNull(); expect(screen.queryByRole('status')).toBeNull();
@@ -121,6 +137,162 @@ describe('DashboardHint', () => {
}); });
}); });
describe('DashboardHint, automatic hints', () => {
beforeEach(() => vi.useFakeTimers({shouldAdvanceTime: true}));
afterEach(() => {
cleanup();
sessionStorage.clear();
vi.useRealTimers();
vi.clearAllMocks();
});
it('shows the admin hints before the automatic ones', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}}, [DISMISS]);
expect(screen.getByText('Heute Nacht Update')).toBeTruthy();
fireEvent.click(screen.getByLabelText('HINT.CLOSE'));
expect(screen.getByText('HINT.AUTO_NO_STEAM')).toBeTruthy();
});
it('shows NO_STEAM only without a Steam ID', async () => {
await showAuto(['NO_STEAM']);
expect(screen.queryByRole('status')).toBeNull();
});
it('hides an automatic hint for 7 days with X', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}});
await act(async () => fireEvent.click(screen.getByLabelText('HINT.CLOSE')));
expect(screen.queryByRole('status')).toBeNull();
expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/auto/NO_STEAM/snooze')]);
});
it('hides an automatic hint for good with the tick', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}});
fireEvent.click(screen.getByLabelText('HINT.DO_NOT_SHOW_AGAIN'));
await act(async () => fireEvent.click(screen.getByLabelText('HINT.CLOSE')));
expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/auto/NO_STEAM/done')]);
});
it('does not store the auto close of an automatic hint and shows it again next session', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}});
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS));
expect(screen.queryByRole('status')).toBeNull();
expect(doneCalls()).toEqual([]);
cleanup();
sessionStorage.clear();
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}});
expect(screen.getByText('HINT.AUTO_NO_STEAM')).toBeTruthy();
});
it('hides the auto closed hint for good when the tick is set', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}});
fireEvent.click(screen.getByLabelText('HINT.DO_NOT_SHOW_AGAIN'));
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS));
expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/auto/NO_STEAM/done')]);
});
it('does not show an automatic hint that Auth did not send', async () => {
await showAuto([], {user: {...USER, steamId: null}, summary: {total: 0}});
expect(screen.queryByRole('status')).toBeNull();
});
it('shows EMPTY_LIBRARY only with an empty library', async () => {
await showAuto(['EMPTY_LIBRARY'], {summary: {total: 3}});
expect(screen.queryByRole('status')).toBeNull();
cleanup();
await showAuto(['EMPTY_LIBRARY'], {summary: {total: 0}});
expect(screen.getByText('HINT.AUTO_EMPTY_LIBRARY')).toBeTruthy();
});
it('shows an admin hint with emptyLibraryOnly only with an empty library', async () => {
const only = {...DISMISS, emptyLibraryOnly: true};
await showAuto([], {summary: {total: 3}}, [only]);
expect(screen.queryByRole('status')).toBeNull();
cleanup();
await showAuto([], {summary: {total: 0}}, [only]);
expect(screen.getByText('Heute Nacht Update')).toBeTruthy();
});
it('shows nothing while a fact it needs is loading', async () => {
await showAuto(['PILE_GREW'], {trend: undefined});
expect(screen.queryByRole('status')).toBeNull();
cleanup();
await showAuto(['EMPTY_LIBRARY'], {summary: undefined});
expect(screen.queryByRole('status')).toBeNull();
});
it('shows PILE_GREW with the growth since the first day of the month', async () => {
await showAuto(['PILE_GREW'], {trend: GREW});
expect(screen.getByText('HINT.AUTO_PILE_GREW:2')).toBeTruthy();
});
it('does not ask the link queue below AUDITOR', async () => {
await showAuto(['LINK_QUEUE'], {user: USER});
expect(screen.queryByRole('status')).toBeNull();
expect(fetch.mock.calls.some(([url]) => url.includes('link-queue'))).toBe(false);
});
it('shows LINK_QUEUE to an auditor with the number of pairs', async () => {
await showAuto(['LINK_QUEUE'], {user: AUDITOR}, [],
{'GET /api/library/link-queue': json(200, {content: [], totalElements: 3})});
expect(screen.getByText('HINT.AUTO_LINK_QUEUE:3')).toBeTruthy();
});
it('shows no LINK_QUEUE hint when the queue is empty or refused', async () => {
await showAuto(['LINK_QUEUE'], {user: AUDITOR}, [],
{'GET /api/library/link-queue': json(403, {})});
expect(screen.queryByRole('status')).toBeNull();
});
it('shows only the admin hints when Auth sends no automatic list', async () => {
hintRoutes(json(200, {results: [DISMISS]}));
mount({user: {...USER, steamId: null}});
await act(async () => undefined);
expect(screen.getByText('Heute Nacht Update')).toBeTruthy();
fireEvent.click(screen.getByLabelText('HINT.CLOSE'));
expect(screen.queryByRole('status')).toBeNull();
});
});
describe('pileGrowth', () => {
it('counts from the first day of the last month and is 0 without a second day', () => {
expect(pileGrowth(GREW)).toBe(2);
expect(pileGrowth({days: [{day: '2026-09-30', backlogGames: 5}, {day: '2026-10-02', backlogGames: 4}]})).toBe(-1);
expect(pileGrowth({days: [{day: '2026-10-02', backlogGames: 4}]})).toBe(0);
expect(pileGrowth({days: [{day: '2026-10-01', backlogGames: 9}, {day: '2026-10-02', backlogGames: 7}]})).toBe(-2);
expect(pileGrowth(null)).toBe(0);
});
it('counts games added on the first day of the month', () => {
// Pile 5 at the end of September, 3 games added on October 1, nothing after.
const trend = {days: [{day: '2026-09-30', backlogGames: 5}, {day: '2026-10-01', backlogGames: 8},
{day: '2026-10-02', backlogGames: 8}]};
expect(pileGrowth(trend)).toBe(3);
// On October 1 itself: last month's snapshot and today's live value.
expect(pileGrowth({days: [{day: '2026-09-30', backlogGames: 5}, {day: '2026-10-01', backlogGames: 8}]})).toBe(3);
});
});
describe('hintText', () => { describe('hintText', () => {
it('takes the German text for a German UI only when there is one', () => { it('takes the German text for a German UI only when there is one', () => {
expect(hintText(DISMISS, 'de-DE')).toBe(DISMISS.textDe); expect(hintText(DISMISS, 'de-DE')).toBe(DISMISS.textDe);
+4 -1
View File
@@ -5,6 +5,7 @@ import {Megaphone, X} 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 {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import HintText from './HintText.jsx';
import {hintText} from '../utils/hints.js'; import {hintText} from '../utils/hints.js';
export default function HintCard({hint, hideForever, onHideForeverChange, onClose, onAccept, className}) { export default function HintCard({hint, hideForever, onHideForeverChange, onClose, onAccept, className}) {
@@ -15,7 +16,9 @@ export default function HintCard({hint, hideForever, onHideForeverChange, onClos
<Card role="status" className={cn('gap-3 p-4 shadow-lg', className)}> <Card role="status" className={cn('gap-3 p-4 shadow-lg', className)}>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Megaphone className="mt-0.5 size-5 shrink-0 text-primary"/> <Megaphone className="mt-0.5 size-5 shrink-0 text-primary"/>
<p className="flex-1 whitespace-pre-line break-words text-sm">{hintText(hint, i18n.language)}</p> <p className="flex-1 whitespace-pre-line break-words text-sm">
<HintText text={hint.text ?? hintText(hint, i18n.language)}/>
</p>
{hint.kind === 'DISMISS' && ( {hint.kind === 'DISMISS' && (
<Button type="button" variant="ghost" size="icon" className="-mr-2 -mt-2 size-8" <Button type="button" variant="ghost" size="icon" className="-mr-2 -mt-2 size-8"
aria-label={t('HINT.CLOSE')} onClick={onClose}> aria-label={t('HINT.CLOSE')} onClick={onClose}>
+42
View File
@@ -0,0 +1,42 @@
import {Link} from 'react-router-dom';
// **bold** and [text](url) only. No nesting: the link text is plain.
const TOKEN = /\*\*([^*\n]+)\*\*|\[([^\]\n]+)\]\(([^)\s]+)\)/g;
// An app path (not //host, and no backslash: browsers read /\host as //host), or https. Case-sensitive on purpose.
const APP_PATH = /^\/(?![/\\])[^\s\\]*$/;
const HTTPS = /^https:\/\/[^\s\\]+$/;
/**
* @returns {{to: string} | {href: string} | null} null: the url is not safe, so the link stays plain text
*/
export const safeLink = (url) => {
if (APP_PATH.test(url)) return {to: url};
if (HTTPS.test(url)) return {href: url};
return null;
};
/**
* Hint and news text with **bold** and [text](url). Only React elements, never HTML: React escapes every string.
*/
export default function HintText({text}) {
const parts = [];
let last = 0;
for (const match of text.matchAll(TOKEN)) {
const [whole, bold, label, url] = match;
if (match.index > last) parts.push(text.slice(last, match.index));
last = match.index + whole.length;
const link = url && safeLink(url);
if (bold !== undefined) {
parts.push(<strong key={match.index}>{bold}</strong>);
} else if (link?.to) {
parts.push(<Link key={match.index} to={link.to} className="underline">{label}</Link>);
} else if (link) {
parts.push(<a key={match.index} href={link.href} target="_blank" rel="noopener noreferrer"
className="underline">{label}</a>);
} else {
parts.push(whole);
}
}
parts.push(text.slice(last));
return <>{parts}</>;
}
+57
View File
@@ -0,0 +1,57 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it} from 'vitest';
import {cleanup, render} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import HintText, {safeLink} from './HintText.jsx';
const renderText = (text) => render(<MemoryRouter><p>{<HintText text={text}/>}</p></MemoryRouter>).container;
const UNSAFE = [
'javascript:alert(1)', 'JavaScript:alert(1)', 'data:text/html,<b>x</b>', 'http://example.com', '//evil.example',
'/\\evil.example', 'HTTPS://x', 'vbscript:x',
];
describe('HintText', () => {
afterEach(cleanup);
it('renders bold and links', () => {
const container = renderText('A **bold** word, [Import](/import) and [site](https://example.com).');
expect(container.querySelector('strong').textContent).toBe('bold');
expect(container.querySelector('a[href="/import"]').textContent).toBe('Import');
const external = container.querySelector('a[href="https://example.com"]');
expect(external.getAttribute('target')).toBe('_blank');
expect(external.getAttribute('rel')).toBe('noopener noreferrer');
expect(container.textContent).toBe('A bold word, Import and site.');
});
it.each(UNSAFE)('shows a link to %s as plain text', (url) => {
const text = `[click](${url})`;
const container = renderText(text);
expect(container.querySelector('a')).toBeNull();
expect(container.textContent).toBe(text);
});
it('shows HTML as text', () => {
const container = renderText('<img src=x onerror=alert(1)> <script>alert(1)</script>');
expect(container.querySelector('img, script')).toBeNull();
expect(container.textContent).toContain('<img src=x onerror=alert(1)>');
});
it('keeps unclosed markup and line breaks as they are', () => {
const container = renderText('**bold\n[text](');
expect(container.querySelector('strong, a')).toBeNull();
expect(container.textContent).toBe('**bold\n[text](');
});
});
describe('safeLink', () => {
it('allows app paths and https only', () => {
expect(safeLink('/import')).toEqual({to: '/import'});
expect(safeLink('https://example.com/a')).toEqual({href: 'https://example.com/a'});
UNSAFE.forEach((url) => expect(safeLink(url)).toBeNull());
});
});
+2 -1
View File
@@ -1,6 +1,7 @@
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import HintText from './HintText.jsx';
import {formatDate} from '../utils/format.js'; import {formatDate} from '../utils/format.js';
import {newsText, newsTitle} from '../utils/news.js'; import {newsText, newsTitle} from '../utils/news.js';
@@ -11,7 +12,7 @@ export default function NewsEntry({entry, className}) {
<article className={cn('space-y-1', className)}> <article className={cn('space-y-1', className)}>
<p className="text-xs text-muted-foreground">{formatDate(entry.publishedAt, i18n.language, {dateStyle: 'medium'})}</p> <p className="text-xs text-muted-foreground">{formatDate(entry.publishedAt, i18n.language, {dateStyle: 'medium'})}</p>
<h3 className="font-semibold">{newsTitle(entry, i18n.language)}</h3> <h3 className="font-semibold">{newsTitle(entry, i18n.language)}</h3>
<p className="whitespace-pre-line break-words text-sm">{newsText(entry, i18n.language)}</p> <p className="whitespace-pre-line break-words text-sm"><HintText text={newsText(entry, i18n.language)}/></p>
</article> </article>
); );
} }
+69 -3
View File
@@ -12,7 +12,8 @@ import {Textarea} from '@/components/ui/textarea';
import HintCard from '../components/HintCard.jsx'; import HintCard from '../components/HintCard.jsx';
import {formatDateTime} from '../utils/format.js'; import {formatDateTime} from '../utils/format.js';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import {createHint, endHint, fetchAdminHints} from '../utils/hints.js'; import {createHint, endHint, fetchAdminHints, setAutoHintEnabled} from '../utils/hints.js';
import {ROLES} from '../utils/admin.js';
import {MAX_HINT_LENGTH} from '../utils/limits.js'; import {MAX_HINT_LENGTH} from '../utils/limits.js';
const WEEK_MS = 7 * 24 * 60 * 60 * 1000; const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
@@ -25,7 +26,18 @@ const toLocalInput = (date) => {
const emptyForm = () => { const emptyForm = () => {
const now = new Date(); const now = new Date();
return {textEn: '', textDe: '', kind: 'DISMISS', startsAt: toLocalInput(now), endsAt: toLocalInput(new Date(now.getTime() + WEEK_MS))}; return {textEn: '', textDe: '', kind: 'DISMISS', startsAt: toLocalInput(now), endsAt: toLocalInput(new Date(now.getTime() + WEEK_MS)),
targetNoSteam: false, targetRole: '', targetNewDays: '', targetEmptyLibrary: false};
};
const targetSummary = (hint, t) => {
const groups = [
hint.targetNoSteam && t('ADMIN_HINTS.TARGET_SUMMARY_NO_STEAM'),
hint.targetRole && t('ADMIN_HINTS.TARGET_SUMMARY_ROLE', {role: t(`ROLES.${hint.targetRole}`)}),
hint.targetNewDays && t('ADMIN_HINTS.TARGET_SUMMARY_NEW', {count: hint.targetNewDays}),
hint.targetEmptyLibrary && t('ADMIN_HINTS.TARGET_SUMMARY_EMPTY_LIBRARY'),
].filter(Boolean);
return groups.length ? groups.join(' + ') : t('ADMIN_HINTS.TARGET_ALL');
}; };
/** /**
@@ -34,6 +46,7 @@ const emptyForm = () => {
export default function AdminHintsPage() { export default function AdminHintsPage() {
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
const [hints, setHints] = useState([]); const [hints, setHints] = useState([]);
const [automatic, setAutomatic] = useState([]);
const [status, setStatus] = useState('loading'); const [status, setStatus] = useState('loading');
const [form, setForm] = useState(emptyForm); const [form, setForm] = useState(emptyForm);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -50,7 +63,9 @@ export default function AdminHintsPage() {
setStatus('error'); setStatus('error');
return; return;
} }
setHints((await response.json()).results); const data = await response.json();
setHints(data.results);
setAutomatic(data.automatic ?? []);
setStatus('ready'); setStatus('ready');
} catch (err) { } catch (err) {
console.error('Admin hints error:', err); console.error('Admin hints error:', err);
@@ -91,10 +106,15 @@ export default function AdminHintsPage() {
kind: form.kind, kind: form.kind,
startsAt: new Date(form.startsAt).toISOString(), startsAt: new Date(form.startsAt).toISOString(),
endsAt: new Date(form.endsAt).toISOString(), endsAt: new Date(form.endsAt).toISOString(),
targetNoSteam: form.targetNoSteam,
targetRole: form.targetRole || null,
targetNewDays: form.targetNewDays ? Number(form.targetNewDays) : null,
targetEmptyLibrary: form.targetEmptyLibrary,
}), 'ADMIN_HINTS.CREATED', () => setForm(emptyForm())); }), 'ADMIN_HINTS.CREATED', () => setForm(emptyForm()));
}; };
const change = (field) => (event) => setForm({...form, [field]: event.target.value}); const change = (field) => (event) => setForm({...form, [field]: event.target.value});
const tick = (field) => (event) => setForm({...form, [field]: event.target.checked});
const formatDate = (value) => formatDateTime(value, i18n.language, {dateStyle: 'short', timeStyle: 'short'}); const formatDate = (value) => formatDateTime(value, i18n.language, {dateStyle: 'short', timeStyle: 'short'});
if (status === 'forbidden') { if (status === 'forbidden') {
@@ -132,6 +152,35 @@ export default function AdminHintsPage() {
</label> </label>
))} ))}
</fieldset> </fieldset>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">{t('ADMIN_HINTS.TARGET')}</legend>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" className="accent-primary" checked={form.targetNoSteam}
onChange={tick('targetNoSteam')}/>
{t('ADMIN_HINTS.TARGET_NO_STEAM')}
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" className="accent-primary" checked={form.targetEmptyLibrary}
onChange={tick('targetEmptyLibrary')}/>
{t('ADMIN_HINTS.TARGET_EMPTY_LIBRARY')}
</label>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="hintTargetRole">{t('ADMIN_HINTS.TARGET_ROLE')}</Label>
<select id="hintTargetRole" value={form.targetRole} onChange={change('targetRole')}
className="h-9 w-full rounded-md border bg-transparent px-3 text-sm">
<option value="">{t('ADMIN_HINTS.TARGET_ROLE_ALL')}</option>
{ROLES.map((role) => <option key={role} value={role}>{t(`ROLES.${role}`)}</option>)}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="hintTargetNewDays">{t('ADMIN_HINTS.TARGET_NEW_DAYS')}</Label>
<Input id="hintTargetNewDays" type="number" min={1} max={365} value={form.targetNewDays}
onChange={change('targetNewDays')}/>
</div>
</div>
<p className="text-xs text-muted-foreground">{t('ADMIN_HINTS.TARGET_HELP')}</p>
</fieldset>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="hintStartsAt">{t('ADMIN_HINTS.STARTS_AT')}</Label> <Label htmlFor="hintStartsAt">{t('ADMIN_HINTS.STARTS_AT')}</Label>
@@ -157,6 +206,22 @@ export default function AdminHintsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle>{t('ADMIN_HINTS.AUTO_TITLE')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{automatic.map(({key, enabled}) => (
<label key={key} className="flex items-center gap-2 text-sm">
<input type="checkbox" className="accent-primary" checked={enabled} disabled={busy}
onChange={(event) => run(() => setAutoHintEnabled(key, event.target.checked),
'ADMIN_HINTS.AUTO_SAVED')}/>
{t(`ADMIN_HINTS.AUTO_${key}`)}
</label>
))}
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>{t('ADMIN_HINTS.LIST')}</CardTitle> <CardTitle>{t('ADMIN_HINTS.LIST')}</CardTitle>
@@ -176,6 +241,7 @@ export default function AdminHintsPage() {
{[t('ADMIN_HINTS.RANGE', {start: formatDate(hint.startsAt), end: formatDate(hint.endsAt)}), {[t('ADMIN_HINTS.RANGE', {start: formatDate(hint.startsAt), end: formatDate(hint.endsAt)}),
t(`ADMIN_HINTS.KIND_${hint.kind}`), t(`ADMIN_HINTS.KIND_${hint.kind}`),
t('ADMIN_HINTS.DONE_COUNT', {count: hint.doneCount}), t('ADMIN_HINTS.DONE_COUNT', {count: hint.doneCount}),
targetSummary(hint, t),
].join(' · ')} ].join(' · ')}
</p> </p>
</div> </div>
+50 -1
View File
@@ -17,9 +17,11 @@ const ACTIVE = {id: 2, textEn: 'Server update tonight', textDe: null, kind: 'DIS
const ENDED = {...ACTIVE, id: 1, textEn: 'Old news', startsAt: at(-3), endsAt: at(-2), status: 'ENDED', doneCount: 0}; const ENDED = {...ACTIVE, id: 1, textEn: 'Old news', startsAt: at(-3), endsAt: at(-2), status: 'ENDED', doneCount: 0};
const withRoutes = (routes) => fetchRoutes({ const withRoutes = (routes) => fetchRoutes({
'GET /api/admin/hints': json(200, {results: [ACTIVE, ENDED]}), 'GET /api/admin/hints': json(200, {results: [ACTIVE, ENDED],
automatic: [{key: 'NO_STEAM', enabled: true}, {key: 'PILE_GREW', enabled: true}]}),
'POST /api/admin/hints': json(201, {id: 3}), 'POST /api/admin/hints': json(201, {id: 3}),
'PUT /api/admin/hints/2/end': json(204), 'PUT /api/admin/hints/2/end': json(204),
'PUT /api/admin/hints/auto/PILE_GREW': json(204),
...routes, ...routes,
}); });
const calls = (method) => fetch.mock.calls.filter(([, init]) => (init?.method ?? 'GET') === method); const calls = (method) => fetch.mock.calls.filter(([, init]) => (init?.method ?? 'GET') === method);
@@ -69,6 +71,53 @@ describe('AdminHintsPage', () => {
expect(screen.getByLabelText('ADMIN_HINTS.TEXT_EN').value).toBe(''); expect(screen.getByLabelText('ADMIN_HINTS.TEXT_EN').value).toBe('');
}); });
it('sends the target groups, empty ones as null', async () => {
renderPage();
await screen.findByText('Server update tonight');
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TEXT_EN'), {target: {value: 'News'}});
fireEvent.click(screen.getByLabelText('ADMIN_HINTS.TARGET_NO_STEAM'));
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TARGET_NEW_DAYS'), {target: {value: '7'}});
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TARGET_ROLE'), {target: {value: 'AUDITOR'}});
fireEvent.click(screen.getByText('ADMIN_HINTS.CREATE'));
await waitFor(() => expect(calls('POST')).toHaveLength(1));
expect(JSON.parse(calls('POST')[0][1].body)).toMatchObject({
targetNoSteam: true, targetRole: 'AUDITOR', targetNewDays: 7, targetEmptyLibrary: false});
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN_HINTS.CREATED'));
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TEXT_EN'), {target: {value: 'More news'}});
fireEvent.click(screen.getByText('ADMIN_HINTS.CREATE'));
await waitFor(() => expect(calls('POST')).toHaveLength(2));
expect(JSON.parse(calls('POST')[1][1].body)).toMatchObject({
targetNoSteam: false, targetRole: null, targetNewDays: null, targetEmptyLibrary: false});
});
it('shows the role labels for the role target', async () => {
renderPage();
expect(await screen.findByRole('option', {name: 'ROLES.AUDITOR'})).toBeTruthy();
});
it('turns an automatic hint off', async () => {
renderPage();
await screen.findByText('Server update tonight');
fireEvent.click(screen.getByLabelText('ADMIN_HINTS.AUTO_PILE_GREW'));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN_HINTS.AUTO_SAVED'));
const [url, init] = calls('PUT')[0];
expect(url).toContain('/api/admin/hints/auto/PILE_GREW');
expect(JSON.parse(init.body)).toEqual({enabled: false});
});
it('previews bold text', async () => {
renderPage();
await screen.findByText('Server update tonight');
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TEXT_EN'), {target: {value: 'Read **this**'}});
expect(document.querySelector('[role="status"] strong').textContent).toBe('this');
});
it('explains an invalid time range', async () => { it('explains an invalid time range', async () => {
withRoutes({'GET /api/admin/hints': json(200, {results: []}), 'POST /api/admin/hints': json(400, {code: 'INVALID_HINT'})}); withRoutes({'GET /api/admin/hints': json(200, {results: []}), 'POST /api/admin/hints': json(400, {code: 'INVALID_HINT'})});
renderPage(); renderPage();
+7 -10
View File
@@ -41,14 +41,10 @@ const Dashboard = () => {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const runSave = useSaveResponse(); const runSave = useSaveResponse();
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. Waits for the saved layout in /me. // The stat groups of the shown stat tiles, and TREND for the pile card and the "pile grew" hint. Waits for the
const statGroups = [...new Set([ // saved layout in /me.
...layout.map((tile) => statGroup(tile.id)).filter(Boolean), const statGroups = [...new Set([...layout.map((tile) => statGroup(tile.id)).filter(Boolean), 'TREND'])].sort();
...(layout.some((tile) => tile.id === 'PILE_OF_SHAME') ? ['TREND'] : []), const stats = useUserStats(statGroups, userData != null);
])].sort();
// No groups would mean "all groups" to the stats endpoint.
const hasStatGroups = statGroups.length > 0;
const stats = useUserStats(statGroups, userData != null && hasStatGroups);
const shuffleSuggestion = async () => { const shuffleSuggestion = async () => {
setShuffling(true); setShuffling(true);
@@ -70,7 +66,7 @@ const Dashboard = () => {
expectInitialSync: hasSteamId && steamData.count === 0, expectInitialSync: hasSteamId && steamData.count === 0,
onComplete: () => { onComplete: () => {
reloadLibrary(); reloadLibrary();
if (hasStatGroups) stats.reload(); stats.reload();
}, },
}); });
const forceSync = useForceSync(sync.watch); const forceSync = useForceSync(sync.watch);
@@ -135,7 +131,8 @@ const Dashboard = () => {
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/> defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
<WhatsNewDialog/> <WhatsNewDialog/>
{/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */} {/* Last child: space-y gives every other child a bottom margin, which would move the fixed card. */}
<DashboardHint/> <DashboardHint user={userData} summary={summaryFailed ? null : summary ?? undefined}
trend={stats.failed ? null : stats.stats ? stats.stats.trend ?? null : undefined}/>
</PageContainer> </PageContainer>
); );
}; };
+2 -2
View File
@@ -92,8 +92,8 @@ describe('Dashboard', () => {
expect(order(container)).toEqual(['STATS_HABITS', 'MOST_PLAYED']); expect(order(container)).toEqual(['STATS_HABITS', 'MOST_PLAYED']);
expect(screen.getByText('Game 2')).toBeTruthy(); expect(screen.getByText('Game 2')).toBeTruthy();
expect(screen.queryByText('Game 3')).toBeNull(); expect(screen.queryByText('Game 3')).toBeNull();
// No pile tile, so no TREND. // TREND is always asked: the "pile grew" hint needs it.
expect(fetch).toHaveBeenCalledWith(expect.stringMatching(/\/api\/library\/stats\?groups=HABITS$/), expect.anything()); expect(fetch).toHaveBeenCalledWith(expect.stringMatching(/\/api\/library\/stats\?groups=HABITS,TREND$/), expect.anything());
}); });
it('leaves out a stat tile whose data did not come', async () => { it('leaves out a stat tile whose data did not come', async () => {
+41 -2
View File
@@ -3,8 +3,9 @@ import {apiFetch} from './api.js';
const AUTH = () => import.meta.env.VITE_API_URL; const AUTH = () => import.meta.env.VITE_API_URL;
/** /**
* GET /api/hints: {results: [{id, textEn, textDe, kind, startsAt, endsAt}]}, the running hints the user has not * GET /api/hints: {results: [{id, textEn, textDe, kind, startsAt, endsAt, emptyLibraryOnly}], automatic: [keys]}.
* closed for good, newest first. * results: the running hints for this user that are not closed for good, newest first (emptyLibraryOnly: show only
* with an empty library, checked here). automatic: the automatic hints that are on and not hidden for this user.
*/ */
export const fetchHints = () => apiFetch(`${AUTH()}/api/hints`); export const fetchHints = () => apiFetch(`${AUTH()}/api/hints`);
@@ -31,3 +32,41 @@ export const createHint = (hint) =>
export const endHint = (hintId) => apiFetch(`${AUTH()}/api/admin/hints/${hintId}/end`, {method: 'PUT'}); export const endHint = (hintId) => apiFetch(`${AUTH()}/api/admin/hints/${hintId}/end`, {method: 'PUT'});
export const hintText = (hint, language) => (language?.startsWith('de') && hint.textDe ? hint.textDe : hint.textEn); export const hintText = (hint, language) => (language?.startsWith('de') && hint.textDe ? hint.textDe : hint.textEn);
/**
* The automatic hints in the order they show.
*/
export const AUTO_HINTS = ['NO_STEAM', 'EMPTY_LIBRARY', 'LINK_QUEUE', 'PILE_GREW'];
/**
* 204 or 404 HINT_NOT_FOUND: hide the automatic hint for 7 days.
*/
export const snoozeAutoHint = (key) => apiFetch(`${AUTH()}/api/hints/auto/${key}/snooze`, {method: 'PUT'});
/**
* 204 or 404 HINT_NOT_FOUND: hide the automatic hint for good.
*/
export const hideAutoHint = (key) => apiFetch(`${AUTH()}/api/hints/auto/${key}/done`, {method: 'PUT'});
/**
* PLATFORM_ADMIN only: 204, or 404 HINT_NOT_FOUND.
*/
export const setAutoHintEnabled = (key, enabled) =>
apiFetch(`${AUTH()}/api/admin/hints/auto/${key}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({enabled}),
});
/**
* How much the pile grew since the end of last month (else since the first day of this month), from the TREND group of the stats. Uses Core's
* dates, not the browser clock.
*/
export const pileGrowth = (trend) => {
const days = trend?.days ?? [];
const last = days.at(-1);
const month = last?.day.slice(0, 7);
// The snapshot of a day is written at its end, so only last month's last row excludes the first day's additions.
const first = days.findLast((d) => d.day.slice(0, 7) < month) ?? days.find((d) => d.day.slice(0, 7) === month);
return last && first !== last ? last.backlogGames - first.backlogGames : 0;
};