Files
steam-backlog-helper/src/routes/+page.svelte
T

1365 lines
45 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { invoke } from '@tauri-apps/api/core';
import { onMount } from 'svelte';
type ActiveView = 'recommend' | 'shortlist';
type ModalView = 'setup' | 'settings' | 'about' | null;
type Recommendation = {
appId: number;
rank: number;
title: string;
fit: string;
reason: string;
imageUrl: string;
playtimeMinutes: number;
genres: string[];
steamTags: string[];
categories: string[];
reviewSummary: string;
reviewPercent: number | null;
supportsSingleplayer: boolean;
supportsMultiplayer: boolean;
};
type SetupSettings = {
steamProfile: string;
steamApiKey: string;
timeToBeatProvider: string;
defaultSessionLength: string;
trueRandomEnabled: boolean;
includeUnplayedGames: boolean;
includePlayedGames: boolean;
preferredReviewLevel: string;
preferredPlayMode: string;
preferredGenre: string;
preferredPlaytime: string;
};
let activeView: ActiveView = 'recommend';
let modalView: ModalView = null;
let setupStep = 0;
let steamProfile = '';
let steamApiKey = '';
let timeToBeatProvider = '';
let defaultSessionLength = '90 minutes';
let trueRandomEnabled = false;
let includeUnplayedGames = true;
let includePlayedGames = true;
let preferredReviewLevel = 'any';
let preferredPlayMode = 'any';
let preferredGenre = '';
let preferredPlaytime = 'any';
let setupComplete = false;
let spinCount = 0;
let wheelRotation = 0;
let selectedRecommendation: Recommendation | null = null;
let importedLibrary: Recommendation[] = [];
let isImportingLibrary = false;
let isEnrichingMetadata = false;
let isEnrichingSelectedMetadata = false;
let isLoadingSettings = true;
let libraryImportError = '';
let metadataRevision = 0;
let isTauriRuntime = false;
let isDebugWindow = false;
let browserReady = false;
let debugSnapshot: ReturnType<typeof createDebugSnapshot> | null = null;
const setupSteps = [
{
label: 'Steam profile',
type: 'text',
placeholder: 'Steam ID, vanity URL, or profile URL',
title: 'Connect your Steam profile',
instruction:
'Enter the public identifier for the Steam account whose backlog should be imported. You can paste a full profile URL, a custom vanity URL, or a 64-bit Steam ID.',
helper:
'Your profile and game details must be visible to the Steam Web API. Private libraries are not part of the MVP.'
},
{
label: 'Steam Web API key',
type: 'password',
placeholder: 'Stored locally',
title: 'Add your Steam Web API key',
instruction:
'Paste a Steam Web API key from Valve. The app uses it to fetch owned games and playtime for the profile you entered.',
helper:
'The key should be stored locally by the desktop app. Do not paste your Steam password; the app should never ask for it.'
},
{
label: 'Time-to-beat provider',
type: 'text',
placeholder: 'HowLongToBeat-compatible provider',
title: 'Choose the duration metadata source',
instruction:
'Provide the configured source or key for a HowLongToBeat-compatible provider. This lets the picker match games to the time available for a session.',
helper:
'If this is left blank later, recommendations can still work, but time-based ranking will be weaker.'
},
{
label: 'Default session length',
type: 'select',
placeholder: '90 minutes',
title: 'Set the default session length',
instruction:
'Choose the starting time window used by the recommendation spinner. You can still change this before each spin once the recommendation flow is implemented.',
helper: 'Pick the duration that best matches your usual play session.'
},
{
label: 'Random mode',
type: 'toggle',
placeholder: 'Enable true random mode',
title: 'Decide how random the picker should be',
instruction:
'Enable true random if you want a mode that ignores scoring and chooses from the eligible library pool directly.',
helper:
'Weighted recommendations remain the default because they can explain why a game was selected.'
}
];
const setupOptions = setupSteps.slice(0, 3);
const reviewPreferences = [
{ value: 'any', label: 'Any review signal', minimum: 0 },
{ value: 'overwhelmingly_positive', label: 'Overwhelmingly Positive', minimum: 95 },
{ value: 'very_positive', label: 'Very Positive', minimum: 80 },
{ value: 'positive', label: 'Positive', minimum: 70 },
{ value: 'mostly_positive', label: 'Mostly Positive', minimum: 60 },
{ value: 'mixed', label: 'Mixed', minimum: 40 },
{ value: 'negative', label: 'Negative or unusual picks', minimum: 0 }
];
const playModePreferences = [
{ value: 'any', label: 'Any mode' },
{ value: 'singleplayer', label: 'Prefer single-player' },
{ value: 'multiplayer', label: 'Prefer multiplayer/co-op' }
];
const playtimePreferences = [
{ value: 'any', label: 'Any playtime' },
{ value: 'unplayed', label: 'Prefer unplayed' },
{ value: 'lightly_played', label: 'Prefer lightly played' },
{ value: 'started', label: 'Prefer already started' }
];
$: availableGenres = Array.from(
new Set(importedLibrary.flatMap((game) => [...game.genres, ...game.steamTags]).filter(Boolean))
).sort((left, right) => left.localeCompare(right));
$: candidateGames = importedLibrary
.filter(isEligibleGame)
.map((game) => ({ ...game, score: scoreGame(game) }))
.sort((left, right) => right.score - left.score || left.title.localeCompare(right.title));
$: wheelGames = candidateGames;
$: shortlistGames = candidateGames.slice(0, 5);
$: debugGames = importedLibrary
.map((game) => ({ game, debug: scoreGameDebug(game) }))
.sort((left, right) => right.debug.total - left.debug.total || left.game.title.localeCompare(right.game.title));
$: excludedDebugGames = debugGames.filter((entry) => !entry.debug.eligible);
$: includedDebugGames = debugGames.filter((entry) => entry.debug.eligible);
$: debugPublishKey = [
selectedRecommendation?.appId ?? 'none',
spinCount,
metadataRevision,
importedLibrary.length,
includedDebugGames.length,
excludedDebugGames.length,
trueRandomEnabled,
includeUnplayedGames,
includePlayedGames,
preferredReviewLevel,
preferredPlayMode,
preferredGenre,
preferredPlaytime,
isEnrichingMetadata,
isEnrichingSelectedMetadata
].join('|');
$: if (browserReady && !isDebugWindow && debugPublishKey) {
publishDebugSnapshot();
}
onMount(() => {
browserReady = true;
isDebugWindow =
new URLSearchParams(window.location.search).get('debug') === '1' ||
window.location.hash === '#debug';
isTauriRuntime = Boolean(window.__TAURI_INTERNALS__);
if (isDebugWindow) {
loadDebugSnapshot();
const interval = window.setInterval(loadDebugSnapshot, 500);
window.addEventListener('storage', loadDebugSnapshot);
return () => {
window.clearInterval(interval);
window.removeEventListener('storage', loadDebugSnapshot);
};
}
void loadSavedSettings();
});
function openModal(view: Exclude<ModalView, null>) {
if (view === 'setup') {
setupStep = 0;
}
modalView = view;
}
function closeModal() {
modalView = null;
}
function hasRequiredSetup() {
return steamProfile.trim().length > 0 && steamApiKey.trim().length > 0;
}
async function loadSavedSettings() {
isLoadingSettings = true;
if (!isTauriRuntime) {
libraryImportError =
'Desktop integration is unavailable in browser preview. Run the app with Tauri to load saved settings and import your Steam library.';
modalView = 'setup';
isLoadingSettings = false;
return;
}
try {
const savedSettings = await invoke<SetupSettings | null>('load_setup_settings');
if (!savedSettings) {
modalView = 'setup';
return;
}
steamProfile = savedSettings.steamProfile;
steamApiKey = savedSettings.steamApiKey;
timeToBeatProvider = savedSettings.timeToBeatProvider;
defaultSessionLength = savedSettings.defaultSessionLength || '90 minutes';
trueRandomEnabled = savedSettings.trueRandomEnabled;
includeUnplayedGames = savedSettings.includeUnplayedGames ?? true;
includePlayedGames = savedSettings.includePlayedGames ?? true;
preferredReviewLevel = savedSettings.preferredReviewLevel || 'any';
preferredPlayMode = savedSettings.preferredPlayMode || 'any';
preferredGenre = savedSettings.preferredGenre || '';
preferredPlaytime = savedSettings.preferredPlaytime || 'any';
setupComplete = hasRequiredSetup();
modalView = null;
if (setupComplete) {
isLoadingSettings = false;
await importSteamLibrary();
}
} catch (error) {
libraryImportError =
error instanceof Error ? error.message : String(error || 'Could not load saved settings.');
modalView = 'setup';
} finally {
isLoadingSettings = false;
}
}
async function persistSetupSettings() {
if (!isTauriRuntime) {
return;
}
await invoke('save_setup_settings', {
settings: {
steamProfile,
steamApiKey,
timeToBeatProvider,
defaultSessionLength,
trueRandomEnabled,
includeUnplayedGames,
includePlayedGames,
preferredReviewLevel,
preferredPlayMode,
preferredGenre,
preferredPlaytime
}
});
}
async function importSteamLibrary() {
if (!hasRequiredSetup()) {
importedLibrary = [];
setupComplete = false;
return;
}
isImportingLibrary = true;
libraryImportError = '';
if (!isTauriRuntime) {
importedLibrary = [];
setupComplete = false;
libraryImportError =
'Steam library import requires the Tauri desktop runtime. Browser preview cannot access native commands.';
isImportingLibrary = false;
return;
}
try {
importedLibrary = await invoke<Recommendation[]>('import_steam_library', {
steamProfile,
apiKey: steamApiKey
});
setupComplete = importedLibrary.length > 0;
selectedRecommendation = null;
spinCount = 0;
metadataRevision += 1;
if (setupComplete) {
void enrichSteamMetadata();
}
} catch (error) {
importedLibrary = [];
setupComplete = false;
libraryImportError =
error instanceof Error ? error.message : String(error || 'Failed to import Steam library.');
} finally {
isImportingLibrary = false;
}
}
async function enrichSteamMetadata() {
if (!isTauriRuntime || importedLibrary.length === 0) {
return;
}
isEnrichingMetadata = true;
try {
const enrichedLibrary = await invoke<Recommendation[]>('enrich_steam_metadata', {
games: importedLibrary
});
importedLibrary = enrichedLibrary;
if (selectedRecommendation) {
selectedRecommendation =
enrichedLibrary.find((game) => game.appId === selectedRecommendation?.appId) ??
selectedRecommendation;
}
metadataRevision += 1;
} catch (error) {
libraryImportError =
error instanceof Error
? error.message
: String(error || 'Failed to enrich Steam metadata.');
} finally {
isEnrichingMetadata = false;
}
}
async function enrichSelectedRecommendation(game: Recommendation) {
if (!isTauriRuntime) {
return;
}
isEnrichingSelectedMetadata = true;
try {
const [enrichedGame] = await invoke<Recommendation[]>('enrich_steam_metadata', {
games: [game]
});
if (!enrichedGame) {
return;
}
selectedRecommendation = enrichedGame;
importedLibrary = importedLibrary.map((libraryGame) =>
libraryGame.appId === enrichedGame.appId ? enrichedGame : libraryGame
);
metadataRevision += 1;
} catch (error) {
libraryImportError =
error instanceof Error
? error.message
: String(error || 'Failed to enrich selected game metadata.');
} finally {
isEnrichingSelectedMetadata = false;
}
}
async function saveSetup() {
setupComplete = hasRequiredSetup();
await persistSetupSettings();
await importSteamLibrary();
modalView = null;
activeView = 'recommend';
}
async function saveSettings() {
setupComplete = hasRequiredSetup();
await persistSetupSettings();
await importSteamLibrary();
modalView = null;
}
async function openDebugWindow() {
publishDebugSnapshot();
if (!isTauriRuntime) {
window.open('?debug=1', 'steam-backlog-helper-debug', 'width=1120,height=820');
return;
}
try {
await invoke('open_debug_window');
} catch (error) {
libraryImportError =
error instanceof Error ? error.message : String(error || 'Could not open debug window.');
}
}
function spinRecommendation() {
if (!setupComplete) {
openModal('setup');
return;
}
if (candidateGames.length === 0) {
libraryImportError = 'Import your Steam library before spinning.';
openModal('setup');
return;
}
const availableGames =
candidateGames.length > 1 && selectedRecommendation
? candidateGames.filter((game) => game.appId !== selectedRecommendation?.appId)
: candidateGames;
const nextRecommendation = trueRandomEnabled
? availableGames[Math.floor(Math.random() * availableGames.length)]
: pickWeightedGame(availableGames);
const visualIndex = Math.max(
0,
wheelGames.findIndex((game) => game.appId === nextRecommendation.appId)
);
selectedRecommendation = nextRecommendation;
wheelRotation += 720 + visualIndex * (360 / wheelGames.length) + 360 / wheelGames.length / 2;
spinCount += 1;
void enrichSelectedRecommendation(nextRecommendation);
}
function nextSetupStep() {
setupStep = Math.min(setupStep + 1, setupSteps.length);
}
function previousSetupStep() {
setupStep = Math.max(setupStep - 1, 0);
}
function isEligibleGame(game: Recommendation) {
const isUnplayed = game.playtimeMinutes === 0;
if (!includeUnplayedGames && isUnplayed) {
return false;
}
if (!includePlayedGames && !isUnplayed) {
return false;
}
return true;
}
function scoreGame(game: Recommendation) {
return scoreGameDebug(game).total;
}
function scoreGameDebug(game: Recommendation) {
const contributions: Array<{ label: string; value: number; detail: string }> = [
{ label: 'Base candidate weight', value: trueRandomEnabled ? 1 : 10, detail: 'Every eligible game starts here.' }
];
const restrictions: string[] = [];
const isUnplayed = game.playtimeMinutes === 0;
if (!includeUnplayedGames && isUnplayed) {
restrictions.push('Excluded because unplayed games are disabled.');
}
if (!includePlayedGames && !isUnplayed) {
restrictions.push('Excluded because games with playtime are disabled.');
}
if (trueRandomEnabled) {
return {
eligible: restrictions.length === 0,
restrictions,
total: restrictions.length === 0 ? 1 : 0,
contributions,
metadata: gameDebugMetadata(game)
};
}
let score = 10;
const reviewPreference = reviewPreferences.find(
(preference) => preference.value === preferredReviewLevel
);
if (reviewPreference && reviewPreference.value !== 'any' && game.reviewPercent !== null) {
const value = game.reviewPercent >= reviewPreference.minimum ? 16 : -6;
score += value;
contributions.push({
label: 'Review preference',
value,
detail: `${game.reviewPercent}% positive vs ${reviewPreference.minimum}% target.`
});
} else if (reviewPreference && reviewPreference.value !== 'any') {
contributions.push({
label: 'Review preference',
value: 0,
detail: 'Review data is pending, so no review weight was applied.'
});
}
if (preferredReviewLevel === 'negative' && game.reviewPercent !== null) {
const value = game.reviewPercent < 60 ? 16 : -4;
score += value;
contributions.push({
label: 'Negative review preference',
value,
detail: `${game.reviewPercent}% positive.`
});
}
if (preferredPlayMode === 'singleplayer') {
const value = game.supportsSingleplayer ? 14 : -4;
score += value;
contributions.push({
label: 'Single-player preference',
value,
detail: game.supportsSingleplayer ? 'Single-player category detected.' : 'Single-player category not detected.'
});
}
if (preferredPlayMode === 'multiplayer') {
const value = game.supportsMultiplayer ? 14 : -4;
score += value;
contributions.push({
label: 'Multiplayer preference',
value,
detail: game.supportsMultiplayer ? 'Multiplayer or co-op category detected.' : 'Multiplayer category not detected.'
});
}
if (
preferredGenre &&
[...game.genres, ...game.steamTags].some((genre) => genre === preferredGenre)
) {
score += 18;
contributions.push({
label: 'Genre preference',
value: 18,
detail: `${preferredGenre} matched.`
});
} else if (preferredGenre) {
contributions.push({
label: 'Genre preference',
value: 0,
detail: `${preferredGenre} not present or metadata is pending.`
});
}
if (preferredPlaytime === 'unplayed') {
const value = game.playtimeMinutes === 0 ? 14 : -2;
score += value;
contributions.push({
label: 'Unplayed preference',
value,
detail: game.playtimeMinutes === 0 ? 'No recorded playtime.' : `${game.playtimeMinutes} minutes played.`
});
}
if (preferredPlaytime === 'lightly_played') {
const value = game.playtimeMinutes > 0 && game.playtimeMinutes <= 300 ? 14 : -2;
score += value;
contributions.push({
label: 'Lightly played preference',
value,
detail: `${game.playtimeMinutes} minutes played.`
});
}
if (preferredPlaytime === 'started') {
const value = game.playtimeMinutes > 0 ? 12 : -2;
score += value;
contributions.push({
label: 'Started preference',
value,
detail: game.playtimeMinutes > 0 ? `${game.playtimeMinutes} minutes played.` : 'No recorded playtime.'
});
}
const eligible = restrictions.length === 0;
return {
eligible,
restrictions,
total: eligible ? Math.max(1, score) : 0,
contributions,
metadata: gameDebugMetadata(game)
};
}
function gameDebugMetadata(game: Recommendation) {
return {
reviews: game.reviewPercent === null ? game.reviewSummary : `${game.reviewSummary} (${game.reviewPercent}%)`,
genres: game.genres.length > 0 ? game.genres.join(', ') : 'Pending',
steamTags: game.steamTags.length > 0 ? game.steamTags.join(', ') : 'Pending',
categories: game.categories.length > 0 ? game.categories.join(', ') : 'Pending',
playMode: [
game.supportsSingleplayer ? 'Single-player' : null,
game.supportsMultiplayer ? 'Multiplayer/co-op' : null
].filter(Boolean).join(', ') || 'Pending',
playtime: game.playtimeMinutes === 0 ? 'Unplayed' : `${Math.floor(game.playtimeMinutes / 60)}h ${game.playtimeMinutes % 60}m`
};
}
function pickWeightedGame(games: Array<Recommendation & { score: number }>) {
const totalWeight = games.reduce((total, game) => total + game.score, 0);
let cursor = Math.random() * totalWeight;
for (const game of games) {
cursor -= game.score;
if (cursor <= 0) {
return game;
}
}
return games[games.length - 1];
}
function createDebugSnapshot() {
return {
generatedAt: new Date().toLocaleTimeString(),
selected: selectedRecommendation
? {
game: selectedRecommendation,
debug: scoreGameDebug(selectedRecommendation)
}
: null,
summary: {
imported: importedLibrary.length,
eligible: includedDebugGames.length,
restricted: excludedDebugGames.length,
mode: trueRandomEnabled ? 'Random' : 'Weighted',
spinCount,
metadataEnrichment:
isEnrichingSelectedMetadata || isEnrichingMetadata ? 'running' : 'idle'
},
rules: {
hardRestrictions: `Unplayed: ${includeUnplayedGames ? 'included' : 'excluded'} · Played: ${
includePlayedGames ? 'included' : 'excluded'
}`,
review:
reviewPreferences.find((preference) => preference.value === preferredReviewLevel)?.label ??
'Any review signal',
playMode:
playModePreferences.find((preference) => preference.value === preferredPlayMode)?.label ??
'Any mode',
genre: preferredGenre || 'Any genre',
playtime:
playtimePreferences.find((preference) => preference.value === preferredPlaytime)?.label ??
'Any playtime'
},
games: debugGames.slice(0, 100)
};
}
function publishDebugSnapshot() {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(
'steam-backlog-helper-debug',
JSON.stringify(createDebugSnapshot())
);
}
function loadDebugSnapshot() {
const rawSnapshot = window.localStorage.getItem('steam-backlog-helper-debug');
debugSnapshot = rawSnapshot ? JSON.parse(rawSnapshot) : null;
}
</script>
<svelte:head>
<title>Steam Backlog Helper</title>
</svelte:head>
{#if isDebugWindow}
<main class="debug-app" aria-labelledby="debug-title">
<header class="debug-window-header">
<div>
<p class="eyebrow">Debug mode</p>
<h1 id="debug-title">Recommendation Diagnostics</h1>
</div>
<p>Last update: {debugSnapshot?.generatedAt ?? 'waiting for main window'}</p>
</header>
<div class="debug-content">
{#if !debugSnapshot}
<article class="empty-state">
<h2>No debug data yet</h2>
<p>Open the main window, import a library, and spin a recommendation.</p>
</article>
{:else}
<section class="debug-summary" aria-label="Debug summary">
<article>
<span>Imported</span>
<strong>{debugSnapshot.summary.imported}</strong>
</article>
<article>
<span>Eligible</span>
<strong>{debugSnapshot.summary.eligible}</strong>
</article>
<article>
<span>Restricted</span>
<strong>{debugSnapshot.summary.restricted}</strong>
</article>
<article>
<span>Spins</span>
<strong>{debugSnapshot.summary.spinCount}</strong>
</article>
<article>
<span>Metadata</span>
<strong>{debugSnapshot.summary.metadataEnrichment}</strong>
</article>
</section>
<section class="debug-panel selected-debug" aria-labelledby="debug-selected-title">
<h2 id="debug-selected-title">Current Pick</h2>
{#if debugSnapshot.selected}
<article>
<img
src={debugSnapshot.selected.game.imageUrl}
alt={debugSnapshot.selected.game.title}
/>
<div>
<span>App {debugSnapshot.selected.game.appId}</span>
<h3>{debugSnapshot.selected.game.title}</h3>
<p>{debugSnapshot.selected.game.fit}</p>
<strong>Score {debugSnapshot.selected.debug.total}</strong>
</div>
</article>
<dl class="debug-metadata">
<div>
<dt>Reviews</dt>
<dd>{debugSnapshot.selected.debug.metadata.reviews}</dd>
</div>
<div>
<dt>Genres</dt>
<dd>{debugSnapshot.selected.debug.metadata.genres}</dd>
</div>
<div>
<dt>Steam tags</dt>
<dd>{debugSnapshot.selected.debug.metadata.steamTags}</dd>
</div>
<div>
<dt>Categories</dt>
<dd>{debugSnapshot.selected.debug.metadata.categories}</dd>
</div>
<div>
<dt>Mode</dt>
<dd>{debugSnapshot.selected.debug.metadata.playMode}</dd>
</div>
</dl>
<table>
<thead>
<tr>
<th>Weight</th>
<th>Value</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
{#each debugSnapshot.selected.debug.contributions as contribution}
<tr>
<td>{contribution.label}</td>
<td>{contribution.value > 0 ? `+${contribution.value}` : contribution.value}</td>
<td>{contribution.detail}</td>
</tr>
{/each}
</tbody>
</table>
{:else}
<p class="empty-debug">No recommendation has been spun yet.</p>
{/if}
</section>
<section class="debug-panel" aria-labelledby="debug-rules-title">
<h2 id="debug-rules-title">Active Rules</h2>
<dl class="debug-rules">
<div>
<dt>Hard restrictions</dt>
<dd>{debugSnapshot.rules.hardRestrictions}</dd>
</div>
<div>
<dt>Review preference</dt>
<dd>{debugSnapshot.rules.review}</dd>
</div>
<div>
<dt>Play mode preference</dt>
<dd>{debugSnapshot.rules.playMode}</dd>
</div>
<div>
<dt>Genre preference</dt>
<dd>{debugSnapshot.rules.genre}</dd>
</div>
<div>
<dt>Playtime preference</dt>
<dd>{debugSnapshot.rules.playtime}</dd>
</div>
</dl>
</section>
<section class="debug-panel" aria-labelledby="debug-games-title">
<h2 id="debug-games-title">Game Weighting</h2>
<div class="debug-table">
{#each debugSnapshot.games as entry}
<article class:restricted={!entry.debug.eligible}>
<header>
<div>
<span>App {entry.game.appId}</span>
<h3>{entry.game.title}</h3>
</div>
<strong>{entry.debug.eligible ? entry.debug.total : 'Excluded'}</strong>
</header>
{#if entry.debug.restrictions.length > 0}
<ul class="debug-restrictions">
{#each entry.debug.restrictions as restriction}
<li>{restriction}</li>
{/each}
</ul>
{/if}
<dl class="debug-metadata">
<div>
<dt>Playtime</dt>
<dd>{entry.debug.metadata.playtime}</dd>
</div>
<div>
<dt>Reviews</dt>
<dd>{entry.debug.metadata.reviews}</dd>
</div>
<div>
<dt>Genres</dt>
<dd>{entry.debug.metadata.genres}</dd>
</div>
<div>
<dt>Steam tags</dt>
<dd>{entry.debug.metadata.steamTags}</dd>
</div>
<div>
<dt>Categories</dt>
<dd>{entry.debug.metadata.categories}</dd>
</div>
<div>
<dt>Mode</dt>
<dd>{entry.debug.metadata.playMode}</dd>
</div>
</dl>
</article>
{/each}
</div>
</section>
{/if}
</div>
</main>
{:else}
<main class="app-shell" aria-labelledby="page-title">
<header class="topbar">
<button type="button" class="brand-button" on:click={() => openModal('about')}>
<span>Steam Backlog Helper</span>
<small>Local picker</small>
</button>
<nav class="view-tabs" aria-label="Primary views">
<button
type="button"
class:active={activeView === 'recommend'}
on:click={() => (activeView = 'recommend')}
>
Recommend
</button>
<button
type="button"
class:active={activeView === 'shortlist'}
on:click={() => (activeView = 'shortlist')}
>
Shortlist
</button>
</nav>
<button
type="button"
class="icon-button"
aria-label="Open settings"
title="Settings"
on:click={() => openModal('settings')}
>
⚙
</button>
</header>
{#if activeView === 'recommend'}
<section class="recommend-view" aria-label="Recommendation spinner">
<div class="session-heading">
<p class="eyebrow">Recommendation</p>
<h1 id="page-title">Session Picker</h1>
</div>
<div class="spinner-stage">
<div class="wheel-stage" aria-label="Recommendation wheel">
<div class="wheel-pointer" aria-hidden="true"></div>
<div
class:ready={setupComplete}
class="game-wheel"
style={`--wheel-rotation: ${wheelRotation}deg`}
aria-hidden="true"
>
{#each wheelGames as game, index}
{@const angle = index * (360 / wheelGames.length)}
{@const segmentSize = 360 / wheelGames.length}
<div
class="wheel-segment"
class:selected={selectedRecommendation?.appId === game.appId}
style={`--segment-angle: ${angle}deg; --segment-size: ${segmentSize}deg; --segment-image: url('${game.imageUrl}')`}
>
<span>{index + 1}</span>
</div>
{/each}
</div>
<article class="wheel-result">
{#if selectedRecommendation}
<img src={selectedRecommendation.imageUrl} alt={selectedRecommendation.title} />
<div>
<span>Selected</span>
<strong>{selectedRecommendation.title}</strong>
<p>{selectedRecommendation.fit}</p>
</div>
{:else}
<div class="empty-cover" aria-hidden="true">?</div>
<div>
<span>
{isLoadingSettings
? 'Loading'
: isImportingLibrary
? 'Importing'
: setupComplete
? 'Ready'
: 'Setup required'}
</span>
<strong>
{isLoadingSettings
? 'Checking saved setup'
: isImportingLibrary
? 'Fetching library'
: setupComplete
? 'Spin the wheel'
: 'Complete setup'}
</strong>
<p>
{isLoadingSettings
? 'Reading local SQLite settings'
: isImportingLibrary
? 'Steam Web API import in progress'
: setupComplete
? `${candidateGames.length} eligible games loaded`
: 'Profile and API key required'}
</p>
</div>
{/if}
</article>
</div>
<div class="spin-copy">
<p class="eyebrow">
{isLoadingSettings
? 'Loading settings'
: isImportingLibrary
? 'Importing library'
: setupComplete
? 'Ready'
: 'Setup required'}
</p>
<h2>
{selectedRecommendation
? selectedRecommendation.title
: isImportingLibrary
? 'Fetching your Steam library.'
: setupComplete
? 'Spin for a recommendation.'
: 'Import your library to spin a recommendation.'}
</h2>
<p>
{selectedRecommendation
? selectedRecommendation.reason
: isImportingLibrary
? 'Importing owned games. Metadata enrichment will run as a separate cached step.'
: setupComplete
? `Configured for ${defaultSessionLength}. The wheel contains ${candidateGames.length} eligible games from ${importedLibrary.length} imported Steam games.`
: 'The first run setup enables Steam import, metadata enrichment, review signals, and time-to-beat matching.'}
</p>
{#if libraryImportError}
<p class="error-message">{libraryImportError}</p>
{/if}
</div>
<div class="spin-actions">
<button
type="button"
class="primary-action"
disabled={isImportingLibrary}
on:click={spinRecommendation}
>
{isLoadingSettings
? 'Loading Settings'
: isImportingLibrary
? 'Importing Library'
: setupComplete
? 'Spin Recommendation'
: 'Run Setup'}
</button>
<button type="button" class="secondary-action" on:click={() => openModal('setup')}>
{setupComplete ? 'Edit Setup' : 'Open Setup'}
</button>
</div>
</div>
</section>
{:else}
<section class="shortlist-view" aria-labelledby="shortlist-title">
<div class="section-heading">
<div>
<p class="eyebrow">Shortlist</p>
<h1 id="shortlist-title">Top candidate games</h1>
</div>
<button type="button" class="secondary-action">Refresh Shortlist</button>
</div>
<div class="shortlist">
{#if shortlistGames.length === 0}
<article class="empty-state">
<h2>No imported library yet</h2>
<p>Complete setup to import your Steam games and build the shortlist.</p>
{#if libraryImportError}
<p class="error-message">{libraryImportError}</p>
{/if}
</article>
{/if}
{#each shortlistGames as game, index}
<article class="shortlist-card">
<b>{index + 1}</b>
<img src={game.imageUrl} alt={game.title} />
<div>
<span>{game.fit} · {game.reviewSummary}</span>
<h2>{game.title}</h2>
<p>
{game.reason}
{#if game.score}
Weighted score: {game.score}.
{/if}
</p>
</div>
</article>
{/each}
</div>
</section>
{/if}
{#if modalView}
<div class="modal-layer">
<button
type="button"
class="modal-backdrop"
aria-label="Close modal"
on:click={closeModal}
></button>
<div
class="modal-panel"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<header class="modal-header">
<div>
<p class="eyebrow">
{modalView === 'about'
? 'About'
: modalView === 'setup'
? `First Run · Step ${Math.min(setupStep + 1, setupSteps.length)} of ${setupSteps.length}`
: 'Settings'}
</p>
<h2 id="modal-title">
{modalView === 'about'
? 'Steam Backlog Helper'
: modalView === 'setup'
? 'Setup Wizard'
: 'Settings'}
</h2>
</div>
<button type="button" class="close-button" aria-label="Close" on:click={closeModal}>
×
</button>
</header>
{#if modalView === 'about'}
<div class="modal-content">
<p>
Steam Backlog Helper is a local-first desktop app for turning a large Steam library
into a focused recommendation for what to play next.
</p>
<dl class="about-list">
<div>
<dt>Display name</dt>
<dd>Steam Backlog Helper</dd>
</div>
<div>
<dt>Package ID</dt>
<dd>com.boris.backloghelper</dd>
</div>
<div>
<dt>Stack</dt>
<dd>Tauri, SvelteKit, TypeScript, Rust, SQLite</dd>
</div>
</dl>
</div>
{:else if modalView === 'setup'}
<div class="wizard">
<ol class="wizard-steps" aria-label="Setup progress">
{#each setupSteps as step, index}
<li class:active={index === setupStep} class:complete={index < setupStep}>
<span>{index + 1}</span>
<p>{step.label}</p>
</li>
{/each}
</ol>
{#if setupStep < setupSteps.length}
{@const step = setupSteps[setupStep]}
<section class="wizard-step" aria-labelledby="wizard-step-title">
<div>
<p class="eyebrow">Configure</p>
<h3 id="wizard-step-title">{step.title}</h3>
<p>{step.instruction}</p>
</div>
{#if step.type === 'select'}
<label>
<span>{step.label}</span>
<select bind:value={defaultSessionLength}>
<option>90 minutes</option>
<option>30 minutes</option>
<option>2 hours</option>
<option>No limit</option>
</select>
</label>
{:else if step.type === 'toggle'}
<label class="toggle-control">
<input type="checkbox" bind:checked={trueRandomEnabled} />
<span>{step.placeholder}</span>
</label>
{:else if setupStep === 0}
<label>
<span>{step.label}</span>
<input type="text" placeholder={step.placeholder} bind:value={steamProfile} />
</label>
{:else if setupStep === 1}
<label>
<span>{step.label}</span>
<input type="password" placeholder={step.placeholder} bind:value={steamApiKey} />
</label>
{:else}
<label>
<span>{step.label}</span>
<input
type="text"
placeholder={step.placeholder}
bind:value={timeToBeatProvider}
/>
</label>
{/if}
<p class="field-help">{step.helper}</p>
</section>
{:else}
<section class="wizard-step review-step" aria-labelledby="wizard-step-title">
<div>
<p class="eyebrow">Review</p>
<h3 id="wizard-step-title">Ready to import</h3>
<p>
Setup will save these local settings, then the app can import your Steam
library and enrich it for recommendations.
</p>
</div>
<ul>
<li>Steam profile can resolve the target library.</li>
<li>Steam Web API key is available for owned-games import.</li>
<li>Time-to-beat metadata can improve session matching.</li>
<li>Default session length: {defaultSessionLength}.</li>
<li>True random mode: {trueRandomEnabled ? 'enabled' : 'disabled'}.</li>
<li>Defaults can be changed later from Settings.</li>
</ul>
</section>
{/if}
<footer class="modal-actions">
<button
type="button"
class="secondary-action"
disabled={setupStep === 0}
on:click={previousSetupStep}
>
Back
</button>
{#if setupStep < setupSteps.length}
<button
type="button"
class="primary-action"
disabled={setupStep === 0 && steamProfile.trim().length === 0}
on:click={nextSetupStep}
>
{setupStep === setupSteps.length - 1 ? 'Review' : 'Next'}
</button>
{:else}
<button
type="button"
class="primary-action"
disabled={!hasRequiredSetup()}
on:click={saveSetup}
>
{isImportingLibrary ? 'Importing' : 'Save And Import'}
</button>
{/if}
</footer>
</div>
{:else}
<form class="settings-form">
{#each setupOptions as option}
{#if option.label === 'Steam profile'}
<label>
<span>{option.label}</span>
<input type="text" placeholder={option.placeholder} bind:value={steamProfile} />
</label>
{:else if option.label === 'Steam Web API key'}
<label>
<span>{option.label}</span>
<input type="password" placeholder={option.placeholder} bind:value={steamApiKey} />
</label>
{:else}
<label>
<span>{option.label}</span>
<input
type="text"
placeholder={option.placeholder}
bind:value={timeToBeatProvider}
/>
</label>
{/if}
{/each}
<label>
<span>Default session length</span>
<select bind:value={defaultSessionLength}>
<option>90 minutes</option>
<option>30 minutes</option>
<option>2 hours</option>
<option>No limit</option>
</select>
</label>
<label class="toggle-control">
<input type="checkbox" bind:checked={trueRandomEnabled} />
<span>Enable true random mode</span>
</label>
<div class="settings-group">
<h3>Eligibility filters</h3>
<p>These remove games from the wheel entirely.</p>
<label class="toggle-control">
<input type="checkbox" bind:checked={includeUnplayedGames} />
<span>Include unplayed games</span>
</label>
<label class="toggle-control">
<input type="checkbox" bind:checked={includePlayedGames} />
<span>Include games with playtime</span>
</label>
</div>
<div class="settings-group">
<h3>Preferred filters</h3>
<p>These change recommendation weighting without excluding games.</p>
<label>
<span>Review preference</span>
<select bind:value={preferredReviewLevel}>
{#each reviewPreferences as preference}
<option value={preference.value}>{preference.label}</option>
{/each}
</select>
</label>
<label>
<span>Player mode</span>
<select bind:value={preferredPlayMode}>
{#each playModePreferences as preference}
<option value={preference.value}>{preference.label}</option>
{/each}
</select>
</label>
<label>
<span>Genre</span>
<select bind:value={preferredGenre}>
<option value="">Any genre</option>
{#each availableGenres as genre}
<option value={genre}>{genre}</option>
{/each}
</select>
</label>
<label>
<span>Playtime</span>
<select bind:value={preferredPlaytime}>
{#each playtimePreferences as preference}
<option value={preference.value}>{preference.label}</option>
{/each}
</select>
</label>
</div>
<div class="settings-group">
<h3>Debug mode</h3>
<p>Inspect active restrictions, weighting rules, review data, and per-game scores.</p>
<button
type="button"
class="secondary-action"
on:click={openDebugWindow}
>
Open Debug Window
</button>
</div>
<footer class="modal-actions">
<button type="button" class="secondary-action" on:click={closeModal}>Cancel</button>
<button type="button" class="primary-action" disabled={isImportingLibrary} on:click={saveSettings}>
{isImportingLibrary ? 'Importing' : 'Save Settings'}
</button>
</footer>
</form>
{/if}
</div>
</div>
{/if}
</main>
{/if}