From 82c4a6f5e8948d5275cb2ef316e7f9b45577dabe Mon Sep 17 00:00:00 2001 From: jxtt-dev Date: Wed, 6 Nov 2024 01:10:18 -0500 Subject: [PATCH] Update to v1.1 --- css/style.css | 122 +++++++++++++++++++++++---- manifest.json | 11 ++- popup/popup.html | 5 +- scripts/content.js | 162 ++++++----------------------------- scripts/generate-html.js | 164 +++++++++++++++++++++++++++++++----- scripts/helpers.js | 45 ++++++++-- scripts/init-html.js | 70 +++++++++++++++ scripts/past-ids.js | 52 ++++++++++++ scripts/process-hours.js | 75 +++++++++++++++++ scripts/process-sessions.js | 156 ++++++++++++++++++++++++++++++++++ static/constants.js | 7 ++ 11 files changed, 681 insertions(+), 188 deletions(-) create mode 100644 scripts/init-html.js create mode 100644 scripts/past-ids.js create mode 100644 scripts/process-hours.js create mode 100644 scripts/process-sessions.js create mode 100644 static/constants.js diff --git a/css/style.css b/css/style.css index 6bdcf88..b94bc34 100644 --- a/css/style.css +++ b/css/style.css @@ -1,31 +1,74 @@ +#prev-ids-show-more { + display: none; +} +#show-ids-label { + color: #777; + text-decoration: underline; + cursor: pointer; + font-weight: normal; + padding-left: 15px; +} +#bm-ids-div { + display: none; + flex-wrap: wrap; + column-gap: 10px; + row-gap: 20px; + padding: 0px 15px 15px; + max-height: 250px; + overflow-y: scroll; + background-color: #151515; + color-scheme: dark; +} +#prev-id-title { + flex: auto; + position: sticky; top: 0; + width: 100%; + padding-top: 10px; + margin-bottom: -15px; + background-color: #151515; +} +.bm-ids-show-more { + display: flex !important; +} +.prev-id { + display: flex; + flex-direction: column; + row-gap: 10px; + min-width: 250px; + max-width: 350px; + height: 50px; + border-left: 2px solid #777; + padding-left: 8px; +} +.prev-id p { + margin: 0; +} + #bm-hour-wrapper { - padding-left: 15px; - padding-right: 15px; - padding-bottom: 10px; + padding-left: 15px; + padding-right: 15px; + padding-bottom: 10px; + color-scheme: dark; } #bm-hour-summary { - min-height: 300px; - width: 100%; + min-height: 300px; + width: 100%; } #bm-hour-summary * { - box-sizing: border-box; + box-sizing: border-box; } -.loading-summary { +.loading-state { display: flex; justify-content: center; align-items: center; padding: 0; background-color: #1C1C1C; } -.loading-summary p { +.loading-state p { font-size: 18px; } -#hour-text { - padding-bottom: 15px; -} - /* Style the tab */ .tab { float: left; @@ -73,13 +116,53 @@ background-color: #151515; } +.past-weeks-hours { + display: flex; + align-items: baseline; +} +.past-weeks-hours select { + margin-left: 8px; + background: transparent; + border: none; + border-bottom: 1px solid; + text-align: center; + background: #151515; +} +.past-weeks-hours select:required:invalid { + color: #777; + border-bottom: 1px solid white; +} +.past-weeks-hours .timeframe-estimation-text { + font-size: 11px; + font-style: italic; + color: #777; +} + +/* Loading spinner */ +.past-weeks-hours .css-loader { + width: 11px; + aspect-ratio: 1; + border-radius: 50%; + background: + radial-gradient(farthest-side,#eeeeee 94%,#0000) top/4px 4px no-repeat, + conic-gradient(#0000 30%,#eeeeee); + -webkit-mask: radial-gradient(farthest-side,#0000 calc(100% - 4px),#000 0); + animation: l13 1s infinite linear; + display: inline-block; + margin-top: 3px; + padding-left: 3px; +} +@keyframes l13{ + 100%{transform: rotate(1turn)} +} + #show-more-label { color: #777; text-decoration: underline; cursor: pointer; font-weight: normal; padding-left: 45%; - margin-top: 10px; + margin-top: 5px; } .tabcontent input[type=checkbox] { @@ -87,10 +170,19 @@ } .server-playtimes { - max-height: 40%; + max-height: 50% !important; overflow-y: hidden; } +#highest-playtime-header { + position: sticky; + top: 0; + + background-color: #151515; + padding: 5px 0 8px 0; + margin: 0 auto; +} + /* Only show first 5 list elements by default */ .server-playtimes li { display: none; } .server-playtimes li:nth-child(-n+5) { display: list-item; } @@ -102,4 +194,4 @@ .show-rest .server-playtimes { overflow-y: scroll; -} +} \ No newline at end of file diff --git a/manifest.json b/manifest.json index 88fa688..368c5ec 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "manifest_version": 3, "name": "BattleMetrics Hour Summary", - "description": "Summarizes hours on BattleMetrics", - "version": "1.0.1", + "description": "Summarizes player hours on BattleMetrics.", + "version": "1.1.0", "icons": { "48": "images/icon48.png", "128": "images/icon128.png" @@ -14,8 +14,13 @@ "content_scripts": [ { "js": [ + "static/constants.js", "static/game_name_mapping.js", "scripts/helpers.js", + "scripts/init-html.js", + "scripts/process-hours.js", + "scripts/process-sessions.js", + "scripts/past-ids.js", "scripts/generate-html.js", "scripts/content.js" ], @@ -25,4 +30,4 @@ ] } ] - } + } \ No newline at end of file diff --git a/popup/popup.html b/popup/popup.html index daeb35f..7ae06be 100644 --- a/popup/popup.html +++ b/popup/popup.html @@ -19,12 +19,13 @@

