Supply Pack Analyzer

Analyze supply pack profitability in Torn City — tracks openings, purchases, drop rates, and EV via API sync.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         Supply Pack Analyzer
// @namespace    torn.supply.pack.analyzer
// @version      1.2.3
// @description  Analyze supply pack profitability in Torn City — tracks openings, purchases, drop rates, and EV via API sync.
// @author       lannav
// @match        https://www.torn.com/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
    "use strict";

    // ════════════════════════════════════════════════════════════
    //  CONSTANTS & CONFIG
    // ════════════════════════════════════════════════════════════

    const VERSION = "1.2.3";
    const DB_NAME = "spa_db";
    const DB_VERSION = 1;
    const LS = (k) => "spa_" + k;

    const API_BASE = "https://api.torn.com/v2";
    const API_DELAY = 750;
    const API_PAGE_LIMIT = 100;
    const API_MAX_LOG_IDS = 10; // Torn API silently returns 0 results if >10 log IDs

    // Log‑type IDs that represent opening/using a supply pack
    // Split into chunks of 10 for API calls
    const OPEN_LOG_IDS = [
        2330, 2350, 2360, 2370, 2390, 2400, 2405, 2406, 2407, 2480, 2500,
        2510, 2520, 2525, 2605, 2615, 4001,
    ];
    // Log‑type IDs for purchases (we filter to supply‑pack items client‑side)
    const BUY_LOG_IDS = [1112, 1225];

    // ════════════════════════════════════════════════════════════
    //  UTILITIES
    // ════════════════════════════════════════════════════════════

    const fmt = {
        money(n) {
            if (n == null) return "$0";
            const abs = Math.abs(n);
            const s = abs >= 1e9 ? (abs / 1e9).toFixed(2) + "B"
                : abs >= 1e6 ? (abs / 1e6).toFixed(2) + "M"
                : abs >= 1e3 ? (abs / 1e3).toFixed(1) + "K"
                : abs.toLocaleString();
            return (n < 0 ? "-$" : "$") + s;
        },
        moneyFull(n) {
            return (n < 0 ? "-" : "") + "$" + Math.abs(n).toLocaleString();
        },
        pct(n) {
            return (n >= 0 ? "+" : "") + n.toFixed(2) + "%";
        },
        num(n) {
            return n.toLocaleString();
        },
        date(ts) {
            const d = new Date(ts * 1000);
            return d.toLocaleDateString() + " " + d.toLocaleTimeString();
        },
        shortDate(ts) {
            const d = new Date(ts * 1000);
            return d.toLocaleDateString();
        },
    };

    function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }

    // ════════════════════════════════════════════════════════════
    //  INDEXED-DB WRAPPER
    // ════════════════════════════════════════════════════════════

    class Database {
        constructor() { this.db = null; }

        open() {
            return new Promise((resolve, reject) => {
                const req = indexedDB.open(DB_NAME, DB_VERSION);
                req.onupgradeneeded = (e) => {
                    const db = e.target.result;
                    if (!db.objectStoreNames.contains("openings")) {
                        const s = db.createObjectStore("openings", { keyPath: "id" });
                        s.createIndex("timestamp", "timestamp");
                        s.createIndex("packItemId", "packItemId");
                    }
                    if (!db.objectStoreNames.contains("purchases")) {
                        const s = db.createObjectStore("purchases", { keyPath: "id" });
                        s.createIndex("timestamp", "timestamp");
                        s.createIndex("packItemId", "packItemId");
                    }
                    if (!db.objectStoreNames.contains("items")) {
                        const s = db.createObjectStore("items", { keyPath: "id" });
                        s.createIndex("type", "type");
                        s.createIndex("name", "name");
                    }
                    if (!db.objectStoreNames.contains("priceHistory")) {
                        const s = db.createObjectStore("priceHistory", { autoIncrement: true });
                        s.createIndex("itemId", "itemId");
                        s.createIndex("timestamp", "timestamp");
                    }
                };
                req.onsuccess = (e) => { this.db = e.target.result; resolve(); };
                req.onerror = (e) => reject(e.target.error);
            });
        }

        _tx(store, mode) {
            const tx = this.db.transaction(store, mode);
            return tx.objectStore(store);
        }

        put(store, data) {
            return new Promise((resolve, reject) => {
                const r = this._tx(store, "readwrite").put(data);
                r.onsuccess = () => resolve();
                r.onerror = (e) => reject(e.target.error);
            });
        }

        putBatch(store, items) {
            return new Promise((resolve, reject) => {
                const tx = this.db.transaction(store, "readwrite");
                const s = tx.objectStore(store);
                for (const item of items) s.put(item);
                tx.oncomplete = () => resolve();
                tx.onerror = (e) => reject(e.target.error);
            });
        }

        get(store, key) {
            return new Promise((resolve, reject) => {
                const r = this._tx(store, "readonly").get(key);
                r.onsuccess = () => resolve(r.result);
                r.onerror = (e) => reject(e.target.error);
            });
        }

        getAll(store) {
            return new Promise((resolve, reject) => {
                const r = this._tx(store, "readonly").getAll();
                r.onsuccess = () => resolve(r.result);
                r.onerror = (e) => reject(e.target.error);
            });
        }

        getAllByIndex(store, indexName, range) {
            return new Promise((resolve, reject) => {
                const r = this._tx(store, "readonly").index(indexName).getAll(range);
                r.onsuccess = () => resolve(r.result);
                r.onerror = (e) => reject(e.target.error);
            });
        }

        count(store) {
            return new Promise((resolve, reject) => {
                const r = this._tx(store, "readonly").count();
                r.onsuccess = () => resolve(r.result);
                r.onerror = (e) => reject(e.target.error);
            });
        }

        clear(store) {
            return new Promise((resolve, reject) => {
                const r = this._tx(store, "readwrite").clear();
                r.onsuccess = () => resolve();
                r.onerror = (e) => reject(e.target.error);
            });
        }

        /** Iterate cursor, calling fn(value) for each record. Memory‑efficient. */
        iterate(store, indexName, range, fn) {
            return new Promise((resolve, reject) => {
                const tx = this.db.transaction(store, "readonly");
                const idx = tx.objectStore(store).index(indexName);
                const req = idx.openCursor(range);
                req.onsuccess = (e) => {
                    const c = e.target.result;
                    if (c) { fn(c.value); c.continue(); }
                };
                tx.oncomplete = () => resolve();
                tx.onerror = (e) => reject(e.target.error);
            });
        }
    }

    // ════════════════════════════════════════════════════════════
    //  API CLIENT
    // ════════════════════════════════════════════════════════════

    class TornAPI {
        constructor() {
            this.apiKey = localStorage.getItem(LS("apiKey")) || "";
            this._lastReq = 0;
        }

        async _rateLimit() {
            const now = Date.now();
            const wait = API_DELAY - (now - this._lastReq);
            if (wait > 0) await sleep(wait);
            this._lastReq = Date.now();
        }

        async _fetch(url) {
            await this._rateLimit();
            const sep = url.includes("?") ? "&" : "?";
            const res = await fetch(url + sep + "key=" + this.apiKey);
            const data = await res.json();
            if (data.error) throw new Error(data.error.error);
            return data;
        }

        async fetchEndpoint(path, params = {}) {
            const url = new URL(API_BASE + path);
            for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
            return this._fetch(url.toString());
        }

        async validate(key) {
            const res = await fetch(
                `${API_BASE}/user/?selections=basic&key=${key}`
            );
            return res.json();
        }

        async fetchSupplyPacks() {
            return this.fetchEndpoint("/torn/items", { cat: "Supply Pack" });
        }

        async fetchItemsByIds(ids) {
            return this.fetchEndpoint(`/torn/${ids.join(",")}/items`);
        }

        async fetchAllItems() {
            return this.fetchEndpoint("/torn/items");
        }

        /**
         * Paginate through user logs for given log‑type IDs.
         * Torn API caps at 10 log IDs per request, so we chunk automatically.
         * Uses sort=desc and follows `prev` links for full pagination.
         * `fromTs` filters to entries with timestamp > fromTs (incremental sync).
         */
        async fetchLogs(logIds, fromTs, onBatch, onProgress, signal) {
            let total = 0;
            const chunks = [];
            for (let i = 0; i < logIds.length; i += API_MAX_LOG_IDS)
                chunks.push(logIds.slice(i, i + API_MAX_LOG_IDS));

            for (let ci = 0; ci < chunks.length; ci++) {
                const logParam = chunks[ci].join(",");
                let url =
                    `${API_BASE}/user/log?log=${logParam}&limit=${API_PAGE_LIMIT}&sort=desc` +
                    (fromTs ? `&from=${fromTs}` : "");
                while (url) {
                    if (signal && signal.aborted) return total;
                    const data = await this._fetch(url);
                    const logs = data.log || [];
                    if (!logs.length) break; // No results — stop paginating this chunk
                    await onBatch(logs);
                    total += logs.length;
                    if (onProgress) onProgress(total, ci + 1, chunks.length);
                    const prev = data._metadata?.links?.prev;
                    url = prev || null;
                }
            }
            return total;
        }
    }

    // ════════════════════════════════════════════════════════════
    //  LOG PARSER
    // ════════════════════════════════════════════════════════════

    class LogParser {
        constructor() {
            this.supplyPackIds = new Set();
            this.supplyPackNameToId = new Map();
            this.itemNameToId = new Map();
            this.itemIdToName = new Map();
        }

        loadItems(itemsArr) {
            for (const it of itemsArr) {
                this.itemNameToId.set(it.name, it.id);
                this.itemIdToName.set(it.id, it.name);
                if (it.type === "Supply Pack") {
                    this.supplyPackIds.add(it.id);
                    this.supplyPackNameToId.set(it.name, it.id);
                }
            }
        }

        // ── API log parsing ───────────────────────────────────

        parseAPIOpening(log) {
            const packId = log.data?.item;
            if (!packId) return null;
            const items = (log.data?.items || []).map((i) => ({
                itemId: i.id,
                name: this.itemIdToName.get(i.id) || `Item #${i.id}`,
                qty: i.qty || 1,
            }));
            return {
                id: log.id,
                timestamp: log.timestamp,
                packItemId: packId,
                packName: this.itemIdToName.get(packId) || `Pack #${packId}`,
                items,
                money: log.data?.money || 0,
                source: "api",
            };
        }

        parseAPIPurchase(log) {
            const logItems = log.data?.items || [];
            if (!logItems.length) return null;
            const item = logItems[0];
            if (!this.supplyPackIds.has(item.id)) return null;
            const channel = log.details?.id === 1112 ? "itemmarket" : "bazaar";
            return {
                id: log.id,
                timestamp: log.timestamp,
                packItemId: item.id,
                packName: this.itemIdToName.get(item.id) || `Pack #${item.id}`,
                qty: item.qty || 1,
                costEach: log.data?.cost_each || 0,
                costTotal: log.data?.cost_total || 0,
                channel,
                source: "api",
            };
        }

    }

    // ════════════════════════════════════════════════════════════
    //  ANALYZER
    // ════════════════════════════════════════════════════════════

    class Analyzer {
        constructor(db) { this.db = db; this._itemPriceCache = {}; }

        async _loadPrices() {
            const all = await this.db.getAll("items");
            this._itemPriceCache = {};
            for (const it of all) this._itemPriceCache[it.id] = it;
        }

        _price(itemId) {
            const it = this._itemPriceCache[itemId];
            return it?.marketPrice || 0;
        }

        _valueOpening(o) {
            let v = o.money || 0;
            for (const it of o.items) v += this._price(it.itemId) * it.qty;
            return v;
        }

        async getOverview(from, to) {
            await this._loadPrices();
            const range = (from || to)
                ? IDBKeyRange.bound(from || 0, to || 9999999999)
                : null;

            const packs = {};
            const addPack = (id, name) => {
                if (!packs[id]) packs[id] = {
                    packItemId: id, packName: name,
                    opened: 0, purchased: 0,
                    totalPurchaseCost: 0, // raw sum of all purchase costs
                    totalSpent: 0,        // cost attributed to opened packs only
                    totalValue: 0, totalMoney: 0,
                    itemsReceived: {},
                };
                return packs[id];
            };

            // Openings
            const openings = range
                ? await this.db.getAllByIndex("openings", "timestamp", range)
                : await this.db.getAll("openings");
            for (const o of openings) {
                const p = addPack(o.packItemId, o.packName);
                p.opened++;
                const val = this._valueOpening(o);
                p.totalValue += val;
                p.totalMoney += o.money || 0;
                for (const it of o.items) {
                    const key = it.itemId || it.name;
                    if (!p.itemsReceived[key])
                        p.itemsReceived[key] = { itemId: it.itemId, name: it.name, qty: 0, drops: 0 };
                    p.itemsReceived[key].qty += it.qty;
                    p.itemsReceived[key].drops++;
                }
            }

            // Purchases — compute avg buy price, then attribute cost only to opened packs
            const purchases = range
                ? await this.db.getAllByIndex("purchases", "timestamp", range)
                : await this.db.getAll("purchases");
            for (const p of purchases) {
                const pk = addPack(p.packItemId, p.packName);
                pk.purchased += p.qty;
                pk.totalPurchaseCost += p.costTotal;
            }

            // Cost-per-opened: spent = opened × avg_buy_price
            // All opened packs valued at avg buy price, even if obtained via trades/gifts
            for (const p of Object.values(packs)) {
                const avgBuyPrice = p.purchased > 0 ? p.totalPurchaseCost / p.purchased : 0;
                p.avgBuyPrice = avgBuyPrice;
                p.totalSpent = p.opened * avgBuyPrice;
            }

            // Totals
            let totalSpent = 0, totalValue = 0, totalOpened = 0, totalPurchased = 0;
            for (const p of Object.values(packs)) {
                totalSpent += p.totalSpent;
                totalValue += p.totalValue;
                totalOpened += p.opened;
                totalPurchased += p.purchased;
            }

            return {
                packs, totalSpent, totalValue, totalOpened, totalPurchased,
                pnl: totalValue - totalSpent,
                roi: totalSpent > 0 ? ((totalValue - totalSpent) / totalSpent) * 100 : 0,
                valuePerPack: totalOpened > 0 ? totalValue / totalOpened : 0,
            };
        }

        async getDropRates(packItemId, from, to) {
            await this._loadPrices();
            const allOpenings = from || to
                ? await this.db.getAllByIndex("openings", "timestamp",
                    IDBKeyRange.bound(from || 0, to || 9999999999))
                : await this.db.getAll("openings");
            const openings = packItemId
                ? allOpenings.filter((o) => o.packItemId === packItemId)
                : allOpenings;

            const total = openings.length;
            const items = {};
            let totalMoney = 0;
            let moneyDrops = 0;
            for (const o of openings) {
                if (o.money) { totalMoney += o.money; moneyDrops++; }
                for (const it of o.items) {
                    const key = it.itemId || it.name;
                    if (!items[key]) items[key] = { itemId: it.itemId, name: it.name, totalQty: 0, drops: 0 };
                    items[key].totalQty += it.qty;
                    items[key].drops++;
                }
            }

            const rates = Object.values(items).map((it) => ({
                ...it,
                dropRate: total > 0 ? (it.drops / total) * 100 : 0,
                avgQtyPerDrop: it.drops > 0 ? it.totalQty / it.drops : 0,
                avgQtyPerOpen: total > 0 ? it.totalQty / total : 0,
                unitPrice: this._price(it.itemId),
                valueContribution: total > 0
                    ? (this._price(it.itemId) * it.totalQty) / total : 0,
            }));

            // Add cash as a virtual drop item
            if (totalMoney > 0) {
                rates.unshift({
                    itemId: null, name: "Cash",
                    totalQty: totalMoney, drops: moneyDrops,
                    dropRate: total > 0 ? (moneyDrops / total) * 100 : 0,
                    avgQtyPerDrop: moneyDrops > 0 ? totalMoney / moneyDrops : 0,
                    avgQtyPerOpen: total > 0 ? totalMoney / total : 0,
                    unitPrice: 1,
                    valueContribution: total > 0 ? totalMoney / total : 0,
                });
            }

            return { total, rates };
        }

        async getEV(packItemId) {
            await this._loadPrices();
            const openings = packItemId
                ? await this.db.getAllByIndex("openings", "packItemId", IDBKeyRange.only(packItemId))
                : await this.db.getAll("openings");
            if (!openings.length) return null;

            let totalVal = 0;
            for (const o of openings) totalVal += this._valueOpening(o);
            const avgReturn = totalVal / openings.length;

            const purchases = await this.db.getAll("purchases");
            let totalCost = 0, totalQty = 0;
            for (const p of purchases) {
                if (packItemId && p.packItemId !== packItemId) continue;
                totalCost += p.costTotal; totalQty += p.qty;
            }
            const avgCost = totalQty > 0 ? totalCost / totalQty : 0;

            return {
                avgReturn,
                avgCost,
                ev: avgReturn - avgCost,
                breakEven: avgReturn,
                sampleSize: openings.length,
            };
        }

        async getBestSources(packItemId, from, to) {
            const purchases = from || to
                ? await this.db.getAllByIndex("purchases", "timestamp",
                    IDBKeyRange.bound(from || 0, to || 9999999999))
                : await this.db.getAll("purchases");
            const filtered = packItemId
                ? purchases.filter((p) => p.packItemId === packItemId)
                : purchases;

            const sources = {};
            for (const p of filtered) {
                const ch = p.channel || "unknown";
                if (!sources[ch]) sources[ch] = { channel: ch, totalCost: 0, totalQty: 0 };
                sources[ch].totalCost += p.costTotal;
                sources[ch].totalQty += p.qty;
            }
            return Object.values(sources).map((s) => ({
                ...s, avgCost: s.totalQty > 0 ? s.totalCost / s.totalQty : 0,
            })).sort((a, b) => a.avgCost - b.avgCost);
        }

        async getPriceTrends() {
            const history = await this.db.getAll("priceHistory");
            const byItem = {};
            for (const h of history) {
                if (!byItem[h.itemId]) byItem[h.itemId] = [];
                byItem[h.itemId].push(h);
            }
            for (const k of Object.keys(byItem)) {
                byItem[k].sort((a, b) => a.timestamp - b.timestamp);
            }
            return byItem;
        }
    }

    // ════════════════════════════════════════════════════════════
    //  SYNC MANAGER
    // ════════════════════════════════════════════════════════════

    class SyncManager {
        constructor(db, api, parser) {
            this.db = db;
            this.api = api;
            this.parser = parser;
            this.syncing = false;
            this.abortController = null;
            this.onProgress = null;
        }

        async sync() {
            if (this.syncing) return;
            this.syncing = true;
            this.abortController = new AbortController();
            const errors = [];
            let openCount = 0, buyCount = 0;
            try {
                // 1. Fetch supply pack definitions + all items
                if (this.onProgress) this.onProgress("Fetching item database...");
                await this._syncItems();

                // 2. Fetch opening logs
                try {
                    if (this.onProgress) this.onProgress("Fetching opening logs...");
                    let openMaxTs = +(localStorage.getItem(LS("lastOpenSync")) || 0);
                    openCount = await this.api.fetchLogs(
                        OPEN_LOG_IDS, openMaxTs || null,
                        async (logs) => {
                            const parsed = logs
                                .map((l) => this.parser.parseAPIOpening(l))
                                .filter(Boolean);
                            if (parsed.length) await this.db.putBatch("openings", parsed);
                            // Track the NEWEST timestamp across all batches
                            for (const l of logs) {
                                if (l.timestamp > openMaxTs) openMaxTs = l.timestamp;
                            }
                        },
                        (n, ci, ct) => { if (this.onProgress) this.onProgress(`Fetching openings... ${fmt.num(n)} logs (batch ${ci}/${ct})`); },
                        this.abortController.signal
                    );
                    if (openMaxTs > 0) localStorage.setItem(LS("lastOpenSync"), openMaxTs + 1);
                } catch (e) {
                    errors.push("Openings: " + e.message);
                    console.error("SPA opening sync error:", e);
                }

                // 3. Fetch purchase logs
                try {
                    if (this.onProgress) this.onProgress("Fetching purchase logs...");
                    let buyMaxTs = +(localStorage.getItem(LS("lastBuySync")) || 0);
                    buyCount = await this.api.fetchLogs(
                        BUY_LOG_IDS, buyMaxTs || null,
                        async (logs) => {
                            const parsed = logs
                                .map((l) => this.parser.parseAPIPurchase(l))
                                .filter(Boolean);
                            if (parsed.length) await this.db.putBatch("purchases", parsed);
                            for (const l of logs) {
                                if (l.timestamp > buyMaxTs) buyMaxTs = l.timestamp;
                            }
                        },
                        (n, ci, ct) => { if (this.onProgress) this.onProgress(`Fetching purchases... ${fmt.num(n)} logs (batch ${ci}/${ct})`); },
                        this.abortController.signal
                    );
                    if (buyMaxTs > 0) localStorage.setItem(LS("lastBuySync"), buyMaxTs + 1);
                } catch (e) {
                    errors.push("Purchases: " + e.message);
                    console.error("SPA purchase sync error:", e);
                }

                // 4. Refresh prices for items found in drops
                try {
                    if (this.onProgress) this.onProgress("Updating item prices...");
                    await this._refreshDropPrices();
                } catch (e) {
                    errors.push("Prices: " + e.message);
                    console.error("SPA price refresh error:", e);
                }

                // 5. Save price history snapshot
                try { await this._savePriceSnapshot(); } catch (e) { /* non-critical */ }

                localStorage.setItem(LS("lastSync"), Date.now());
                const msg = `Sync done. ${fmt.num(openCount)} opening logs, ${fmt.num(buyCount)} purchase logs.`;
                if (this.onProgress) this.onProgress(errors.length ? msg + " Errors: " + errors.join("; ") : msg);
            } catch (e) {
                // Only reaches here if _syncItems fails
                localStorage.setItem(LS("lastSync"), Date.now());
                if (this.onProgress) this.onProgress("Sync error: " + e.message);
                console.error("SPA sync error:", e);
            } finally {
                this.syncing = false;
            }
        }

        abort() {
            if (this.abortController) this.abortController.abort();
        }

        async _syncItems() {
            const data = await this.api.fetchAllItems();
            const items = (data.items || []).map((it) => ({
                id: it.id,
                name: it.name,
                type: it.type,
                image: it.image,
                marketPrice: it.value?.market_price || 0,
                sellPrice: it.value?.sell_price || 0,
                buyPrice: it.value?.buy_price || 0,
                circulation: it.circulation || 0,
                lastUpdated: Math.floor(Date.now() / 1000),
            }));
            await this.db.putBatch("items", items);
            this.parser.loadItems(items);
        }

        async _refreshDropPrices() {
            const openings = await this.db.getAll("openings");
            const ids = new Set();
            for (const o of openings) {
                for (const it of o.items) if (it.itemId) ids.add(it.itemId);
            }
            if (!ids.size) return;

            const idArr = [...ids];
            // Fetch in batches of 50
            for (let i = 0; i < idArr.length; i += 50) {
                const batch = idArr.slice(i, i + 50);
                try {
                    const data = await this.api.fetchItemsByIds(batch);
                    const items = (data.items || []).map((it) => ({
                        id: it.id,
                        name: it.name,
                        type: it.type,
                        image: it.image,
                        marketPrice: it.value?.market_price || 0,
                        sellPrice: it.value?.sell_price || 0,
                        buyPrice: it.value?.buy_price || 0,
                        circulation: it.circulation || 0,
                        lastUpdated: Math.floor(Date.now() / 1000),
                    }));
                    await this.db.putBatch("items", items);
                } catch (e) {
                    console.warn("SPA price refresh batch failed:", e);
                }
            }
        }

        async _savePriceSnapshot() {
            const items = await this.db.getAll("items");
            const ts = Math.floor(Date.now() / 1000);
            const snapshots = items
                .filter((it) => it.type === "Supply Pack" || it.marketPrice > 0)
                .map((it) => ({ itemId: it.id, timestamp: ts, marketPrice: it.marketPrice }));
            if (snapshots.length) await this.db.putBatch("priceHistory", snapshots);
        }
    }

    // ════════════════════════════════════════════════════════════
    //  CSS
    // ════════════════════════════════════════════════════════════

    function injectCSS() {
        const style = document.createElement("style");
        style.textContent = `
/* Supply Pack Analyzer */

/* Footer button — amber background, white icon like default buttons */
#spa-footer-btn{background:linear-gradient(to bottom,#c49000,#8a6500)!important}
#spa-footer-btn:hover{background:linear-gradient(to bottom,#daa520,#a07800)!important}

/* Overlay & Panel */
#spa-overlay{display:none;position:fixed;inset:0;z-index:999998;background:rgba(0,0,0,.7)}
#spa-panel{display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:999999;
  background:#1a1a1a;border:1px solid #444;border-radius:10px;overflow:hidden;resize:both;
  font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:#ddd;font-size:14px;
  width:760px;max-width:100vw;max-height:88vh;min-width:340px;min-height:300px;
  display:none;flex-direction:column}
#spa-panel *{box-sizing:border-box;color:inherit}

/* Dark scrollbars */
#spa-panel ::-webkit-scrollbar{width:6px;height:6px}
#spa-panel ::-webkit-scrollbar-track{background:#1a1a1a}
#spa-panel ::-webkit-scrollbar-thumb{background:#444;border-radius:3px}
#spa-panel ::-webkit-scrollbar-thumb:hover{background:#555}
#spa-panel{scrollbar-color:#444 #1a1a1a;scrollbar-width:thin}

#spa-header{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;
  background:#222;border-bottom:1px solid #444}
#spa-header h2{margin:0;font-size:17px;color:#fff}
#spa-header .spa-ver{color:#666;font-size:12px;margin-left:8px}
#spa-close{background:none;border:none;color:#999;font-size:22px;cursor:pointer;padding:4px 8px}
#spa-close:hover{color:#fff}
#spa-tabs{display:flex;background:#252525;border-bottom:1px solid #444;overflow-x:auto}
.spa-tab{padding:10px 20px;cursor:pointer;color:#999!important;border-bottom:2px solid transparent;
  white-space:nowrap;font-size:14px;transition:all .15s}
.spa-tab:hover{color:#ccc!important;background:#2a2a2a}
.spa-tab.active{color:#4fc3f7!important;border-bottom-color:#4fc3f7}
#spa-content{padding:16px;overflow-y:auto;flex:1;min-height:0}
.spa-cards{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px}
.spa-card{background:#252525;border:1px solid #333;border-radius:8px;padding:12px;color:#ddd}
.spa-card .label{color:#888;font-size:12px;text-transform:uppercase;letter-spacing:.5px}
.spa-card .value{font-size:22px;font-weight:700;margin-top:4px}
.spa-card .sub{color:#888;font-size:12px;margin-top:2px}
.spa-green{color:#4caf50!important}.spa-red{color:#ef5350!important}.spa-blue{color:#4fc3f7!important}.spa-yellow{color:#ffb74d!important}

/* Tables */
table.spa-table{width:100%;border-collapse:collapse;margin-top:8px}
.spa-table th,.spa-table td{padding:8px 12px;text-align:left;border-bottom:1px solid #333;font-size:14px;
  color:#ddd;white-space:nowrap;vertical-align:middle}
.spa-table th{color:#999!important;font-weight:600;text-transform:uppercase;font-size:12px;position:sticky;
  top:0;background:#1a1a1a;cursor:pointer;border-bottom:2px solid #444}
.spa-table th:hover{color:#fff!important}
.spa-table th[data-sort]::after{content:" ⇅";color:#555;font-size:10px}
.spa-table th[data-sort].sort-asc::after{content:" ▲";color:#4fc3f7;font-size:10px}
.spa-table th[data-sort].sort-desc::after{content:" ▼";color:#4fc3f7;font-size:10px}
.spa-table tbody tr:hover td{background:#252525}
.spa-table th.num,.spa-table td.num{text-align:right;font-variant-numeric:tabular-nums}
.spa-table-wrap{padding-right:0}

/* Column toggle */
.spa-col-toggle{display:inline-flex;gap:4px;flex-wrap:wrap;margin-bottom:6px}
.spa-col-toggle label{font-size:11px;cursor:pointer;padding:3px 8px;border-radius:3px;user-select:none;
  transition:all .15s}
.spa-col-toggle input{display:none}
.spa-col-toggle label:has(input:checked){background:#1a3a4a;border:1px solid #4fc3f7}
.spa-col-toggle label:has(input:checked) span{color:#4fc3f7}
.spa-col-toggle label:has(input:not(:checked)){background:#333;border:1px solid #444}
.spa-col-toggle label:has(input:not(:checked)) span{color:#666}
.spa-col-toggle label:hover{border-color:#888}
.spa-col-hidden{display:none!important}

.spa-date-filter{display:flex;gap:8px;align-items:center;margin-bottom:16px;flex-wrap:wrap}
.spa-date-filter button{padding:5px 14px;background:#333;border:1px solid #444;color:#ccc;border-radius:4px;
  cursor:pointer;font-size:13px}
.spa-date-filter button:hover,.spa-date-filter button.active{background:#4fc3f7;color:#111;border-color:#4fc3f7}
.spa-date-filter input{background:#252525;border:1px solid #444;color:#ddd;padding:5px 8px;border-radius:4px;font-size:13px}
.spa-section{margin-bottom:20px}
.spa-section h3{margin:0 0 8px;font-size:14px;color:#eee}
.spa-section p{color:#aaa}
.spa-select{background:#252525;border:1px solid #444;color:#ddd;padding:6px 10px;border-radius:4px;font-size:14px;min-width:200px}
.spa-btn{padding:8px 16px;border:none;border-radius:4px;cursor:pointer;font-size:14px;font-weight:600;color:#ddd}
.spa-btn-primary{background:#4fc3f7;color:#111!important}.spa-btn-primary:hover{background:#29b6f6}
.spa-btn-danger{background:#ef5350;color:#fff!important}.spa-btn-danger:hover{background:#f44336}
.spa-btn-success{background:#4caf50;color:#fff!important}.spa-btn-success:hover{background:#43a047}
.spa-btn:disabled{opacity:.5;cursor:not-allowed}
.spa-input{background:#252525;border:1px solid #444;color:#ddd;padding:6px 10px;border-radius:4px;font-size:14px}
.spa-status{padding:8px 12px;background:#252525;border-radius:4px;color:#999;font-size:13px;margin:8px 0}
.spa-hint{color:#777;font-size:12px;margin:4px 0}
.spa-flex{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.spa-mt{margin-top:12px}.spa-mb{margin-bottom:12px}
.spa-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.spa-empty{text-align:center;color:#888;padding:30px 0;font-size:14px}

/* Mobile */
@media(max-width:768px){
  #spa-panel{width:100vw!important;max-width:100vw;min-width:0;border-radius:0;top:0;left:0;
    transform:none;max-height:100vh;height:100vh}
  .spa-grid-2{grid-template-columns:1fr}
  .spa-cards{grid-template-columns:1fr 1fr}
  .spa-date-filter{gap:4px}
}
`;
        document.head.appendChild(style);
    }

    // ════════════════════════════════════════════════════════════
    //  UI
    // ════════════════════════════════════════════════════════════

    class UI {
        constructor(db, api, analyzer, parser, syncManager) {
            this.db = db;
            this.api = api;
            this.analyzer = analyzer;
            this.parser = parser;
            this.sync = syncManager;
            this.activeTab = "dashboard";
            this.dateFrom = null;
            this.dateTo = null;
            this.selectedPack = null;
            this._sortCol = null;
            this._sortDir = 1;
        }

        inject() {
            injectCSS();

            // Overlay
            const overlay = document.createElement("div");
            overlay.id = "spa-overlay";
            document.body.appendChild(overlay);

            // Panel
            const panel = document.createElement("div");
            panel.id = "spa-panel";
            panel.innerHTML = `
                <div id="spa-header">
                    <h2>Supply Pack Analyzer <span class="spa-ver">v${VERSION}</span>
                        <span style="color:#888;font-size:11px;font-weight:400;margin-left:10px">
                            Like the script? Send a Xanax to
                            <a href="https://www.torn.com/profiles.php?XID=4192025" target="_blank"
                               style="color:#cc3333;text-decoration:none">eugene_s [4192025]</a>
                        </span>
                    </h2>
                    <button id="spa-close">&times;</button>
                </div>
                <div id="spa-tabs">
                    <div class="spa-tab active" data-tab="dashboard">Dashboard</div>
                    <div class="spa-tab" data-tab="packDetail">Pack Detail</div>
                    <div class="spa-tab" data-tab="settings">Settings</div>
                </div>
                <div id="spa-content"></div>
            `;
            document.body.appendChild(panel);

            // Inject button into Torn's footer bar
            this._injectFooterButton();

            // Events
            overlay.addEventListener("click", () => this._toggle(false));
            document.getElementById("spa-close").addEventListener("click", () => this._toggle(false));
            document.getElementById("spa-tabs").addEventListener("click", (e) => {
                const tab = e.target.closest(".spa-tab");
                if (tab) { this.activeTab = tab.dataset.tab; this._renderActiveTab(); this._updateTabs(); }
            });

            document.addEventListener("keydown", (e) => {
                if (e.key === "Escape") this._toggle(false);
            });

            this.sync.onProgress = (msg) => {
                const el = document.getElementById("spa-sync-status");
                if (el) el.textContent = msg;
            };
        }

        _injectFooterButton() {
            const createBtn = () => {
                if (document.getElementById("spa-footer-btn")) return true;
                // Find a reference button in the footer to clone its structure/classes
                const refBtn = document.getElementById("notes_panel_button") || document.getElementById("people_panel_button");
                if (!refBtn) return false;

                // Clone the reference button's classes for proper layout
                const btnClasses = refBtn.className;
                const iconClasses = refBtn.querySelector("svg")?.className?.baseVal || "";

                const btn = document.createElement("button");
                btn.type = "button";
                btn.id = "spa-footer-btn";
                btn.className = btnClasses;
                btn.title = "Supply Pack Analyzer";
                btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" class="${iconClasses}">
                    <defs><linearGradient id="spa_icon_grad" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox">
                        <stop offset="0" stop-color="#ddd"/><stop offset="1" stop-color="#999"/>
                    </linearGradient></defs>
                    <g fill="url(#spa_icon_grad)"><path d="M12 2L2 7v10l10 5 10-5V7L12 2zm0 2.18l6.5 3.25L12 10.68 5.5 7.43 12 4.18zM4 8.9l7 3.5v7.7l-7-3.5V8.9zm9 11.2v-7.7l7-3.5v7.7l-7 3.5z"/></g>
                </svg>`;
                btn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); this._toggle(true); });
                refBtn.parentNode.insertBefore(btn, refBtn);
                return true;
            };
            if (createBtn()) return;
            const obs = new MutationObserver(() => { if (createBtn()) obs.disconnect(); });
            obs.observe(document.body, { childList: true, subtree: true });
            setTimeout(() => obs.disconnect(), 30000);
        }

        _toggle(show) {
            document.getElementById("spa-panel").style.display = show ? "flex" : "none";
            document.getElementById("spa-overlay").style.display = show ? "block" : "none";
            if (show) { this._updateTabs(); this._renderActiveTab(); }
        }

        _updateTabs() {
            document.querySelectorAll(".spa-tab").forEach((t) => {
                t.classList.toggle("active", t.dataset.tab === this.activeTab);
            });
        }

        _content() { return document.getElementById("spa-content"); }

        async _renderActiveTab() {
            const c = this._content();
            c.innerHTML = '<div class="spa-empty">Loading...</div>';
            try {
                switch (this.activeTab) {
                    case "dashboard": await this._renderDashboard(c); break;
                    case "packDetail": await this._renderPackDetail(c); break;
                    case "settings": await this._renderSettings(c); break;
                }
            } catch (e) {
                c.innerHTML = `<div class="spa-empty">Error: ${e.message}</div>`;
                console.error("SPA render error:", e);
            }
        }

        // ── Date filter ─────────────────────────────────────

        _dateFilterHTML() {
            const now = Math.floor(Date.now() / 1000);
            return `<div class="spa-date-filter">
                <button data-range="week" class="${this._rangeActive("week")}">Last Week</button>
                <button data-range="month" class="${this._rangeActive("month")}">Last Month</button>
                <button data-range="year" class="${this._rangeActive("year")}">Last Year</button>
                <button data-range="all" class="${this._rangeActive("all")}">All Time</button>
                <input type="date" id="spa-from" value="${this._dateInputVal(this.dateFrom)}">
                <span style="color:#666">to</span>
                <input type="date" id="spa-to" value="${this._dateInputVal(this.dateTo)}">
                <button data-range="custom" class="spa-btn-primary spa-btn" style="padding:4px 10px">Apply</button>
            </div>`;
        }

        _rangeActive(r) {
            const now = Math.floor(Date.now() / 1000);
            if (r === "all" && !this.dateFrom && !this.dateTo) return "active";
            if (r === "week" && this.dateFrom && Math.abs(now - 7 * 86400 - this.dateFrom) < 3600) return "active";
            if (r === "month" && this.dateFrom && Math.abs(now - 30 * 86400 - this.dateFrom) < 3600) return "active";
            if (r === "year" && this.dateFrom && Math.abs(now - 365 * 86400 - this.dateFrom) < 3600) return "active";
            return "";
        }

        _dateInputVal(ts) {
            if (!ts) return "";
            return new Date(ts * 1000).toISOString().slice(0, 10);
        }

        _bindDateFilter() {
            const now = Math.floor(Date.now() / 1000);
            document.querySelectorAll(".spa-date-filter button").forEach((b) => {
                b.addEventListener("click", () => {
                    const r = b.dataset.range;
                    if (r === "all") { this.dateFrom = null; this.dateTo = null; }
                    else if (r === "week") { this.dateFrom = now - 7 * 86400; this.dateTo = null; }
                    else if (r === "month") { this.dateFrom = now - 30 * 86400; this.dateTo = null; }
                    else if (r === "year") { this.dateFrom = now - 365 * 86400; this.dateTo = null; }
                    else if (r === "custom") {
                        const f = document.getElementById("spa-from").value;
                        const t = document.getElementById("spa-to").value;
                        this.dateFrom = f ? Math.floor(new Date(f).getTime() / 1000) : null;
                        this.dateTo = t ? Math.floor(new Date(t + "T23:59:59").getTime() / 1000) : null;
                    }
                    this._renderActiveTab();
                });
            });
        }

        // ── Dashboard ───────────────────────────────────────

        async _renderDashboard(c) {
            const ov = await this.analyzer.getOverview(this.dateFrom, this.dateTo);
            const pnlClass = ov.pnl >= 0 ? "spa-green" : "spa-red";
            const roiClass = ov.roi >= 0 ? "spa-green" : "spa-red";

            let packRows = Object.values(ov.packs);
            if (this._sortCol) {
                packRows.sort((a, b) => {
                    let va = a[this._sortCol] ?? 0, vb = b[this._sortCol] ?? 0;
                    if (this._sortCol === "packName") { va = a.packName; vb = b.packName; return va.localeCompare(vb) * this._sortDir; }
                    const pnlA = a.totalValue - a.totalSpent, pnlB = b.totalValue - b.totalSpent;
                    if (this._sortCol === "pnl") { va = pnlA; vb = pnlB; }
                    if (this._sortCol === "roi") { va = a.totalSpent ? pnlA / a.totalSpent : 0; vb = b.totalSpent ? pnlB / b.totalSpent : 0; }
                    if (this._sortCol === "vpk") { va = a.opened ? a.totalValue / a.opened : 0; vb = b.opened ? b.totalValue / b.opened : 0; }
                    return (va - vb) * this._sortDir;
                });
            }

            c.innerHTML = `
                ${this._dateFilterHTML()}
                <div class="spa-cards">
                    <div class="spa-card"><div class="label">Total Spent</div>
                        <div class="value spa-yellow">${fmt.money(ov.totalSpent)}</div>
                        <div class="sub">Cost of ${fmt.num(ov.totalOpened)} opened</div></div>
                    <div class="spa-card"><div class="label">Total Value Gained</div>
                        <div class="value spa-blue">${fmt.money(ov.totalValue)}</div>
                        <div class="sub">${fmt.num(ov.totalOpened)} packs opened</div></div>
                    <div class="spa-card"><div class="label">Profit / Loss</div>
                        <div class="value ${pnlClass}">${fmt.money(ov.pnl)}</div>
                        <div class="sub">${fmt.moneyFull(ov.pnl)}</div></div>
                    <div class="spa-card"><div class="label">ROI</div>
                        <div class="value ${roiClass}">${fmt.pct(ov.roi)}</div>
                        <div class="sub">Avg ${fmt.money(ov.valuePerPack)} per pack</div></div>
                </div>
                ${packRows.length ? `
                <div class="spa-section">
                    <h3>Pack Performance</h3>
                    <div class="spa-table-wrap"><table class="spa-table" id="spa-pack-table">
                        <thead><tr>
                            <th data-sort="packName" class="${this._sortCls(this._sortCol, this._sortDir, "packName")}">Pack</th>
                            <th data-sort="opened" class="num ${this._sortCls(this._sortCol, this._sortDir, "opened")}">Opened</th>
                            <th data-sort="totalSpent" class="num ${this._sortCls(this._sortCol, this._sortDir, "totalSpent")}">Cost</th>
                            <th data-sort="totalValue" class="num ${this._sortCls(this._sortCol, this._sortDir, "totalValue")}">Value</th>
                            <th data-sort="pnl" class="num ${this._sortCls(this._sortCol, this._sortDir, "pnl")}">P&L</th>
                            <th data-sort="roi" class="num ${this._sortCls(this._sortCol, this._sortDir, "roi")}">ROI</th>
                            <th data-sort="vpk" class="num ${this._sortCls(this._sortCol, this._sortDir, "vpk")}">Val/Pack</th>
                        </tr></thead>
                        <tbody>
                        ${packRows.map((p) => {
                            const pnl = p.totalValue - p.totalSpent;
                            const roi = p.totalSpent > 0 ? (pnl / p.totalSpent) * 100 : 0;
                            const vpk = p.opened > 0 ? p.totalValue / p.opened : 0;
                            return `<tr data-pack="${p.packItemId}" style="cursor:pointer">
                                <td>${this._escHtml(p.packName)}</td>
                                <td class="num">${fmt.num(p.opened)}</td>
                                <td class="num">${fmt.money(p.totalSpent)}</td>
                                <td class="num">${fmt.money(p.totalValue)}</td>
                                <td class="num ${pnl >= 0 ? "spa-green" : "spa-red"}">${fmt.money(pnl)}</td>
                                <td class="num ${roi >= 0 ? "spa-green" : "spa-red"}">${fmt.pct(roi)}</td>
                                <td class="num">${fmt.money(vpk)}</td>
                            </tr>`;
                        }).join("")}
                        </tbody>
                    </table></div>
                </div>` : '<div class="spa-empty">No data yet. Add your API key and sync in Settings.</div>'}
            `;

            this._bindDateFilter();
            this._addColToggle("spa-pack-table", "dash", [2, 3, 6]);

            // Sort
            c.querySelectorAll("#spa-pack-table th[data-sort]").forEach((th) => {
                th.addEventListener("click", () => {
                    const col = th.dataset.sort;
                    if (this._sortCol === col) this._sortDir *= -1;
                    else { this._sortCol = col; this._sortDir = 1; }
                    this._renderActiveTab();
                });
            });

            // Click row → pack detail
            c.querySelectorAll("#spa-pack-table tr[data-pack]").forEach((tr) => {
                tr.addEventListener("click", () => {
                    this.selectedPack = tr.dataset.pack === "null" ? null : +tr.dataset.pack;
                    this.activeTab = "packDetail";
                    this._updateTabs();
                    this._renderActiveTab();
                });
            });
        }

        // ── Pack Detail ─────────────────────────────────────

        async _renderPackDetail(c) {
            const ov = await this.analyzer.getOverview(this.dateFrom, this.dateTo);
            const packKeys = Object.keys(ov.packs);

            if (!packKeys.length) {
                c.innerHTML = '<div class="spa-empty">No pack data available.</div>';
                return;
            }

            if (!this.selectedPack || !ov.packs[this.selectedPack])
                this.selectedPack = +packKeys[0];

            const pk = ov.packs[this.selectedPack];
            const ev = await this.analyzer.getEV(this.selectedPack);
            const sources = await this.analyzer.getBestSources(this.selectedPack, this.dateFrom, this.dateTo);
            const dr = await this.analyzer.getDropRates(this.selectedPack, this.dateFrom, this.dateTo);

            const pnl = pk.totalValue - pk.totalSpent;
            const roi = pk.totalSpent > 0 ? (pnl / pk.totalSpent) * 100 : 0;
            const vpk = pk.opened > 0 ? pk.totalValue / pk.opened : 0;
            const avgBuy = pk.avgBuyPrice || 0;
            const unopened = Math.max(0, pk.purchased - pk.opened);

            c.innerHTML = `
                ${this._dateFilterHTML()}
                <div class="spa-flex spa-mb">
                    <select class="spa-select" id="spa-pack-select">
                        ${packKeys.map((k) => `<option value="${k}" ${+k === this.selectedPack ? "selected" : ""}>${this._escHtml(ov.packs[k].packName)}</option>`).join("")}
                    </select>
                </div>

                <div class="spa-cards">
                    <div class="spa-card"><div class="label">Opened</div><div class="value">${fmt.num(pk.opened)}</div>
                        <div class="sub">${fmt.num(pk.purchased)} bought${unopened ? `, ${fmt.num(unopened)} unopened` : ""}</div></div>
                    <div class="spa-card"><div class="label">Cost of Opened</div><div class="value spa-yellow">${fmt.money(pk.totalSpent)}</div>
                        <div class="sub">Avg ${fmt.money(avgBuy)}/pack</div></div>
                    <div class="spa-card"><div class="label">Total Value</div><div class="value spa-blue">${fmt.money(pk.totalValue)}</div>
                        <div class="sub">${fmt.money(vpk)}/pack</div></div>
                    <div class="spa-card"><div class="label">P&L</div>
                        <div class="value ${pnl >= 0 ? "spa-green" : "spa-red"}">${fmt.money(pnl)}</div>
                        <div class="sub">ROI: ${fmt.pct(roi)}</div></div>
                </div>

                <div class="spa-grid-2">
                    <div class="spa-section">
                        <h3>Expected Value</h3>
                        ${ev ? `
                        <table class="spa-table">
                            <tr><td>Avg Return/Pack</td><td class="num">${fmt.money(ev.avgReturn)}</td></tr>
                            <tr><td>Avg Cost/Pack</td><td class="num">${fmt.money(ev.avgCost)}</td></tr>
                            <tr><td>EV (profit/pack)</td><td class="num ${ev.ev >= 0 ? "spa-green" : "spa-red"}">${fmt.money(ev.ev)}</td></tr>
                            <tr><td>Break-even Price</td><td class="num spa-blue">${fmt.money(ev.breakEven)}</td></tr>
                            <tr><td>Sample Size</td><td class="num">${fmt.num(ev.sampleSize)}</td></tr>
                        </table>` : '<div class="spa-empty">Not enough data</div>'}
                    </div>
                </div>

                <div class="spa-section">
                    <h3>Best Sources</h3>
                    ${sources.length ? `
                    <table class="spa-table">
                        <thead><tr><th>Channel</th><th class="num">Qty Bought</th><th class="num">Avg Price</th></tr></thead>
                        <tbody>${sources.map((s) => `
                            <tr><td>${s.channel === "itemmarket" ? "Item Market" : s.channel === "bazaar" ? "Bazaar" : s.channel}</td>
                                <td class="num">${fmt.num(s.totalQty)}</td>
                                <td class="num">${fmt.money(s.avgCost)}</td></tr>
                        `).join("")}</tbody>
                    </table>` : '<div class="spa-empty">No purchase data</div>'}
                </div>

                <div class="spa-section">
                    <h3>Loot Breakdown</h3>
                    ${dr.rates.length ? `
                    <div class="spa-table-wrap"><table class="spa-table" id="spa-loot-table">
                        <thead><tr>
                            <th data-sort="name">Item</th>
                            <th data-sort="totalQty" class="num">Total Qty</th>
                            <th data-sort="drops" class="num">Times Dropped</th>
                            <th data-sort="dropRate" class="num">Drop Rate</th>
                            <th data-sort="avgQtyPerDrop" class="num">Avg Qty/Drop</th>
                            <th data-sort="unitPrice" class="num">Unit Price</th>
                            <th data-sort="valueContribution" class="num">Value/Pack</th>
                        </tr></thead>
                        <tbody>${dr.rates.map((r) => `
                            <tr><td>${this._escHtml(r.name)}</td>
                                <td class="num">${fmt.num(r.totalQty)}</td>
                                <td class="num">${fmt.num(r.drops)}</td>
                                <td class="num">${r.dropRate.toFixed(1)}%</td>
                                <td class="num">${r.avgQtyPerDrop.toFixed(2)}</td>
                                <td class="num">${fmt.money(r.unitPrice)}</td>
                                <td class="num">${fmt.money(r.valueContribution)}</td></tr>
                        `).join("")}</tbody>
                    </table></div>` : '<div class="spa-empty">No drops recorded</div>'}
                </div>
            `;

            this._bindDateFilter();
            this._addColToggle("spa-loot-table", "loot", [1, 2, 4]);
            document.getElementById("spa-pack-select").addEventListener("change", (e) => {
                this.selectedPack = +e.target.value;
                this._renderActiveTab();
            });

            // Loot table sorting
            c.querySelectorAll("#spa-loot-table th[data-sort]").forEach((th) => {
                th.addEventListener("click", () => {
                    const col = th.dataset.sort;
                    if (this._lootSortCol === col) this._lootSortDir *= -1;
                    else { this._lootSortCol = col; this._lootSortDir = -1; }
                    // Update sort indicator classes
                    c.querySelectorAll("#spa-loot-table th[data-sort]").forEach((h) => {
                        h.classList.remove("sort-asc", "sort-desc");
                    });
                    th.classList.add(this._lootSortDir > 0 ? "sort-asc" : "sort-desc");
                    // Sort rows
                    const tbody = document.querySelector("#spa-loot-table tbody");
                    const rows = [...tbody.querySelectorAll("tr")];
                    const ci = [...th.parentNode.children].indexOf(th);
                    rows.sort((a, b) => {
                        const at = a.children[ci].textContent.trim();
                        const bt = b.children[ci].textContent.trim();
                        if (col === "name") return at.localeCompare(bt) * this._lootSortDir;
                        const an = parseFloat(at.replace(/[$,%]/g, "").replace(/,/g, "")) || 0;
                        const bn = parseFloat(bt.replace(/[$,%]/g, "").replace(/,/g, "")) || 0;
                        return (an - bn) * this._lootSortDir;
                    });
                    for (const r of rows) tbody.appendChild(r);
                });
            });
        }

        // ── Settings ────────────────────────────────────────

        async _renderSettings(c) {
            const apiKey = this.api.apiKey;
            const lastSync = localStorage.getItem(LS("lastSync"));
            const openCount = await this.db.count("openings");
            const purchCount = await this.db.count("purchases");
            const itemCount = await this.db.count("items");

            c.innerHTML = `
                <div class="spa-grid-2">
                    <div>
                        <div class="spa-section">
                            <h3>API Configuration</h3>
                            <p class="spa-hint">Requires a <strong style="color:#ddd">Full Access</strong> API key to read your logs.</p>
                            <p class="spa-hint">Your key is stored locally in your browser and is only sent directly to the official Torn API. It is never shared with any third party.</p>
                            <div class="spa-flex spa-mb">
                                <input type="password" class="spa-input" id="spa-apikey" placeholder="Paste your Full Access API key"
                                    value="${apiKey}" style="flex:1">
                                <button class="spa-btn spa-btn-primary" id="spa-validate-key">Validate</button>
                            </div>
                            <div id="spa-key-status" class="spa-status">${apiKey ? "Key saved" : "No API key set"}</div>
                        </div>

                        <div class="spa-section">
                            <h3>Sync</h3>
                            <div class="spa-flex spa-mb">
                                <button class="spa-btn spa-btn-primary" id="spa-sync-btn" ${!apiKey ? "disabled" : ""}>Sync Now</button>
                                <button class="spa-btn spa-btn-danger" id="spa-sync-abort" style="display:none">Stop</button>
                            </div>
                            <p class="spa-hint">First sync may take a few minutes depending on your log history. Subsequent syncs are much faster as only new data is fetched.</p>
                            <div id="spa-sync-status" class="spa-status">
                                ${lastSync ? "Last sync: " + new Date(+lastSync).toLocaleString() : "Never synced"}
                            </div>
                        </div>

                        <div class="spa-section">
                            <h3>Database</h3>
                            <table class="spa-table">
                                <tr><td>Opening logs</td><td class="num">${fmt.num(openCount)}</td></tr>
                                <tr><td>Purchase logs</td><td class="num">${fmt.num(purchCount)}</td></tr>
                                <tr><td>Items in database</td><td class="num">${fmt.num(itemCount)}</td></tr>
                            </table>
                            <div class="spa-flex spa-mt">
                                <button class="spa-btn spa-btn-danger" id="spa-clear-data">Clear All Data</button>
                                <button class="spa-btn" id="spa-export-data" style="background:#555;color:#ddd">Export JSON</button>
                                <label class="spa-btn" style="background:#555;color:#ddd;cursor:pointer">
                                    Import JSON <input type="file" id="spa-import-data" accept=".json" style="display:none">
                                </label>
                            </div>
                        </div>
                    </div>

                </div>
            `;

            // Bind events
            document.getElementById("spa-validate-key").addEventListener("click", async () => {
                const key = document.getElementById("spa-apikey").value.trim();
                const status = document.getElementById("spa-key-status");
                if (!key) { status.textContent = "Please enter a key"; return; }
                status.textContent = "Validating...";
                try {
                    const data = await this.api.validate(key);
                    if (data.error) { status.innerHTML = `<span class="spa-red">Invalid: ${data.error.error}</span>`; return; }
                    localStorage.setItem(LS("apiKey"), key);
                    this.api.apiKey = key;
                    status.innerHTML = `<span class="spa-green">Valid! Player: ${data.player_id || data.name || "OK"}</span>`;
                    document.getElementById("spa-sync-btn").disabled = false;
                } catch (e) {
                    status.innerHTML = `<span class="spa-red">Error: ${e.message}</span>`;
                }
            });

            document.getElementById("spa-sync-btn").addEventListener("click", async () => {
                const btn = document.getElementById("spa-sync-btn");
                const abortBtn = document.getElementById("spa-sync-abort");
                btn.disabled = true;
                abortBtn.style.display = "inline-block";
                await this.sync.sync();
                btn.disabled = false;
                abortBtn.style.display = "none";
                // Reload parser items
                const items = await this.db.getAll("items");
                this.parser.loadItems(items);
                this._renderActiveTab();
            });

            document.getElementById("spa-sync-abort").addEventListener("click", () => {
                this.sync.abort();
            });

            document.getElementById("spa-clear-data").addEventListener("click", async () => {
                if (!confirm("Clear ALL analyzer data? This cannot be undone.")) return;
                await this.db.clear("openings");
                await this.db.clear("purchases");
                await this.db.clear("items");
                await this.db.clear("priceHistory");
                localStorage.removeItem(LS("lastOpenSync"));
                localStorage.removeItem(LS("lastBuySync"));
                localStorage.removeItem(LS("lastSync"));
                this._renderActiveTab();
            });

            document.getElementById("spa-export-data").addEventListener("click", async () => {
                const data = {
                    version: VERSION,
                    exported: new Date().toISOString(),
                    openings: await this.db.getAll("openings"),
                    purchases: await this.db.getAll("purchases"),
                    items: await this.db.getAll("items"),
                };
                const blob = new Blob([JSON.stringify(data)], { type: "application/json" });
                const url = URL.createObjectURL(blob);
                const a = document.createElement("a");
                a.href = url;
                a.download = `spa-export-${new Date().toISOString().slice(0, 10)}.json`;
                a.click();
                URL.revokeObjectURL(url);
            });

            document.getElementById("spa-import-data").addEventListener("change", async (e) => {
                const file = e.target.files[0];
                if (!file) return;
                try {
                    const text = await file.text();
                    const data = JSON.parse(text);
                    if (data.openings) await this.db.putBatch("openings", data.openings);
                    if (data.purchases) await this.db.putBatch("purchases", data.purchases);
                    if (data.items) await this.db.putBatch("items", data.items);
                    alert(`Imported: ${data.openings?.length || 0} openings, ${data.purchases?.length || 0} purchases, ${data.items?.length || 0} items`);
                    this._renderActiveTab();
                } catch (err) {
                    alert("Import failed: " + err.message);
                }
            });
        }

        // ── Helpers ─────────────────────────────────────────

        _sortCls(stateCol, stateDir, col) {
            if (stateCol !== col) return "";
            return stateDir > 0 ? "sort-asc" : "sort-desc";
        }

        /**
         * Add column toggle buttons above a table.
         * storageKey persists choices. mobileHidden = array of column indices (1-based) to hide by default on mobile.
         */
        _addColToggle(tableId, storageKey, mobileHidden = []) {
            const table = document.getElementById(tableId);
            if (!table) return;
            const headers = [...table.querySelectorAll("thead th")];
            if (headers.length < 3) return;

            const isMobile = window.innerWidth <= 768;
            const saved = JSON.parse(localStorage.getItem(LS("cols_" + storageKey)) || "null");

            // Build full state: if saved exists use it, otherwise build defaults
            const state = {};
            headers.forEach((_, i) => {
                if (i === 0) return;
                if (saved && saved[i] !== undefined) {
                    state[i] = saved[i];
                } else {
                    state[i] = isMobile ? !mobileHidden.includes(i) : true;
                }
            });

            const wrap = document.createElement("div");
            wrap.className = "spa-col-toggle";

            const applyCol = (i, show) => {
                table.querySelectorAll(`th:nth-child(${i + 1}), td:nth-child(${i + 1})`).forEach((el) => {
                    el.classList.toggle("spa-col-hidden", !show);
                });
            };

            headers.forEach((th, i) => {
                if (i === 0) return;
                const name = th.textContent.trim().replace(/[⇅▲▼]/g, "").trim();
                const visible = state[i];
                const label = document.createElement("label");
                label.innerHTML = `<input type="checkbox" ${visible ? "checked" : ""}><span>${name}</span>`;
                const cb = label.querySelector("input");

                // Apply initial state
                applyCol(i, visible);

                cb.addEventListener("change", () => {
                    state[i] = cb.checked;
                    applyCol(i, cb.checked);
                    localStorage.setItem(LS("cols_" + storageKey), JSON.stringify(state));
                });
                wrap.appendChild(label);
            });

            // Save full state so future renders are consistent
            localStorage.setItem(LS("cols_" + storageKey), JSON.stringify(state));
            table.parentNode.insertBefore(wrap, table);
        }

        _escHtml(s) {
            if (!s) return "";
            return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
        }
    }

    // ════════════════════════════════════════════════════════════
    //  MAIN
    // ════════════════════════════════════════════════════════════

    async function main() {
        const db = new Database();
        await db.open();

        const api = new TornAPI();
        const parser = new LogParser();
        const analyzer = new Analyzer(db);
        const syncManager = new SyncManager(db, api, parser);
        const ui = new UI(db, api, analyzer, parser, syncManager);

        // Pre‑load item definitions if available
        const items = await db.getAll("items");
        if (items.length) parser.loadItems(items);

        ui.inject();
    }

    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", main);
    } else {
        main();
    }
})();