🌺🧸 Unified Display & Points (Above PDA) v3.4.2-merged

Slim top-center toggle + fixed merged YATA+Prometheus stock. Display+inventory + abroad stk (flowers, plushies, Xanax). Refresh button, color-coded stk, merge by averaging. 45s refresh. Collapsed shows only "🌺 🧸 Exporter".

Ekde 2025/10/13. Vidu La ĝisdata versio.

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 (Above PDA) v3.4.2-merged
// @namespace    http://tampermonkey.net/
// @version      3.4.2.1
// @description  Slim top-center toggle + fixed merged YATA+Prometheus stock. Display+inventory + abroad stk (flowers, plushies, Xanax). Refresh button, color-coded stk, merge by averaging. 45s refresh. Collapsed shows only "🌺 🧸 Exporter".
// @match        https://www.torn.com/*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @connect      api.torn.com
// @connect      yata.yt
// @connect      api.prombot.co.uk
// @run-at       document-end
// ==/UserScript==

(function(){
  'use strict';

  const PANEL_ID = 'unified_points_merged_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';

  // Items: [FullName, {code, flag, short}]
  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';

  // mapping many common country names -> 3/abbr codes used in YATA
  const COUNTRY_NAME_TO_CODE = {
    'JAPAN':'JAP','JAP':'JAP','JPN':'JAP',
    'MEXICO':'MEX','MEX':'MEX',
    'CANADA':'CAN','CAN':'CAN',
    'CHINA':'CHI','CHN':'CHI','CHI':'CHI',
    'UNITED KINGDOM':'UNI','UK':'UNI','GBR':'UNI','BRITAIN':'UNI',
    'ARGENTINA':'ARG','ARG':'ARG',
    'SWITZERLAND':'SWI','SWI':'SWI',
    'HAWAII':'HAW','HAW':'HAW',
    'UAE':'UAE','UNITED ARAB EMIRATES':'UAE',
    'CAYMAN ISLANDS':'CAY','CAY':'CAY',
    'SOUTH AFRICA':'SOU','S.A':'SOU','SA':'SOU','SOU':'SOU',
    'TORN':'BB','B.B':'BB','TOWN':'BB'
  };

  // helper to compute PDA top
  function getPDANavHeight(){
    const nav = document.querySelector('#pda-nav') || document.querySelector('.pda') || document.querySelector('#pda');
    return nav ? nav.offsetHeight : 40;
  }

  // styles: center top, width ~1/3 screen, squeezed left
  GM_addStyle(`
    #${PANEL_ID} { position: fixed; top: ${getPDANavHeight()}px; left: 50%; transform: translateX(-50%); z-index: 999999; pointer-events:auto; font-family:"DejaVu Sans Mono",monospace; font-size:12px; }
    .${PANEL_ID}-toggle { display:flex; align-items:center; gap:8px; padding:6px 8px; cursor:pointer; color:#dfe7ff; user-select:none; border-radius:4px; transition:background .12s; }
    .${PANEL_ID}-toggle:hover { background: rgba(255,255,255,0.02); }
    .${PANEL_ID}-card { margin-top:6px; width:33vw; min-width:300px; max-width:640px; background: rgba(8,8,8,0.78); color:#e9eef8; border-radius:6px; box-shadow:0 10px 30px rgba(0,0,0,0.6); border:1px solid rgba(255,255,255,0.04); overflow:hidden; }
    .${PANEL_ID}.collapsed .${PANEL_ID}-card { display:none; }
    .${PANEL_ID}-header { display:flex; align-items:center; padding:6px 8px; gap:8px; }
    .${PANEL_ID}-refresh { margin-left:auto; background:transparent; color:#dfe7ff; border:1px solid rgba(255,255,255,0.04); padding:4px 7px; border-radius:4px; cursor:pointer; }
    .${PANEL_ID}-body { padding:8px 6px; font-size:12px; line-height:1.06; max-height:68vh; 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 44px; text-align:right; color:#cfe8c6; padding-left:2px; }
    .col-st { flex:0 0 72px; text-align:right; color:#f7b3b3; padding-left:2px; }
    .col-mis { flex:0 0 40px; 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:4px; }
    .${PANEL_ID}-source { color:#9ea6b3; font-size:11px; margin-top:4px; }
    @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; } }
  `);

  // build UI
  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`, false);
    if (collapsed) root.classList.add(PANEL_ID, 'collapsed');
    else root.classList.add(PANEL_ID);

    root.innerHTML = `
      <div class="${PANEL_ID}-toggle" id="${PANEL_ID}-toggle">🌺 🧸 Exporter</div>
      <div class="${PANEL_ID}-card" role="region" aria-label="Unified Display & Points">
        <div class="${PANEL_ID}-header">
          <div style="font-weight:700;color:#dfe7ff">Unified Display & 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" id="${PANEL_ID}-bottom">
          <div id="${PANEL_ID}-flylines"></div>
          <div class="${PANEL_ID}-source" id="${PANEL_ID}-source">Data source: —</div>
        </div>
      </div>
    `;

    document.body.appendChild(root);

    // toggle
    const toggle = document.getElementById(`${PANEL_ID}-toggle`);
    toggle.addEventListener('click', () => {
      const root = document.getElementById(PANEL_ID);
      const collapsedNow = root.classList.toggle('collapsed');
      GM_setValue(`${PANEL_ID}-collapsed`, collapsedNow);
      // ensure collapsed state only leaves toggle visible (card display toggled by CSS)
    });
    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); });

    return root;
  }

  // --- Networking helper (GM_xmlhttpRequest 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(e){ reject(e); }
        },
        onerror: err => reject(err),
        ontimeout: () => reject(new Error('timeout'))
      });
    });
  }

  // --- Parse YATA (country codes are keys)
  function parseYata(yataData){
    const map = {}; // countryCode -> {itemName: qty}
    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: try to normalize to same country-code keyed map
  function parseProm(promData){
    const map = {}; // countryCode -> {itemName: qty}
    if (!promData) return map;
    // Prometheus may return an object keyed by country names (or codes). We'll handle both.
    for (const [countryKey, countryVal] of Object.entries(promData)){
      if (!countryVal) continue;
      // normalize countryKey to a code if possible
      const up = String(countryKey).trim().toUpperCase();
      let code = up;
      // if it's a full name, try mapping
      if (COUNTRY_NAME_TO_CODE[up]) code = COUNTRY_NAME_TO_CODE[up];
      // prepare item map
      const m = {};
      // countryVal might be {stocks: [...] } or { "Camel Plushie": {quantity: 123} } or similar
      if (Array.isArray(countryVal.stocks)){
        // handle array-of-stocks format
        for (const it of countryVal.stocks){
          if (!it || !it.name) continue;
          m[it.name] = Number(it.quantity ?? it.qty ?? 0) || 0;
        }
      } else {
        // object map style
        for (const [k,v] of Object.entries(countryVal)){
          // v might be {quantity: X} or a raw number
          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)){
            // sometimes nested
            for (const it of v.stocks) if (it && it.name) m[it.name] = Number(it.quantity ?? it.qty ?? 0) || 0;
          } else {
            // best-effort: skip
          }
        }
      }
      map[String(code).toUpperCase()] = m;
    }
    return map;
  }

  // --- Merge maps 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){
          // average
          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;
  }

  // helper: sum 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 for item (largest quantity) from merged map
  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 (same logic as before)
  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 helpers
  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 concise fly lines: up to 4 lines, "Fly to <ABBR> for <Short>"
  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 —'];
  }

  // show merged data & UI
  function renderUI(displayMap, yataMapRaw, promMapRaw, sourcesUsed){
    // parse maps
    const yataMap = parseYata(yataMapRaw);
    const promMap = parseProm(promMapRaw);
    const mergedMap = mergeMaps(yataMap, promMap);

    const flowers = computeForGroup(displayMap, FLOWERS_ORDER);
    const plush = computeForGroup(displayMap, PLUSHIES_ORDER);
    const totalSets = (flowers.sets || 0) + (plush.sets || 0);
    const totalPoints = totalSets * POINTS_PER_SET;

    const statusEl = document.getElementById(`${PANEL_ID}-status`);
    if (statusEl) statusEl.textContent = `Updated: ${new Date().toLocaleTimeString()} — Sets:${totalSets} 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 row: count from displayMap, stk from mergedMap (sum across countries)
    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);
    // find best country for xanax
    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>`;

    // fly lines
    const flyLines = buildFlyLines(flowers.missing, plush.missing, mergedMap);
    const flyContainer = document.getElementById(`${PANEL_ID}-flylines`);
    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(' + ') : '—'}`;
  }

  // fetch flow: get display data then both sources 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 || {};
      }

      // determine sources used
      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()} — Sets and points above.`;
    } catch (e){
      console.warn('refreshAll err', e);
      const statusEl = document.getElementById(`${PANEL_ID}-status`);
      if (statusEl) statusEl.textContent = 'Update failed';
    }
  }

  // Torn display 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;
  }

  // small utilities
  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();
  // restore collapse
  const wasCollapsed = GM_getValue(`${PANEL_ID}-collapsed`, false);
  if (wasCollapsed) document.getElementById(PANEL_ID).classList.add('collapsed');

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

  // reposition on header 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);
  const obs = new MutationObserver(reposition);
  obs.observe(document.documentElement || document.body, { childList:true, subtree:true, attributes:true });

})();