Mug Log

Personal mugging analytics dashboard for Torn.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==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, '&amp;')
            .replace(/"/g, '&quot;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;');
    }

    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();

})();