Visit a players BattleMetrics page to view their hour summary.

+

If hour summary does not render, try refreshing the page.


- + \ No newline at end of file diff --git a/scripts/content.js b/scripts/content.js index f9cbe95..1e73388 100644 --- a/scripts/content.js +++ b/scripts/content.js @@ -1,130 +1,4 @@ -const PLAYER_PAGE_URL = 'https://www.battlemetrics.com/players/'; -const BM_API = `https://api.battlemetrics.com/players/`; - -const DETAILS_DIV_SELECTOR = '#PlayerPage > div:nth-child(2)'; -const DATA_STORE_ID = 'storeBootstrap'; -const PLAYER_PAGE_ID = 'PlayerPage'; - -// Extract player ID from URL -const extractPlayerId = (currentURL) => { - const url = new URL(currentURL); - return url.pathname.replace('/players/', ''); -} - -// Fetch player server data from BattleMetrics API -// API documentation: https://www.battlemetrics.com/developers/documentation#link-GET-player-/players/{(%23%2Fdefinitions%2Fplayer%2Fdefinitions%2Fidentity)} -const fetchServerData = async (playerId) => { - const url = BM_API + playerId + '?' + new URLSearchParams({ include: 'server' }).toString(); - try { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`BattleMetrics API Response status: ${response.status}`); - } - - const json = await response.json(); - if (!('included' in json)) { - throw new Error(`BattleMetrics API returned incorrect data`); - } - return json.included; - } catch (e) { - throw e; - } -} - -// Calculate hour summary -const calculateHours = (serverData) => { - let timeData = new Object(); - - serverData.forEach((server) => { - const serverName = server.attributes.name; - const serverGame = server.relationships.game.data.id; - const timePlayed = server.meta.timePlayed; - - // Populate game info if it hasn't been seen before - !(serverGame in timeData) && (timeData[serverGame] = { - 'playTime': 0, - 'serverList': [], - }) - - // Add playtime to game total - timeData[serverGame]['playTime'] += timePlayed; - - // Add server to server list for game - timeData[serverGame]['serverList'].push({ - 'serverName': serverName, - 'serverPlayTime': timePlayed, - }); - }); - - const formatTime = (time) => (time / 3600).toFixed(2); - - // Sort serverList for each game by playtime and convert playtime from seconds to hours - Object.keys(timeData).forEach(gameId => { - const gameData = timeData[gameId]; - - // Sort serverList by playtime - timeData[gameId].serverList.sort((a, b) => b.serverPlayTime - a.serverPlayTime); - - // Convert total playtime of game - timeData[gameId].playTime = formatTime(gameData.playTime); - - // Convert playtime for each server in serverList - timeData[gameId].serverList = gameData.serverList.map(serverData => { - serverData.serverPlayTime = formatTime(serverData.serverPlayTime); - return serverData; - }); - }); - - return timeData; -} - -// Setup initial HTML and add spinner while calculating hour summary -const initHTML = () => { - const detailsDiv = document.querySelector(DETAILS_DIV_SELECTOR); - - // Create wrapper div - const wrapperDiv = document.createElement('div'); - wrapperDiv.setAttribute('id', 'bm-hour-wrapper'); - wrapperDiv.setAttribute('class', 'row'); - - // Add title - wrapperDiv.innerHTML = '

