Torn Faction OC Item Handling

Uses Torn API to list missing OC item requirements for your faction and adds quick-send helpers on /item.php

// ==UserScript==
// @name         Torn Faction OC Item Handling
// @namespace    https://torn.com/
// @version      1.1.1
// @description  Uses Torn API to list missing OC item requirements for your faction and adds quick-send helpers on /item.php
// @author       Canixe [3753120]
// @match        https://www.torn.com/item.php*
// @run-at       document-idle
// @grant        GM_xmlhttpRequest
// @grant        GM.xmlHttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM.registerMenuCommand
// @grant        GM_registerMenuCommand
// @connect      api.torn.com
// ==/UserScript==

(() => {
  "use strict";

  //////////////////////////////////////////////////////////////////////////////
  // CONSTANTS
  //////////////////////////////////////////////////////////////////////////////
  const SECTION_ID  = "tf-oc-item-handling";
  const TITLE_TEXT  = "Faction OC Item Handling";
  const API_COMMENT = "TF-OC-Item-Handling";
  const API_URL     = "https://api.torn.com/v2/faction/crimes";
  const ITEMS_URL   = "https://api.torn.com/v2/torn/items";
  const ITEMS_CACHE_KEY = "itemsCatalogV1";
  const ITEMS_TTL_MS    = 24 * 60 * 60 * 1000; // 24 hours cache

  const FILTER_STATUS_KEY = "filterOcStatus";
  const FILTER_TYPE_KEY   = "filterItemType";
  const DEFAULT_STATUS = "Recruiting,Planning";
  const DEFAULT_TYPES  = "Consumables,Reusables";
  const ALLOWED_STATUS = ["Recruiting", "Planning"];
  const ALLOWED_TYPES  = ["Consumables", "Reusables"];

  const DEFAULT_MSG_REUSABLE   = "For upcoming OC, return when OC completed";
  const DEFAULT_MSG_CONSUMABLE = "For upcoming OC";
  const KEY_MSG_REUSABLE   = `${SECTION_ID}:msg:reusable`;
  const KEY_MSG_CONSUMABLE = `${SECTION_ID}:msg:consumable`;

  const SHOW_THANKS = false;
  const TEST_MODE = false;

  const TEST_FIXTURE = {
    crimes: [{
      id: 999999,
      name: "Dummy Organized Crime",
      status: "Planning",
      ready_at: 1757942576,
      slots: [{
        user: { id: 3753120, name: null },
        item_requirement: { id: 201, is_reusable: false, is_available: false }
      }]
    }, {
      id: 999999,
      name: "Dummy Organized Crime",
      status: "Planning",
      ready_at: 1757942576,
      slots: [{
        user: { id: 3753120, name: null },
        item_requirement: { id: 1431, is_reusable: false, is_available: false }
      }]
    }, {
      id: 999999,
      name: "Dummy Organized Crime",
      status: "Planning",
      ready_at: 1757942576,
      slots: [{
        user: { id: 3753120, name: null },
        item_requirement: { id: 1429, is_reusable: false, is_available: false }
      }]
    }, {
      id: 999999,
      name: "Dummy Organized Crime",
      status: "Planning",
      ready_at: 1757942576,
      slots: [{
        user: { id: 3753120, name: null },
        item_requirement: { id: 1203, is_reusable: true, is_available: false }
      }]
    }]
  };

  //////////////////////////////////////////////////////////////////////////////
  // STYLES
  //////////////////////////////////////////////////////////////////////////////
  const ICONS = {
    refresh:     '<svg class="tf-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"/><path d="M21 13a8 8 0 1 1-3-6.74L21 8"/></svg>',
    search:      '<svg class="tf-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>',
    send:        '<svg class="tf-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="M22 2 15 22 11 13 2 9 22 2"/></svg>',
    user:        '<svg class="tf-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21a8 8 0 0 0-16 0"/><circle cx="12" cy="7" r="4"/></svg>',
    comment:     '<svg class="tf-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
    pen:         '<svg class="tf-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 1 1 3 3L7 19l-4 1 1-4Z"/></svg>',
    arrowRight:  '<svg class="tf-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>',
    check:       '<svg class="tf-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
  };

  const style = `
    #${SECTION_ID} { margin-bottom:14px; }
    #${SECTION_ID} .tf-refresh{
      display:inline-flex; align-items:center; gap:6px;
      color:#fff; font-weight:600; cursor:pointer; user-select:none;
      padding:0 4px; text-decoration:none;
    }
    #${SECTION_ID} .tf-refresh:hover{ text-decoration:underline; }
    #${SECTION_ID}-content{
      padding:5px;
      background: var(--default-bg-panel-color);
      border-radius: 0 0 6px 6px;
    }
    #${SECTION_ID} .tf-muted{ opacity:.75; font-style:italic; padding:4px 2px; }

    /* grid: main content dominates, thanks card slim on the right (wide screens) */
    #${SECTION_ID} .tf-grid{
      display:grid; gap:8px; align-items:start;
      grid-template-columns: 1fr;
    }
    @media (min-width: 980px){
      #${SECTION_ID} .tf-grid{ grid-template-columns: minmax(340px, 1fr) 220px; }
    }
    @media (min-width: 980px){
      #${SECTION_ID} .tf-grid.no-thanks{ grid-template-columns: minmax(340px, 1fr); }
    }

    /* card */
    #${SECTION_ID} .card{
      border:1px solid var(--default-panel-divider-outer-side-color);
      border-radius:6px;
      background: var(--default-bg-panel-color);
      padding:8px; display:flex; flex-direction:column;
    }
    #${SECTION_ID} .card h4{
      margin:0 0 6px; font-weight:700; font-size:13px;
      display:flex; gap:8px; align-items:center; justify-content:space-between;
    }
    #${SECTION_ID} .card a.small{ font-size:11px; text-decoration:underline; color:var(--default-blue-color); }

    /* list + row buttons */
    #${SECTION_ID} .card ul{ list-style:none; margin:0; padding-left:0; }
    #${SECTION_ID} .card li{ display:flex; align-items:flex-start; gap:6px; line-height:1.35; padding:2px 0; }
    #${SECTION_ID} .tf-rowbtn{
      display:inline-flex; align-items:center; justify-content:center;
      width:16px; height:16px; margin-top:1px; flex:0 0 auto; padding:0;
      border:1px solid var(--default-panel-divider-outer-side-color);
      border-radius:3px; cursor:pointer; font-size:11px; line-height:1;
      background: var(--default-bg-panel-active-color);
    }
    #${SECTION_ID} .tf-rowbtn:hover{ filter:brightness(1.06); }
    #${SECTION_ID} .tf-rowbtn.tf-done{ opacity:.65; cursor:default; }

    /* misc */
    #${SECTION_ID} .spinner{ font-size:12px; opacity:.7; }
    #${SECTION_ID} .error{ color:#b00020; }
    #${SECTION_ID} .hint{ font-size:12px; opacity:.8; }

    /* log */
    #${SECTION_ID} .tf-log h4{ margin:0 0 6px; font-weight:700; font-size:13px; }
    #${SECTION_ID} .tf-log ul{ margin:0; padding-left:18px; }
    #${SECTION_ID} .tf-log-head{
      display:flex; align-items:center; justify-content:space-between;
      margin:0 0 6px; font-weight:700; font-size:13px;
    }
    #${SECTION_ID} .tf-copy{
      font-size:12px; padding:2px 8px; cursor:pointer; border-radius:4px;
      border:1px solid var(--default-panel-divider-outer-side-color);
      background: var(--default-bg-panel-active-color);
    }
    #${SECTION_ID} .tf-copy:hover{ filter:brightness(1.06); }

    /* settings area */
    #${SECTION_ID} .pill { display:inline-block; border:1px solid var(--default-panel-divider-outer-side-color); border-radius:999px; padding:2px 8px; font-size:11px; margin-right:6px; background:var(--default-bg-panel-active-color); }
    #${SECTION_ID} .pill a { color:var(--default-blue-color); text-decoration:underline; }
    #${SECTION_ID} .tf-tip { font-size:11px; opacity:.75; }

    /* thanks card — extra small and subtle */
    #${SECTION_ID} .card--thanks{
      font-size:11px; opacity:.7; padding:6px;
    }
    #${SECTION_ID} .card--thanks h4{ font-size:12px; margin-bottom:4px; }
    #${SECTION_ID} .card--thanks p{ margin:0; line-height:1.3; }
    #${SECTION_ID} .card--thanks a{ color:var(--default-blue-color); text-decoration:underline; }
    #${SECTION_ID} .tf-deadline{ margin-left:6px; font-size:11px; opacity:.8; }
    #${SECTION_ID} .tf-deadline.overdue{ color:#b00020; opacity:1; font-weight:600; }
    #${SECTION_ID} .tf-icon { width:14px; height:14px; display:inline-block; vertical-align:-2px; }
    #${SECTION_ID} .tf-rowbtn svg.tf-icon { width:12px; height:12px; }
    @media (pointer:coarse){
      #${SECTION_ID} .tf-rowbtn { width:22px; height:22px; }       /* bigger tap target on mobile */
      #${SECTION_ID} .tf-rowbtn svg.tf-icon { width:16px; height:16px; }
    }
    #${SECTION_ID} .tf-buy {
      margin-left: 8px;
      font-size: 11px;
      padding: 2px 6px;
      border: 1px solid var(--default-panel-divider-outer-side-color);
      border-radius: 4px;
      background: var(--default-bg-panel-active-color);
      color: var(--default-blue-color);
      text-decoration: underline;
    }
    .tf-popover{
      position: fixed;
      z-index: 2147483647;
      min-width:220px; max-width:280px; padding:8px;
      border:1px solid var(--default-panel-divider-outer-side-color);
      border-radius:6px; background:var(--default-bg-panel-color);
      box-shadow:0 8px 20px rgba(0,0,0,.25);
      max-height: calc(100vh - 24px);
      overflow: auto;
    }
    .tf-popover-backdrop{
      position: fixed;
      inset: 0;
      z-index: 2147483646;
      background: transparent;       /* keep it invisible */
    }
    .tf-popover .hd{ font-weight:700; margin:0 0 6px; display:flex; justify-content:space-between; align-items:center; }
    .tf-popover .bd label{ display:flex; align-items:center; gap:6px; margin:3px 0; }
    .tf-popover .ft{ margin-top:8px; display:flex; gap:6px; justify-content:flex-end; }
    .tf-btn{
      font-size:12px; padding:3px 8px; border-radius:4px; cursor:pointer;
      border:1px solid var(--default-panel-divider-outer-side-color);
      background:var(--default-bg-panel-active-color);
    }
    .tf-btn:hover{ filter:brightness(1.06); }
    .tf-popover .bd textarea{
      width:100%; min-height:48px; resize:vertical;
      background:var(--default-bg-panel-active-color);
      border:1px solid var(--default-panel-divider-outer-side-color);
      border-radius:6px; padding:6px; font:inherit;
    }
    .tf-popover .hd .tf-close{
      text-decoration:none; line-height:1; padding:0 6px;
    }
  `;

  //////////////////////////////////////////////////////////////////////////////
  // UTILITIES
  //////////////////////////////////////////////////////////////////////////////
  let ID_TO_NAME = new Map();
  let ID_TO_TYPE = new Map();
  let NAME_TO_ID = new Map();

  let MSG_REUSABLE_CUR   = DEFAULT_MSG_REUSABLE;
  let MSG_CONSUMABLE_CUR = DEFAULT_MSG_CONSUMABLE;

  const delay = ms => new Promise(r => setTimeout(r, ms));
  const normalize = s => (s||"").replace(/\s+/g," ").trim().toLowerCase();

  async function loadMessageTemplates(){
    MSG_REUSABLE_CUR   = await getSetting(KEY_MSG_REUSABLE,   DEFAULT_MSG_REUSABLE);
    MSG_CONSUMABLE_CUR = await getSetting(KEY_MSG_CONSUMABLE, DEFAULT_MSG_CONSUMABLE);
  }

  function getMessageByType(isReusable){
    return isReusable ? MSG_REUSABLE_CUR : MSG_CONSUMABLE_CUR;
  }

  function setInputValue(el, value){
    if(!el) return;
    const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
    setter ? setter.call(el, value) : (el.value = value);
    el.dispatchEvent(new Event("input",  { bubbles:true }));
    el.dispatchEvent(new Event("change", { bubbles:true }));
    el.dispatchEvent(new KeyboardEvent("keyup", { bubbles:true, key:"Enter" }));
  }
  async function waitFor(fn, tries=30, ms=100){
    for(let i=0;i<tries;i++){ const v = fn(); if(v) return v; await delay(ms); }
    return null;
  }
  function clickEl(el){ if(!el) return false; el.dispatchEvent(new MouseEvent("mousedown",{bubbles:true})); el.dispatchEvent(new MouseEvent("mouseup",{bubbles:true})); el.click(); return true; }
  function isVisible(el){
    if(!el || !el.isConnected) return false;
    const r = el.getBoundingClientRect(); const cs = getComputedStyle(el);
    return cs.display!=="none" && cs.visibility!=="hidden" && r.width>0 && r.height>0;
  }
  function getServerEpochSec(){
    const el = document.querySelector(".tc-clock .server-date-time");
    if (el) {
      const m = el.textContent.trim().match(/^[A-Za-z]{3}\s+(\d{2}):(\d{2}):(\d{2})\s*-\s*(\d{2})\/(\d{2})\/(\d{2})$/);
      if (m) {
        const hh = +m[1], mm = +m[2], ss = +m[3];
        const DD = +m[4], MM = +m[5], YY = +m[6];

        return Math.floor(Date.UTC(2000 + YY, MM - 1, DD, hh, mm, ss) / 1000);
      }
    }
    return Math.floor(Date.now() / 1000);
  }
  function getServerTimeFormatted(){
    const epoch = getServerEpochSec();
    const d = new Date(epoch * 1000);
    const p = n => String(n).padStart(2, "0");

    return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())} - ${p(d.getUTCDate())}/${p(d.getUTCMonth() + 1)}/${String(d.getUTCFullYear()).slice(-2)}`;
  }

  function buildMarketUrlByName(itemName) {
    const base = "https://www.torn.com/page.php?sid=ItemMarket#/market/view=search";
    const id = NAME_TO_ID.get(normalize(itemName));
    if (!id) return `${base}&itemName=${encodeURIComponent(itemName)}`;

    const type = ID_TO_TYPE.get(id);
    const typeParam = type ? `&itemType=${encodeURIComponent(type)}` : "";
    return `${base}&itemID=${id}&itemName=${encodeURIComponent(itemName)}${typeParam}`;
  }

  function parseMulti(str, allowed) {
    const set = new Set(
      String(str || "")
      .split(",")
      .map(s => s.trim())
      .filter(v => allowed.includes(v))
    );
    return set.size ? set : new Set(allowed);
  }

  function prettyList(setOrArr) {
    return Array.from(setOrArr).join(", ");
  }

  function showCheckboxPopover(anchorEl, { title, options, selected, onSave }) {
    const sel = new Set(selected || []);

    const pop = document.createElement("div");
    pop.className = "tf-popover";
    pop.innerHTML = `
      <div class="hd">
        <span>${title}</span>
        <button type="button" class="tf-btn tf-close">×</button>
      </div>
      <div class="bd"></div>
      <div class="ft">
        <button type="button" class="tf-btn tf-cancel">Cancel</button>
        <button type="button" class="tf-btn tf-save">Save</button>
      </div>
    `;

    const bd = pop.querySelector(".bd");
    options.forEach(opt => {
      const id = `tfopt-${title.replace(/\s+/g,'-')}-${opt.value}`;
      const row = document.createElement("label");
      row.innerHTML = `
      <input type="checkbox" id="${id}" value="${opt.value}">
      <span>${opt.label}</span>
    `;
      const cb = row.querySelector("input");
      cb.checked = sel.has(opt.value);
      cb.addEventListener("change", () => {
        if (cb.checked) sel.add(opt.value); else sel.delete(opt.value);
      });
      bd.appendChild(row);
    });

    // Backdrop + attach
    const backdrop = document.createElement('div');
    backdrop.className = 'tf-popover-backdrop';
    document.body.appendChild(backdrop);
    document.body.appendChild(pop);

    // Initial placement + keep in view on resize/scroll
    const place = () => clampPopoverToViewport(pop, anchorEl, 8);
    place();
    const onReflow = () => place();
    window.addEventListener('resize', onReflow, { passive: true });
    window.addEventListener('scroll', onReflow, { passive: true });

    // Close helpers
    const close = () => {
      window.removeEventListener('resize', onReflow);
      window.removeEventListener('scroll', onReflow);
      backdrop.remove();
      pop.remove();
      document.removeEventListener('keydown', onKey);
    };

    const onKey = (e) => { if (e.key === 'Escape') close(); };

    // Wire buttons + outside click
    pop.querySelector(".tf-close")  .addEventListener("click", close);
    pop.querySelector(".tf-cancel") .addEventListener("click", close);
    pop.querySelector(".tf-save")   .addEventListener("click", () => {
      if (sel.size === 0) return; // require at least one
      onSave(Array.from(sel));
      close();
    });
    backdrop.addEventListener('mousedown', close, { once: true });
    document.addEventListener('keydown', onKey);

  }

  function openMessagesPopover(anchorEl){
    document.querySelectorAll('.tf-popover, .tf-popover-backdrop').forEach(n => n.remove());

    const backdrop = document.createElement('div');
    backdrop.className = 'tf-popover-backdrop';

    const pop = document.createElement('div');
    pop.className = 'tf-popover';
    pop.innerHTML = `
    <div class="hd">
      <span>OC Send Messages</span>
      <a href="#" class="tf-btn tf-close" data-act="cancel" aria-label="Close">×</a>
    </div>
    <div class="bd">
      <label>
        <strong style="min-width:84px; display:inline-block;">Reusable</strong>
        <textarea id="${SECTION_ID}-msg-reusable" placeholder="${DEFAULT_MSG_REUSABLE}">${MSG_REUSABLE_CUR}</textarea>
      </label>
      <label>
        <strong style="min-width:84px; display:inline-block;">Consumable</strong>
        <textarea id="${SECTION_ID}-msg-consumable" placeholder="${DEFAULT_MSG_CONSUMABLE}">${MSG_CONSUMABLE_CUR}</textarea>
      </label>
    </div>
    <div class="ft">
      <button class="tf-btn" data-act="cancel">Cancel</button>
      <button class="tf-btn" data-act="save">Save</button>
    </div>
  `;

    document.body.appendChild(backdrop);
    document.body.appendChild(pop);

    const place = () => {
      const pad = 8;
      const r = anchorEl.getBoundingClientRect();
      const w = pop.offsetWidth || 280;
      const h = pop.offsetHeight || 120;
      let left = Math.max(pad, Math.min(r.left, window.innerWidth - w - pad));
      let top  = Math.max(pad, Math.min(r.bottom + 6, window.innerHeight - h - pad));
      pop.style.left = left + 'px';
      pop.style.top  = top  + 'px';
    };
    place();

    const ro = () => place();
    window.addEventListener('resize', ro, { passive:true });
    window.addEventListener('scroll', ro, { passive:true });

    const cleanup = () => {
      window.removeEventListener('resize', ro);
      window.removeEventListener('scroll', ro);
      backdrop.remove();
      pop.remove();
    };

    backdrop.addEventListener('mousedown', cleanup, { once:true });
    pop.addEventListener('click', async (e) => {
      const act = e.target?.dataset?.act;
      if(!act) return;
      e.preventDefault();
      if(act === 'cancel'){ cleanup(); return; }
      if(act === 'save'){
        const r = pop.querySelector(`#${SECTION_ID}-msg-reusable`)?.value ?? MSG_REUSABLE_CUR;
        const c = pop.querySelector(`#${SECTION_ID}-msg-consumable`)?.value ?? MSG_CONSUMABLE_CUR;
        await setSetting(KEY_MSG_REUSABLE, r.trim());
        await setSetting(KEY_MSG_CONSUMABLE, c.trim());
        await loadMessageTemplates();
        cleanup();
      }
    });
  }

  function clampPopoverToViewport(panelEl, triggerEl, offset = 8){
    if (!panelEl || !triggerEl) return;

    panelEl.style.position = 'fixed';
    panelEl.style.visibility = 'hidden';
    panelEl.style.display = 'block';

    const r = triggerEl.getBoundingClientRect();
    const w = panelEl.offsetWidth;
    const h = panelEl.offsetHeight;

    let top  = r.bottom + offset;
    let left = r.left;

    if (top + h > window.innerHeight - offset) {
      const above = r.top - h - offset;
      top = (above > offset) ? above : Math.max(offset, window.innerHeight - h - offset);
    }

    if (left + w > window.innerWidth - offset) left = Math.max(offset, window.innerWidth - w - offset);
    if (left < offset) left = offset;

    panelEl.style.top = `${top}px`;
    panelEl.style.left = `${left}px`;
    panelEl.style.visibility = '';
  }

  //////////////////////////////////////////////////////////////////////////////
  // STORAGE / MENU
  //////////////////////////////////////////////////////////////////////////////
  async function getSetting(key, def=""){
    try{
      if(typeof GM !== "undefined" && GM.getValue) return await GM.getValue(key, def);
      if(typeof GM_getValue !== "undefined"){
        const v = GM_getValue(key);
        return v == null ? def : v;
      }
    }catch{}
    return def;
  }
  async function setSetting(key, val){
    try{
      if(typeof GM !== "undefined" && GM.setValue) return await GM.setValue(key, val);
      if(typeof GM_setValue !== "undefined") return GM_setValue(key, val);
    }catch{}
  }
  function registerMenus(){
    const reg = (label, fn) => {
      if(typeof GM !== "undefined" && GM.registerMenuCommand) GM.registerMenuCommand(label, fn);
      else if(typeof GM_registerMenuCommand !== "undefined") GM_registerMenuCommand(label, fn);
    };
    reg("Set Torn API key", async () => {
      const cur = await getSetting("apiKey", "");
      const v = prompt("Enter your Torn API key (requires Minimal Access):", cur || "");
      if(v !== null) await setSetting("apiKey", v.trim());
      showSettingsHint();
    });
  }

  //////////////////////////////////////////////////////////////////////////////
  // DOM
  //////////////////////////////////////////////////////////////////////////////
  function injectStyle(){
    if(document.getElementById(`${SECTION_ID}-style`)) return;
    const s=document.createElement("style"); s.id=`${SECTION_ID}-style`; s.textContent=style; document.head.appendChild(s);
  }

  function makeSection(){
    if(document.getElementById(SECTION_ID)) return document.getElementById(SECTION_ID);

    const CLS = { wrap:"equipped-items-wrap", main:"main___QuzF7", header:"header___f_BFs", title:"title___nIMRx", icons:"icons___VmEI4", btn:"button___MO5cW", caretBtn:"iconParentButton___POutJ", caretFill:"grayFill___tkuer", content:"content___Gb8DR" };
    const OPEN_SVG     = `<svg xmlns="http://www.w3.org/2000/svg" width="11" height="16" viewBox="0 0 11 16" class="${CLS.caretFill}"><path d="M1302,21l-5,5V16Z" transform="translate(-1294 -13)"></path></svg>`;
    const COLLAPSE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="11" viewBox="0 0 16 11" class="${CLS.caretFill}"><path d="M1302,21l-5,5V16Z" transform="translate(29 -1294) rotate(90)"></path></svg>`;

    const wrap = document.createElement("div");
    wrap.id = SECTION_ID;
    wrap.className = CLS.wrap;
    wrap.innerHTML = `
      <div class="${CLS.main}">
        <header class="${CLS.header}">
          <p class="${CLS.title}" role="heading" aria-level="2">${TITLE_TEXT}</p>
          <nav class="${CLS.icons}">
            <div id="${SECTION_ID}-refresh" class="tf-refresh">${ICONS.refresh} Refresh</div>
            <button type="button" class="${CLS.btn} ${CLS.caretBtn}" id="${SECTION_ID}-toggle" aria-label="Open" aria-expanded="false">${OPEN_SVG}</button>
          </nav>
        </header>
        <div class="${CLS.content}" id="${SECTION_ID}-content" hidden>
          <div id="${SECTION_ID}-placeholder" class="tf-muted">Click Refresh to display information.</div>
          <div id="${SECTION_ID}-settings" class="hint" style="padding:4px 0 6px;"></div>
          <div class="tf-grid" id="${SECTION_ID}-grid" style="display:none"></div>
          <div id="${SECTION_ID}-logwrap" class="card tf-log" style="display:none">
            <h4 class="tf-log-head"><span>LOG</span><button type="button" id="${SECTION_ID}-copylog" class="tf-copy">Copy</button></h4>
            <ul id="${SECTION_ID}-loglist"></ul>
          </div>
        </div>
      </div>
    `;

    const contentEl = wrap.querySelector(`#${SECTION_ID}-content`);
    const toggleBtn = wrap.querySelector(`#${SECTION_ID}-toggle`);
    const setExpanded = (on) => {
      contentEl.hidden = !on;
      toggleBtn.setAttribute("aria-expanded", String(on));
      toggleBtn.setAttribute("aria-label", on ? "Collapse" : "Open");
      toggleBtn.innerHTML = on ? COLLAPSE_SVG : OPEN_SVG;
    };
    setExpanded(false);
    toggleBtn.addEventListener("click", e => { e.preventDefault(); setExpanded(toggleBtn.getAttribute("aria-expanded")==="false"); });

    return wrap;
  }

  function insertSection(){
    const section = makeSection();
    if(!section) return;
    const quickItems   = document.querySelector("#quickItems");
    const loadoutsRoot = document.querySelector("#loadoutsRoot");
    if(quickItems && loadoutsRoot)        loadoutsRoot.parentElement.insertBefore(section, loadoutsRoot);
    else if(quickItems)                   quickItems.insertAdjacentElement("afterend", section);
    else if(loadoutsRoot)                 loadoutsRoot.parentElement.insertBefore(section, loadoutsRoot);
    else { (document.querySelector(".main-items-cont-wrap") || document.body).prepend(section); }
  }

  async function showSettingsHint(){
    const el = document.getElementById(`${SECTION_ID}-settings`);
    if(!el) return;

    const testTag = TEST_MODE ? `<span class="pill" title="Using dummy data instead of the live API">TEST MODE</span>` : "";

    const key = await getSetting("apiKey","");
    const keyBadge = key
      ? `<span class="pill">API key: <em>set</em> · <a href="#" id="${SECTION_ID}-setkey">edit</a></span>`
      : `<span class="pill">API key: <strong>not set</strong> · <a href="#" id="${SECTION_ID}-setkey">set</a></span>`;
    const tip = key ? "" : `<span class="tf-tip">Requires <em>Minimal Access</em>.</span>`;

    const msgsPill = `<span class="pill"><a href="#" id="${SECTION_ID}-editmsgs">Messages</a></span>`;

    const statusSel = parseMulti(await getSetting(FILTER_STATUS_KEY, DEFAULT_STATUS), ALLOWED_STATUS);
    const typesSel  = parseMulti(await getSetting(FILTER_TYPE_KEY,   DEFAULT_TYPES),  ALLOWED_TYPES);

    const statusBadge = `<span class="pill">OC status: <em>${prettyList(statusSel)}</em> · <a href="#" id="${SECTION_ID}-setstatus">edit</a></span>`;
    const typeBadge   = `<span class="pill">Item type: <em>${prettyList(typesSel)}</em> · <a href="#" id="${SECTION_ID}-settype">edit</a></span>`;

    el.innerHTML = `${testTag} ${keyBadge}${tip ? " " + tip : ""} ${msgsPill} ${statusBadge} ${typeBadge}`;

    el.querySelector(`#${SECTION_ID}-setkey`)?.addEventListener("click", async (e)=>{
      e.preventDefault();
      const cur=await getSetting("apiKey","");
      const v=prompt("Enter your Torn API key (requires Minimal Access):", cur||"");
      if(v!==null){ await setSetting("apiKey", v.trim()); showSettingsHint(); }
    });

    el.querySelector(`#${SECTION_ID}-setstatus`)?.addEventListener("click", async (e)=>{
      e.preventDefault();

      const current = parseMulti(await getSetting(FILTER_STATUS_KEY, DEFAULT_STATUS), ALLOWED_STATUS);
      showCheckboxPopover(e.currentTarget, {
        title: "OC status",
        options: [
          { value: "Recruiting", label: "Recruiting" },
          { value: "Planning",   label: "Planning"   },
        ],
        selected: current,
        onSave: async (vals) => {
          await setSetting(FILTER_STATUS_KEY, vals.join(","));
          await showSettingsHint();            // refresh pills text
          await refreshAll();                  // reload data with new filter
        }
      });
    });

    el.querySelector(`#${SECTION_ID}-settype`)?.addEventListener("click", async (e)=>{
      e.preventDefault();

      const current = parseMulti(await getSetting(FILTER_TYPE_KEY, DEFAULT_TYPES), ALLOWED_TYPES);
      showCheckboxPopover(e.currentTarget, {
        title: "Item type",
        options: [
          { value: "Consumables", label: "Consumables" },
          { value: "Reusables",   label: "Reusables"   },
        ],
        selected: current,
        onSave: async (vals) => {
          await setSetting(FILTER_TYPE_KEY, vals.join(","));
          await showSettingsHint();
          await refreshAll();
        }
      });
    });

    el.querySelector(`#${SECTION_ID}-editmsgs`)?.addEventListener('click', (e) => {
      e.preventDefault();
      openMessagesPopover(e.currentTarget);
    });

    await loadMessageTemplates();
  }

  //////////////////////////////////////////////////////////////////////////////
  // NETWORKING
  //////////////////////////////////////////////////////////////////////////////
  const httpGetJSON = (url) => {
    const fn = (typeof GM !== "undefined" && GM.xmlHttpRequest) ? GM.xmlHttpRequest : GM_xmlhttpRequest;
    return new Promise((resolve,reject) => {
      fn({
        method:"GET", url, headers:{Accept:"application/json"},
        onload:res => {
          if(!(res.status>=200 && res.status<300)) return reject(new Error(`HTTP ${res.status}`));
          try{
            const data = JSON.parse(res.responseText);
            if(data && (data.error || data.code)){
              const code = data.error?.code ?? data.code ?? "unknown";
              const msg  = data.error?.error ?? data.error?.message ?? "API error";
              const nice =
                    (code === 5 || code === 17) ? "Rate limited: please try again in ~30s" :
                    (code === 7) ? "Incorrect ID-entity relation — this key lacks Faction API Access (ask your faction to grant it)." :
                    msg;
              reject(new Error(`Torn API error ${code}: ${nice}`));
            }else{
              resolve(data);
            }
          }catch(e){ reject(new Error("Invalid JSON response")); }
        },
        onerror:() => reject(new Error("Network error")),
        ontimeout:() => reject(new Error("Request timed out")),
        timeout:25000
      });
    });
  };

  async function fetchCrimesForCat(cat) {
    const key = await getSetting("apiKey","");
    if(!key) throw new Error("No Torn API key set. Use the menu or the 'set' link above.");
    const u = new URL(API_URL);
    u.searchParams.set("comment", API_COMMENT);
    u.searchParams.set("key", key.trim());
    u.searchParams.set("cat", String(cat).toLowerCase());
    return await httpGetJSON(u.toString());
  }

  async function fetchCrimesFromTorn(cats) {
    if (TEST_MODE) return TEST_FIXTURE;

    const list = (Array.isArray(cats) && cats.length) ? cats : ALLOWED_STATUS;
    const results = await Promise.allSettled(list.map(c => fetchCrimesForCat(c)));

    let crimes = [];
    let firstErr = null;

    for (const r of results) {
      if (r.status === "fulfilled" && r.value && r.value.crimes) {
        const arr = Array.isArray(r.value.crimes) ? r.value.crimes : Object.values(r.value.crimes);
        crimes = crimes.concat(arr);
      } else if (!firstErr && r.status === "rejected") {
        firstErr = r.reason;
      }
    }

    if (!crimes.length && firstErr) throw firstErr;
    return { crimes };
  }


  function parseItemsPayload(json){
    const raw = json?.items || json?.result?.items || json || {};
    let entries = Array.isArray(raw) ? raw
    : raw && typeof raw === "object" ? Object.entries(raw).map(([id, v]) => ({ id: Number(id), ...v }))
    : [];

    const byIdName = new Map();
    const byIdType = new Map();
    for (const it of entries) {
      const id = Number(it?.id);
      const nm = String(it?.name || "").trim();
      if (!Number.isFinite(id) || !nm) continue;
      byIdName.set(id, nm);
      if (it?.type) byIdType.set(id, String(it.type));
    }
    return { byIdName, byIdType };
  }

  function mergeCatalogMaps({ byIdName, byIdType }){
    ID_TO_NAME = byIdName || new Map();
    ID_TO_TYPE = byIdType || new Map();
    NAME_TO_ID = new Map([...ID_TO_NAME].map(([id, nm]) => [normalize(nm), id]));
  }

  async function ensureItemsCatalog(){
    const cached = await getSetting(ITEMS_CACHE_KEY, "");
    if (cached) {
      try {
        const { ts, items } = JSON.parse(cached);
        if (ts && (Date.now() - ts) < ITEMS_TTL_MS && items) {
          mergeCatalogMaps(parseItemsPayload(items));
          return;
        }
      } catch {}
    }

    const key = await getSetting("apiKey", "");
    if (!key) return;

    const u = new URL(ITEMS_URL);
    u.searchParams.set("comment", API_COMMENT);
    u.searchParams.set("key", key.trim());

    const data = await httpGetJSON(u.toString());
    await setSetting(ITEMS_CACHE_KEY, JSON.stringify({ ts: Date.now(), items: data }));
    mergeCatalogMaps(parseItemsPayload(data));
  }

  //////////////////////////////////////////////////////////////////////////////
  // DATA BUILDERS
  //////////////////////////////////////////////////////////////////////////////
  function buildMissingEntriesFromAPI(json){
    const entries = [];
    const missingIds = new Set();

    const crimes = Array.isArray(json?.crimes) ? json.crimes : (json?.crimes ? Object.values(json.crimes) : []);
    for (const crime of crimes){
      const readyAt = crime?.ready_at ?? null;
      const slots = Array.isArray(crime?.slots) ? crime.slots : [];
      for (const slot of slots){
        const user = slot?.user;
        const req  = slot?.item_requirement;
        if (user && req && req.is_available === false){
          const uname = user.name || "";
          const uid   = user.id ?? user.user_id ?? "";
          const itemId = req.id ?? req.item_id ?? null;

          const nameFromAPI   = req.name || null;
          const nameFromItems = (itemId != null) ? ID_TO_NAME.get(Number(itemId)) : null;
          const friendly      = nameFromAPI || nameFromItems;

          if (!friendly){
            if (itemId != null) missingIds.add(Number(itemId));
            continue;
          }

          entries.push({
            playerName: uname,
            playerId: String(uid),
            itemName: friendly,
            itemId: Number(itemId) || null,
            isReusable: !!req.is_reusable,
            readyAt: (typeof readyAt === "number" ? readyAt : null),
            line: `${uname} [${uid}] (${friendly})`
          });
        }
      }
    }
    return { entries, missingIds: Array.from(missingIds) };
  }

  function scrapeSendConfirmation(){
    const wrap = document.querySelector('.action-wrap.send-act.msg-active');
    if(!wrap) return null;
    const p = wrap.querySelector('p');
    if(!p) return null;

    const itemEl     = p.querySelector('b');
    const userEl     = p.querySelector('a[href*="/profiles.php"]');
    const itemName   = itemEl?.textContent?.trim();
    const playerName = userEl?.textContent?.trim();

    const txt = p.textContent || "";
    const m = txt.match(/with the message:\s*(.+)$/i);
    const message = m ? m[1].trim() : "";

    if(!itemName || !playerName) return null;
    return { itemName, playerName, message };
  }

  function scrapePendingConfirm(){
    const form = document.querySelector('.action-wrap.send-act.msg-active form[data-confirm="1"]');
    if(!form) return null;

    const p = form.querySelector('p');
    const bTags = p ? p.querySelectorAll('b') : [];
    const itemName   = bTags[0]?.textContent?.trim() || "";
    const playerName = bTags[1]?.textContent?.trim() || "";

    let message = "";
    const tagInput = form.querySelector('input[name="tag"]');
    if(tagInput) {
      message = String(tagInput.value || "").trim();
    } else {
      const txt = p?.textContent || "";
      const m = txt.match(/message:\s*(.+?)\?$/i);
      message = m ? m[1].trim() : "";
    }

    if(!itemName || !playerName) return null;
    return { itemName, playerName, message };
  }

  //////////////////////////////////////////////////////////////////////////////
  // ACTIONS
  //////////////////////////////////////////////////////////////////////////////
  function findItemLiByName(itemName){
    const target = normalize(itemName);
    if(!target) return null;
    const rows = document.querySelectorAll('li[data-item][data-category]');
    for(const row of rows){
      const n = normalize((row.querySelector('.title-wrap .name-wrap .name') || row.querySelector('.name'))?.textContent);
      if(n === target) return row;
    }
    return null;
  }
  function clickMessageToggle(){
    const a = document.querySelector("a.action-message.left");
    return a ? (clickEl(a), true) : false;
  }
  function findVisibleSendButtonByName(itemName){
    const sel = `button.option-send[aria-label="Send ${CSS.escape(itemName)}"]`;
    const list = document.querySelectorAll(sel);
    for(const n of list){ if(isVisible(n)) return n; }
    return null;
  }
  async function actionSendItemByName(itemName){
    await delay(120);

    const row = findItemLiByName(itemName);
    if (!row) return false;

    const cont = row.querySelector('.cont-wrap[role="tabpanel"]');
    const header = row.querySelector('.title-wrap.ui-accordion-header');
    const isAccordion = !!header || document.querySelector('#items-search-tab.ui-accordion');

    if (isAccordion && cont && (cont.style.display === 'none' || cont.getAttribute('aria-expanded') === 'false')) {
      header?.click();
      await waitFor(() => cont.getAttribute('aria-expanded') === 'true', 15, 50);
    }

    let btn = await waitFor(() => findVisibleSendButtonByName(itemName), 30, 80);
    if(btn){ clickEl(btn); return true; }

    const thumb = row.querySelector('.thumbnail-wrap, .thumbnail') || row;
    ["mouseenter","mouseover","mousemove"].forEach(t => thumb.dispatchEvent(new MouseEvent(t,{bubbles:true})));
    await delay(80);
    btn = findVisibleSendButtonByName(itemName);
    if(btn){ clickEl(btn); return true; }
  }

  function attachMultiActionButton(li, actions, lineText){
    let state = 0;
    const btn = document.createElement("button");
    btn.type = "button";
    btn.className = "tf-rowbtn";
    const setBtnUI = () => {
      const next = actions[state];
      btn.title = next?.title || "Next action";
      btn.innerHTML = next?.label ?? "•";
    };
    setBtnUI();

    const textNode = document.createTextNode(lineText);
    btn.addEventListener("click", async () => {
      const act = actions[state];
      if(act?.run){ try { await act.run(btn, state, lineText); } catch(e){ console.error("Row action error:", e); } }
      state = (state + 1) % actions.length;
      setBtnUI();
    });
    li.append(btn, textNode);
  }

  function addLogEntry({ itemName, playerName, message }){
    const wrap = document.getElementById(`${SECTION_ID}-logwrap`);
    const ul   = document.getElementById(`${SECTION_ID}-loglist`);
    if(!wrap || !ul) return;
    const li = document.createElement("li");
    li.textContent = `${getServerTimeFormatted()} You sent a ${itemName} to ${playerName} with the message: ${message}`;
    ul.appendChild(li);
    wrap.style.display = "";
  }

  async function copyLogsToClipboard(){
    const ul = document.getElementById(`${SECTION_ID}-loglist`);
    if(!ul) return false;
    const text = Array.from(ul.querySelectorAll("li"))
    .map(li => li.textContent.trim())
    .filter(Boolean)
    .join("\n");
    if(!text) return false;
    try { await navigator.clipboard.writeText(text); return true; }
    catch {
      const ta = document.createElement("textarea");
      ta.value = text; ta.style.position="fixed"; ta.style.opacity="0";
      document.body.appendChild(ta); ta.select();
      const ok = document.execCommand("copy"); document.body.removeChild(ta);
      return ok;
    }
  }

  //////////////////////////////////////////////////////////////////////////////
  // PANELS
  //////////////////////////////////////////////////////////////////////////////
  function makePanelCard(){
    const card = document.createElement("div");
    card.className = "card";
    card.innerHTML = `
      <h4>
        <span>Missing Items</span>
        <span class="spinner" aria-live="polite">loading…</span>
      </h4>
      <div class="content muted">Fetching…</div>
    `;
    return card;
  }

  function makeThanksCard(){
    const card = document.createElement("div");
    card.className = "card card--thanks";
    card.innerHTML = `
      <h4><span>Thanks</span></h4>
      <div class="content">
        <p>Free by <strong>Canixe</strong> · <a href="https://www.torn.com/bazaar.php?userId=3753120#/" target="_blank" rel="noopener">bazaar</a></p>
      </div>
    `;
    return card;
  }

  async function loadCardFromAPI(card){
    const spinner = card.querySelector(".spinner");
    const content = card.querySelector(".content");
    try{
      spinner.textContent = "loading…";
      const statusSel = parseMulti(await getSetting(FILTER_STATUS_KEY, DEFAULT_STATUS), ALLOWED_STATUS);
      const data = await fetchCrimesFromTorn(Array.from(statusSel));

      const { entries, missingIds } = buildMissingEntriesFromAPI(data);

      const typeSel = parseMulti(await getSetting(FILTER_TYPE_KEY, DEFAULT_TYPES), ALLOWED_TYPES);
      const allowReus = typeSel.has("Reusables");
      const allowCons = typeSel.has("Consumables");
      let filtered = entries.filter(e => (e.isReusable && allowReus) || (!e.isReusable && allowCons));
      filtered.sort((a, b) => {
        const A = (typeof a.readyAt === "number") ? a.readyAt : Infinity;
        const B = (typeof b.readyAt === "number") ? b.readyAt : Infinity;
        return A - B;
      });

      const serverNowSec = getServerEpochSec();

      const fmtDue = (readyAt) => {
        if (typeof readyAt !== "number" || !isFinite(readyAt)) return "";
        let dsec = readyAt - serverNowSec;
        const past = dsec < 0; dsec = Math.abs(dsec);
        const d = Math.floor(dsec / 86400); dsec %= 86400;
        const h = Math.floor(dsec / 3600);  dsec %= 3600;
        const m = Math.floor(dsec / 60);
        let txt = d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : `${m}m`;
        return past ? `${txt} ago` : `in ${txt}`;
      };

      let warningHtml = "";
      if (missingIds.length){
        warningHtml = `<div class="error" style="margin-bottom:6px;">
            Unknown item IDs (not found in Items catalog): ${missingIds.join(", ")}.
            Try Refresh (the Items list is cached ~24h).
          </div>`;
      }

      if(!filtered.length){
        content.innerHTML = `${warningHtml}<div class="muted">No missing item requirements found.</div>`;
      }else{
        const ul = document.createElement("ul");
        const frag = document.createDocumentFragment();

        filtered.forEach(info => {
          const li = document.createElement("li");

          attachMultiActionButton(li, [
            { label: ICONS.search, title: "Search item", run: async (btn) => {
              const s = await waitFor(() => document.getElementById("items_search"), 40, 120);
              setInputValue(s, info.itemName);
              if (s) s.focus();

              await waitFor(() => document.querySelector('#items-search-tab'), 60, 150);

              const status = await waitFor(() => {
                const tab = document.querySelector('#items-search-tab');
                if (!tab) return null;

                const qAttr = tab.getAttribute('data-query') || "";
                if (qAttr && normalize(qAttr) !== normalize(info.itemName)) {
                  return null;
                }

                if (tab.querySelector('li[data-item]')) return { kind: 'found' };
                const msg = tab.querySelector('.item-cont .info-msg, .info-msg');
                if (msg && /no items matching/i.test(msg.textContent)) {
                  return { kind: 'empty', el: msg };
                }
                return null;
              }, 80, 150);

              if (status && status.kind === 'empty') {
                btn.disabled = true;
                btn.classList.add('tf-done');
                btn.title = "Item not in your inventory";

                const li = btn.closest('li');
                if (li && !li.querySelector('.tf-buy')) {
                  const url = buildMarketUrlByName(info.itemName);
                  if (url) {
                    const a = document.createElement('a');
                    a.className = 'tf-buy';
                    a.href = url;
                    a.target = '_blank';
                    a.rel = 'noopener';
                    a.textContent = 'Buy on Market';
                    li.appendChild(a);
                  }
                }
              }
            }},
            { label: ICONS.send, title: "Open Send", run: async () => {
              const ok = await actionSendItemByName(info.itemName);
              if(!ok) console.warn("Send button not found/visible for:", info.itemName);
            }},
            { label: ICONS.user, title: "Set recipient", run: async () => {
              const u = await waitFor(() => document.querySelector('input.ac-search[name="userID"]'), 40, 100);
              setInputValue(u, info.playerName || info.playerId || ""); if(u) u.focus();
            }},
            { label: ICONS.comment, title: "Toggle message", run: async () => {
              let ok = clickMessageToggle(); if(!ok){ await delay(120); clickMessageToggle(); }
            }},
            { label: ICONS.pen, title: "Write message", run: async () => {
              const m = await waitFor(() => document.querySelector('input.message[name="tag"]'), 40, 100);
              setInputValue(m, getMessageByType(info.isReusable)); if(m) m.focus();
            }},
            { label: ICONS.arrowRight, title: "Submit", run: async () => {
              const send = await waitFor(() => document.querySelector('input.torn-btn[type="submit"][value="SEND"]'), 50, 100);
              if(!send) return console.warn("SEND submit not found");
              clickEl(send);
            }},
            { label: ICONS.check, title: "Confirm & log", run: async (btn) => {
              const form = await waitFor(
                () => document.querySelector('.action-wrap.send-act.msg-active form[data-confirm="1"]'),
                50, 120
              );
              const pending = form ? scrapePendingConfirm() : null;
              const yes = form
              ? form.querySelector('a.next-act.t-blue')
              : await waitFor(() => {
                const a = document.querySelector("a.next-act.t-blue");
                return a && /yes/i.test(a.textContent) ? a : null;
              }, 50, 120);

              if (yes) clickEl(yes);
              else console.warn("Yes confirm not found");

              if (pending && pending.itemName && pending.playerName) {
                addLogEntry(pending);
              } else {
                const conf = await waitFor(() => scrapeSendConfirmation(), 40, 120);
                if (conf) addLogEntry(conf);
                else addLogEntry({ itemName: info.itemName, playerName: info.playerName, message: getMessageByType(info.isReusable) });
              }

              btn.innerHTML = ICONS.check;
              btn.classList.add("tf-done"); btn.disabled = true;
            }},
          ], info.line);

          // append a small "due …" note if we have a deadline
          if (typeof info.readyAt === "number" && isFinite(info.readyAt)) {
            const due = document.createElement("span");
            due.className = "tf-deadline";
            due.textContent = `· due ${fmtDue(info.readyAt)}`;
            {
              const pad = n => String(n).padStart(2, "0");
              const dt  = new Date(info.readyAt * 1000);
              const tct = `${pad(dt.getUTCHours())}:${pad(dt.getUTCMinutes())}:${pad(dt.getUTCSeconds())} - ${pad(dt.getUTCDate())}/${pad(dt.getUTCMonth()+1)}/${String(dt.getUTCFullYear()).slice(2)} TCT`;
              const parts = Intl.DateTimeFormat(undefined, { timeZoneName: "short" }).formatToParts(dt);
              const shortName = parts.find(p => p.type === "timeZoneName")?.value;

              const offsetMin = -dt.getTimezoneOffset(); // minutes east of UTC
              const sign = offsetMin >= 0 ? "+" : "-";
              const hhOff = pad(Math.floor(Math.abs(offsetMin) / 60));
              const mmOff = pad(Math.abs(offsetMin) % 60);
              const tzLabel = shortName || `UTC${sign}${hhOff}:${mmOff}`;
              const loc = `${pad(dt.getHours())}:${pad(dt.getMinutes())}:${pad(dt.getSeconds())} - ${pad(dt.getDate())}/${pad(dt.getMonth()+1)}/${String(dt.getFullYear()).slice(2)} ${tzLabel}`;

              due.title = `Deadline:\u00A0${tct}<br/>Local:\u00A0${loc}`;
            }

            li.appendChild(due);
          }

          frag.appendChild(li);
        });

        content.innerHTML = warningHtml;
        ul.appendChild(frag);
        content.appendChild(ul);
      }
    }catch(err){
      content.innerHTML = `<div class="error">Failed to load: ${err?.message || err}</div>
                           <div class="hint">Tip: set your Torn API key (Minimal Access) via the button above.</div>`;
    }finally{
      spinner.textContent = "";
      content.classList.remove("muted");
    }
  }

  //////////////////////////////////////////////////////////////////////////////
  // BOOT
  //////////////////////////////////////////////////////////////////////////////
  async function refreshAll(){
    await showSettingsHint();
    const grid = document.getElementById(`${SECTION_ID}-grid`);
    const ph   = document.getElementById(`${SECTION_ID}-placeholder`);
    if(ph) ph.style.display = "none";
    grid.style.display = "";
    grid.innerHTML = "";

    const mainCard = makePanelCard();
    grid.appendChild(mainCard);

    if (SHOW_THANKS) {
      grid.classList.remove('no-thanks');
      grid.appendChild(makeThanksCard());
    } else {
      grid.classList.add('no-thanks');
    }

    await ensureItemsCatalog();
    await loadCardFromAPI(mainCard);
  }

  function wireUp(){
    const refreshBtn = document.getElementById(`${SECTION_ID}-refresh`);
    if(refreshBtn && !refreshBtn.dataset.bound){
      refreshBtn.dataset.bound = "1";
      refreshBtn.addEventListener("click", async e => {
        e.preventDefault();
        if (refreshBtn.dataset.loading === "1") return;
        refreshBtn.dataset.loading = "1";

        const toggleBtn = document.getElementById(`${SECTION_ID}-toggle`);
        if(toggleBtn?.getAttribute("aria-expanded")==="false") toggleBtn.click();

        try { await refreshAll(); }
        finally { delete refreshBtn.dataset.loading; }
      });
    }
    const copyBtn = document.getElementById(`${SECTION_ID}-copylog`);
    if(copyBtn && !copyBtn.dataset.bound){
      copyBtn.dataset.bound = "1";
      copyBtn.addEventListener("click", async (e) => {
        e.preventDefault();
        e.stopPropagation();
        const ok = await copyLogsToClipboard();
        const prev = copyBtn.textContent;
        copyBtn.textContent = ok ? "Copied!" : "No logs";
        setTimeout(() => copyBtn.textContent = prev, 1200);
      });
    }
  }

  function observe(){
    const obs = new MutationObserver(() => {
      if(!location.pathname.includes("/item.php")) return;
      const el = document.getElementById(SECTION_ID);
      if(!el || !el.isConnected){ insertSection(); wireUp(); }
    });
    obs.observe(document.documentElement, { childList:true, subtree:true });
  }

  // init
  injectStyle();
  insertSection();
  wireUp();
  observe();
  registerMenus();
  showSettingsHint();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址