feat(audit): game catalog search and time to beat correction

New page /audit/games: search every game and open the correction dialog
without owning it. Time to beat as hours + minutes. Audit log game links
open /audit/games?appid=. Menu entry next to the link queue.
This commit is contained in:
lobo committed 2026-10-02 11:21:15 +02:00
1 parent 6627a8db66
commit 0c300c455d
14 files changed
+439 -19

No files matched your search

+4 -1
View File
@@ -1,7 +1,7 @@
import {useEffect, useState} from 'react';
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Settings, Shield, Sun} from 'lucide-react';
import {ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Search, Settings, Shield, Sun} from 'lucide-react';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
@@ -158,6 +158,9 @@ export default function AppHeader({isAuthenticated, onLogout}) {
</DropdownMenuItem>
{canCorrect(currentUser) && (
<>
<DropdownMenuItem asChild>
<Link to="/audit/games"><Search/>{t('NAV.GAME_CATALOG')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/audit/links"><Link2/>{t('NAV.LINK_QUEUE')}</Link>
</DropdownMenuItem>
+4 -2
View File
@@ -88,12 +88,13 @@ describe('AppHeader', () => {
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
});
it('offersTheLinkQueueAndAuditLogToAnAuditorOnly', async () => {
it('offersTheCatalogLinkQueueAndAuditLogToAnAuditorOnly', async () => {
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
openMenu();
expect(await screen.findByText('NAV.LINK_QUEUE')).toBeTruthy();
expect(await screen.findByText('NAV.GAME_CATALOG')).toBeTruthy();
expect(screen.getByText('NAV.LINK_QUEUE')).toBeTruthy();
expect(screen.getByText('NAV.AUDIT_LOG')).toBeTruthy();
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
});
@@ -108,6 +109,7 @@ describe('AppHeader', () => {
expect(screen.getByText('NAV.WHATS_NEW')).toBeTruthy();
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
expect(screen.queryByText('NAV.LINK_QUEUE')).toBeNull();
expect(screen.queryByText('NAV.GAME_CATALOG')).toBeNull();
expect(screen.queryByText('NAV.ADMIN_NEWS')).toBeNull();
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull();
+52 -3
View File
@@ -10,12 +10,19 @@ import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '../utils/corrections.js';
import {genreKey} from '../utils/stats.js';
import {MAX_CORRECTION_GENRES, MAX_CORRECTION_LENGTH} from '../utils/limits.js';
import {MAX_CORRECTION_GENRES, MAX_CORRECTION_LENGTH, MAX_TIME_TO_BEAT_MINUTES} from '../utils/limits.js';
const REFUSAL_KEY = {INVALID_CORRECTION: 'CORRECTION.INVALID'};
const ENDLESS_CHOICES = [[null, 'UNKNOWN'], [true, 'YES'], [false, 'NO']];
const toHoursAndMinutes = (minutes) => (minutes ? {h: String(Math.floor(minutes / 60)), m: String(minutes % 60)} : {h: '', m: ''});
const toMinutes = ({h, m}) => (h.trim() === '' && m.trim() === '' ? null
: Math.round(Number(h || 0) * 60 + Number(m || 0)));
const hasNegativePart = ({normally, completely}) => [normally.h, normally.m, completely.h, completely.m].some((part) => Number(part) < 0);
const initialValues = (game) => ({
name: game.name ?? '',
image: game.imageUrl ?? '',
@@ -23,10 +30,19 @@ const initialValues = (game) => ({
moods: game.moods ?? [],
endless: game.endless ?? null,
genres: game.genres ?? [],
time_to_beat: {
normally: toHoursAndMinutes(game.timeToBeat?.normallyMinutes),
completely: toHoursAndMinutes(game.timeToBeat?.completelyMinutes),
},
});
// An empty text field means "none": the Steam header, or no speedrun.
const toValue = (value) => (typeof value === 'string' ? value.trim() || null : value);
const toValue = (field, value) => {
if (field === 'time_to_beat') {
return {normallyMinutes: toMinutes(value.normally), completelyMinutes: toMinutes(value.completely)};
}
return typeof value === 'string' ? value.trim() || null : value;
};
const toggled = (list, item) => (list.includes(item) ? list.filter((each) => each !== item) : [...list, item]);
@@ -81,6 +97,14 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
}
};
const save = (field) => {
if (field === 'time_to_beat' && hasNegativePart(values.time_to_beat)) {
toast.error(t('CORRECTION.NEGATIVE'));
return;
}
send(field, () => saveCorrection(game.appid, field, toValue(field, values[field])));
};
const row = (field, input) => (
<div key={field} className="space-y-2 border-b pb-4 last:border-b-0">
<div className="flex items-center gap-2">
@@ -90,7 +114,7 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
{input}
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" disabled={busy !== null}
onClick={() => send(field, () => saveCorrection(game.appid, field, toValue(values[field])))}>
onClick={() => save(field)}>
{busy === field ? <Loader2 className="animate-spin"/> : <Save/>}{t('CORRECTION.SAVE')}
</Button>
{corrected.has(field) && (
@@ -116,6 +140,25 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
</div>
);
const timeInputs = (kind, label) => {
const setPart = (part, text) => set('time_to_beat', {
...values.time_to_beat, [kind]: {...values.time_to_beat[kind], [part]: text},
});
return (
<div className="flex flex-wrap items-center gap-2">
<span className="w-20 text-sm">{label}</span>
<Input type="number" min="0" max={MAX_TIME_TO_BEAT_MINUTES / 60} step="1" className="w-24"
aria-label={`${label} ${t('CORRECTION.HOURS')}`} value={values.time_to_beat[kind].h}
onChange={(e) => setPart('h', e.target.value)}/>
<span className="text-sm text-muted-foreground">{t('CORRECTION.HOURS')}</span>
<Input type="number" min="0" max="59" step="1" className="w-20"
aria-label={`${label} ${t('CORRECTION.MINUTES')}`} value={values.time_to_beat[kind].m}
onChange={(e) => setPart('m', e.target.value)}/>
<span className="text-sm text-muted-foreground">{t('CORRECTION.MINUTES')}</span>
</div>
);
};
// A genre IGDB added later still shows, so it can be removed.
const genreOptions = [...IGDB_GENRES, ...values.genres.filter((g) => !IGDB_GENRES.includes(g))];
@@ -149,6 +192,12 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
))}
{row('genres', choices('genres', genreOptions,
(genre) => t(`STATS.GENRE.${genreKey(genre)}`, {defaultValue: genre}), MAX_CORRECTION_GENRES))}
{row('time_to_beat', (
<div id="correction-time_to_beat" className="space-y-2">
{timeInputs('normally', t('GAME_DETAIL.TTB_NORMALLY'))}
{timeInputs('completely', t('GAME_DETAIL.TTB_COMPLETELY'))}
</div>
))}
</div>
</DialogContent>
</Dialog>
+46 -2
View File
@@ -13,12 +13,13 @@ const game = {
appid: 620, name: 'Portal 2', imageUrl: null, speedrunUrl: 'https://www.speedrun.com/portal_2',
moods: ['STORY'], endless: null, genres: ['Puzzle'],
corrections: [{field: 'NAME', correctedAt: '2026-09-28T10:00:00Z'}],
timeToBeat: {hastilyMinutes: 300, normallyMinutes: 750, completelyMinutes: null},
};
const GAME_URL = '/api/library/games/620/corrections';
const corrections = (answer) => fetchRoutes({
[`PUT ${GAME_URL}/name`]: answer, [`PUT ${GAME_URL}/image`]: answer, [`PUT ${GAME_URL}/speedrun`]: answer,
[`PUT ${GAME_URL}/moods`]: answer, [`DELETE ${GAME_URL}/name`]: answer,
[`PUT ${GAME_URL}/moods`]: answer, [`PUT ${GAME_URL}/time_to_beat`]: answer, [`DELETE ${GAME_URL}/name`]: answer,
});
const renderDialog = (onChanged = vi.fn()) => {
@@ -26,7 +27,7 @@ const renderDialog = (onChanged = vi.fn()) => {
return onChanged;
};
// The rows in order: name, image, speedrun, moods, endless, genres.
// The rows in order: name, image, speedrun, moods, endless, genres, time_to_beat.
const saveButton = (row) => screen.getAllByRole('button', {name: 'CORRECTION.SAVE'})[row];
const sent = () => fetch.mock.calls.map(([url, init]) => ({url, method: init?.method, body: init?.body && JSON.parse(init.body)}));
@@ -69,6 +70,49 @@ describe('GameCorrectionDialog', () => {
expect(sent().map((call) => call.body)).toEqual([{value: null}, {value: ['COOP']}]);
});
it('shows the time to beat as hours and minutes and sends minutes, empty as null', async () => {
renderDialog();
expect(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.HOURS').value).toBe('12');
expect(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES').value).toBe('30');
expect(screen.getByLabelText('GAME_DETAIL.TTB_COMPLETELY CORRECTION.HOURS').value).toBe('');
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES'), {target: {value: ''}});
fireEvent.click(saveButton(6));
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
expect(sent()[0].body).toEqual({value: {normallyMinutes: 720, completelyMinutes: null}});
});
it('sendsFractionalHoursAsExactMinutes', async () => {
renderDialog();
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.HOURS'), {target: {value: '1.5'}});
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES'), {target: {value: ''}});
fireEvent.click(saveButton(6));
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
expect(sent()[0].body.value.normallyMinutes).toBe(90);
});
it('refusesANegativeTimePartBeforeSaving', () => {
renderDialog();
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.HOURS'), {target: {value: '1'}});
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES'), {target: {value: '-5'}});
fireEvent.click(saveButton(6));
expect(toast.error).toHaveBeenCalledWith('CORRECTION.NEGATIVE');
expect(fetch).not.toHaveBeenCalled();
});
it('offers back to source for a corrected time to beat', () => {
render(<MemoryRouter><GameCorrectionDialog open onOpenChange={vi.fn()} onChanged={vi.fn()}
game={{...game, corrections: [{field: 'TIME_TO_BEAT', correctedAt: '2026-09-28T10:00:00Z'}]}}/></MemoryRouter>);
expect(screen.getAllByText('CORRECTION.CORRECTED')).toHaveLength(1);
expect(screen.getByRole('button', {name: 'CORRECTION.BACK_TO_SOURCE'})).toBeTruthy();
});
it('marks a corrected field and goes back to the source', async () => {
const onChanged = renderDialog();