Hour summary

'; - - // Insert wrapper after details div - detailsDiv.parentNode.insertBefore(wrapperDiv, detailsDiv.nextSibling); - - // Create inner div - const summaryDiv = document.createElement('div'); - summaryDiv.setAttribute('id', 'bm-hour-summary'); - - // Add spinner to inner div - summaryDiv.classList.toggle('loading-summary'); - summaryDiv.innerHTML = '

Loading Hour Summary...

'; - - // Insert inner div - wrapperDiv.appendChild(summaryDiv); -} - -// Update HTML with hour summary content -const updateHTML = (timeData) => { - const summaryDiv = document.getElementById('bm-hour-summary'); - - // Clear loading state, set height to fit vertical tabs - summaryDiv.classList.toggle('loading-summary'); - summaryDiv.innerHTML = ''; - summaryDiv.style.setProperty('height', `${64 * Object.keys(timeData).length}px`); - - // Add tab buttons - generateTabButtons(summaryDiv, timeData); - - // Add tab content - generateTabContents(summaryDiv, timeData); - - // Setup tab buttons - prepTabs(timeData); -} - -const renderHourSummary = async () => { +const renderContent = async () => { try { // Setup HTML with loading state initHTML(); @@ -136,37 +10,49 @@ const renderHourSummary = async () => { // Populate HTML with summary data updateHTML(gameTime); + + // Render past usernames + await renderPastIdentifiers(playerId); + + // Empty object to store session data + let timeframeData = {}; + + // Setup event listener to handle changes to timeframe dropdown + setupTimeframeListener(timeframeData, gameTime, playerId); + console.log('BattleMetrics Hour Summary - Finished generating content'); } catch (e) { console.log(`BattleMetrics Hour Summary - ERROR: ${e}`); + renderError(); } -} +}; -// Use MutationObserver to trigger renderHourSummary when on a new page +// Use MutationObserver to trigger renderContent when on a new page // Required since page content on battlemetrics.com is ajaxed in const addLocationObserver = (callback) => { // Options for the observer (which mutations to observe) - const config = { attributes: false, childList: true, subtree: false } + const config = { attributes: false, childList: true, subtree: false }; // Create an observer instance linked to the callback function - const observer = new MutationObserver(callback) + const observer = new MutationObserver(callback); // Start observing page title for mutations const title = document.querySelector('head > title'); observer.observe(title, config); -} +}; -// Verify that user is on player overview page +// Verify that user is on player overview page, then render summary const observerCallback = async () => { if ( - window.location.href.startsWith(PLAYER_PAGE_URL) - && document.getElementById(PLAYER_PAGE_ID) - && !document.getElementById('bm-hour-wrapper') // Prevent double rendering + window.location.href.startsWith(PLAYER_PAGE_URL) && + document.getElementById(PLAYER_PAGE_ID) && + !document.getElementById('bm-hour-wrapper') // Prevent double rendering ) { - await renderHourSummary(); + await renderContent(); } -} +}; +// Call renderContent once user is on a player overview page (async () => { addLocationObserver(observerCallback); await observerCallback(); diff --git a/scripts/generate-html.js b/scripts/generate-html.js index 8ee4fc4..49c684b 100644 --- a/scripts/generate-html.js +++ b/scripts/generate-html.js @@ -1,10 +1,42 @@ +// Update HTML with hour summary content +const updateHTML = (totalData) => { + const summaryDiv = document.getElementById('bm-hour-summary'); + + // Clear loading state, set height to fit vertical tabs + summaryDiv.classList.toggle('loading-state'); + summaryDiv.innerHTML = ''; + summaryDiv.style.setProperty( + 'height', + `${70 * Object.keys(totalData).length}px` + ); + + // Setup previous IDs toggle + prepPreviousIDsToggle(); + + // Add tab buttons + generateTabButtons(summaryDiv, totalData); + + // Add tab content + generateTabContents(summaryDiv, totalData); + + // Setup tab buttons + prepTabs(totalData); +}; + +// Render error state +const renderError = () => { + const summaryDiv = document.getElementById('bm-hour-summary'); + summaryDiv.classList.add('loading-state'); + summaryDiv.innerHTML = '

