🌺🧸 Unified Display & Points (v3.4.2-final)

Fixed top-center unified panel (YATA + Prometheus merged). Collapsed shows only 🔘. Flower/plush sets + points, abroad stk, color-coded, refresh button. 45s refresh.

As of 2025-10-13. See the latest version.

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 or Violentmonkey 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.

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

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!)

// ==UserScript==
// @name         🌺🧸 Unified Display & Points (v3.4.2-final)
// @namespace    http://tampermonkey.net/
// @version      3.4.2-final
// @description  Fixed top-center unified panel (YATA + Prometheus merged). Collapsed shows only 🔘. Flower/plush sets + points, abroad stk, color-coded, refresh button. 45s refresh.
// @match        https://www.torn.com/*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @connect      yata.yt
// @connect      api.prombot.co.uk
// @connect      api.torn.com
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  const PANEL_ID = 'unified_points_final_v3_4_2';
  const REFRESH_MS = 45 * 1000;
  const POINTS_PER_SET = 10;
  const YATA_URL = 'https://yata.yt/api/v1/travel/export/';
  const PROM_URL = 'https://api.prombot.co.uk/api/travel';

  const FLOWERS_ORDER = [
    ["Dahlia", { code: 'MEX', flag: '🇲🇽', short: 'Dahlia' }],
    ["Orchid", { code: 'HAW', flag: '🏝️', short: 'Orchid' }],
    ["African Violet", { code: 'SOU', flag: '🇿🇦', short: 'A.Violet' }],
    ["Cherry Blossom", { code: 'JAP', flag: '🇯🇵', short: 'C.Blossom' }],
    ["Peony", { code: 'CHI', flag: '🇨🇳', short: 'Peony' }],
    ["Ceibo Flower", { code: 'ARG', flag: '🇦🇷', short: 'Ceibo' }],
    ["Edelweiss", { code: 'SWI', flag: '🇨🇭', short: 'Edelweiss' }],
    ["Crocus", { code: 'CAN', flag: '🇨🇦', short: 'Crocus' }],
    ["Heather", { code: 'UNI', flag: '🇬🇧', short: 'Heather' }],
    ["Tribulus Omanense", { code: 'UAE', flag: '🇦🇪', short: 'Tribulus' }],
    ["Banana Orchid", { code: 'CAY', flag: '🇰🇾', short: 'Banana' }]
  ];

  const PLUSHIES_ORDER = [
    ["Sheep Plushie", { code: 'BB', flag: '🏪', short: 'Sheep' }],
    ["Teddy Bear Plushie", { code: 'BB', flag: '🏪', short: 'Teddy' }],
    ["Kitten Plushie", { code: 'BB', flag: '🏪', short: 'Kitten' }],
    ["Jaguar Plushie", { code: 'MEX', flag: '🇲🇽', short: 'Jaguar' }],
    ["Wolverine Plushie", { code: 'CAN', flag: '🇨🇦', short: 'Wolverine' }],
    ["Nessie Plushie", { code: 'UNI', flag: '🇬🇧', short: 'Nessie' }],
    ["Red Fox Plushie", { code: 'UNI', flag: '🇬🇧', short: 'R.Fox' }],
    ["Monkey Plushie", { code: 'ARG', flag: '🇦🇷', short: 'Monkey' }],
    ["Chamois Plushie", { code: 'SWI', flag: '🇨🇭', short: 'Chamois' }],
    ["Panda Plushie", { code: 'CHI', flag: '🇨🇳', short: 'Panda' }],
    ["Lion Plushie", { code: 'SOU', flag: '🇿🇦', short: 'Lion' }],
    ["Camel Plushie", { code: 'UAE', flag: '🇦🇪', short: 'Camel' }],
    ["Stingray Plushie", { code: 'CAY', flag: '🇰🇾', short: 'Stingray' }]
  ];

  const SPECIAL_DRUG = 'Xanax';

  const COUNTRY_NAME_TO_CODE = {
    'JAPAN': 'JAP', 'MEXICO': 'MEX', 'CANADA': 'CAN', 'CHINA': 'CHI', 'UNITED KINGDOM': 'UNI',
    'ARGENTINA': 'ARG', 'SWITZERLAND': 'SWI', 'HAWAII': 'HAW', 'UAE': 'UAE', 'CAYMAN ISLANDS': 'CAY',
    'SOUTH AFRICA': 'SOU', 'S.A': 'SOU', 'SA': 'SOU', 'TORN': 'BB', 'B.B': 'BB'
  };

  function getPDANavHeight() {
    const nav = document.querySelector('#pda-nav') || document.querySelector('.pda') || document.querySelector('#pda');
    return nav ? nav.offsetHeight : 40;
  }

  GM_addStyle(`
    #${PANEL_ID} { position: fixed; top: ${getPDANavHeight()}px; left: 50%; transform: translateX(-50%); z-index: 2147483647; font-family: "DejaVu Sans Mono", monospace; font-size:12px; pointer-events:auto; }
    .${PANEL_ID}-toggle { width:36px; height:36px; display:inline-flex; align-items:center; justify-content:center; cursor:pointer; border-radius:8px; background:rgba(255,255,255,0.06); color:#ffffff; border:1px solid rgba(255,255,255,0.06); box-shadow:0 6px 14px rgba(0,0,0,0.45); user-select:none; }
    .${PANEL_ID}-toggle:hover { background:rgba(255,255,255,0.08); }
    .${PANEL_ID}-card { margin-top:8px; width:33vw; min-width:320px; max-width:680px; background: rgba(8,8,8,0.90); color:#e9eef8; border-radius:8px; overflow:hidden; border:1px solid rgba(255,255,255,0.04); box-shadow:0 12px 34px rgba(0,0,0,0.6); }
    .${PANEL_ID}.collapsed .${PANEL_ID}-card { display:none; }
    .${PANEL_ID}-header { display:flex; align-items:center; padding:8px; gap:8px; }
    .${PANEL_ID}-refresh { margin-left:auto; background:transparent; color:#dfe7ff; border:1px solid rgba(255,255,255,0.04); padding:6px 8px; border-radius:6px; cursor:pointer; }
    .${PANEL_ID}-body { padding:8px 6px; font-size:12px; line-height:1.08; max-height:70vh; overflow:auto; }
    .tbl-head { display:flex; gap:6px; padding:4px 2px; color:#bfc9d6; font-weight:700; font-size:11px; border-bottom:1px solid rgba(255,255,255,0.03); margin-bottom:6px; }
    .tbl-row { display:flex; gap:6px; align-items:center; padding:4px 2px; white-space:nowrap; border-bottom:1px solid rgba(255,255,255,0.02); }
    .col-dot { flex:0 0 18px; display:flex; align-items:center; justify-content:flex-start; padding-left:2px; }
    .col-av { flex:0 0 48px; text-align:right; color:#cfe8c6; padding-left:2px; }
    .col-st { flex:0 0 84px; text-align:right; color:#f7b3b3; padding-left:2px; }
    .col-mis { flex:0 0 44px; text-align:right; color:#f0d08a; padding-left:2px; }
    .col-name { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; color:#e9eef8; padding-left:8px; text-align:left; }
    .dot { width:10px; height:10px; border-radius:50%; margin-right:6px; flex:0 0 10px; }
    .stock-green{ background:#00c853; } .stock-orange{ background:#ff9800; } .stock-red{ background:#ff1744; } .stock-gray{ background:#9ea6b3; }
    .${PANEL_ID}-bottom { padding:8px; border-top:1px solid rgba(255,255,255,0.03); color:#bfc9d6; font-size:12px; display:flex; flex-direction:column; gap:6px; }
    .${PANEL_ID}-source { color:#9ea6b3; font-size:11px; }
    .${PANEL_ID}-points-breakdown { margin-top:4px; font-weight:700; color:#dfe7ff; }
    @media (max-width:900px){ #${PANEL_ID}{ left:6px; transform:none; } .${PANEL_ID}-card { width:92vw; } .col-st{ flex:0 0 56px; } .col-av{ flex:0 0 36px; } .col-mis{ flex:0 0 34px; } }
  `);

  // UI creation
  function buildUI() {
    let root = document.getElementById(PANEL_ID);
    if (root) return root;
    root = document.createElement('div');
    root.id = PANEL_ID;
    const collapsed = GM_getValue(`${PANEL_ID}-collapsed`, true);
    if (collapsed) root.classList.add('collapsed');

    root.innerHTML = `
      <div style="display:flex;align-items:center;justify-content:center;gap:8px;">
        <div class="${PANEL_ID}-toggle" id="${PANEL_ID}-toggle" aria-label="Toggle Exporter" title="Toggle Exporter">🔘</div>
      </div>

      <div class="${PANEL_ID}-card" role="region" aria-label="Unified Display & Points">
        <div class="${PANEL_ID}-header">
          <div style="font-weight:800;color:#dfe7ff">Unified Display & Points</div>
          <div style="margin-left:12px;color:#9ea6b3;font-size:12px;" id="${PANEL_ID}-summary">Sets: - | Points: -</div>
          <button class="${PANEL_ID}-refresh" id="${PANEL_ID}-refresh">Refresh</button>
        </div>

        <div class="${PANEL_ID}-body">
          <div id="${PANEL_ID}-status" style="font-weight:700;margin-bottom:6px;color:#dfe7ff">Waiting...</div>

          <div id="${PANEL_ID}-flowers">
            <div class="tbl-head"><div style="flex:0 0 18px"></div><div class="col-av">AV</div><div class="col-st">STK</div><div class="col-mis">MIS</div><div class="col-name">Flower</div></div>
            <div id="${PANEL_ID}-flowers-rows"></div>
          </div>

          <div id="${PANEL_ID}-plush" style="margin-top:8px;">
            <div class="tbl-head"><div style="flex:0 0 18px"></div><div class="col-av">AV</div><div class="col-st">STK</div><div class="col-mis">MIS</div><div class="col-name">Plushie</div></div>
            <div id="${PANEL_ID}-plush-rows"></div>
          </div>

          <div id="${PANEL_ID}-drugs" style="margin-top:8px;">
            <div class="tbl-head"><div style="flex:0 0 18px"></div><div class="col-av">AV</div><div class="col-st">STK</div><div class="col-mis"></div><div class="col-name">Drugs</div></div>
            <div id="${PANEL_ID}-drugs-rows"></div>
          </div>
        </div>

        <div class="${PANEL_ID}-bottom">
          <div id="${PANEL_ID}-flylines"></div>
          <div class="${PANEL_ID}-source" id="${PANEL_ID}-source">Data source: —</div>
          <div class="${PANEL_ID}-points-breakdown" id="${PANEL_ID}-points-breakdown">Flowers: - | Plushies: - | Total: -</div>
        </div>
      </div>
    `;

    document.body.appendChild(root);

    // Toggle click: collapse/expand
    const toggle = document.getElementById(`${PANEL_ID}-toggle`);
    toggle.addEventListener('click', (e) => {
      e.stopPropagation();
      const root = document.getElementById(PANEL_ID);
      const nowCollapsed = root.classList.toggle('collapsed');
      GM_setValue(`${PANEL_ID}-collapsed`, nowCollapsed);
    });

    // Double-click quick refresh
    toggle.addEventListener('dblclick', (e) => { e.stopPropagation(); refreshAll(true); });

    // Refresh button
    const refreshBtn = document.getElementById(`${PANEL_ID}-refresh`);
    refreshBtn.addEventListener('click', (e) => { e.stopPropagation(); refreshAll(true); });

    // Close panel when clicking outside (optional safe: does not collapse automatically unless toggle pressed)
    // (Not implementing auto-close on outside click per your previous confirmations)

    return root;
  }

  // Networking wrapper
  function gmGetJson(url, timeout = 14000) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        timeout,
        onload: (res) => {
          try {
            const txt = (typeof res.response === 'string' && res.response) ? res.response : res.responseText;
            const parsed = txt && txt.length ? JSON.parse(txt) : res.response;
            resolve(parsed);
          } catch (err) { reject(err); }
        },
        onerror: (err) => reject(err),
        ontimeout: () => reject(new Error('timeout'))
      });
    });
  }

  // Parse YATA
  function parseYata(yataData) {
    const map = {};
    if (!yataData || !yataData.stocks) return map;
    for (const [code, obj] of Object.entries(yataData.stocks)) {
      const countryCode = String(code).toUpperCase();
      const arr = Array.isArray(obj.stocks) ? obj.stocks : [];
      const m = {};
      for (const it of arr) if (it && it.name) m[it.name] = Number(it.quantity ?? it.qty ?? 0) || 0;
      map[countryCode] = m;
    }
    return map;
  }

  // Parse Prometheus (best-effort normalization)
  function parseProm(promData) {
    const map = {};
    if (!promData) return map;
    for (const [countryKey, countryVal] of Object.entries(promData)) {
      if (!countryVal) continue;
      const up = String(countryKey).trim().toUpperCase();
      let code = up;
      if (COUNTRY_NAME_TO_CODE[up]) code = COUNTRY_NAME_TO_CODE[up];
      const m = {};
      if (Array.isArray(countryVal.stocks)) {
        for (const it of countryVal.stocks) if (it && it.name) m[it.name] = Number(it.quantity ?? it.qty ?? 0) || 0;
      } else {
        for (const [k, v] of Object.entries(countryVal)) {
          if (v == null) continue;
          if (typeof v === 'object' && ('quantity' in v || 'qty' in v || 'amount' in v)) {
            m[k] = Number(v.quantity ?? v.qty ?? v.amount ?? 0) || 0;
          } else if (typeof v === 'number' || !isNaN(Number(v))) {
            m[k] = Number(v) || 0;
          } else if (typeof v === 'object' && v.stocks && Array.isArray(v.stocks)) {
            for (const it of v.stocks) if (it && it.name) m[it.name] = Number(it.quantity ?? it.qty ?? 0) || 0;
          }
        }
      }
      map[String(code).toUpperCase()] = m;
    }
    return map;
  }

  // Merge by averaging when both present for same country+item
  function mergeMaps(yataMap, promMap) {
    const merged = {};
    const countries = new Set([...Object.keys(yataMap || {}), ...Object.keys(promMap || {})]);
    for (const c of countries) {
      const yItems = yataMap[c] || {};
      const pItems = promMap[c] || {};
      const itemNames = new Set([...Object.keys(yItems), ...Object.keys(pItems)]);
      const m = {};
      for (const item of itemNames) {
        const yv = Number(yItems[item] ?? NaN);
        const pv = Number(pItems[item] ?? NaN);
        const hasY = !Number.isNaN(yv);
        const hasP = !Number.isNaN(pv);
        if (hasY && hasP) m[item] = Math.round((yv + pv) / 2);
        else if (hasY) m[item] = Math.round(yv);
        else if (hasP) m[item] = Math.round(pv);
      }
      merged[c] = m;
    }
    return merged;
  }

  // Sum merged across countries for item
  function sumMergedFor(itemName, mergedMap) {
    let total = 0;
    for (const c of Object.keys(mergedMap || {})) total += Number(mergedMap[c][itemName] || 0);
    return total;
  }

  // Best country in merged map for item
  function bestCountryForMerged(itemName, mergedMap) {
    let best = { code: null, qty: 0 };
    for (const [code, m] of Object.entries(mergedMap || {})) {
      const q = Number(m[itemName] || 0);
      if (q > best.qty) best = { code, qty: q };
    }
    return best;
  }

  // compute sets & missing
  function computeForGroup(displayMap, groupOrder) {
    const counts = groupOrder.map(([name]) => Number(displayMap[name] || 0));
    const sets = counts.length ? Math.min(...counts) : 0;
    const missing = groupOrder.reduce((acc, [name]) => {
      const c = Number(displayMap[name] || 0);
      acc[name] = Math.max(0, (sets + 1) - c);
      return acc;
    }, {});
    const countsMap = groupOrder.reduce((acc, [name]) => { acc[name] = Number(displayMap[name] || 0); return acc; }, {});
    return { sets, countsMap, missing };
  }

  // color thresholds
  function stockClassByQty(q) {
    q = Number(q || 0);
    if (q === 0) return 'stock-red';
    if (q >= 1500) return 'stock-green';
    if (q >= 321 && q <= 749) return 'stock-orange';
    return 'stock-gray';
  }

  function escapeHtml(s) { if (s == null) return ''; return String(s).replace(/[&<>"']/g, m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m])); }

  // Render rows (AV | STK | MIS | NAME)
  function renderGroupRows(containerId, order, countsMap, mergedMap, missingMap) {
    const el = document.getElementById(containerId);
    if (!el) return;
    let html = '';
    for (const [name, meta] of order) {
      const av = Number(countsMap[name] || 0);
      const stk = sumMergedFor(name, mergedMap);
      const miss = Number(missingMap[name] || 0);
      const dotClass = stockClassByQty(stk);
      const best = bestCountryForMerged(name, mergedMap);
      const bestCode = best.code ? String(best.code).toUpperCase() : '';
      const codeInfo = bestCode ? ` | ${bestCode}` : '';
      html += `<div class="tbl-row"><div class="col-dot"><div class="dot ${dotClass}"></div></div><div class="col-av">${av}</div><div class="col-st">${stk}${codeInfo}</div><div class="col-mis">${miss>0?miss:'—'}</div><div class="col-name">${escapeHtml(meta.short||name)} ${meta.flag||''}${meta.code?(' | '+meta.code):''}</div></div>`;
    }
    el.innerHTML = html;
  }

  // Build fly lines up to 4 lines
  function buildFlyLines(flowersMissing, plushMissing, mergedMap) {
    const lines = [];
    const collect = (order, missing) => {
      for (const [name, meta] of order) {
        if (lines.length >= 4) break;
        const miss = Number(missing[name] || 0);
        if (miss <= 0) continue;
        const best = bestCountryForMerged(name, mergedMap);
        const code = best.code ? String(best.code).toUpperCase() : (meta.code || '');
        const displayCode = (code === 'SOU') ? 'S.A' : (code || meta.code || '');
        lines.push(`Fly to ${displayCode} for ${meta.short || name}`);
      }
    };
    collect(FLOWERS_ORDER, flowersMissing);
    collect(PLUSHIES_ORDER, plushMissing);
    return lines.length ? lines : ['Fly to —'];
  }

  // Main rendering: compute points and update UI
  function renderUI(displayMap, yataRaw, promRaw, sourcesUsed) {
    const yataMap = parseYata(yataRaw);
    const promMap = parseProm(promRaw);
    const mergedMap = mergeMaps(yataMap, promMap);

    const flowers = computeForGroup(displayMap, FLOWERS_ORDER);
    const plush = computeForGroup(displayMap, PLUSHIES_ORDER);

    const flowerSets = flowers.sets || 0;
    const plushSets = plush.sets || 0;
    const flowerPoints = flowerSets * POINTS_PER_SET;
    const plushPoints = plushSets * POINTS_PER_SET;
    const totalPoints = flowerPoints + plushPoints;

    // status and summary
    const statusEl = document.getElementById(`${PANEL_ID}-status`);
    if (statusEl) statusEl.textContent = `Updated: ${new Date().toLocaleTimeString()} — Sets F:${flowerSets} P:${plushSets}`;

    const summaryEl = document.getElementById(`${PANEL_ID}-summary`);
    if (summaryEl) summaryEl.textContent = `Sets: F:${flowerSets} P:${plushSets} | Points: ${totalPoints}`;

    renderGroupRows(`${PANEL_ID}-flowers-rows`, FLOWERS_ORDER, flowers.countsMap, mergedMap, flowers.missing);
    renderGroupRows(`${PANEL_ID}-plush-rows`, PLUSHIES_ORDER, plush.countsMap, mergedMap, plush.missing);

    // Xanax
    const drugsEl = document.getElementById(`${PANEL_ID}-drugs-rows`);
    const xanInv = Number(displayMap[SPECIAL_DRUG] || 0);
    const xanStk = sumMergedFor(SPECIAL_DRUG, mergedMap) || 0;
    const xanDot = stockClassByQty(xanStk);
    const xanBest = bestCountryForMerged(SPECIAL_DRUG, mergedMap);
    const xanBestCode = xanBest.code ? xanBest.code.toUpperCase() : 'SOU';
    drugsEl.innerHTML = `<div class="tbl-row"><div class="col-dot"><div class="dot ${xanDot}"></div></div><div class="col-av">${xanInv}</div><div class="col-st">${xanStk} | ${xanBestCode}</div><div class="col-mis">—</div><div class="col-name">${escapeHtml(SPECIAL_DRUG)} 🇿🇦</div></div>`;

    const flyLines = buildFlyLines(flowers.missing, plush.missing, mergedMap);
    const flyContainer = document.getElementById(`${PANEL_ID}-flylines`);
    if (flyContainer) flyContainer.innerHTML = flyLines.map(l => `<div>${escapeHtml(l)}</div>`).join('');

    const srcEl = document.getElementById(`${PANEL_ID}-source`);
    if (srcEl) srcEl.textContent = `Data source: ${sourcesUsed.length ? sourcesUsed.join(' + ') : '—'}`;

    const pb = document.getElementById(`${PANEL_ID}-points-breakdown`);
    if (pb) pb.textContent = `Flowers: ${flowerPoints} | Plushies: ${plushPoints} | Total: ${totalPoints}`;
  }

  // Refresh flow: fetch Torn display/inv, YATA, Prometheus in parallel
  async function refreshAll(force = false) {
    try {
      const statusEl = document.getElementById(`${PANEL_ID}-status`);
      if (statusEl) statusEl.textContent = 'Fetching...';

      const tornPromise = fetchTornDisplayInventory();
      const yataPromise = gmGetJson(YATA_URL).catch(() => null);
      const promPromise = gmGetJson(PROM_URL).catch(() => null);

      const [displayFromApi, yataRaw, promRaw] = await Promise.all([tornPromise, yataPromise, promPromise]);

      let displayMap = {};
      if (displayFromApi && Object.keys(displayFromApi).length > 0) displayMap = displayFromApi;
      else {
        const dom = fetchDisplayViaDOM();
        displayMap = dom || displayFromApi || {};
      }

      const sourcesUsed = [];
      if (yataRaw && Object.keys(yataRaw).length) sourcesUsed.push('YATA');
      if (promRaw && Object.keys(promRaw).length) sourcesUsed.push('Prometheus');

      renderUI(displayMap, yataRaw, promRaw, sourcesUsed);

      if (statusEl) statusEl.textContent = `Updated: ${new Date().toLocaleTimeString()}`;
    } catch (err) {
      console.warn('refreshAll err', err);
      const statusEl = document.getElementById(`${PANEL_ID}-status`);
      if (statusEl) statusEl.textContent = 'Update failed';
    }
  }

  // Torn display/inventory fetch helpers
  async function fetchTornDisplayInventory() {
    const key = GM_getValue('tornAPIKey', null);
    if (!key) return null;
    const url = `https://api.torn.com/user/?selections=display,inventory&key=${encodeURIComponent(key)}`;
    try {
      const data = await gmGetJson(url);
      if (!data || data.error) return null;
      return aggregateFromApiResponse(data);
    } catch (e) { console.warn('fetchTornDisplayInventory', e); return null; }
  }
  function aggregateFromApiResponse(data) {
    const items = {};
    const push = (src) => {
      if (!src) return;
      const entries = Array.isArray(src) ? src : Object.values(src);
      for (const e of entries) {
        if (!e) continue;
        const name = e.name || e.item_name || e.title || e.item || null;
        if (!name) continue;
        const qty = Number(e.quantity ?? e.qty ?? e.amount ?? 1) || 0;
        items[name] = (items[name] || 0) + qty;
      }
    };
    push(data.display);
    push(data.inventory);
    return items;
  }
  function fetchDisplayViaDOM() {
    const map = {};
    const els = document.querySelectorAll('.display-item, .item-wrap .item, .dcItem, .display_case_item, .item');
    if (els && els.length) {
      els.forEach(el => {
        let name = ''; let qty = 0;
        const nameEl = el.querySelector('.item-name, .name, .title') || el.querySelector('a') || el;
        if (nameEl) name = (nameEl.innerText || '').trim();
        const qtyEl = el.querySelector('.item-amount, .count, .qty, .quantity') || el.querySelector('.item-qty');
        if (qtyEl) qty = parseInt((qtyEl.innerText || '').replace(/\D/g, '')) || 0;
        if (name) map[name] = (map[name] || 0) + qty;
      });
    }
    return map;
  }

  // Generic GM json helper
  function gmGetJson(url, timeout = 14000) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        timeout,
        onload: (res) => {
          try {
            const txt = (typeof res.response === 'string' && res.response) ? res.response : res.responseText;
            const parsed = txt && txt.length ? JSON.parse(txt) : res.response;
            resolve(parsed);
          } catch (e) { reject(e); }
        },
        onerror: (err) => reject(err),
        ontimeout: () => reject(new Error('timeout'))
      });
    });
  }

  // Boot
  buildUI();
  // ensure collapsed default
  const initiallyCollapsed = GM_getValue(`${PANEL_ID}-collapsed`, true);
  const rootEl = document.getElementById(PANEL_ID);
  if (initiallyCollapsed) rootEl.classList.add('collapsed'); else rootEl.classList.remove('collapsed');

  refreshAll(true);
  let timer = setInterval(() => refreshAll(false), REFRESH_MS);
  window.addEventListener('beforeunload', () => { if (timer) clearInterval(timer); });

  // reposition if header size changes
  function reposition() {
    const r = document.getElementById(PANEL_ID);
    if (!r) return;
    const top = getPDANavHeight();
    r.style.top = top + 'px';
  }
  reposition();
  window.addEventListener('resize', reposition);
  new MutationObserver(reposition).observe(document.documentElement || document.body, { childList: true, subtree: true, attributes: true });

})();