From 08eb8365fe1e7499c9673ee08f4252b0f85e49cf Mon Sep 17 00:00:00 2001 From: mderouet Date: Fri, 16 Jan 2026 20:10:55 +0100 Subject: [PATCH 1/2] feat: add multi-account management, persistent caching, and enhanced stats display New Features: - Multi-account management: save/switch between accounts (Tab/Shift+Tab) - Persistent caching: per-account cache in ~/.lol-cli/ for matches, ranks, masteries - Ranked info display: Solo/Duo and Flex ranks with LP, tier, win rate - Champion mastery display: top 5 champions with levels and points - Champion stats panel: recent game performance by champion with win rate/KDA - Queue type detection: auto-detects Ranked/Flex/ARAM/Normal/Arena - Region auto-detection: detects region from Riot ID tagline (e.g., #NA1) - Last search persistence: remembers last searched account Bug Fixes: - Add retry with exponential backoff for rate-limited API calls (429) - Fix API error handling to preserve original error information - Handle 404 gracefully for live game endpoint (not in game) - Fix rank calculation for edge cases (score <= 0) - Fix .env path detection for binary builds Code Improvements: - Refactored API functions to use consistent retryWithBackoff() wrapper - Multi-panel results layout with collapsible sections - Generic readCacheFile() helper with validation - PUUID validation to prevent directory traversal - Dynamic footer hints based on context - Progress bar message-only updates --- .gitignore | 3 + src/api.js | 123 +++++++----- src/index.js | 230 +++++++++++++++++++--- src/loadingTui.js | 7 +- src/resultsTui.js | 489 ++++++++++++++++++++++++++++++++++++++-------- src/tui.js | 57 +++++- src/ui.js | 108 ++++++++-- src/utils.js | 254 ++++++++++++++++++++++++ 8 files changed, 1098 insertions(+), 173 deletions(-) diff --git a/.gitignore b/.gitignore index 4ba5a74..759e39e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ node_modules .env .DS_Store lol-cli +lol-cli-macos-arm64 +lol-cli-win-x64.exe +.last_search.json diff --git a/src/api.js b/src/api.js index 9e8b82a..8c41660 100644 --- a/src/api.js +++ b/src/api.js @@ -14,9 +14,34 @@ api.interceptors.request.use((config) => { return config; }); +const retryWithBackoff = async (fn, maxRetries = 5, baseDelay = 1000) => { + let lastError; + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error; + // Only retry on rate limit (429) + if (error.response?.status !== 429) { + handleApiError(error); // Transform and re-throw non-429 errors + } + // Use Retry-After header if present, otherwise exponential backoff + const retryAfter = error.response?.headers?.['retry-after']; + const delay = retryAfter + ? parseInt(retryAfter, 10) * 1000 + : baseDelay * Math.pow(2, attempt); + await new Promise(res => setTimeout(res, delay)); + } + } + throw lastError || new Error('Max retries exceeded'); +}; + const handleApiError = (error) => { if (error.response) { const { status, data } = error.response; + if (status === 429) { + throw error; // Re-throw original error for retry logic + } if (status === 403) { throw new Error('Forbidden: Invalid API Key or insufficient permissions.'); } @@ -24,9 +49,9 @@ const handleApiError = (error) => { throw new Error('Not Found: The requested resource was not found.'); } throw new Error(data.status ? data.status.message : 'An API error occurred.'); - } else { - throw new Error('An unexpected error occurred.'); } + // Re-throw network errors (no response) to preserve original error info + throw error; }; const getRegionalPlatform = (region) => { @@ -43,14 +68,12 @@ const getRegionalPlatform = (region) => { }; const getSummonerByPuuid = async (region, puuid) => { - try { + return retryWithBackoff(async () => { const response = await api.get( `https://${region}.api.riotgames.com/lol/summoner/v4/summoners/by-puuid/${puuid}` ); return response.data; - } catch (error) { - handleApiError(error); - } + }); }; const getSummonerDataByRiotId = async (region, riotId) => { @@ -64,15 +87,12 @@ const getSummonerDataByRiotId = async (region, riotId) => { const accountRegion = getRegionalPlatform(region); const accountApiUrl = `https://${accountRegion}.api.riotgames.com`; - let puuid; - try { - const response = await api.get( + const response = await retryWithBackoff(async () => { + return api.get( `${accountApiUrl}/riot/account/v1/accounts/by-riot-id/${encodeURIComponent(gameName)}/${tagLine}` ); - puuid = response.data.puuid; - } catch (error) { - handleApiError(error); - } + }); + const puuid = response.data.puuid; if (!puuid) { throw new Error('Could not retrieve PUUID for the given Riot ID.'); @@ -84,72 +104,74 @@ const getSummonerDataByRiotId = async (region, riotId) => { const getMatchHistory = async (region, puuid) => { - try { + return retryWithBackoff(async () => { const response = await api.get( `https://${getRegionalPlatform(region)}.api.riotgames.com/lol/match/v5/matches/by-puuid/${puuid}/ids?start=0&count=10` ); return response.data; - } catch (error) { - handleApiError(error); - } + }); }; const getMatchDetails = async (region, matchId) => { - try { + return retryWithBackoff(async () => { const response = await api.get( `https://${getRegionalPlatform(region)}.api.riotgames.com/lol/match/v5/matches/${matchId}` ); return response.data; - } catch (error) { - handleApiError(error); - } + }); }; const getMatchTimeline = async (region, matchId) => { - try { + return retryWithBackoff(async () => { const response = await api.get( `https://${getRegionalPlatform(region)}.api.riotgames.com/lol/match/v5/matches/${matchId}/timeline` ); return response.data; - } catch (error) { - handleApiError(error); - } + }); }; const getRankedData = async (region, puuid) => { - try { - const response = await api.get( - `https://${region}.api.riotgames.com/lol/league/v4/entries/by-puuid/${puuid}` - ); - return response.data; - } catch (error) { - handleApiError(error); - } + return retryWithBackoff(async () => { + const response = await api.get( + `https://${region}.api.riotgames.com/lol/league/v4/entries/by-puuid/${puuid}` + ); + return response.data; + }); }; const getChampionMastery = async (region, puuid, championId) => { - try { - const response = await api.get( - `https://${region}.api.riotgames.com/lol/champion-mastery/v4/champion-masteries/by-puuid/${puuid}/by-champion/${championId}` - ); - return response.data; - } catch (error) { - handleApiError(error); - } + return retryWithBackoff(async () => { + const response = await api.get( + `https://${region}.api.riotgames.com/lol/champion-mastery/v4/champion-masteries/by-puuid/${puuid}/by-champion/${championId}` + ); + return response.data; + }); +}; + +const getTopChampionMasteries = async (region, puuid, count = 5) => { + return retryWithBackoff(async () => { + const response = await api.get( + `https://${region}.api.riotgames.com/lol/champion-mastery/v4/champion-masteries/by-puuid/${puuid}/top?count=${count}` + ); + return response.data; + }); }; const getLiveGame = async (region, encryptedPUUID) => { + return retryWithBackoff(async () => { try { - const response = await api.get( - `https://${region}.api.riotgames.com/lol/spectator/v5/active-games/by-summoner/${encryptedPUUID}` - ); - return response.data; + const response = await api.get( + `https://${region}.api.riotgames.com/lol/spectator/v5/active-games/by-summoner/${encryptedPUUID}` + ); + return response.data; } catch (error) { - if (error.response && error.response.status === 404) { - return null; // Not in a game, return null instead of throwing - } - handleApiError(error); // For all other errors, use the handler + // 404 means no active game - return null instead of throwing + if (error.response?.status === 404) { + return null; + } + throw error; // Let retryWithBackoff handle other errors } + }); }; module.exports = { @@ -159,5 +181,6 @@ module.exports = { getMatchTimeline, getRankedData, getChampionMastery, + getTopChampionMasteries, getLiveGame, -}; \ No newline at end of file +}; diff --git a/src/index.js b/src/index.js index 569bbc6..f88530c 100755 --- a/src/index.js +++ b/src/index.js @@ -1,10 +1,14 @@ #!/usr/bin/env node const path = require('path'); -require('dotenv').config({ path: path.resolve(path.dirname(process.execPath), '.env') }); +const envPath = process.pkg + ? path.resolve(path.dirname(process.execPath), '.env') + : path.resolve(__dirname, '..', '.env'); +require('dotenv').config({ path: envPath }); const { Command } = require('commander'); const blessed = require('blessed'); -const { getSummonerDataByRiotId, getMatchHistory, getMatchDetails, getMatchTimeline } = require('./api'); +const { getSummonerDataByRiotId, getMatchHistory, getMatchDetails, getMatchTimeline, getRankedData, getTopChampionMasteries } = require('./api'); const { createSearchScreen } = require('./tui'); +const { getLastSearch, saveLastSearch, getMatchCache, saveMatchCache, getMonitoredAccounts, addMonitoredAccount, removeMonitoredAccount, setActiveAccountIndex, getAccountDataCache, saveAccountDataCache } = require('./utils'); const { createResultsScreen } = require('./resultsTui'); const { createLoadingScreen } = require('./loadingTui'); @@ -21,24 +25,197 @@ program.parse(process.argv); const options = program.opts(); if (!program.args.length) { + // Helper function to load account data from cache (for instant switching) + const loadAccountDataFromCache = (puuid) => { + const accountCache = getAccountDataCache(puuid); + if (!accountCache) return null; + + const matchCache = getMatchCache(puuid); + const matches = matchCache.matches.map(m => ({ + details: m.details, + timeline: m.timeline, + })); + + return { + summoner: accountCache.summoner, + matches, + rankedData: accountCache.rankedData || [], + topMasteries: accountCache.topMasteries || [], + }; + }; + + // Helper function to load account data (full API fetch) + const loadAccountData = async (region, riotId, screen, loading) => { + loading.update(5, 'Fetching summoner data...'); + const summoner = await getSummonerDataByRiotId(region, riotId); + + loading.update(10, 'Fetching player data...'); + const [rankedData, topMasteries, matchHistory] = await Promise.all([ + getRankedData(region, summoner.puuid).catch(() => []), + getTopChampionMasteries(region, summoner.puuid, 5).catch(() => []), + getMatchHistory(region, summoner.puuid), + ]); + + const matchCache = getMatchCache(summoner.puuid); + const cachedMatchIds = new Set(matchCache.matches.map(m => m.matchId)); + const newMatchIds = matchHistory.filter(id => !cachedMatchIds.has(id)); + const totalNewMatches = newMatchIds.length; + + const newMatches = []; + if (totalNewMatches === 0) { + loading.update(100, 'All matches cached, loading from disk...'); + } else { + loading.update(15, `Found ${totalNewMatches} new match(es) to fetch...`); + for (let i = 0; i < totalNewMatches; i++) { + const matchId = newMatchIds[i]; + loading.update(null, `Fetching new match ${i + 1}/${totalNewMatches}...`); + + const [matchDetails, matchTimeline] = await Promise.all([ + getMatchDetails(region, matchId), + getMatchTimeline(region, matchId), + ]); + newMatches.push({ + matchId, + gameCreation: matchDetails.info.gameCreation, + details: matchDetails, + timeline: matchTimeline + }); + + const progress = 15 + (((i + 1) / totalNewMatches) * 85); + loading.update(progress); + } + } + + const allCachedMatches = [...newMatches, ...matchCache.matches]; + allCachedMatches.sort((a, b) => b.gameCreation - a.gameCreation); + + matchCache.matches = allCachedMatches; + matchCache.region = region; + saveMatchCache(summoner.puuid, matchCache); + + const matches = allCachedMatches.map(m => ({ + details: m.details, + timeline: m.timeline, + })); + + // Save account data to cache for instant switching + saveAccountDataCache(summoner.puuid, { summoner, rankedData, topMasteries }); + + loading.update(100, 'All data loaded.'); + + return { summoner, matches, rankedData, topMasteries }; + }; + + // Helper to handle results from createResultsScreen + const handleScreenResult = (result) => { + if (!result) { + return { action: 'EXIT' }; + } + if (result === 'BACK') { + return { action: 'BACK' }; + } + if (result.action === 'SWITCH_ACCOUNT') { + const updatedMonitored = setActiveAccountIndex(result.accountIndex); + const account = updatedMonitored.accounts[result.accountIndex]; + if (!account) { + return { action: 'BACK' }; + } + return { + action: 'SWITCH', + accountIndex: result.accountIndex, + region: account.region, + riotId: account.riotId, + puuid: account.puuid, + }; + } + if (result.action === 'REMOVE_ACCOUNT') { + const updatedMonitored = removeMonitoredAccount(result.puuid); + if (updatedMonitored.accounts.length === 0) { + return { action: 'BACK' }; + } + const account = updatedMonitored.accounts[updatedMonitored.activeIndex]; + return { + action: 'SWITCH', + accountIndex: updatedMonitored.activeIndex, + region: account.region, + riotId: account.riotId, + puuid: account.puuid, + }; + } + return { action: 'EXIT' }; + }; + const main = async () => { let region, riotId; let cliOptions = { ...options }; + let skipSearch = false; + let currentAccountIndex = 0; + let switchToPuuid = null; // Track puuid when switching for cache-first loading + + // Check for monitored accounts on startup (skip search if available and no CLI args) + const initialMonitored = getMonitoredAccounts(); + if (!cliOptions.region && !cliOptions.riotId && initialMonitored.accounts.length > 0) { + skipSearch = true; + currentAccountIndex = initialMonitored.activeIndex; + const account = initialMonitored.accounts[currentAccountIndex]; + region = account.region; + riotId = account.riotId; + switchToPuuid = account.puuid; + } while (true) { - if (!cliOptions.region || !cliOptions.riotId) { - const searchData = await createSearchScreen(); + // Determine if we need to show search screen + if (!skipSearch && !cliOptions.region && !cliOptions.riotId) { + const lastSearch = getLastSearch(); + const monitoredAccounts = getMonitoredAccounts(); + const searchData = await createSearchScreen(lastSearch || {}, { accountCount: monitoredAccounts.accounts.length }); if (!searchData || searchData === 'EXIT' || !searchData.riotId) { console.log('Exiting.'); break; } region = searchData.region; riotId = searchData.riotId; - } else { + saveLastSearch(riotId, region); + } else if (cliOptions.region && cliOptions.riotId) { region = cliOptions.region; riotId = cliOptions.riotId; cliOptions = {}; } + // else: skipSearch is true, region/riotId already set + + skipSearch = false; // Reset for next iteration + + // Try cache-first loading when switching accounts + if (switchToPuuid) { + const cachedData = loadAccountDataFromCache(switchToPuuid); + if (cachedData && cachedData.matches.length > 0) { + // Use cached data - instant switch, no loading screen + const monitoredData = getMonitoredAccounts(); + currentAccountIndex = monitoredData.activeIndex; + + const result = await createResultsScreen(cachedData.summoner, cachedData.matches, region, cachedData.rankedData, cachedData.topMasteries, { + monitoredAccounts: monitoredData, + currentAccountIndex, + }); + + const handled = handleScreenResult(result); + if (handled.action === 'EXIT') { + break; + } else if (handled.action === 'BACK') { + switchToPuuid = null; + continue; + } else if (handled.action === 'SWITCH') { + currentAccountIndex = handled.accountIndex; + region = handled.region; + riotId = handled.riotId; + switchToPuuid = handled.puuid; + skipSearch = true; + continue; + } + } + // No cache or empty matches - fall through to full load + switchToPuuid = null; + } const screen = blessed.screen({ smartCSR: true, @@ -49,31 +226,32 @@ if (!program.args.length) { screen.render(); try { - loading.update(10, 'Fetching summoner data...'); - const summoner = await getSummonerDataByRiotId(region, riotId); - - loading.update(30, 'Fetching match history...'); - const matchHistory = await getMatchHistory(region, summoner.puuid); - - const matches = []; - for (let i = 0; i < matchHistory.length; i++) { - const matchId = matchHistory[i]; - const progress = 30 + (i / matchHistory.length) * 60; - loading.update(progress, `Fetching details for match ${i + 1}/${matchHistory.length}...`); - - const matchDetails = await getMatchDetails(region, matchId); - const matchTimeline = await getMatchTimeline(region, matchId); - matches.push({ details: matchDetails, timeline: matchTimeline }); - } + const { summoner, matches, rankedData, topMasteries } = await loadAccountData(region, riotId, screen, loading); + + // Auto-save to monitored accounts + const monitoredData = addMonitoredAccount(summoner, region, riotId); + currentAccountIndex = monitoredData.activeIndex; - loading.update(100, 'All data loaded.'); loading.destroy(); - - screen.destroy(); // Destroy the screen used for loading + screen.destroy(); + + const result = await createResultsScreen(summoner, matches, region, rankedData, topMasteries, { + monitoredAccounts: monitoredData, + currentAccountIndex, + }); - const result = await createResultsScreen(summoner, matches, region); - if (result !== 'BACK') { + const handled = handleScreenResult(result); + if (handled.action === 'EXIT') { break; + } else if (handled.action === 'BACK') { + continue; + } else if (handled.action === 'SWITCH') { + currentAccountIndex = handled.accountIndex; + region = handled.region; + riotId = handled.riotId; + switchToPuuid = handled.puuid; + skipSearch = true; + continue; } } catch (error) { screen.destroy(); diff --git a/src/loadingTui.js b/src/loadingTui.js index 1f17774..2da1e5b 100644 --- a/src/loadingTui.js +++ b/src/loadingTui.js @@ -80,8 +80,13 @@ const createLoadingScreen = (screen) => { }); return { + // Update progress bar and/or message. + // - progress: number (0-100) to set progress, or null/undefined to skip progress update + // - msg: optional string to update the loading message update: (progress, msg) => { - progressBar.setProgress(progress); + if (progress !== null && progress !== undefined) { + progressBar.setProgress(progress); + } if (msg) { message.setContent(msg); } diff --git a/src/resultsTui.js b/src/resultsTui.js index bfb604c..fde19a9 100644 --- a/src/resultsTui.js +++ b/src/resultsTui.js @@ -1,7 +1,7 @@ const blessed = require('blessed'); const { getRankedData, getChampionMastery, getLiveGame } = require('./api'); -const { formatSummary, formatMatchDetails } = require('./ui'); -const { getItemData, getChampionData, getSummonerSpellData, getRuneData, getQueueData } = require('./utils'); +const { formatSummary, formatMatchDetails, formatRankedInfo, formatChampionStats, formatMasteryDisplay, colorizeRank } = require('./ui'); +const { getItemData, getChampionData, getSummonerSpellData, getRuneData, getQueueData, getParticipantRankCache, saveParticipantRankCache } = require('./utils'); const { createMasteryScreen } = require('./masteryTui'); const { createTimelineScreen } = require('./timelineTui'); @@ -27,7 +27,7 @@ const rankToScore = (rank) => { }; const scoreToRank = (score) => { - if (score === 0) return 'Unranked'; + if (score <= 0) return 'Unranked'; const tiers = ['IRON', 'BRONZE', 'SILVER', 'GOLD', 'PLATINUM', 'EMERALD', 'DIAMOND', 'MASTER', 'GRANDMASTER', 'CHALLENGER']; const divisions = ['IV', 'III', 'II', 'I']; const tierIndex = Math.floor(score / 4); @@ -38,33 +38,18 @@ const scoreToRank = (score) => { return `${tier} ${division}`; }; -const colorizeRank = (rank) => { - if (!rank) return `{white-fg}Unranked{/white-fg}`; - const tier = rank.split(' ')[0]; - const rankColors = { - 'CHALLENGER': colors.yellow, - 'GRANDMASTER': colors.red, - 'MASTER': colors.pink, - 'DIAMOND': colors.cyan, - 'EMERALD': colors.green, - 'PLATINUM': '#8be9fd', - 'GOLD': colors.yellow, - 'SILVER': colors.fg, - 'BRONZE': '#cd7f32', - 'IRON': '#a9a9a9', - 'Unranked': colors.fg, - }; - const color = rankColors[tier] || colors.fg; - return `{${color}-fg}${rank}{/${color}-fg}`; -}; - -const createResultsScreen = async (summoner, matches, region) => { +const createResultsScreen = async (summoner, matches, region, rankedData, topMasteries, options = {}) => { + const { monitoredAccounts = null, currentAccountIndex = 0 } = options; + const totalAccounts = monitoredAccounts?.accounts?.length || 1; + const hasMultipleAccounts = totalAccounts > 1; const itemData = await getItemData(); const spellData = await getSummonerSpellData(); const runeData = await getRuneData(); const queueData = await getQueueData(); + const championData = await getChampionData(); const itemMap = itemData.data; const spellMap = new Map(Object.values(spellData.data).map(s => [s.key, s.name])); + const championMap = new Map(Object.values(championData.data).map(c => [c.key, c.name])); const runeMap = new Map(); runeData.forEach(tree => { runeMap.set(tree.id, tree.name); @@ -76,10 +61,127 @@ const createResultsScreen = async (summoner, matches, region) => { }); const queueMap = new Map(queueData.map(q => [q.queueId, q.description])); + const getShortQueueName = (desc, queueId) => { + if (queueId === 1700) return 'Arena'; + if (desc.includes('Ranked Solo')) return 'Ranked'; + if (desc.includes('Ranked Flex')) return 'Flex'; + if (desc.includes('ARAM')) return 'ARAM'; + if (desc.includes('Draft')) return 'Normal'; + if (desc.includes('Blind')) return 'Blind'; + return 'Other'; + }; + + // Load participant rank cache from disk + const diskRankCache = getParticipantRankCache(summoner.puuid); + const RANK_TTL = 60 * 60 * 1000; // 1 hour in milliseconds + + const playerRankCache = {}; // Cache by PUUID (persists across matches) + const matchRankCache = {}; // Cache computed match averages + + // Pre-populate playerRankCache from disk cache + Object.entries(diskRankCache.ranks).forEach(([puuid, data]) => { + playerRankCache[puuid] = data.rank; + }); - const rankCache = {}; // Cache for player ranks and average rank + // Helper to compute match average from cached ranks + const computeMatchAverage = (matchIndex) => { + const participants = matches[matchIndex].details.info.participants; + const ranks = {}; + let totalScore = 0; + let rankedCount = 0; + let allCached = true; + + for (const p of participants) { + if (playerRankCache[p.puuid] !== undefined) { + ranks[p.puuid] = playerRankCache[p.puuid]; + if (ranks[p.puuid] !== 'N/A') { + totalScore += rankToScore(ranks[p.puuid]); + rankedCount++; + } + } else { + allCached = false; + } + } + + if (allCached) { + matchRankCache[matchIndex] = { + players: ranks, + average: rankedCount > 0 ? scoreToRank(totalScore / rankedCount) : 'N/A', + }; + return true; + } + return false; + }; + + // Pre-compute match averages from cached data + for (let i = 0; i < matches.length; i++) { + computeMatchAverage(i); + } + + const fetchMatchRanks = async (matchIndex, skipDelay = false) => { + if (matchRankCache[matchIndex]) return; // Already fully cached + + const ranks = {}; + let totalScore = 0; + let rankedCount = 0; + const participants = matches[matchIndex].details.info.participants; + const delay = ms => new Promise(res => setTimeout(res, ms)); + + for (const p of participants) { + // Check PUUID cache first + if (playerRankCache[p.puuid] !== undefined) { + ranks[p.puuid] = playerRankCache[p.puuid]; + if (ranks[p.puuid] !== 'N/A') { + totalScore += rankToScore(ranks[p.puuid]); + rankedCount++; + } + continue; + } + + try { + const rankData = await getRankedData(region, p.puuid); + const soloQueue = rankData.find(q => q.queueType === 'RANKED_SOLO_5x5'); + const rankString = soloQueue ? `${soloQueue.tier} ${soloQueue.rank}` : 'Unranked'; + ranks[p.puuid] = rankString; + playerRankCache[p.puuid] = rankString; // Cache by PUUID + // Update disk cache + diskRankCache.ranks[p.puuid] = { rank: rankString, fetchedAt: new Date().toISOString() }; + totalScore += rankToScore(rankString); + rankedCount++; + } catch (error) { + ranks[p.puuid] = 'N/A'; + playerRankCache[p.puuid] = 'N/A'; // Cache failures too + diskRankCache.ranks[p.puuid] = { rank: 'N/A', fetchedAt: new Date().toISOString() }; + } + if (!skipDelay) await delay(100); + } + + matchRankCache[matchIndex] = { + players: ranks, + average: rankedCount > 0 ? scoreToRank(totalScore / rankedCount) : 'N/A', + }; + }; + + // Find stale ranks (older than TTL) that need refresh + const getStalePlayerPuuids = () => { + const now = Date.now(); + const stalePuuids = new Set(); + + for (const match of matches) { + for (const p of match.details.info.participants) { + const cached = diskRankCache.ranks[p.puuid]; + const fetchTime = cached ? new Date(cached.fetchedAt).getTime() : NaN; + if (!cached || isNaN(fetchTime) || (now - fetchTime > RANK_TTL)) { + stalePuuids.add(p.puuid); + } + } + } + return Array.from(stalePuuids); + }; return new Promise((resolve) => { + let isScreenDestroyed = false; + const screen = blessed.screen({ smartCSR: true, title: `Stats for ${summoner.name} [${region}]`, @@ -98,22 +200,52 @@ const createResultsScreen = async (summoner, matches, region) => { blessed.text({ parent: layout, - top: 1, + top: 0, left: 'center', - content: `Summoner: {bold}${summoner.name}{/bold} | Region: {bold}${region}{/bold}`, + content: `Summoner: {bold}${summoner.name}{/bold} [Lvl ${summoner.summonerLevel}] | Region: {bold}${region}{/bold}`, tags: true, style: { fg: colors.cyan, }, }); - const summaryBox = blessed.box({ + // Account list indicator (row 1, right-aligned) + // Format: Accounts: > Name1 (R1) | Name2 (R2) | Name3 (R3) + const formatAccountList = () => { + if (!hasMultipleAccounts) return ''; + const accounts = monitoredAccounts.accounts; + const parts = accounts.map((acc, idx) => { + // Truncate name to 12 chars + let name = acc.riotId.split('#')[0]; + if (name.length > 12) name = name.substring(0, 11) + '~'; + // Shorten region display + const regionShort = acc.region.replace(/1$/, ''); + const marker = idx === currentAccountIndex ? '>' : ' '; + return `${marker}${name} (${regionShort})`; + }); + return `Accounts: ${parts.join(' | ')}`; + }; + + const accountIndicator = blessed.text({ parent: layout, - width: '100%', + top: 1, + right: 1, + content: formatAccountList(), + tags: true, + style: { + fg: colors.yellow, + }, + }); + + // Row 1: Ranked Info (left) | Live Game (right) + const rankedBox = blessed.box({ + parent: layout, + width: '50%', height: 9, - top: 3, + top: 2, left: 0, - content: formatSummary(summoner, matches), + label: ' Ranked ', + content: formatRankedInfo(rankedData), tags: true, border: { type: 'line', @@ -126,9 +258,9 @@ const createResultsScreen = async (summoner, matches, region) => { const liveGameBox = blessed.box({ parent: layout, - width: '30%', + width: '50%', height: 9, - top: 3, + top: 2, right: 0, label: ' Live Game ', tags: true, @@ -141,13 +273,67 @@ const createResultsScreen = async (summoner, matches, region) => { } }); + // Row 2: Summary (left) | Top Masteries (right) + const summaryBox = blessed.box({ + parent: layout, + width: '50%', + height: 9, + top: 11, + left: 0, + label: ` Last ${matches.length} Games `, + content: formatSummary(summoner, matches), + tags: true, + border: { + type: 'line', + fg: colors.purple, + }, + style: { + border: { fg: colors.purple } + } + }); + + const masteryBox = blessed.box({ + parent: layout, + width: '50%', + height: 9, + top: 11, + right: 0, + label: ' Top Champion Masteries ', + content: formatMasteryDisplay(topMasteries, championMap), + tags: true, + border: { + type: 'line', + fg: colors.purple, + }, + style: { + border: { fg: colors.purple } + } + }); + + // Row 3: Champion Stats (full width) + const championStatsBox = blessed.box({ + parent: layout, + width: '100%', + height: 9, + top: 20, + left: 0, + label: ' Champion Stats (Recent Games) ', + content: formatChampionStats(matches, summoner.puuid), + tags: true, + border: { + type: 'line', + fg: colors.purple, + }, + style: { + border: { fg: colors.purple } + } + }); + const checkLiveGame = async () => { const liveGameData = await getLiveGame(region, summoner.puuid); if (liveGameData) { const participant = liveGameData.participants.find(p => p.puuid === summoner.puuid); if (participant) { - const championData = await getChampionData(); - const championMap = new Map(Object.values(championData.data).map(c => [c.key, c.name])); const championName = championMap.get(String(participant.championId)) || 'Unknown'; const spell1 = spellMap.get(String(participant.spell1Id)) || 'N/A'; @@ -198,14 +384,14 @@ const createResultsScreen = async (summoner, matches, region) => { const matchList = blessed.list({ parent: layout, width: '100%', - top: 12, + top: 29, bottom: 1, items: [], border: { type: 'line', fg: colors.purple, }, - label: ' Match History (Select to expand) ', + label: ` Match History - ${matches.length} Games (Select to expand) `, mouse: true, keys: true, vi: true, @@ -217,13 +403,26 @@ const createResultsScreen = async (summoner, matches, region) => { }, }); + const getFooterContent = (isExpanded) => { + let content = ''; + if (hasMultipleAccounts) { + content += 'Tab: Switch | '; + } + content += 'd: Remove | Backspace: Back | m: Mastery'; + if (isExpanded) { + content += ' | t: Timeline'; + } + content += ' | q: Quit'; + return content; + }; + const footer = blessed.box({ parent: layout, width: '100%', height: 1, bottom: 0, left: 'center', - content: 'b: Back | m: Mastery | t: Timeline | q: Quit', + content: getFooterContent(false), tags: true, style: { fg: colors.orange, @@ -233,16 +432,29 @@ const createResultsScreen = async (summoner, matches, region) => { const expanded = {}; let listIndexMap = []; + const setPanelVisibility = (visible) => { + rankedBox.hidden = !visible; + liveGameBox.hidden = !visible; + summaryBox.hidden = !visible; + masteryBox.hidden = !visible; + championStatsBox.hidden = !visible; + matchList.top = visible ? 29 : 2; + matchList.height = visible ? 12 : '90%'; + }; + const updateList = () => { const items = []; listIndexMap = []; let isAnyExpanded = false; matches.forEach((match, index) => { const participant = match.details.info.participants.find(p => p.puuid === summoner.puuid); - const role = (match.details.info.queueId === 1700 - ? 'Arena' + const queueId = match.details.info.queueId; + const queueDesc = queueMap.get(queueId) || ''; + const queueLabel = getShortQueueName(queueDesc, queueId).padEnd(10); + const role = (queueId === 1700 + ? 'Arena' : (participant.teamPosition || participant.individualPosition || '')).padEnd(10); - const avgRank = rankCache[index] ? `Avg Rank: ${colorizeRank(rankCache[index].average)}`.padEnd(40) : ''.padEnd(40); + const avgRank = matchRankCache[index] ? `Avg Rank: ${colorizeRank(matchRankCache[index].average)}`.padEnd(40) : ''.padEnd(40); const gameDate = new Date(match.details.info.gameCreation).toLocaleDateString().padEnd(12); let resultText; if (!participant.win && match.details.info.gameDuration < 300) { @@ -261,13 +473,13 @@ const createResultsScreen = async (summoner, matches, region) => { } else { coloredResult = `{grey-fg}${paddedResult}{/grey-fg}`; } - const summary = `${gameDate}${coloredResult} - ${championName}${role}${kda} ${avgRank}`; + const summary = `${gameDate}${coloredResult} - ${championName}${queueLabel}${role}${kda} ${avgRank}`; items.push(summary); listIndexMap.push(index); if (expanded[index]) { isAnyExpanded = true; - const details = formatMatchDetails(match, summoner, itemMap, spellMap, runeMap, screen.width, rankCache[index].players); + const details = formatMatchDetails(match, summoner, itemMap, spellMap, runeMap, screen.width, matchRankCache[index]?.players); details.split('\n').forEach(line => { items.push(line); listIndexMap.push(null); @@ -276,11 +488,10 @@ const createResultsScreen = async (summoner, matches, region) => { }); matchList.setItems(items); - if (isAnyExpanded) { - footer.setContent('b: Back | m: Mastery | t: Timeline | q: Quit'); - } else { - footer.setContent('b: Back | m: Mastery | q: Quit'); - } + // Hide/show panels based on expansion state + setPanelVisibility(!isAnyExpanded); + + footer.setContent(getFooterContent(isAnyExpanded)); screen.render(); }; @@ -290,13 +501,14 @@ const createResultsScreen = async (summoner, matches, region) => { const currentlySelected = matchList.selected; expanded[matchIndex] = !expanded[matchIndex]; - if (expanded[matchIndex] && !rankCache[matchIndex]) { - const loading = blessed.box({ - parent: screen, - top: 'center', - left: 'center', - height: 1, - width: 20, + if (expanded[matchIndex] && !matchRankCache[matchIndex]) { + // Only show loading if not already preloaded + const loading = blessed.box({ + parent: screen, + top: 'center', + left: 'center', + height: 1, + width: 20, content: 'Loading ranks...', style: { bg: colors.bg, @@ -305,24 +517,11 @@ const createResultsScreen = async (summoner, matches, region) => { }); screen.render(); - const ranks = {}; - let totalScore = 0; - const participants = matches[matchIndex].details.info.participants; - const delay = ms => new Promise(res => setTimeout(res, ms)); - - for (const p of participants) { - const rankData = await getRankedData(region, p.puuid); - const soloQueue = rankData.find(q => q.queueType === 'RANKED_SOLO_5x5'); - const rankString = soloQueue ? `${soloQueue.tier} ${soloQueue.rank}` : 'Unranked'; - ranks[p.puuid] = rankString; - totalScore += rankToScore(rankString); - await delay(100); + try { + await fetchMatchRanks(matchIndex); + } finally { + loading.destroy(); } - rankCache[matchIndex] = { - players: ranks, - average: scoreToRank(totalScore / participants.length), - }; - loading.destroy(); } updateList(); @@ -393,20 +592,156 @@ const createResultsScreen = async (summoner, matches, region) => { - screen.key(['escape', 'q', 'C-c'], () => { + // Save rank cache before exit + const saveCacheAndExit = (result) => { + isScreenDestroyed = true; + saveParticipantRankCache(summoner.puuid, diskRankCache); screen.destroy(); - resolve(); + resolve(result); + }; + + screen.key(['escape', 'q', 'C-c'], () => { + saveCacheAndExit(); + }); + + screen.key(['b', 'backspace', 'delete'], () => { + if (modalOpen) return; + + // Check if any match is expanded + const isAnyExpanded = Object.values(expanded).some(v => v); + + if (isAnyExpanded) { + // Collapse all expanded matches first + Object.keys(expanded).forEach(key => { + expanded[key] = false; + }); + updateList(); + screen.render(); + } else { + // No match expanded, go back to Search screen + saveCacheAndExit('BACK'); + } + }); + + // Tab: Switch to next account + screen.key('tab', () => { + if (modalOpen || !hasMultipleAccounts) return; + const nextIndex = (currentAccountIndex + 1) % totalAccounts; + saveCacheAndExit({ action: 'SWITCH_ACCOUNT', accountIndex: nextIndex }); + }); + + // Shift+Tab: Switch to previous account + screen.key('S-tab', () => { + if (modalOpen || !hasMultipleAccounts) return; + const prevIndex = (currentAccountIndex - 1 + totalAccounts) % totalAccounts; + saveCacheAndExit({ action: 'SWITCH_ACCOUNT', accountIndex: prevIndex }); }); - screen.key('b', () => { + // d: Remove current account from monitored list + screen.key('d', () => { if (modalOpen) return; - screen.destroy(); - resolve('BACK'); + + // Show confirmation dialog + const confirmBox = blessed.box({ + parent: screen, + top: 'center', + left: 'center', + width: 50, + height: 7, + border: { type: 'line', fg: colors.red }, + style: { bg: colors.bg, fg: colors.fg }, + label: ' Remove Account ', + tags: true, + }); + + blessed.text({ + parent: confirmBox, + top: 1, + left: 'center', + content: `Remove {bold}${summoner.name}{/bold} from monitored accounts?`, + tags: true, + style: { fg: colors.fg }, + }); + + blessed.text({ + parent: confirmBox, + top: 3, + left: 'center', + content: '{green-fg}y{/green-fg}: Yes | {red-fg}n{/red-fg}: No', + tags: true, + style: { fg: colors.fg }, + }); + + screen.render(); + + const confirmHandler = (ch, key) => { + if (key.name === 'y') { + screen.unkey(['y', 'n', 'escape'], confirmHandler); + confirmBox.destroy(); + screen.render(); + matchList.focus(); + saveCacheAndExit({ action: 'REMOVE_ACCOUNT', puuid: summoner.puuid }); + } else if (key.name === 'n' || key.name === 'escape') { + screen.unkey(['y', 'n', 'escape'], confirmHandler); + confirmBox.destroy(); + screen.render(); + matchList.focus(); + } + }; + + screen.key(['y', 'n', 'escape'], confirmHandler); }); updateList(); matchList.focus(); screen.render(); + + // Background rank refresh for stale data + const refreshStaleRanks = async () => { + const stalePuuids = getStalePlayerPuuids(); + if (stalePuuids.length === 0) return; + + const delay = ms => new Promise(res => setTimeout(res, ms)); + + for (const puuid of stalePuuids) { + if (isScreenDestroyed) return; + + try { + const rankData = await getRankedData(region, puuid); + const soloQueue = rankData.find(q => q.queueType === 'RANKED_SOLO_5x5'); + const rankString = soloQueue ? `${soloQueue.tier} ${soloQueue.rank}` : 'Unranked'; + playerRankCache[puuid] = rankString; + diskRankCache.ranks[puuid] = { rank: rankString, fetchedAt: new Date().toISOString() }; + } catch (error) { + // On rate limit (429), stop background refresh entirely + if (error.response?.status === 429) { + break; + } + // For other errors, mark as N/A and continue + playerRankCache[puuid] = 'N/A'; + diskRankCache.ranks[puuid] = { rank: 'N/A', fetchedAt: new Date().toISOString() }; + } + + if (isScreenDestroyed) return; + + // After each fetch, try to compute any newly-completable match averages + let anyNewComputed = false; + for (let i = 0; i < matches.length; i++) { + if (!matchRankCache[i] && computeMatchAverage(i)) { + anyNewComputed = true; + } + } + if (anyNewComputed) { + updateList(); + screen.render(); + } + + await delay(200); // 200ms between requests (less aggressive than 100ms) + } + }; + + // Start background refresh after initial render (fire and forget) + refreshStaleRanks().catch(() => {}); }); }; diff --git a/src/tui.js b/src/tui.js index fd9081b..6236b12 100644 --- a/src/tui.js +++ b/src/tui.js @@ -1,6 +1,11 @@ const blessed = require('blessed'); +const { detectRegionFromRiotId } = require('./utils'); + +const REGIONS = ['NA1', 'KR', 'EUW1', 'EUN1', 'JP1', 'BR1', 'LA1', 'LA2', 'OC1', 'RU', 'TR1']; + +const createSearchScreen = (defaults = {}, options = {}) => { + const { accountCount = 0 } = options; -const createSearchScreen = () => { return new Promise((resolve) => { const screen = blessed.screen({ smartCSR: true, @@ -43,6 +48,19 @@ const createSearchScreen = () => { }, }); + // Account indicator (if monitored accounts exist) + if (accountCount > 0) { + blessed.text({ + parent: layout, + top: 1, + right: 1, + content: `${accountCount} account${accountCount > 1 ? 's' : ''} saved`, + style: { + fg: colors.yellow, + }, + }); + } + const form = blessed.form({ parent: layout, width: '80%', @@ -109,7 +127,7 @@ const createSearchScreen = () => { height: 3, top: 4, left: 15, - items: ['NA1', 'KR', 'EUW1', 'EUN1', 'JP1', 'BR1', 'LA1', 'LA2', 'OC1', 'RU', 'TR1'], + items: REGIONS, mouse: true, keys: true, lockKeys: true, @@ -177,6 +195,18 @@ const createSearchScreen = () => { }, }); + // Auto-detect status message + const autoDetectStatus = blessed.text({ + parent: form, + top: 4, + right: 3, + content: '', + tags: true, + style: { + fg: colors.green, + }, + }); + // Footer with key hints blessed.text({ parent: layout, @@ -188,9 +218,32 @@ const createSearchScreen = () => { }, }); + if (defaults.riotId) { + riotIdInput.setValue(defaults.riotId); + } + if (defaults.region) { + const regionIndex = REGIONS.indexOf(defaults.region); + if (regionIndex !== -1) { + regionList.select(regionIndex); + } + } + riotIdInput.focus(); riotIdInput.key('enter', () => { + const riotId = riotIdInput.getValue(); + const detectedRegion = detectRegionFromRiotId(riotId); + if (detectedRegion) { + const regionIndex = REGIONS.indexOf(detectedRegion); + if (regionIndex !== -1) { + regionList.select(regionIndex); + autoDetectStatus.setContent(`(Auto: ${detectedRegion})`); + screen.render(); + } + } else { + autoDetectStatus.setContent(''); + screen.render(); + } regionList.focus(); }); diff --git a/src/ui.js b/src/ui.js index bd2e3c2..56cfed0 100644 --- a/src/ui.js +++ b/src/ui.js @@ -37,31 +37,30 @@ const formatSummary = (summoner, matches) => { const labels = ["Win Rate:", "Overall KDA:", "Most Played:", "Time Played:"]; const longestLabel = Math.max(...labels.map(l => l.length)); - return `{bold}{green-fg}Last ${matches.length} Games Summary:{/green-fg}{/bold}\n` + - `${labels[0].padEnd(longestLabel)} {bold}${winRate}%{/bold}\n` + + return `${labels[0].padEnd(longestLabel)} {bold}${winRate}%{/bold}\n` + `${labels[1].padEnd(longestLabel)} {bold}${overallKda}{/bold}\n` + `${labels[2].padEnd(longestLabel)} {bold}${mostPlayed}{/bold}\n` + `${labels[3].padEnd(longestLabel)} {bold}${timePlayed}{/bold}`; }; const colorizeRank = (rank) => { - if (!rank) return 'Unranked'; + if (!rank) return '{white-fg}Unranked{/white-fg}'; const tier = rank.split(' ')[0]; const colors = { - 'CHALLENGER': 'yellow-fg', - 'GRANDMASTER': 'red-fg', - 'MASTER': 'magenta-fg', - 'DIAMOND': 'cyan-fg', - 'EMERALD': 'green-fg', - 'PLATINUM': 'blue-fg', - 'GOLD': 'yellow-fg', - 'SILVER': 'white-fg', - 'BRONZE': '#CD7F32-fg', - 'IRON': 'grey-fg', - 'Unranked': 'white-fg' + 'CHALLENGER': '#f1fa8c', + 'GRANDMASTER': '#ff5555', + 'MASTER': '#ff79c6', + 'DIAMOND': '#8be9fd', + 'EMERALD': '#50fa7b', + 'PLATINUM': '#8be9fd', + 'GOLD': '#f1fa8c', + 'SILVER': '#f8f8f2', + 'BRONZE': '#cd7f32', + 'IRON': '#a9a9a9', + 'Unranked': '#f8f8f2' }; - const color = colors[tier] || 'white-fg'; - return `{${color}}${rank}{/${color}}`; + const color = colors[tier] || '#f8f8f2'; + return `{${color}-fg}${rank}{/${color}-fg}`; }; const padStringWithTags = (str, length) => { @@ -199,7 +198,82 @@ const createXAxis = (durationMinutes, width) => { }; +const formatRankedInfo = (rankedData) => { + const soloQueue = rankedData?.find(q => q.queueType === 'RANKED_SOLO_5x5'); + const flexQueue = rankedData?.find(q => q.queueType === 'RANKED_FLEX_SR'); + + const formatQueue = (queue, name) => { + if (!queue) { + return `{bold}${name}:{/bold} Unranked`; + } + const division = ['MASTER', 'GRANDMASTER', 'CHALLENGER'].includes(queue.tier) ? '' : ` ${queue.rank}`; + const colorizedRank = colorizeRank(`${queue.tier}${division}`); + const lp = `${queue.leaguePoints} LP`; + const wins = queue.wins; + const losses = queue.losses; + const winRate = (wins + losses) > 0 ? ((wins / (wins + losses)) * 100).toFixed(0) : '0'; + return `{bold}${name}:{/bold} ${colorizedRank} - ${lp}\n${wins}W ${losses}L (${winRate}%)`; + }; + + return formatQueue(soloQueue, 'Solo/Duo') + '\n\n' + formatQueue(flexQueue, 'Flex'); +}; + +const formatChampionStats = (matches, puuid) => { + const champStats = {}; + + matches.forEach(match => { + const participant = match.details.info.participants.find(p => p.puuid === puuid); + if (participant) { + const name = participant.championName; + if (!champStats[name]) { + champStats[name] = { games: 0, wins: 0, kills: 0, deaths: 0, assists: 0 }; + } + champStats[name].games++; + if (participant.win) champStats[name].wins++; + champStats[name].kills += participant.kills; + champStats[name].deaths += participant.deaths; + champStats[name].assists += participant.assists; + } + }); + + const sorted = Object.entries(champStats) + .sort((a, b) => b[1].games - a[1].games) + .slice(0, 5); + + if (sorted.length === 0) return 'No champion data available'; + + const header = 'Champion'.padEnd(14) + 'Games'.padEnd(7) + 'Win%'.padEnd(7) + 'KDA'; + const lines = sorted.map(([name, stats]) => { + const games = stats.games || 1; // Defensive check for division by zero + const winRate = ((stats.wins / games) * 100).toFixed(0) + '%'; + const avgK = (stats.kills / games).toFixed(1); + const avgD = (stats.deaths / games).toFixed(1); + const avgA = (stats.assists / games).toFixed(1); + const winColor = stats.wins / games >= 0.5 ? 'green' : 'red'; + return `${name.padEnd(14)}${String(stats.games).padEnd(7)}{${winColor}-fg}${winRate.padEnd(7)}{/${winColor}-fg}${avgK}/${avgD}/${avgA}`; + }); + + return `{bold}${header}{/bold}\n${lines.join('\n')}`; +}; + +const formatMasteryDisplay = (masteries, championMap) => { + if (!masteries || masteries.length === 0) return 'No mastery data available'; + + const lines = masteries.map((m, index) => { + const champName = championMap?.get(String(m.championId)) || `Champion ${m.championId}`; + const level = `Lvl ${m.championLevel}`; + const points = m.championPoints.toLocaleString() + ' pts'; + return `${(index + 1)}. ${champName.padEnd(14)} ${level.padEnd(6)} ${points}`; + }); + + return lines.join('\n'); +}; + module.exports = { formatSummary, formatMatchDetails, -}; \ No newline at end of file + formatRankedInfo, + formatChampionStats, + formatMasteryDisplay, + colorizeRank, +}; diff --git a/src/utils.js b/src/utils.js index a908d87..c10ffd1 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,12 +1,34 @@ const axios = require('axios'); const fs = require('fs'); const path = require('path'); +const os = require('os'); + +// Generic cache file reader with validation +const readCacheFile = (filePath, defaultValue, validator = () => true) => { + try { + if (fs.existsSync(filePath)) { + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + if (validator(data)) return data; + } + } catch (e) { + // Corrupted cache, return default + } + return typeof defaultValue === 'function' ? defaultValue() : defaultValue; +}; const CACHE_FILE = path.resolve(__dirname, '../.item_cache.json'); + +// Per-account cache directories +const LOL_CLI_DIR = path.join(os.homedir(), '.lol-cli'); +const ACCOUNTS_DIR = path.join(LOL_CLI_DIR, 'accounts'); +const MONITORED_ACCOUNTS_FILE = path.join(LOL_CLI_DIR, 'monitored_accounts.json'); const CHAMPION_CACHE_FILE = path.resolve(__dirname, '../.champion_cache.json'); const SPELL_CACHE_FILE = path.resolve(__dirname, '../.spell_cache.json'); const RUNE_CACHE_FILE = path.resolve(__dirname, '../.rune_cache.json'); const QUEUE_CACHE_FILE = path.resolve(__dirname, '../.queue_cache.json'); +const LAST_SEARCH_FILE = process.pkg + ? path.resolve(path.dirname(process.execPath), '.last_search.json') + : path.resolve(__dirname, '../.last_search.json'); const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours const getLatestVersion = async () => { @@ -84,10 +106,242 @@ const getItemData = async () => { return itemData; }; +const getLastSearch = () => { + try { + if (fs.existsSync(LAST_SEARCH_FILE)) { + return JSON.parse(fs.readFileSync(LAST_SEARCH_FILE, 'utf-8')); + } + } catch (error) { + return null; + } + return null; +}; + +const saveLastSearch = (riotId, region) => { + try { + fs.writeFileSync(LAST_SEARCH_FILE, JSON.stringify({ riotId, region })); + } catch (error) { + // Intentionally silent: saving search history is non-critical functionality. + // Failure should not interrupt the user's workflow. + } +}; + +const detectRegionFromRiotId = (riotId) => { + if (!riotId || !riotId.includes('#')) return null; + + const tagline = riotId.split('#')[1]?.toUpperCase(); + if (!tagline) return null; + + const taglineToRegion = { + 'NA': 'NA1', 'NA1': 'NA1', + 'EUW': 'EUW1', 'EUW1': 'EUW1', + 'EUNE': 'EUN1', 'EUN1': 'EUN1', + 'KR': 'KR', + 'JP': 'JP1', 'JP1': 'JP1', + 'BR': 'BR1', 'BR1': 'BR1', + 'LAN': 'LA1', 'LA1': 'LA1', + 'LAS': 'LA2', 'LA2': 'LA2', + 'OCE': 'OC1', 'OC1': 'OC1', + 'RU': 'RU', + 'TR': 'TR1', 'TR1': 'TR1', + }; + + return taglineToRegion[tagline] || null; +}; + +// Per-account cache functions +const ensureCacheDir = (puuid) => { + // Validate puuid to prevent directory traversal + if (!puuid || typeof puuid !== 'string' || /[\/\\]/.test(puuid)) { + throw new Error('Invalid puuid'); + } + const accountDir = path.join(ACCOUNTS_DIR, puuid); + if (!fs.existsSync(accountDir)) { + fs.mkdirSync(accountDir, { recursive: true }); + } + return accountDir; +}; + +const getMatchCache = (puuid) => { + const cacheFile = path.join(ACCOUNTS_DIR, puuid, 'matches.json'); + return readCacheFile( + cacheFile, + () => ({ version: 1, puuid, region: null, lastUpdated: null, matches: [] }), + (data) => data.version === 1 && data.puuid === puuid + ); +}; + +const saveMatchCache = (puuid, data) => { + try { + ensureCacheDir(puuid); + const cacheFile = path.join(ACCOUNTS_DIR, puuid, 'matches.json'); + data.version = 1; + data.puuid = puuid; + data.lastUpdated = new Date().toISOString(); + fs.writeFileSync(cacheFile, JSON.stringify(data, null, 2)); + } catch (error) { + // Silent fail - caching is non-critical + } +}; + +const getParticipantRankCache = (puuid) => { + const cacheFile = path.join(ACCOUNTS_DIR, puuid, 'participant_ranks.json'); + return readCacheFile( + cacheFile, + { version: 1, ranks: {} }, + (data) => data.version === 1 + ); +}; + +const saveParticipantRankCache = (puuid, data) => { + try { + ensureCacheDir(puuid); + const cacheFile = path.join(ACCOUNTS_DIR, puuid, 'participant_ranks.json'); + data.version = 1; + fs.writeFileSync(cacheFile, JSON.stringify(data, null, 2)); + } catch (error) { + // Silent fail - caching is non-critical + } +}; + +// Account data cache (summoner, ranked, masteries) for instant switching +const getAccountDataCache = (puuid) => { + const cacheFile = path.join(ACCOUNTS_DIR, puuid, 'account_data.json'); + return readCacheFile( + cacheFile, + null, + (data) => data.version === 1 && data.summoner + ); +}; + +const saveAccountDataCache = (puuid, data) => { + try { + ensureCacheDir(puuid); + const cacheFile = path.join(ACCOUNTS_DIR, puuid, 'account_data.json'); + const cacheData = { + version: 1, + summoner: data.summoner, + rankedData: data.rankedData, + topMasteries: data.topMasteries, + lastFetched: new Date().toISOString(), + }; + fs.writeFileSync(cacheFile, JSON.stringify(cacheData, null, 2)); + } catch (error) { + // Silent fail - caching is non-critical + } +}; + +// Monitored accounts functions +const ensureLolCliDir = () => { + if (!fs.existsSync(LOL_CLI_DIR)) { + fs.mkdirSync(LOL_CLI_DIR, { recursive: true }); + } +}; + +const getMonitoredAccounts = () => { + return readCacheFile( + MONITORED_ACCOUNTS_FILE, + { version: 1, activeIndex: 0, accounts: [] }, + (data) => data.version === 1 && Array.isArray(data.accounts) + ); +}; + +const saveMonitoredAccounts = (data) => { + try { + ensureLolCliDir(); + data.version = 1; + fs.writeFileSync(MONITORED_ACCOUNTS_FILE, JSON.stringify(data, null, 2)); + } catch (error) { + // Silent fail - non-critical + } +}; + +const addMonitoredAccount = (summoner, region, riotId) => { + const data = getMonitoredAccounts(); + const existingIndex = data.accounts.findIndex(a => a.puuid === summoner.puuid); + + const accountEntry = { + puuid: summoner.puuid, + riotId: riotId, + region: region, + summonerLevel: summoner.summonerLevel, + name: summoner.name, + addedAt: existingIndex >= 0 ? data.accounts[existingIndex].addedAt : new Date().toISOString(), + lastViewed: new Date().toISOString(), + }; + + if (existingIndex >= 0) { + // Update existing account + data.accounts[existingIndex] = accountEntry; + data.activeIndex = existingIndex; + } else { + // Add new account + data.accounts.push(accountEntry); + data.activeIndex = data.accounts.length - 1; + } + + saveMonitoredAccounts(data); + return data; +}; + +const removeMonitoredAccount = (puuid) => { + const data = getMonitoredAccounts(); + const index = data.accounts.findIndex(a => a.puuid === puuid); + + if (index >= 0) { + data.accounts.splice(index, 1); + // Adjust activeIndex if needed + if (data.accounts.length === 0) { + data.activeIndex = 0; + } else if (data.activeIndex >= data.accounts.length) { + data.activeIndex = data.accounts.length - 1; + } else if (data.activeIndex > index) { + data.activeIndex--; + } + saveMonitoredAccounts(data); + + // Clean up orphaned cache files for this account + try { + const accountDir = path.join(ACCOUNTS_DIR, puuid); + if (fs.existsSync(accountDir)) { + fs.rmSync(accountDir, { recursive: true }); + } + } catch (e) { + // Silent fail - cleanup is non-critical + } + } + + return data; +}; + +const setActiveAccountIndex = (index) => { + const data = getMonitoredAccounts(); + if (index >= 0 && index < data.accounts.length) { + data.activeIndex = index; + data.accounts[index].lastViewed = new Date().toISOString(); + saveMonitoredAccounts(data); + } + return data; +}; + module.exports = { getItemData, getChampionData, getSummonerSpellData, getRuneData, getQueueData, + getLastSearch, + saveLastSearch, + detectRegionFromRiotId, + getMatchCache, + saveMatchCache, + getParticipantRankCache, + saveParticipantRankCache, + getAccountDataCache, + saveAccountDataCache, + getMonitoredAccounts, + saveMonitoredAccounts, + addMonitoredAccount, + removeMonitoredAccount, + setActiveAccountIndex, }; From f8605ba854c33bac62662e815f2d82c43c82d931 Mon Sep 17 00:00:00 2001 From: mderouet Date: Fri, 16 Jan 2026 22:17:31 +0100 Subject: [PATCH 2/2] feat(ui): highlight selected account in cyan for better visibility The account list now uses cyan color for the selected account instead of just a > marker, making it easier to identify the active account. --- src/resultsTui.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/resultsTui.js b/src/resultsTui.js index fde19a9..b6a050b 100644 --- a/src/resultsTui.js +++ b/src/resultsTui.js @@ -220,8 +220,11 @@ const createResultsScreen = async (summoner, matches, region, rankedData, topMas if (name.length > 12) name = name.substring(0, 11) + '~'; // Shorten region display const regionShort = acc.region.replace(/1$/, ''); - const marker = idx === currentAccountIndex ? '>' : ' '; - return `${marker}${name} (${regionShort})`; + const display = `${name} (${regionShort})`; + if (idx === currentAccountIndex) { + return `{cyan-fg}>${display}{/cyan-fg}`; // Selected: cyan with > + } + return ` ${display}`; // Unselected: default yellow, space padding }); return `Accounts: ${parts.join(' | ')}`; };