Error generating Hour Summary

'; +}; + const generateTabButtons = (summaryDiv, timeData) => { // Div to hold tabs const tabDiv = document.createElement('div'); tabDiv.classList.add('tab'); // Tab button for each game - Object.keys(timeData).forEach(gameId => { + Object.keys(timeData).forEach((gameId) => { const tabButton = document.createElement('button'); tabButton.textContent = gameIdName(gameId); tabButton.id = `${gameId}-button`; @@ -16,31 +48,59 @@ const generateTabButtons = (summaryDiv, timeData) => { summaryDiv.appendChild(tabDiv); }; +// Tab content for each game const generateTabContents = (summaryDiv, timeData) => { - // Tab content for each game - Object.keys(timeData).forEach((gameId, idx) => { + Object.keys(timeData).forEach((gameId) => { const tabContentDiv = document.createElement('div'); tabContentDiv.setAttribute('id', `${gameId}-tab`); tabContentDiv.setAttribute('class', 'tabcontent'); - let tabInnerHTML = ''; - tabInnerHTML += ` + tabContentDiv.innerHTML = `

${gameIdName(gameId)}

-

${timeData[gameId].playTime} hrs on BattleMetrics

+

${timeData[gameId].playTime} hrs on BattleMetrics

+ `; + + // Show hours in past X weeks + // TODO: add hover tooltip (title="...") that shows the date being calculated to + const pastWeeksHoursDiv = document.createElement('div'); + pastWeeksHoursDiv.setAttribute('id', `${gameId}-past-weeks-hours`); + pastWeeksHoursDiv.setAttribute('class', `past-weeks-hours`); + + // Option value is number of weeks + pastWeeksHoursDiv.innerHTML = ` + + +

 (estimated, public sessions only)

