🌺🧸 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.

اعتبارا من 13-10-2025. شاهد أحدث إصدار.

// ==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 });

})();