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

+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 {toast} from 'sonner';
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;
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"
* comes back in the next browser session.
* The hints on the dashboard, one at a time: the admin hints newest first, then the automatic ones. A DISMISS hint
* 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 [hints, setHints] = useState([]);
const [data, setData] = useState({hints: [], automatic: []});
const [closed, setClosed] = useState(closedIds);
const [hideForever, setHideForever] = useState(false);
const hint = hints[0];
const [linkCount, setLinkCount] = useState();
const wantsLinkQueue = data.automatic.includes('LINK_QUEUE') && canCorrect(user);
useEffect(() => {
// A failed load shows nothing, not an error.
fetchHints()
.then((response) => (response.ok ? response.json() : null))
.then((data) => {
const closed = closedIds();
setHints((data?.results ?? []).filter((hint) => !closed.includes(hint.id)));
})
.then((body) => setData({hints: body?.results ?? [], automatic: body?.automatic ?? []}))
.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.
const close = useCallback(async (done) => {
const close = useCallback(async (forever, byUser) => {
rememberClosed(hint.id);
setHints((current) => current.slice(1));
setClosed((current) => [...current, hint.id]);
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 {
const response = await markHintDone(hint.id);
const response = await save();
if (!response.ok) toast.error(t('HINT.SAVE_FAILED'));
} catch {
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
@@ -62,7 +105,7 @@ export default function DashboardHint() {
useEffect(() => {
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);
}, [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.
return (
<HintCard key={hint.id} hint={hint} hideForever={hideForever} onHideForeverChange={setHideForever}
onClose={(event) => event.detail < 2 && close(hideForever)}
onAccept={(event) => event.detail < 2 && close(true)}
onClose={(event) => event.detail < 2 && close(hideForever, true)}
onAccept={(event) => event.detail < 2 && close(true, true)}
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
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner';
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';
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 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,
'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 mount = (props = {}) => render(
<MemoryRouter><DashboardHint user={USER} summary={{total: 5}} trend={NO_TREND} {...props}/></MemoryRouter>);
const showHints = async (...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);
};
@@ -50,7 +66,7 @@ describe('DashboardHint', () => {
fireEvent.click(screen.getByLabelText('HINT.CLOSE'));
cleanup();
render(<DashboardHint/>);
mount();
await act(async () => undefined);
expect(screen.queryByText(DISMISS.textDe)).toBeNull();
@@ -92,7 +108,7 @@ describe('DashboardHint', () => {
it('shows an error when the answer is not stored', async () => {
hintRoutes(json(200, {results: [ACCEPT]}), json(500));
render(<DashboardHint/>);
mount();
await act(async () => undefined);
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 () => {
hintRoutes(json(500));
render(<DashboardHint/>);
mount();
await act(async () => undefined);
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', () => {
it('takes the German text for a German UI only when there is one', () => {
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 {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import HintText from './HintText.jsx';
import {hintText} from '../utils/hints.js';
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)}>
<div className="flex items-start gap-3">
<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' && (
<Button type="button" variant="ghost" size="icon" className="-mr-2 -mt-2 size-8"
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 {cn} from '@/lib/utils';
import HintText from './HintText.jsx';
import {formatDate} from '../utils/format.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)}>
<p className="text-xs text-muted-foreground">{formatDate(entry.publishedAt, i18n.language, {dateStyle: 'medium'})}</p>
<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>
);
}