`; + tabContentDiv.appendChild(pastWeeksHoursDiv); + // List top servers by playtime - tabInnerHTML += ` -
Highest playtime:
-
- ` - tabInnerHTML += '
    '; - timeData[gameId].serverList.slice(0, 20).forEach(server => { - tabInnerHTML += `
  1. ${server.serverName} — ${server.serverPlayTime} hrs
  2. ` + const topServersDiv = document.createElement('div'); + topServersDiv.setAttribute('class', 'server-playtimes'); + let topServersDivInnerHTML = + '
    Highest playtime:
    '; + + topServersDivInnerHTML += '
      '; + timeData[gameId].serverList.slice(0, 20).forEach((server) => { + topServersDivInnerHTML += `
    1. ${server.serverName} — ${server.serverPlayTime} hrs
    2. `; }); - tabInnerHTML += '
'; + topServersDivInnerHTML += ''; - tabContentDiv.innerHTML = tabInnerHTML; + topServersDiv.innerHTML = topServersDivInnerHTML; + tabContentDiv.appendChild(topServersDiv); // If more than 5 servers played, render show more toggle if (timeData[gameId].serverList.length > 5) { @@ -50,7 +110,7 @@ const generateTabContents = (summaryDiv, timeData) => { const showMoreLabel = document.createElement('label'); showMoreLabel.setAttribute('for', `${gameId}-show-more`); - showMoreLabel.setAttribute('id', 'show-more-label') + showMoreLabel.setAttribute('id', 'show-more-label'); showMoreLabel.innerHTML = 'Show more...'; tabContentDiv.appendChild(showMoreButton); @@ -60,7 +120,7 @@ const generateTabContents = (summaryDiv, timeData) => { // Append to summaryDiv summaryDiv.appendChild(tabContentDiv); }); -} +}; // Open tab when button is pressed const openTab = (button, gameId) => { @@ -75,12 +135,74 @@ const openTab = (button, gameId) => { } document.getElementById(`${gameId}-tab`).style.display = 'block'; button.className += ' active'; -} +}; // Expand most played server list when show more toggle is pressed const expandList = (checkbox, gameId) => { const gameTab = document.getElementById(`${gameId}-tab`); gameTab.classList.toggle('show-rest'); - const showMoreLabel = document.querySelector('label[for=' + `${gameId}-show-more` + ']'); - showMoreLabel.innerHTML = checkbox.checked ? 'Show less...' : 'Show more...'; -} \ No newline at end of file + const showMoreLabel = document.querySelector( + 'label[for=' + `${gameId}-show-more` + ']' + ); + showMoreLabel.innerHTML = checkbox.checked + ? 'Show less...' + : 'Show more...'; +}; + +// Add event listener for past timeframe dropdown +const setupTimeframeListener = async (timeframeData, totalData, playerId) => { + const onChange = async (selectorValue, gameId) => { + const timeframeLabel = document.querySelector( + `label[for=${gameId}-timeframe-selector]` + ); + + // Set label to loading spinner while fetching data + timeframeLabel.innerHTML = '
hours past'; + + // Fetch and update timeframeData if additional data is required + timeframeData = await onTimeframeChange( + timeframeData, + playerId, + selectorValue + ); + + // console.log(timeframeData); + + // Calculate total time placed in selected timeframe + const gameTimeframData = timeframeData[gameId]; + let timeFrameTotal = gameTimeframData + .slice(0, selectorValue) + .reduce((a, b) => a + b, 0); + timeFrameTotal = Math.round(timeFrameTotal * 100) / 100; // Round to 2 decimal places + + // Update HTML + timeframeLabel.innerHTML = `${timeFrameTotal} hours past`; + }; + + // Setup event listener for each game + Object.keys(totalData).forEach((gameId) => { + const timeframeSelector = document.getElementById( + `${gameId}-timeframe-selector` + ); + + timeframeSelector.addEventListener('change', async (event) => { + const selectorValue = parseInt(event.target.value); + onChange(selectorValue, gameId); + }); + }); +}; + +const onTimeframeChange = async (timeframeData, playerId, numberOfWeeks) => { + // Check if sessions up to numberOfWeeks have already been processed + if ( + Object.keys(timeframeData).length !== 0 && + Object.values(timeframeData)[0].length >= numberOfWeeks - 1 + ) { + return timeframeData; + } + // Otherwise fetch additional data required and update timeframeData + const sessionData = await fetchSessionData(playerId, numberOfWeeks); + const newTimeframeData = calculatePastSessions(sessionData, numberOfWeeks); + + return newTimeframeData; +}; diff --git a/scripts/helpers.js b/scripts/helpers.js index 604c709..af0e51a 100644 --- a/scripts/helpers.js +++ b/scripts/helpers.js @@ -1,14 +1,39 @@ +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// Extract player ID from URL +const extractPlayerId = (currentURL) => { + const url = new URL(currentURL); + return url.pathname.replace('/players/', ''); +}; + const toTitleCase = (str) => { return str.replace( /\w\S*/g, - text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase() + (text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase() ); }; // Fetch game name from mapping file. If not found default to title case version of gameId const gameIdName = (gameId) => { - return gameId in GAME_NAME_MAPPING ? GAME_NAME_MAPPING[gameId] : toTitleCase(gameId); -} + return gameId in GAME_NAME_MAPPING + ? GAME_NAME_MAPPING[gameId] + : toTitleCase(gameId); +}; + +// Set up event listener for previous ids toggle +const prepPreviousIDsToggle = () => { + const prevIdCheckbox = document.getElementById('prev-ids-show-more'); + const prevIdDiv = document.getElementById('bm-ids-div'); + prevIdCheckbox.addEventListener('change', () => { + prevIdDiv.classList.toggle('bm-ids-show-more'); + const showMoreLabel = document.querySelector( + 'label[for=' + `prev-ids-show-more` + ']' + ); + showMoreLabel.innerHTML = prevIdCheckbox.checked + ? 'Hide previous identifiers' + : 'Show previous identifiers'; + }); +}; const prepTabs = (timeData) => { // Get all elements with class="tabcontent" and hide all except first @@ -25,16 +50,18 @@ const prepTabs = (timeData) => { } // Add event listener to each tab button - Object.keys(timeData).forEach(gameId => { + Object.keys(timeData).forEach((gameId) => { const tabButton = document.getElementById(`${gameId}-button`); - tabButton.addEventListener('click', () => openTab(tabButton, gameId), false); + tabButton.addEventListener('click', () => openTab(tabButton, gameId)); }); - // Add event listener to each show more toggle - Object.keys(timeData).forEach(gameId => { + // Add event listener to each games show more toggle + Object.keys(timeData).forEach((gameId) => { const showMoreCheckbox = document.getElementById(`${gameId}-show-more`); if (showMoreCheckbox) { - showMoreCheckbox.addEventListener('change', () => expandList(showMoreCheckbox, gameId), false); + showMoreCheckbox.addEventListener('change', () => + expandList(showMoreCheckbox, gameId) + ); } }); -} \ No newline at end of file +}; diff --git a/scripts/init-html.js b/scripts/init-html.js new file mode 100644 index 0000000..a37074a --- /dev/null +++ b/scripts/init-html.js @@ -0,0 +1,70 @@ +// Setup initial HTML +const initHTML = () => { + // ############# + // HOUR SUMMARY + // ############# + + const serversDiv = document.querySelector(SERVERS_DIV_SELECTOR); + + // Create wrapper div for hour summary + const hourWrapperDiv = document.createElement('div'); + hourWrapperDiv.setAttribute('id', 'bm-hour-wrapper'); + hourWrapperDiv.setAttribute('class', 'row'); + + // Add title + hourWrapperDiv.innerHTML = '

Hour summary

'; + + // Insert hour summary after details div + serversDiv.parentNode.insertBefore(hourWrapperDiv, serversDiv); + + // Create inner div + const summaryDiv = document.createElement('div'); + summaryDiv.setAttribute('id', 'bm-hour-summary'); + + // Add loading info to inner div + summaryDiv.classList.toggle('loading-state'); + summaryDiv.innerHTML = '

Loading Hour Summary...

'; + + // Insert inner div + hourWrapperDiv.appendChild(summaryDiv); + + // #################### + // PREVIOUS IDENTIFIERS + // #################### + + // Create wrapper div for previous identifiers + const prevIdsWrapperDiv = document.createElement('div'); + prevIdsWrapperDiv.setAttribute('id', 'bm-ids-wrapper'); + prevIdsWrapperDiv.setAttribute('class', 'row'); + + // Add title + const prevIdsTitleDiv = document.createElement('div'); + prevIdsTitleDiv.setAttribute('id', 'bm-ids-title'); + + // Create show/hide button + const showButton = document.createElement('input'); + showButton.setAttribute('type', 'checkbox'); + showButton.setAttribute('id', `prev-ids-show-more`); + + const showLabel = document.createElement('label'); + showLabel.setAttribute('for', `prev-ids-show-more`); + showLabel.setAttribute('id', 'show-ids-label'); + showLabel.innerHTML = 'Show previous identifiers'; + + // Create previous ids div, hidden on first load + const prevIdsDiv = document.createElement('div'); + prevIdsDiv.setAttribute('id', 'bm-ids-div'); + prevIdsDiv.innerHTML = + '

Previous Identifiers

'; + + prevIdsWrapperDiv.appendChild(showButton); + prevIdsWrapperDiv.appendChild(showLabel); + prevIdsWrapperDiv.appendChild(prevIdsDiv); + + // Insert previous ids after details div + const usernameDiv = document.querySelector(USERNAME_SELECTOR); + usernameDiv.parentNode.insertBefore( + prevIdsWrapperDiv, + usernameDiv.nextSibling + ); +}; diff --git a/scripts/past-ids.js b/scripts/past-ids.js new file mode 100644 index 0000000..aba93d5 --- /dev/null +++ b/scripts/past-ids.js @@ -0,0 +1,52 @@ +// Render all previous usernames seen on BattleMetrics +const renderPastIdentifiers = async (playerId) => { + let pastIdentifiers = []; + try { + pastIdentifiers = await fetchPastIdentifiers(playerId); + } catch (e) { + throw e; + } + + const prevIdDiv = document.getElementById('bm-ids-div'); + + // Render a div for each previous identifier + pastIdentifiers.forEach((identifier) => { + // Format last seen time + const lastSeenDate = new Date( + Date.parse(identifier.attributes.lastSeen) + ); + const lastSeenText = `${lastSeenDate.toLocaleString([], { + dateStyle: 'medium', + timeStyle: 'short', + })}`; + + const identifierDiv = document.createElement('div'); + identifierDiv.setAttribute('class', 'prev-id'); + identifierDiv.innerHTML = ` + ${identifier.attributes.identifier} +

