Personal mugging analytics dashboard for Torn.
// ==UserScript==
// @name Mug Log
// @namespace st4tic.muglog
// @version 0.2.2
// @description Personal mugging analytics dashboard for Torn.
// @match https://www.torn.com/profiles.php*
// @grant none
// @license GPL-3.0-or-later
// ==/UserScript==
(function () {
'use strict';
const APP = 'st4tic_mug_log_v020';
const CONFIG = {
width: 785,
cacheMs: 5 * 60 * 1000,
apiUrl: 'https://api.torn.com/user/?selections=log&key='
};
const LS = {
apiKey: `${APP}_api_key`,
activeTab: `${APP}_active_tab`,
db: `${APP}_database`,
cacheTime: `${APP}_cache_time`
};
const state = {
apiKey: localStorage.getItem(LS.apiKey) || '',
activeTab: localStorage.getItem(LS.activeTab) || 'overview',
db: loadDB(),
loading: false,
status: ''
};
const TABS = [
['overview', 'OVERVIEW'],
['targets', 'TARGETS'],
['analytics', 'ANALYTICS'],
['milestones', 'MILESTONES'],
['ledger', 'LEDGER']
];
const css = `
#mug-log-box {
width: ${CONFIG.width}px;
max-width: ${CONFIG.width}px;
margin: 14px 0 0 0;
background: linear-gradient(180deg, #2f2f2f 0%, #242424 100%);
border: 1px solid #3c3c3c;
border-radius: 5px;
color: #ddd;
font-family: Arial, sans-serif;
box-shadow: 0 2px 10px rgba(0,0,0,.35);
overflow: hidden;
}
#mug-log-box * { box-sizing: border-box; }
.ml-head {
height: 42px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 13px;
background: linear-gradient(180deg, #383838, #2c2c2c);
border-bottom: 1px solid #1c1c1c;
}
.ml-title {
font-size: 14px;
font-weight: 700;
color: #f1f1f1;
letter-spacing: .3px;
}
.ml-actions { display: flex; gap: 6px; }
.ml-btn {
border: 1px solid #494949;
background: #303030;
color: #cfcfcf;
border-radius: 4px;
height: 25px;
padding: 0 8px;
cursor: pointer;
font-size: 12px;
}
.ml-btn:hover {
background: #3a3a3a;
color: #fff;
}
.ml-tabs {
display: grid;
grid-template-columns: repeat(5, 1fr);
background: #282828;
border-bottom: 1px solid #171717;
}
.ml-tab {
height: 34px;
border: none;
border-right: 1px solid #3a3a3a;
background: #292929;
color: #aaa;
font-size: 11px;
font-weight: 700;
cursor: pointer;
}
.ml-tab:last-child { border-right: none; }
.ml-tab.active {
background: #353535;
color: #7ed321;
box-shadow: inset 0 -2px 0 #7ed321;
}
.ml-body { padding: 13px; }
.ml-sub {
display: flex;
justify-content: space-between;
color: #b9b9b9;
font-size: 12px;
margin-bottom: 12px;
}
.ml-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
margin-bottom: 15px;
}
.ml-card {
background: linear-gradient(180deg, #303030, #272727);
border: 1px solid #3d3d3d;
border-radius: 6px;
min-height: 108px;
padding: 12px 8px;
text-align: center;
}
.ml-icon {
font-size: 24px;
opacity: .85;
margin-bottom: 10px;
}
.ml-label {
font-size: 10px;
font-weight: 700;
color: #d8d8d8;
text-transform: uppercase;
margin-bottom: 8px;
}
.ml-value {
font-size: 18px;
font-weight: 800;
color: #7ed321;
line-height: 1.1;
word-break: break-word;
}
.ml-small {
font-size: 11px;
color: #aaa;
margin-top: 5px;
}
.ml-section-title {
font-size: 13px;
font-weight: 700;
color: #efefef;
margin: 6px 0 8px;
}
.ml-table {
width: 100%;
border-collapse: collapse;
background: #292929;
border: 1px solid #3a3a3a;
border-radius: 5px;
overflow: hidden;
}
.ml-table th {
text-align: left;
font-size: 12px;
color: #d5d5d5;
font-weight: 600;
background: #303030;
padding: 10px 9px;
border-bottom: 1px solid #404040;
}
.ml-table td {
font-size: 12px;
color: #dcdcdc;
padding: 9px;
border-bottom: 1px solid #383838;
}
.ml-table tr:last-child td { border-bottom: none; }
.ml-money { color: #7ed321 !important; font-weight: 700; }
.ml-name { color: #6bb8ff !important; cursor: pointer; }
.ml-muted { color: #999 !important; }
.ml-rank { color: #ddd; font-weight: 700; }
.ml-rating {
display: inline-block;
min-width: 44px;
padding: 3px 6px;
border-radius: 4px;
background: #333;
font-size: 11px;
font-weight: 700;
text-align: center;
}
.ml-rating.s { color: #ff7070; }
.ml-rating.a { color: #ffae42; }
.ml-rating.b { color: #ffd84d; }
.ml-rating.c { color: #bdbdbd; }
.ml-footer {
margin-top: 10px;
color: #999;
font-size: 11px;
display: flex;
justify-content: space-between;
}
.ml-empty {
text-align: center;
padding: 20px;
color: #aaa;
background: #292929;
border: 1px solid #3a3a3a;
border-radius: 5px;
}
.ml-two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.ml-panel {
background: #292929;
border: 1px solid #3a3a3a;
border-radius: 5px;
padding: 12px;
}
.ml-bar-row {
margin-bottom: 10px;
}
.ml-bar-top {
display: flex;
justify-content: space-between;
font-size: 12px;
margin-bottom: 4px;
}
.ml-bar {
height: 8px;
background: #1f1f1f;
border: 1px solid #3a3a3a;
border-radius: 8px;
overflow: hidden;
}
.ml-bar-fill {
height: 100%;
background: #7ed321;
width: 0%;
}
.ml-modal-bg {
position: fixed;
inset: 0;
background: rgba(0,0,0,.65);
z-index: 999999;
display: flex;
align-items: center;
justify-content: center;
}
.ml-modal {
width: 460px;
background: #2d2d2d;
border: 1px solid #444;
border-radius: 6px;
padding: 15px;
color: #ddd;
box-shadow: 0 4px 20px rgba(0,0,0,.5);
}
.ml-modal h3 {
margin: 0 0 12px;
font-size: 15px;
}
.ml-input, .ml-textarea {
width: 100%;
background: #1f1f1f;
border: 1px solid #444;
color: #eee;
border-radius: 4px;
padding: 8px;
margin-bottom: 12px;
}
.ml-input { height: 32px; }
.ml-textarea { height: 160px; resize: vertical; }
.ml-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
`;
function loadDB() {
try {
const raw = localStorage.getItem(LS.db);
if (raw) return JSON.parse(raw);
} catch (_) {}
return {
version: '0.2.0',
started: Date.now(),
mugs: [],
seen: {}
};
}
function saveDB() {
localStorage.setItem(LS.db, JSON.stringify(state.db));
}
function injectCSS() {
if (document.getElementById('mug-log-style')) return;
const style = document.createElement('style');
style.id = 'mug-log-style';
style.textContent = css;
document.head.appendChild(style);
}
function money(n) {
n = Number(n || 0);
return '$' + n.toLocaleString();
}
function shortMoney(n) {
n = Number(n || 0);
if (n >= 1_000_000_000) return '$' + (n / 1_000_000_000).toFixed(2) + 'B';
if (n >= 1_000_000) return '$' + (n / 1_000_000).toFixed(2) + 'M';
if (n >= 1_000) return '$' + (n / 1_000).toFixed(1) + 'K';
return money(n);
}
function ago(ts) {
if (!ts) return '-';
const diff = Math.max(0, Date.now() / 1000 - ts);
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
}
function dateOnly(ts) {
if (!ts) return '-';
return new Date(ts * 1000).toLocaleDateString();
}
function uniqueId(entry) {
return [
entry.timestamp || 0,
entry.victimId || '-',
entry.cash || 0,
String(entry.text || '').slice(0, 60)
].join('|');
}
function extractMoney(text, data) {
if (data && typeof data === 'object') {
for (const key of ['money', 'cash', 'amount', 'mugged', 'value', 'gain']) {
const val = data[key];
if (typeof val === 'number') return val;
if (typeof val === 'string') {
const cleaned = val.replace(/[$,]/g, '');
if (cleaned && !isNaN(cleaned)) return Number(cleaned);
}
}
}
const match = String(text || '').match(/\$([\d,]+)/);
return match ? Number(match[1].replace(/,/g, '')) : 0;
}
function extractVictim(text, data) {
if (data && typeof data === 'object') {
for (const key of ['victim_name', 'target_name', 'defender_name', 'name']) {
if (data[key]) return String(data[key]);
}
}
const clean = String(text || '');
const m1 = clean.match(/mugged\s+(.+?)\s+(for|and|,|\$)/i);
if (m1) return m1[1].replace(/\[\d+\]/, '').trim();
const m2 = clean.match(/from\s+(.+?)\s+for/i);
if (m2) return m2[1].replace(/\[\d+\]/, '').trim();
return 'Unknown';
}
function extractVictimId(text, data) {
if (data && typeof data === 'object') {
for (const key of ['victim_id', 'target_id', 'defender_id', 'user_id', 'id']) {
if (data[key]) return String(data[key]);
}
}
const match = String(text || '').match(/\[(\d+)\]/);
return match ? match[1] : '-';
}
function normalizeLogs(apiData) {
const raw = apiData.log || apiData.logs || {};
const arr = Array.isArray(raw)
? raw
: Object.entries(raw).map(([id, entry]) => ({ id, ...entry }));
return arr.map(entry => {
const text = [
entry.title,
entry.text,
entry.log,
entry.message,
entry.event
].filter(Boolean).join(' ');
const lower = text.toLowerCase();
const data = entry.data || entry.params || entry.details || {};
if (!lower.includes('mug')) return null;
const item = {
sourceId: String(entry.id || entry.log_id || ''),
timestamp: Number(entry.timestamp || entry.time || entry.date || 0),
text,
victim: extractVictim(text, data),
victimId: extractVictimId(text, data),
cash: extractMoney(text, data)
};
item.uid = item.sourceId || uniqueId(item);
return item;
}).filter(Boolean);
}
function mergeMugs(newMugs) {
let added = 0;
newMugs.forEach(mug => {
if (!state.db.seen[mug.uid]) {
state.db.seen[mug.uid] = true;
state.db.mugs.push(mug);
added++;
}
});
state.db.mugs.sort((a, b) => b.timestamp - a.timestamp);
saveDB();
return added;
}
function getStats() {
const mugs = state.db.mugs || [];
const total = mugs.length;
const cash = mugs.reduce((sum, x) => sum + Number(x.cash || 0), 0);
const biggest = mugs.reduce((max, x) => Number(x.cash || 0) > Number(max.cash || 0) ? x : max, { cash: 0, victim: '-' });
const avg = total ? Math.round(cash / total) : 0;
const targets = getTargets();
const bestTarget = targets[0] || null;
return { total, cash, biggest, avg, bestTarget };
}
function getTargets() {
const map = {};
state.db.mugs.forEach(x => {
const key = `${x.victim}|${x.victimId}`;
if (!map[key]) {
map[key] = {
victim: x.victim || 'Unknown',
victimId: x.victimId || '-',
mugs: 0,
cash: 0,
biggest: 0,
first: x.timestamp,
last: x.timestamp
};
}
const t = map[key];
t.mugs++;
t.cash += Number(x.cash || 0);
t.biggest = Math.max(t.biggest, Number(x.cash || 0));
t.first = Math.min(t.first || x.timestamp, x.timestamp);
t.last = Math.max(t.last || x.timestamp, x.timestamp);
});
return Object.values(map)
.map(t => ({
...t,
avg: t.mugs ? Math.round(t.cash / t.mugs) : 0,
rating: rating(t.mugs ? Math.round(t.cash / t.mugs) : 0)
}))
.sort((a, b) => b.cash - a.cash);
}
function rating(avg) {
if (avg >= 10000000) return { label: 'S', cls: 's', icon: '💀' };
if (avg >= 5000000) return { label: 'A', cls: 'a', icon: '🔥' };
if (avg >= 1000000) return { label: 'B', cls: 'b', icon: '⚔️' };
return { label: 'C', cls: 'c', icon: '🪙' };
}
function analytics() {
const mugs = state.db.mugs || [];
const byDay = {};
mugs.forEach(x => {
if (!x.timestamp) return;
const d = new Date(x.timestamp * 1000).toLocaleDateString();
if (!byDay[d]) byDay[d] = { mugs: 0, cash: 0 };
byDay[d].mugs++;
byDay[d].cash += Number(x.cash || 0);
});
const days = Object.entries(byDay)
.map(([day, v]) => ({ day, ...v }))
.sort((a, b) => new Date(b.day) - new Date(a.day));
const bestDay = days.reduce((max, d) => d.cash > max.cash ? d : max, { day: '-', cash: 0, mugs: 0 });
const last7 = days.slice(0, 7);
const weekCash = last7.reduce((s, d) => s + d.cash, 0);
const weekMugs = last7.reduce((s, d) => s + d.mugs, 0);
return { days, bestDay, weekCash, weekMugs };
}
function render() {
const old = document.getElementById('mug-log-box');
if (old) old.remove();
const mount = findMount();
if (!mount) return;
const box = document.createElement('div');
box.id = 'mug-log-box';
box.innerHTML = `
<div class="ml-head">
<div class="ml-title">MUG LOG</div>
<div class="ml-actions">
<button class="ml-btn" id="ml-refresh">${state.loading ? '...' : '↻'}</button>
<button class="ml-btn" id="ml-settings">⚙</button>
</div>
</div>
<div class="ml-tabs">
${TABS.map(([id, label]) => `
<button class="ml-tab ${state.activeTab === id ? 'active' : ''}" data-tab="${id}">
${label}
</button>
`).join('')}
</div>
<div class="ml-body">
${renderActiveTab()}
</div>
`;
mount.appendChild(box);
box.querySelectorAll('.ml-tab').forEach(btn => {
btn.onclick = () => {
state.activeTab = btn.dataset.tab;
localStorage.setItem(LS.activeTab, state.activeTab);
render();
};
});
const refresh = document.getElementById('ml-refresh');
const settings = document.getElementById('ml-settings');
if (refresh) refresh.onclick = () => fetchLogs(true);
if (settings) settings.onclick = openSettings;
box.querySelectorAll('[data-target-id]').forEach(el => {
el.onclick = () => openTargetReport(el.dataset.targetKey);
});
const exp = document.getElementById('ml-export');
const imp = document.getElementById('ml-import');
const reset = document.getElementById('ml-reset');
if (exp) exp.onclick = exportDB;
if (imp) imp.onclick = openImport;
if (reset) reset.onclick = resetDB;
}
function renderActiveTab() {
if (state.activeTab === 'targets') return renderTargets();
if (state.activeTab === 'analytics') return renderAnalytics();
if (state.activeTab === 'milestones') return renderMilestones();
if (state.activeTab === 'ledger') return renderLedger();
return renderOverview();
}
function renderOverview() {
const s = getStats();
const recent = state.db.mugs.slice(0, 7);
return `
<div class="ml-sub">
<span>Personal mugging analytics dashboard.</span>
<span>${state.status || 'Stored locally in your browser.'}</span>
</div>
<div class="ml-grid">
${card('💰', 'Total Mugs', s.total.toLocaleString(), 'lifetime tracked')}
${card('💵', 'Total Cash Mugged', shortMoney(s.cash), money(s.cash))}
${card('👑', 'Biggest Mug', shortMoney(s.biggest.cash), `from ${s.biggest.victim}`)}
${card('📈', 'Average Mug', shortMoney(s.avg), 'per mug')}
${card('🎯', 'Best Target', s.bestTarget ? s.bestTarget.victim : '-', s.bestTarget ? `${s.bestTarget.mugs} mugs / ${shortMoney(s.bestTarget.cash)}` : 'no data yet')}
</div>
<div class="ml-section-title">RECENT MUGS</div>
${
recent.length
? mugTable(recent)
: `<div class="ml-empty">${state.apiKey ? 'No mug logs found yet. Go mug someone 😈' : 'Add your Torn API key to load mug logs.'}</div>`
}
${footer()}
`;
}
function renderTargets() {
const targets = getTargets();
if (!targets.length) {
return `
<div class="ml-sub">
<span>Target Intelligence ranks mugged players by total earnings.</span>
<span>No target data yet.</span>
</div>
<div class="ml-empty">No targets recorded yet.</div>
${footer()}
`;
}
return `
<div class="ml-sub">
<span>Target Intelligence ranks your most profitable victims.</span>
<span>Click a target name for a detailed report.</span>
</div>
<table class="ml-table">
<thead>
<tr>
<th>#</th>
<th>Target</th>
<th>Mugs</th>
<th>Total Earned</th>
<th>Average</th>
<th>Biggest</th>
<th>Rating</th>
</tr>
</thead>
<tbody>
${targets.slice(0, 25).map((t, i) => `
<tr>
<td class="ml-rank">${i + 1}</td>
<td class="ml-name" data-target-id="${t.victimId}" data-target-key="${escapeAttr(t.victim + '|' + t.victimId)}">${t.victim} [${t.victimId}]</td>
<td>${t.mugs}</td>
<td class="ml-money">${money(t.cash)}</td>
<td class="ml-money">${money(t.avg)}</td>
<td class="ml-money">${money(t.biggest)}</td>
<td><span class="ml-rating ${t.rating.cls}">${t.rating.icon} ${t.rating.label}</span></td>
</tr>
`).join('')}
</tbody>
</table>
${footer()}
`;
}
function renderAnalytics() {
const a = analytics();
const maxCash = Math.max(...a.days.slice(0, 7).map(d => d.cash), 1);
return `
<div class="ml-sub">
<span>Daily tracking based on locally stored mug records.</span>
<span>Last 7 tracked days</span>
</div>
<div class="ml-grid">
${card('📅', '7-Day Mugs', a.weekMugs.toLocaleString(), 'tracked days only')}
${card('💸', '7-Day Cash', shortMoney(a.weekCash), money(a.weekCash))}
${card('🏆', 'Best Day', shortMoney(a.bestDay.cash), `${a.bestDay.mugs} mugs / ${a.bestDay.day}`)}
${card('📊', 'Tracked Days', a.days.length.toLocaleString(), 'days with mug data')}
${card('🔥', 'Daily Average', shortMoney(a.days.length ? a.days.reduce((s,d)=>s+d.cash,0)/a.days.length : 0), 'cash per active day')}
</div>
<div class="ml-section-title">LAST 7 DAYS</div>
<div class="ml-panel">
${
a.days.slice(0, 7).length
? a.days.slice(0, 7).map(d => `
<div class="ml-bar-row">
<div class="ml-bar-top">
<span>${d.day} — ${d.mugs} mugs</span>
<span class="ml-money">${money(d.cash)}</span>
</div>
<div class="ml-bar"><div class="ml-bar-fill" style="width:${Math.max(4, Math.round((d.cash / maxCash) * 100))}%"></div></div>
</div>
`).join('')
: `<div class="ml-empty">No daily analytics yet.</div>`
}
</div>
${footer()}
`;
}
function renderMilestones() {
const s = getStats();
const goals = [
['🥉', 'First Mug', s.total >= 1, `${s.total}/1`],
['🥈', '100 Mugs', s.total >= 100, `${s.total}/100`],
['🥇', '1,000 Mugs', s.total >= 1000, `${s.total}/1,000`],
['💰', '$100M Mugged', s.cash >= 100000000, `${shortMoney(s.cash)}/$100M`],
['💎', '$1B Mugged', s.cash >= 1000000000, `${shortMoney(s.cash)}/$1B`],
['👑', '$10M Single Mug', s.biggest.cash >= 10000000, `${shortMoney(s.biggest.cash)}/$10M`]
];
return `
<div class="ml-sub">
<span>Personal mugging milestones.</span>
<span>${goals.filter(g => g[2]).length}/${goals.length} unlocked</span>
</div>
<div class="ml-two">
${goals.map(g => `
<div class="ml-panel">
<div style="font-size:24px;margin-bottom:8px;">${g[0]}</div>
<div class="ml-label">${g[1]}</div>
<div class="ml-value">${g[2] ? 'Unlocked' : 'Locked'}</div>
<div class="ml-small">${g[3]}</div>
</div>
`).join('')}
</div>
${footer()}
`;
}
function renderLedger() {
const s = getStats();
const started = state.db.started ? new Date(state.db.started).toLocaleDateString() : '-';
const days = state.db.started ? Math.max(1, Math.ceil((Date.now() - state.db.started) / 86400000)) : 1;
return `
<div class="ml-sub">
<span>The Ledger is your permanent local Mug Log database.</span>
<span>Export backups before clearing browser data.</span>
</div>
<div class="ml-grid">
${card('📚', 'Records Stored', s.total.toLocaleString(), 'unique mug logs')}
${card('💵', 'Lifetime Cash', shortMoney(s.cash), money(s.cash))}
${card('📆', 'Started', started, `${days} days tracking`)}
${card('🎯', 'Targets', getTargets().length.toLocaleString(), 'unique players')}
${card('🧾', 'Version', 'v0.2.0', 'Target Intelligence')}
</div>
<div class="ml-panel">
<div class="ml-section-title">BACKUP TOOLS</div>
<button class="ml-btn" id="ml-export">Export Ledger</button>
<button class="ml-btn" id="ml-import">Import Ledger</button>
<button class="ml-btn" id="ml-reset">Reset Ledger</button>
<div class="ml-small" style="margin-top:10px;">
Your API key and Mug Ledger are stored only in your browser localStorage.
</div>
</div>
${footer()}
`;
}
function card(icon, label, value, small) {
return `
<div class="ml-card">
<div class="ml-icon">${icon}</div>
<div class="ml-label">${label}</div>
<div class="ml-value">${value}</div>
<div class="ml-small">${small || ''}</div>
</div>
`;
}
function mugTable(mugs) {
return `
<table class="ml-table">
<thead>
<tr>
<th>Victim</th>
<th>Victim ID</th>
<th>Time</th>
<th>Cash Mugged</th>
</tr>
</thead>
<tbody>
${mugs.map(x => `
<tr>
<td class="ml-name">${x.victim}</td>
<td>${x.victimId}</td>
<td>${ago(x.timestamp)}</td>
<td class="ml-money">${money(x.cash)}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
function footer() {
const last = Number(localStorage.getItem(LS.cacheTime) || 0);
return `
<div class="ml-footer">
<span>No gameplay actions are automated.</span>
<span>${last ? 'Last API check: ' + new Date(last).toLocaleTimeString() : 'Never checked API'}</span>
</div>
`;
}
function findMount() {
return document.querySelector('#mainContainer .content-wrapper')
|| document.querySelector('#mainContainer')
|| document.querySelector('.content-wrapper')
|| document.body;
}
function openSettings() {
const bg = document.createElement('div');
bg.className = 'ml-modal-bg';
bg.innerHTML = `
<div class="ml-modal">
<h3>Mug Log Settings</h3>
<input class="ml-input" id="ml-key" type="password" placeholder="Torn API key" value="${escapeAttr(state.apiKey)}">
<div class="ml-small">Recommended: limited access key with log access only.</div>
<div class="ml-modal-actions">
<button class="ml-btn" id="ml-cancel">Cancel</button>
<button class="ml-btn" id="ml-save">Save</button>
</div>
</div>
`;
document.body.appendChild(bg);
document.getElementById('ml-cancel').onclick = () => bg.remove();
document.getElementById('ml-save').onclick = () => {
state.apiKey = document.getElementById('ml-key').value.trim();
localStorage.setItem(LS.apiKey, state.apiKey);
bg.remove();
fetchLogs(true);
};
}
function openTargetReport(key) {
const [name, id] = key.split('|');
const target = getTargets().find(t => t.victim === name && t.victimId === id);
if (!target) return;
const bg = document.createElement('div');
bg.className = 'ml-modal-bg';
bg.innerHTML = `
<div class="ml-modal">
<h3>Target Report</h3>
<table class="ml-table">
<tbody>
<tr><td>Target</td><td class="ml-name">${target.victim} [${target.victimId}]</td></tr>
<tr><td>Total Mugs</td><td>${target.mugs}</td></tr>
<tr><td>Total Earned</td><td class="ml-money">${money(target.cash)}</td></tr>
<tr><td>Average Payout</td><td class="ml-money">${money(target.avg)}</td></tr>
<tr><td>Biggest Mug</td><td class="ml-money">${money(target.biggest)}</td></tr>
<tr><td>First Seen</td><td>${dateOnly(target.first)}</td></tr>
<tr><td>Last Mugged</td><td>${ago(target.last)}</td></tr>
<tr><td>Heat Rating</td><td><span class="ml-rating ${target.rating.cls}">${target.rating.icon} ${target.rating.label}</span></td></tr>
</tbody>
</table>
<div class="ml-modal-actions" style="margin-top:12px;">
<button class="ml-btn" id="ml-close-report">Close</button>
</div>
</div>
`;
document.body.appendChild(bg);
document.getElementById('ml-close-report').onclick = () => bg.remove();
}
async function fetchLogs(force = false) {
if (!state.apiKey) {
state.status = 'Add API key in settings.';
render();
return;
}
const last = Number(localStorage.getItem(LS.cacheTime) || 0);
if (!force && Date.now() - last < CONFIG.cacheMs) {
render();
return;
}
state.loading = true;
state.status = 'Loading API logs...';
render();
try {
const res = await fetch(CONFIG.apiUrl + encodeURIComponent(state.apiKey));
const json = await res.json();
if (json.error) throw new Error(json.error.error || JSON.stringify(json.error));
const logs = normalizeLogs(json);
const added = mergeMugs(logs);
localStorage.setItem(LS.cacheTime, String(Date.now()));
state.status = added ? `Added ${added} new mug record${added === 1 ? '' : 's'}.` : 'No new mug records found.';
} catch (err) {
state.status = 'API error: ' + err.message;
}
state.loading = false;
render();
}
function exportDB() {
const payload = JSON.stringify(state.db, null, 2);
const blob = new Blob([payload], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `mug-log-ledger-${Date.now()}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function openImport() {
const bg = document.createElement('div');
bg.className = 'ml-modal-bg';
bg.innerHTML = `
<div class="ml-modal">
<h3>Import Ledger</h3>
<textarea class="ml-textarea" id="ml-import-data" placeholder="Paste exported Mug Log JSON here"></textarea>
<div class="ml-modal-actions">
<button class="ml-btn" id="ml-import-cancel">Cancel</button>
<button class="ml-btn" id="ml-import-save">Import</button>
</div>
</div>
`;
document.body.appendChild(bg);
document.getElementById('ml-import-cancel').onclick = () => bg.remove();
document.getElementById('ml-import-save').onclick = () => {
try {
const data = JSON.parse(document.getElementById('ml-import-data').value);
if (!data || !Array.isArray(data.mugs)) throw new Error('Invalid Mug Log file.');
data.seen = data.seen || {};
data.mugs.forEach(m => {
m.uid = m.uid || uniqueId(m);
data.seen[m.uid] = true;
});
state.db = data;
saveDB();
bg.remove();
state.status = 'Ledger imported.';
render();
} catch (err) {
alert('Import failed: ' + err.message);
}
};
}
function resetDB() {
if (!confirm('Reset Mug Log Ledger? This clears stored mug history from this browser.')) return;
state.db = {
version: '0.2.0',
started: Date.now(),
mugs: [],
seen: {}
};
saveDB();
state.status = 'Ledger reset.';
render();
}
function escapeAttr(str) {
return String(str || '')
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/</g, '<')
.replace(/>/g, '>');
}
function start() {
injectCSS();
render();
fetchLogs(false);
const obs = new MutationObserver(() => {
if (!document.getElementById('mug-log-box')) render();
});
obs.observe(document.body, { childList: true, subtree: true });
}
start();
})();