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' && (