1document.addEventListener('DOMContentLoaded', () => { 2 const HLTB_CACHE_KEY = 'hltbCache:v1'; 3 const HLTB_CACHE_LIMIT = 2000; 4 const loadBtn = document.getElementById('loadBtn'); 5 const apiKeyInput = document.getElementById('apiKey'); 6 const apiKeyPanel = document.getElementById('apiKeyPanel'); 7 const revealApiKeyBtn = document.getElementById('revealApiKey'); 8 const steamIdInput = document.getElementById('steamId'); 9 const statusMsg = document.getElementById('statusMsg'); 10 const resultsPanel = document.getElementById('resultsPanel'); 11 const gamesBody = document.getElementById('gamesBody'); 12 const statsDisplay = document.getElementById('statsDisplay'); 13 const searchFilter = document.getElementById('searchFilter'); 14 const unplayedOnlyFilter = document.getElementById('unplayedOnly'); 15 const hideMissingHltbFilter = document.getElementById('hideMissingHltb'); 16 const maxHoursEnabledFilter = document.getElementById('maxHoursEnabled'); 17 const maxHoursFilter = document.getElementById('maxHoursFilter'); 18 19 let gamesData = []; 20 let processingQueue = []; 21 let abortController = null; 22 let currentSortKey = 'playtime'; 23 let currentSortDir = 'desc'; 24 let hltbCache = loadHltbCache(); 25 26 if (localStorage.getItem('steamApiKey')) apiKeyInput.value = localStorage.getItem('steamApiKey'); 27 if (localStorage.getItem('steamApiKey') && apiKeyPanel && revealApiKeyBtn) { 28 apiKeyPanel.classList.remove('hidden'); 29 revealApiKeyBtn.setAttribute('aria-expanded', 'true'); 30 } 31 if (localStorage.getItem('steamId')) steamIdInput.value = localStorage.getItem('steamId'); 32 if (localStorage.getItem('searchFilter')) searchFilter.value = localStorage.getItem('searchFilter'); 33 if (localStorage.getItem('unplayedOnly') === 'true') unplayedOnlyFilter.checked = true; 34 if (localStorage.getItem('hideMissingHltb') === 'true') hideMissingHltbFilter.checked = true; 35 if (localStorage.getItem('maxHoursEnabled') === 'true') maxHoursEnabledFilter.checked = true; 36 if (localStorage.getItem('maxHoursFilter')) maxHoursFilter.value = localStorage.getItem('maxHoursFilter'); 37 maxHoursFilter.disabled = !maxHoursEnabledFilter.checked; 38 39 const toggleApiKeyBtn = document.getElementById('toggleApiKey'); 40 if (revealApiKeyBtn && apiKeyPanel) { 41 revealApiKeyBtn.addEventListener('click', () => { 42 const isHidden = apiKeyPanel.classList.contains('hidden'); 43 apiKeyPanel.classList.toggle('hidden', !isHidden); 44 revealApiKeyBtn.setAttribute('aria-expanded', isHidden ? 'true' : 'false'); 45 46 if (isHidden) { 47 apiKeyInput.focus(); 48 } 49 }); 50 } 51 52 if (toggleApiKeyBtn) { 53 toggleApiKeyBtn.addEventListener('click', () => { 54 const type = apiKeyInput.getAttribute('type') === 'password' ? 'text' : 'password'; 55 const isVisible = type === 'text'; 56 57 apiKeyInput.setAttribute('type', type); 58 toggleApiKeyBtn.style.color = isVisible ? '#38bdf8' : ''; 59 toggleApiKeyBtn.setAttribute('aria-pressed', isVisible ? 'true' : 'false'); 60 toggleApiKeyBtn.setAttribute('aria-label', isVisible ? 'Hide API key' : 'Show API key'); 61 }); 62 } 63 64 loadBtn.addEventListener('click', async () => { 65 const apiKey = apiKeyInput.value.trim(); 66 const steamId = steamIdInput.value.trim(); 67 68 if (!steamId) { 69 showStatus('Please enter a Steam ID.', 'error'); 70 return; 71 } 72 73 localStorage.setItem('steamApiKey', apiKey); 74 localStorage.setItem('steamId', steamId); 75 76 if (abortController) abortController.abort(); 77 abortController = new AbortController(); 78 gamesData = []; 79 processingQueue = []; 80 gamesBody.innerHTML = ''; 81 resultsPanel.classList.add('hidden'); 82 showStatus('Fetching Steam library...', 'info'); 83 84 try { 85 const response = await fetch('/api/fetch_library', { 86 method: 'POST', 87 headers: { 'Content-Type': 'application/json' }, 88 body: JSON.stringify({ api_key: apiKey, steam_id: steamId }), 89 signal: abortController.signal 90 }); 91 92 if (!response.ok) { 93 const data = await response.json(); 94 throw new Error(data.error || 'Failed to fetch library.'); 95 } 96 97 const data = await response.json(); 98 gamesData = data.games.map(game => ({ 99 appid: game.appid, 100 name: game.name, 101 playtime_forever: game.playtime_forever || 0, 102 img_icon_url: game.img_icon_url, 103 hltb: game.hltb || null, 104 source: game.source 105 })); 106 107 hydrateGamesFromLocalCache(gamesData); 108 persistServerCache(gamesData); 109 110 if (data.errors && data.errors.length > 0) { 111 const errorMsg = data.errors.join(' | '); 112 113 if (gamesData.length > 0) { 114 showStatus(`Loaded ${gamesData.length} games. Warning: ${errorMsg}`, 'error'); 115 } else { 116 showStatus(`Failed: ${errorMsg}`, 'error'); 117 return; 118 } 119 } else { 120 showStatus(`Found ${gamesData.length} games. Fetching completion times...`, 'success'); 121 } 122 123 applyCurrentView(); 124 resultsPanel.classList.remove('hidden'); 125 126 processingQueue = gamesData.filter(game => !game.hltb); 127 128 if (processingQueue.length > 0) { 129 showStatus(`Found ${gamesData.length} games. Fetching data for ${processingQueue.length} items...`, 'info'); 130 processQueue(); 131 } else { 132 showStatus(`Loaded ${gamesData.length} games from cache!`, 'success'); 133 } 134 } catch (error) { 135 if (error.name === 'AbortError') return; 136 showStatus(error.message, 'error'); 137 } 138 }); 139 140 async function processQueue() { 141 if (processingQueue.length === 0) { 142 showStatus('Finished fetching data.', 'success'); 143 return; 144 } 145 146 const batchSize = 50; 147 const batch = processingQueue.splice(0, batchSize); 148 149 const promises = batch.map(async (game) => { 150 try { 151 const response = await fetch('/api/get_game_time', { 152 method: 'POST', 153 headers: { 'Content-Type': 'application/json' }, 154 body: JSON.stringify({ game_name: game.name }) 155 }); 156 157 const targetGame = gamesData.find(g => g.appid === game.appid); 158 if (!targetGame) { 159 return; 160 } 161 162 if (response.ok) { 163 targetGame.hltb = await response.json(); 164 } else { 165 targetGame.hltb = { not_found: true }; 166 } 167 168 saveGameTimeToLocalCache(targetGame.name, targetGame.hltb); 169 170 applyCurrentView(); 171 } catch (e) { 172 console.error(`Error fetching for ${game.name}`, e); 173 } 174 }); 175 176 await Promise.all(promises); 177 178 const remaining = processingQueue.length; 179 const total = gamesData.length; 180 showStatus(`Fetching completion times... (${total - remaining}/${total})`, 'info'); 181 182 if (processingQueue.length > 0) { 183 setTimeout(processQueue, 10); 184 } else { 185 showStatus(`Done! Loaded ${total} games.`, 'success'); 186 } 187 } 188 189 function renderTable(games) { 190 gamesBody.innerHTML = '';
191 games.forEach(game => { 192 const tr = document.createElement('tr'); 193 tr.id = `row-${game.appid}`; 194 tr.innerHTML = getRowHTML(game); 195 gamesBody.appendChild(tr); 196 }); 197 } 198 199 function getRowHTML(game) { 200 const iconUrl = game.img_icon_url 201 ? `http://media.steampowered.com/steamcommunity/public/images/apps/${game.appid}/${game.img_icon_url}.jpg` 202 : 'https://via.placeholder.com/32'; 203 204 const playtimeHours = ((game.playtime_forever || 0) / 60).toFixed(1); 205 206 let main = '<div class="loading-pulse"></div>'; 207 let extra = '<div class="loading-pulse"></div>'; 208 let compl = '<div class="loading-pulse"></div>'; 209 210 if (game.hltb) { 211 if (game.hltb.not_found) { 212 main = '-'; 213 extra = '-'; 214 compl = '-'; 215 } else { 216 main = formatTime(game.hltb.main_story); 217 extra = formatTime(game.hltb.main_extra); 218 compl = formatTime(game.hltb.completionist); 219 } 220 } 221 222 return ` 223 <td class="game-cell"> 224 <img src="${iconUrl}" class="game-icon" alt=""> 225 <div class="game-info"> 226 <span>${escapeHtml(game.name)}</span> 227 </div> 228 </td> 229 <td>${playtimeHours}h</td> 230 <td>${main}</td> 231 <td>${extra}</td> 232 <td>${compl}</td> 233 `; 234 } 235 236 function formatTime(val) { 237 if (!val) return '-'; 238 return `${val}h`; 239 } 240 241 function escapeHtml(text) { 242 if (!text) return ''; 243 return text.replace(/&/g, '&') 244 .replace(/</g, '<') 245 .replace(/>/g, '>') 246 .replace(/"/g, '"') 247 .replace(/'/g, '''); 248 } 249 250 function showStatus(msg, type) { 251 statusMsg.setAttribute('role', type === 'error' ? 'alert' : 'status'); 252 statusMsg.setAttribute('aria-live', type === 'error' ? 'assertive' : 'polite'); 253 statusMsg.textContent = msg; 254 statusMsg.style.color = type === 'error' ? '#ef4444' : '#38bdf8';
255 } 256 257 function getFilteredGames() { 258 const term = searchFilter.value.trim().toLowerCase(); 259 const unplayedOnly = unplayedOnlyFilter.checked; 260 const hideMissingHltb = hideMissingHltbFilter.checked; 261 const maxHoursEnabled = maxHoursEnabledFilter.checked; 262 const maxHours = Number(maxHoursFilter.value); 263 264 return gamesData.filter(game => { 265 const playtimeHours = (game.playtime_forever || 0) / 60; 266 267 if (term && !game.name.toLowerCase().includes(term)) { 268 return false; 269 } 270 271 if (unplayedOnly && playtimeHours > 0) { 272 return false; 273 } 274 275 if (hideMissingHltb && (!game.hltb || game.hltb.not_found)) { 276 return false; 277 } 278 279 if (maxHoursEnabled) { 280 if (!Number.isFinite(maxHours) || maxHours < 0) { 281 return false; 282 } 283 284 if (playtimeHours >= maxHours) { 285 return false; 286 } 287 } 288 289 return true; 290 }); 291 } 292 293 function updateStats() { 294 const visibleCount = getFilteredGames().length; 295 statsDisplay.textContent = `Showing ${visibleCount} of ${gamesData.length} games`; 296 } 297 298 function applyCurrentView() { 299 sortData(currentSortKey, currentSortDir, false); 300 renderTable(getFilteredGames()); 301 updateStats(); 302 } 303 304 searchFilter.addEventListener('input', () => { 305 localStorage.setItem('searchFilter', searchFilter.value); 306 applyCurrentView(); 307 }); 308 309 unplayedOnlyFilter.addEventListener('change', () => { 310 localStorage.setItem('unplayedOnly', String(unplayedOnlyFilter.checked)); 311 applyCurrentView(); 312 }); 313 314 hideMissingHltbFilter.addEventListener('change', () => { 315 localStorage.setItem('hideMissingHltb', String(hideMissingHltbFilter.checked)); 316 applyCurrentView(); 317 }); 318 319 maxHoursEnabledFilter.addEventListener('change', () => { 320 localStorage.setItem('maxHoursEnabled', String(maxHoursEnabledFilter.checked)); 321 maxHoursFilter.disabled = !maxHoursEnabledFilter.checked; 322 applyCurrentView(); 323 }); 324 325 maxHoursFilter.addEventListener('input', () => { 326 localStorage.setItem('maxHoursFilter', maxHoursFilter.value); 327 applyCurrentView(); 328 }); 329 330 document.querySelectorAll('.sort-btn').forEach(button => { 331 button.addEventListener('click', () => { 332 const sortKey = button.dataset.sort; 333 const header = button.closest('th'); 334 const currentDir = header.dataset.dir === 'asc' ? 'desc' : 'asc'; 335 336 document.querySelectorAll('th.sortable').forEach(h => { 337 h.dataset.dir = ''; 338 h.setAttribute('aria-sort', 'none'); 339 h.querySelector('.arrow').textContent = ''; 340 }); 341 342 header.dataset.dir = currentDir; 343 header.setAttribute('aria-sort', currentDir === 'asc' ? 'ascending' : 'descending'); 344 header.querySelector('.arrow').innerHTML = currentDir === 'asc' ? '▲' : '▼'; 345 346 sortData(sortKey, currentDir); 347 }); 348 }); 349 350 const defaultSortButton = document.querySelector('.sort-btn[data-sort="playtime"]'); 351 const defaultSortHeader = defaultSortButton ? defaultSortButton.closest('th') : null; 352 if (defaultSortHeader) { 353 defaultSortHeader.dataset.dir = currentSortDir; 354 defaultSortHeader.setAttribute('aria-sort', 'descending'); 355 defaultSortHeader.querySelector('.arrow').innerHTML = '▼'; 356 } 357 358 function sortData(key, dir, rerender = true) { 359 currentSortKey = key; 360 currentSortDir = dir; 361 362 gamesData.sort((a, b) => { 363 let valA; 364 let valB; 365 366 if (key === 'playtime') { 367 valA = a.playtime_forever; 368 valB = b.playtime_forever; 369 } else { 370 const mapKey = key === 'main' ? 'main_story' : (key === 'extra' ? 'main_extra' : 'completionist'); 371 372 valA = a.hltb && !a.hltb.not_found ? parseTime(a.hltb[mapKey]) : -1; 373 valB = b.hltb && !b.hltb.not_found ? parseTime(b.hltb[mapKey]) : -1; 374 } 375 376 if (valA < valB) return dir === 'asc' ? -1 : 1; 377 if (valA > valB) return dir === 'asc' ? 1 : -1; 378 return 0; 379 }); 380 381 if (rerender) { 382 renderTable(getFilteredGames()); 383 updateStats(); 384 } 385 } 386 387 function parseTime(val) { 388 return Number(val) || 0; 389 } 390 391 function loadHltbCache() { 392 try { 393 const raw = localStorage.getItem(HLTB_CACHE_KEY); 394 if (!raw) { 395 return {}; 396 } 397 398 const parsed = JSON.parse(raw); 399 return parsed && typeof parsed === 'object' ? parsed : {}; 400 } catch (error) { 401 console.warn('Failed to load HLTB cache from localStorage', error); 402 return {}; 403 } 404 } 405 406 function saveHltbCache() { 407 try {
408 hltbCache = pruneHltbCache(hltbCache); 409 localStorage.setItem(HLTB_CACHE_KEY, JSON.stringify(hltbCache)); 410 } catch (error) { 411 console.warn('Failed to save HLTB cache to localStorage', error); 412 } 413 } 414 415 function pruneHltbCache(cache) { 416 const entries = Object.entries(cache); 417 if (entries.length <= HLTB_CACHE_LIMIT) { 418 return cache; 419 } 420 421 entries.sort((a, b) => (a[1].cachedAt || 0) - (b[1].cachedAt || 0)); 422 const trimmedEntries = entries.slice(entries.length - HLTB_CACHE_LIMIT); 423 return Object.fromEntries(trimmedEntries); 424 } 425 426 function getCachedGameTime(gameName) { 427 if (!gameName) { 428 return null; 429 } 430 431 const entry = hltbCache[gameName]; 432 return entry ? entry.data : null; 433 } 434 435 function saveGameTimeToLocalCache(gameName, gameTimeData) { 436 if (!gameName || !gameTimeData) { 437 return; 438 } 439 440 hltbCache[gameName] = { 441 cachedAt: Date.now(), 442 data: gameTimeData 443 }; 444 saveHltbCache(); 445 } 446 447 function hydrateGamesFromLocalCache(games) { 448 games.forEach(game => { 449 if (game.hltb) { 450 return; 451 } 452 453 const cachedGameTime = getCachedGameTime(game.name); 454 if (cachedGameTime) { 455 game.hltb = cachedGameTime; 456 } 457 }); 458 } 459 460 function persistServerCache(games) { 461 let hasUpdates = false; 462 463 games.forEach(game => { 464 if (!game.hltb) { 465 return; 466 } 467 468 hltbCache[game.name] = { 469 cachedAt: Date.now(), 470 data: game.hltb 471 }; 472 hasUpdates = true; 473 }); 474 475 if (hasUpdates) { 476 saveHltbCache(); 477 } 478 } 479});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.