Assistant Director

Director's helper for Torn companies (ES5 hard-compat): popularity, environment, effectiveness, profit tips, per-role breakdown, retail pricing advisor. No async/await. Uses GM_xmlhttpRequest. Observer ignores panel & pauses while typing.

// ==UserScript==
// @name         Assistant Director
// @namespace    https://greasyfork.org/users/your-username
// @version      0.3.6
// @description  Director's helper for Torn companies (ES5 hard-compat): popularity, environment, effectiveness, profit tips, per-role breakdown, retail pricing advisor. No async/await. Uses GM_xmlhttpRequest. Observer ignores panel & pauses while typing.
// @author       YourName
// @match        https://www.torn.com/*
// @license      MIT
// @grant        GM_xmlhttpRequest
// @connect      api.torn.com
// ==/UserScript==

(function () {
  'use strict';

  var CFG = {
    debug: false,
    panelId: 'assistant-director-panel',
    ls: {
      itemCosts: 'assistantDirector.itemCosts',
      collapse: 'assistantDirector.panelCollapsed',
      apiKey: 'assistantDirector.apiKey',
      lowEffThreshold: 'assistantDirector.lowEffThreshold',
      warnEffThreshold: 'assistantDirector.warnEffThreshold'
    },
    retail: {
      targetSellThroughDaily: 0.15,
      elasticityStepPctPerStep: 7,
      minFloorMarginPct: 5,
      maxNudgePct: 20,
      currencySymbol: '$'
    },
    staff: {
      lowEffDefault: 60,
      warnEffDefault: 75,
      inactivePhrases: ['inactive', 'days', 'weeks', 'month']
    },
    apiThrottleMs: 10000
  };

  // ---------- Utilities ----------
  function coalesce(a, b) { return (a !== undefined && a !== null) ? a : b; }
  function log(){ if (CFG.debug){ var a=[].slice.call(arguments); a.unshift('[AD v0.3.6]'); console.log.apply(console,a);} }
  function sleep(ms, cb){ return setTimeout(cb, ms); }
  function clamp(v, lo, hi){ return Math.min(hi, Math.max(lo, v)); }
  function qs(sel, root){ return (root||document).querySelector(sel); }
  function qsa(sel, root){ return [].slice.call((root||document).querySelectorAll(sel)); }
  function getText(el){ return el ? el.textContent.trim() : ''; }

  function normalizeSpaces(str){
    return (str+'').replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g,' ')
                   .replace(/\s+/g,' ').trim();
  }
  function parseNumber(str){
    if (str==null) return null;
    var s = normalizeSpaces(str);
    var neg = /\(.*\)/.test(s);
    var n = parseFloat(s.replace(/[,$£€%]/g,'').replace(/[^\d.\-]/g,''));
    if (isNaN(n)) return null;
    return neg ? -n : n;
  }
  function percent(v,d){ return v==null ? '—' : (v.toFixed(d||0)+'%'); }
  function money(v,s){ return v==null ? '—' : ((s||CFG.retail.currencySymbol)+new Intl.NumberFormat().format(v)); }

  var store = {
    get: function(key,fallback){
      try{ var v = localStorage.getItem(key); return v==null ? (fallback===undefined?null:fallback) : JSON.parse(v); }
      catch(e){ return (fallback===undefined?null:fallback); }
    },
    set: function(key,val){ localStorage.setItem(key, JSON.stringify(val)); },
    del: function(key){ localStorage.removeItem(key); }
  };

  function detectCurrencySymbol(){
    var m = (document.body.innerText.match(/([£$€])\s?\d[\d,]*\.?\d*/) || [])[1];
    return m || CFG.retail.currencySymbol;
  }
  CFG.retail.currencySymbol = detectCurrencySymbol();

  function isTypingInPanel(){
    var panel = document.getElementById(CFG.panelId);
    var ae = document.activeElement;
    return !!(panel && ae && panel.contains(ae) && (/^(INPUT|TEXTAREA)$/).test(ae.tagName));
  }

  // ---------- HTTP ----------
  function httpGetJson(url){
    return new Promise(function(resolve,reject){
      try{
        GM_xmlhttpRequest({
          method:'GET', url:url, headers:{'Accept':'application/json'},
          onload:function(res){
            try{
              if (res.status<200 || res.status>=300) return reject(new Error('HTTP '+res.status));
              resolve(JSON.parse(res.responseText));
            }catch(e){ reject(e); }
          },
          onerror:function(){ reject(new Error('Network error')); },
          ontimeout:function(){ reject(new Error('Request timeout')); }
        });
      }catch(e){ reject(e); }
    });
  }

  // ---------- Torn API ----------
  function tornApiFetch(path,key){
    var url = 'https://api.torn.com/' + path + '&key=' + encodeURIComponent(key);
    return httpGetJson(url).then(function(data){
      if (data && data.error) throw new Error(data.error.error || 'API error');
      return data;
    });
  }

  function safeRevenue(fin){
    return (fin.revenue_daily !== undefined ? fin.revenue_daily :
           (fin.revenueDay !== undefined ? fin.revenueDay : null));
  }
  function safeProfit(fin){
    return (fin.profit_daily !== undefined ? fin.profit_daily :
           (fin.profitDay !== undefined ? fin.profitDay : null));
  }
  function computeMargin(fin){
    var rev = safeRevenue(fin);
    var prof = safeProfit(fin);
    if (rev==null || rev===0 || prof==null) return null;
    return (prof / rev) * 100;
  }

  function getCompanyViaApi(apiKey){
    return tornApiFetch('company/?selections=profile,employees,financials,upgrades,stock,newsales,settings', apiKey)
      .then(function(data){
        var c = data.company || data;
        var fin = c.financials || {};

        var summary = {
          popularity: (c.popularity !== undefined ? c.popularity : (c.company_popularity !== undefined ? c.company_popularity : null)),
          environment: (c.environment !== undefined ? c.environment : (c.company_environment !== undefined ? c.company_environment : null)),
          effectiveness: (c.effectiveness !== undefined ? c.effectiveness :
                          (c.employees && typeof c.employees.effectiveness === 'number' ? c.employees.effectiveness : null)),
          income: safeRevenue(fin),
          expenses: (fin.expenses_daily !== undefined ? fin.expenses_daily : (fin.expensesDay !== undefined ? fin.expensesDay : null)),
          profit: safeProfit(fin),
          margin: computeMargin(fin),
          vacancies: (c.positions_open !== undefined ? c.positions_open : (c.vacancies !== undefined ? c.vacancies : null))
        };

        var employees = [];
        if (Array.isArray(c.employees)){
          for (var i=0;i<c.employees.length;i++){
            var e1=c.employees[i];
            employees.push({
              name: (e1.name!==undefined?e1.name:(e1.playername!==undefined?e1.playername:'Unknown')),
              role: (e1.position!==undefined?e1.position:(e1.job!==undefined?e1.job:'Unknown')),
              effectiveness: (typeof e1.effectiveness==='number'?e1.effectiveness:(typeof e1.efficiency==='number'?e1.efficiency:null)),
              lastAction: (e1.last_action || e1.lastAction || '')
            });
          }
        } else if (c.employees && typeof c.employees==='object'){
          var keys = Object.keys(c.employees);
          for (var j=0;j<keys.length;j++){
            var e=c.employees[keys[j]];
            employees.push({
              name: (e.name!==undefined?e.name:(e.playername!==undefined?e.playername:'Unknown')),
              role: (e.position!==undefined?e.position:(e.job!==undefined?e.job:'Unknown')),
              effectiveness: (typeof e.effectiveness==='number'?e.effectiveness:(typeof e.efficiency==='number'?e.efficiency:null)),
              lastAction: (e.last_action || e.lastAction || '')
            });
          }
        }

        var stockRows = [];
        if (c.stock && typeof c.stock==='object'){
          var sKeys = Object.keys(c.stock);
          for (var k=0;k<sKeys.length;k++){
            var it=c.stock[sKeys[k]];
            stockRows.push({
              name: (it.name!==undefined?it.name:'Item'),
              price: (it.price!==undefined?it.price:null),
              stock: (it.in_stock!==undefined?it.in_stock:(it.stock!==undefined?it.stock:null)),
              soldToday: (it.sold_today!==undefined?it.sold_today:null),
              sold7d: (it.sold_week!==undefined?it.sold_week:(it.sold7d!==undefined?it.sold7d:null))
            });
          }
        }

        return { summary: summary, employees: employees, stockRows: stockRows };
      });
  }

  var lastApiTs = 0;
  function maybeGetApiData(apiKey){
    var now = Date.now();
    var needCompany = onCompanyPage() || !!isStaffTableVisible() || scrapeInventoryDOM().length>0;
    if (!apiKey || !needCompany) return Promise.resolve(null);
    if (now - lastApiTs < CFG.apiThrottleMs) return Promise.resolve(null);
    lastApiTs = now;
    return getCompanyViaApi(apiKey).catch(function(e){ console.warn('API error:', e.message); return null; });
  }

  // ---------- Panel & Styles ----------
  function injectStyles(){
    if (qs('#ad-shared-styles')) return;
    var css=document.createElement('style');
    css.id='ad-shared-styles';
    css.textContent=[
      '#'+CFG.panelId+'{',
      '  position: fixed; right: 16px; bottom: 40px; z-index: 9999;',
      '  width: 320px; max-height: 70vh; overflow: auto;',
      '  background: #0c0c0f; color: #e9e9ef; border: 1px solid #29292c; border-radius: 12px;',
      '  box-shadow: 0 8px 24px rgba(0,0,0,0.35); font-family: Arial, Helvetica, sans-serif;',
      '}',
      '#'+CFG.panelId+':focus{ outline:none; }',
      '#'+CFG.panelId+'.collapsed .ad-body, #'+CFG.panelId+'.collapsed .ad-footer{ display:none; }',
      '#'+CFG.panelId+'.collapsed{ height:auto; max-height:unset; }',
      '#'+CFG.panelId+' .ad-header{',
      '  display:flex; align-items:center; justify-content:space-between; gap:8px;',
      '  padding:10px 12px; border-bottom:1px solid #222; position:sticky; top:0; background:#0c0c0f;',
      '}',
      '#'+CFG.panelId+' .ad-title{ font-size:14px; font-weight:700; }',
      '#'+CFG.panelId+' .ad-controls button{',
      '  background:#18181b; color:#e9e9ef; border:1px solid #333; border-radius:8px; padding:4px 8px; cursor:pointer; font-size:12px;',
      '}',
      '#'+CFG.panelId+' .ad-controls button:hover{ background:#222; }',
      '#'+CFG.panelId+' .ad-body{ padding:10px 12px; }',
      '#'+CFG.panelId+' .ad-section{ border:1px solid #222; border-radius:10px; padding:8px; margin-bottom:10px; background:#101014; }',
      '#'+CFG.panelId+' .ad-section h3{ margin:0 0 6px; font-size:13px; font-weight:700; }',
      '#'+CFG.panelId+' .ad-grid{ display:grid; grid-template-columns:1fr 1fr; gap:6px; }',
      '#'+CFG.panelId+' .ad-kv{ display:flex; justify-content:space-between; background:#121217; padding:6px; border-radius:6px; }',
      '#'+CFG.panelId+' .muted{ color:#a7a7b2; }',
      '#'+CFG.panelId+' .ok{ color:#7bd88f; } .warn{ color:#f2c14e; } .bad{ color:#ef6a6a; }',
      '#'+CFG.panelId+' .ad-chip{ display:inline-block; padding:1px 6px; border-radius:999px; background:#1d1d22; border:1px solid #333; font-size:10px; margin-left:6px; color:#9aa0a6; }',
      '#'+CFG.panelId+' .retail .item{ border-top:1px dashed #2a2a2f; padding-top:8px; margin-top:8px; }',
      '#'+CFG.panelId+' .retail .item h4{ margin:0 0 4px; font-size:12px; }',
      '#'+CFG.panelId+' .retail .row{ display:flex; justify-content:space-between; gap:8px; font-size:12px; margin:2px 0; }',
      '#'+CFG.panelId+' .retail input.cost{ width:110px; background:#0e0e12; color:#e9e9ef; border:1px solid #333; border-radius:6px; padding:2px 6px; font-size:12px; }',
      '#'+CFG.panelId+' .ad-footer{ padding:8px 12px; border-top:1px solid #222; font-size:11px; color:#a7a7b2; }',
      '#'+CFG.panelId+' .row{ display:flex; justify-content:space-between; gap:8px; font-size:12px; margin:2px 0; }',
      '#'+CFG.panelId+' input[type="password"], #'+CFG.panelId+' input[type="number"]{ background:#0e0e12; color:#e9e9ef; border:1px solid #333; border-radius:6px; padding:4px 6px; font-size:12px; }'
    ].join('\n');
    document.head.appendChild(css);
  }

  function ensurePanel(){
    injectStyles();
    var panel = qs('#'+CFG.panelId);
    if (!panel){
      panel = document.createElement('div');
      panel.id = CFG.panelId;
      panel.setAttribute('tabindex','0');
      panel.innerHTML=[
        '<div class="ad-header">',
        '  <div class="ad-title">Assistant Director <span class="ad-chip">v0.3.6</span></div>',
        '  <div class="ad-controls">',
        '    <button data-ad="refresh">Refresh</button>',
        '    <button data-ad="collapse">Collapse</button>',
        '  </div>',
        '</div>',
        '<div class="ad-body"></div>',
        '<div class="ad-footer">Read-only. Tips are heuristics; API key stored locally if you add it.</div>'
      ].join('\n');
      document.body.appendChild(panel);

      var collapsed = !!store.get(CFG.ls.collapse,false);
      if (collapsed) panel.classList.add('collapsed');
      updateCollapseButtonLabel(panel);

      panel.querySelector('[data-ad="collapse"]').addEventListener('click', function(){
        panel.classList.toggle('collapsed');
        store.set(CFG.ls.collapse, panel.classList.contains('collapsed'));
        updateCollapseButtonLabel(panel);
      });
      panel.querySelector('[data-ad="refresh"]').addEventListener('click', function(){ runAll(); });

      panel.addEventListener('input', function(e){
        if (e && e.target && e.target.id==='ad-api-key'){
          sessionStorage.setItem('ad.apiKey.draft', e.target.value);
        }
      });
    }
    return panel;
  }

  function updateCollapseButtonLabel(panel){
    var btn = qs('[data-ad="collapse"]', panel);
    if (!btn) return;
    btn.textContent = panel.classList.contains('collapsed') ? 'Expand' : 'Collapse';
  }

  function setPanelBody(html){
    var panel = ensurePanel();
    qs('.ad-body', panel).innerHTML = html;
  }

  function renderApiControls(){
    var draft = sessionStorage.getItem('ad.apiKey.draft');
    var saved = localStorage.getItem(CFG.ls.apiKey) || '';
    var apiKey = (draft !== null ? draft : saved);
    var lowDefault = store.get(CFG.ls.lowEffThreshold, CFG.staff.lowEffDefault);
    var warnDefault = store.get(CFG.ls.warnEffThreshold, CFG.staff.warnEffDefault);

    return [
      '<div class="ad-section">',
      '  <h3>Settings</h3>',
      '  <div class="row"><span class="muted">Torn API key (optional)</span><span><input id="ad-api-key" type="password" placeholder="Enter key" value="'+apiKey+'"></span></div>',
      '  <div class="row"><span class="muted">Low effectiveness threshold</span><span><input id="ad-low-thr" type="number" min="0" max="100" value="'+lowDefault+'">%</span></div>',
      '  <div class="row"><span class="muted">Warn effectiveness threshold</span><span><input id="ad-warn-thr" type="number" min="0" max="100" value="'+warnDefault+'">%</span></div>',
      '  <div class="row"><span></span><span><button id="ad-save-settings">Save</button></span></div>',
      '</div>'
    ].join('\n');
  }

  function wireSettings(){
    var panel = ensurePanel();
    var saveBtn = qs('#ad-save-settings', panel);
    if (!saveBtn) return;
    saveBtn.addEventListener('click', function(){
      var keyEl = qs('#ad-api-key', panel);
      var key = keyEl ? (keyEl.value||'').trim() : '';
      if (key) localStorage.setItem(CFG.ls.apiKey, key); else localStorage.removeItem(CFG.ls.apiKey);
      sessionStorage.removeItem('ad.apiKey.draft');

      var lowEl = qs('#ad-low-thr', panel);
      var warnEl = qs('#ad-warn-thr', panel);
      var low = parseNumber(lowEl ? lowEl.value : null);
      var warn = parseNumber(warnEl ? warnEl.value : null);
      if (low!=null) store.set(CFG.ls.lowEffThreshold, low);
      if (warn!=null) store.set(CFG.ls.warnEffThreshold, warn);
      runAll();
    });
  }

  // ---------- Page detectors ----------
  function onCompanyPage(){
    var url = location.pathname + location.search;
    return (/\/company\.php|\/companies\.php/i).test(url);
  }
  function isStaffTableVisible(){
    var tables = qsa('table, .table, .employees, .staff-list, .company-employees');
    for (var i=0;i<tables.length;i++){
      var tbl=tables[i], tr=qs('tr', tbl);
      var hdr = getText(tr||tbl).toLowerCase();
      if (/(\bemployee\b|name)/.test(hdr) && /(role|position)/.test(hdr) && /(effective|efficiency)/.test(hdr)) return tbl;
    }
    return null;
  }

  // ---------- DOM scraping (fallback) ----------
  function findByLabelNearby(label){
    var nodes = qsa('*').filter(function(el){
      var t = getText(el).toLowerCase();
      return t && t.indexOf(label)!==-1;
    });
    for (var i=0;i<nodes.length;i++){
      var el = nodes[i];
      var numHere = parseNumber(getText(el));
      if (numHere!=null) return numHere;
      var near = (el.closest && el.closest('tr,li,div')) || el.parentElement;
      if (near){
        var n1 = parseNumber(getText(qs('.value, .stat, .right, .bold, .number, ._value, ._stat, .t-green, .t-red', near)));
        if (n1!=null) return n1;
        var txt = getText(near);
        var p = /(-?\d[\d,]*\.?\d*)\s*%/.exec(txt);
        if (p) return parseNumber(p[1]);
        var m = /[$£€]\s*(-?\d[\d,]*\.?\d*)/.exec(txt);
        if (m) return parseNumber(m[1]);
      }
    }
    return null;
  }

  function scrapeCompanySummaryDOM(){
    var popularity = findByLabelNearby('popularity');
    var environment = findByLabelNearby('environment');
    var effectiveness = findByLabelNearby('effectiveness');
    var income = coalesce(findByLabelNearby('income'), findByLabelNearby('revenue'));
    var expenses = coalesce(findByLabelNearby('expenses'), findByLabelNearby('wages'));
    var profit = null, margin = null;
    if (income!=null && expenses!=null){
      profit = income - expenses;
      if (income>0) margin = (profit/income)*100;
    } else {
      var profitLbl = coalesce(findByLabelNearby('profit'), findByLabelNearby('net'));
      if (profitLbl!=null) profit = profitLbl;
    }
    var vacancies = coalesce(findByLabelNearby('vacancies'), null);
    return { popularity:popularity, environment:environment, effectiveness:effectiveness, income:income, expenses:expenses, profit:profit, margin:margin, vacancies:vacancies };
  }

  function arrayFindIndex(arr, predicate){
    for (var i=0;i<arr.length;i++){ if (predicate(arr[i], i, arr)) return i; }
    return -1;
  }
  function arrayFind(arr, predicate){
    for (var i=0;i<arr.length;i++){ if (predicate(arr[i], i, arr)) return arr[i]; }
    return undefined;
  }

  function scrapeEmployeesDOM(){
    var tbl = isStaffTableVisible();
    if (!tbl) return [];
    var rows = qsa('tr', tbl).slice(1);
    var out = [];
    rows.forEach(function(tr){
      var tds = qsa('td', tr);
      if (!tds.length) return;
      var name = getText(tds[0]) || 'Unknown';
      var role = '';
      var eff = null;
      var lastAction = '';
      for (var i=0;i<tds.length;i++){
        var td = tds[i];
        var t = getText(td).toLowerCase();
        if (!role && /(role|position)/.test(t)) role = getText(td);
        if (eff==null && /%/.test(t)){
          var p = /(-?\d[\d,]*\.?\d*)\s*%/.exec(getText(td));
          if (p) eff = parseNumber(p[1]);
        }
        if (!lastAction && /last action/i.test(getText(td))) lastAction = getText(td);
      }
      role = role || (getText(tds[1]) || '').trim();
      if (eff==null){
        var pctCell = arrayFind(tds, function(td){ return /%/.test(getText(td)); });
        eff = parseNumber(getText(pctCell || ''));
      }
      out.push({ name:name, role: role||'Unknown', effectiveness: eff, lastAction: lastAction });
    });
    return out.filter(function(e){ return e.role; });
  }

  function scrapeInventoryDOM(){
    var tables = qsa('table, .table, .inventory, .stock-list, .company-products, .items-list');
    var rows = [];
    for (var t=0;t<tables.length;t++){
      var tbl = tables[t];
      var trs = qsa('tr', tbl);
      if (trs.length<2) continue;
      var hdr = getText(trs[0]).toLowerCase();
      var looks = /(item|product|name)/.test(hdr) && /price/.test(hdr) && /(stock|qty|quantity)/.test(hdr);
      if (!looks) continue;
      for (var i=1;i<trs.length;i++){
        var tds = qsa('td', trs[i]);
        if (tds.length<3) continue;
        var name = getText(tds[0]);
        var price = parseNumber(getText(tds[1]));
        var stock = parseNumber(getText(tds[2]));
        if (!name || price==null || stock==null) continue;
var cellsText = [];
for (var c = 0; c < tds.length; c++) {
  cellsText.push(getText(tds[c]).toLowerCase());
}
        var idxToday = arrayFindIndex(cellsText, function(x){ return /sold.*today/.test(x); });
        var idx7d = arrayFindIndex(cellsText, function(x){ return /(7\s*d|week)/.test(x) || /sold.*7/.test(x); });
        var soldToday = idxToday>=0 ? parseNumber(getText(tds[idxToday])) : null;
        var sold7d = idx7d>=0 ? parseNumber(getText(tds[idx7d])) : null;
        rows.push({ name:name, price:price, stock:stock, soldToday:soldToday, sold7d:sold7d });
      }
    }
    return rows;
  }

  // ---------- Renderers ----------
  function renderCompanySummary(summary){
    var popularity=summary.popularity, environment=summary.environment, effectiveness=summary.effectiveness;
    var income=summary.income, expenses=summary.expenses, profit=summary.profit, margin=summary.margin, vacancies=summary.vacancies;

    function statClass(v, good, warn){
      if (v==null) return '';
      if (typeof v==='number' && !isNaN(v)){
        if (v>=good) return 'ok';
        if (v>=warn) return 'warn';
      }
      return 'bad';
    }

    var popCls=statClass(popularity,80,60);
    var envCls=statClass(environment,80,60);
    var effCls=statClass(effectiveness,80,60);
    var marCls=statClass(margin,20,10);

    var tips=[];
    if (vacancies!=null && vacancies>0) tips.push('You have <b>'+vacancies+'</b> vacancies — hire to lift popularity & effectiveness.');
    if (popularity!=null && popularity<70) tips.push('Consider ads/specials to boost <b>Popularity</b>.');
    if (environment!=null && environment<70) tips.push('Review upgrades/perks to improve <b>Environment</b>.');
    if (effectiveness!=null && effectiveness<75) tips.push('Check role fit & activity to raise <b>Effectiveness</b>.');
    if (margin!=null && margin<10) tips.push('Margin is low — review wages/ads/supplies, or nudge prices (see Retail Advisor).');
    if (!tips.length) tips.push('Looking solid. Maintain consistency to push for stars.');

    return [
      '<div class="ad-section">',
      '  <h3>Company Snapshot</h3>',
      '  <div class="ad-grid">',
      '    <div class="ad-kv"><span class="muted">Popularity</span><span class="'+popCls+'">'+(popularity==null?'—':percent(popularity,0))+'</span></div>',
      '    <div class="ad-kv"><span class="muted">Environment</span><span class="'+envCls+'">'+(environment==null?'—':percent(environment,0))+'</span></div>',
      '    <div class="ad-kv"><span class="muted">Effectiveness</span><span class="'+effCls+'">'+(effectiveness==null?'—':percent(effectiveness,0))+'</span></div>',
      '    <div class="ad-kv"><span class="muted">Daily Income</span><span>'+(income==null?'—':money(income))+'</span></div>',
      '    <div class="ad-kv"><span class="muted">Daily Expenses</span><span>'+(expenses==null?'—':money(expenses))+'</span></div>',
      '    <div class="ad-kv"><span class="muted">Daily Profit</span><span class="'+((profit!=null && profit<0)?'bad':'')+'">'+(profit==null?'—':money(profit))+'</span></div>',
      '    <div class="ad-kv"><span class="muted">Margin</span><span class="'+marCls+'">'+(margin==null?'—':percent(margin,1))+'</span></div>',
      '    <div class="ad-kv"><span class="muted">Vacancies</span><span>'+(vacancies==null?'—':vacancies)+'</span></div>',
      '  </div>',
      '</div>',
      renderApiControls()
    ].join('\n');
  }

  function loadItemCosts(){
    var costs = store.get(CFG.ls.itemCosts, {});
    if (!costs || typeof costs!=='object'){ costs={}; store.set(CFG.ls.itemCosts, costs); }
    return costs;
  }
  function saveItemCosts(m){ store.set(CFG.ls.itemCosts, m||{}); }

  function buildRetailAdvice(items){
    var costs = loadItemCosts();
    return items.map(function(it){
      var cost = costs[it.name];
      var margin = (cost!=null) ? ((it.price - cost)/Math.max(1,cost))*100 : null;
      var dailySold = (it.soldToday!=null) ? it.soldToday : ((it.sold7d!=null) ? it.sold7d/7 : null);
      var stockDaysLeft = dailySold ? (it.stock/Math.max(0.01,dailySold)) : null;
      var sellThroughPct = (dailySold && it.stock) ? Math.min(100,(dailySold/it.stock)*100) : null;

      var suggestion='Hold price', nudgePct=0;
      if (sellThroughPct!=null){
        var target = CFG.retail.targetSellThroughDaily*100;
        var diff = sellThroughPct - target;
        if (diff>5){
          var stepsUp = Math.ceil(diff/5);
          nudgePct = clamp(stepsUp*CFG.retail.elasticityStepPctPerStep, 0, CFG.retail.maxNudgePct);
          suggestion = 'Raise ~'+nudgePct.toFixed(0)+'%';
        } else if (diff<-5){
          var stepsDn = Math.ceil(Math.abs(diff)/5);
          nudgePct = -clamp(stepsDn*CFG.retail.elasticityStepPctPerStep, 0, CFG.retail.maxNudgePct);
          suggestion = 'Lower ~'+Math.abs(nudgePct).toFixed(0)+'%';
        }
      }

      var warnings=[];
      if (margin!=null && margin<CFG.retail.minFloorMarginPct) warnings.push('Low margin ('+percent(margin,0)+')');
      if (stockDaysLeft!=null && stockDaysLeft>30) warnings.push('Overstocked (>30 days)');

      var newPrice = nudgePct ? Math.max(0, Math.round(it.price*(1+nudgePct/100))) : it.price;

      return { name:it.name, price:it.price, stock:it.stock, soldToday:it.soldToday, sold7d:it.sold7d,
        cost:cost, margin:margin, dailySold:dailySold, stockDaysLeft:stockDaysLeft, sellThroughPct:sellThroughPct,
        suggestion:suggestion, nudgePct:nudgePct, newPrice:newPrice, warnings:warnings };
    });
  }

  function renderRetailSection(advice){
    if (!advice.length) return '';
    return [
      '<div class="ad-section retail">',
      '  <h3>Retail Pricing Advisor <span class="ad-chip">beta</span></h3>',
      advice.map(function(i){
        return [
          '<div class="item" data-name="'+i.name+'">',
          '  <h4>'+i.name+'</h4>',
          '  <div class="row"><span class="muted">Price</span><span>'+money(i.price)+'</span></div>',
          '  <div class="row"><span class="muted">Stock</span><span>'+i.stock+'</span></div>',
          (i.dailySold!=null ? '  <div class="row"><span class="muted">Sold/day</span><span>'+i.dailySold.toFixed(1)+'</span></div>' : ''),
          (i.sellThroughPct!=null ? '  <div class="row"><span class="muted">Sell-through</span><span>'+percent(i.sellThroughPct,1)+'</span></div>' : ''),
          (i.stockDaysLeft!=null ? '  <div class="row"><span class="muted">Days of stock</span><span>'+i.stockDaysLeft.toFixed(1)+'</span></div>' : ''),
          '  <div class="row"><span class="muted">Cost (edit)</span><span><input class="cost" type="number" step="1" min="0" placeholder="optional" value="'+(i.cost!=null?i.cost:'')+'" data-itemcost="'+i.name+'"></span></div>',
          (i.margin!=null ? '  <div class="row"><span class="muted">Margin</span><span>'+percent(i.margin,1)+'</span></div>' : ''),
          '  <div class="row"><span class="muted">Advice</span><span>'+i.suggestion+(i.newPrice!==i.price?(' → <b>'+money(i.newPrice)+'</b>'):'')+'</span></div>',
          (i.warnings.length ? '  <div class="row warn">⚠ '+i.warnings.join(' • ')+'</div>' : ''),
          '</div>'
        ].join('\n');
      }).join('\n'),
      '  <div class="muted" style="margin-top:6px;">Tip: set item costs to calculate margins & low-margin warnings.</div>',
      '</div>'
    ].join('\n');
  }

  function wireRetailCostInputs(){
    var container = qs('#'+CFG.panelId+' .retail');
    if (!container) return;
    var costs = loadItemCosts();
    qsa('input.cost[data-itemcost]', container).forEach(function(inp){
      inp.addEventListener('change', function(){
        var name = inp.getAttribute('data-itemcost');
        var val = parseNumber(inp.value);
        if (val==null){ delete costs[name]; } else { costs[name]=val; }
        saveItemCosts(costs);
        runAll();
      });
    });
  }

  function renderPerRoleTable(employees){
    if (!employees.length) return '';
    var lowThr = store.get(CFG.ls.lowEffThreshold, CFG.staff.lowEffDefault);
    var warnThr = store.get(CFG.ls.warnEffThreshold, CFG.staff.warnEffDefault);

    var byRole = new Map();
    employees.forEach(function(e){
      var key = e.role || 'Unknown';
      if (!byRole.has(key)) byRole.set(key, []);
      byRole.get(key).push(e);
    });

    var rows=[];
    byRole.forEach(function(list, role){
      var effs = list.map(function(l){ return (typeof l.effectiveness==='number'?l.effectiveness:null); }).filter(function(v){ return v!=null; });
      var avg = effs.length ? (effs.reduce(function(a,b){ return a+b; },0)/effs.length) : null;
      var lowCount = effs.filter(function(v){ return v<lowThr; }).length;
      var inactiveCount = list.filter(function(l){
        var la = (l.lastAction || '').toLowerCase();
        for (var i=0;i<CFG.staff.inactivePhrases.length;i++){ if (la.indexOf(CFG.staff.inactivePhrases[i])!==-1) return true; }
        return false;
      }).length;

      rows.push({ role:role, staff:list.length, avg:avg, low:lowCount, inactive:inactiveCount });
    });

    rows.sort(function(a,b){
      if (a.avg==null) return -1;
      if (b.avg==null) return 1;
      return b.avg - a.avg;
    });

    return [
      '<div class="ad-section">',
      '  <h3>Per-role Effectiveness</h3>',
      rows.map(function(r){
        var cls = (r.avg==null) ? '' : (r.avg>=warnThr ? (r.avg>=90?'ok':'warn') : 'bad');
        return '<div class="row"><span class="muted">'+r.role+' (Staff '+r.staff+')</span><span class="'+cls+'">'+(r.avg==null?'—':percent(r.avg,0))+' · Low '+r.low+' · Inactive '+r.inactive+'</span></div>';
      }).join('\n'),
      '  <div class="muted" style="margin-top:6px;">Thresholds: Low '+lowThr+'% · Warn '+warnThr+'% (adjust in Settings above).</div>',
      '</div>'
    ].join('\n');
  }

  // ---------- Main render ----------
  function runAll(){
    sleep(150, function(){
      var sections=[];
      var apiKey = localStorage.getItem(CFG.ls.apiKey) || '';
      var summary=null, employees=[], invRows=[];

      maybeGetApiData(apiKey).then(function(apiData){
        if (apiData){
          summary = apiData.summary || null;
          employees = apiData.employees || [];
          invRows = apiData.stockRows || [];
        }

        if (!summary && onCompanyPage()) summary = scrapeCompanySummaryDOM();
        if (!employees.length) employees = scrapeEmployeesDOM();
        if (!invRows.length) invRows = scrapeInventoryDOM();

        if (summary) sections.push(renderCompanySummary(summary));
        if (employees.length) sections.push(renderPerRoleTable(employees));
        if (invRows.length){
          var advice = buildRetailAdvice(invRows);
          sections.push(renderRetailSection(advice));
        }

        if (!sections.length){
          sections.push([
            '<div class="ad-section">',
            '  <h3>Assistant Director</h3>',
            '  <div class="muted">Enter your Torn API key (optional) and open Company/Staff/Inventory tabs to see data. The panel also works without API by reading the page.</div>',
            '</div>'
          ].join('\n'));
        }

        setPanelBody(sections.join('\n'));
        wireRetailCostInputs();
        wireSettings();
        updateCollapseButtonLabel(ensurePanel());
      });
    });
  }

  // ---------- Observer ----------
  var observer=null, observerTimer=null;
  function attachObserver(){
    if (observer) observer.disconnect();
    observer = new MutationObserver(function(mutations){
      var panel = ensurePanel();
      if (isTypingInPanel()) return;

      var relevant=false;
      for (var i=0;i<mutations.length;i++){
        var m = mutations[i];
        if (m.type!=='childList') continue;
        if (!panel){ relevant=true; break; }
        if (panel.contains(m.target)) continue;
        var skip=false, n;
        for (n=0;n<m.addedNodes.length;n++){ if (panel.contains(m.addedNodes[n])) { skip=true; break; } }
        if (skip) continue;
        for (n=0;n<m.removedNodes.length;n++){ if (panel.contains(m.removedNodes[n])) { skip=true; break; } }
        if (skip) continue;
        relevant=true; break;
      }
      if (!relevant) return;
      clearTimeout(observerTimer);
      observerTimer = setTimeout(runAll, 400);
    });
    observer.observe(document.body, { childList:true, subtree:true });
  }

  // Boot
  ensurePanel();
  runAll();
  attachObserver();

})();