feat(library): backlog status, rating, notes and priority in the UI
Build, Test & Push Frontend / quality-check (push) Successful in 7s
Build, Test & Push Frontend / docker (push) Successful in 31s

Detail page gets an edit card. The library gets status badges, status filter
chips and a debounced name search, both kept in the URL.
This commit is contained in:
lobo committed 2026-09-24 11:04:42 +02:00
1 parent 7b676f0a1e
commit f94e0aff15
9 files changed
+394 -13

No files matched your search

+31 -1
View File
@@ -109,7 +109,10 @@
"SYNCING": "Synchronisiere mit Steam …", "SYNCING": "Synchronisiere mit Steam …",
"SYNCING_FIRST_TIME": "Erste Synchronisation mit Steam läuft. Deine Spiele erscheinen gleich.", "SYNCING_FIRST_TIME": "Erste Synchronisation mit Steam läuft. Deine Spiele erscheinen gleich.",
"SYNC_FAILED": "Synchronisation fehlgeschlagen. Bitte später erneut versuchen.", "SYNC_FAILED": "Synchronisation fehlgeschlagen. Bitte später erneut versuchen.",
"LAST_SYNCED": "Zuletzt synchronisiert {{when}}" "LAST_SYNCED": "Zuletzt synchronisiert {{when}}",
"FILTER_ALL": "Alle",
"SEARCH_PLACEHOLDER": "Spiel suchen …",
"NO_MATCH": "Keine Spiele für diesen Filter."
}, },
"GAME_DETAIL": { "GAME_DETAIL": {
"BACK": "Zurück", "BACK": "Zurück",
@@ -157,5 +160,32 @@
"LANGUAGE": "Sprache", "LANGUAGE": "Sprache",
"DARK_MODE": "Dunkles Design", "DARK_MODE": "Dunkles Design",
"LIGHT_MODE": "Helles Design" "LIGHT_MODE": "Helles Design"
},
"BACKLOG": {
"TITLE": "Mein Backlog",
"STATUS_LABEL": "Status",
"PRIORITY_LABEL": "Priorität",
"RATING_LABEL": "Bewertung",
"RATING_STARS_one": "{{count}} Stern",
"RATING_STARS_other": "{{count}} Sterne",
"NOTES_LABEL": "Notizen",
"NOTES_PLACEHOLDER": "Was willst du dir zu diesem Spiel merken?",
"SAVE": "Speichern",
"RESET": "Verwerfen",
"SAVED": "Gespeichert",
"SAVE_FAILED": "Speichern fehlgeschlagen. Bitte erneut versuchen.",
"STATUS": {
"BACKLOG": "Backlog",
"PLAYING": "Spiele ich",
"FINISHED": "Durchgespielt",
"COMPLETED": "100 %",
"DROPPED": "Abgebrochen"
},
"PRIORITY": {
"NONE": "Keine",
"HIGH": "Hoch",
"MEDIUM": "Mittel",
"LOW": "Niedrig"
}
} }
} }
+31 -1
View File
@@ -109,7 +109,10 @@
"SYNCING": "Syncing with Steam …", "SYNCING": "Syncing with Steam …",
"SYNCING_FIRST_TIME": "First sync with Steam in progress. Your games will appear here shortly.", "SYNCING_FIRST_TIME": "First sync with Steam in progress. Your games will appear here shortly.",
"SYNC_FAILED": "Sync failed. Please try again later.", "SYNC_FAILED": "Sync failed. Please try again later.",
"LAST_SYNCED": "Last synced {{when}}" "LAST_SYNCED": "Last synced {{when}}",
"FILTER_ALL": "All",
"SEARCH_PLACEHOLDER": "Search games …",
"NO_MATCH": "No games match this filter."
}, },
"GAME_DETAIL": { "GAME_DETAIL": {
"BACK": "Back", "BACK": "Back",
@@ -157,5 +160,32 @@
"LANGUAGE": "Language", "LANGUAGE": "Language",
"DARK_MODE": "Dark mode", "DARK_MODE": "Dark mode",
"LIGHT_MODE": "Light mode" "LIGHT_MODE": "Light mode"
},
"BACKLOG": {
"TITLE": "My backlog",
"STATUS_LABEL": "Status",
"PRIORITY_LABEL": "Priority",
"RATING_LABEL": "Rating",
"RATING_STARS_one": "{{count}} star",
"RATING_STARS_other": "{{count}} stars",
"NOTES_LABEL": "Notes",
"NOTES_PLACEHOLDER": "What do you want to remember about this game?",
"SAVE": "Save",
"RESET": "Discard",
"SAVED": "Saved",
"SAVE_FAILED": "Saving failed. Please try again.",
"STATUS": {
"BACKLOG": "Backlog",
"PLAYING": "Playing",
"FINISHED": "Finished",
"COMPLETED": "100%",
"DROPPED": "Dropped"
},
"PRIORITY": {
"NONE": "None",
"HIGH": "High",
"MEDIUM": "Medium",
"LOW": "Low"
}
} }
} }
+156
View File
@@ -0,0 +1,156 @@
import {useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {ListTodo, Loader2, Star} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Label} from '@/components/ui/label';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import {Textarea} from '@/components/ui/textarea';
import {cn} from '@/lib/utils';
import {STATUS_META} from './GameStatusBadge.jsx';
import {GAME_PRIORITIES, GAME_STATUSES, NO_PRIORITY, toBacklogRequest} from '../utils/backlog.js';
const NOTES_MAX = 2000;
const toForm = (entry) => ({
status: entry.status ?? 'BACKLOG',
rating: entry.rating ?? 0,
notes: entry.notes ?? '',
priority: entry.priority ?? NO_PRIORITY,
});
export default function BacklogCard({game, onSaved}) {
const {t} = useTranslation();
const navigate = useNavigate();
const [saved, setSaved] = useState(() => toForm(game));
const [form, setForm] = useState(saved);
const [busy, setBusy] = useState(false);
const dirty = JSON.stringify(form) !== JSON.stringify(saved);
const set = (field) => (value) => setForm((f) => ({...f, [field]: value}));
const save = async () => {
const token = localStorage.getItem('tbp_jwt');
if (!token) {
navigate('/login');
return;
}
setBusy(true);
try {
const response = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${game.appid}/backlog`, {
method: 'PUT',
headers: {'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json'},
body: JSON.stringify(toBacklogRequest(form)),
});
if (response.status === 401) {
localStorage.removeItem('tbp_jwt');
navigate('/login');
return;
}
if (!response.ok) {
toast.error(t('BACKLOG.SAVE_FAILED'));
return;
}
const entry = await response.json();
const next = toForm(entry);
setSaved(next);
onSaved?.(entry);
setForm(next);
toast.success(t('BACKLOG.SAVED'));
} catch (err) {
console.error('backlog save error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
return (
<Card className="p-6 gap-5">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<ListTodo className="size-4 text-primary"/>
{t('BACKLOG.TITLE')}
</h2>
<div className="grid gap-5 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="backlog-status">{t('BACKLOG.STATUS_LABEL')}</Label>
<Select value={form.status} onValueChange={set('status')}>
<SelectTrigger id="backlog-status" className="w-full"><SelectValue/></SelectTrigger>
<SelectContent>
{GAME_STATUSES.map((s) => {
const {icon: Icon, text} = STATUS_META[s];
return (
<SelectItem key={s} value={s}>
<Icon className={text}/>
{t(`BACKLOG.STATUS.${s}`)}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="backlog-priority">{t('BACKLOG.PRIORITY_LABEL')}</Label>
<Select value={form.priority} onValueChange={set('priority')}>
<SelectTrigger id="backlog-priority" className="w-full"><SelectValue/></SelectTrigger>
<SelectContent>
<SelectItem value={NO_PRIORITY}>{t('BACKLOG.PRIORITY.NONE')}</SelectItem>
{GAME_PRIORITIES.map((p) => (
<SelectItem key={p} value={p}>{t(`BACKLOG.PRIORITY.${p}`)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label id="backlog-rating">{t('BACKLOG.RATING_LABEL')}</Label>
<div role="group" aria-labelledby="backlog-rating" className="flex h-9 items-center gap-1">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
aria-label={t('BACKLOG.RATING_STARS', {count: n})}
aria-pressed={form.rating === n}
onClick={() => set('rating')(form.rating === n ? 0 : n)}
className="rounded-sm p-0.5 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<Star className={cn('size-6 transition-colors',
n <= form.rating ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/40 hover:text-amber-400')}/>
</button>
))}
</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-baseline justify-between">
<Label htmlFor="backlog-notes">{t('BACKLOG.NOTES_LABEL')}</Label>
<span className="text-xs text-muted-foreground tabular-nums">{form.notes.length} / {NOTES_MAX}</span>
</div>
<Textarea
id="backlog-notes"
rows={3}
maxLength={NOTES_MAX}
placeholder={t('BACKLOG.NOTES_PLACEHOLDER')}
value={form.notes}
onChange={(e) => set('notes')(e.target.value)}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setForm(saved)} disabled={!dirty || busy}>
{t('BACKLOG.RESET')}
</Button>
<Button onClick={save} disabled={!dirty || busy}>
{busy && <Loader2 className="animate-spin"/>}
{t('BACKLOG.SAVE')}
</Button>
</div>
</Card>
);
}
+30
View File
@@ -0,0 +1,30 @@
import {useTranslation} from 'react-i18next';
import {Ban, CircleCheck, Gamepad2, Inbox, Trophy} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {cn} from '@/lib/utils';
export const STATUS_META = {
BACKLOG: {icon: Inbox, text: 'text-muted-foreground', badge: 'bg-secondary text-secondary-foreground'},
PLAYING: {icon: Gamepad2, text: 'text-primary', badge: 'bg-primary/15 text-primary'},
FINISHED: {icon: CircleCheck, text: 'text-emerald-600 dark:text-emerald-400', badge: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'},
COMPLETED: {icon: Trophy, text: 'text-amber-600 dark:text-amber-400', badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-400'},
DROPPED: {icon: Ban, text: 'text-rose-600 dark:text-rose-400', badge: 'bg-rose-500/15 text-rose-600 dark:text-rose-400'},
};
/**
* @param compact shows only the icon below the sm breakpoint
*/
export default function GameStatusBadge({status, compact = false, className}) {
const {t} = useTranslation();
const meta = STATUS_META[status];
if (!meta) return null;
const Icon = meta.icon;
const label = t(`BACKLOG.STATUS.${status}`);
return (
<Badge className={cn(meta.badge, className)} title={label}>
<Icon/>
<span className={compact ? 'hidden sm:inline' : undefined}>{label}</span>
</Badge>
);
}
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+11 -4
View File
@@ -8,7 +8,9 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton'; import {Skeleton} from '@/components/ui/skeleton';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import BacklogCard from '../components/BacklogCard.jsx';
import GameCover from '../components/GameCover.jsx'; import GameCover from '../components/GameCover.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
const formatUnlockedAt = (instant, locale) => { const formatUnlockedAt = (instant, locale) => {
@@ -123,10 +125,13 @@ export default function GameDetail() {
<h1 className="text-2xl md:text-3xl font-bold tracking-tight"> <h1 className="text-2xl md:text-3xl font-bold tracking-tight">
{data.name ?? `App ${data.appid}`} {data.name ?? `App ${data.appid}`}
</h1> </h1>
<Badge variant="secondary" className="mt-3 tabular-nums"> <div className="mt-3 flex flex-wrap gap-2">
<Clock className="size-3"/> <Badge variant="secondary" className="tabular-nums">
{t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(data.playtimeMinutes)})} <Clock className="size-3"/>
</Badge> {t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(data.playtimeMinutes)})}
</Badge>
<GameStatusBadge status={data.status}/>
</div>
</div> </div>
<Button asChild variant="outline" size="sm" className="self-start"> <Button asChild variant="outline" size="sm" className="self-start">
<a href={`https://store.steampowered.com/app/${data.appid}`} target="_blank" rel="noopener noreferrer"> <a href={`https://store.steampowered.com/app/${data.appid}`} target="_blank" rel="noopener noreferrer">
@@ -136,6 +141,8 @@ export default function GameDetail() {
</div> </div>
</Card> </Card>
<BacklogCard key={data.appid} game={data} onSaved={(entry) => setData((d) => ({...d, ...entry}))}/>
{!data.hasAchievements && ( {!data.hasAchievements && (
<Card className="items-center py-10 text-center text-sm text-muted-foreground"> <Card className="items-center py-10 text-center text-sm text-muted-foreground">
<Trophy className="size-8"/> <Trophy className="size-8"/>
+62 -7
View File
@@ -5,18 +5,22 @@ 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 {ArrowUpDown, ChevronLeft, ChevronRight, Clock, Library, RefreshCw} from 'lucide-react'; import {GAME_STATUSES, libraryQuery, parseStatus} from '../utils/backlog.js';
import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, Library, RefreshCw, Search, SearchX} from 'lucide-react';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import {Skeleton} from '@/components/ui/skeleton'; import {Skeleton} from '@/components/ui/skeleton';
import GameCover from '../components/GameCover.jsx'; import GameCover from '../components/GameCover.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx';
const PAGE_SIZES = [20, 40, 80]; const PAGE_SIZES = [20, 40, 80];
const DEFAULT_SIZE = 20; const DEFAULT_SIZE = 20;
const DEFAULT_SORT = 'playtimeMinutes,desc'; const DEFAULT_SORT = 'playtimeMinutes,desc';
const SEARCH_DEBOUNCE_MS = 300;
const SORT_OPTIONS = [ const SORT_OPTIONS = [
{value: 'playtimeMinutes,desc', labelKey: 'LIBRARY.SORT_PLAYTIME_DESC'}, {value: 'playtimeMinutes,desc', labelKey: 'LIBRARY.SORT_PLAYTIME_DESC'},
{value: 'playtimeMinutes,asc', labelKey: 'LIBRARY.SORT_PLAYTIME_ASC'}, {value: 'playtimeMinutes,asc', labelKey: 'LIBRARY.SORT_PLAYTIME_ASC'},
@@ -34,7 +38,11 @@ export default function GameLibrary() {
const size = PAGE_SIZES.includes(sizeParam) ? sizeParam : DEFAULT_SIZE; const size = PAGE_SIZES.includes(sizeParam) ? sizeParam : DEFAULT_SIZE;
const sortParam = searchParams.get('sort') ?? DEFAULT_SORT; const sortParam = searchParams.get('sort') ?? DEFAULT_SORT;
const sort = SORT_OPTIONS.some((o) => o.value === sortParam) ? sortParam : DEFAULT_SORT; const sort = SORT_OPTIONS.some((o) => o.value === sortParam) ? sortParam : DEFAULT_SORT;
const status = parseStatus(searchParams.get('status'));
const q = searchParams.get('q') ?? '';
const filtered = Boolean(status || q.trim());
const librarySearch = searchParams.toString(); const librarySearch = searchParams.toString();
const [search, setSearch] = useState(q);
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);
@@ -49,7 +57,7 @@ export default function GameLibrary() {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?page=${page}&size=${size}&sort=${encodeURIComponent(sort)}`; const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status, q})}`;
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
'Authorization': `Bearer ${token}`, 'Authorization': `Bearer ${token}`,
@@ -76,13 +84,13 @@ export default function GameLibrary() {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [navigate, page, size, sort, t]); }, [navigate, page, size, sort, status, q, t]);
useEffect(() => { useEffect(() => {
load(); load();
}, [load]); }, [load]);
const expectInitialSync = !isLoading && !error && data.totalElements === 0; const expectInitialSync = !isLoading && !error && !filtered && data.totalElements === 0;
const sync = useLibrarySync({ const sync = useLibrarySync({
enabled: true, enabled: true,
expectInitialSync, expectInitialSync,
@@ -91,10 +99,26 @@ export default function GameLibrary() {
const updateParams = (next) => { const updateParams = (next) => {
const params = new URLSearchParams(searchParams); const params = new URLSearchParams(searchParams);
Object.entries(next).forEach(([k, v]) => params.set(k, String(v))); Object.entries(next).forEach(([k, v]) => (v === null || v === '' ? params.delete(k) : params.set(k, String(v))));
setSearchParams(params); setSearchParams(params);
}; };
// Back/forward navigation changes q without typing. Keeps a trailing space the user is still typing.
useEffect(() => {
setSearch((current) => (current.trim() === q ? current : q));
}, [q]);
// searchParams is a dependency, so a status click during the delay restarts the timer with fresh params.
useEffect(() => {
if (search.trim() === q) return undefined;
const timer = setTimeout(() => updateParams({q: search.trim(), page: 0}), SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [search, q, searchParams]);
const onStatusChange = (value) => {
updateParams({status: value, page: 0});
};
const onSizeChange = (value) => { const onSizeChange = (value) => {
updateParams({size: value, page: 0}); updateParams({size: value, page: 0});
}; };
@@ -155,6 +179,35 @@ export default function GameLibrary() {
</div> </div>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div role="group" aria-label={t('BACKLOG.STATUS_LABEL')} className="flex flex-wrap gap-1.5">
{[null, ...GAME_STATUSES].map((s) => (
<Button
key={s ?? 'ALL'}
size="sm"
variant={status === s ? 'default' : 'outline'}
aria-pressed={status === s}
className="h-7 rounded-full px-3 text-xs"
onClick={() => onStatusChange(s)}
>
{s ? t(`BACKLOG.STATUS.${s}`) : t('LIBRARY.FILTER_ALL')}
</Button>
))}
</div>
<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"/>
<Input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('LIBRARY.SEARCH_PLACEHOLDER')}
aria-label={t('LIBRARY.SEARCH_PLACEHOLDER')}
maxLength={100}
className="pl-8"
/>
</div>
</div>
{isLoading && ( {isLoading && (
<Card className="gap-0 py-0 divide-y"> <Card className="gap-0 py-0 divide-y">
{Array.from({length: 8}, (_, i) => ( {Array.from({length: 8}, (_, i) => (
@@ -171,9 +224,10 @@ export default function GameLibrary() {
{ready && data.content.length === 0 && ( {ready && data.content.length === 0 && (
<Card className="items-center py-16 text-center text-muted-foreground"> <Card className="items-center py-16 text-center text-muted-foreground">
<Library className="size-10"/> {filtered ? <SearchX className="size-10"/> : <Library className="size-10"/>}
<p className="max-w-sm text-sm"> <p className="max-w-sm text-sm">
{sync.isRunning ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')} {filtered ? t('LIBRARY.NO_MATCH')
: sync.isRunning ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
</p> </p>
</Card> </Card>
)} )}
@@ -192,6 +246,7 @@ export default function GameLibrary() {
<span className="min-w-0 flex-1 truncate font-medium group-hover:text-primary transition-colors"> <span className="min-w-0 flex-1 truncate font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`} {g.name ?? `App ${g.appid}`}
</span> </span>
<GameStatusBadge status={g.status} compact/>
<Badge variant="secondary" className="tabular-nums"> <Badge variant="secondary" className="tabular-nums">
<Clock className="size-3"/> <Clock className="size-3"/>
{formatPlaytime(g.playtimeMinutes)} {formatPlaytime(g.playtimeMinutes)}
+21
View File
@@ -0,0 +1,21 @@
export const GAME_STATUSES = ['BACKLOG', 'PLAYING', 'FINISHED', 'COMPLETED', 'DROPPED'];
export const GAME_PRIORITIES = ['HIGH', 'MEDIUM', 'LOW'];
// Radix Select has no empty value, so "no priority" needs its own value in the form.
export const NO_PRIORITY = 'NONE';
export const parseStatus = (value) => (GAME_STATUSES.includes(value) ? value : null);
export const toBacklogRequest = ({status, rating, notes, priority}) => ({
status,
rating: rating || null,
notes: notes?.trim() ? notes : null,
priority: priority === NO_PRIORITY ? null : priority,
});
export const libraryQuery = ({page, size, sort, status, q}) => {
const params = new URLSearchParams({page: String(page), size: String(size), sort});
if (status) params.set('status', status);
if (q?.trim()) params.set('q', q.trim());
return params.toString();
};
+34
View File
@@ -0,0 +1,34 @@
import {describe, expect, it} from 'vitest';
import {libraryQuery, NO_PRIORITY, parseStatus, toBacklogRequest} from './backlog.js';
describe('toBacklogRequest', () => {
it('sends every field', () => {
expect(toBacklogRequest({status: 'FINISHED', rating: 4, notes: 'Great', priority: 'HIGH'}))
.toEqual({status: 'FINISHED', rating: 4, notes: 'Great', priority: 'HIGH'});
});
it('sends null for cleared fields', () => {
expect(toBacklogRequest({status: 'BACKLOG', rating: 0, notes: ' ', priority: NO_PRIORITY}))
.toEqual({status: 'BACKLOG', rating: null, notes: null, priority: null});
});
});
describe('parseStatus', () => {
it('accepts a known status only', () => {
expect(parseStatus('PLAYING')).toBe('PLAYING');
expect(parseStatus('playing')).toBeNull();
expect(parseStatus(null)).toBeNull();
});
});
describe('libraryQuery', () => {
it('leaves out empty filters', () => {
expect(libraryQuery({page: 0, size: 20, sort: 'playtimeMinutes,desc', status: null, q: ' '}))
.toBe('page=0&size=20&sort=playtimeMinutes%2Cdesc');
});
it('adds status and the trimmed, encoded search', () => {
expect(libraryQuery({page: 1, size: 40, sort: 'appid,asc', status: 'DROPPED', q: ' 100% & more '}))
.toBe('page=1&size=40&sort=appid%2Casc&status=DROPPED&q=100%25+%26+more');
});
});