Public Access
feat(dashboard): new layout with sync button and top 5 most played
Remove the placeholder tiles and the settings tile, fix the mobile header width. iconUrl/formatPlaytime move to utils/games.js (599 min now shows 10h, not 10.0h), and the sync button logic to the useForceSync hook shared with the library.
This commit is contained in:
1 parent
84e31b23ea
commit
76a9ac3e6f
9 files changed
+216
-128
No files matched your search
+3
-1
@@ -70,7 +70,9 @@
|
||||
"LOADING": "Lade Dashboard ...",
|
||||
"HELLO": "Hallo",
|
||||
"STEAM_GAMES": "Steam Spiele",
|
||||
"NO_STEAM_ID": "Keine Steam-ID hinterlegt – jetzt in den Einstellungen eintragen"
|
||||
"NO_STEAM_ID": "Keine Steam-ID hinterlegt – jetzt in den Einstellungen eintragen",
|
||||
"MOST_PLAYED": "Meistgespielt",
|
||||
"SHOW_ALL": "Alle anzeigen"
|
||||
},
|
||||
"LIBRARY": {
|
||||
"TITLE": "Spielebibliothek",
|
||||
|
||||
+3
-1
@@ -70,7 +70,9 @@
|
||||
"LOADING": "Loading dashboard ...",
|
||||
"HELLO": "Hello",
|
||||
"STEAM_GAMES": "Steam Games",
|
||||
"NO_STEAM_ID": "No Steam ID saved yet – add it in the settings"
|
||||
"NO_STEAM_ID": "No Steam ID saved yet – add it in the settings",
|
||||
"MOST_PLAYED": "Most played",
|
||||
"SHOW_ALL": "Show all"
|
||||
},
|
||||
"LIBRARY": {
|
||||
"TITLE": "Game Library",
|
||||
|
||||
+7
-6
@@ -43,9 +43,9 @@ function App() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-slate-50 dark:bg-slate-950 transition-colors duration-300 font-sans">
|
||||
<header className="p-4 flex justify-between items-center border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 sticky top-0 z-50">
|
||||
<Link to={isAuthenticated ? '/dashboard' : '/'} className="font-black text-blue-600 dark:text-blue-500 text-2xl flex items-center tracking-tight">
|
||||
<i className="mdi mdi-controller mr-2 text-3xl"></i> Game Log
|
||||
<header className="px-3 py-3 sm:p-4 flex justify-between items-center gap-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 sticky top-0 z-50">
|
||||
<Link to={isAuthenticated ? '/dashboard' : '/'} className="font-black text-blue-600 dark:text-blue-500 text-xl sm:text-2xl flex items-center tracking-tight whitespace-nowrap">
|
||||
<i className="mdi mdi-controller mr-1 sm:mr-2 text-2xl sm:text-3xl"></i> Game Log
|
||||
</Link>
|
||||
|
||||
{isAuthenticated && (
|
||||
@@ -65,7 +65,7 @@ function App() {
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<div className="flex gap-5 items-center">
|
||||
<div className="flex gap-3 sm:gap-5 items-center">
|
||||
<button onClick={toggleLanguage} className="text-sm font-bold text-slate-600 dark:text-slate-300 hover:text-blue-500 transition-colors">
|
||||
{i18n.language.toUpperCase()}
|
||||
</button>
|
||||
@@ -76,8 +76,9 @@ function App() {
|
||||
</button>
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<button onClick={handleLogout} className="bg-red-600 hover:bg-red-500 text-white px-5 py-2 rounded-lg font-bold transition-all shadow-md shadow-red-500/20">
|
||||
{t('MAIN.LOGOUT')}
|
||||
<button onClick={handleLogout} title={t('MAIN.LOGOUT')} className="bg-red-600 hover:bg-red-500 text-white px-3 sm:px-5 py-2 rounded-lg font-bold transition-all shadow-md shadow-red-500/20">
|
||||
<i className="mdi mdi-logout sm:hidden"></i>
|
||||
<span className="hidden sm:inline">{t('MAIN.LOGOUT')}</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import {useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
const RELOAD_DELAY_MS = 4000;
|
||||
|
||||
/**
|
||||
* Starts a library sync (POST /api/library/sync) and calls onStarted after a short delay,
|
||||
* so the caller can reload once Steam data has arrived.
|
||||
*/
|
||||
export default function useForceSync(onStarted) {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState(null);
|
||||
|
||||
const trigger = async () => {
|
||||
const token = localStorage.getItem('tbp_jwt');
|
||||
if (!token) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/sync`, {
|
||||
method: 'POST',
|
||||
headers: {'Authorization': `Bearer ${token}`},
|
||||
});
|
||||
if (response.status === 429) {
|
||||
const json = await response.json().catch(() => ({}));
|
||||
setMessage(t('LIBRARY.SYNC_COOLDOWN', {seconds: json.retryAfterSeconds ?? 60}));
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
setMessage(t('LIBRARY.SYNC_FAILED'));
|
||||
return;
|
||||
}
|
||||
setMessage(t('LIBRARY.SYNC_STARTED'));
|
||||
setTimeout(onStarted, RELOAD_DELAY_MS);
|
||||
} catch (err) {
|
||||
console.error('sync trigger error:', err);
|
||||
setMessage(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {trigger, busy, message};
|
||||
}
|
||||
+124
-64
@@ -4,25 +4,32 @@ import {useTranslation} from 'react-i18next';
|
||||
import SteamGameCount from '../components/SteamGameCount';
|
||||
import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
|
||||
import useLibrarySync from '../hooks/useLibrarySync.js';
|
||||
import useForceSync from '../hooks/useForceSync.js';
|
||||
import {formatPlaytime, iconUrl} from '../utils/games.js';
|
||||
|
||||
const TOP_GAMES = 5;
|
||||
// The games endpoint accepts only the page sizes 20, 40 and 80.
|
||||
const TOP_GAMES_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc';
|
||||
|
||||
const cardClass = 'bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700';
|
||||
|
||||
const authHeaders = () => ({
|
||||
'Authorization': `Bearer ${localStorage.getItem('tbp_jwt')}`,
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
|
||||
const Dashboard = () => {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [userData, setUserData] = useState(null);
|
||||
const [steamData, setSteamData] = useState({count: 0, hasSteamId: false});
|
||||
const [topGames, setTopGames] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const fetchSteamCount = useCallback(async () => {
|
||||
const token = localStorage.getItem('tbp_jwt');
|
||||
if (!token) return;
|
||||
try {
|
||||
const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`, {headers: authHeaders()});
|
||||
if (r.ok) {
|
||||
setSteamData(await r.json());
|
||||
}
|
||||
@@ -31,11 +38,26 @@ const Dashboard = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchTopGames = useCallback(async () => {
|
||||
try {
|
||||
const r = await fetch(`${import.meta.env.VITE_CORE_API_URL}${TOP_GAMES_URL}`, {headers: authHeaders()});
|
||||
if (r.ok) {
|
||||
const page = await r.json();
|
||||
setTopGames((page.content ?? []).slice(0, TOP_GAMES));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching top games:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reloadLibrary = useCallback(() => {
|
||||
fetchSteamCount();
|
||||
fetchTopGames();
|
||||
}, [fetchSteamCount, fetchTopGames]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserData = async () => {
|
||||
const token = localStorage.getItem('tbp_jwt');
|
||||
|
||||
if (!token) {
|
||||
if (!localStorage.getItem('tbp_jwt')) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
@@ -43,10 +65,7 @@ const Dashboard = () => {
|
||||
try {
|
||||
const profileResponse = await fetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
headers: authHeaders(),
|
||||
});
|
||||
|
||||
if (profileResponse.ok) {
|
||||
@@ -54,7 +73,7 @@ const Dashboard = () => {
|
||||
setUserData(data);
|
||||
|
||||
if (data.steamId) {
|
||||
await fetchSteamCount();
|
||||
await Promise.all([fetchSteamCount(), fetchTopGames()]);
|
||||
}
|
||||
} else if (profileResponse.status === 401 || profileResponse.status === 403) {
|
||||
localStorage.removeItem('tbp_jwt');
|
||||
@@ -71,18 +90,19 @@ const Dashboard = () => {
|
||||
};
|
||||
|
||||
fetchUserData();
|
||||
}, [navigate, t, fetchSteamCount]);
|
||||
}, [navigate, t, fetchSteamCount, fetchTopGames]);
|
||||
|
||||
const expectInitialSync = !!userData?.steamId && steamData.count === 0;
|
||||
const hasSteamId = !!userData?.steamId;
|
||||
const sync = useLibrarySync({
|
||||
enabled: !!userData?.steamId,
|
||||
expectInitialSync,
|
||||
onComplete: fetchSteamCount,
|
||||
enabled: hasSteamId,
|
||||
expectInitialSync: hasSteamId && steamData.count === 0,
|
||||
onComplete: reloadLibrary,
|
||||
});
|
||||
const forceSync = useForceSync(reloadLibrary);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50 dark:bg-slate-900 text-slate-900 dark:text-white">
|
||||
<div className="py-24 text-center text-slate-600 dark:text-slate-300">
|
||||
{t('DASHBOARD.LOADING')}
|
||||
</div>
|
||||
);
|
||||
@@ -90,24 +110,22 @@ const Dashboard = () => {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50 dark:bg-slate-900 text-red-500">
|
||||
<div className="py-24 text-center text-red-500">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 p-8">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
const syncBusy = forceSync.busy || sync.isRunning;
|
||||
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-sm p-8 border border-slate-200 dark:border-slate-700">
|
||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-white mb-2">
|
||||
{t('DASHBOARD.HELLO')} {userData?.email.split('@')[0]} 👋
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto p-4 md:p-8 space-y-6">
|
||||
<section className={`${cardClass} p-6 md:p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-4`}>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-slate-900 dark:text-white truncate">
|
||||
{t('DASHBOARD.HELLO')} {userData?.email?.split('@')[0]} 👋
|
||||
</h1>
|
||||
<p className="text-lg text-slate-600 dark:text-slate-300">
|
||||
{userData?.dashboardMessage}
|
||||
</p>
|
||||
{userData?.steamId ? (
|
||||
{hasSteamId ? (
|
||||
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
<i className="mdi mdi-steam mr-1"></i>
|
||||
{t('SETTINGS.STEAM_ID')}: <span className="font-mono">{userData.steamId}</span>
|
||||
@@ -118,41 +136,83 @@ const Dashboard = () => {
|
||||
{t('DASHBOARD.NO_STEAM_ID')}
|
||||
</Link>
|
||||
)}
|
||||
{userData?.steamId && (
|
||||
<div className="mt-4">
|
||||
<SyncStatusBadge
|
||||
status={sync.status}
|
||||
lastSyncedAt={sync.lastSyncedAt}
|
||||
isRunning={sync.isRunning}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{steamData.hasSteamId && (
|
||||
<SteamGameCount targetCount={steamData.count} />
|
||||
)}
|
||||
<div className="bg-white dark:bg-slate-800 h-40 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 flex items-center justify-center">
|
||||
<span className="text-slate-400 dark:text-slate-500">Tile 1 (Coming soon)</span>
|
||||
{hasSteamId && (
|
||||
<div className="flex flex-col items-start md:items-end gap-2 shrink-0">
|
||||
<SyncStatusBadge
|
||||
status={sync.status}
|
||||
lastSyncedAt={sync.lastSyncedAt}
|
||||
isRunning={sync.isRunning}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={forceSync.trigger}
|
||||
disabled={syncBusy}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-bold bg-blue-600 hover:bg-blue-500 text-white shadow-md shadow-blue-500/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<i className={`mdi mdi-sync ${syncBusy ? 'mdi-spin' : ''}`}></i>
|
||||
{t('LIBRARY.RESYNC')}
|
||||
</button>
|
||||
{forceSync.message && (
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">{forceSync.message}</span>
|
||||
)}
|
||||
</div>
|
||||
{!steamData.hasSteamId && (
|
||||
<div className="bg-white dark:bg-slate-800 h-40 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 flex items-center justify-center">
|
||||
<span className="text-slate-400 dark:text-slate-500">Tile 2 (Coming soon)</span>
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
to="/settings"
|
||||
className="bg-white dark:bg-slate-800 h-40 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 flex flex-col items-center justify-center hover:border-blue-500 dark:hover:border-blue-500 transition-colors"
|
||||
>
|
||||
<i className="mdi mdi-cog text-4xl text-slate-500 dark:text-slate-400 mb-2"></i>
|
||||
<span className="text-slate-700 dark:text-slate-300 font-semibold">
|
||||
{t('SETTINGS.TITLE')}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
{hasSteamId && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<SteamGameCount targetCount={steamData.count}/>
|
||||
|
||||
<section className={`${cardClass} md:col-span-2 flex flex-col`}>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 className="font-bold text-slate-900 dark:text-white">
|
||||
<i className="mdi mdi-trophy-outline mr-2 text-amber-500"></i>
|
||||
{t('DASHBOARD.MOST_PLAYED')}
|
||||
</h2>
|
||||
<Link to="/library" className="text-sm font-semibold text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{t('DASHBOARD.SHOW_ALL')} <i className="mdi mdi-arrow-right"></i>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{topGames.length === 0 ? (
|
||||
<p className="px-5 py-8 text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
{sync.isRunning ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
|
||||
</p>
|
||||
) : (
|
||||
<ol className="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
{topGames.map((g, index) => {
|
||||
const icon = iconUrl(g.appid, g.imgIconUrl);
|
||||
return (
|
||||
<li key={g.appid}>
|
||||
<Link
|
||||
to={`/library/${g.appid}`}
|
||||
className="flex items-center gap-3 px-5 py-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors"
|
||||
>
|
||||
<span className="w-5 text-right text-sm font-bold text-slate-400 dark:text-slate-500 tabular-nums">
|
||||
{index + 1}.
|
||||
</span>
|
||||
{icon ? (
|
||||
<img src={icon} alt="" className="w-8 h-8 rounded" loading="lazy"/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded bg-slate-200 dark:bg-slate-700"/>
|
||||
)}
|
||||
<span className="flex-1 min-w-0 truncate text-slate-900 dark:text-white">
|
||||
{g.name ?? `App ${g.appid}`}
|
||||
</span>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400 tabular-nums">
|
||||
{formatPlaytime(g.playtimeMinutes)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {Link, useNavigate, useParams} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
const iconUrl = (appid, hash) =>
|
||||
hash
|
||||
? `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${hash}.jpg`
|
||||
: null;
|
||||
|
||||
const formatPlaytime = (minutes) => {
|
||||
if (!minutes || minutes <= 0) return '0h';
|
||||
const hours = minutes / 60;
|
||||
return hours >= 10 ? `${Math.round(hours)}h` : `${hours.toFixed(1)}h`;
|
||||
};
|
||||
import {formatPlaytime, iconUrl} from '../utils/games.js';
|
||||
|
||||
const formatUnlockedAt = (instant, locale) => {
|
||||
if (!instant) return '';
|
||||
|
||||
@@ -3,6 +3,8 @@ import {Link, useNavigate, useSearchParams} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import useLibrarySync from '../hooks/useLibrarySync.js';
|
||||
import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
|
||||
import useForceSync from '../hooks/useForceSync.js';
|
||||
import {formatPlaytime, iconUrl} from '../utils/games.js';
|
||||
|
||||
const PAGE_SIZES = [20, 40, 80];
|
||||
const DEFAULT_SIZE = 20;
|
||||
@@ -14,17 +16,6 @@ const SORT_OPTIONS = [
|
||||
{value: 'firstSeenAt,desc', labelKey: 'LIBRARY.SORT_FIRST_SEEN'},
|
||||
];
|
||||
|
||||
const iconUrl = (appid, hash) =>
|
||||
hash
|
||||
? `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${hash}.jpg`
|
||||
: null;
|
||||
|
||||
const formatPlaytime = (minutes) => {
|
||||
if (!minutes || minutes <= 0) return '0h';
|
||||
const hours = minutes / 60;
|
||||
return hours >= 10 ? `${Math.round(hours)}h` : `${hours.toFixed(1)}h`;
|
||||
};
|
||||
|
||||
export default function GameLibrary() {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -109,40 +100,7 @@ export default function GameLibrary() {
|
||||
updateParams({page: target});
|
||||
};
|
||||
|
||||
const [syncBusy, setSyncBusy] = useState(false);
|
||||
const [syncMessage, setSyncMessage] = useState(null);
|
||||
|
||||
const triggerSync = async () => {
|
||||
const token = localStorage.getItem('tbp_jwt');
|
||||
if (!token) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
setSyncBusy(true);
|
||||
setSyncMessage(null);
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/sync`, {
|
||||
method: 'POST',
|
||||
headers: {'Authorization': `Bearer ${token}`},
|
||||
});
|
||||
if (response.status === 429) {
|
||||
const json = await response.json().catch(() => ({}));
|
||||
setSyncMessage(t('LIBRARY.SYNC_COOLDOWN', {seconds: json.retryAfterSeconds ?? 60}));
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
setSyncMessage(t('LIBRARY.SYNC_FAILED'));
|
||||
return;
|
||||
}
|
||||
setSyncMessage(t('LIBRARY.SYNC_STARTED'));
|
||||
setTimeout(load, 4000);
|
||||
} catch (err) {
|
||||
console.error('sync trigger error:', err);
|
||||
setSyncMessage(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
} finally {
|
||||
setSyncBusy(false);
|
||||
}
|
||||
};
|
||||
const {trigger: triggerSync, busy: syncBusy, message: syncMessage} = useForceSync(load);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 p-8">
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const iconUrl = (appid, hash) =>
|
||||
hash
|
||||
? `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${hash}.jpg`
|
||||
: null;
|
||||
|
||||
export const formatPlaytime = (minutes) => {
|
||||
if (!minutes || minutes <= 0) return '0h';
|
||||
// Round to one decimal first, so 599 minutes becomes "10h" and not "10.0h".
|
||||
const hours = Math.round(minutes / 6) / 10;
|
||||
return hours >= 10 ? `${Math.round(hours)}h` : `${hours.toFixed(1)}h`;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {formatPlaytime, iconUrl} from './games.js';
|
||||
|
||||
describe('formatPlaytime', () => {
|
||||
it.each([[0, '0h'], [null, '0h'], [30, '0.5h'], [599, '10h'], [24720, '412h']])('%j minutes -> %s', (minutes, expected) => {
|
||||
expect(formatPlaytime(minutes)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('iconUrl', () => {
|
||||
it('is null without a hash', () => {
|
||||
expect(iconUrl(220, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user