${lastSeenText}

+ `; + + // Add previous identifier div to main div + prevIdDiv.appendChild(identifierDiv); + }); +}; + +// Fetch all previous usernames seen on BattleMetrics +const fetchPastIdentifiers = async (playerId) => { + // Fetch past identifiers + const url = + BM_API + + playerId + + '?' + + new URLSearchParams({ include: 'identifier' }).toString(); + try { + const response = await fetch(url); + const json = await response.json(); + const pastIdentifiers = json.included; + + return pastIdentifiers; + } catch (e) { + throw e; + } +}; diff --git a/scripts/process-hours.js b/scripts/process-hours.js new file mode 100644 index 0000000..6d2d0da --- /dev/null +++ b/scripts/process-hours.js @@ -0,0 +1,75 @@ +// Fetch player server data from BattleMetrics API +// API documentation: https://www.battlemetrics.com/developers/documentation#link-GET-player-/players/{(%23%2Fdefinitions%2Fplayer%2Fdefinitions%2Fidentity)} +const fetchServerData = async (playerId) => { + const url = + BM_API + + playerId + + '?' + + new URLSearchParams({ include: 'server' }).toString(); + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `BattleMetrics API Response status: ${response.status}` + ); + } + + const json = await response.json(); + if (!('included' in json)) { + throw new Error(`BattleMetrics API returned incorrect data`); + } + return json.included; + } catch (e) { + throw e; + } +}; + +// Calculate hour summary +const calculateHours = (serverData) => { + let timeData = {}; + + serverData.forEach((server) => { + const serverName = server.attributes.name; + const serverGame = server.relationships.game.data.id; + const timePlayed = server.meta.timePlayed; + + // Populate game info if it hasn't been seen before + !(serverGame in timeData) && + (timeData[serverGame] = { + playTime: 0, + serverList: [], + }); + + // Add playtime to game total + timeData[serverGame]['playTime'] += timePlayed; + + // Add server to server list for game + timeData[serverGame]['serverList'].push({ + serverName: serverName, + serverPlayTime: timePlayed, + }); + }); + + const formatTime = (time) => (time / 3600).toFixed(2); + + // Sort serverList for each game by playtime and convert playtime from seconds to hours + Object.keys(timeData).forEach((gameId) => { + const gameData = timeData[gameId]; + + // Sort serverList by playtime + timeData[gameId].serverList.sort( + (a, b) => b.serverPlayTime - a.serverPlayTime + ); + + // Convert total playtime of game + timeData[gameId].playTime = formatTime(gameData.playTime); + + // Convert playtime for each server in serverList + timeData[gameId].serverList = gameData.serverList.map((serverData) => { + serverData.serverPlayTime = formatTime(serverData.serverPlayTime); + return serverData; + }); + }); + + return timeData; +}; diff --git a/scripts/process-sessions.js b/scripts/process-sessions.js new file mode 100644 index 0000000..d3ed30f --- /dev/null +++ b/scripts/process-sessions.js @@ -0,0 +1,156 @@ +// Fetch session data from BattleMetrics API +const fetchSessionData = async (playerId, numberOfWeeks) => { + const periodEnd = new Date(); + periodEnd.setDate(periodEnd.getDate() - 7 * numberOfWeeks); + + // Example URL: https://api.battlemetrics.com/players/[playerId]/relationships/sessions?include=server&page%5Bsize%5D=100 + const url = + BM_API + + playerId + + '/relationships/sessions' + + '?' + + new URLSearchParams({ + 'include': 'server', + 'page[size]': 100, + }).toString(); + + let nextUrl = ''; + let currentPage = 0; + let sessionData = { + data: [], // Array of sessions objects + included: [], // Array of servers that sessions occurred on + }; + + try { + // Keep fetching data until periodEnd date is reached + do { + // Use next link provided by API to fetch pages after page 1 + const response = await fetch(currentPage === 0 ? url : nextUrl); + + if (!response.ok) { + throw new Error( + `BattleMetrics API Response status: ${response.status}` + ); + } + + const json = await response.json(); + if (!('data' in json)) { + throw new Error(`BattleMetrics API returned incorrect data`); + } + + // Add current pages data to sessionData object + sessionData = { + data: [...sessionData.data, ...json.data], + included: [...sessionData.included, ...json.included], + }; + + // Break if last page reached + if (!('next' in json.links)) { + break; + } + nextUrl = json.links.next; + + // Wait between requests to prevent rate limiting + if (currentPage !== 0) { + await sleep(1000); + } + currentPage++; + } while (!sessionEndReached(sessionData, periodEnd)); + + // Dedupe included array, which contains all servers played in sessions array + sessionData['included'] = sessionData['included'].filter( + (value, index, self) => + index === self.findIndex((x) => x.id === value.id) + ); + + return sessionData; + } catch (e) { + throw e; + } +}; + +// Check if oldest session fetched is before the periodEnd cutoff +const sessionEndReached = (cumulativeData, periodEnd) => { + const lastSession = cumulativeData.data.at(-1); + const lastSessionStart = Date.parse(lastSession.attributes.start); + const lastSessionEnd = Date.parse(lastSession.attributes.end); + + // True if last session starts or ends before periodEnd cutoff + return lastSessionStart < periodEnd || lastSessionEnd < periodEnd; +}; + +const calculatePastSessions = (sessionData, numberOfWeeks) => { + const pastSessions = {}; + + const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000; + + const now = new Date().getTime(); + const weekBoundaries = Array(numberOfWeeks) + .fill(0) + .map((_, index) => { + const weekEnd = now - index * MS_PER_WEEK; + const weekStart = weekEnd - MS_PER_WEEK; + return { start: weekStart, end: weekEnd }; + }); + + // Create a map of server IDs to game names + const serverToGame = {}; + sessionData.included.forEach((server) => { + serverToGame[server.id] = server.relationships.game.data.id; + }); + + // Process each session + sessionData.data.forEach((session) => { + const serverId = session.relationships.server.data.id; + const gameName = serverToGame[serverId]; + const startTime = new Date(session.attributes.start).getTime(); + const stopTime = new Date(session.attributes.stop).getTime(); + + // Initialize arrays for new games + if (!pastSessions[gameName]) { + pastSessions[gameName] = new Array(numberOfWeeks).fill(0); + } + + const sessionTime = stopTime - startTime; + + // Get week index of start and end of current session + const startWeekIndex = weekBoundaries.findIndex( + (boundary) => + startTime >= boundary.start && startTime < boundary.end + ); + + const stopWeekIndex = weekBoundaries.findIndex( + (boundary) => stopTime > boundary.start && stopTime <= boundary.end + ); + + // If session fits entirely within one week + if (startWeekIndex === stopWeekIndex && startWeekIndex !== -1) { + pastSessions[gameName][startWeekIndex] += sessionTime; + } else if (startWeekIndex !== -1 || stopWeekIndex !== -1) { + // If session spans multiple weeks + weekBoundaries.forEach((boundary, i) => { + const overlapStart = Math.max(startTime, boundary.start); + const overlapEnd = Math.min(stopTime, boundary.end); + + if (overlapEnd > overlapStart) { + const overlapTime = overlapEnd - overlapStart; + pastSessions[gameName][i] += overlapTime; + } + }); + } + }); + + // Convert milliseconds to hours + for (const game in pastSessions) { + pastSessions[game] = pastSessions[game].map( + (millisecondsPerWeek) => millisecondsPerWeek / 3_600_000 + ); + } + + return pastSessions; +}; + +const pastSessionsWrapper = async (playerId, numberOfWeeks) => { + const sessionData = await fetchSessionData(playerId, numberOfWeeks); + return calculatePastSessions(sessionData, numberOfWeeks); +}; diff --git a/static/constants.js b/static/constants.js new file mode 100644 index 0000000..6a0d5e2 --- /dev/null +++ b/static/constants.js @@ -0,0 +1,7 @@ +const PLAYER_PAGE_URL = 'https://www.battlemetrics.com/players/'; +const BM_API = `https://api.battlemetrics.com/players/`; + +const SERVERS_DIV_SELECTOR = '#PlayerPage > .row:last-of-type'; +const USERNAME_SELECTOR = '#PlayerPage > h1'; +const DATA_STORE_ID = 'storeBootstrap'; +const PLAYER_PAGE_ID = 'PlayerPage';