feat(library): add source chips to game library

Chips show only when the user has 2 or more sources.
A selected source the user lacks stays visible, so it can be cleared.
This commit is contained in:
lobo committed 2026-10-09 10:03:05 +02:00
1 parent d3ad19e2fa
commit e136a7abc1
8 files changed
+189 -20

No files matched your search

+1 -1
View File
@@ -55,7 +55,7 @@ To Be Played is split into three repositories:
system setting "reduce motion" or the "Reduce animations" switch in the settings. system setting "reduce motion" or the "Reduce animations" switch in the settings.
- **Landing page**: next to the community numbers and the top games, three blocks: games per platform, top genres and - **Landing page**: next to the community numbers and the top games, three blocks: games per platform, top genres and
finished games per year of all users. finished games per year of all users.
- **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest - **Library**: status filters, source filter (Steam, PlayStation, manual; a linked set counts on each of its sources), "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
in library, rating or priority, paging. Each row shows the unlocked achievements. in library, rating or priority, paging. Each row shows the unlocked achievements.
- **Editions**: an auditor links the editions of one game (Steam and PS5 copy, remaster, port) for every user. A user - **Editions**: an auditor links the editions of one game (Steam and PS5 copy, remaster, port) for every user. A user
who owns two or more of a linked set sees "Also in your library" on the details page, an "x2" badge in the library who owns two or more of a linked set sees "Also in your library" on the details page, an "x2" badge in the library
+2
View File
@@ -256,6 +256,8 @@
} }
}, },
"LIBRARY": { "LIBRARY": {
"SOURCE_LABEL": "Quelle",
"SOURCE": { "STEAM": "Steam", "PSN": "PlayStation", "MANUAL": "Manuell" },
"TITLE": "Spielebibliothek", "TITLE": "Spielebibliothek",
"LOADING": "Lade Spiele …", "LOADING": "Lade Spiele …",
"EMPTY": "Noch keine Spiele. Synchronisiere deine Steam-Bibliothek oder füge ein Spiel hinzu.", "EMPTY": "Noch keine Spiele. Synchronisiere deine Steam-Bibliothek oder füge ein Spiel hinzu.",
+2
View File
@@ -256,6 +256,8 @@
} }
}, },
"LIBRARY": { "LIBRARY": {
"SOURCE_LABEL": "Source",
"SOURCE": { "STEAM": "Steam", "PSN": "PlayStation", "MANUAL": "Manual" },
"TITLE": "Game Library", "TITLE": "Game Library",
"LOADING": "Loading games …", "LOADING": "Loading games …",
"EMPTY": "No games yet. Sync your Steam library or add a game.", "EMPTY": "No games yet. Sync your Steam library or add a game.",
+53 -13
View File
@@ -1,12 +1,12 @@
import {useCallback, useEffect, useState} from 'react'; import {useCallback, useEffect, useRef, useState} from 'react';
import {Link, useSearchParams} from 'react-router-dom'; import {Link, useSearchParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import useLibrarySync from '../hooks/useLibrarySync.js'; import useLibrarySync from '../hooks/useLibrarySync.js';
import SyncStatusBadge from '../components/SyncStatusBadge.jsx'; import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
import useForceSync from '../hooks/useForceSync.js'; import useForceSync from '../hooks/useForceSync.js';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import {libraryQuery, parseStatus} from '../utils/backlog.js'; import {libraryQuery, parseSources, parseStatus} from '../utils/backlog.js';
import {GAME_STATUSES, MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js'; import {GAME_SOURCES, GAME_STATUSES, MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js';
import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, EyeOff, Gamepad2, Layers, Library, Plus, RefreshCw, Search, SearchX, Timer, Trophy, Upload} from 'lucide-react'; import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, EyeOff, Gamepad2, Layers, Library, Plus, RefreshCw, Search, SearchX, Timer, Trophy, Upload} from 'lucide-react';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
@@ -63,12 +63,16 @@ export default function GameLibrary() {
const listStatus = hiddenOnly ? null : status; const listStatus = hiddenOnly ? null : status;
const listShortOnly = !hiddenOnly && shortOnly; const listShortOnly = !hiddenOnly && shortOnly;
const listHideDuplicates = !hiddenOnly && hideDuplicates; const listHideDuplicates = !hiddenOnly && hideDuplicates;
const filtered = Boolean(listStatus || q.trim() || listShortOnly || hiddenOnly || listHideDuplicates); const sources = parseSources(searchParams.get('source'));
const listSources = hiddenOnly ? [] : sources;
const sourceKey = listSources.join(',');
const filtered = Boolean(listStatus || q.trim() || listShortOnly || hiddenOnly || listHideDuplicates || sourceKey);
const [search, setSearch] = useState(q); const [search, setSearch] = useState(q);
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
// A Steam ID or a PlayStation link. Starts true until /games-count answers, so the sync controls do not vanish // A Steam ID or a PlayStation link. Starts true until /games-count answers, so the sync controls do not vanish
// and come back. // and come back.
const [canSync, setCanSync] = useState(true); const [canSync, setCanSync] = useState(true);
const [ownSources, setOwnSources] = useState([]);
const [data, setData] = useState({content: [], page: 0, size, totalElements: 0, totalPages: 0}); const [data, setData] = useState({content: [], page: 0, size, totalElements: 0, totalPages: 0});
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -79,7 +83,7 @@ export default function GameLibrary() {
const signal = nextSignal(); const signal = nextSignal();
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status: listStatus, q, maxMinutes: listShortOnly ? SHORT_GAME_MINUTES : null, hidden: hiddenOnly, hideDuplicates: listHideDuplicates})}`; const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status: listStatus, q, maxMinutes: listShortOnly ? SHORT_GAME_MINUTES : null, hidden: hiddenOnly, hideDuplicates: listHideDuplicates, source: sourceKey})}`;
const {ok, status, data: json} = await request(url, {signal}); const {ok, status, data: json} = await request(url, {signal});
if (signal.aborted) return; if (signal.aborted) return;
setIsLoading(false); setIsLoading(false);
@@ -89,30 +93,41 @@ export default function GameLibrary() {
return; return;
} }
setData(json); setData(json);
}, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly, listHideDuplicates]); }, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly, listHideDuplicates, sourceKey]);
useEffect(() => { useEffect(() => {
load(); load();
}, [load]); }, [load]);
const mounted = useRef(true);
useEffect(() => { useEffect(() => {
let cancelled = false; mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const loadCounts = useCallback(() => {
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`) apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`)
.then((response) => (response.ok ? response.json() : null)) .then((response) => (response.ok ? response.json() : null))
.then((json) => { .then((json) => {
if (!cancelled && json) setCanSync(json.canSync); if (!mounted.current || !json) return;
setCanSync(json.canSync);
setOwnSources(json.sources ?? []);
}) })
.catch(() => {}); .catch(() => {});
return () => {
cancelled = true;
};
}, []); }, []);
useEffect(() => {
loadCounts();
}, [loadCounts]);
const expectInitialSync = !isLoading && !error && !filtered && data.totalElements === 0; const expectInitialSync = !isLoading && !error && !filtered && data.totalElements === 0;
const sync = useLibrarySync({ const sync = useLibrarySync({
enabled: canSync, enabled: canSync,
expectInitialSync, expectInitialSync,
onComplete: load, onComplete: () => {
load();
loadCounts();
},
}); });
const updateParams = useCallback((next) => { const updateParams = useCallback((next) => {
@@ -137,8 +152,13 @@ export default function GameLibrary() {
updateParams({status: value, hidden: null, page: 0}); updateParams({status: value, hidden: null, page: 0});
}; };
const toggleSource = (source) => {
const next = GAME_SOURCES.filter((s) => (s === source ? !sources.includes(s) : sources.includes(s)));
updateParams({source: next.join(','), hidden: null, page: 0});
};
const onHiddenChange = () => { const onHiddenChange = () => {
updateParams(hiddenOnly ? {hidden: null, page: 0} : {hidden: true, status: null, maxMinutes: null, hideDuplicates: null, page: 0}); updateParams(hiddenOnly ? {hidden: null, page: 0} : {hidden: true, status: null, maxMinutes: null, hideDuplicates: null, source: null, page: 0});
}; };
const onSizeChange = (value) => { const onSizeChange = (value) => {
@@ -158,6 +178,8 @@ export default function GameLibrary() {
const syncing = syncBusy || sync.isRunning; const syncing = syncBusy || sync.isRunning;
const ready = !isLoading && !error; const ready = !isLoading && !error;
// A selected source the user lacks stays visible so a stale URL value can be cleared.
const sourceChips = GAME_SOURCES.filter((s) => ownSources.includes(s) || sources.includes(s));
return ( return (
<PageContainer className="p-4 md:p-8 space-y-6"> <PageContainer className="p-4 md:p-8 space-y-6">
@@ -216,6 +238,7 @@ export default function GameLibrary() {
{adding && <AddGamePanel onClose={() => setAdding(false)}/>} {adding && <AddGamePanel onClose={() => setAdding(false)}/>}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1.5">
<div role="group" aria-label={t('BACKLOG.STATUS_LABEL')} className="flex flex-wrap gap-1.5"> <div role="group" aria-label={t('BACKLOG.STATUS_LABEL')} className="flex flex-wrap gap-1.5">
{[null, ...GAME_STATUSES].map((s) => ( {[null, ...GAME_STATUSES].map((s) => (
<Button <Button
@@ -257,6 +280,23 @@ export default function GameLibrary() {
<EyeOff className="size-3"/>{t('LIBRARY.FILTER_HIDDEN')} <EyeOff className="size-3"/>{t('LIBRARY.FILTER_HIDDEN')}
</Button> </Button>
</div> </div>
{sourceChips.length >= 2 && (
<div role="group" aria-label={t('LIBRARY.SOURCE_LABEL')} className="flex flex-wrap gap-1.5">
{sourceChips.map((s) => (
<Button
key={s}
size="sm"
variant={listSources.includes(s) ? 'default' : 'outline'}
aria-pressed={listSources.includes(s)}
className="h-7 rounded-full px-3 text-xs"
onClick={() => toggleSource(s)}
>
{t(`LIBRARY.SOURCE.${s}`)}
</Button>
))}
</div>
)}
</div>
<div className="relative sm:w-64"> <div className="relative sm:w-64">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/> <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
<Input <Input
+108 -3
View File
@@ -1,4 +1,5 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {StrictMode} from 'react';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {createMemoryRouter, RouterProvider, useLocation, useNavigate} from 'react-router-dom'; import {createMemoryRouter, RouterProvider, useLocation, useNavigate} from 'react-router-dom';
@@ -11,16 +12,20 @@ import {toast} from 'sonner';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})})); const syncOptions = vi.hoisted(() => ({current: null}));
vi.mock('../hooks/useLibrarySync.js', () => ({default: (opts) => {
syncOptions.current = opts;
return {status: 'OK', lastSyncedAt: null, isRunning: false};
}}));
vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})})); vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
const DEBOUNCE_WAIT_MS = 700; const DEBOUNCE_WAIT_MS = 700;
const page = {content: [{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG'}], page: 0, size: 20, totalElements: 1, totalPages: 1}; const page = {content: [{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG'}], page: 0, size: 20, totalElements: 1, totalPages: 1};
const answer = (games = page.content, canSync = true, hasSteamId = canSync) => fetchRoutes({ const answer = (games = page.content, canSync = true, hasSteamId = canSync, sources = []) => fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}), 'GET /api/users/me': json(200, {settings: {}}),
'GET /api/library/games': json(200, {...page, content: games, totalElements: games.length, totalPages: games.length ? 1 : 0}), 'GET /api/library/games': json(200, {...page, content: games, totalElements: games.length, totalPages: games.length ? 1 : 0}),
'GET /api/library/games-count': json(200, {count: games.length, hasSteamId, canSync}), 'GET /api/library/games-count': json(200, {count: games.length, hasSteamId, canSync, sources}),
}); });
const wait = (ms) => act(() => new Promise((resolve) => setTimeout(resolve, ms))); const wait = (ms) => act(() => new Promise((resolve) => setTimeout(resolve, ms)));
@@ -250,6 +255,106 @@ describe('GameLibrary short games', () => {
}); });
}); });
describe('GameLibrary source filter', () => {
const lastGamesUrl = () => String(fetch.mock.calls.map(([url]) => url).filter((url) => String(url).includes('/api/library/games?')).at(-1));
const chip = (source) => screen.getByRole('button', {name: `LIBRARY.SOURCE.${source}`});
it('sourceFilter_AddsSourceToTheUrlAndTheRequest', async () => {
answer(page.content, true, true, ['STEAM', 'PSN']);
const lib = renderLibrary('/library?page=2');
await screen.findByRole('group', {name: 'LIBRARY.SOURCE_LABEL'});
fireEvent.click(chip('STEAM'));
await waitFor(() => expect(lib.params().get('source')).toBe('STEAM'));
fireEvent.click(chip('PSN'));
await waitFor(() => expect(lib.params().get('source')).toBe('STEAM,PSN'));
expect(lib.params().get('page')).toBe('0');
await waitFor(() => expect(lastGamesUrl()).toMatch(/[?&]source=STEAM%2CPSN(&|$)/));
expect(chip('STEAM').getAttribute('aria-pressed')).toBe('true');
fireEvent.click(chip('STEAM'));
await waitFor(() => expect(lib.params().get('source')).toBe('PSN'));
fireEvent.click(chip('PSN'));
await waitFor(() => expect(lib.params().get('source')).toBeNull());
});
it('sourceFilter_HiddenWithOneOwnSource', async () => {
answer(page.content, true, true, ['STEAM']);
renderLibrary();
await waitFor(() => expect(fetch).toHaveBeenCalled());
await wait(50);
expect(screen.queryByRole('group', {name: 'LIBRARY.SOURCE_LABEL'})).toBeNull();
});
it('sourceFilter_ShowsASelectedSourceTheUserLacks', async () => {
answer(page.content, true, true, ['STEAM']);
const lib = renderLibrary('/library?source=PSN');
expect((await screen.findByRole('button', {name: 'LIBRARY.SOURCE.PSN'})).getAttribute('aria-pressed')).toBe('true');
fireEvent.click(chip('PSN'));
await waitFor(() => expect(lib.params().get('source')).toBeNull());
});
it('sourceFilter_IgnoresUnknownValuesInTheUrl', async () => {
answer(page.content, true, true, ['STEAM', 'PSN']);
renderLibrary('/library?source=XBOX');
await waitFor(() => expect(lastGamesUrl()).toContain('/api/library/games?'));
expect(lastGamesUrl()).not.toMatch(/source=/);
});
it('hiddenFilter_ClearsTheSource', async () => {
answer(page.content, true, true, ['STEAM', 'PSN']);
const lib = renderLibrary('/library?source=PSN');
await screen.findByRole('group', {name: 'LIBRARY.SOURCE_LABEL'});
fireEvent.click(screen.getByRole('button', {name: 'LIBRARY.FILTER_HIDDEN'}));
await waitFor(() => expect(lib.params().get('hidden')).toBe('true'));
expect(lib.params().get('source')).toBeNull();
await waitFor(() => expect(lastGamesUrl()).not.toMatch(/source=/));
});
it('hiddenLink_IgnoresTheSource', async () => {
renderLibrary('/library?hidden=true&source=PSN');
await waitFor(() => expect(lastGamesUrl()).toMatch(/[?&]hidden=true(&|$)/));
expect(lastGamesUrl()).not.toMatch(/source=/);
});
it('sourceFilter_ChipsFollowAFinishedSync', async () => {
answer(page.content, true, true, ['STEAM']);
renderLibrary();
await waitFor(() => expect(fetch).toHaveBeenCalled());
await wait(50);
expect(screen.queryByRole('group', {name: 'LIBRARY.SOURCE_LABEL'})).toBeNull();
answer(page.content, true, true, ['STEAM', 'PSN']);
await act(async () => syncOptions.current.onComplete());
expect(await screen.findByRole('group', {name: 'LIBRARY.SOURCE_LABEL'})).toBeTruthy();
});
it('sourceFilter_ChipsShowUnderStrictMode', async () => {
answer(page.content, true, true, ['STEAM', 'PSN']);
const router = createMemoryRouter([{
path: '/library',
element: <PreferencesProvider><CurrentUserProvider><GameLibrary/></CurrentUserProvider></PreferencesProvider>,
}], {initialEntries: ['/library']});
render(<StrictMode><RouterProvider router={router}/></StrictMode>);
expect(await screen.findByRole('group', {name: 'LIBRARY.SOURCE_LABEL'})).toBeTruthy();
});
it('sourceFilter_EmptyResultShowsNoMatch', async () => {
answer([], true, true, ['STEAM', 'PSN']);
renderLibrary('/library?source=PSN');
expect(await screen.findByText('LIBRARY.NO_MATCH')).toBeTruthy();
});
});
describe('GameLibrary with games added by hand', () => { describe('GameLibrary with games added by hand', () => {
const zelda = {appid: -1025, name: 'Zelda', playtimeMinutes: 90, status: 'BACKLOG', source: 'MANUAL', const zelda = {appid: -1025, name: 'Zelda', playtimeMinutes: 90, status: 'BACKLOG', source: 'MANUAL',
platform: 'Nintendo Switch', imageUrl: 'https://images.igdb.com/z.jpg'}; platform: 'Nintendo Switch', imageUrl: 'https://images.igdb.com/z.jpg'};
+5 -2
View File
@@ -1,11 +1,13 @@
import {apiFetch} from './api.js'; import {apiFetch} from './api.js';
import {GAME_STATUSES} from './limits.js'; import {GAME_SOURCES, GAME_STATUSES} from './limits.js';
// Radix Select has no empty value, so "no priority" needs its own value in the form. // Radix Select has no empty value, so "no priority" needs its own value in the form.
export const NO_PRIORITY = 'NONE'; export const NO_PRIORITY = 'NONE';
export const parseStatus = (value) => (GAME_STATUSES.includes(value) ? value : null); export const parseStatus = (value) => (GAME_STATUSES.includes(value) ? value : null);
export const parseSources = (value) => GAME_SOURCES.filter((s) => (value ?? '').split(',').includes(s));
export const toBacklogRequest = ({status, rating, notes, priority}) => ({ export const toBacklogRequest = ({status, rating, notes, priority}) => ({
status, status,
rating: rating || null, rating: rating || null,
@@ -56,13 +58,14 @@ export const dismissFinishedHint = (appid, dismissed) =>
body: JSON.stringify({dismissed}), body: JSON.stringify({dismissed}),
}); });
export const libraryQuery = ({page, size, sort, status, q, maxMinutes, hidden, hideDuplicates}) => { export const libraryQuery = ({page, size, sort, status, q, maxMinutes, hidden, hideDuplicates, source}) => {
const params = new URLSearchParams({page: String(page), size: String(size), sort}); const params = new URLSearchParams({page: String(page), size: String(size), sort});
if (hidden) params.set('hidden', 'true'); if (hidden) params.set('hidden', 'true');
if (hideDuplicates) params.set('hideDuplicates', 'true'); if (hideDuplicates) params.set('hideDuplicates', 'true');
if (status) params.set('status', status); if (status) params.set('status', status);
if (q?.trim()) params.set('q', q.trim()); if (q?.trim()) params.set('q', q.trim());
if (maxMinutes) params.set('maxMinutes', String(maxMinutes)); if (maxMinutes) params.set('maxMinutes', String(maxMinutes));
if (source) params.set('source', source);
return params.toString(); return params.toString();
}; };
+16 -1
View File
@@ -1,5 +1,5 @@
import {describe, expect, it} from 'vitest'; import {describe, expect, it} from 'vitest';
import {backlogShare, libraryQuery, NO_PRIORITY, parseStatus, toBacklogRequest} from './backlog.js'; import {backlogShare, libraryQuery, NO_PRIORITY, parseSources, parseStatus, toBacklogRequest} from './backlog.js';
describe('toBacklogRequest', () => { describe('toBacklogRequest', () => {
it('sends every field', () => { it('sends every field', () => {
@@ -60,3 +60,18 @@ describe('libraryQuery hideDuplicates', () => {
expect(libraryQuery({...base, hideDuplicates: false})).not.toContain('hideDuplicates'); expect(libraryQuery({...base, hideDuplicates: false})).not.toContain('hideDuplicates');
}); });
}); });
describe('sources', () => {
it('parseSources_KeepsKnownValuesInOrder', () => {
expect(parseSources('PSN,FOO,STEAM,PSN')).toEqual(['STEAM', 'PSN']);
expect(parseSources(null)).toEqual([]);
expect(parseSources('')).toEqual([]);
});
it('libraryQuery_SetsSourceOnlyWhenSelected', () => {
const base = {page: 0, size: 20, sort: 'appid,asc', status: null, q: ''};
expect(libraryQuery({...base, source: 'STEAM,PSN'})).toBe('page=0&size=20&sort=appid%2Casc&source=STEAM%2CPSN');
expect(libraryQuery({...base, source: ''})).not.toContain('source');
expect(libraryQuery(base)).not.toContain('source');
});
});
+2
View File
@@ -2,6 +2,8 @@
// Core GameStatus // Core GameStatus
export const GAME_STATUSES = ['BACKLOG', 'PLAYING', 'FINISHED', 'COMPLETED', 'DROPPED']; export const GAME_STATUSES = ['BACKLOG', 'PLAYING', 'FINISHED', 'COMPLETED', 'DROPPED'];
// Core GameSource, in the order the UI shows it
export const GAME_SOURCES = ['STEAM', 'PSN', 'MANUAL'];
// Core GamePriority, in the order the UI shows it // Core GamePriority, in the order the UI shows it
export const GAME_PRIORITIES = ['HIGH', 'MEDIUM', 'LOW']; export const GAME_PRIORITIES = ['HIGH', 'MEDIUM', 'LOW'];