// ==UserScript==
// @name Tobii Ambassador Bonanza Stats
// @description Provide stats for Tobii - show points, hours watched and countdown to start/end.
// @author Lone Destroyer
// @license MIT
// @match https://engage.tobii.gg/ambassador-bonanza*
// @icon https://engage.tobii.gg/favicon.ico
// @version 1.0
// @namespace https://github.com/LoneDestroyer
// @downloadURL https://raw.githubusercontent.com/LoneDestroyer/Tobii-Ambassador-Bonanza-Stats/main/Tobii-Ambassador-Bonanza-Stats.user.js
// @updateURL https://raw.githubusercontent.com/LoneDestroyer/Tobii-Ambassador-Bonanza-Stats/main/Tobii-Ambassador-Bonanza-Stats.user.js
// @run-at document-start
// ==/UserScript==
(function() {
'use strict';
const PATHS = { status: '/api/sweepstake/ambassador-bonanza/status', watched: '/api/sweepstake/ambassador-bonanza/watched' };
const state = { statusData: null, watchedData: null, nodes: null, renderInterval: null, closed: false };
const pick = (root, selector) => root.querySelector(selector);
const nowMs = () => Date.now();
const fmt = {
countdown(endAt) {
if (!endAt) return '';
const diff = new Date(endAt).getTime() - nowMs();
if (Number.isNaN(diff + nowMs())) return 'Unknown';
if (diff <= 0) return 'Ended';
const total = Math.floor(diff / 1000);
const days = Math.floor(total / 86400);
const hours = Math.floor(total % 86400 / 3600);
const minutes = Math.floor(total % 3600 / 60);
const seconds = total % 60;
if (total > 86400) return days + 'd ' + String(hours).padStart(2, '0') + 'h';
return [hours, minutes, seconds].map((value, index) => String(value).padStart(2, '0') + 'hms'[index]).join(' ');
},
watched(totalMinutes) {
return typeof totalMinutes === 'number' && Number.isFinite(totalMinutes)
? Math.floor(totalMinutes / 60) + 'h ' + totalMinutes % 60 + 'm'
: '';
},
};
function ensurePanel() {
if (state.nodes) return state.nodes;
const panel = document.createElement('aside');
panel.id = 'tobii-bonanza-stats-panel';
panel.style.cssText = 'position:fixed;top:14px;right:14px;z-index:2147483647;width:min(280px,calc(100vw - 24px));padding:6px 8px;border:1px solid rgba(67,208,255,.28);border-radius:16px;background:linear-gradient(165deg,rgba(8,8,18,.97),rgba(14,20,44,.95) 52%,rgba(7,34,62,.94));color:#eef8ff;font:12px/1.35 Trebuchet MS,Segoe UI,sans-serif;box-shadow:0 16px 32px rgba(2,8,24,.48),inset 0 1px 0 rgba(171,115,255,.12);backdrop-filter:blur(12px)';
panel.innerHTML = '
';
const header = pick(panel, '[data-drag]');
const toggle = pick(panel, '[data-toggle]');
const closeBtn = pick(panel, '[data-close]');
const body = pick(panel, '[data-body]');
let x = 0, y = 0;
header.onpointerdown = (event) => {
if (event.target === toggle || event.target === closeBtn) return;
const rect = panel.getBoundingClientRect();
x = event.clientX - rect.left;
y = event.clientY - rect.top;
panel.style.left = rect.left + 'px';
panel.style.top = rect.top + 'px';
panel.style.right = 'auto';
header.setPointerCapture(event.pointerId);
};
header.onpointermove = (event) => header.hasPointerCapture(event.pointerId) && ((panel.style.left = Math.max(8, event.clientX - x) + 'px'), (panel.style.top = Math.max(8, event.clientY - y) + 'px'));
header.onpointerup = (event) => header.hasPointerCapture(event.pointerId) && header.releasePointerCapture(event.pointerId);
toggle.onclick = () => ((body.style.display = body.style.display === 'none' ? 'block' : 'none'), (toggle.textContent = body.style.display === 'none' ? '+' : '−'));
closeBtn.onclick = () => { state.closed = true; panel.remove(); clearInterval(state.renderInterval); };
const mount = () => document.body && !panel.isConnected && document.body.appendChild(panel);
return (state.nodes = { timeLabel: pick(panel, '[data-label="time"]'), timeValue: pick(panel, '[data-key="time"]'), pointsValue: pick(panel, '[data-key="points"]'), watchedValue: pick(panel, '[data-key="watched"]'), pointsRow: pick(panel, '[data-row="points"]'), watchedRow: pick(panel, '[data-row="watched"]'), divider: pick(panel, '[data-divider]') });
}
function render() {
if (!state.statusData && !state.watchedData) return;
const participant = state.statusData && state.statusData.participant;
const sweepstake = state.statusData && state.statusData.sweepstake;
const totalWatched = state.watchedData && state.watchedData.totalWatched;
const startAt = sweepstake && sweepstake.startAt;
const endAt = sweepstake && sweepstake.endAt;
const startMs = startAt ? new Date(startAt).getTime() : NaN;
const hasValidStart = Number.isFinite(startMs);
const notStarted = hasValidStart && startMs > nowMs();
const timeLabel = notStarted ? 'Starts in' : 'Ends in';
const timeValue = fmt.countdown(notStarted ? startAt : endAt);
const nodes = ensurePanel();
if (!nodes.timeValue.closest('aside').isConnected) {
const mount = () => document.body && document.body.appendChild(nodes.timeValue.closest('aside'));
document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', mount, { once: true }) : mount();
}
nodes.timeLabel.textContent = timeLabel;
nodes.timeValue.textContent = timeValue;
nodes.pointsRow.style.display = notStarted ? 'none' : 'flex';
nodes.watchedRow.style.display = notStarted ? 'none' : 'flex';
nodes.divider.style.display = notStarted ? 'none' : 'block';
nodes.pointsValue.textContent = notStarted ? '' : participant && typeof participant.points === 'number' ? String(participant.points) : '';
nodes.watchedValue.textContent = notStarted ? '' : fmt.watched(totalWatched);
unsafeWindow.__tobiiAmbassadorBonanzaStats = {
status: state.statusData,
watched: state.watchedData,
extracted: { startsIn: notStarted ? timeValue : null, endsIn: notStarted ? null : timeValue, points: participant && participant.points, totalWatchedMinutes: totalWatched, totalWatchedHours: typeof totalWatched === 'number' ? Number((totalWatched / 60).toFixed(2)) : null },
};
}
function inspectResponse(urlLike, bodyText) {
try {
const { pathname } = new URL(urlLike, unsafeWindow.location.origin);
const key = Object.keys(PATHS).find((name) => PATHS[name] === pathname);
if (!key) return;
state[key + 'Data'] = JSON.parse(bodyText);
render();
} catch (_) {}
}
const originalFetch = unsafeWindow.fetch;
unsafeWindow.fetch = function(...args) {
return originalFetch.apply(this, args).then((response) => {
try {
response && response.url && response.clone().text().then((bodyText) => inspectResponse(response.url, bodyText)).catch(() => {});
} catch (_) {}
return response;
});
};
const originalXhrOpen = XMLHttpRequest.prototype.open;
const originalXhrSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
this.__tobiiTrackedUrl = url;
return originalXhrOpen.apply(this, [method, url, ...rest]);
};
XMLHttpRequest.prototype.send = function(...args) {
this.addEventListener('load', function() {
typeof this.responseText === 'string' && this.__tobiiTrackedUrl && inspectResponse(this.__tobiiTrackedUrl, this.responseText);
});
return originalXhrSend.apply(this, args);
};
unsafeWindow.setInterval(() => !state.closed && render(), 1000);
})();