From 8598024bce1b4cb4fc9471df42a9e9a6c807db92 Mon Sep 17 00:00:00 2001 From: lobo Date: Fri, 2 Oct 2026 14:36:49 +0200 Subject: [PATCH] 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. --- README.md | 8 +- public/i18n/de.json | 33 ++++- public/i18n/en.json | 33 ++++- src/components/DashboardHint.jsx | 79 ++++++++--- src/components/DashboardHint.test.jsx | 184 +++++++++++++++++++++++++- src/components/HintCard.jsx | 5 +- src/components/HintText.jsx | 42 ++++++ src/components/HintText.test.jsx | 57 ++++++++ src/components/NewsEntry.jsx | 3 +- src/pages/AdminHintsPage.jsx | 72 +++++++++- src/pages/AdminHintsPage.test.jsx | 51 ++++++- src/pages/Dashboard.jsx | 17 +-- src/pages/Dashboard.test.jsx | 4 +- src/utils/hints.js | 43 +++++- 14 files changed, 578 insertions(+), 53 deletions(-) create mode 100644 src/components/HintText.jsx create mode 100644 src/components/HintText.test.jsx diff --git a/README.md b/README.md index f037427..3fae2fd 100644 --- a/README.md +++ b/README.md @@ -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. - **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 - "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" 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`. diff --git a/public/i18n/de.json b/public/i18n/de.json index 7939d7a..193c82c 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -739,13 +739,19 @@ "CLOSE": "Schließen", "DO_NOT_SHOW_AGAIN": "Nicht mehr anzeigen", "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": { "NEW": "Neuer Hinweis", "TEXT_EN": "Text (Englisch)", "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_DISMISS": "Schließen: geht nach 30 s oder mit X zu", "KIND_ACCEPT": "Bestätigen: bleibt, bis der Nutzer OK klickt", @@ -767,7 +773,26 @@ "STATUS_ENDED": "Beendet", "RANGE": "{{start}} bis {{end}}", "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": { "TITLE": "Neuigkeiten", @@ -806,7 +831,7 @@ "TITLE_DE": "Titel (Deutsch, optional)", "TEXT_EN": "Text (Englisch)", "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", "CREATE": "Veröffentlichen", "SAVE": "Speichern", diff --git a/public/i18n/en.json b/public/i18n/en.json index d2efbd0..631d877 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -739,13 +739,19 @@ "CLOSE": "Close", "DO_NOT_SHOW_AGAIN": "Do not show again", "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": { "NEW": "New hint", "TEXT_EN": "Text (English)", "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_DISMISS": "Dismiss: closes after 30 s or with X", "KIND_ACCEPT": "Accept: stays until the user clicks OK", @@ -767,7 +773,26 @@ "STATUS_ENDED": "Ended", "RANGE": "{{start}} to {{end}}", "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": { "TITLE": "What's new", @@ -806,7 +831,7 @@ "TITLE_DE": "Title (German, optional)", "TEXT_EN": "Text (English)", "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", "CREATE": "Publish", "SAVE": "Save", diff --git a/src/components/DashboardHint.jsx b/src/components/DashboardHint.jsx index 6514fd4..b4f1aef 100644 --- a/src/components/DashboardHint.jsx +++ b/src/components/DashboardHint.jsx @@ -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 ( 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"/> ); } diff --git a/src/components/DashboardHint.test.jsx b/src/components/DashboardHint.test.jsx index 4cf20d9..f5948c0 100644 --- a/src/components/DashboardHint.test.jsx +++ b/src/components/DashboardHint.test.jsx @@ -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( + ); + const showHints = async (...hints) => { hintRoutes(json(200, {results: hints})); - render(); + 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(); + 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(); + 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(); + 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); diff --git a/src/components/HintCard.jsx b/src/components/HintCard.jsx index b20cbc1..50c8290 100644 --- a/src/components/HintCard.jsx +++ b/src/components/HintCard.jsx @@ -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
-

{hintText(hint, i18n.language)}

+

+ +

{hint.kind === 'DISMISS' && (