Files
TokenWatch/public/app.js
T
Jannik 85fb13b7c5
Build and Push Docker Image / build-and-push (push) Canceled after 0s
fix: resolve history chart data misalignment and missing providers
2026-09-05 00:58:30 +02:00

324 lines
12 KiB
JavaScript

let historyChart = null;
document.addEventListener('DOMContentLoaded', () => {
fetchQuotas();
fetchHistory();
setupThemeDropdown();
});
function setupThemeDropdown() {
const dropdown = document.getElementById('theme-dropdown');
dropdown.addEventListener('change', (e) => {
// Change body class
document.body.className = e.target.value;
// Update chart colors if chart exists
if (historyChart) {
updateChartTheme(e.target.value);
}
});
}
async function fetchQuotas() {
try {
const response = await fetch('/api/quotas');
const quotas = await response.json();
// Mock data if DB is empty
const activeQuotas = quotas.length > 0 ? quotas : [
{ provider: 'AG Gemini (5h)', used: 73, total: 100, percentage: 73, meta: { resetStr: 'in 01:23' } },
{ provider: 'AG Gemini (Weekly)', used: 73, total: 100, percentage: 73, meta: { resetStr: 'in 01:23' } },
{ provider: 'AG Claude (5h)', used: 0, total: 100, percentage: 0, meta: { resetStr: 'in 03:00' } },
{ provider: 'AG Claude (Weekly)', used: 0, total: 100, percentage: 0, meta: { resetStr: 'in 03:00' } },
{ provider: 'POE', used: 33750, total: 75000, percentage: 45 },
{ provider: 'MiniMax (5h)', used: 60, total: 100, percentage: 60, meta: { reset: 1729000000 } },
{ provider: 'MiniMax (Weekly)', used: 40, total: 100, percentage: 40, meta: { reset: 1729500000 } }
];
activeQuotas.forEach(quota => {
const providerId = quota.provider.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
updateRow(providerId, quota);
});
} catch (error) {
console.error('Error fetching quotas:', error);
}
}
function updateRow(providerId, quota) {
const fill = document.getElementById(`fill-${providerId}`);
const statusText = document.getElementById(`status-${providerId}`);
const percentText = document.getElementById(`percentage-${providerId}`);
if (!fill || !statusText || !percentText) return;
const percentage = quota.percentage;
fill.style.width = `${percentage}%`;
// Update percentage text
percentText.innerText = `${Math.round(percentage)}%`;
// Status coloring
percentText.className = 'percentage'; // reset
if (quota.provider.toLowerCase() === 'poe') {
if (percentage < 10) {
percentText.classList.add('status-danger');
} else if (percentage < 30) {
percentText.classList.add('status-warn');
} else {
percentText.classList.add('status-good');
}
} else {
if (percentage > 90) {
percentText.classList.add('status-danger');
} else if (percentage > 70) {
percentText.classList.add('status-warn');
} else {
percentText.classList.add('status-good');
}
}
// Detail text
if (quota.provider.startsWith('MiniMax') && quota.meta) {
statusText.innerText = `${Math.round(quota.used)}% / 100%`;
const detailsId = `details-${quota.provider.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
const detailsEl = document.getElementById(detailsId);
if (detailsEl) {
const parseDate = (val) => {
if (!val) return '--';
const ts = (typeof val === 'number' && val < 9999999999) ? val * 1000 : val;
return new Date(ts).toLocaleString();
};
const resetStr = parseDate(quota.meta.reset);
if (quota.provider.includes('5h')) {
detailsEl.innerHTML = `
<div><span>Reset in:</span> <strong id="minimax-countdown" style="color: var(--success)">--:--:--</strong></div>
`;
const targetTs = (typeof quota.meta.reset === 'number' && quota.meta.reset < 9999999999) ? quota.meta.reset * 1000 : quota.meta.reset;
if (targetTs) startMinimaxCountdown(targetTs);
} else {
detailsEl.innerHTML = `
<div><span>Reset:</span> <strong style="color: var(--success)">${resetStr}</strong></div>
`;
}
}
} else if (quota.provider.startsWith('AG ') && quota.meta) {
statusText.innerText = `${Math.round(quota.used)}% / 100%`;
const detailsId = `details-${quota.provider.toLowerCase().replace(/[^a-z0-9]/g, '-')}`;
const detailsEl = document.getElementById(detailsId);
if (detailsEl) {
const resetStr = quota.meta.resetStr || '--';
detailsEl.innerHTML = `
<div><span>Reset:</span> <strong style="color: var(--success)">${resetStr}</strong></div>
`;
}
} else if (quota.provider.toLowerCase() === 'poe' && quota.meta) {
statusText.innerText = `${(quota.used).toLocaleString()} / ${(quota.total).toLocaleString()}`;
const detailsEl = document.getElementById(`details-poe`);
if (detailsEl) {
const { available, dailyRefresh, expiringPoints, daysUntilCap, todayUsed } = quota.meta;
detailsEl.innerHTML = `
<div><span>Übrig:</span> <strong style="color: #fff">${available.toLocaleString()}</strong></div>
<div><span>Heute Verbraucht:</span> <strong style="color: #fff">${(todayUsed || 0).toLocaleString()}</strong></div>
<div><span>Verfällt:</span> <strong style="color: ${expiringPoints > 0 ? 'var(--danger)' : 'var(--text-muted)'}">${expiringPoints.toLocaleString()}</strong></div>
<div><span>Tage bis Cap:</span> <strong style="color: #fff">${daysUntilCap}</strong></div>
<div><span>Refresh in:</span> <strong id="poe-countdown" style="color: var(--success)">--:--:--</strong></div>
`;
startPoeCountdown();
}
} else {
statusText.innerText = `${(quota.used).toLocaleString()} / ${(quota.total).toLocaleString()}`;
}
if (percentage >= 100) {
confetti({
particleCount: 100,
spread: 70,
origin: { y: 0.6 }
});
if (quota.provider.toLowerCase() !== 'poe') {
statusText.innerText = 'LIMIT REACHED!';
statusText.style.color = 'var(--danger)';
}
}
}
let poeCountdownInterval = null;
function startPoeCountdown() {
if (poeCountdownInterval) clearInterval(poeCountdownInterval);
const updateCountdown = () => {
const now = new Date();
const nextMidnightUTC = new Date();
nextMidnightUTC.setUTCHours(24, 0, 0, 0);
let diffMs = nextMidnightUTC.getTime() - now.getTime();
if (diffMs < 0) {
// Already passed, next midnight is +24h (edge case just after midnight)
diffMs += 24 * 60 * 60 * 1000;
}
const h = Math.floor(diffMs / (1000 * 60 * 60));
const m = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
const s = Math.floor((diffMs % (1000 * 60)) / 1000);
const el = document.getElementById('poe-countdown');
if (el) {
el.innerText = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
};
updateCountdown();
poeCountdownInterval = setInterval(updateCountdown, 1000);
}
let minimaxCountdownInterval = null;
function startMinimaxCountdown(targetTs) {
if (minimaxCountdownInterval) clearInterval(minimaxCountdownInterval);
const targetDate = new Date(targetTs);
const updateCountdown = () => {
const now = new Date();
let diffMs = targetDate.getTime() - now.getTime();
if (diffMs < 0) {
// Already passed, show 00:00:00 or let the UI handle refresh
diffMs = 0;
}
const h = Math.floor(diffMs / (1000 * 60 * 60));
const m = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
const s = Math.floor((diffMs % (1000 * 60)) / 1000);
const el = document.getElementById('minimax-countdown');
if (el) {
el.innerText = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
};
updateCountdown();
minimaxCountdownInterval = setInterval(updateCountdown, 1000);
}
async function fetchHistory() {
try {
const response = await fetch('/api/history');
const history = await response.json();
// Group by 5-minute intervals to align points from the same fetch batch
const snapshots = {};
history.forEach(item => {
const time = new Date(item.timestamp);
const coeff = 1000 * 60 * 5;
const roundedTime = new Date(Math.round(time.getTime() / coeff) * coeff);
const timeKey = roundedTime.getTime();
if (!snapshots[timeKey]) {
snapshots[timeKey] = {
label: roundedTime.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}),
data: {}
};
}
snapshots[timeKey].data[item.provider] = item.percentage;
});
const sortedKeys = Object.keys(snapshots).sort((a, b) => a - b);
const labels = sortedKeys.map(k => snapshots[k].label);
const datasetsConfig = [
{ label: 'AG Gemini (5h)', provider: 'AG Gemini (5h)', color: '#58a6ff' },
{ label: 'AG Claude (5h)', provider: 'AG Claude (5h)', color: '#8957e5' },
{ label: 'POE', provider: 'POE', color: '#3fb950' },
{ label: 'MiniMax (5h)', provider: 'MiniMax (5h)', color: '#d29922' }
];
const datasets = datasetsConfig.map(config => ({
label: config.label,
data: sortedKeys.length ? sortedKeys.map(k => snapshots[k].data[config.provider] || 0) : [0, 0, 0, 0],
borderColor: config.color,
backgroundColor: config.color + '1a', // will be overridden by theme
borderWidth: 2,
tension: 0.3,
fill: true
}));
const ctx = document.getElementById('historyChart').getContext('2d');
historyChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels.length ? labels : ['10:00', '11:00', '12:00', '13:00'],
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: { color: '#c9d1d9' }
}
},
scales: {
y: {
beginAtZero: true,
max: 100,
grid: { color: '#30363d' },
ticks: { color: '#8b949e' }
},
x: {
grid: { color: '#30363d' },
ticks: { color: '#8b949e' }
}
}
}
});
// Apply initial theme to chart
updateChartTheme(document.body.className);
} catch (error) {
console.error('Error fetching history:', error);
}
}
function updateChartTheme(themeClass) {
if (!historyChart) return;
let colors = ['#58a6ff', '#8957e5', '#3fb950', '#d29922']; // Default
switch (themeClass) {
case 'style-pacman':
colors = ['#f1c40f', '#f39c12', '#e67e22', '#d35400'];
break;
case 'style-nyancat':
colors = ['#ff0000', '#ff9900', '#33ff00', '#0099ff'];
break;
case 'style-matrix':
colors = ['#0f0', '#0c0', '#0a0', '#050'];
break;
case 'style-lava':
colors = ['#ff4e00', '#ff7a00', '#ec9f05', '#bf3a00'];
break;
}
historyChart.data.datasets.forEach((dataset, index) => {
dataset.borderColor = colors[index % colors.length];
// Convert hex to rgba for background
let hex = colors[index % colors.length].replace('#', '');
if (hex.length === 3) {
hex = hex.split('').map(char => char + char).join('');
}
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
dataset.backgroundColor = `rgba(${r}, ${g}, ${b}, 0.1)`;
});
historyChart.update();
}