Torn OC Manager - Edited

Faction leader dashboard for Organised Crimes 2.0 — CPR warnings, member availability, slot gaps.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Torn OC Manager - Edited
// @namespace    torn_oc_manager
// @version      3.5.2
// @description  Faction leader dashboard for Organised Crimes 2.0 — CPR warnings, member availability, slot gaps.
// @author       TheOddSod (2640064)
// @license      MIT
// @match        https://www.torn.com/factions.php*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        GM_xmlHttpRequest
// @grant        GM.xmlHttpRequest
// @connect      tornprobability.com
// @connect      www.tornstats.com
// @connect      api.torn.com
// ==/UserScript==
//
//
// Changelog (recent):
// v3.5.2 — Fixed 'best open slot' advisory recommending 0% CPR slots. An open
//          slot has no CPR of its own, so it now looks up YOUR predicted CPR for
//          that crime+role from TornStats data (leader mode now resolves your own
//          player ID). If there's no CPR data for you in any open role, the advisory
//          hides instead of showing a meaningless 0%.
// v3.5.1 — Profit tab: added a 'Profit since' date cutoff (defaults to first-run
//          date, so it shows profit since you took over) and an All-time (tracked)
//          view that persistently records every completed OC's profit keyed by OC id,
//          accumulating past the API's ~100-OC cap. Toggle between live API window
//          and tracked all-time; clear the date for full history.
// v3.5.0 — New 💰 Profit tab: aggregates money + item value + respect from
//          completed OCs. KPI tiles (total profit, cash, item value, respect, per-day),
//          a By-Scenario breakdown (cash vs items vs total, avg/run), and an Items
//          Received table (qty, unit market value, total). Item values pulled from
//          /torn/items market price. Estimates — actual sale value varies.
// v3.4.0 — Themes now apply across the whole UI (converted all semantic colours
//          to CSS variables, not just CPR-class elements). Fixed: stale leader
//          'best slot' card showing in forced member mode; member-mode best-slot
//          no longer recommends 0%/unknown-CPR slots; Force Member Mode toggle
//          stays reachable so you can switch back to leader mode.
// v3.3.9 — Ported from upstream v3.5.7: opt-in colour themes (High Contrast +
//          Deuteranopia/Protanopia/Tritanopia colourblind modes with ✓/⚠/✗ shape
//          markers; default theme = current look). Travel-page injection: dashboard
//          now also shows on the faction profile page when abroad (crimes tab gone).
// v3.3.8 — TornStats CPR table now shows live slot CPR (blue dot) overriding
//          the estimate, plus role weights per column. Layout fixes: blocked
//          timer column, analytics table overlap, Last-5 OC header spacing.
// v3.3.7 — Job Planner-style UI refactor; TornStats CPR integration; per-
//          member best-fit slot recommendation with assignment-based dedup;
//          OC history CPR fallback; newsletter button with personalized
//          recommendations; mobile-first card layout for Available members.
// v3.3.5 — OC History pivot in Analytics.
// v3.3.4 — Member mode current-OC card.
// v3.3.3 — 11 CSV exports grouped by section.
// v3.3.2 — Member OC History in Analytics.
// v3.3.1 — Stuck OCs section.
// v3.3.0 — Recruits split, abroad flags, spawn reminder, last 5 OCs, heatmap fix.

(function () {
  'use strict';

  // ─── DUPLICATE GUARD ─────────────────────────────────────────────────────────
  if (window._ocmLoaded) return;
  window._ocmLoaded = true;

  // ─── CONFIG ──────────────────────────────────────────────────────────────────
  const API_BASE      = 'https://api.torn.com/v2';

  // ─── PERSISTENT PROFIT TRACKING ────────────────────────────────────────────
  // The Torn API only returns ~100 recent completed OCs. To build an all-time
  // total that survives past that cap, we record each completed OC's profit
  // (keyed by its unique OC id) into GM storage as we see it. Re-seeing the same
  // OC id never double-counts. This only captures OCs from the moment the user
  // starts running this version forward — it can't recover OCs already aged out.
  const PROFIT_STORE_KEY = 'ocm_profit_ledger';

  /** Load the persistent ledger: { [ocId]: {name, money, itemValue, respect, executedAt, items:{id:qty}} } */
  function loadProfitLedger() {
    try { return JSON.parse(GM_getValue(PROFIT_STORE_KEY, '{}')) || {}; }
    catch (_) { return {}; }
  }
  function saveProfitLedger(ledger) {
    try { GM_setValue(PROFIT_STORE_KEY, JSON.stringify(ledger)); } catch (_) {}
  }

  /**
   * Record completed OCs into the persistent ledger. Keyed by oc.id so repeated
   * refreshes don't double-count. Returns the updated ledger.
   * itemValues is used to snapshot item value at time of recording (so historical
   * profit isn't retroactively changed by market price swings).
   */
  function recordProfit(crimes, itemValues = {}) {
    const ledger = loadProfitLedger();
    let changed = false;
    for (const oc of Object.values(crimes || {})) {
      if (!oc || typeof oc !== 'object') continue;
      const status = (oc.status || '').toLowerCase();
      if (status !== 'successful' && status !== 'success') continue;
      const id = String(oc.id ?? '');
      if (!id || ledger[id]) continue; // already recorded
      const rewards = oc.rewards;
      if (!rewards) continue;
      const money = Number(rewards.money) || 0;
      const resp  = Number(rewards.respect) || 0;
      const rwItems = rewards.items
        ? (Array.isArray(rewards.items) ? rewards.items : Object.values(rewards.items))
        : [];
      let itemValue = 0;
      const items = {};
      for (const it of rwItems) {
        const iid = String(it?.id || it?.item_id || '');
        const qty = Number(it?.quantity || it?.qty || 1);
        if (!iid) continue;
        items[iid] = (items[iid] || 0) + qty;
        itemValue += (Number(itemValues[iid]) || 0) * qty;
      }
      ledger[id] = {
        name: oc.name || `OC #${id}`,
        money, itemValue, respect: resp,
        executedAt: oc.executed_at ?? null,
        items,
      };
      changed = true;
    }
    if (changed) saveProfitLedger(ledger);
    return ledger;
  }

  let CPR_WARN        = Number(GM_getValue('ocm_cfg_cpr_warn',    70));
  let CPR_CRIT        = Number(GM_getValue('ocm_cfg_cpr_crit',    60));
  let WEIGHT_HIGH     = Number(GM_getValue('ocm_cfg_weight_high', 25));
  let WEIGHT_MID      = Number(GM_getValue('ocm_cfg_weight_mid',  15));
  let REFRESH_S       = Number(GM_getValue('ocm_cfg_refresh',     60));
  // Minimum number of recruiting OCs per difficulty before a warning is shown
  let MIN_PER_DIFF    = Number(GM_getValue('ocm_cfg_min_per_diff', 2));

  let tsCprData = {};
  const TS_CACHE_TTL = 86400000; // 24 hours in ms
  // Cooldown after a failed TornStats fetch so we don't hammer the API
  // when it returns errors (rate limit, auth fail, network blip, etc.)
  const TS_FAIL_COOLDOWN = 10 * 60 * 1000; // 10 minutes
  // In-flight guard so concurrent loadData() calls don't double-fire fetches
  let _tsFetchInFlight = false;
  let _tsLastFailAt    = 0;

  // Load cached TS CPR data immediately so it's available on first render
  try {
    const cached = JSON.parse(GM_getValue('ocm_ts_cpr_cache', '{}'));
    if (cached.ts && (Date.now() - cached.ts < TS_CACHE_TTL) && cached.data) {
      tsCprData = cached.data;
    }
  } catch(_) {}

  function saveTsCprCache(data) {
    tsCprData = data;
    GM_setValue('ocm_ts_cpr_cache', JSON.stringify({ ts: Date.now(), data }));
  }

  function isTsCprCacheFresh() {
    try {
      const cached = JSON.parse(GM_getValue('ocm_ts_cpr_cache', '{}'));
      return cached.ts && (Date.now() - cached.ts < TS_CACHE_TTL);
    } catch(_) { return false; }
  }

  // Reference to GM.xmlHttpRequest — the @grant GM.xmlHttpRequest makes this
  // available via the GM object even when GM_xmlHttpRequest is not directly accessible.
  const _gmXhrFn = (typeof GM_xmlHttpRequest === 'function' ? GM_xmlHttpRequest : null)
                || (typeof GM !== 'undefined' && typeof GM.xmlHttpRequest === 'function'
                    ? GM.xmlHttpRequest.bind(GM) : null);

  /** Promise wrapper around GM_xmlHttpRequest for cross-origin requests only.
   *  Torn API calls continue using fetch() (same-origin). */
  function gmFetch(url) {
    return new Promise((resolve, reject) => {
      if (!_gmXhrFn) { reject(new Error('GM_xmlHttpRequest not available')); return; }
      _gmXhrFn({
        method: 'GET', url,
        onload(r) {
          if (r.status < 200 || r.status >= 300) { reject(new Error(`HTTP ${r.status}`)); return; }
          try { resolve(JSON.parse(r.responseText)); }
          catch (e) { reject(e); }
        },
        onerror() { reject(new Error('Network error')); },
      });
    });
  }

  /** Persist all config values and update local variables. */
  function saveConfig(warn, crit, wHigh, wMid, refresh, minPerDiff) {
    CPR_WARN     = warn;
    CPR_CRIT     = crit;
    WEIGHT_HIGH  = wHigh;
    WEIGHT_MID   = wMid;
    REFRESH_S    = refresh;
    MIN_PER_DIFF = minPerDiff;
    GM_setValue('ocm_cfg_cpr_warn',      warn);
    GM_setValue('ocm_cfg_cpr_crit',      crit);
    GM_setValue('ocm_cfg_weight_high',   wHigh);
    GM_setValue('ocm_cfg_weight_mid',    wMid);
    GM_setValue('ocm_cfg_refresh',       refresh);
    GM_setValue('ocm_cfg_min_per_diff',  minPerDiff);
  }

  // ─── ROLE WEIGHTS ────────────────────────────────────────────────────────────
  let roleWeights = {};

  const FALLBACK_WEIGHTS = {
    // Tier 1 / Low difficulty
    "no reserve":              { "car thief": 33, "techie": 33, "engineer": 33 },
    "cash me if you can":      { "thief #1": 54, "thief 1": 54, "thief #2": 28, "thief 2": 28, "lookout": 18, "thief": 41 },
    "pet project":             { "kidnapper": 40, "muscle": 35, "picklock": 25 },
    "best of the lot":         { "car thief": 35, "muscle": 30, "picklock": 20, "imitator": 15 },
    // Tier 2 / Mid difficulty
    "smoke and wing mirrors":  { "car thief": 32, "imitator": 28, "hustler": 20, "hustler #1": 20, "hustler #2": 20 },
    "plucking the lotus petal":{ "muscle": 48, "robber #1": 14, "robber #2": 24, "hustler": 14, "robber": 19 },
    "guardian ángels":         { "muscle": 34, "lookout": 33, "engineer": 33, "enforcer": 33, "hustler": 33 },
    "snow blind":              { "hustler": 48, "imitator": 36, "muscle": 8, "muscle #1": 8, "muscle #2": 8 },
    "leave no trace":          { "techie": 34, "negotiator": 33, "imitator": 33 },
    "market forces":           { "enforcer": 28, "negotiator": 24, "lookout": 20, "arsonist": 15, "muscle": 13 },
    "sneaky git grab":         { "hacker": 30, "techie": 28, "picklock": 22, "pickpocket": 22, "lookout": 20, "imitator": 20 },
    "gaslight the way":        { "imitator": 22, "looter": 18, "imitator #1": 22, "imitator #2": 22, "imitator #3": 22, "looter #1": 18, "looter #2": 18, "looter #3": 18 },
    "mob mentality":           { "looter 1": 34, "looter #1": 34, "looter 2": 26, "looter #2": 26, "looter 4": 23, "looter #4": 23, "looter 3": 18, "looter #3": 18, "looter": 25 },
    "counter offer":           { "hacker": 30, "picklock": 22, "engineer": 20, "looter": 15, "robber": 13 },
    "honey trap":              { "muscle #2": 42, "muscle #1": 31, "enforcer": 27, "muscle": 37 },
    "bidding war":             { "robber 3": 28, "robber #3": 28, "robber 2": 21, "robber #2": 21, "driver": 18, "bomber 2": 16, "bomber #2": 16, "bomber 1": 9, "bomber #1": 9, "robber 1": 7, "robber #1": 7, "robber": 19, "bomber": 13 },
    "stage fright":            { "sniper": 46, "enforcer": 16, "muscle #1": 12, "muscle 1": 12, "muscle #3": 9, "muscle 3": 9, "muscle #2": 3, "muscle 2": 3, "lookout": 6, "muscle": 8 },
    // Tier 3 / Higher difficulty
    "blast from the past":     { "muscle": 34, "engineer": 24, "hacker": 12, "bomber": 16, "picklock 1": 11, "picklock 2": 3, "picklock": 11 },
    "stacking the deck":       { "imitator": 48, "hacker": 26, "cat burglar": 23, "driver": 3 },
    "break the bank":          { "muscle 3": 32, "thief 2": 29, "muscle 1": 14, "robber": 13, "muscle 2": 10, "thief 1": 3, "muscle": 18, "thief": 16 },
    "clinical precision":      { "imitator": 43, "cleaner": 22, "cat burglar": 19, "assassin": 16 },
    // Tier 4 / High difficulty
    "crane reaction":          { "sniper": 41, "lookout": 17, "bomber": 16, "muscle 1": 10, "muscle 2": 8, "engineer": 8, "muscle": 9 },
    "manifest cruelty":        { "reviver": 46, "interrogator": 24, "hacker": 16, "cat burglar": 14 },
    // Tier 5 / Omega
    "ace in the hole":         { "hacker": 28, "muscle 2": 25, "imitator": 21, "muscle 1": 18, "driver": 8, "muscle": 22 },
    "gone fission":            { "hijacker": 25, "imitator": 25, "bomber": 18, "pickpocket": 17, "engineer": 15 },
  };

  /** Load fallback weights immediately then overlay with live data from tornprobability.com. */
  function fetchRoleWeights() {
    const normKey = s => (s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim().replace(/\s+v\d+$/i, '');

    // Pre-load fallbacks so weights are always available even if the live fetch fails
    roleWeights = {};
    for (const [ocName, roles] of Object.entries(FALLBACK_WEIGHTS)) {
      roleWeights[normKey(ocName)] = Object.fromEntries(
        Object.entries(roles).map(([r, w]) => [r.toLowerCase().trim(), w])
      );
    }

    const processResponse = text => {
      try {
        const data = JSON.parse(text);
        // Merge live data over fallbacks — never wipe what we already have
        for (const [ocName, roles] of Object.entries(data)) {
          roleWeights[normKey(ocName)] = Object.fromEntries(
            Object.entries(roles || {}).map(([r, w]) => [r.toLowerCase().trim(), w])
          );
        }
      } catch (_) {}
    };

    const url = 'https://tornprobability.com:3000/api/GetRoleWeights';

    if (typeof GM_xmlHttpRequest !== 'undefined') {
      GM_xmlHttpRequest({ method: 'GET', url, onload: r => processResponse(r.responseText), onerror: () => {} });
    } else if (typeof GM !== 'undefined' && GM.xmlHttpRequest) {
      GM.xmlHttpRequest({ method: 'GET', url, onload: r => processResponse(r.responseText), onerror: () => {} });
    }
  }

  /** Look up the weight for a given role in a given OC. Returns null if not found. */
  function getWeight(ocName, roleName) {
    const ocKey   = (ocName   || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim().replace(/\s+v\d+$/i, '');
    const roleKey = (roleName || '').toLowerCase().trim();
    const oc      = roleWeights[ocKey];
    if (oc && oc[roleKey] != null) return oc[roleKey];
    // Last resort — check FALLBACK_WEIGHTS directly in case live API used different key formatting
    const fb = FALLBACK_WEIGHTS[ocKey];
    if (fb && fb[roleKey] != null) return fb[roleKey];
    return null;
  }

  // ─── COUNTRY → FLAG EMOJI MAP ─────────────────────────────────────────────
  // Used by statusIcon() to convert Torn travel destination strings to emoji flags.
  // Torn description strings look like "Traveling to Japan" or "Returning from Mexico".
  // We scan the description for any of these country names (case-insensitive).
  const COUNTRY_FLAGS = {
    'argentina': '🇦🇷', 'canada': '🇨🇦', 'cayman': '🇰🇾', 'china': '🇨🇳',
    'hawaii': '🇺🇸',    'japan': '🇯🇵',   'mexico': '🇲🇽',  'south africa': '🇿🇦',
    'switzerland': '🇨🇭','uae': '🇦🇪',    'united arab': '🇦🇪', 'uk': '🇬🇧',
    'united kingdom': '🇬🇧', 'usa': '🇺🇸', 'united states': '🇺🇸',
  };

  /**
   * Returns the emoji flag for a country found in a Torn travel description,
   * or 🌍 if no known country is matched.
   */
  function flagFromDescription(description) {
    const lower = (description || '').toLowerCase();
    for (const [country, flag] of Object.entries(COUNTRY_FLAGS)) {
      if (lower.includes(country)) return flag;
    }
    return '🌍';
  }

  /** Title-case a country name pulled out of a description. */
  function prettyCountry(description) {
    const lower = (description || '').toLowerCase();
    for (const country of Object.keys(COUNTRY_FLAGS)) {
      if (lower.includes(country)) {
        return country.replace(/\b\w/g, c => c.toUpperCase());
      }
    }
    return null;
  }

  /**
   * Parse a Torn travel/abroad description into { dir, country, flag, label }.
   *   dir: 'out' (flying to a country) | 'back' (returning to Torn) | 'abroad' (landed)
   * Torn strings: "Traveling to Japan", "Returning to Torn from Japan",
   *               "In Japan", "Flying to Mexico", etc.
   */
  function travelInfo(status, description) {
    const s     = (status || '').toLowerCase();
    const lower = (description || '').toLowerCase();
    const flag  = flagFromDescription(description);
    const country = prettyCountry(description);
    if (s === 'abroad') {
      return { dir: 'abroad', country, flag, label: country ? `${flag} In ${country}` : `${flag} Abroad` };
    }
    // traveling — compact labels: arrow direction tells the story; country name kept short
    const returning = lower.includes('returning') || lower.includes('back to torn') || lower.includes('to torn');
    if (returning) {
      return { dir: 'back', country, flag, label: country ? `${flag}→🏠 From ${country}` : '✈→🏠 Returning' };
    }
    return { dir: 'out', country, flag, label: country ? `🏠→${flag} To ${country}` : '🏠→✈ Traveling' };
  }

  // ─── THEME SYSTEM (opt-in) ─────────────────────────────────────────────────
  // The dashboard's CSS uses hardcoded colours for layout-critical rules; only the
  // *semantic* colours (CPR good/warn/crit, links, key text/bg) are themeable via
  // CSS variables. The "default" theme reproduces the exact current look, so the
  // dashboard is visually identical unless the user opts into another theme.
  //
  // Accessibility themes remap the CPR good/warn/crit trio (and links) to palettes
  // that stay distinguishable under the common colourblindness types, and add
  // ✓/⚠/✗ shape prefixes so meaning is never carried by colour alone.
  const THEMES = {
    default: {
      label: 'Default (current look)',
      '--ocm-cpr-good':  '#7abf7a',
      '--ocm-cpr-warn':  '#bf9f5a',
      '--ocm-cpr-crit':  '#bf7a7a',
      '--ocm-link':      '#7a9acc',
      '--ocm-text':      '#e0e0e0',
      '--ocm-text-card': '#ccc',
      '--ocm-shape-good': '""',
      '--ocm-shape-warn': '""',
      '--ocm-shape-crit': '""',
    },
    highcontrast: {
      label: 'High Contrast',
      '--ocm-cpr-good':  '#00ff88',
      '--ocm-cpr-warn':  '#ffdd00',
      '--ocm-cpr-crit':  '#ff5555',
      '--ocm-link':      '#66bbff',
      '--ocm-text':      '#ffffff',
      '--ocm-text-card': '#f0f0f0',
      '--ocm-shape-good': '""',
      '--ocm-shape-warn': '""',
      '--ocm-shape-crit': '""',
    },
    // Red/green colourblindness (most common) — green→blue, red→orange, plus shapes
    deuteranopia: {
      label: 'Deuteranopia (Red/Green CB)',
      '--ocm-cpr-good':  '#4499ff',
      '--ocm-cpr-warn':  '#ffcc00',
      '--ocm-cpr-crit':  '#ff6600',
      '--ocm-link':      '#88ccff',
      '--ocm-text':      '#e0e0e0',
      '--ocm-text-card': '#ccc',
      '--ocm-shape-good': '"\\2713  "',
      '--ocm-shape-warn': '"\\26a0  "',
      '--ocm-shape-crit': '"\\2717  "',
    },
    // Red deficiency — red appears very dark; use cyan/amber/white + shapes
    protanopia: {
      label: 'Protanopia (Red Deficiency)',
      '--ocm-cpr-good':  '#00ddcc',
      '--ocm-cpr-warn':  '#ffcc00',
      '--ocm-cpr-crit':  '#ffffff',
      '--ocm-link':      '#88ccff',
      '--ocm-text':      '#e0e0e0',
      '--ocm-text-card': '#ccc',
      '--ocm-shape-good': '"\\2713  "',
      '--ocm-shape-warn': '"\\26a0  "',
      '--ocm-shape-crit': '"\\2717  "',
    },
    // Blue/yellow colourblindness — use teal/pink/red + shapes
    tritanopia: {
      label: 'Tritanopia (Blue/Yellow CB)',
      '--ocm-cpr-good':  '#00ddaa',
      '--ocm-cpr-warn':  '#ff88cc',
      '--ocm-cpr-crit':  '#ff2255',
      '--ocm-link':      '#ff99dd',
      '--ocm-text':      '#e0e0e0',
      '--ocm-text-card': '#ccc',
      '--ocm-shape-good': '"\\2713  "',
      '--ocm-shape-warn': '"\\26a0  "',
      '--ocm-shape-crit': '"\\2717  "',
    },
  };

  /** Apply a theme by setting its CSS variables on #ocm-root. */
  function applyTheme(themeKey) {
    const theme = THEMES[themeKey] || THEMES.default;
    const root  = document.getElementById('ocm-root');
    if (!root) return;
    for (const [prop, value] of Object.entries(theme)) {
      if (prop.startsWith('--')) root.style.setProperty(prop, value);
    }
    GM_setValue('ocm_theme', themeKey);
  }

  // ─── STYLES ──────────────────────────────────────────────────────────────────
  GM_addStyle(`
    /* ═══ OC Manager v3.3.7 — Job Planner-style UI ═══
       Palette: bg #181818  card #1e1e1e  hover #1f1f1f  border #333/#2e2e2e
                text #e0e0e0/#ccc/#889/#556
                accents: g #7abf7a  a #bf9f5a  r #bf7a7a  b #7a9acc        */

    /* ── ROOT ─────────────────────────────────────────────────────────── */
    #ocm-root {
      /* Themeable semantic colours — default values reproduce the current look.
         Overridden by applyTheme() when a non-default theme is selected. */
      --ocm-cpr-good:  #7abf7a;
      --ocm-cpr-warn:  #bf9f5a;
      --ocm-cpr-crit:  #bf7a7a;
      --ocm-link:      #7a9acc;
      --ocm-text:      #e0e0e0;
      --ocm-text-card: #ccc;
      --ocm-shape-good: "";
      --ocm-shape-warn: "";
      --ocm-shape-crit: "";
      margin: 8px 0 12px;
      background: #181818;
      border: 1px solid #333;
      border-radius: 6px;
      font-family: Arial, sans-serif;
      font-size: 13px;
      color: #ccc;
      overflow: hidden;
    }

    /* ── HEADER (top bar with title + refresh) ────────────────────────── */
    #ocm-header, .ocm-header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 10px 14px;
      background: linear-gradient(135deg, #242424, #1c1c1c);
      border-bottom: 1px solid #2a2a2a;
      gap: 10px;
    }
    #ocm-header h2, .ocm-header h2 {
      font-size: 15px;
      font-weight: bold;
      color: #e0e0e0;
      margin: 0;
      letter-spacing: 0;
    }
    #ocm-header h2, .ocm-header h2 span {
      font-size: 10px;
      color: #555;
      font-weight: normal;
      margin-left: 4px;
    }
    #ocm-last-update, .ocm-time {
      font-size: 11px;
      color: #667;
    }

    /* ── SUB-STATUS BAR (key saved · CPR thresholds · refresh interval) */
    #ocm-config-strip, .ocm-config-strip {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 8px 14px;
      background: #1c1c1c;
      border-bottom: 1px solid #252525;
      font-size: 11px;
      color: #aab;
      gap: 8px;
      flex-wrap: wrap;
    }

    /* ── BUTTONS ──────────────────────────────────────────────────────── */
    .ocm-cfg-btn, #ocm-refresh-btn, .ocm-config-toggle {
      padding: 7px 12px;
      border-radius: 4px;
      border: 1px solid #383838;
      background: #222;
      color: #ddd;
      font-size: 12px;
      font-weight: bold;
      cursor: pointer;
      transition: background .15s, border-color .15s;
    }
    .ocm-cfg-btn:hover, #ocm-refresh-btn:hover, .ocm-config-toggle:hover {
      background: #2a2a2a;
      border-color: #4a4a4a;
    }
    /* Primary save buttons get green tint */
    #ocm-save-key-btn, #ocm-cfg-save-btn, #ocm-ts-save-btn {
      background: #1a2518;
      border-color: #3a5030;
      color: #7abf7a;
    }
    #ocm-save-key-btn:hover, #ocm-cfg-save-btn:hover, #ocm-ts-save-btn:hover {
      background: #20301e;
      border-color: #4a6040;
    }
    /* Refresh gets blue tint */
    #ocm-refresh-btn {
      background: #182028;
      border-color: #304060;
      color: #6aaade;
    }
    #ocm-refresh-btn:hover {
      background: #1e2a38;
      border-color: #405078;
    }

    /* ── INPUTS ───────────────────────────────────────────────────────── */
    #ocm-root input[type="text"],
    #ocm-root input[type="password"],
    #ocm-root input[type="number"],
    #ocm-root select {
      padding: 7px 10px;
      background: #222;
      border: 1px solid #383838;
      border-radius: 4px;
      color: #e0e0e0;
      font-size: 13px;
      box-sizing: border-box;
    }
    #ocm-root input:focus, #ocm-root select:focus {
      outline: none;
      border-color: #555;
      background: #282828;
    }
    .ocm-cfg-num {
      width: 60px;
      text-align: center;
    }

    /* ── CONFIG PANEL ─────────────────────────────────────────────────── */
    #ocm-config-panel, .ocm-config-panel {
      background: #1a1a1a;
      border-bottom: 1px solid #252525;
    }
    .ocm-cfg-section {
      padding: 12px 14px;
      border-bottom: 1px solid #252525;
    }
    .ocm-cfg-section:last-child { border-bottom: none; }
    .ocm-cfg-label {
      display: block;
      color: #889;
      font-size: 10px;
      text-transform: uppercase;
      letter-spacing: 0.08em;
      font-weight: bold;
      margin-bottom: 8px;
    }
    .ocm-cfg-row {
      display: flex;
      align-items: center;
      gap: 8px;
      flex-wrap: wrap;
      font-size: 12px;
      color: #ccd;
    }
    .ocm-cfg-status {
      font-size: 11px;
      color: #99a;
      margin-top: 6px;
    }

    /* ── STATS BAR ────────────────────────────────────────────────────── */
    #ocm-stats-bar, .ocm-stats-bar {
      padding: 8px 10px;
      background: #181818;
      border-bottom: 1px solid #252525;
      display: flex;
      flex-direction: column;
      gap: 4px;
    }
    .ocm-stats-row {
      display: grid;
      grid-template-columns: repeat(6, minmax(0, 1fr));
      gap: 4px;
    }
    .ocm-stats-row-recruiting {
      background: #1e1e1e;
      border: 1px solid #2e2e2e;
      border-radius: 4px;
      padding: 6px 8px;
      display: flex;
      align-items: center;
      gap: 8px;
      flex-wrap: wrap;
    }
    .ocm-stats-row-recruiting .ocm-stat-label {
      margin-bottom: 0;
      flex-shrink: 0;
    }
    .ocm-recruiting-chip {
      background: #1f1f1f;
      border: 1px solid #2e2e2e;
      border-radius: 3px;
      padding: 2px 6px;
      font-size: 10px;
      color: #c8b060;
      font-weight: bold;
      white-space: nowrap;
    }
    .ocm-recruiting-chip.danger {
      color: var(--ocm-cpr-crit);
      border-color: #4a2a2a;
    }
    .ocm-stat {
      background: #1e1e1e;
      border: 1px solid #2e2e2e;
      border-radius: 4px;
      padding: 6px 8px;
      min-width: 0;
      overflow: hidden;
    }
    .ocm-stat-label {
      display: block;
      color: #667;
      font-size: 9px;
      text-transform: uppercase;
      letter-spacing: 0.04em;
      font-weight: bold;
      margin-bottom: 2px;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    .ocm-stat-value {
      display: block;
      color: #dde;
      font-size: 18px;
      font-weight: bold;
      line-height: 1.1;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    .ocm-s-active   .ocm-stat-value { color: var(--ocm-link); }
    .ocm-s-open     .ocm-stat-value { color: #dde; }
    .ocm-s-lowcpr   .ocm-stat-value { color: var(--ocm-cpr-warn); }
    .ocm-s-blocked  .ocm-stat-value { color: var(--ocm-cpr-crit); }
    .ocm-s-free     .ocm-stat-value { color: var(--ocm-cpr-good); }
    .ocm-s-stuck    .ocm-stat-value { color: var(--ocm-cpr-crit); }
    @media (max-width: 500px) {
      .ocm-stats-row { grid-template-columns: repeat(3, minmax(0, 1fr)); }
      .ocm-stat-value { font-size: 16px; }
    }

    /* ── BANNERS / NOTICES ────────────────────────────────────────────── */
    #ocm-stuck-banner, #ocm-next-banner, .ocm-banner-issue, .ocm-stuck-banner {
      margin: 8px 12px;
      padding: 10px 12px;
      background: #201810;
      border: 1px solid #4a3018;
      border-radius: 4px;
      color: #c8a060;
      font-size: 12px;
      line-height: 1.5;
    }
    .ocm-banner-title {
      color: #d8a060;
      font-weight: bold;
      margin-bottom: 4px;
    }
    .ocm-banner-issues { font-size: 11px; color: #b89060; }

    /* Next OC banner */
    #ocm-next-banner, .ocm-next-banner {
      margin: 8px 12px;
      padding: 10px 12px;
      background: #14181e;
      border: 1px solid #2a3040;
      border-radius: 4px;
      color: #aac;
      font-size: 12px;
      line-height: 1.6;
    }
    #ocm-next-banner.banner-ok {
      background: #14201a;
      border-color: #2a4a32;
      color: #aac8a0;
    }
    #ocm-next-banner.banner-warn, .ocm-next-banner.warn {
      background: #1c1810;
      border-color: #4a3a18;
      color: #c8a060;
    }
    #ocm-next-banner.banner-crit {
      background: #201410;
      border-color: #4a2820;
      color: #c88a70;
    }
    #ocm-next-banner .ocm-banner-title {
      color: inherit;
      font-size: 13px;
      font-weight: bold;
      margin-bottom: 4px;
    }
    #ocm-next-banner .ocm-banner-title strong { color: #e0e0e0; }
    #ocm-next-banner .ocm-banner-issues {
      margin-top: 6px;
      display: flex;
      flex-direction: column;
      gap: 3px;
    }
    #ocm-next-banner .ocm-banner-issue {
      font-size: 11px;
      color: inherit;
      opacity: 0.85;
      display: block;
    }

    /* Best slot card */
    .ocm-stat-pill, #ocm-best-slot, .ocm-best-slot {
      margin: 8px 12px;
      padding: 10px 12px;
      background: #14181e;
      border: 1px solid #2a3040;
      border-radius: 4px;
      color: #aac;
      font-size: 12px;
      line-height: 1.6;
    }

    /* ── SECTION TITLES (collapsible headers) ─────────────────────────── */
    /* Group tabs — switch between Action / Status / Optimize / Active OCs / Reference */
    .ocm-tabs {
      display: flex;
      gap: 4px;
      padding: 8px 10px 0;
      background: #181818;
      border-bottom: 1px solid #2a2a2a;
      flex-wrap: wrap;
    }
    .ocm-tab {
      background: transparent;
      border: 1px solid #2a2a2a;
      border-bottom: none;
      border-radius: 4px 4px 0 0;
      color: #778;
      font-size: 11px;
      font-weight: bold;
      letter-spacing: 0.05em;
      text-transform: uppercase;
      padding: 7px 12px;
      cursor: pointer;
      user-select: none;
      display: flex;
      align-items: center;
      gap: 5px;
      transition: background .15s, color .15s, border-color .15s;
      position: relative;
      top: 1px;
    }
    .ocm-tab:hover {
      background: #1f1f1f;
      color: #aab;
    }
    .ocm-tab.active {
      background: #1e1e1e;
      color: #dde;
      border-color: #3a3a3a;
      border-bottom: 1px solid #1e1e1e;
    }
    .ocm-tab-count {
      background: #2a2a2a;
      color: #889;
      font-size: 9px;
      padding: 1px 5px;
      border-radius: 8px;
      min-width: 14px;
      text-align: center;
    }
    .ocm-tab.active .ocm-tab-count {
      background: #3a3a3a;
      color: #ccd;
    }
    .ocm-tab-count.has-attn {
      background: #4a3a20;
      color: #d8b070;
    }
    .ocm-tab.active .ocm-tab-count.has-attn {
      background: #5a4a30;
      color: #e8c890;
    }
    .ocm-tab-pane { display: none; min-width: 0; }
    .ocm-tab-pane.active { display: block; min-width: 0; overflow: hidden; }

    .ocm-section-title {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 7px 14px;
      background: linear-gradient(135deg, #1e1e1e, #181818);
      border-bottom: 1px solid #252525;
      font-size: 11px;
      font-weight: bold;
      color: #889;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      cursor: pointer;
      user-select: none;
      transition: background .15s, color .15s;
    }
    .ocm-section-title:hover {
      background: linear-gradient(135deg, #232323, #1c1c1c);
      color: #aab;
    }
    .ocm-section-title::after {
      content: '▼';
      font-size: 9px;
      color: #445;
      transition: transform .2s;
      margin-left: 8px;
    }
    .ocm-section-title.collapsed::after {
      transform: rotate(-90deg);
    }

    /* Section content panels */
    #ocm-available, #ocm-recruits, #ocm-blocked, #ocm-lowcpr, #ocm-overqualified, #ocm-tscpr,
    #ocm-analytics, #ocm-downloads {
      background: #181818;
      padding-top: 6px;
      padding-bottom: 6px;
      min-width: 0;
      overflow: hidden;
    }
    /* Section content gets uniform horizontal padding by default; specific
       layouts (mcard-list, row-grids, table wrappers) handle their own. */
    #ocm-available > *:not(.ocm-mcard-list),
    #ocm-recruits > *:not(.ocm-blocked-row):not(.ocm-lowcpr-row),
    #ocm-blocked > *:not(.ocm-blocked-row):not(.ocm-lowcpr-row),
    #ocm-lowcpr > *:not(.ocm-blocked-row):not(.ocm-lowcpr-row),
    #ocm-overqualified > *:not(.ocm-lowcpr-row),
    #ocm-tscpr > *:not(.ocm-tscpr-oc-block),
    #ocm-analytics > *,
    #ocm-downloads > * {
      padding-left: 12px;
      padding-right: 12px;
    }

    /* ── PHASE HEADERS (Planning / Recruiting bar) ────────────────────── */
    .ocm-phase-header, #ocm-planning-header, #ocm-recruiting-header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 10px;
      margin: 10px 12px;
      padding: 10px 14px;
      background: #14181e;
      border: 1px solid #2a3040;
      border-radius: 5px;
      flex-wrap: wrap;
    }
    .ocm-phase-recruiting, #ocm-recruiting-header {
      background: #1e180e;
      border-color: #4a3a18;
    }
    .ocm-phase-recruiting .ocm-phase-count, #ocm-recruiting-header .ocm-phase-count {
      color: #c8a060;
    }
    .ocm-phase-planning .ocm-phase-count, #ocm-planning-header .ocm-phase-count {
      color: var(--ocm-link);
    }
    .ocm-phase-count {
      font-size: 14px;
      font-weight: bold;
    }
    .ocm-phase-collapse {
      color: #445;
      font-size: 11px;
      cursor: pointer;
      user-select: none;
    }
    .ocm-diff-chip, .ocm-diff-chip-plan {
      background: #1a2030;
      border: 1px solid #2a4060;
      color: var(--ocm-link);
      padding: 2px 8px;
      border-radius: 3px;
      font-size: 11px;
      font-weight: bold;
    }
    .ocm-diff-chip-plan { background: #14181e; }
    .ocm-diff-sep { color: #445; }

    /* ── OC GRID ──────────────────────────────────────────────────────── */
    .ocm-oc-grid, #ocm-grid-planning, #ocm-grid-recruiting {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
      gap: 6px;
      padding: 0 12px 10px;
    }

    /* ── OC CARDS ─────────────────────────────────────────────────────── */
    .ocm-stuck-card, .ocm-card {
      background: #1e1e1e;
      border: 1px solid #2e2e2e;
      border-radius: 5px;
      overflow: hidden;
      transition: border-color .15s;
    }
    .ocm-stuck-card:hover, .ocm-card:hover {
      border-color: #3a3a3a;
    }
    .ocm-stuck-card-title, .ocm-card-title, .ocm-stuck-header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 8px 10px 2px;
      gap: 8px;
    }
    .ocm-stuck-card-title strong, .ocm-card-title strong {
      color: #e0e0e0;
      font-size: 13px;
      font-weight: bold;
    }
    .ocm-card-subtitle {
      padding: 0 10px 4px;
      color: #889;
      font-size: 11px;
    }
    .ocm-stuck-diff {
      background: #1a2030;
      border: 1px solid #2a4060;
      color: var(--ocm-link);
      padding: 2px 8px;
      border-radius: 3px;
      font-size: 10px;
      font-weight: bold;
      letter-spacing: 0.05em;
    }
    .ocm-stuck-expiry { color: #778; font-size: 11px; padding: 0 12px 6px; }

    /* ── SLOTS ────────────────────────────────────────────────────────── */
    .ocm-slots {
      display: flex;
      flex-direction: column;
    }
    .ocm-slot {
      display: flex;
      align-items: center;
      gap: 5px;
      padding: 4px 10px;
      background: #181818;
      border-top: 1px solid #232323;
      font-size: 12px;
      flex-wrap: nowrap;
    }
    .ocm-slot:hover { background: #1f1f1f; }
    .ocm-slot-status { font-size: 11px; flex-shrink: 0; }
    .ocm-slot-role { color: #889; font-size: 12px; flex-shrink: 0; min-width: 60px; }
    .ocm-slot-member {
      color: #dde;
      font-size: 12px;
      flex: 1 1 auto;
      min-width: 50px;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    .ocm-slot-cpr {
      font-weight: bold;
      font-size: 12px;
      text-align: right;
      flex-shrink: 0;
      min-width: 36px;
      margin-left: auto;
    }
    .ocm-slot-cpr.cpr-good { color: var(--ocm-cpr-good); }
    .ocm-slot-cpr.cpr-warn { color: var(--ocm-cpr-warn); }
    .ocm-slot-cpr.cpr-crit { color: var(--ocm-cpr-crit); }
    .ocm-slot-cpr.cpr-empty { color: #445; }
    .ocm-slot-weight { color: #556; font-size: 10px; flex-shrink: 0; }

    /* Smaller progress bar for mobile-friendly slot rows */
    .ocm-progress-wrap {
      width: 50px;
      height: 5px;
      background: #232323;
      border-radius: 3px;
      overflow: hidden;
      flex-shrink: 0;
    }
    .ocm-progress-fill {
      height: 100%;
      background: #4a5a78;
      border-radius: 3px;
      transition: width .3s;
    }
    .ocm-progress-fill.cpr-good { background: #4a8a4a; }
    .ocm-progress-fill.cpr-warn { background: #8a7a3a; }
    .ocm-progress-fill.cpr-crit { background: #8a4a4a; }

    /* ── TS BADGE (TornStats CPR pill next to member name) ───────────── */
    .ocm-ts-badge {
      display: inline-block;
      background: #1a2028;
      border: 1px solid #2a3a4a;
      font-size: 9px;
      font-weight: bold;
      padding: 1px 5px;
      border-radius: 3px;
      letter-spacing: 0.02em;
      white-space: nowrap;
      flex-shrink: 0;
    }

    /* Item status badge — color-coded by availability so it's instantly readable */
    .ocm-item-tag {
      font-size: 10px;
      padding: 2px 5px;
      border-radius: 3px;
      text-decoration: none;
      display: inline-flex;
      align-items: center;
      gap: 2px;
      flex-shrink: 0;
      line-height: 1.2;
      transition: filter .15s;
    }
    .ocm-item-tag:hover { filter: brightness(1.3); }
    .ocm-item-tag.item-ok {
      background: #14201a;
      border: 1px solid #2a4a32;
      color: var(--ocm-cpr-good);
    }
    .ocm-item-tag.item-armory {
      background: #14181e;
      border: 1px solid #2a3a4a;
      color: var(--ocm-link);
    }
    .ocm-item-tag.item-missing {
      background: #201410;
      border: 1px solid #4a2820;
      color: var(--ocm-cpr-crit);
    }
    .ocm-item-tag.item-unknown {
      background: #1c1810;
      border: 1px solid #3a3018;
      color: var(--ocm-cpr-warn);
    }

    /* ── MEMBER TABLES (Available, Recruits, Blocked, Low CPR) ───────── */

    /* Recruits notice — info banner above the recruits table */
    .ocm-recruits-notice {
      background: #1c1810;
      border: 1px solid #3a3018;
      border-radius: 4px;
      color: #c8a060;
      font-size: 11px;
      line-height: 1.5;
      padding: 8px 10px !important;
      margin: 8px 0;
    }
    .ocm-recruits-notice strong { color: #d8a060; }

    /* Card layout for Available members — mobile-first, prominent recommendation */
    .ocm-mcard-list {
      display: flex;
      flex-direction: column;
      gap: 6px;
      padding: 8px 12px;
    }
    .ocm-mcard {
      background: #1e1e1e;
      border: 1px solid #2e2e2e;
      border-radius: 5px;
      padding: 8px 10px;
      transition: border-color .15s;
    }
    .ocm-mcard:hover { border-color: #3a3a3a; }
    .ocm-mcard-head {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 8px;
      margin-bottom: 6px;
    }
    .ocm-mcard-name {
      color: #e0e0e0;
      font-weight: bold;
      font-size: 13px;
      text-decoration: none;
    }
    .ocm-mcard-name:hover { color: #fff; }
    .ocm-mcard-seen {
      font-size: 11px;
      color: #667;
      flex-shrink: 0;
    }
    .ocm-mcard-rec {
      background: #14181e;
      border: 1px solid #2a3040;
      border-radius: 4px;
      padding: 6px 8px;
      margin-bottom: 4px;
    }
    .ocm-mcard-meta {
      font-size: 11px;
      color: #667;
    }
    .ocm-members-table, .ocm-blocked-row, .ocm-mh-table, .ocm-analytics-table {
      width: 100%;
      border-collapse: collapse;
      font-size: 12px;
    }
    .ocm-members-table th, .ocm-mh-table th, .ocm-analytics-table th {
      background: #181818;
      color: #556;
      font-size: 10px;
      letter-spacing: 0.06em;
      text-transform: uppercase;
      font-weight: bold;
      border-bottom: 1px solid #2a2a2a;
      padding: 6px 12px;
      text-align: left;
    }
    .ocm-members-table td, .ocm-mh-table td, .ocm-analytics-table td {
      padding: 5px 12px;
      border-bottom: 1px solid #232323;
      color: #dde;
    }
    .ocm-members-table tr:hover td,
    .ocm-mh-table tr:hover td,
    .ocm-analytics-table tr:hover td {
      background: #1f1f1f;
    }

    .ocm-blocked-row {
      display: grid;
      grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 1.2fr) 88px;
      align-items: center;
      gap: 8px;
      padding: 6px 12px;
      border-bottom: 1px solid #232323;
      font-size: 12px;
      min-width: 0;
      box-sizing: border-box;
      width: 100%;
    }
    .ocm-blocked-row:hover { background: #1f1f1f; }
    .ocm-blocked-name {
      color: #dde;
      text-decoration: none;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
      min-width: 0;
    }
    .ocm-blocked-name:hover { color: #fff; }
    .ocm-blocked-status {
      font-size: 10px;
      color: #aac;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    .ocm-blocked-oc {
      color: #778;
      font-size: 10px;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    .ocm-blocked-timer {
      color: #aac;
      font-size: 10px;
      text-align: right;
      white-space: nowrap;
    }
    .ocm-blocked-timer.notimer { color: #555; }

    /* Low CPR rows — different columns from blocked: member, OC, role, extras, CPR */
    .ocm-lowcpr-row {
      display: grid;
      grid-template-columns: minmax(80px, 1fr) minmax(0, 1.5fr) minmax(60px, 0.8fr) auto auto;
      align-items: center;
      gap: 8px;
      padding: 6px 12px;
      border-bottom: 1px solid #232323;
      font-size: 12px;
    }
    .ocm-lowcpr-row:hover { background: #1f1f1f; }
    .ocm-lowcpr-name {
      color: #dde;
      text-decoration: none;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    .ocm-lowcpr-oc {
      color: #889;
      font-size: 11px;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    .ocm-lowcpr-role {
      color: #889;
      font-size: 11px;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    .ocm-lowcpr-extras {
      display: flex;
      gap: 4px;
      align-items: center;
    }
    .ocm-lowcpr-cpr {
      text-align: right;
      min-width: 36px;
    }
    .ocm-blocked-reason { color: var(--ocm-cpr-crit); font-size: 11px; }

    /* ── TORNSTATS CPR SECTION ───────────────────────────────────────── */
    .ocm-tscpr-oc-block {
      margin: 8px 12px;
      background: #1e1e1e;
      border: 1px solid #2e2e2e;
      border-radius: 4px;
      overflow: hidden;
    }
    .ocm-tscpr-oc-header {
      display: flex;
      align-items: center;
      gap: 8px;
      padding: 8px 12px;
      background: #1e1e1e;
      color: #dde;
      font-size: 13px;
      font-weight: bold;
      cursor: pointer;
      user-select: none;
      transition: background .15s;
    }
    .ocm-tscpr-oc-header:hover { background: #232323; }
    .ocm-tscpr-arrow {
      font-size: 9px;
      color: #445;
      transition: transform .2s;
    }
    .ocm-tscpr-oc-header.collapsed .ocm-tscpr-arrow {
      transform: rotate(-90deg);
    }
    .ocm-tscpr-oc-body {
      border-top: 1px solid #2a2a2a;
      overflow-x: auto;
    }
    .ocm-tscpr-table {
      width: 100%;
      border-collapse: collapse;
      font-size: 11px;
      table-layout: auto;
    }
    .ocm-tscpr-table th {
      background: #181818;
      color: #556;
      font-size: 9px;
      letter-spacing: 0.04em;
      text-transform: uppercase;
      font-weight: bold;
      border-bottom: 1px solid #2a2a2a;
      padding: 5px 6px;
      text-align: right;
      white-space: nowrap;
    }
    .ocm-tscpr-table th:first-child { text-align: left; }
    .ocm-tscpr-table td {
      padding: 3px 6px;
      border-bottom: 1px solid #232323;
      color: #dde;
      text-align: right;
      font-variant-numeric: tabular-nums;
    }
    .ocm-tscpr-table td:first-child {
      text-align: left;
      color: #ccc;
      white-space: nowrap;
      max-width: 120px;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    .ocm-tscpr-table tr:hover td { background: #1f1f1f; }

    /* Difficulty badge inside TS CPR header */
    .ocm-tscpr-oc-header span[style*="7a9acc"] {
      background: #1a2030 !important;
      border: 1px solid #2a4060;
      color: var(--ocm-link) !important;
      padding: 1px 6px;
      border-radius: 3px;
      font-weight: bold !important;
      letter-spacing: 0.04em;
    }

    /* ── ANALYTICS ────────────────────────────────────────────────────── */
    .ocm-analytics-grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
      gap: 8px;
      padding: 0 12px 12px;
    }
    .ocm-analytics-card {
      background: #1e1e1e;
      border: 1px solid #2e2e2e;
      border-radius: 4px;
      padding: 10px 12px;
      min-width: 0;
      overflow: hidden;
    }
    .ocm-analytics-card .ocm-analytics-table {
      table-layout: fixed;
      width: 100%;
    }
    .ocm-analytics-card .ocm-analytics-table td,
    .ocm-analytics-card .ocm-analytics-table th {
      padding: 5px 4px;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    /* First column (name) takes all remaining space and ellipsizes */
    .ocm-analytics-card .ocm-analytics-table th:first-child,
    .ocm-analytics-card .ocm-analytics-table td:first-child {
      width: auto;
      padding-left: 10px;
    }
    .ocm-analytics-card .ocm-analytics-table th.td-right,
    .ocm-analytics-card .ocm-analytics-table td.td-right {
      width: 44px;
    }
    .ocm-analytics-card .ocm-analytics-table th.ocm-sfe-col,
    .ocm-analytics-card .ocm-analytics-table td.ocm-sfe-col {
      width: 70px;
    }
    .ocm-chart-wrap {
      background: #1e1e1e;
      border: 1px solid #2e2e2e;
      border-radius: 4px;
      padding: 8px;
      margin: 8px 12px;
    }

    /* ── HEATMAP / TIMER / SPINNER ────────────────────────────────────── */
    .ocm-heatmap td {
      padding: 4px 6px;
      font-size: 11px;
      color: #dde;
      border: 1px solid #232323;
    }
    .ocm-timer { font-family: monospace; color: #aac; }
    .ocm-spinner { color: #556; font-size: 12px; padding: 14px; text-align: center; }
    .ocm-error {
      background: #201818;
      border: 1px solid #4a2828;
      color: var(--ocm-cpr-crit);
      padding: 10px 14px;
      margin: 8px 12px;
      border-radius: 4px;
      font-size: 12px;
    }
    .ocm-empty-phase { color: #556; padding: 12px 14px; font-size: 12px; }

    /* ── DOWNLOADS ────────────────────────────────────────────────────── */
    .ocm-downloads-grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
      gap: 6px;
      padding: 8px 12px;
    }
    .ocm-dl-btn {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 8px 12px;
      background: #1a2518;
      border: 1px solid #3a5030;
      border-radius: 4px;
      color: var(--ocm-cpr-good);
      font-size: 12px;
      cursor: pointer;
      transition: background .15s;
    }
    .ocm-dl-btn:hover { background: #20301e; border-color: #4a6040; }
    .ocm-dl-btn strong { color: var(--ocm-cpr-good); }
    .ocm-dl-btn span { color: #556; font-size: 10px; }

    /* ── FOOTER ───────────────────────────────────────────────────────── */
    #ocm-footer, .ocm-footer {
      padding: 8px 14px;
      background: #181818;
      border-top: 1px solid #252525;
      color: #445;
      font-size: 11px;
      text-align: center;
    }

    /* ── CPR TEXT COLOR CLASSES ───────────────────────────────────────── */
    .cpr-good { color: var(--ocm-cpr-good); }
    .cpr-warn { color: var(--ocm-cpr-warn); }
    .cpr-crit { color: var(--ocm-cpr-crit); }
    /* Shape prefixes — empty by default, set to ✓/⚠/✗ by accessibility themes */
    .cpr-good::before { content: var(--ocm-shape-good); }
    .cpr-warn::before { content: var(--ocm-shape-warn); }
    .cpr-crit::before { content: var(--ocm-shape-crit); }
    .cpr-empty { color: #445; }

    /* ── MEMBER HISTORY / OC HISTORY (Analytics drilldowns) ───────────── */
    .ocm-mh-search-wrap, .ocm-oh-search-wrap {
      position: relative;
      margin: 8px 0;
    }
    .ocm-mh-search, .ocm-oh-search {
      width: 100%;
      box-sizing: border-box;
    }
    .ocm-mh-clear, .ocm-oh-clear {
      position: absolute;
      right: 8px;
      top: 50%;
      transform: translateY(-50%);
      background: #2a2a2a;
      border: none;
      color: #889;
      font-size: 11px;
      padding: 2px 6px;
      border-radius: 3px;
      cursor: pointer;
    }
    .ocm-mh-dropdown, .ocm-oh-dropdown {
      background: #1e1e1e;
      border: 1px solid #2e2e2e;
      border-radius: 4px;
      max-height: 200px;
      overflow-y: auto;
    }
    .ocm-mh-option {
      padding: 6px 10px;
      cursor: pointer;
      font-size: 12px;
      color: #dde;
      border-bottom: 1px solid #232323;
    }
    .ocm-mh-option:hover { background: #232323; }
    .ocm-mh-empty, .ocm-oh-empty { color: #556; padding: 10px 14px; font-size: 12px; }
    .ocm-mh-summary, .ocm-oh-summary {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
      gap: 6px;
      margin: 8px 0;
    }
    .ocm-mh-sum-item {
      background: #1e1e1e;
      border: 1px solid #2e2e2e;
      border-radius: 4px;
      padding: 6px 10px;
    }
    .ocm-mh-sum-label { color: #556; font-size: 9px; text-transform: uppercase; letter-spacing: 0.06em; }
    .ocm-mh-sum-value { color: #dde; font-size: 14px; font-weight: bold; }
    .ocm-mh-table-wrap, .ocm-oh-table-wrap { overflow-x: auto; margin-top: 8px; }

    .ocm-last5-wrap { margin: 4px 0; }
    .ocm-last5-row-header {
      display: flex;
      align-items: center;
      gap: 8px;
      padding: 5px 10px;
      background: #1a1a1a;
      border-radius: 3px;
      cursor: pointer;
      font-size: 11px;
      color: #aac;
    }
    .ocm-last5-row-header:hover { background: #1f1f1f; }
    .ocm-last5-detail, .ocm-oh-run-detail {
      padding: 8px 10px;
      background: #181818;
      border: 1px solid #2a2a2a;
      border-radius: 3px;
      margin-top: 4px;
      font-size: 11px;
      color: #aab;
    }

    /* ── KEY/STATUS LABELS ────────────────────────────────────────────── */
    .ocm-key-status {
      font-size: 11px;
      color: var(--ocm-cpr-good);
    }
    .ocm-key-status.bad { color: var(--ocm-cpr-crit); }
    .ocm-last-update { font-size: 11px; color: #667; }

    /* ── BADGE / PILL UTILITY ─────────────────────────────────────────── */
    .ocm-badge {
      display: inline-block;
      background: #1a2030;
      border: 1px solid #2a4060;
      color: var(--ocm-link);
      padding: 1px 6px;
      border-radius: 3px;
      font-size: 10px;
      font-weight: bold;
      letter-spacing: 0.04em;
    }
  `);

  // ─── UTILITIES ───────────────────────────────────────────────────────────────

  /** Format a duration in seconds to a human-readable string. */
  function fmtTime(seconds) {
    if (seconds <= 0) return '0s';
    const d = Math.floor(seconds / 86400);
    const h = Math.floor((seconds % 86400) / 3600);
    const m = Math.floor((seconds % 3600) / 60);
    const s = seconds % 60;
    if (d > 0) return `${d}d ${String(h).padStart(2,'0')}h ${String(m).padStart(2,'0')}m ${String(s).padStart(2,'0')}s`;
    if (h > 0) return `${h}h ${String(m).padStart(2,'0')}m ${String(s).padStart(2,'0')}s`;
    return `${m}m ${String(s).padStart(2,'0')}s`;
  }

  /** Returns a live countdown string for a future unix timestamp. */
  function fmtCountdown(untilTs) {
    const diff = Math.max(0, untilTs - Math.floor(Date.now() / 1000));
    return fmtTime(diff);
  }

  /** Returns the CSS class for a CPR value based on configured thresholds. */
  function cprClass(cpr) {
    if (cpr === null) return 'cpr-empty';
    if (cpr >= CPR_WARN) return 'cpr-good';
    if (cpr >= CPR_CRIT) return 'cpr-warn';
    return 'cpr-crit';
  }

  /**
   * Returns an HTML span with the appropriate status icon.
   * Now distinguishes 'abroad' (🌍 + country flag) from 'traveling' (✈ direction).
   * Country flag is resolved from the description field via COUNTRY_FLAGS.
   */
  function statusIcon(status, description) {
    if (!status) return `<span class="ocm-slot-status status-unknown">?</span>`;
    const s = status.toLowerCase();
    if (s === 'okay') {
      return `<span class="ocm-slot-status status-ok" title="Okay">✓</span>`;
    }
    if (s === 'hospital') {
      return `<span class="ocm-slot-status status-hospital" title="Hospital">🏥</span>`;
    }
    if (s === 'jail') {
      return `<span class="ocm-slot-status status-jail" title="Jail">⛓</span>`;
    }
    if (s === 'traveling') {
      // Traveling = in transit (plane is in the air)
      const desc      = (description || '').toLowerCase();
      const returning = desc.includes('returning');
      const tip       = description || 'Traveling';
      const arrow     = returning ? '✈→🏠' : '🏠→✈';
      return `<span class="ocm-slot-status status-travel" title="${tip}">${arrow}</span>`;
    }
    if (s === 'abroad') {
      // Abroad = already at destination — show country flag
      const flag = flagFromDescription(description);
      const tip  = description || 'Abroad';
      return `<span class="ocm-slot-status status-abroad" title="${tip}">${flag}</span>`;
    }
    return `<span class="ocm-slot-status status-unknown" title="${status}">?</span>`;
  }

  /**
   * Returns true if a member's status prevents them from participating in OC initiation.
   * Traveling and abroad are both blocking.
   */
  function isBlocked(status) {
    if (!status) return false;
    const s = status.toLowerCase();
    return s === 'hospital' || s === 'jail' || s === 'traveling' || s === 'abroad';
  }

  /**
   * Returns true if the member holds the Recruit rank and therefore cannot join OCs.
   * Checks both the faction.position field and a top-level rank field.
   */
  function isRecruit(member) {
    const pos  = (member?.faction?.position || member?.position || '').toLowerCase().trim();
    const rank = (member?.rank || '').toLowerCase().trim();
    return pos === 'recruit' || rank === 'recruit';
  }

  // ─── API ─────────────────────────────────────────────────────────────────────

  /** Fetch a Torn API v2 endpoint and return the parsed JSON. Throws on error. */
  async function apiFetch(path, apiKey) {
    const sep = path.includes('?') ? '&' : '?';
    const url = `${API_BASE}${path}${sep}key=${apiKey}&comment=OCManager`;
    const res  = await fetch(url);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const data = await res.json();
    if (data.error) throw new Error(`API error ${data.error.code}: ${data.error.error}`);
    return data;
  }

  /** Fetch all data required for leader (faction) mode. */
  async function fetchAll(apiKey) {
    const [faction, members, armoryData, me] = await Promise.all([
      apiFetch('/faction?selections=crimes,basic', apiKey),
      apiFetch('/faction?selections=members', apiKey),
      apiFetch('/faction?selections=armory', apiKey).catch(() => ({})),
      apiFetch('/user?selections=basic', apiKey).catch(() => ({})),
    ]);

    // Viewer's own player ID — used by the "best open slot" advisory to look up
    // YOUR predicted CPR for each open role (an empty slot has no CPR of its own).
    const viewerId = me?.player_id ? String(me.player_id) : (me?.profile?.id ? String(me.profile.id) : null);
    if (viewerId) GM_setValue('ocm_my_player_id', viewerId);

    // Build armory inventory: item_id → total quantity
    const armory = {};
    const rawArmory = armoryData.armory || {};
    for (const item of Object.values(rawArmory)) {
      const id = String(item.id || item.ID || '');
      if (id) armory[id] = (armory[id] || 0) + (item.quantity || item.qty || 1);
    }

    // Collect item IDs referenced in active OC slots so we can resolve their names
    const itemIds = new Set();
    const INACTIVE = new Set(['completed', 'expired', 'cancelled', 'failed', 'success']);
    for (const oc of Object.values(faction.crimes || {})) {
      if (!oc || typeof oc !== 'object') continue;
      const ocStatus = (oc.status || '').toLowerCase();
      // Active OCs: collect item-requirement IDs (for the slot item badges)
      if (!INACTIVE.has(ocStatus)) {
        for (const slot of Object.values(oc.slots || oc.participants || [])) {
          const rawItems = slot.items
            ? (Array.isArray(slot.items) ? slot.items : Object.values(slot.items))
            : slot.item_requirement ? [slot.item_requirement] : [];
          for (const item of rawItems) {
            const id = item?.id || item?.item_id;
            if (id) itemIds.add(String(id));
          }
        }
      }
      // ALL OCs: collect reward-item IDs (for profit tracking item values)
      const rwItems = oc.rewards?.items;
      if (rwItems) {
        const arr = Array.isArray(rwItems) ? rwItems : Object.values(rwItems);
        for (const it of arr) {
          const id = it?.id || it?.item_id;
          if (id) itemIds.add(String(id));
        }
      }
    }

    // Resolve item names AND market values via the torn items endpoint
    let itemNames = {};
    let itemValues = {};
    if (itemIds.size > 0) {
      try {
        const ids = [...itemIds].join(',');
        const url = `https://api.torn.com/torn/${ids}?selections=items&key=${apiKey}&comment=OCManager`;
        const res  = await fetch(url);
        const data = await res.json();
        for (const [id, item] of Object.entries(data.items || {})) {
          itemNames[String(id)]  = item.name || `Item #${id}`;
          // v2 nests price under value.market_price; v1 used market_value
          itemValues[String(id)] = item.value?.market_price ?? item.market_value ?? item.value?.sell_price ?? 0;
        }
      } catch (_) {}
    }

    // Build last-OC records and collect ex-member IDs from completed OC history
    const lastOc       = {};
    const exMemberIds  = new Set();
    const currentMemberIds = new Set(Object.values(members.members || {}).map(m => String(m.id)));

    // Historical CPR map from completed OCs:
    //   ocHistoryCpr[memberId][ocName][roleNoNum] = { sum, count }
    // Used as a fallback when TornStats has no data for a member+role
    const ocHistoryCpr = {};

    for (const oc of Object.values(faction.crimes || {})) {
      if (!oc?.executed_at) continue;
      const ocSlots = Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || []);
      for (const slot of ocSlots) {
        if (!slot) continue;
        const uid = slot.user?.id ? String(slot.user.id) : null;
        if (!uid) continue;
        if (!lastOc[uid] || oc.executed_at > lastOc[uid].executed_at) {
          lastOc[uid] = { name: oc.name || `OC #${oc.id}`, executed_at: oc.executed_at };
        }
        if (!currentMemberIds.has(uid)) exMemberIds.add(uid);

        // Record CPR for fallback lookup
        const cpr = slot.checkpoint_pass_rate;
        if (cpr != null && oc.name) {
          const roleRaw  = slot.position_info?.label || slot.position || '';
          const roleNorm = roleRaw.replace(/\s*#\d+$/, '').toLowerCase().trim();
          if (roleNorm) {
            const ocKey = oc.name.toLowerCase().trim();
            if (!ocHistoryCpr[uid]) ocHistoryCpr[uid] = {};
            if (!ocHistoryCpr[uid][ocKey]) ocHistoryCpr[uid][ocKey] = {};
            const bucket = ocHistoryCpr[uid][ocKey][roleNorm] || { sum: 0, count: 0 };
            bucket.sum   += cpr;
            bucket.count += 1;
            ocHistoryCpr[uid][ocKey][roleNorm] = bucket;
          }
        }
      }
    }
    window._ocmHistoryCpr = ocHistoryCpr;

    // Fetch display names for ex-members who've left the faction
    const exMemberNames = {};
    if (exMemberIds.size > 0) {
      await Promise.all([...exMemberIds].map(async uid => {
        try {
          const url  = `https://api.torn.com/user/${uid}?selections=basic&key=${apiKey}&comment=OCManager`;
          const res  = await fetch(url);
          const data = await res.json();
          if (data?.name) exMemberNames[uid] = data.name;
        } catch (_) {}
      }));
    }

    return { faction, members: members.members || {}, armory, itemNames, itemValues, lastOc, exMemberNames, viewerId };
  }

  // ─── BUILD UI ────────────────────────────────────────────────────────────────

  /** Construct the dashboard root element. Returns an unattached DOM node. */
  function buildRoot() {
    const root = document.createElement('div');
    root.id = 'ocm-root';
    root.innerHTML = `
      <div id="ocm-header">
        <h2>⚔ OC Manager <span style="font-size:10px;font-weight:normal;opacity:.5">v3.5.2</span></h2>
        <small id="ocm-last-update">Not loaded</small>
        <button id="ocm-refresh-btn" title="Refresh data">↻ Refresh</button>
      </div>
      <div id="ocm-config-strip">
        <span id="ocm-key-status" style="font-size:11px;color:#99a;flex:1"></span>
        <button id="ocm-config-toggle" title="Settings">⚙ Config</button>
      </div>
      <div id="ocm-config-panel" style="display:none">
        <div class="ocm-cfg-section">
          <div class="ocm-cfg-label">Colour Theme
            <span style="font-size:10px;color:#99a;font-weight:normal;text-transform:none;letter-spacing:0;margin-left:6px">
              Accessibility themes add ✓ ⚠ ✗ shape markers so CPR status isn't colour-only.
            </span>
          </div>
          <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
            <select id="ocm-theme-select" style="background:#1c1810;border:1px solid #3a3018;border-radius:4px;color:#e0e0e0;padding:4px 8px;font-size:12px;cursor:pointer;flex:1;max-width:280px">
              <option value="default">Default (current look)</option>
              <option value="highcontrast">High Contrast</option>
              <option value="deuteranopia">Deuteranopia (Red/Green CB)</option>
              <option value="protanopia">Protanopia (Red Deficiency)</option>
              <option value="tritanopia">Tritanopia (Blue/Yellow CB)</option>
            </select>
          </div>
        </div>
        <div class="ocm-cfg-section">
          <div class="ocm-cfg-label">Torn API Key
            <span style="font-size:10px;color:#99a;font-weight:normal;text-transform:none;letter-spacing:0;margin-left:6px">
              Requires: Faction data (read) access. Generate at
              <a href="https://www.torn.com/preferences.php#tab=api" target="_blank" style="color:var(--ocm-link)">Preferences → API</a>.
            </span>
          </div>
          <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
            <input id="ocm-api-input" type="password" placeholder="Paste your API key (faction read access)" style="flex:1;min-width:160px" />
            <button id="ocm-save-key-btn" class="ocm-cfg-btn">Save & Load</button>
          </div>
        </div>
        <div class="ocm-cfg-section">
          <div class="ocm-cfg-label">TornStats API Key
            <span style="font-size:10px;color:#99a;font-weight:normal;text-transform:none;letter-spacing:0;margin-left:6px">
              Optional. Get at <a href="https://www.tornstats.com/profile" target="_blank" style="color:var(--ocm-link)">TornStats → Profile</a>.
            </span>
          </div>
          <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
            <input id="ocm-ts-key-input" type="password" placeholder="TornStats API key" style="flex:1;min-width:160px" />
            <button id="ocm-ts-save-btn" class="ocm-cfg-btn">Save TS Key</button>
            <button id="ocm-ts-fetch-btn" class="ocm-cfg-btn" style="background:#1a3a1a">⬇ Fetch CPR</button>
          </div>
          <div id="ocm-ts-status" style="font-size:11px;color:#99a;margin-top:4px"></div>
        </div>
        <div class="ocm-cfg-section">
          <div class="ocm-cfg-label">CPR Thresholds</div>
          <div class="ocm-cfg-row">
            <label>Warn below <input id="ocm-cfg-cpr-warn" type="number" min="0" max="100" value="${CPR_WARN}" class="ocm-cfg-num" />%</label>
            <label>Crit below <input id="ocm-cfg-cpr-crit" type="number" min="0" max="100" value="${CPR_CRIT}" class="ocm-cfg-num" />%</label>
          </div>
        </div>
        <div class="ocm-cfg-section">
          <div class="ocm-cfg-label">Role Weight Thresholds</div>
          <div class="ocm-cfg-row">
            <label>High ≥ <input id="ocm-cfg-w-high" type="number" min="0" max="100" value="${WEIGHT_HIGH}" class="ocm-cfg-num" />%</label>
            <label>Mid ≥ <input id="ocm-cfg-w-mid" type="number" min="0" max="100" value="${WEIGHT_MID}" class="ocm-cfg-num" />%</label>
          </div>
        </div>
        <div class="ocm-cfg-section">
          <div class="ocm-cfg-label">Auto-refresh Interval</div>
          <div class="ocm-cfg-row">
            <label>Every <input id="ocm-cfg-refresh" type="number" min="30" max="3600" value="${REFRESH_S}" class="ocm-cfg-num" style="width:52px" /> seconds</label>
          </div>
        </div>
        <div class="ocm-cfg-section">
          <div class="ocm-cfg-label">OC Spawn Reminder — min recruiting OCs per difficulty</div>
          <div class="ocm-cfg-row">
            <label>Min per difficulty <input id="ocm-cfg-min-per-diff" type="number" min="0" max="20" value="${MIN_PER_DIFF}" class="ocm-cfg-num" /></label>
            <span style="font-size:10px;color:#99a">Stats bar turns red when any difficulty falls below this value. Set 0 to disable.</span>
          </div>
        </div>
        <div class="ocm-cfg-section" id="ocm-cfg-section-membermode" style="display:none">
          <div class="ocm-cfg-label">View Mode</div>
          <div class="ocm-cfg-row">
            <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
              <input type="checkbox" id="ocm-cfg-force-member" style="cursor:pointer" />
              <span>Force Member Mode</span>
            </label>
          </div>
          <div style="font-size:10px;color:#99a;margin-top:6px;line-height:1.4">
            Your API key has faction-data access, so you can see the full leader dashboard.
            Tick this to preview the simplified Member Mode view (slot recommendations only).
          </div>
        </div>
        <div class="ocm-cfg-section">
          <div class="ocm-cfg-label">Debug</div>
          <div class="ocm-cfg-row">
            <button id="ocm-debug-snapshot-btn" class="ocm-cfg-btn" title="Download a JSON snapshot of the script's current internal state for troubleshooting">📋 Download Debug Snapshot</button>
            <button id="ocm-debug-copy-btn" class="ocm-cfg-btn" title="Copy a debug snapshot to clipboard">📋 Copy to Clipboard</button>
          </div>
          <div style="font-size:10px;color:#99a;margin-top:6px;line-height:1.4">
            Includes script state, computed sections, CPR data sources, and OC structure.
            API keys and full member rosters are redacted — safe to share for troubleshooting.
          </div>
        </div>
        <div style="padding:6px 10px 10px;display:flex;gap:8px">
          <button id="ocm-cfg-save-btn" class="ocm-cfg-btn">💾 Save Settings</button>
          <button id="ocm-cfg-reset-btn" class="ocm-cfg-btn" style="background:#222">↺ Reset Defaults</button>
          <span id="ocm-cfg-status" style="font-size:11px;color:var(--ocm-cpr-good);align-self:center"></span>
        </div>
      </div>
      <div id="ocm-stats-bar" style="display:none">
        <div class="ocm-stats-row">
          <div class="ocm-stat ocm-s-active" title="Number of active OCs (excluding completed/expired)">
            <span class="ocm-stat-label">Active</span>
            <span class="ocm-stat-value" id="ocm-s-active">–</span>
          </div>
          <div class="ocm-stat ocm-s-open" title="Slots across all active OCs with no member assigned yet">
            <span class="ocm-stat-label">Open</span>
            <span class="ocm-stat-value" id="ocm-s-open">–</span>
          </div>
          <div class="ocm-stat ocm-s-free" title="Faction members not currently assigned to any OC — available to fill open slots">
            <span class="ocm-stat-label">Free</span>
            <span class="ocm-stat-value" id="ocm-s-free">–</span>
          </div>
          <div class="ocm-stat ocm-s-lowcpr" title="Filled slots where the member's Checkpoint Pass Rate is below the warn threshold — they may cause failure">
            <span class="ocm-stat-label">⚠ Low CPR</span>
            <span class="ocm-stat-value" id="ocm-s-lowcpr">–</span>
          </div>
          <div class="ocm-stat ocm-s-blocked" title="Members currently in an OC who are jailed, hospitalised, or travelling — OC cannot initiate while any member is blocked">
            <span class="ocm-stat-label">🔴 Blocked</span>
            <span class="ocm-stat-value" id="ocm-s-blocked">–</span>
          </div>
          <div class="ocm-stat ocm-s-stuck" id="ocm-s-stuck-stat" title="OCs where all slots are filled and planning is complete, but initiation is blocked by a jailed/hospitalised/abroad member.">
            <span class="ocm-stat-label">🚨 Stuck</span>
            <span class="ocm-stat-value" id="ocm-s-stuck">–</span>
          </div>
        </div>
        <div class="ocm-stats-row-recruiting" id="ocm-s-recruiting-stat" title="Recruiting OCs per difficulty. Turns red when any difficulty is below the configured minimum.">
          <span class="ocm-stat-label">Recruiting by Diff</span>
          <span id="ocm-s-recruiting" style="display:flex;gap:4px;flex-wrap:wrap;flex:1">–</span>
        </div>
      </div>
      <div id="ocm-body" style="display:none">
        <div id="ocm-error"></div>
        <div id="ocm-stuck-banner" style="display:none"></div>
        <div id="ocm-next-banner"></div>
        <div id="ocm-leader-advice" style="display:none;background:#1e1e1e;border:0.5px solid #2e2e2e;border-radius:6px;padding:8px 12px;margin-bottom:10px;font-size:12px"></div>
        <div class="ocm-tabs" id="ocm-tabs" role="tablist">
          <div class="ocm-tab active" data-tab="action"    role="tab">🎯 Action <span class="ocm-tab-count" id="ocm-tab-count-action">0</span></div>
          <div class="ocm-tab"        data-tab="status"    role="tab">🚫 Status <span class="ocm-tab-count" id="ocm-tab-count-status">0</span></div>
          <div class="ocm-tab"        data-tab="optimize"  role="tab">⚡ Optimize <span class="ocm-tab-count" id="ocm-tab-count-optimize">0</span></div>
          <div class="ocm-tab"        data-tab="ocs"       role="tab">📋 Active OCs <span class="ocm-tab-count" id="ocm-tab-count-ocs">0</span></div>
          <div class="ocm-tab"        data-tab="profit"    role="tab">💰 Profit</div>
          <div class="ocm-tab"        data-tab="reference" role="tab">📚 Reference</div>
        </div>
        <div class="ocm-tab-pane active" id="ocm-pane-action">
          <div class="ocm-section-title collapsed" id="ocm-title-available">Members Available for Assignment <span id="ocm-newsletter-btn" title="Build a newsletter message with each member's recommended slot, copy it to clipboard, and open the newsletter page" onclick="event.stopPropagation()" style="color:var(--ocm-link);cursor:pointer;font-size:13px;margin-left:6px">✉</span></div>
          <div id="ocm-available" style="display:none"></div>
          <div class="ocm-section-title collapsed" id="ocm-title-recruits">🚧 Recruits (cannot join OCs)</div>
          <div id="ocm-recruits" style="display:none"></div>
        </div>
        <div class="ocm-tab-pane" id="ocm-pane-status">
          <div class="ocm-section-title collapsed" id="ocm-title-blocked">Blocked Members (Jail / Hospital / Abroad)</div>
          <div id="ocm-blocked" style="display:none"></div>
        </div>
        <div class="ocm-tab-pane" id="ocm-pane-optimize">
          <div class="ocm-section-title collapsed" id="ocm-title-lowcpr">⚠ Low CPR Members — below ${CPR_WARN}%</div>
          <div id="ocm-lowcpr" style="display:none"></div>
          <div class="ocm-section-title collapsed" id="ocm-title-overqualified">⬆ Underutilized — could run a harder OC</div>
          <div id="ocm-overqualified" style="display:none"></div>
        </div>
        <div class="ocm-tab-pane" id="ocm-pane-ocs">
          <div id="ocm-planning-header" class="ocm-phase-header ocm-phase-planning">⏳ Planning <span id="ocm-planning-count" class="ocm-phase-count"></span><span class="ocm-phase-collapse">▼</span></div>
          <div id="ocm-grid-planning" class="ocm-oc-grid"></div>
          <div id="ocm-recruiting-header" class="ocm-phase-header ocm-phase-recruiting">🔍 Recruiting <span id="ocm-recruiting-count" class="ocm-phase-count"></span><span class="ocm-phase-collapse">▼</span></div>
          <div id="ocm-grid-recruiting" class="ocm-oc-grid"></div>
        </div>
        <div class="ocm-tab-pane" id="ocm-pane-profit">
          <div id="ocm-profit"></div>
        </div>
        <div class="ocm-tab-pane" id="ocm-pane-reference">
          <div class="ocm-section-title collapsed" id="ocm-title-tscpr">📈 TornStats CPR</div>
          <div id="ocm-tscpr" style="display:none"></div>
          <div class="ocm-section-title collapsed" id="ocm-title-analytics">📊 Analytics — Last 100 OCs</div>
          <div id="ocm-analytics" style="display:none"></div>
          <div class="ocm-section-title collapsed" id="ocm-title-downloads">⬇ Downloads</div>
          <div id="ocm-downloads" style="display:none"></div>
        </div>
        <div id="ocm-footer"></div>
      </div>
    `;
    return root;
  }

  // ─── RENDER ──────────────────────────────────────────────────────────────────

  /**
   * Main render function — called after a successful faction API fetch.
   * Builds the complete dashboard from raw API data.
   */
  function renderDashboard(faction, memberMap, armory, itemNames, lastOc, exMemberNames = {}, itemValues = {}, viewerId = null) {
    const crimes = faction.crimes || {};
    // Make sure tab strip is visible (might have been hidden in a previous member-mode render)
    const _tabsEl = document.getElementById('ocm-tabs');
    if (_tabsEl) _tabsEl.style.display = '';

    /** Resolve an item name from itemNames cache or fall back gracefully. */
    function itemName(item) {
      const id = String(item?.id || item?.item_id || '');
      return itemNames[id] || item?.name || (id ? `Item #${id}` : 'Unknown item');
    }

    // Build a quick-lookup of member info indexed by string ID
    const mInfo = {};
    for (const [, m] of Object.entries(memberMap)) {
      const key   = String(m.id);
      const state = m.status?.state || m.status?.description || 'Unknown';
      const desc  = m.status?.description || '';
      mInfo[key] = { name: m.name, status: state, description: desc, recruit: isRecruit(m) };
    }

    // --- First pass: collect aggregate stats
    const assignedIds    = new Set();
    let openSlots        = 0;
    let lowCprCount      = 0;
    let blockedInOcCount = 0;
    const readyOcs       = [];
    const planningOcs    = [];
    const recruitingOcs  = [];
    const blockedOcs     = [];

    // Build OC name → difficulty map for TornStats CPR section
    const crimeDiffMap = {};
    for (const oc of Object.values(crimes)) {
      if (oc?.name && oc?.difficulty != null) crimeDiffMap[oc.name.toLowerCase().trim()] = oc.difficulty;
    }
    window._ocmCrimeDiffMap = crimeDiffMap;

    const ACTIVE = new Set(['recruiting', 'planning', 'ready', 'blocked', 'awaiting', 'initiated', 'executing', 'in progress', 'active']);

    for (const [ocId, oc] of Object.entries(crimes)) {
      if (!oc || typeof oc !== 'object') continue;
      const phase  = (oc.status || '').toLowerCase();
      if (!ACTIVE.has(phase)) continue;
      const ocName = oc.name || `OC #${ocId}`;

      for (const slot of (Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || []))) {
        const user   = slot.user;
        const userId = user?.id ? String(user.id) : null;

        if (userId && !mInfo[userId]) {
          mInfo[userId] = { name: user.name || `#${userId}`, status: user.status?.state || 'Unknown' };
        }

        if (userId) assignedIds.add(userId);
        if (userId && mInfo[userId] && isBlocked(mInfo[userId].status)) blockedInOcCount++;

        const cpr = slot.checkpoint_pass_rate ?? null;
        if (cpr != null && userId && cpr < CPR_WARN) lowCprCount++;
        if (!userId) openSlots++;

        // Backfill tsCprData: if a member is in an OC with a known CPR but
        // TornStats doesn't have data for this crime+role, use the live CPR
        if (cpr != null && userId && oc.name) {
          const rn = (slot.position_info?.label || slot.position || '').replace(/\s*#\d+$/, '');
          if (rn) {
            if (!tsCprData[userId]) tsCprData[userId] = {};
            if (!tsCprData[userId][oc.name]) tsCprData[userId][oc.name] = {};
            if (tsCprData[userId][oc.name][rn] == null) {
              tsCprData[userId][oc.name][rn] = cpr;
            }
          }
        }
      }

      if (phase === 'recruiting')    recruitingOcs.push({ id: ocId, oc });
      else if (phase === 'planning') planningOcs.push({ id: ocId, oc });
      else if (phase === 'ready')    readyOcs.push({ id: ocId, oc });
      else if (phase === 'blocked')  blockedOcs.push({ id: ocId, oc });
    }

    // --- Split free members into recruits vs eligible
    const freeMembers  = [];
    const freeRecruits = [];
    for (const m of Object.values(memberMap)) {
      const mid  = String(m.id);
      const inOc = m.is_in_oc ?? assignedIds.has(mid);
      if (inOc) continue;
      const state = m.status?.state || m.status?.description || 'Unknown';
      if (isRecruit(m)) {
        freeRecruits.push({ id: mid, name: m.name, status: state });
      } else {
        freeMembers.push({ id: mid, name: m.name, status: state });
      }
    }

    // --- Recruiting OC counts per difficulty for spawn reminder stat
    const recruitingByDiff = {};
    for (const { oc } of recruitingOcs) {
      const diff = String(oc.difficulty ?? '?');
      recruitingByDiff[diff] = (recruitingByDiff[diff] || 0) + 1;
    }
    // Check if any known difficulty is below the minimum threshold
    const anyBelowMin = MIN_PER_DIFF > 0 && Object.values(recruitingByDiff).some(v => v < MIN_PER_DIFF);
    const recruitStatEl = document.getElementById('ocm-s-recruiting-stat');
    const sortedDiffs = Object.keys(recruitingByDiff).sort((a, b) => Number(a) - Number(b));
    const recruitChipHtml = sortedDiffs.length
      ? sortedDiffs.map(d => {
          const count = recruitingByDiff[d];
          const danger = MIN_PER_DIFF > 0 && count < MIN_PER_DIFF;
          return `<span class="ocm-recruiting-chip${danger ? ' danger' : ''}" title="D${d}: ${count} recruiting OC${count===1?'':'s'}">D${d}·${count}</span>`;
        }).join('')
      : '<span style="color:#556;font-size:11px">–</span>';
    document.getElementById('ocm-s-recruiting').innerHTML = recruitChipHtml;
    if (recruitStatEl) {
      const title = anyBelowMin
        ? `⚠ One or more difficulty levels has fewer than ${MIN_PER_DIFF} recruiting OC(s). Consider spawning more.`
        : 'Recruiting OCs per difficulty. All levels above minimum threshold.';
      recruitStatEl.title = title;
    }

    // --- Update stats bar
    const activeOcCount = Object.values(crimes).filter(oc => oc && ACTIVE.has((oc.status||'').toLowerCase())).length;
    document.getElementById('ocm-s-active').textContent  = activeOcCount;
    document.getElementById('ocm-s-open').textContent    = openSlots;
    document.getElementById('ocm-s-lowcpr').textContent  = lowCprCount;
    document.getElementById('ocm-s-blocked').textContent = blockedInOcCount;
    document.getElementById('ocm-s-free').textContent    = freeMembers.length;
    document.getElementById('ocm-stats-bar').style.display = 'flex';
    document.getElementById('ocm-body').style.display       = 'block';

    // 'now' used throughout the rest of renderDashboard — defined once here
    const now = Math.floor(Date.now() / 1000);

    // --- Detect stuck OCs:
    // An OC is "stuck" when every slot is filled, all planning progress is
    // complete (progress >= 100 for all members), and at least one member is
    // jailed, hospitalised, or abroad — preventing initiation.
    const stuckOcs = [];
    for (const [ocId, oc] of Object.entries(crimes)) {
      if (!oc || typeof oc !== 'object') continue;
      const phase = (oc.status || '').toLowerCase();
      // Only consider planning-phase OCs (ready, planning, blocked)
      if (!['planning', 'ready', 'blocked', 'awaiting'].includes(phase)) continue;
      const ocSlots = Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || []);
      // All slots must be filled
      if (ocSlots.some(s => !s.user?.id)) continue;
      // All members must have completed planning (progress >= 100, or field absent = done)
      const allPlanned = ocSlots.every(s => (s.user?.progress ?? 100) >= 100);
      if (!allPlanned) continue;
      // At least one member must be blocked
      const blockers = ocSlots
        .map(s => {
          const uid  = String(s.user.id);
          const info = mInfo[uid];
          if (!info || !isBlocked(info.status)) return null;
          return { uid, name: info.name, status: info.status, description: info.description || '' };
        })
        .filter(Boolean);
      if (blockers.length === 0) continue;
      stuckOcs.push({ id: ocId, oc, blockers });
    }

    // --- Update stuck OC stat
    const stuckStatEl = document.getElementById('ocm-s-stuck-stat');
    document.getElementById('ocm-s-stuck').textContent = stuckOcs.length;
    if (stuckStatEl) {
      stuckStatEl.classList.toggle('ocm-stat-warn', stuckOcs.length > 0);
      stuckStatEl.title = stuckOcs.length > 0
        ? `⚠ ${stuckOcs.length} OC${stuckOcs.length > 1 ? 's are' : ' is'} fully planned and filled but cannot initiate — a member is unavailable.`
        : 'No stuck OCs — all fully planned OCs can initiate.';
    }

    // --- Render stuck OC banner
    const stuckBannerEl = document.getElementById('ocm-stuck-banner');
    if (stuckOcs.length === 0) {
      stuckBannerEl.style.display = 'none';
    } else {
      stuckBannerEl.style.display = 'block';
      const cardsHtml = stuckOcs.map(({ id, oc, blockers }) => {
        const expiredAt = oc.expired_at ?? null;
        const expiryHtml = expiredAt
          ? (() => {
              const secsLeft = expiredAt - now;
              const urgCol   = secsLeft < 3600  ? 'var(--ocm-cpr-crit)'
                             : secsLeft < 86400 ? 'var(--ocm-cpr-warn)'
                             : 'var(--ocm-cpr-warn)';
              return secsLeft > 0
                ? `<span class="ocm-stuck-expiry" style="color:${urgCol}">Expires in <span class="ocm-time" data-until="${expiredAt}">${fmtTime(secsLeft)}</span></span>`
                : `<span class="ocm-stuck-expiry" style="color:var(--ocm-cpr-crit)">Expired</span>`;
            })()
          : '';

        const blockersHtml = blockers.map(b => {
          const s           = (b.status || '').toLowerCase();
          let statusLabel;
          if (s === 'abroad' || s === 'traveling') {
            statusLabel = travelInfo(b.status, b.description).label;
          } else {
            const icons = { hospital: '🏥', jail: '⛓' };
            statusLabel = `${icons[s] || '❓'} ${b.status}`;
          }
          return `<div class="ocm-stuck-blocker">
            ↳ <a href="/profiles.php?XID=${b.uid}" target="_blank">${b.name}</a>
            <span style="color:var(--ocm-cpr-crit)">${statusLabel}</span>
            ${b.description ? `<span style="color:#666;font-size:10px" title="${b.description}">${b.description.length > 40 ? b.description.slice(0,38)+'…' : b.description}</span>` : ''}
          </div>`;
        }).join('');

        return `<div class="ocm-stuck-card">
          <div class="ocm-stuck-card-title">
            <strong>${oc.name || `OC #${id}`}</strong>
            <span class="ocm-stuck-diff">D${oc.difficulty ?? '?'} · ${(Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || [])).length} slots · fully planned</span>
            ${expiryHtml}
          </div>
          ${blockersHtml}
        </div>`;
      }).join('');

      stuckBannerEl.innerHTML = `
        <div class="ocm-stuck-header">
          🚨 Stuck OCs — cannot initiate
          <span>${stuckOcs.length} OC${stuckOcs.length > 1 ? 's are' : ' is'} ready but blocked by an unavailable member</span>
        </div>
        ${cardsHtml}`;
    }

    // --- Build OC card grids
    const gridPlanning   = document.getElementById('ocm-grid-planning');
    const gridRecruiting = document.getElementById('ocm-grid-recruiting');
    gridPlanning.innerHTML   = '';
    gridRecruiting.innerHTML = '';

    /** Compute a sort key for an OC so urgent/imminent ones sort first. */
    function ocSortKey(oc) {
      const now       = Math.floor(Date.now() / 1000);
      const ocSlots   = Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || []);
      const openCount = ocSlots.filter(s => !s.user).length;
      if (oc.executed_at && oc.executed_at > now) return oc.executed_at;
      if (oc.ready_at    && oc.ready_at    > now) return oc.ready_at;
      if (oc.time_left > 0) return now + oc.time_left + (openCount * 24 * 3600);
      if (openCount > 0)    return now + (openCount * 24 * 3600);
      if (oc.expired_at)    return oc.expired_at;
      return Infinity;
    }

    const planningAll = [
      ...readyOcs.map(o    => ({ ...o, cardClass: 'ocm-card-ready',   badgeClass: 'badge-ready',    badgeLabel: 'READY' })),
      ...blockedOcs.map(o  => ({ ...o, cardClass: 'ocm-card-blocked', badgeClass: 'badge-blocked',  badgeLabel: 'BLOCKED' })),
      ...planningOcs.map(o => ({ ...o, cardClass: '',                 badgeClass: 'badge-planning', badgeLabel: 'PLANNING' })),
    ].sort((a, b) => ocSortKey(a.oc) - ocSortKey(b.oc));

    const recruitingAll = recruitingOcs
      .map(o => ({ ...o, cardClass: 'ocm-card-warn', badgeClass: 'badge-recruiting', badgeLabel: 'RECRUITING' }))
      .sort((a, b) => ocSortKey(a.oc) - ocSortKey(b.oc));

    // --- Update phase header labels
    const planningDiff = {};
    for (const { oc } of planningAll) {
      const diff = oc.difficulty ?? '?';
      planningDiff[diff] = (planningDiff[diff] || 0) + 1;
    }
    const planningBreakdownHtml = Object.keys(planningDiff)
      .sort((a, b) => Number(a) - Number(b))
      .map(diff => {
        const count = planningDiff[diff];
        return `<span class="ocm-diff-chip ocm-diff-chip-plan">D${diff}: <strong>${count}</strong> OC${count !== 1 ? 's' : ''}</span>`;
      }).join('');
    const planningHeader = document.getElementById('ocm-planning-header');
    planningHeader.innerHTML = `⏳ Planning <span class="ocm-phase-count">(${planningAll.length})</span>${planningBreakdownHtml ? '<span class="ocm-diff-sep" style="color:var(--ocm-link)">—</span>' + planningBreakdownHtml : ''}<span class="ocm-phase-collapse">▼</span>`;

    const diffBreakdown = {};
    for (const { oc } of recruitingAll) {
      const diff = oc.difficulty ?? '?';
      if (!diffBreakdown[diff]) diffBreakdown[diff] = { ocs: 0, slots: 0 };
      diffBreakdown[diff].ocs++;
      const ocSlotList = Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || []);
      diffBreakdown[diff].slots += ocSlotList.filter(s => !s.user).length;
    }
    const breakdownHtml = Object.keys(diffBreakdown)
      .sort((a, b) => Number(a) - Number(b))
      .map(diff => {
        const { ocs, slots } = diffBreakdown[diff];
        return `<span class="ocm-diff-chip">D${diff}: <strong>${ocs}</strong> OC${ocs !== 1 ? 's' : ''} · <strong>${slots}</strong> slot${slots !== 1 ? 's' : ''}</span>`;
      }).join('');
    const recruitingHeader = document.getElementById('ocm-recruiting-header');
    recruitingHeader.innerHTML = `🔍 Recruiting <span class="ocm-phase-count">(${recruitingAll.length})</span>${breakdownHtml ? '<span class="ocm-diff-sep">—</span>' + breakdownHtml : ''}<span class="ocm-phase-collapse">▼</span>`;

    // --- Next OC banner
    const nextOc = planningAll.length > 0 ? planningAll[0].oc : null;
    const bannerEl = document.getElementById('ocm-next-banner');

    if (nextOc) {
      const ocSlotList = Array.isArray(nextOc.slots) ? nextOc.slots : Object.values(nextOc.slots || []);
      const executesAt = (nextOc.executed_at && nextOc.executed_at > now ? nextOc.executed_at : null)
                      ?? (nextOc.ready_at    && nextOc.ready_at    > now ? nextOc.ready_at    : null);
      const timeLeft   = nextOc.time_left ?? null;
      const openCount  = ocSlotList.filter(s => !s.user).length;

      let timeDisplay;
      if (executesAt) {
        const tctStr  = new Date(executesAt * 1000).toLocaleTimeString('en-GB', { timeZone: 'UTC', hour: '2-digit', minute: '2-digit', hour12: false });
        const tctDate = new Date(executesAt * 1000).toLocaleDateString('en-GB', { timeZone: 'UTC', day: '2-digit', month: 'short' });
        const openExtra = openCount > 0 ? ` + ~${fmtTime(openCount * 24 * 3600)} (${openCount} open)` : '';
        timeDisplay = `<span class="ocm-time" data-until="${executesAt}">${fmtTime(executesAt - now)}</span>${openExtra} <span style="opacity:.6;font-size:11px">(${tctDate} ${tctStr} TCT)</span>`;
      } else if (timeLeft > 0) {
        const openExtra = openCount > 0 ? ` + ~${fmtTime(openCount * 24 * 3600)} (${openCount} open)` : '';
        timeDisplay = `~${fmtTime(timeLeft)}${openExtra} <span style="opacity:.6;font-size:11px">(paused)</span>`;
      } else if (openCount > 0) {
        timeDisplay = `~${fmtTime(openCount * 24 * 3600)} <span style="opacity:.6;font-size:11px">(${openCount} slot${openCount > 1 ? 's' : ''} × 24h est.)</span>`;
      } else {
        timeDisplay = `<span style="color:var(--ocm-cpr-good);font-weight:bold">Ready to initiate!</span>`;
      }

      const issues = [];
      for (const slot of ocSlotList) {
        const uid      = slot.user?.id ? String(slot.user.id) : null;
        const info     = uid ? mInfo[uid] : null;
        const slotRole = slot.position_info?.label || slot.position || 'Unknown';
        const slotCpr  = slot.checkpoint_pass_rate ?? null;
        const slotW    = getWeight(nextOc.name || '', slotRole);

        if (!uid) {
          issues.push({ sev: 'crit', msg: `Open slot: ${slotRole}` });
        } else if (info && isBlocked(info.status)) {
          issues.push({ sev: 'crit', msg: `${info.name} — ${info.status}` });
        }
        const req = slot.item_requirement;
        if (req && uid && !req.is_available && !armory[String(req.id)]) {
          issues.push({ sev: 'warn', msg: `${info?.name || uid} missing: ${itemName(req)}` });
        }
        if (slotW != null && slotW >= WEIGHT_HIGH && slotCpr != null && slotCpr < CPR_WARN && uid) {
          issues.push({ sev: 'warn', msg: `${info?.name || uid} — low CPR (${slotCpr}%) in high-weight role ${slotRole} (${slotW.toFixed(0)}%)` });
        }
      }

      const hasCritIssue = issues.some(i => i.sev === 'crit');
      const hasWarnIssue = issues.some(i => i.sev === 'warn');
      const bannerClass  = hasCritIssue ? 'banner-crit' : hasWarnIssue ? 'banner-warn' : 'banner-ok';
      const bannerIcon   = hasCritIssue ? '🔴' : hasWarnIssue ? '⚠️' : '✅';

      const issuesHtml = issues.length > 0
        ? `<div class="ocm-banner-issues">${issues.map(i => `<span class="ocm-banner-issue">${i.sev === 'crit' ? '🔴' : '⚠️'} ${i.msg}</span>`).join('')}</div>`
        : `<div style="font-size:11px;margin-top:3px;opacity:.7">No issues — ready to initiate on schedule.</div>`;

      bannerEl.className = bannerClass;
      bannerEl.style.display = 'block';
      bannerEl.innerHTML = `
        <div class="ocm-banner-title">${bannerIcon} Next OC: <strong>${nextOc.name}</strong> &nbsp;·&nbsp; ${timeDisplay}</div>
        ${issuesHtml}`;

      GM_setValue('ocm_sidebar_cache', JSON.stringify({
        name:       nextOc.name,
        executesAt: executesAt ?? null,
        severity:   hasCritIssue ? 'crit' : hasWarnIssue ? 'warn' : 'ok',
        issues:     issues.slice(0, 3),
        cachedAt:   now,
      }));

      // --- Leader slot advice ("your best open slot")
      // An OPEN slot has no CPR of its own (nobody's in it), so we look up the
      // VIEWER's own predicted CPR for that crime+role via TornStats data. Without
      // a viewer ID or CPR data we can't recommend, so the advisory hides instead
      // of showing a meaningless 0%.
      const leaderAdvisoryEl = document.getElementById('ocm-leader-advice');
      if (leaderAdvisoryEl) {
        const myId = viewerId || GM_getValue('ocm_my_player_id', '') || null;
        const haveTs = myId && Object.keys(tsCprData).length > 0;
        const leaderSlots = [];
        if (haveTs) {
          for (const [ocId, oc] of Object.entries(crimes)) {
            if (!oc || (oc.status || '').toLowerCase() !== 'recruiting') continue;
            const ocSlots = Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || []);
            for (const slot of ocSlots) {
              if (slot.user?.id) continue;
              const role   = slot.position_info?.label || slot.position || 'Unknown';
              // Look up the viewer's predicted CPR for this exact crime+role
              const tsRes  = tsGetCpr(myId, oc.name || '', role);
              const cpr    = tsRes?.cpr ?? null;
              if (cpr == null) continue; // no CPR data for you in this role — skip
              const weight = getWeight(oc.name || '', role);
              leaderSlots.push({ ocName: oc.name || `OC #${ocId}`, ocId: oc.id ?? ocId, role, cpr, weight, difficulty: oc.difficulty ?? '?', expiredAt: oc.expired_at ?? null, timeLeft: oc.time_left ?? null });
            }
          }
        }
        if (leaderSlots.length === 0) {
          // Either we don't know who the viewer is, have no TS CPR data, or none of
          // the open roles have a CPR prediction for them. Hide rather than mislead.
          leaderAdvisoryEl.style.display = 'none';
        } else {
          const nowTs2 = Math.floor(Date.now() / 1000);
          function urgencyBonusL(s) {
            let bonus = 0;
            if (s.expiredAt) {
              const secsToExpiry = s.expiredAt - nowTs2;
              if (secsToExpiry > 0 && secsToExpiry < 6  * 3600) bonus += 500;
              else if (secsToExpiry > 0 && secsToExpiry < 24 * 3600) bonus += 200;
            }
            if (s.timeLeft != null) {
              if (s.timeLeft < 12 * 3600) bonus += 100;
              else if (s.timeLeft < 24 * 3600) bonus +=  50;
            }
            return Math.min(bonus, 999);
          }
          const scored = leaderSlots.map(s => {
            const cpr    = s.cpr ?? 0;
            const weight = s.weight ?? 15;
            const diff   = Number(s.difficulty) || 0;
            const eligible    = cpr >= CPR_WARN;
            const comfort     = eligible ? Math.max(0, (cpr - CPR_WARN) / (100 - CPR_WARN)) : 0;
            const weightBonus = weight * comfort;
            let tag = null;
            if (cpr < CPR_CRIT)           tag = 'risky';
            else if (cpr < CPR_WARN)      tag = 'marginal';
            else if (weight < WEIGHT_MID) tag = 'underutilised';
            else                          tag = 'good';
            const score = eligible
              ? diff * 1000 + urgencyBonusL(s) + weightBonus + cpr
              : -(1000 - cpr);
            return { ...s, score, tag, eligible, urgent: urgencyBonusL(s) > 0 };
          }).sort((a, b) => b.score - a.score);
          const top    = scored[0];
          const cprCol = top.cpr == null ? '#555' : top.cpr >= CPR_WARN ? 'var(--ocm-cpr-good)' : top.cpr >= CPR_CRIT ? 'var(--ocm-cpr-warn)' : 'var(--ocm-cpr-crit)';
          const wCol   = top.weight == null ? '#555' : top.weight >= WEIGHT_HIGH ? 'var(--ocm-cpr-warn)' : top.weight >= WEIGHT_MID ? '#aaa' : '#555';
          const tagLabel = top.tag === 'good'
            ? '<span style="font-size:10px;background:#14201a;color:var(--ocm-cpr-good);border-radius:3px;padding:1px 5px">&#10003; Good fit</span>'
            : top.tag === 'underutilised'
              ? '<span style="font-size:10px;background:#1c1810;color:var(--ocm-cpr-warn);border-radius:3px;padding:1px 5px">&#9432; Low-weight role</span>'
              : top.tag === 'marginal'
                ? '<span style="font-size:10px;background:#1c1810;color:var(--ocm-cpr-warn);border-radius:3px;padding:1px 5px">&#9888; Marginal CPR</span>'
                : '<span style="font-size:10px;background:#1c1410;color:var(--ocm-cpr-crit);border-radius:3px;padding:1px 5px">&#9888; Below threshold</span>';
          const urgLabel2 = top.urgent ? (() => {
            const secsLeft = top.expiredAt ? top.expiredAt - Math.floor(Date.now()/1000) : null;
            return secsLeft != null && secsLeft < 6 * 3600
              ? '<span style="font-size:10px;background:#181810;color:var(--ocm-cpr-warn);border-radius:3px;padding:1px 5px">&#9201; Expires soon</span>'
              : secsLeft != null && secsLeft < 24 * 3600
                ? '<span style="font-size:10px;background:#181810;color:var(--ocm-cpr-warn);border-radius:3px;padding:1px 5px">&#9201; Expiring today</span>'
                : '<span style="font-size:10px;background:#181810;color:var(--ocm-cpr-warn);border-radius:3px;padding:1px 5px">&#9201; Nearly ready</span>';
          })() : '';
          leaderAdvisoryEl.style.display = 'block';
          leaderAdvisoryEl.innerHTML = `
            <div style="font-size:10px;color:var(--ocm-link);text-transform:uppercase;letter-spacing:.08em;font-weight:bold;margin-bottom:6px">Your best open slot</div>
            <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px">
              <span style="font-weight:bold;color:#e0e0e0">${top.role}</span>
              <span style="color:#667">in</span>
              <span style="color:#aac;font-weight:bold">${top.ocName}</span>
              <span style="color:#556;font-size:10px">D${top.difficulty}</span>
              ${tagLabel}${urgLabel2}
              <span style="margin-left:auto;display:flex;gap:10px;font-size:11px">
                <span style="color:#778">CPR: <strong style="color:${cprCol}">${top.cpr != null ? top.cpr+'%' : '?'}</strong></span>
                <span style="color:#778">Weight: <strong style="color:${wCol}">${top.weight != null ? top.weight.toFixed(0)+'%' : '?'}</strong></span>
              </span>
            </div>
            ${scored.length > 1 ? `<div style="font-size:10px;color:#445;margin-top:6px">${scored.length} open slots total — showing best fit</div>` : ''}`;
        }
      }
    } else {
      bannerEl.style.display = 'none';
      GM_setValue('ocm_sidebar_cache', '');
    }

    // Re-apply collapse state to phase headers after their content is refreshed
    ['ocm-planning-header','ocm-recruiting-header'].forEach(id => {
      const el   = document.getElementById(id);
      const grid = document.getElementById(id === 'ocm-planning-header' ? 'ocm-grid-planning' : 'ocm-grid-recruiting');
      if (el && grid && grid.style.display === 'none') el.classList.add('collapsed');
    });
    if (recruitingAll.length === 0) gridRecruiting.innerHTML = '<div class="ocm-empty-phase">No OCs currently recruiting.</div>';

    const allOcs = [...planningAll, ...recruitingAll];
    if (allOcs.length === 0) gridPlanning.innerHTML = '<div class="ocm-empty-phase">No active OCs found.</div>';

    // --- Render individual OC cards
    for (const { id, oc, cardClass, badgeClass, badgeLabel } of allOcs) {
      const isRecruiting = badgeLabel === 'RECRUITING';
      const targetGrid   = isRecruiting ? gridRecruiting : gridPlanning;

      const slots    = Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || []);
      const hasLow   = slots.some(s => s.user?.id && s.checkpoint_pass_rate != null && s.checkpoint_pass_rate < CPR_WARN);
      const hasCrit  = slots.some(s => s.user?.id && s.checkpoint_pass_rate != null && s.checkpoint_pass_rate < CPR_CRIT);
      const hasBlock = slots.some(s => { const uid = s.user?.id ? String(s.user.id) : null; return uid && mInfo[uid] && isBlocked(mInfo[uid].status); });

      let finalClass = cardClass;
      if (hasCrit)       finalClass = 'ocm-card-crit';
      else if (hasBlock) finalClass = 'ocm-card-blocked';
      else if (hasLow && !cardClass) finalClass = 'ocm-card-warn';

      const card = document.createElement('div');
      card.className = `ocm-card ${finalClass}`;

      const executesAt         = oc.executed_at ?? oc.ready_at ?? null;
      const timeLeft           = oc.time_left ?? null;
      const expiredAt          = oc.expired_at ?? null;
      const ocSlots            = Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || []);
      const openCount          = ocSlots.filter(s => !s.user).length;

      let timerHtml = '';
      if (executesAt && executesAt > now) {
        const secsLeft  = executesAt - now;
        const tctStr    = new Date(executesAt * 1000).toLocaleTimeString('en-GB', { timeZone: 'UTC', hour: '2-digit', minute: '2-digit', hour12: false });
        const tctDate   = new Date(executesAt * 1000).toLocaleDateString('en-GB', { timeZone: 'UTC', day: '2-digit', month: 'short' });
        const openExtra = openCount > 0
          ? ` <span style="color:#666;font-size:10px">+ ~${fmtTime(openCount * 24 * 3600)} (${openCount} open slot${openCount > 1 ? 's' : ''})</span>`
          : '';
        timerHtml = `<div class="ocm-timer" title="Executes at ${tctDate} ${tctStr} TCT">⏱ <span class="ocm-time" data-until="${executesAt}">${fmtTime(secsLeft)}</span>${openExtra} <span style="color:#555;font-size:10px">(${tctDate} ${tctStr} TCT)</span></div>`;
      } else if (timeLeft > 0) {
        const openExtra = openCount > 0 ? ` + ~${fmtTime(openCount * 24 * 3600)} for ${openCount} open slot${openCount > 1 ? 's' : ''}` : '';
        timerHtml = `<div class="ocm-timer" style="color:#888">⏸ ${fmtTime(timeLeft)} remaining (paused)${openExtra}</div>`;
      } else if (openCount > 0) {
        timerHtml = `<div class="ocm-timer" style="color:#888">⏸ ~${fmtTime(openCount * 24 * 3600)} est. remaining (${openCount} slot${openCount > 1 ? 's' : ''} × 24h)</div>`;
      } else if (expiredAt) {
        const secsToExpiry = expiredAt - now;
        const expTctStr    = new Date(expiredAt * 1000).toLocaleTimeString('en-GB', { timeZone: 'UTC', hour: '2-digit', minute: '2-digit', hour12: false });
        const expTctDate   = new Date(expiredAt * 1000).toLocaleDateString('en-GB', { timeZone: 'UTC', day: '2-digit', month: 'short' });
        const urgency      = secsToExpiry < 86400 ? 'color:var(--ocm-cpr-warn)' : 'color:#666';
        timerHtml = `<div class="ocm-timer" style="${urgency}">⏳ Expires in <span class="ocm-time" data-until="${expiredAt}">${secsToExpiry > 0 ? fmtTime(secsToExpiry) : 'Expired'}</span> <span style="color:#555;font-size:10px">(${expTctDate} ${expTctStr} TCT)</span></div>`;
      } else {
        timerHtml = `<div class="ocm-timer" style="color:#555">⏸ No timer info</div>`;
      }

      // Planning progress: find the most recently-joined still-planning member
      const filledSlots   = ocSlots.filter(s => s.user);
      const inProgress    = filledSlots.filter(s => (s.user.progress ?? 100) < 100);
      const activePlanner = inProgress.length > 0
        ? inProgress.reduce((a, b) => (a.user.joined_at ?? 0) < (b.user.joined_at ?? 0) ? a : b)
        : null;

      const sortedSlots = [...ocSlots].sort((a, b) => {
        const pa = a.user ? (a.user.progress ?? 0) : -1;
        const pb = b.user ? (b.user.progress ?? 0) : -1;
        return pb - pa;
      });

      const slotsHtml = sortedSlots.map(slot => {
        const user       = slot.user;
        const userId     = user?.id ? String(user.id) : null;
        const member     = userId ? mInfo[userId] : null;
        const memberName = member
          ? `<a href="/profiles.php?XID=${userId}" target="_blank" style="color:#ccc;text-decoration:none">${member.name}</a>`
          : '<span style="color:#555">Open slot</span>';
        const slotStatusHtml = member
          ? statusIcon(member.status, member.description)
          : `<span class="ocm-slot-status status-open" title="No member assigned">✗</span>`;
        const cpr     = slot.checkpoint_pass_rate ?? null;
        const cprText = cpr != null ? `${cpr}%` : (userId ? '?' : '–');
        const cprCls  = cpr != null ? cprClass(cpr) : 'cpr-empty';
        const roleName = slot.position_info?.label || slot.position || 'Unknown role';

        let progressHtml = '';
        if (userId) {
          const progress  = user.progress ?? null;
          const isDone    = progress >= 100;
          const isActive  = activePlanner && slot.user?.id === activePlanner.user?.id;
          const pct       = Math.min(100, Math.max(0, progress ?? 0));
          const fillClass = isDone ? 'progress-done' : isActive ? 'progress-active' : 'progress-waiting';
          const tip       = isDone ? 'Planning complete' : isActive ? `Actively planning — ${pct.toFixed(1)}%` : `Waiting — ${pct.toFixed(1)}%`;
          progressHtml = `<div class="ocm-progress-wrap" title="${tip}"><div class="ocm-progress-fill ${fillClass}" style="width:${pct}%"></div></div>`;
        } else {
          progressHtml = `<div class="ocm-progress-wrap" title="No member assigned"><div class="ocm-progress-fill progress-waiting" style="width:0%"></div></div>`;
        }

        const req = slot.item_requirement;
        let itemBadge = '<span style="flex:0 0 22px;display:inline-block"></span>';
        if (req) {
          const st      = !userId ? 'open' : req.is_available ? 'ok' : armory[String(req.id)] ? 'armory' : 'missing';
          const name    = itemName(req);
          const isTool  = req.is_reusable ?? false;
          const tips    = { ok:'Has item', armory:'In armory — needs to loan', missing:'MISSING — needs sourcing', open:'Item needed when slot is filled' };
          const icons   = { ok:'✓', armory:'🏛', missing:'✗', open:'?' };
          const classes = { ok:'item-ok', armory:'item-armory', missing:'item-missing', open:'item-unknown' };
          const marketUrl = `https://www.torn.com/page.php?sid=ItemMarket#/market/view=search&itemID=${req.id}`;
          const tipText  = `${tips[st]||''}\n${name}\n${isTool ? '🔧 Tool (reusable)' : '📦 Material (consumed)'}\nClick to open item market`;
          itemBadge = `<a class="ocm-item-tag ${classes[st]||'item-unknown'}" href="${marketUrl}" target="_blank" title="${tipText}">${icons[st]||'?'}${isTool?'🔧':'📦'}</a>`;
        }

        const weight   = getWeight(oc.name || '', roleName);
        let weightHtml = '<span class="ocm-slot-weight"></span>';
        if (weight != null) {
          const wCls = weight >= WEIGHT_HIGH ? 'w-high' : weight >= WEIGHT_MID ? 'w-mid' : 'w-low';
          weightHtml = `<span class="ocm-slot-weight ${wCls}" title="Role weight: ${weight.toFixed(1)}% — how much this role influences overall success">${weight.toFixed(0)}%</span>`;
        }

        const isRisk  = weight != null && weight >= WEIGHT_HIGH && cpr != null && cpr < CPR_WARN && userId;
        const riskCls = isRisk ? 'ocm-slot-risk' : '';
        const riskIcon = isRisk
          ? `<span title="⚠ High-weight role (${weight.toFixed(0)}%) with low CPR (${cpr}%) — significant risk to OC success" style="font-size:11px;cursor:help">⚠</span>`
          : '';

        // TornStats CPR for this member in this specific OC+role
        let tsCprHtml = '';
        if (userId && Object.keys(tsCprData).length > 0) {
          const tsResult = tsGetCpr(userId, oc.name || '', roleName);
          if (tsResult != null) {
            const tsCol = tsResult.cpr >= CPR_WARN ? 'var(--ocm-cpr-good)' : tsResult.cpr >= CPR_CRIT ? 'var(--ocm-cpr-warn)' : 'var(--ocm-cpr-crit)';
            const label = tsResult.exact ? `TS:${tsResult.cpr}%` : `TS:~${tsResult.cpr}%`;
            const tip = tsResult.exact
              ? `TornStats CPR for ${roleName}: ${tsResult.cpr}%`
              : `TornStats CPR (approx — no exact role match): ${tsResult.cpr}%`;
            tsCprHtml = `<span class="ocm-ts-badge" style="color:${tsCol}" title="${tip}">${label}</span>`;
          }
        }

        return `
          <div class="ocm-slot ${riskCls}">
            ${slotStatusHtml}
            <span class="ocm-slot-role" title="${roleName}">${roleName}</span>
            <span class="ocm-slot-member">${memberName}</span>${tsCprHtml}
            ${progressHtml}
            ${riskIcon}
            ${itemBadge}
            ${weightHtml}
            <span class="ocm-slot-cpr ${cprCls}">${cprText}</span>
          </div>`;
      }).join('');

      const level = oc.difficulty ?? '?';
      card.innerHTML = `
        <div class="ocm-card-title">
          <span>${oc.name || `OC #${id}`}</span>
          <span class="ocm-badge ${badgeClass}">${badgeLabel}</span>
        </div>
        <div class="ocm-card-subtitle">Difficulty ${level} · ${slots.length} slots</div>
        ${timerHtml}
        <div class="ocm-slots">${slotsHtml}</div>
      `;
      targetGrid.appendChild(card);
    }

    // ── Available members (non-recruits, not in OC)
    const availTitle = document.getElementById('ocm-title-available');
    const avail      = document.getElementById('ocm-available');
    if (availTitle) {
      const mailLink = availTitle.querySelector('a');
      availTitle.textContent = `Members Available for Assignment (${freeMembers.length})`;
      if (mailLink) availTitle.appendChild(mailLink);
    }

    function fmtRelative(ts) {
      if (!ts) return null;
      const diff = now - ts;
      if (diff < 3600)      return { text: `${Math.floor(diff / 60)}m ago`,   cls: 'ocm-seen-recent' };
      if (diff < 86400)     return { text: `${Math.floor(diff / 3600)}h ago`,  cls: 'ocm-seen-recent' };
      if (diff < 86400 * 7) return { text: `${Math.floor(diff / 86400)}d ago`, cls: 'ocm-seen-day' };
      return { text: `${Math.floor(diff / 86400)}d ago`, cls: 'ocm-seen-old' };
    }

    function fmtOcRelative(ts) {
      if (!ts) return null;
      const diff = now - ts;
      if (diff < 43200)  return { text: `${diff < 3600 ? Math.floor(diff/60)+'m' : Math.floor(diff/3600)+'h'} ago`, cls: 'ocm-oc-recent' };
      if (diff < 86400)  return { text: `${Math.floor(diff / 3600)}h ago`, cls: 'ocm-oc-warn' };
      return { text: `${Math.floor(diff / 86400)}d ago`, cls: 'ocm-oc-old' };
    }

    /** For an available member, find their best-fit open slot across all recruiting OCs.
     *  Uses TornStats CPR for matching, falls back to role weight only.
     *  Returns { ocName, role, cpr, weight, difficulty, score } or null. */
    /** Score a single (member, openSlot) candidate. Higher = better fit. */
    function scoreSlotForMember(memberId, oc, ocId, slot) {
      const role     = slot.position_info?.label || slot.position || 'Unknown';
      const ocName   = oc.name || `OC #${ocId}`;
      const tsResult = tsGetCpr(memberId, ocName, role);
      const cpr      = tsResult ? tsResult.cpr : null;
      const exact    = tsResult ? tsResult.exact : false;
      const source   = tsResult ? tsResult.source : null;
      const weight   = getWeight(ocName, role);
      const diff     = Number(oc.difficulty) || 0;
      // Item requirement (if any) and whether the faction armory has it
      const req         = slot.item_requirement;
      const itemMissing = !!(req && req.is_available === false);
      const itemName    = req ? (itemNames[String(req.id)] || `Item #${req.id}`) : null;
      const eligible    = cpr != null && cpr >= CPR_WARN;
      const comfort     = eligible ? Math.max(0, (cpr - CPR_WARN) / (100 - CPR_WARN)) : 0;
      const weightBonus = (weight ?? 15) * comfort;
      // Slight score penalty if item is missing so a recommendation with
      // the item available is preferred over one without (other things equal).
      const itemPenalty = itemMissing ? 25 : 0;
      const score       = eligible
        ? diff * 1000 + weightBonus + cpr + (exact ? 50 : 0) - itemPenalty
        : (cpr != null ? -(100 - cpr) : -10000);
      return { ocName, ocId, role, cpr, weight, difficulty: oc.difficulty ?? '?', score, exact, eligible, source, itemMissing, itemName };
    }

    /** Build the global assignment of available members → unique open slots.
     *  Greedy: at each step pick the highest-scoring eligible (member, slot) pair,
     *  lock both, and repeat. A member can only be assigned one slot, and each
     *  slot only goes to one member. Members without an eligible match still
     *  get a fallback recommendation (their highest-scoring non-eligible slot)
     *  so they aren't left empty-handed.
     *  Returns Map<memberId, slotInfo>. */
    function buildBestSlotAssignment(memberIds) {
      // Collect all open slots
       const openSlots = [];
      for (const [ocId, oc] of Object.entries(crimes)) {
        if (!oc || (oc.status || '').toLowerCase() !== 'recruiting') continue;
        const ocSlots = Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || []);
        ocSlots.forEach((slot, slotIdx) => {
          if (slot.user?.id) return;
          openSlots.push({ ocId, oc, slot, slotKey: `${ocId}:${slotIdx}` });
        });
      }

      // Build all (member, slot) pairs with scores
      const pairs = [];
      for (const mid of memberIds) {
        for (const os of openSlots) {
          const info = scoreSlotForMember(mid, os.oc, os.ocId, os.slot);
          pairs.push({ memberId: mid, slotKey: os.slotKey, info });
        }
      }
      // Sort by score descending
      pairs.sort((a, b) => b.info.score - a.info.score);

      const assigned    = new Map();      // memberId → slotInfo (eligible match only)
      const usedSlots   = new Set();
      const usedMembers = new Set();

      // Pass 1: greedy assignment of eligible pairs only
      for (const p of pairs) {
        if (!p.info.eligible) break; // sorted desc — no more eligibles after first non-eligible
        if (usedMembers.has(p.memberId) || usedSlots.has(p.slotKey)) continue;
        assigned.set(p.memberId, p.info);
        usedMembers.add(p.memberId);
        usedSlots.add(p.slotKey);
      }

      // Pass 2: members with no eligible match — give them a fallback only if
      // we have SOME CPR signal for them. Members with zero data (new recruits,
      // no TornStats, no faction history) get nothing — better to flag them as
      // "needs to run an OC" than to invent a recommendation.
      for (const mid of memberIds) {
        if (assigned.has(mid)) continue;
        const myPairs = pairs.filter(p => p.memberId === mid && p.info.cpr != null);
        if (myPairs.length) assigned.set(mid, myPairs[0].info);
      }

      return assigned;
    }

    // Cache the assignment per render so repeat lookups are free
    let _bestSlotCache = null;
    function getBestSlotForMember(memberId) {
      if (!_bestSlotCache) {
        _bestSlotCache = buildBestSlotAssignment(freeMembers.map(m => m.id));
      }
      return _bestSlotCache.get(memberId) || null;
    }

    /** Render a members table into a target element. Used for both Available and Recruits.
     *  When showBestFit=true, adds a recommended-role column using TornStats CPR data. */
    function renderMembersTable(members, containerEl, extraClass = '', showBestFit = false) {
      if (members.length === 0) {
        containerEl.innerHTML = '<span style="color:#555;font-size:11px">None.</span>';
        return;
      }
      const sorted = [...members].sort((a, b) => {
        const ta = lastOc[a.id]?.executed_at ?? Infinity;
        const tb = lastOc[b.id]?.executed_at ?? Infinity;
        return ta - tb;
      });

      // Available members get a card layout with prominent best-fit recommendation.
      // Recruits/etc keep the compact table layout.
      if (showBestFit) {
        const cards = sorted.map(m => {
          const member  = Object.values(memberMap).find(x => String(x.id) === m.id);
          const lastTs  = member?.last_action?.timestamp ?? null;
          const seen    = fmtRelative(lastTs);
          const oc      = lastOc[m.id];
          const ocTs    = oc ? fmtOcRelative(oc.executed_at) : null;
          const best    = getBestSlotForMember(m.id);

          let recHtml;
          if (!best) {
            recHtml = `<div style="color:#667;font-size:11px;line-height:1.4">
              <span style="color:var(--ocm-cpr-warn);font-weight:bold">No CPR data yet</span>
              <span style="color:#556"> — run any OC once to build a personal baseline.</span>
            </div>`;
          } else {
            const cprCol = best.cpr == null ? '#445'
                          : best.cpr >= CPR_WARN ? 'var(--ocm-cpr-good)'
                          : best.cpr >= CPR_CRIT ? 'var(--ocm-cpr-warn)' : 'var(--ocm-cpr-crit)';
            const cprTxt = best.cpr == null ? '?' : `${best.cpr}%`;
            const exactMark = best.exact ? '' : '~';
            const sourceTag = best.source === 'history'
              ? `<span style="font-size:9px;color:#778;background:#1c1810;border:1px solid #3a3018;padding:0 4px;border-radius:2px;margin-left:4px" title="From this member's faction OC history (no TornStats data)">hist</span>`
              : '';
            const itemWarn = best.itemMissing
              ? `<div style="font-size:10px;color:var(--ocm-cpr-crit);margin-top:3px;display:flex;align-items:center;gap:4px" title="Required item not available in faction armory. The slot still recommends this member but they (or the faction) need to source ${best.itemName} before initiation.">⚠ Item missing: <span style="color:#d8a070">${best.itemName}</span></div>`
              : '';
            recHtml = `<div style="display:flex;align-items:center;flex-wrap:wrap;gap:6px;font-size:12px;line-height:1.5">
              <span style="color:var(--ocm-link);font-size:9px;font-weight:bold;letter-spacing:.06em;text-transform:uppercase">Best fit</span>
              <span style="color:#e0e0e0;font-weight:bold">${best.role}</span>
              <span style="color:#667;font-size:11px">in</span>
              <span style="color:#aac">${best.ocName}</span>
              <span style="color:#556;font-size:10px">D${best.difficulty}</span>
              <span style="color:${cprCol};font-weight:bold;margin-left:auto">${exactMark}${cprTxt}${sourceTag}</span>
            </div>${itemWarn}`;
          }

          const lastOcStr = oc
            ? `<span style="color:#778">${oc.name.length > 22 ? oc.name.slice(0, 20) + '…' : oc.name}</span> · <span class="${ocTs ? ocTs.cls : ''}">${ocTs ? ocTs.text : ''}</span>`
            : `<span style="color:#445;font-style:italic">No OC history</span>`;
          const seenStr = seen
            ? `<span class="${seen.cls}">${seen.text}</span>`
            : `<span style="color:#444">Unknown</span>`;

          return `<div class="ocm-mcard">
            <div class="ocm-mcard-head">
              <a href="/profiles.php?XID=${m.id}" target="_blank" class="ocm-mcard-name">${m.name}</a>
              <span class="ocm-mcard-seen">${seenStr}</span>
            </div>
            <div class="ocm-mcard-rec">${recHtml}</div>
            <div class="ocm-mcard-meta">${lastOcStr}</div>
          </div>`;
        }).join('');
        containerEl.innerHTML = `<div class="ocm-mcard-list">${cards}</div>`;
        return;
      }

      // Default compact table layout (Recruits, etc.)
      const rows = sorted.map(m => {
        const member     = Object.values(memberMap).find(x => String(x.id) === m.id);
        const lastTs     = member?.last_action?.timestamp ?? null;
        const seen       = fmtRelative(lastTs);
        const oc         = lastOc[m.id];
        const ocTs       = oc ? fmtOcRelative(oc.executed_at) : null;
        const nameCell   = `<a href="/profiles.php?XID=${m.id}" target="_blank" style="color:#ccc;text-decoration:none">${m.name}</a>`;
        const ocName     = oc ? (oc.name.length > 24 ? oc.name.slice(0, 22) + '…' : oc.name) : '';
        const ocAgeCell  = oc
          ? `<span class="${ocTs ? ocTs.cls : ''}">${ocTs ? ocTs.text : ''}</span>`
          : `<span class="ocm-lastoc-never">No record</span>`;
        const ocNameCell = oc ? `<span title="${oc.name}" style="color:#888">${ocName}</span>` : '';
        const seenCell   = seen
          ? `<span class="${seen.cls}">${seen.text}</span>`
          : `<span style="color:#444">Unknown</span>`;
        return `<tr>
          <td class="col-name">${nameCell}</td>
          <td class="col-oc">${ocNameCell}</td>
          <td class="col-ocage">${ocAgeCell}</td>
          <td class="col-seen">${seenCell}</td>
        </tr>`;
      }).join('');
      containerEl.innerHTML = `
        <table class="ocm-members-table ${extraClass}">
          <thead><tr>
            <th>Member</th>
            <th>Last OC</th><th></th><th>Last Online</th>
          </tr></thead>
          <tbody>${rows}</tbody></table>`;
    }

    if (freeMembers.length === 0) {
      avail.innerHTML = '<span style="color:#555;font-size:11px">All active members are assigned.</span>';
    } else {
      renderMembersTable(freeMembers, avail, '', true);
      injectTsBadges(avail);
    }
    // Build a map of live OC slot CPRs so the TornStats table can show a member's
    // ACTUAL current checkpoint pass rate (overriding the TornStats estimate) when
    // they're slotted in that crime+role right now.
    //   liveCprMap[uid][crimeNameLower][roleNoNumLower] = checkpoint_pass_rate
    const liveCprMap = {};
    for (const oc of Object.values(crimes)) {
      if (!oc || typeof oc !== 'object') continue;
      const ocKey = (oc.name || '').toLowerCase().trim();
      if (!ocKey) continue;
      const slots = Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || []);
      for (const slot of slots) {
        if (!slot?.user?.id) continue;
        const cpr = slot.checkpoint_pass_rate;
        if (cpr == null) continue;
        const uid     = String(slot.user.id);
        const roleKey = (slot.position_info?.label || slot.position || '').replace(/\s*#\d+$/, '').toLowerCase().trim();
        if (!roleKey) continue;
        if (!liveCprMap[uid]) liveCprMap[uid] = {};
        if (!liveCprMap[uid][ocKey]) liveCprMap[uid][ocKey] = {};
        // Keep the highest live CPR if a member somehow appears in the same role twice
        if (liveCprMap[uid][ocKey][roleKey] == null || cpr > liveCprMap[uid][ocKey][roleKey]) {
          liveCprMap[uid][ocKey][roleKey] = cpr;
        }
      }
    }
    renderTsCprSection(memberMap, liveCprMap);
    recordProfit(crimes, itemValues);
    renderProfitSection(crimes, itemValues, itemNames);

    // ── Newsletter button — build message with per-member recommendations
    const newsletterBtn = document.getElementById('ocm-newsletter-btn');
    if (newsletterBtn && !newsletterBtn._wired) {
      newsletterBtn._wired = true;
      newsletterBtn.addEventListener('click', (e) => {
        e.stopPropagation();
        if (freeMembers.length === 0) {
          alert('No available members to message.');
          return;
        }
        // Build per-member recommendation list
        const lines = [];
        lines.push('Hey team — we have open OC slots that need filling. Here are the best fits based on your CPR:');
        lines.push('');
        const sorted = [...freeMembers].sort((a, b) => a.name.localeCompare(b.name));
        let withRecs = 0;
        for (const m of sorted) {
          const best = getBestSlotForMember(m.id);
          if (best && best.cpr != null) {
            const cprTxt = best.exact ? `${best.cpr}%` : `~${best.cpr}%`;
            lines.push(`• ${m.name}: ${best.role} in ${best.ocName} (D${best.difficulty}, CPR ${cprTxt})`);
            withRecs++;
          } else {
            lines.push(`• ${m.name}: no CPR data yet — please join any OC you're eligible for to build a baseline`);
          }
        }
        lines.push('');
        lines.push('Please join your recommended slot when you can. Thanks!');
        const message = lines.join('\n');

        // Copy to clipboard
        const opened = () => window.open('https://www.torn.com/factions.php?step=your#/tab=controls&option=newsletter&target=notInOC', '_blank');
        if (navigator.clipboard && navigator.clipboard.writeText) {
          navigator.clipboard.writeText(message).then(() => {
            alert(`✓ Message copied to clipboard (${withRecs} members with recommendations).\nOpening newsletter page — paste into the message body.`);
            opened();
          }).catch(() => {
            // Fallback if clipboard API blocked
            const ta = document.createElement('textarea');
            ta.value = message;
            ta.style.position = 'fixed';
            ta.style.left = '-9999px';
            document.body.appendChild(ta);
            ta.select();
            try { document.execCommand('copy'); } catch(_) {}
            ta.remove();
            alert(`✓ Message copied (${withRecs} members with recommendations).\nOpening newsletter page — paste into the message body.`);
            opened();
          });
        } else {
          // Old browser fallback
          const ta = document.createElement('textarea');
          ta.value = message;
          document.body.appendChild(ta);
          ta.select();
          try { document.execCommand('copy'); } catch(_) {}
          ta.remove();
          alert(`✓ Message copied (${withRecs} members with recommendations).\nOpening newsletter page — paste into the message body.`);
          opened();
        }
      });
    }

    // ── Recruits section
    const recruitsEl    = document.getElementById('ocm-recruits');
    const recruitsTitle = document.getElementById('ocm-title-recruits');
    if (recruitsTitle) recruitsTitle.textContent = `🚧 Recruits — cannot join OCs (${freeRecruits.length})`;
    if (freeRecruits.length === 0) {
      recruitsEl.innerHTML = '<span style="color:#555;font-size:11px">No recruits currently unassigned.</span>';
    } else {
      const notice = document.createElement('div');
      notice.className = 'ocm-recruits-notice';
      notice.innerHTML = 'Members listed here hold the <strong>Recruit</strong> rank and are not yet eligible to participate in Organised Crimes.';
      recruitsEl.innerHTML = '';
      recruitsEl.appendChild(notice);
      const tbl = document.createElement('div');
      renderMembersTable(freeRecruits, tbl, 'recruits-table');
      recruitsEl.appendChild(tbl);
    }

    // ── Blocked members (in OC + jail/hospital/abroad)
    const blockedEl    = document.getElementById('ocm-blocked');
    const blockedTitle = document.getElementById('ocm-title-blocked');
    const allBlocked   = [];
    for (const m of Object.values(memberMap)) {
      const mid = String(m.id);
      if (!assignedIds.has(mid) || !isBlocked(m.status?.state)) continue;
      let ocName = null, ocExecutesAt = null;
      for (const oc of Object.values(crimes)) {
        if (!oc?.slots) continue;
        const ocSlots = Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots);
        if (!ocSlots.some(s => s.user?.id && String(s.user.id) === mid)) continue;
        ocName = oc.name || `OC #${oc.id}`;
        const exAt = oc.executed_at ?? oc.ready_at ?? null;
        ocExecutesAt = exAt && exAt > now ? exAt : null;
        break;
      }
      allBlocked.push({ id: mid, name: m.name, status: m.status?.state, description: m.status?.description || '', ocName, ocExecutesAt });
    }
    if (blockedTitle) blockedTitle.textContent = `Blocked Members — In OC (${allBlocked.length})`;
    allBlocked.sort((a, b) => {
      if (a.ocExecutesAt && b.ocExecutesAt) return a.ocExecutesAt - b.ocExecutesAt;
      if (a.ocExecutesAt) return -1;
      if (b.ocExecutesAt) return 1;
      return 0;
    });
    if (allBlocked.length === 0) {
      blockedEl.innerHTML = '<span style="color:#555;font-size:11px">No blocked members. ✓</span>';
    } else {
      blockedEl.innerHTML = allBlocked.map(m => {
        const s           = (m.status || '').toLowerCase();
        let statusLabel;
        if (s === 'abroad' || s === 'traveling') {
          statusLabel = travelInfo(m.status, m.description).label;
        } else {
          statusLabel = m.status || 'Unknown';
        }
        const ocLabel = m.ocName
          ? `<span class="ocm-blocked-oc" title="OC: ${m.ocName}">${m.ocName}</span>`
          : '<span class="ocm-blocked-oc"></span>';
        const countdownLabel = m.ocExecutesAt
          ? `<span class="ocm-blocked-timer ocm-time" data-until="${m.ocExecutesAt}">${fmtTime(m.ocExecutesAt - now)}</span>`
          : `<span class="ocm-blocked-timer notimer">No timer</span>`;
        return `
          <div class="ocm-blocked-row">
            <a class="ocm-blocked-name" href="/profiles.php?XID=${m.id}" target="_blank">${m.name}</a>
            <span class="ocm-blocked-status" title="${m.description || m.status}">${statusLabel}</span>
            ${ocLabel}
            ${countdownLabel}
          </div>`;
      }).join('');
    }

    // ── Low CPR members
    const lowCprEl    = document.getElementById('ocm-lowcpr');
    const lowCprTitle = document.getElementById('ocm-title-lowcpr');
    const lowCprRows  = [];

    for (const [ocId, oc] of Object.entries(crimes)) {
      if (!oc || typeof oc !== 'object') continue;
      const phase = (oc.status || '').toLowerCase();
      if (!ACTIVE.has(phase)) continue;
      const ocName = oc.name || `OC #${ocId}`;
      const slots  = Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || []);
      for (const slot of slots) {
        const uid = slot.user?.id ? String(slot.user.id) : null;
        if (!uid) continue;
        const cpr = slot.checkpoint_pass_rate ?? null;
        if (cpr === null || cpr >= CPR_WARN) continue;
        const roleName = slot.position_info?.label || slot.position || 'Unknown role';
        const weight   = getWeight(ocName, roleName);
        const isRisk   = weight != null && weight >= WEIGHT_HIGH;
        lowCprRows.push({ uid, name: mInfo[uid]?.name || `#${uid}`, cpr, roleName, ocName, weight, isRisk });
      }
    }

    lowCprRows.sort((a, b) => a.cpr - b.cpr || (b.weight ?? 0) - (a.weight ?? 0));
    if (lowCprTitle) lowCprTitle.textContent = `⚠ Low CPR Members — below ${CPR_WARN}% (${lowCprRows.length})`;

    if (lowCprRows.length === 0) {
      lowCprEl.innerHTML = '<span style="color:#555;font-size:11px">No members with low CPR. ✓</span>';
    } else {
      lowCprEl.innerHTML = lowCprRows.map(r => {
        const cprCls     = r.cpr < CPR_CRIT ? 'cpr-crit' : 'cpr-warn';
        const weightHtml = r.weight != null
          ? `<span class="ocm-slot-weight ${r.weight >= WEIGHT_HIGH ? 'w-high' : r.weight >= WEIGHT_MID ? 'w-mid' : 'w-low'}" title="Role weight: ${r.weight.toFixed(1)}%">${r.weight.toFixed(0)}%</span>`
          : '';
        const riskBadge = r.isRisk
          ? `<span title="High-weight role (${r.weight.toFixed(0)}%) with low CPR — significant risk" style="font-size:11px;cursor:help">⚠</span>`
          : '';
        return `
          <div class="ocm-lowcpr-row">
            <a class="ocm-lowcpr-name" href="/profiles.php?XID=${r.uid}" target="_blank">${r.name}</a>
            <span class="ocm-lowcpr-oc" title="${r.ocName}">${r.ocName}</span>
            <span class="ocm-lowcpr-role" title="${r.roleName}">${r.roleName}</span>
            <span class="ocm-lowcpr-extras">${weightHtml}${riskBadge}</span>
            <span class="ocm-slot-cpr ${cprCls} ocm-lowcpr-cpr">${r.cpr}%</span>
          </div>`;
      }).join('');
    }

    // ── Underutilized members: in a low-difficulty OC but could clear a harder one
    // For each member currently assigned to an OC, compare their current OC's
    // difficulty against the highest-difficulty *recruiting* OC where they'd
    // still meet the CPR warn threshold (estimated via TornStats / OC history).
    // If a meaningfully harder OC is available, flag them as underutilized.
    const overEl    = document.getElementById('ocm-overqualified');
    const overTitle = document.getElementById('ocm-title-overqualified');
    const overRows  = [];

    // Build the set of difficulties currently recruiting (with at least one open slot)
    // mapped to a representative open OC name + role per difficulty.
    const recruitingOpenByDiff = {};
    for (const [ocId, oc] of Object.entries(crimes)) {
      if (!oc || (oc.status || '').toLowerCase() !== 'recruiting') continue;
      const diff   = Number(oc.difficulty) || 0;
      const ocName = oc.name || `OC #${ocId}`;
      const slots  = Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || []);
      for (const slot of slots) {
        if (slot?.user?.id) continue; // only open slots
        const role = slot.position_info?.label || slot.position || 'Unknown';
        if (!recruitingOpenByDiff[diff]) recruitingOpenByDiff[diff] = [];
        recruitingOpenByDiff[diff].push({ ocName, role, difficulty: diff });
      }
    }
    const recruitingDiffs = Object.keys(recruitingOpenByDiff).map(Number).sort((a, b) => b - a); // high → low

    // For each filled slot in an active OC, see if a harder recruiting OC fits
    for (const [ocId, oc] of Object.entries(crimes)) {
      if (!oc || typeof oc !== 'object') continue;
      const phase = (oc.status || '').toLowerCase();
      if (!ACTIVE.has(phase)) continue;
      const curDiff = Number(oc.difficulty) || 0;
      const ocName  = oc.name || `OC #${ocId}`;
      const slots   = Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || []);

      for (const slot of slots) {
        const uid = slot?.user?.id ? String(slot.user.id) : null;
        if (!uid) continue;
        const curCpr = slot.checkpoint_pass_rate ?? null;
        // Only consider members performing WELL in their current OC (>= warn).
        // A struggling member belongs in the Low CPR section, not here.
        if (curCpr === null || curCpr < CPR_WARN) continue;
        const curRole = slot.position_info?.label || slot.position || 'Unknown';

        // Find the highest-difficulty recruiting OC where this member would still
        // clear the warn threshold, strictly harder than their current OC.
        // CRITICAL: only trust an EXACT estimate — real TornStats data or personal
        // history for that specific crime+role. Fuzzy/overall-lowest fallbacks
        // (the "~" estimates) are not reliable predictions of a brand-new role at
        // a much higher difficulty, so they're rejected here to avoid suggesting
        // everyone could run the hardest OC in the game.
        let bestUpgrade = null;
        for (const hd of recruitingDiffs) {
          if (hd <= curDiff) break; // sorted desc — nothing harder left
          for (const cand of recruitingOpenByDiff[hd]) {
            const est = tsGetCpr(uid, cand.ocName, cand.role);
            // Require an exact match (TornStats exact, or personal history exact)
            if (est && est.exact && est.cpr >= CPR_WARN) {
              bestUpgrade = { ...cand, estCpr: est.cpr, exact: est.exact, source: est.source };
              break;
            }
          }
          if (bestUpgrade) break;
        }
        if (!bestUpgrade) continue;

        overRows.push({
          uid, name: mInfo[uid]?.name || `#${uid}`,
          curOc: ocName, curDiff, curRole, curCpr,
          upOc: bestUpgrade.ocName, upDiff: bestUpgrade.difficulty, upRole: bestUpgrade.role,
          upCpr: bestUpgrade.estCpr, upExact: bestUpgrade.exact, upSource: bestUpgrade.source,
          gain: bestUpgrade.difficulty - curDiff,
        });
      }
    }

    // Sort by biggest difficulty jump, then by current performance
    overRows.sort((a, b) => b.gain - a.gain || b.curCpr - a.curCpr);
    if (overTitle) overTitle.textContent = `⬆ Underutilized — could run a harder OC (${overRows.length})`;

    if (overRows.length === 0) {
      overEl.innerHTML = '<span style="color:#555;font-size:11px">No underutilized members — everyone is in an appropriately challenging OC. ✓</span>';
    } else {
      overEl.innerHTML = overRows.map(r => {
        const upCprCls = r.upCpr >= CPR_WARN ? 'cpr-good' : r.upCpr >= CPR_CRIT ? 'cpr-warn' : 'cpr-crit';
        const srcTag = r.upSource === 'history'
          ? `<span style="font-size:9px;color:#778;background:#1c1810;border:1px solid #3a3018;padding:0 4px;border-radius:2px;margin-left:3px" title="Estimate from this member's OC history">hist</span>`
          : '';
        return `
          <div class="ocm-lowcpr-row" title="${r.name} is doing well in a D${r.curDiff} OC and has real data showing they can clear a D${r.upDiff} OC">
            <a class="ocm-lowcpr-name" href="/profiles.php?XID=${r.uid}" target="_blank">${r.name}</a>
            <span class="ocm-lowcpr-oc">
              <span style="color:#778">D${r.curDiff}</span>
              <span style="color:var(--ocm-cpr-good);font-weight:bold">${r.curCpr}%</span>
              <span style="color:#445">→</span>
              <span style="color:var(--ocm-link);font-weight:bold">D${r.upDiff}</span>
            </span>
            <span class="ocm-lowcpr-role" title="${r.upOc} · ${r.upRole}">${r.upRole} in ${r.upOc}</span>
            <span class="ocm-lowcpr-extras"><span class="ocm-badge" title="+${r.gain} difficulty levels">+${r.gain}</span></span>
            <span class="ocm-slot-cpr ${upCprCls} ocm-lowcpr-cpr" title="Known CPR at D${r.upDiff}">${r.upCpr}%${srcTag}</span>
          </div>`;
      }).join('');
    }

    // ── Analytics section
    const analyticsEl    = document.getElementById('ocm-analytics');
    const analyticsTitle = document.getElementById('ocm-title-analytics');

    /** Normalise OC status to 'successful' | 'failure' | 'expired' | null */
    function normStatus(raw) {
      const s = (raw || '').toLowerCase().trim();
      if (s === 'successful' || s === 'success') return 'successful';
      if (s === 'failure'    || s === 'failed'  || s === 'fail') return 'failure';
      if (s === 'expired'    || s === 'expire')  return 'expired';
      return null;
    }

    /**
     * Normalise OC name for grouping — strip diacritics, version suffixes (V1/V2),
     * and lowercase. Used as the key for scenarioStats and heatmap data.
     */
    function normOcName(raw) {
      return (raw || 'Unknown')
        .trim()
        .normalize('NFD').replace(/[\u0300-\u036f]/g, '')
        .toLowerCase()
        .replace(/\s+[Vv]\d+$/, '')
        .trim();
    }

    const completed = Object.values(crimes).filter(oc => oc && normStatus(oc.status) !== null);
    const successes = completed.filter(oc => normStatus(oc.status) === 'successful');
    const failures  = completed.filter(oc => normStatus(oc.status) === 'failure');
    const expired   = completed.filter(oc => normStatus(oc.status) === 'expired');
    const total     = completed.length;

    if (analyticsTitle) analyticsTitle.textContent = `📊 Analytics — ${total} completed OCs`;

    // Per-scenario stats — keyed by normOcName()
    const scenarioStats = {};
    for (const oc of completed) {
      const key = normOcName(oc.name);
      if (!scenarioStats[key]) scenarioStats[key] = { success: 0, failure: 0, expired: 0, total: 0 };
      const s = normStatus(oc.status);
      scenarioStats[key].total++;
      if (s === 'successful') scenarioStats[key].success++;
      else if (s === 'failure') scenarioStats[key].failure++;
      else scenarioStats[key].expired++;
    }

    // Per-member stats
    const memberStats = {};
    for (const oc of completed) {
      const s = normStatus(oc.status);
      const ocSlots = Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || []);
      for (const slot of ocSlots) {
        const uid = slot.user?.id ? String(slot.user.id) : null;
        if (!uid) continue;
        if (!memberStats[uid]) {
          const name = mInfo[uid]?.name || exMemberNames[uid] || `#${uid}`;
          const isEx = !mInfo[uid];
          memberStats[uid] = { name, isEx, participated: 0, success: 0, failure: 0, cprSum: 0, cprCount: 0 };
        }
        memberStats[uid].participated++;
        if (s === 'successful') memberStats[uid].success++;
        else if (s === 'failure') memberStats[uid].failure++;
        const cpr = slot.checkpoint_pass_rate ?? null;
        if (cpr != null) { memberStats[uid].cprSum += cpr; memberStats[uid].cprCount++; }
      }
    }

    const memberRows = Object.entries(memberStats)
      .map(([uid, s]) => ({ uid, ...s, avgCpr: s.cprCount > 0 ? s.cprSum / s.cprCount : null, rate: (s.success + s.failure) > 0 ? s.success / (s.success + s.failure) : 0 }))
      .sort((a, b) => b.participated - a.participated);

    const scenarioRows = Object.entries(scenarioStats)
      .map(([name, s]) => ({ name, ...s, rate: (s.success + s.failure) > 0 ? s.success / (s.success + s.failure) : 0 }))
      .sort((a, b) => b.total - a.total);

    function rateCls(r) { return r >= 0.85 ? 'ocm-rate-high' : r >= 0.65 ? 'ocm-rate-mid' : 'ocm-rate-low'; }
    function pct(r)     { return `${Math.round(r * 100)}%`; }

    const overallRate = (successes.length + failures.length) > 0 ? successes.length / (successes.length + failures.length) : 0;

    // --- Last 5 completed OCs (sorted most-recent first)
    const last5 = [...completed]
      .sort((a, b) => (b.executed_at || 0) - (a.executed_at || 0))
      .slice(0, 5);

    const last5Html = last5.map((oc, idx) => {
      const s        = normStatus(oc.status);
      const icon     = s === 'successful' ? '✅' : '❌';
      const col      = s === 'successful' ? 'var(--ocm-cpr-good)' : 'var(--ocm-cpr-crit)';
      const ocSlots  = Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || []);
      const filled   = ocSlots.filter(sl => sl.user?.id);
      const avgCpr   = filled.length > 0
        ? (filled.reduce((a, sl) => a + (sl.checkpoint_pass_rate ?? 0), 0) / filled.length).toFixed(1)
        : null;
      const execDate = oc.executed_at
        ? new Date(oc.executed_at * 1000).toLocaleDateString('en-GB', { timeZone:'UTC', day:'2-digit', month:'short', year:'2-digit' })
        : '–';
      const rewards    = oc.rewards;
      const money      = rewards?.money ?? null;
      const respect    = rewards?.respect ?? null;
      const paid       = rewards?.paid ?? rewards?.is_paid ?? null;
      const paidBadge  = paid === true
        ? `<span style="font-size:9px;background:#14201a;color:var(--ocm-cpr-good);border-radius:3px;padding:1px 4px;margin-left:4px">Paid ✓</span>`
        : paid === false
          ? `<span style="font-size:9px;background:#1c1410;color:var(--ocm-cpr-crit);border-radius:3px;padding:1px 4px;margin-left:4px">Unpaid</span>`
          : '';
      const rewardParts = [];
      if (money && Number(money) > 0) rewardParts.push(`💰 $${Number(money).toLocaleString()}`);
      if (respect && Number(respect) > 0) rewardParts.push(`⭐ ${respect} resp`);

      // Per-member detail rows for the expandable section
      const memberDetailRows = filled.map(sl => {
        const uid  = String(sl.user.id);
        const name = mInfo[uid]?.name || exMemberNames[uid] || `#${uid}`;
        const role = sl.position_info?.label || sl.position || '?';
        const cpr  = sl.checkpoint_pass_rate ?? null;
        const w    = getWeight(oc.name || '', role);
        const cprCol = cpr == null ? '#555' : cpr >= CPR_WARN ? 'var(--ocm-cpr-good)' : cpr >= CPR_CRIT ? 'var(--ocm-cpr-warn)' : 'var(--ocm-cpr-crit)';
        const wCol   = w   == null ? '#555' : w >= WEIGHT_HIGH ? 'var(--ocm-cpr-warn)' : w >= WEIGHT_MID ? '#aaa' : '#555';
        return `<tr>
          <td><a href="/profiles.php?XID=${uid}" target="_blank" style="color:#ccc;text-decoration:none">${name}</a></td>
          <td style="color:#777;text-align:right">${role}</td>
          <td style="text-align:right;font-weight:bold;color:${cprCol}">${cpr != null ? cpr+'%' : '–'}</td>
          <td style="text-align:right;font-weight:bold;color:${wCol}">${w != null ? w.toFixed(0)+'%' : '–'}</td>
        </tr>`;
      }).join('');

      return `
        <div class="ocm-last5-row-header" data-idx="${idx}" style="border-left:3px solid ${col}">
          <span style="color:${col};font-size:13px">${icon}</span>
          <span style="font-weight:bold;color:#e0e0e0;flex:1">${oc.name || 'Unknown'}</span>
          <span style="color:#666;font-size:10px">D${oc.difficulty ?? '?'}</span>
          <span style="color:#888;font-size:10px">${execDate}</span>
          ${avgCpr ? `<span style="font-size:10px;color:#aaa">CPR: <strong class="${cprClass(Number(avgCpr))}">${avgCpr}%</strong></span>` : ''}
          ${paidBadge}
          ${rewardParts.length ? `<span style="font-size:10px;color:#99a">${rewardParts.join(' ')}</span>` : ''}
          <span style="color:#555;font-size:10px">▼</span>
        </div>
        <div class="ocm-last5-detail" id="ocm-last5-detail-${idx}">
          <table>
            <thead><tr style="font-size:9px;color:#555"><th>Member</th><th style="text-align:right">Role</th><th style="text-align:right">CPR</th><th style="text-align:right">Wt</th></tr></thead>
            <tbody>${memberDetailRows || '<tr><td colspan="4" style="color:#555;font-style:italic">No member data</td></tr>'}</tbody>
          </table>
        </div>`;
    }).join('');

    analyticsEl.innerHTML = `
      <div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:10px;align-items:stretch">
        <div style="background:#1e1e1e;border:1px solid #2e2e2e;border-radius:6px;padding:8px 14px;text-align:center;min-width:90px">
          <div style="font-size:9px;color:#888;text-transform:uppercase;letter-spacing:.5px">Success Rate</div>
          <div style="font-size:22px;font-weight:bold" class="${rateCls(overallRate)}">${pct(overallRate)}</div>
          <div style="font-size:10px;color:#555">${successes.length}S / ${failures.length}F / ${expired.length}E</div>
        </div>
        <div style="background:#1e1e1e;border:1px solid #2e2e2e;border-radius:6px;padding:8px 14px;text-align:center;min-width:80px">
          <div style="font-size:9px;color:#888;text-transform:uppercase;letter-spacing:.5px">OCs Analysed</div>
          <div style="font-size:22px;font-weight:bold;color:var(--ocm-link)">${total}</div>
          <div style="font-size:10px;color:#555">of 100 cap</div>
        </div>
      </div>

      <div id="ocm-last5-wrap">
        <button id="ocm-last5-toggle">📋 Last 5 Completed OCs ▼</button>
        <div id="ocm-last5-body">
          ${last5.length === 0
            ? '<div style="color:#555;font-size:11px;padding:6px">No completed OCs found.</div>'
            : last5Html}
        </div>
      </div>

      <div class="ocm-analytics-grid">
        <div class="ocm-analytics-card">
          <h4>By Scenario</h4>
          <table class="ocm-analytics-table">
            <thead><tr><th>OC</th><th class="td-right">Ran</th><th class="td-right">Success%</th><th class="td-right ocm-sfe-col">S/F/E</th></tr></thead>
            <tbody>${scenarioRows.map(r => {
              const activeRate = (r.success + r.failure) > 0 ? r.success / (r.success + r.failure) : 0;
              return `<tr>
                <td title="${r.name}">${r.name}</td>
                <td class="td-right">${r.total}</td>
                <td class="td-right ${rateCls(activeRate)}">${pct(activeRate)}</td>
                <td class="td-right ocm-sfe-col" style="font-size:10px">
                  <span class="ocm-stat-pill pill-success">${r.success}</span>
                  <span class="ocm-stat-pill pill-failure">${r.failure}</span>
                  <span class="ocm-stat-pill pill-expired">${r.expired}</span>
                </td>
              </tr>`;}).join('')}
            </tbody>
          </table>
        </div>
        <div class="ocm-analytics-card">
          <h4>By Member</h4>
          <table class="ocm-analytics-table">
            <thead><tr><th>Member</th><th class="td-right">OCs</th><th class="td-right">Success%</th><th class="td-right">Avg CPR</th></tr></thead>
            <tbody>${memberRows.map(r => `
              <tr${r.isEx ? ' style="opacity:.5"' : ''}>
                <td><a href="/profiles.php?XID=${r.uid}" target="_blank" style="color:${r.isEx?'#888':'#ccc'};text-decoration:none">${r.name}</a>${r.isEx?'<span style="font-size:9px;color:#555;margin-left:3px">(left)</span>':''}</td>
                <td class="td-right">${r.participated}</td>
                <td class="td-right ${rateCls(r.rate)}">${pct(r.rate)}</td>
                <td class="td-right ${r.avgCpr != null ? cprClass(r.avgCpr) : ''}">${r.avgCpr != null ? r.avgCpr.toFixed(1)+'%' : '–'}</td>
              </tr>`).join('')}
            </tbody>
          </table>
        </div>
        <div class="ocm-analytics-card" style="grid-column:1/-1">
          <h4>Success Rate Over Time <button class="ocm-chart-toggle" data-target="ocm-chart-timeline">Show Chart</button></h4>
          <div id="ocm-chart-timeline" class="ocm-chart-wrap"></div>
        </div>
        <div class="ocm-analytics-card" style="grid-column:1/-1">
          <h4>Success Rate by Scenario <button class="ocm-chart-toggle" data-target="ocm-chart-scenario">Show Chart</button></h4>
          <div id="ocm-chart-scenario" class="ocm-chart-wrap"></div>
        </div>
        <div class="ocm-analytics-card" style="grid-column:1/-1">
          <h4>CPR Distribution <button class="ocm-chart-toggle" data-target="ocm-chart-cpr">Show Chart</button></h4>
          <div id="ocm-chart-cpr" class="ocm-chart-wrap"></div>
        </div>
        <div class="ocm-analytics-card" style="grid-column:1/-1">
          <h4>Member Participation &amp; Success Rate <button class="ocm-chart-toggle" data-target="ocm-chart-members">Show Chart</button></h4>
          <div id="ocm-chart-members" class="ocm-chart-wrap"></div>
        </div>
        <div class="ocm-analytics-card" style="grid-column:1/-1">
          <h4>Member × Scenario Heatmap <button class="ocm-chart-toggle" data-target="ocm-heatmap">Show Chart</button></h4>
          <div id="ocm-heatmap" class="ocm-chart-wrap" style="overflow-x:auto"></div>
        </div>
      </div>

      <div id="ocm-member-history-wrap">
        <h4>👤 Member OC History</h4>
        <div id="ocm-mh-search-wrap">
          <input id="ocm-mh-search" type="text" placeholder="Search member name…" autocomplete="off" />
          <div id="ocm-mh-dropdown"></div>
          <button id="ocm-mh-clear">✕ Clear</button>
        </div>
        <div id="ocm-mh-summary" style="display:none"></div>
        <div id="ocm-mh-table-wrap">
          <div id="ocm-mh-empty">Type a member name above to view their OC history.</div>
        </div>
      </div>

      <div id="ocm-oc-history-wrap">
        <h4>🗂 OC Scenario History</h4>
        <div id="ocm-oh-search-wrap">
          <input id="ocm-oh-search" type="text" placeholder="Search OC name… (e.g. Sneaky Git Grab)" autocomplete="off" />
          <div id="ocm-oh-dropdown"></div>
          <button id="ocm-oh-clear">✕ Clear</button>
        </div>
        <div id="ocm-oh-summary" style="display:none"></div>
        <div id="ocm-oh-table-wrap">
          <div id="ocm-oh-empty">Type an OC name above to view all runs of that scenario.</div>
        </div>
      </div>`;

    // Wire Last 5 expand/collapse toggle
    document.getElementById('ocm-last5-toggle').addEventListener('click', () => {
      const body = document.getElementById('ocm-last5-body');
      const btn  = document.getElementById('ocm-last5-toggle');
      const open = body.style.display === 'block';
      body.style.display = open ? 'none' : 'block';
      btn.textContent = `📋 Last 5 Completed OCs ${open ? '▼' : '▲'}`;
    });

    // Wire per-row expand/collapse in the Last 5 table
    analyticsEl.querySelectorAll('.ocm-last5-row-header').forEach(header => {
      header.addEventListener('click', () => {
        const idx    = header.dataset.idx;
        const detail = document.getElementById(`ocm-last5-detail-${idx}`);
        if (!detail) return;
        const isOpen = detail.style.display === 'block';
        detail.style.display = isOpen ? 'none' : 'block';
        const arrow = header.querySelector('span:last-child');
        if (arrow) arrow.textContent = isOpen ? '▼' : '▲';
      });
    });

    // ── Member OC History — search and render logic

    /**
     * Build a per-member history index from the completed OC list.
     * Each entry: { ocName, difficulty, role, weight, cpr, outcome, executedAt, respect }
     * Sorted newest-first within each member's array.
     */
    const memberHistoryIndex = {};
    for (const oc of completed) {
      const s       = normStatus(oc.status);
      const ocSlots = Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || []);
      // Total respect from the OC rewards object (shared pot — not split per member)
      const respect = oc.rewards?.respect ?? null;
      for (const slot of ocSlots) {
        const uid = slot.user?.id ? String(slot.user.id) : null;
        if (!uid) continue;
        const name = mInfo[uid]?.name || exMemberNames[uid] || `#${uid}`;
        if (!memberHistoryIndex[uid]) memberHistoryIndex[uid] = { name, entries: [] };
        const role   = slot.position_info?.label || slot.position || '?';
        const weight = getWeight(oc.name || '', role);
        memberHistoryIndex[uid].entries.push({
          ocName:     oc.name || 'Unknown',
          difficulty: oc.difficulty ?? '?',
          role,
          weight,
          cpr:        slot.checkpoint_pass_rate ?? null,
          outcome:    s,
          executedAt: oc.executed_at ?? null,
          respect,
        });
      }
    }
    // Sort each member's entries newest-first
    for (const uid of Object.keys(memberHistoryIndex)) {
      memberHistoryIndex[uid].entries.sort((a, b) => (b.executedAt || 0) - (a.executedAt || 0));
    }

    // Build sorted list of all member names for autocomplete
    const mhAllMembers = Object.values(memberHistoryIndex)
      .map(m => ({ uid: Object.keys(memberHistoryIndex).find(k => memberHistoryIndex[k] === m), name: m.name }))
      .sort((a, b) => a.name.localeCompare(b.name));

    /** Render the history table and summary stats for a given uid. */
    function renderMemberHistory(uid) {
      const record    = memberHistoryIndex[uid];
      const summaryEl = document.getElementById('ocm-mh-summary');
      const tableWrap = document.getElementById('ocm-mh-table-wrap');
      if (!record || record.entries.length === 0) {
        summaryEl.style.display = 'none';
        tableWrap.innerHTML = `<div id="ocm-mh-empty">No completed OC history found for this member in the last 100 OCs.</div>`;
        return;
      }

      const entries   = record.entries;
      const total     = entries.length;
      const successes = entries.filter(e => e.outcome === 'successful').length;
      const rate      = total > 0 ? Math.round(successes / total * 100) : 0;
      const cprs      = entries.filter(e => e.cpr != null).map(e => e.cpr);
      const avgCpr    = cprs.length > 0 ? (cprs.reduce((a, b) => a + b, 0) / cprs.length).toFixed(1) : null;

      // Most-played role
      const roleCounts = {};
      for (const e of entries) roleCounts[e.role] = (roleCounts[e.role] || 0) + 1;
      const topRole = Object.entries(roleCounts).sort((a, b) => b[1] - a[1])[0]?.[0] ?? '—';

      const rateCol = rate >= 85 ? 'var(--ocm-cpr-good)' : rate >= 65 ? 'var(--ocm-cpr-warn)' : 'var(--ocm-cpr-crit)';

      // Summary bar
      summaryEl.style.display = 'flex';
      summaryEl.innerHTML = `
        <div class="ocm-mh-sum-item">
          <span class="ocm-mh-sum-label">OCs</span>
          <span class="ocm-mh-sum-value">${total}</span>
        </div>
        <div class="ocm-mh-sum-item">
          <span class="ocm-mh-sum-label">Success Rate</span>
          <span class="ocm-mh-sum-value" style="color:${rateCol}">${rate}%</span>
        </div>
        <div class="ocm-mh-sum-item">
          <span class="ocm-mh-sum-label">Avg CPR</span>
          <span class="ocm-mh-sum-value ${avgCpr != null ? cprClass(Number(avgCpr)) : ''}">${avgCpr != null ? avgCpr + '%' : '—'}</span>
        </div>
        <div class="ocm-mh-sum-item">
          <span class="ocm-mh-sum-label">Most Played Role</span>
          <span class="ocm-mh-sum-value" style="font-size:11px;padding-top:2px">${topRole}</span>
        </div>`;

      // History table
      const rows = entries.map(e => {
        const dateStr   = e.executedAt
          ? new Date(e.executedAt * 1000).toLocaleDateString('en-GB', { timeZone: 'UTC', day: '2-digit', month: 'short', year: '2-digit' })
          : '—';
        const cprStr    = e.cpr != null ? `${e.cpr}%` : '—';
        const cprCls    = e.cpr != null ? cprClass(e.cpr) : '';
        const wStr      = e.weight != null ? `${e.weight.toFixed(0)}%` : '—';
        const wCls      = e.weight != null ? (e.weight >= WEIGHT_HIGH ? 'w-high' : e.weight >= WEIGHT_MID ? 'w-mid' : 'w-low') : '';
        // Respect — only shown on successful OCs; dim/dash on failure or expired
        const respStr   = (e.outcome === 'successful' && e.respect != null) ? `${e.respect}` : '—';
        const respStyle = (e.outcome === 'successful' && e.respect != null) ? '' : 'color:#333';
        const outIcon   = e.outcome === 'successful' ? '✅' : e.outcome === 'failure' ? '❌' : '⏰';
        const outCls    = e.outcome === 'successful' ? 'ocm-mh-outcome-success' : e.outcome === 'failure' ? 'ocm-mh-outcome-failure' : 'ocm-mh-outcome-expired';
        const outText   = e.outcome === 'successful' ? 'Success' : e.outcome === 'failure' ? 'Failure' : 'Expired';
        return `<tr>
          <td class="col-date">${dateStr}</td>
          <td class="col-oc" title="${e.ocName}">${e.ocName}</td>
          <td class="col-diff">D${e.difficulty}</td>
          <td class="col-role" title="${e.role}">${e.role}</td>
          <td class="col-weight ocm-slot-weight ${wCls}">${wStr}</td>
          <td class="col-cpr ${cprCls}">${cprStr}</td>
          <td class="col-respect" style="${respStyle}">${respStr}</td>
          <td class="col-outcome ${outCls}">${outIcon} ${outText}</td>
        </tr>`;
      }).join('');

      tableWrap.innerHTML = `
        <table class="ocm-mh-table">
          <thead><tr>
            <th class="col-date">Date</th>
            <th class="col-oc">OC Name</th>
            <th class="col-diff" style="text-align:center">Diff</th>
            <th class="col-role">Role</th>
            <th class="col-weight" style="text-align:right">Weight</th>
            <th class="col-cpr" style="text-align:right">CPR</th>
            <th class="col-respect" style="text-align:right">Respect</th>
            <th class="col-outcome" style="padding-left:14px">Outcome</th>
          </tr></thead>
          <tbody>${rows}</tbody>
        </table>`;
    }

    // ── Wire member history search UI
    const mhSearch   = document.getElementById('ocm-mh-search');
    const mhDropdown = document.getElementById('ocm-mh-dropdown');
    const mhClear    = document.getElementById('ocm-mh-clear');
    let mhSelectedUid = null;

    /** Highlight matching portion of a name with <em> tags. */
    function highlightMatch(name, query) {
      const idx = name.toLowerCase().indexOf(query.toLowerCase());
      if (idx === -1) return name;
      return name.slice(0, idx) + '<em>' + name.slice(idx, idx + query.length) + '</em>' + name.slice(idx + query.length);
    }

    /** Show the autocomplete dropdown filtered by the current search query. */
    function updateDropdown(query) {
      if (!query.trim()) { mhDropdown.classList.remove('visible'); mhDropdown.innerHTML = ''; return; }
      const matches = mhAllMembers.filter(m => m.name.toLowerCase().includes(query.toLowerCase()));
      if (matches.length === 0) { mhDropdown.classList.remove('visible'); mhDropdown.innerHTML = ''; return; }
      mhDropdown.innerHTML = matches.map(m =>
        `<div class="ocm-mh-option" data-uid="${m.uid}">${highlightMatch(m.name, query)}</div>`
      ).join('');
      mhDropdown.classList.add('visible');
      mhDropdown.querySelectorAll('.ocm-mh-option').forEach(opt => {
        opt.addEventListener('mousedown', e => {
          e.preventDefault(); // prevent blur firing before click
          const uid  = opt.dataset.uid;
          const name = memberHistoryIndex[uid]?.name || '';
          mhSearch.value  = name;
          mhSelectedUid   = uid;
          mhDropdown.classList.remove('visible');
          renderMemberHistory(uid);
        });
      });
    }

    mhSearch.addEventListener('input', () => {
      mhSelectedUid = null;
      updateDropdown(mhSearch.value);
      // If the typed text exactly matches a member name, render immediately
      const exact = mhAllMembers.find(m => m.name.toLowerCase() === mhSearch.value.toLowerCase());
      if (exact) renderMemberHistory(exact.uid);
    });

    mhSearch.addEventListener('blur', () => {
      // Small delay so mousedown on option fires first
      setTimeout(() => mhDropdown.classList.remove('visible'), 150);
    });

    mhSearch.addEventListener('focus', () => {
      if (mhSearch.value.trim()) updateDropdown(mhSearch.value);
    });

    mhClear.addEventListener('click', () => {
      mhSearch.value  = '';
      mhSelectedUid   = null;
      mhDropdown.classList.remove('visible');
      document.getElementById('ocm-mh-summary').style.display = 'none';
      document.getElementById('ocm-mh-table-wrap').innerHTML =
        `<div id="ocm-mh-empty">Type a member name above to view their OC history.</div>`;
    });

    // ── OC Scenario History — build index and wire search

    /**
     * Build a per-scenario history index from the completed OC list.
     * Keyed by normOcName() so searching "Sneaky Git Grab" matches all variants.
     * Each entry is a full OC record with its slots resolved for display.
     */
    const ocHistoryIndex = {};
    for (const oc of completed) {
      const key     = normOcName(oc.name);
      const display = oc.name || 'Unknown';
      if (!ocHistoryIndex[key]) ocHistoryIndex[key] = { display, runs: [] };
      const s       = normStatus(oc.status);
      const ocSlots = Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || []);
      const respect = oc.rewards?.respect ?? null;
      // Build per-slot detail for the expandable row
      const slotDetail = ocSlots
        .filter(sl => sl.user?.id)
        .map(sl => {
          const uid  = String(sl.user.id);
          const name = mInfo[uid]?.name || exMemberNames[uid] || `#${uid}`;
          const role = sl.position_info?.label || sl.position || '?';
          const w    = getWeight(oc.name || '', role);
          const cpr  = sl.checkpoint_pass_rate ?? null;
          return { name, uid, role, weight: w, cpr };
        });
      // Avg CPR across filled slots
      const cprs   = slotDetail.filter(s => s.cpr != null).map(s => s.cpr);
      const avgCpr = cprs.length > 0 ? cprs.reduce((a, b) => a + b, 0) / cprs.length : null;

      ocHistoryIndex[key].runs.push({
        ocName:     display,
        difficulty: oc.difficulty ?? '?',
        outcome:    s,
        executedAt: oc.executed_at ?? null,
        respect,
        avgCpr,
        slots:      slotDetail,
      });
    }
    // Sort each scenario's runs newest-first
    for (const key of Object.keys(ocHistoryIndex)) {
      ocHistoryIndex[key].runs.sort((a, b) => (b.executedAt || 0) - (a.executedAt || 0));
    }

    // Sorted list of unique scenario names for autocomplete
    const ohAllScenarios = Object.entries(ocHistoryIndex)
      .map(([key, val]) => ({ key, display: val.display }))
      .sort((a, b) => a.display.localeCompare(b.display));

    /** Render all runs of a scenario into the OC history panel. */
    function renderOcHistory(key) {
      const record    = ocHistoryIndex[key];
      const summaryEl = document.getElementById('ocm-oh-summary');
      const tableWrap = document.getElementById('ocm-oh-table-wrap');
      if (!record || record.runs.length === 0) {
        summaryEl.style.display = 'none';
        tableWrap.innerHTML = `<div id="ocm-oh-empty">No history found for this scenario in the last 100 OCs.</div>`;
        return;
      }

      const runs      = record.runs;
      const total     = runs.length;
      const successes = runs.filter(r => r.outcome === 'successful').length;
      const failures  = runs.filter(r => r.outcome === 'failure').length;
      const rate      = (successes + failures) > 0 ? Math.round(successes / (successes + failures) * 100) : 0;
      const rateCol   = rate >= 85 ? 'var(--ocm-cpr-good)' : rate >= 65 ? 'var(--ocm-cpr-warn)' : 'var(--ocm-cpr-crit)';
      // Avg CPR across all runs
      const allCprs   = runs.filter(r => r.avgCpr != null).map(r => r.avgCpr);
      const overallAvgCpr = allCprs.length > 0 ? (allCprs.reduce((a, b) => a + b, 0) / allCprs.length).toFixed(1) : null;
      // Most common difficulty
      const diffCounts = {};
      for (const r of runs) diffCounts[r.difficulty] = (diffCounts[r.difficulty] || 0) + 1;
      const topDiff = Object.entries(diffCounts).sort((a, b) => b[1] - a[1])[0]?.[0] ?? '?';

      // Summary bar
      summaryEl.style.display = 'flex';
      summaryEl.innerHTML = `
        <div class="ocm-mh-sum-item">
          <span class="ocm-mh-sum-label">Runs</span>
          <span class="ocm-mh-sum-value">${total}</span>
        </div>
        <div class="ocm-mh-sum-item">
          <span class="ocm-mh-sum-label">Success Rate</span>
          <span class="ocm-mh-sum-value" style="color:${rateCol}">${rate}%</span>
        </div>
        <div class="ocm-mh-sum-item">
          <span class="ocm-mh-sum-label">W / F / E</span>
          <span class="ocm-mh-sum-value" style="font-size:12px">
            <span style="color:var(--ocm-cpr-good)">${successes}</span> /
            <span style="color:var(--ocm-cpr-crit)">${failures}</span> /
            <span style="color:#888">${total - successes - failures}</span>
          </span>
        </div>
        <div class="ocm-mh-sum-item">
          <span class="ocm-mh-sum-label">Avg CPR</span>
          <span class="ocm-mh-sum-value ${overallAvgCpr != null ? cprClass(Number(overallAvgCpr)) : ''}">${overallAvgCpr != null ? overallAvgCpr + '%' : '—'}</span>
        </div>
        <div class="ocm-mh-sum-item">
          <span class="ocm-mh-sum-label">Common Diff</span>
          <span class="ocm-mh-sum-value">D${topDiff}</span>
        </div>`;

      // Run rows — each expandable to show per-member slot detail
      const runsHtml = runs.map((r, idx) => {
        const dateStr  = r.executedAt
          ? new Date(r.executedAt * 1000).toLocaleDateString('en-GB', { timeZone: 'UTC', day: '2-digit', month: 'short', year: '2-digit' })
          : '—';
        const outIcon  = r.outcome === 'successful' ? '✅' : r.outcome === 'failure' ? '❌' : '⏰';
        const outCls   = r.outcome === 'successful' ? 'ocm-mh-outcome-success' : r.outcome === 'failure' ? 'ocm-mh-outcome-failure' : 'ocm-mh-outcome-expired';
        const outText  = r.outcome === 'successful' ? 'Success' : r.outcome === 'failure' ? 'Failure' : 'Expired';
        const respStr  = (r.outcome === 'successful' && r.respect != null) ? `${r.respect}` : '—';
        const respCol  = (r.outcome === 'successful' && r.respect != null) ? 'var(--ocm-cpr-warn)' : '#333';
        const avgStr   = r.avgCpr != null ? `${r.avgCpr.toFixed(1)}%` : '—';
        const avgCol   = r.avgCpr != null ? cprClass(r.avgCpr) : '';

        // Per-slot detail rows
        const slotRows = r.slots.map(sl => {
          const wStr  = sl.weight != null ? `${sl.weight.toFixed(0)}%` : '—';
          const wCls  = sl.weight != null ? (sl.weight >= WEIGHT_HIGH ? 'w-high' : sl.weight >= WEIGHT_MID ? 'w-mid' : 'w-low') : '';
          const cStr  = sl.cpr != null ? `${sl.cpr}%` : '—';
          const cCls  = sl.cpr != null ? cprClass(sl.cpr) : '';
          return `<tr>
            <td><a href="/profiles.php?XID=${sl.uid}" target="_blank" style="color:#ccc;text-decoration:none">${sl.name}</a></td>
            <td style="color:#888">${sl.role}</td>
            <td class="td-right ocm-slot-weight ${wCls}">${wStr}</td>
            <td class="td-right ${cCls}" style="font-weight:bold">${cStr}</td>
          </tr>`;
        }).join('');

        return `
          <div class="ocm-oh-run-header" data-oh-idx="${idx}" style="border-left:3px solid ${r.outcome === 'successful' ? '#3a5030' : r.outcome === 'failure' ? '#4a2820' : '#444'}">
            <span class="${outCls}">${outIcon}</span>
            <span style="color:#666;font-size:10px;flex:0 0 70px">${dateStr}</span>
            <span style="color:#888;font-size:10px;flex:0 0 30px">D${r.difficulty}</span>
            <span class="${outCls}" style="flex:1">${outText}</span>
            <span style="color:#aaa;font-size:10px">Avg CPR: <strong class="${avgCol}">${avgStr}</strong></span>
            <span style="color:${respCol};font-size:10px;flex:0 0 60px;text-align:right">${respStr !== '—' ? `${respStr} resp` : '—'}</span>
            <span style="color:#555;font-size:10px;margin-left:4px">▼</span>
          </div>
          <div class="ocm-oh-run-detail" id="ocm-oh-detail-${idx}">
            <table>
              <thead><tr>
                <th>Member</th><th>Role</th>
                <th class="td-right">Weight</th>
                <th class="td-right">CPR</th>
              </tr></thead>
              <tbody>${slotRows || '<tr><td colspan="4" style="color:#555;font-style:italic">No member data</td></tr>'}</tbody>
            </table>
          </div>`;
      }).join('');

      tableWrap.innerHTML = `<div>${runsHtml}</div>`;

      // Wire expand/collapse per run row
      tableWrap.querySelectorAll('.ocm-oh-run-header').forEach(header => {
        header.addEventListener('click', () => {
          const idx    = header.dataset.ohIdx;
          const detail = document.getElementById(`ocm-oh-detail-${idx}`);
          if (!detail) return;
          const isOpen = detail.style.display === 'block';
          detail.style.display = isOpen ? 'none' : 'block';
          const arrow = header.querySelector('span:last-child');
          if (arrow) arrow.textContent = isOpen ? '▼' : '▲';
        });
      });
    }

    // Wire OC history search UI
    const ohSearch   = document.getElementById('ocm-oh-search');
    const ohDropdown = document.getElementById('ocm-oh-dropdown');
    const ohClear    = document.getElementById('ocm-oh-clear');

    function updateOhDropdown(query) {
      if (!query.trim()) { ohDropdown.classList.remove('visible'); ohDropdown.innerHTML = ''; return; }
      const matches = ohAllScenarios.filter(s => s.display.toLowerCase().includes(query.toLowerCase()));
      if (matches.length === 0) { ohDropdown.classList.remove('visible'); ohDropdown.innerHTML = ''; return; }
      ohDropdown.innerHTML = matches.map(s =>
        `<div class="ocm-mh-option" data-key="${s.key}">${highlightMatch(s.display, query)}</div>`
      ).join('');
      ohDropdown.classList.add('visible');
      ohDropdown.querySelectorAll('.ocm-mh-option').forEach(opt => {
        opt.addEventListener('mousedown', e => {
          e.preventDefault();
          ohSearch.value = ocHistoryIndex[opt.dataset.key]?.display || '';
          ohDropdown.classList.remove('visible');
          renderOcHistory(opt.dataset.key);
        });
      });
    }

    ohSearch.addEventListener('input', () => {
      updateOhDropdown(ohSearch.value);
      const exact = ohAllScenarios.find(s => s.display.toLowerCase() === ohSearch.value.toLowerCase());
      if (exact) renderOcHistory(exact.key);
    });

    ohSearch.addEventListener('blur', () => {
      setTimeout(() => ohDropdown.classList.remove('visible'), 150);
    });

    ohSearch.addEventListener('focus', () => {
      if (ohSearch.value.trim()) updateOhDropdown(ohSearch.value);
    });

    ohClear.addEventListener('click', () => {
      ohSearch.value = '';
      ohDropdown.classList.remove('visible');
      document.getElementById('ocm-oh-summary').style.display = 'none';
      document.getElementById('ocm-oh-table-wrap').innerHTML =
        `<div id="ocm-oh-empty">Type an OC name above to view all runs of that scenario.</div>`;
    });



    function svgLine(canvasId, labels, values, color = 'var(--ocm-cpr-good)') {
      const el = document.getElementById(canvasId);
      if (!el) return;
      const W = 760, H = 120;
      const pad = { t: 10, r: 10, b: 30, l: 36 };
      const cW = W - pad.l - pad.r, cH = H - pad.t - pad.b;
      const valids = values.filter(v => v != null);
      if (!valids.length) { el.innerHTML = '<span style="color:#555;font-size:11px;padding:8px;display:block">Not enough data</span>'; return; }
      const xStep  = cW / Math.max(labels.length - 1, 1);
      const yScale = v => cH - (v / 100) * cH;
      let pathD    = '';
      values.forEach((v, i) => {
        if (v == null) return;
        const x = pad.l + i * xStep, y = pad.t + yScale(v);
        pathD += pathD === '' ? `M${x},${y}` : `L${x},${y}`;
      });
      const yTicks = [0, 25, 50, 75, 100];
      el.innerHTML = `<svg width="100%" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" style="display:block">
        ${yTicks.map(t => `
          <line x1="${pad.l}" y1="${pad.t + yScale(t)}" x2="${pad.l + cW}" y2="${pad.t + yScale(t)}" stroke="rgba(255,255,255,0.05)" stroke-width="1"/>
          <text x="${pad.l - 4}" y="${pad.t + yScale(t) + 4}" text-anchor="end" fill="#555" font-size="9">${t}%</text>`).join('')}
        <path d="${pathD}" fill="none" stroke="${color}" stroke-width="2" stroke-linejoin="round"/>
        ${values.map((v, i) => v != null ? `<circle cx="${pad.l + i * xStep}" cy="${pad.t + yScale(v)}" r="3" fill="${color}"/>` : '').join('')}
        ${labels.map((l, i) => (i === 0 || i === labels.length - 1 || i % Math.ceil(labels.length / 5) === 0)
          ? `<text x="${pad.l + i * xStep}" y="${H - 4}" text-anchor="middle" fill="#555" font-size="9">${l}</text>` : '').join('')}
      </svg>`;
    }

    function svgBarH(canvasId, labels, values) {
      const el = document.getElementById(canvasId);
      if (!el) return;
      const W = 760;
      const rowH = 22, pad = { t: 4, r: 54, b: 4, l: 160 };
      const H = labels.length * rowH + pad.t + pad.b;
      const cW = W - pad.l - pad.r;
      el.innerHTML = `<svg width="100%" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" style="display:block">
        ${labels.map((l, i) => {
          const barW = Math.max(2, (values[i] / 100) * cW);
          const y   = pad.t + i * rowH;
          const col = values[i] >= 85 ? 'var(--ocm-cpr-good)' : values[i] >= 65 ? 'var(--ocm-cpr-warn)' : 'var(--ocm-cpr-crit)';
          return `
            <text x="${pad.l - 6}" y="${y + rowH * 0.68}" text-anchor="end" fill="#aaa" font-size="10">${l}</text>
            <rect x="${pad.l}" y="${y + 3}" width="${barW}" height="${rowH - 6}" fill="${col}" rx="2" opacity="0.8"/>
            <text x="${pad.l + barW + 4}" y="${y + rowH * 0.68}" fill="#aaa" font-size="10">${values[i]}%</text>`;
        }).join('')}
      </svg>`;
    }

    function svgBarV(canvasId, labels, values, colors) {
      const el = document.getElementById(canvasId);
      if (!el) return;
      const W = 760, H = 160;
      const pad = { t: 16, r: 10, b: 50, l: 30 };
      const cW = W - pad.l - pad.r, cH = H - pad.t - pad.b;
      const maxV = Math.max(...values, 1);
      const gap  = cW / labels.length;
      const barW = Math.max(6, gap - 4);
      el.innerHTML = `<svg width="100%" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" style="display:block">
        <line x1="${pad.l}" y1="${pad.t + cH}" x2="${pad.l + cW}" y2="${pad.t + cH}" stroke="#333" stroke-width="1"/>
        ${values.map((v, i) => {
          const bH  = Math.max(2, (v / maxV) * cH);
          const x   = pad.l + i * gap + (gap - barW) / 2;
          const y   = pad.t + cH - bH;
          const col = colors ? colors[i] : 'var(--ocm-cpr-good)';
          const raw = labels[i];
          const lbl = raw.length > 10 ? raw.slice(0,9)+'…' : raw;
          return `
            <rect x="${x}" y="${y}" width="${barW}" height="${bH}" fill="${col}" rx="2" opacity="0.85"/>
            <text x="${x + barW/2}" y="${y - 3}" text-anchor="middle" fill="#aaa" font-size="9">${v}</text>
            <text x="${x + barW/2}" y="${pad.t + cH + 12}" text-anchor="end" fill="#555" font-size="9"
              transform="rotate(-40 ${x + barW/2} ${pad.t + cH + 12})">${lbl}</text>`;
        }).join('')}
      </svg>`;
    }

    /**
     * Render the requested chart into its container.
     * The heatmap fix: both heatData and heatScenarios now use normOcName() as their key,
     * eliminating the previous mismatch between raw oc.name keys and normalised scenario keys.
     */
    function renderCharts(targetId) {
      // 1. Success rate over time (weekly buckets)
      if (targetId === 'ocm-chart-timeline') {
        const byWeek = {};
        for (const oc of completed) {
          if (!oc.executed_at) continue;
          const d  = new Date(oc.executed_at * 1000);
          const wk = Math.ceil(d.getUTCDate() / 7);
          const key = `${d.getUTCFullYear()}-${String(d.getUTCMonth()+1).padStart(2,'0')}-W${wk}`;
          if (!byWeek[key]) byWeek[key] = { s: 0, f: 0 };
          if (normStatus(oc.status) === 'successful') byWeek[key].s++;
          else if (normStatus(oc.status) === 'failure') byWeek[key].f++;
        }
        const weekKeys  = Object.keys(byWeek).sort();
        const weekRates = weekKeys.map(k => { const { s, f } = byWeek[k]; return (s+f) > 0 ? Math.round(s/(s+f)*100) : null; });
        svgLine('ocm-chart-timeline', weekKeys.map(k => k.slice(5)), weekRates);
      }

      // 2. Success rate by scenario (horizontal bar)
      if (targetId === 'ocm-chart-scenario') {
        const scenSorted = [...scenarioRows]
          .filter(r => r.success + r.failure > 0)
          .sort((a, b) => (b.success/(b.success+b.failure)) - (a.success/(a.success+a.failure)));
        svgBarH('ocm-chart-scenario',
          scenSorted.map(r => r.name.length > 22 ? r.name.slice(0,20)+'…' : r.name),
          scenSorted.map(r => Math.round(r.success / (r.success + r.failure) * 100)),
        );
      }

      // 3. CPR distribution histogram
      if (targetId === 'ocm-chart-cpr') {
        const cprBuckets = { '0–60': 0, '60–70': 0, '70–80': 0, '80–90': 0, '90–100': 0 };
        for (const oc of completed) {
          const ocSlots = Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || []);
          for (const slot of ocSlots) {
            if (!slot.user?.id) continue;
            const cpr = slot.checkpoint_pass_rate ?? null;
            if (cpr === null) continue;
            if (cpr < 60)       cprBuckets['0–60']++;
            else if (cpr < 70)  cprBuckets['60–70']++;
            else if (cpr < 80)  cprBuckets['70–80']++;
            else if (cpr < 90)  cprBuckets['80–90']++;
            else                cprBuckets['90–100']++;
          }
        }
        svgBarV('ocm-chart-cpr', Object.keys(cprBuckets), Object.values(cprBuckets),
          ['var(--ocm-cpr-crit)','var(--ocm-cpr-warn)','var(--ocm-cpr-warn)','var(--ocm-cpr-good)','var(--ocm-link)']);
      }

      // 4. Member participation & success rate (top 20 current members)
      if (targetId === 'ocm-chart-members') {
        const topMembers = memberRows.filter(r => !r.isEx).slice(0, 20);
        svgBarV('ocm-chart-members',
          topMembers.map(r => r.name),
          topMembers.map(r => r.participated),
          topMembers.map(r => r.rate >= 0.85 ? 'var(--ocm-cpr-good)' : r.rate >= 0.65 ? 'var(--ocm-cpr-warn)' : 'var(--ocm-cpr-crit)'),
        );
      }

      // 5. Member × Scenario Heatmap
      // FIX: heatData is now keyed by normOcName(oc.name) to match heatScenarios
      // which comes from the normOcName()-keyed scenarioStats. Previously oc.name
      // was used as the key causing every cell lookup to miss.
      if (targetId === 'ocm-heatmap') {
        // Unique normalised scenario names that have at least one data point
        const heatScenarios = Object.keys(scenarioStats).sort();

        // Build heatData[memberUid][normOcName] = {s, f}
        const heatData = {};
        for (const oc of completed) {
          const s       = normStatus(oc.status);
          const normKey = normOcName(oc.name); // ← was oc.name (bug)
          const ocSlots = Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || []);
          for (const slot of ocSlots) {
            const uid = slot.user?.id ? String(slot.user.id) : null;
            if (!uid) continue;
            // Only include members who appear in memberRows (participated in at least one OC)
            if (!memberRows.find(m => m.uid === uid)) continue;
            if (!heatData[uid]) heatData[uid] = {};
            if (!heatData[uid][normKey]) heatData[uid][normKey] = { s: 0, f: 0 };
            if (s === 'successful') heatData[uid][normKey].s++;
            else if (s === 'failure') heatData[uid][normKey].f++;
          }
        }

        // Only show scenarios that have at least one cell of data
        const heatScens = heatScenarios.filter(sc =>
          memberRows.some(m => heatData[m.uid]?.[sc])
        );

        const cellSize = 30; // slightly larger for readability with 45 members
        const labelW   = 140;

        const heatEl = document.getElementById('ocm-heatmap');
        if (!heatEl) return;
        heatEl.innerHTML = `
          <table style="border-collapse:collapse;font-size:10px;min-width:${heatScens.length * cellSize + labelW}px">
            <thead><tr>
              <th style="min-width:${labelW}px"></th>
              ${heatScens.map(sc => `<th style="text-align:center;color:#666;padding:2px;writing-mode:vertical-rl;transform:rotate(180deg);height:80px;white-space:nowrap;font-weight:normal" title="${sc}">${sc.length>16 ? sc.slice(0,14)+'…' : sc}</th>`).join('')}
            </tr></thead>
            <tbody>${memberRows.map(m => {
              const hasAny = heatScens.some(sc => heatData[m.uid]?.[sc]);
              if (!hasAny) return '';
              return `<tr>
                <td style="padding:2px 6px;color:${m.isEx?'#555':'#ccc'};white-space:nowrap">${m.name}${m.isEx?' <span style="font-size:9px;color:#444">(left)</span>':''}</td>
                ${heatScens.map(sc => {
                  const d = heatData[m.uid]?.[sc];
                  if (!d || (d.s + d.f) === 0) return `<td style="width:${cellSize}px;height:${cellSize}px;background:#0a1020;border:1px solid #111" title="No data"></td>`;
                  const rate = d.s / (d.s + d.f);
                  const alpha = 0.3 + rate * 0.5;
                  const bg = rate >= 0.85
                    ? `rgba(68,238,136,${alpha})`
                    : rate >= 0.65
                      ? `rgba(255,170,0,${alpha})`
                      : `rgba(255,68,68,${0.3+(1-rate)*0.5})`;
                  return `<td style="width:${cellSize}px;height:${cellSize}px;background:${bg};border:1px solid #111;text-align:center;color:#e0e0e0;font-weight:bold;font-size:9px" title="${m.name} — ${sc}: ${d.s}/${d.s+d.f} (${Math.round(rate*100)}%)">${Math.round(rate*100)}%</td>`;
                }).join('')}
              </tr>`;
            }).join('')}
            </tbody>
          </table>`;
      }
    }

    // Wire chart show/hide toggles — chart is rendered on first click, then cached
    const chartRendered = {};
    analyticsEl.querySelectorAll('.ocm-chart-toggle').forEach(btn => {
      btn.addEventListener('click', () => {
        const target = document.getElementById(btn.dataset.target);
        if (!target) return;
        const visible = target.classList.toggle('visible');
        btn.textContent = visible ? 'Hide Chart' : 'Show Chart';
        if (visible && !chartRendered[btn.dataset.target]) {
          chartRendered[btn.dataset.target] = true;
          renderCharts(btn.dataset.target);
        }
      });
    });

    // ── Downloads section
    const downloadsEl = document.getElementById('ocm-downloads');

    function makeCSV(headers, rows) {
      const escape = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
      return [headers.map(escape).join(','), ...rows.map(r => r.map(escape).join(','))].join('\n');
    }

    function triggerDownload(filename, csv) {
      const blob = new Blob([csv], { type: 'text/csv' });
      const url  = URL.createObjectURL(blob);
      const a    = document.createElement('a');
      a.href = url; a.download = filename; a.click();
      URL.revokeObjectURL(url);
    }

    const dlButtons = [
      // ── Active state exports
      {
        icon: '📋', label: 'Active OC State', desc: 'All active OCs with slots, member, CPR, weight, item status',
        fn: () => {
          const headers = ['OC Name','Difficulty','Status','Role','Member','CPR%','Weight%','Item Status','Blocked'];
          const rows = [];
          for (const oc of [...planningAll, ...recruitingAll]) {
            const ocSlots = Array.isArray(oc.oc.slots) ? oc.oc.slots : Object.values(oc.oc.slots || []);
            for (const slot of ocSlots) {
              const uid    = slot.user?.id ? String(slot.user.id) : null;
              const name   = uid ? (mInfo[uid]?.name || uid) : 'Open';
              const cpr    = slot.checkpoint_pass_rate ?? '';
              const role   = slot.position_info?.label || slot.position || '';
              const w      = getWeight(oc.oc.name || '', role);
              const req    = slot.item_requirement;
              const itemSt = !req ? '' : !uid ? 'needed' : req.is_available ? 'has item' : armory[String(req.id)] ? 'in armory' : 'missing';
              const blocked = uid && mInfo[uid] && isBlocked(mInfo[uid].status) ? mInfo[uid].status : '';
              rows.push([oc.oc.name, oc.oc.difficulty, oc.badgeLabel, role, name, cpr, w != null ? w.toFixed(1) : '', itemSt, blocked]);
            }
          }
          triggerDownload(`ocm_active_ocs_${Date.now()}.csv`, makeCSV(headers, rows));
        },
      },
      {
        icon: '🚨', label: 'Stuck OCs', desc: 'Fully planned OCs blocked from initiating by an unavailable member',
        fn: () => {
          const headers = ['OC Name','Difficulty','Blocking Member','Member Status','Expires At'];
          const rows = [];
          for (const { oc, blockers } of stuckOcs) {
            for (const b of blockers) {
              const expiry = oc.expired_at
                ? new Date(oc.expired_at * 1000).toLocaleString('en-GB', { timeZone: 'UTC' })
                : '';
              rows.push([oc.name || '', oc.difficulty ?? '', b.name, b.status, expiry]);
            }
          }
          if (rows.length === 0) rows.push(['No stuck OCs','','','','']);
          triggerDownload(`ocm_stuck_ocs_${Date.now()}.csv`, makeCSV(headers, rows));
        },
      },
      {
        icon: '⚠', label: 'Low CPR Report', desc: 'Filled slots below CPR warn threshold, sorted by risk',
        fn: () => {
          const headers = ['Member','OC','Role','CPR%','Weight%','High Risk'];
          const rows = lowCprRows.map(r => [r.name, r.ocName, r.roleName, r.cpr, r.weight != null ? r.weight.toFixed(1) : '', r.isRisk ? 'Yes' : 'No']);
          triggerDownload(`ocm_low_cpr_${Date.now()}.csv`, makeCSV(headers, rows));
        },
      },
      // ── Member state exports
      {
        icon: '👥', label: 'Member Availability', desc: 'Members not currently in any OC, with last OC and last online',
        fn: () => {
          const headers = ['Member','Last OC','Last OC Date','Status'];
          const rows = freeMembers.map(m => {
            const oc = lastOc[m.id];
            const ts = oc ? new Date(oc.executed_at * 1000).toLocaleDateString('en-GB') : '';
            return [m.name, oc ? oc.name : 'No record', ts, m.status || ''];
          });
          triggerDownload(`ocm_availability_${Date.now()}.csv`, makeCSV(headers, rows));
        },
      },
      {
        icon: '🚧', label: 'Recruits', desc: 'Members currently holding Recruit rank — ineligible for OCs',
        fn: () => {
          const headers = ['Member','Last OC','Last OC Date','Last Online'];
          const rows = freeRecruits.map(m => {
            const member = Object.values(memberMap).find(x => String(x.id) === m.id);
            const oc     = lastOc[m.id];
            const ocDate = oc ? new Date(oc.executed_at * 1000).toLocaleDateString('en-GB') : '';
            const seenTs = member?.last_action?.timestamp ?? null;
            const seenStr = seenTs ? new Date(seenTs * 1000).toLocaleString('en-GB') : '';
            return [m.name, oc ? oc.name : 'No record', ocDate, seenStr];
          });
          if (rows.length === 0) rows.push(['No recruits','','','']);
          triggerDownload(`ocm_recruits_${Date.now()}.csv`, makeCSV(headers, rows));
        },
      },
      {
        icon: '🔴', label: 'Blocked Members', desc: 'Members in an OC who are jailed, hospitalised, or abroad',
        fn: () => {
          const headers = ['Member','Status','Description','OC Name','OC Executes At'];
          const rows = allBlocked.map(b => {
            const execStr = b.ocExecutesAt
              ? new Date(b.ocExecutesAt * 1000).toLocaleString('en-GB', { timeZone: 'UTC' })
              : '';
            return [b.name, b.status || '', b.description || '', b.ocName || '', execStr];
          });
          if (rows.length === 0) rows.push(['No blocked members','','','','']);
          triggerDownload(`ocm_blocked_${Date.now()}.csv`, makeCSV(headers, rows));
        },
      },
      // ── Analytics exports
      {
        icon: '📊', label: 'Member Analytics', desc: 'OC participation, success rate, avg CPR per member',
        fn: () => {
          const headers = ['Member','OCs Participated','Successes','Failures','Win%','Avg CPR%'];
          const rows = memberRows.map(r => [r.name, r.participated, r.success, r.failure, pct(r.rate), r.avgCpr != null ? r.avgCpr.toFixed(1) : '']);
          triggerDownload(`ocm_member_analytics_${Date.now()}.csv`, makeCSV(headers, rows));
        },
      },
      {
        icon: '🏆', label: 'Scenario Analytics', desc: 'Success rates and run counts per OC scenario',
        fn: () => {
          const headers = ['Scenario','Times Run','Successes','Failures','Expired','Win%'];
          const rows = scenarioRows.map(r => [r.name, r.total, r.success, r.failure, r.expired, pct(r.rate)]);
          triggerDownload(`ocm_scenario_analytics_${Date.now()}.csv`, makeCSV(headers, rows));
        },
      },
      {
        icon: '🗂', label: 'Full OC History', desc: 'Every completed OC slot — one row per member per OC, with all fields',
        fn: () => {
          const headers = ['Date','OC Name','Difficulty','Member','Role','Weight%','CPR%','Outcome','Respect'];
          const rows = [];
          for (const oc of [...completed].sort((a, b) => (b.executed_at || 0) - (a.executed_at || 0))) {
            const s       = normStatus(oc.status);
            const ocSlots = Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || []);
            const dateStr = oc.executed_at
              ? new Date(oc.executed_at * 1000).toLocaleDateString('en-GB', { timeZone: 'UTC' })
              : '';
            const respect = oc.rewards?.respect ?? '';
            for (const slot of ocSlots) {
              const uid  = slot.user?.id ? String(slot.user.id) : null;
              if (!uid) continue;
              const name = mInfo[uid]?.name || exMemberNames[uid] || `#${uid}`;
              const role = slot.position_info?.label || slot.position || '';
              const w    = getWeight(oc.name || '', role);
              const cpr  = slot.checkpoint_pass_rate ?? '';
              const out  = s === 'successful' ? 'Success' : s === 'failure' ? 'Failure' : 'Expired';
              rows.push([dateStr, oc.name || '', oc.difficulty ?? '', name, role, w != null ? w.toFixed(1) : '', cpr, out, s === 'successful' ? respect : '']);
            }
          }
          triggerDownload(`ocm_full_history_${Date.now()}.csv`, makeCSV(headers, rows));
        },
      },
      {
        icon: '🔥', label: 'Member × Scenario Heatmap', desc: 'Success rate per member per scenario — flat table for spreadsheet use',
        fn: () => {
          // Collect all normalised scenario names with data
          const heatScens = Object.keys(scenarioStats).sort();
          // Build heatmap data using same normOcName keying as the chart
          const heatData = {};
          for (const oc of completed) {
            const s       = normStatus(oc.status);
            const normKey = normOcName(oc.name);
            const ocSlots = Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || []);
            for (const slot of ocSlots) {
              const uid = slot.user?.id ? String(slot.user.id) : null;
              if (!uid) continue;
              if (!heatData[uid]) heatData[uid] = {};
              if (!heatData[uid][normKey]) heatData[uid][normKey] = { s: 0, f: 0 };
              if (s === 'successful') heatData[uid][normKey].s++;
              else if (s === 'failure') heatData[uid][normKey].f++;
            }
          }
          const headers = ['Member', ...heatScens];
          const rows = memberRows.map(m => {
            const cells = heatScens.map(sc => {
              const d = heatData[m.uid]?.[sc];
              if (!d || (d.s + d.f) === 0) return '';
              return `${Math.round(d.s / (d.s + d.f) * 100)}%`;
            });
            return [m.name, ...cells];
          });
          triggerDownload(`ocm_heatmap_${Date.now()}.csv`, makeCSV(headers, rows));
        },
      },
      {
        icon: '📝', label: 'Member OC History', desc: 'Full per-member OC history — one row per slot across all 100 completed OCs',
        fn: () => {
          const headers = ['Member','Date','OC Name','Difficulty','Role','Weight%','CPR%','Outcome','Respect'];
          const rows = [];
          // Iterate memberHistoryIndex already built above
          for (const [uid, record] of Object.entries(memberHistoryIndex)) {
            for (const e of record.entries) {
              const dateStr = e.executedAt
                ? new Date(e.executedAt * 1000).toLocaleDateString('en-GB', { timeZone: 'UTC' })
                : '';
              const out  = e.outcome === 'successful' ? 'Success' : e.outcome === 'failure' ? 'Failure' : 'Expired';
              const resp = (e.outcome === 'successful' && e.respect != null) ? e.respect : '';
              rows.push([record.name, dateStr, e.ocName, e.difficulty, e.role, e.weight != null ? e.weight.toFixed(1) : '', e.cpr ?? '', out, resp]);
            }
          }
          // Sort by member name then date desc
          rows.sort((a, b) => a[0].localeCompare(b[0]) || (b[1] < a[1] ? -1 : 1));
          triggerDownload(`ocm_member_history_${Date.now()}.csv`, makeCSV(headers, rows));
        },
      },
    ];

    const dlGroups = [
      { label: '📋 Active State',   indices: [0, 1, 2] },
      { label: '👤 Member State',   indices: [3, 4, 5] },
      { label: '📊 Analytics',      indices: [6, 7, 8, 9, 10] },
    ];

    downloadsEl.innerHTML = dlGroups.map(g => `
      <div style="margin-bottom:10px">
        <div style="font-size:10px;color:#555;text-transform:uppercase;letter-spacing:.5px;margin-bottom:5px;padding-bottom:3px;border-bottom:1px solid #1e1e1e">${g.label}</div>
        <div class="ocm-downloads-grid">
          ${g.indices.map(i => `<button class="ocm-dl-btn" data-dl="${i}"><strong>${dlButtons[i].icon} ${dlButtons[i].label}</strong><span>${dlButtons[i].desc}</span></button>`).join('')}
        </div>
      </div>`).join('');

    downloadsEl.querySelectorAll('.ocm-dl-btn').forEach(btn => {
      btn.addEventListener('click', () => dlButtons[Number(btn.dataset.dl)].fn());
    });

    document.getElementById('ocm-last-update').textContent = `Updated ${new Date().toLocaleTimeString()}`;
    updateTabCounts();
    startCountdowns();
  }

  /** Start the 1-second interval that updates all live countdown timers in the DOM. */
  function startCountdowns() {
    clearInterval(window._ocmTimer);
    window._ocmTimer = setInterval(() => {
      document.querySelectorAll('.ocm-time[data-until]').forEach(el => {
        el.textContent = fmtCountdown(parseInt(el.dataset.until, 10));
      });
    }, 1000);
  }

  // ─── MEMBER MODE ─────────────────────────────────────────────────────────────

  /** Fetch data for member mode (no faction API access). */
  async function fetchMember(apiKey) {
    const url  = `${API_BASE}/user?selections=organizedcrimes,basic&key=${apiKey}&comment=OCManager`;
    const res  = await fetch(url);
    const data = await res.json();
    if (data.error) throw new Error(`API error ${data.error.code}: ${data.error.error}`);
    return data;
  }

  /** Render the member-mode dashboard (slot recommendations only, no faction data). */
  function renderMemberDashboard(data) {
    document.getElementById('ocm-body').style.display       = 'block';
    document.getElementById('ocm-stats-bar').style.display  = 'none';
    document.getElementById('ocm-next-banner').style.display = 'none';

    ['ocm-title-available','ocm-available','ocm-title-recruits','ocm-recruits',
     'ocm-title-blocked','ocm-blocked','ocm-title-lowcpr','ocm-lowcpr',
     'ocm-title-overqualified','ocm-overqualified',
     'ocm-title-tscpr','ocm-tscpr',
     'ocm-leader-advice',
     'ocm-planning-header','ocm-grid-planning','ocm-recruiting-header',
     'ocm-grid-recruiting','ocm-title-analytics','ocm-analytics',
     'ocm-title-downloads','ocm-downloads'].forEach(id => {
      const el = document.getElementById(id);
      if (el) el.style.display = 'none';
    });
    // Hide tab strip; force all tab panes visible so the member-mode content (which is
    // injected inside panes) still renders. The member-mode UI is much smaller and
    // doesn't benefit from tabbing.
    const tabsEl = document.getElementById('ocm-tabs');
    if (tabsEl) tabsEl.style.display = 'none';
    document.querySelectorAll('.ocm-tab-pane').forEach(p => p.classList.add('active'));

    const crimes     = data.organizedcrimes || data.organized_crimes || {};
    const memberName = data.name || 'You';
    const myId       = data.player_id ? String(data.player_id) : null;
    const nowTs      = Math.floor(Date.now() / 1000);

    const errEl = document.getElementById('ocm-error');
    errEl.style.display = 'none';

    const footer = document.getElementById('ocm-footer');
    footer.innerHTML = '';

    const container = document.createElement('div');
    container.style.cssText = 'margin-top:4px';

    // Mode notice
    const notice = document.createElement('div');
    notice.style.cssText = 'background:#1e1e1e;border:0.5px solid #2e2e2e;border-left:3px solid #3a3018;border-radius:0 6px 6px 0;padding:8px 12px;font-size:11px;color:#99a;margin-bottom:10px';
    notice.innerHTML = '<strong style="color:var(--ocm-cpr-warn)">Member Mode</strong> \u2014 faction-wide data requires Faction API access on your role. Ask your faction leader.';
    container.appendChild(notice);

    // Detect whether the member is currently assigned to an active OC.
    // Scan all OCs returned by the API; find the one where a slot user.id
    // matches the member own player_id (returned as data.player_id).
    let currentOc = null;
    let mySlot    = null;

    for (const oc of Object.values(crimes)) {
      if (!oc) continue;
      const phase   = (oc.status || '').toLowerCase();
      // Skip terminal and recruiting phases
      if (['completed','expired','cancelled','failed','success','recruiting'].includes(phase)) continue;
      const ocSlots = Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || []);
      const mine    = myId ? ocSlots.find(s => s.user && String(s.user.id) === myId) : null;
      if (mine) { currentOc = oc; mySlot = mine; break; }
    }

    if (currentOc) {
      // ── CURRENT OC CARD ──────────────────────────────────────────────────────
      const phase   = (currentOc.status || '').toLowerCase();
      const ocSlots = Array.isArray(currentOc.slots) ? currentOc.slots : Object.values(currentOc.slots || []);

      // Determine countdown / timer display
      const executesAt = (currentOc.executed_at && currentOc.executed_at > nowTs ? currentOc.executed_at : null)
                      || (currentOc.ready_at    && currentOc.ready_at    > nowTs ? currentOc.ready_at    : null);
      const timeLeft   = currentOc.time_left != null ? currentOc.time_left : null;
      const expiredAt  = currentOc.expired_at != null ? currentOc.expired_at : null;
      const openCount  = ocSlots.filter(s => !s.user).length;

      let timerHtml;
      if (executesAt) {
        const tctStr  = new Date(executesAt * 1000).toLocaleTimeString('en-GB', { timeZone: 'UTC', hour: '2-digit', minute: '2-digit', hour12: false });
        const tctDate = new Date(executesAt * 1000).toLocaleDateString('en-GB', { timeZone: 'UTC', day: '2-digit', month: 'short' });
        timerHtml = '\u23F1 <span class="ocm-time" data-until="' + executesAt + '">' + fmtTime(executesAt - nowTs) + '</span>'
          + ' <span style="color:#555;font-size:10px">(' + tctDate + ' ' + tctStr + ' TCT)</span>';
      } else if (timeLeft > 0) {
        timerHtml = '\u23F8 ~' + fmtTime(timeLeft) + ' remaining <span style="color:#555;font-size:10px">(paused)</span>';
      } else if (openCount > 0) {
        timerHtml = '\u23F8 ~' + fmtTime(openCount * 24 * 3600) + ' est.'
          + ' <span style="color:#555;font-size:10px">(' + openCount + ' open slot' + (openCount > 1 ? 's' : '') + ' x 24h)</span>';
      } else if (expiredAt && expiredAt > nowTs) {
        timerHtml = '\u23F3 Expires in <span class="ocm-time" data-until="' + expiredAt + '">' + fmtTime(expiredAt - nowTs) + '</span>';
      } else {
        timerHtml = '<span style="color:var(--ocm-cpr-good);font-weight:bold">Ready to initiate!</span>';
      }

      // My slot stats
      const myRole   = mySlot && mySlot.position_info && mySlot.position_info.label ? mySlot.position_info.label : (mySlot && mySlot.position ? mySlot.position : '?');
      const myCpr    = mySlot && mySlot.checkpoint_pass_rate != null ? mySlot.checkpoint_pass_rate : null;
      const myWeight = getWeight(currentOc.name || '', myRole);
      const myProg   = mySlot && mySlot.user && mySlot.user.progress != null ? mySlot.user.progress : null;
      const cprCol   = myCpr == null ? '#555' : myCpr >= CPR_WARN ? 'var(--ocm-cpr-good)' : myCpr >= CPR_CRIT ? 'var(--ocm-cpr-warn)' : 'var(--ocm-cpr-crit)';
      const wCol     = myWeight == null ? '#555' : myWeight >= WEIGHT_HIGH ? 'var(--ocm-cpr-warn)' : myWeight >= WEIGHT_MID ? '#aaa' : '#555';
      const progPct  = Math.min(100, Math.max(0, myProg != null ? myProg : 0));
      const progCol  = progPct >= 100 ? 'var(--ocm-cpr-good)' : 'var(--ocm-cpr-warn)';
      const progLabel = myProg == null ? 'No progress data' : myProg >= 100 ? 'Planning complete \u2713' : ('Planning: ' + progPct.toFixed(0) + '%');

      // Phase badge
      const phaseBadge = phase === 'ready'
        ? '<span style="background:#14201a;color:var(--ocm-cpr-good);font-size:10px;padding:1px 6px;border-radius:3px;font-weight:bold">READY</span>'
        : phase === 'blocked'
          ? '<span style="background:#181818;color:#bf7abf;font-size:10px;padding:1px 6px;border-radius:3px;font-weight:bold">BLOCKED</span>'
          : '<span style="background:#14181e;color:var(--ocm-link);font-size:10px;padding:1px 6px;border-radius:3px;font-weight:bold">PLANNING</span>';

      // Detect blocked members and stuck status
      const blockedSlots = ocSlots.filter(s => {
        if (!s.user || !s.user.id) return false;
        const st = (s.user.status ? (s.user.status.state || s.user.status.description || '') : '').toLowerCase();
        return st === 'hospital' || st === 'jail' || st === 'traveling' || st === 'abroad';
      });
      const allFilled  = ocSlots.every(s => s.user && s.user.id);
      const allPlanned = ocSlots.every(s => {
        const p = s.user && s.user.progress != null ? s.user.progress : 100;
        return p >= 100;
      });
      const isStuck = allFilled && allPlanned && blockedSlots.length > 0;

      // Alert banner
      let alertHtml = '';
      if (isStuck) {
        alertHtml = '<div style="background:#1c1810;border:1px solid #4a2820;border-radius:4px;padding:6px 10px;margin-bottom:8px;font-size:11px;color:var(--ocm-cpr-crit)">'
          + '\uD83D\uDEA8 <strong>Stuck</strong> \u2014 OC is fully planned but cannot initiate. '
          + blockedSlots.length + ' member' + (blockedSlots.length > 1 ? 's are' : ' is') + ' unavailable.</div>';
      } else if (blockedSlots.length > 0) {
        alertHtml = '<div style="background:#181410;border:1px solid #3a3018;border-radius:4px;padding:6px 10px;margin-bottom:8px;font-size:11px;color:var(--ocm-cpr-warn)">'
          + '\u26A0 ' + blockedSlots.length + ' member' + (blockedSlots.length > 1 ? 's are' : ' is') + ' currently jailed, hospitalised, or abroad.</div>';
      }

      // All slots list
      const otherSlotsHtml = ocSlots.map(s => {
        const isMe   = myId && s.user && String(s.user.id) === myId;
        const uid    = s.user ? String(s.user.id) : null;
        const name   = uid ? (s.user.name || ('#' + uid)) : 'Open slot';
        const role   = s.position_info && s.position_info.label ? s.position_info.label : (s.position || '?');
        const prog   = s.user && s.user.progress != null ? s.user.progress : null;
        const st     = s.user && s.user.status ? (s.user.status.state || s.user.status.description || '') : '';
        const stL    = st.toLowerCase();
        const desc   = s.user && s.user.status ? (s.user.status.description || '') : '';
        const stIcon = stL === 'okay'     ? '<span style="color:var(--ocm-cpr-good)">\u2713</span>'
          : stL === 'hospital' ? '\uD83C\uDFE5'
          : stL === 'jail'     ? '\u26D3'
          : stL === 'traveling'? '\u2708'
          : stL === 'abroad'   ? (flagFromDescription(desc) + ' ')
          : uid                ? '<span style="color:#555">?</span>'
          : '<span style="color:var(--ocm-cpr-crit)">\u2717</span>';
        const progStr = prog == null ? '' : prog >= 100
          ? '<span style="color:var(--ocm-cpr-good);font-size:9px">\u2713 done</span>'
          : '<span style="color:var(--ocm-cpr-warn);font-size:9px">' + prog.toFixed(0) + '%</span>';
        const nameStyle = isMe ? 'color:var(--ocm-link);font-weight:bold' : !uid ? 'color:#555;font-style:italic' : 'color:#ccc';
        return '<div style="display:flex;align-items:center;gap:6px;padding:3px 0;font-size:11px;border-bottom:1px solid #111">'
          + '<span style="flex:0 0 14px;text-align:center">' + stIcon + '</span>'
          + '<span style="flex:0 0 90px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#888" title="' + role + '">' + role + '</span>'
          + '<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' + nameStyle + '">' + name + (isMe ? ' (you)' : '') + '</span>'
          + progStr
          + '</div>';
      }).join('');

      const borderCol = isStuck ? '#4a2820' : blockedSlots.length ? '#3a3018' : phase === 'ready' ? 'var(--ocm-cpr-good)' : '#2a3040';
      const card = document.createElement('div');
      card.style.cssText = 'background:#1e1e1e;border:1px solid ' + borderCol + ';border-radius:6px;padding:10px 12px;margin-bottom:10px';
      card.innerHTML =
        '<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;flex-wrap:wrap">'
          + '<span style="font-size:13px;font-weight:bold;color:#e0e0e0">' + (currentOc.name || 'Your OC') + '</span>'
          + '<span style="color:#666;font-size:10px">D' + (currentOc.difficulty != null ? currentOc.difficulty : '?') + ' \xB7 ' + ocSlots.length + ' slots</span>'
          + phaseBadge
        + '</div>'
        + '<div style="font-size:11px;color:#aaa;margin-bottom:8px">' + timerHtml + '</div>'
        + alertHtml
        + '<div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:10px;padding:6px 10px;background:#14181e;border-radius:4px;font-size:11px">'
          + '<div><div style="font-size:9px;color:#555;text-transform:uppercase;letter-spacing:.5px">Your Role</div><div style="color:#ccc;font-weight:bold">' + myRole + '</div></div>'
          + '<div><div style="font-size:9px;color:#555;text-transform:uppercase;letter-spacing:.5px">Weight</div><div style="font-weight:bold;color:' + wCol + '">' + (myWeight != null ? myWeight.toFixed(0) + '%' : '\u2014') + '</div></div>'
          + '<div><div style="font-size:9px;color:#555;text-transform:uppercase;letter-spacing:.5px">Your CPR</div><div style="font-weight:bold;color:' + cprCol + '">' + (myCpr != null ? myCpr + '%' : '\u2014') + '</div></div>'
          + '<div style="flex:1;min-width:120px">'
            + '<div style="font-size:9px;color:#555;text-transform:uppercase;letter-spacing:.5px;margin-bottom:3px">' + progLabel + '</div>'
            + '<div style="height:5px;background:#0a1020;border-radius:3px;overflow:hidden">'
              + '<div style="height:100%;width:' + progPct + '%;background:' + progCol + ';border-radius:3px;transition:width .3s"></div>'
            + '</div>'
          + '</div>'
        + '</div>'
        + '<div style="font-size:9px;color:#555;text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px">All Slots</div>'
        + '<div>' + otherSlotsHtml + '</div>';

      container.appendChild(card);

    } else {
      // ── NOT IN AN OC — show open slot recommendations ─────────────────────────

      const slots = [];
      for (const oc of Object.values(crimes)) {
        if (!oc || (oc.status || '').toLowerCase() !== 'recruiting') continue;
        const ocSlots = Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || []);
        for (const slot of ocSlots) {
          if (slot.user && slot.user.id) continue;
          const role   = slot.position_info && slot.position_info.label ? slot.position_info.label : (slot.position || 'Unknown');
          const cpr    = slot.checkpoint_pass_rate != null ? slot.checkpoint_pass_rate : null;
          const weight = getWeight(oc.name || '', role);
          slots.push({
            ocName: oc.name || 'Unknown OC', ocId: oc.id, role, cpr, weight,
            difficulty: oc.difficulty != null ? oc.difficulty : '?',
            expiredAt:  oc.expired_at  != null ? oc.expired_at  : null,
            timeLeft:   oc.time_left   != null ? oc.time_left   : null,
          });
        }
      }

      function urgencyBonus(s) {
        let bonus = 0;
        if (s.expiredAt) {
          const secsToExpiry = s.expiredAt - nowTs;
          if (secsToExpiry > 0 && secsToExpiry < 6  * 3600) bonus += 500;
          else if (secsToExpiry > 0 && secsToExpiry < 24 * 3600) bonus += 200;
        }
        if (s.timeLeft != null) {
          if (s.timeLeft < 12 * 3600) bonus += 100;
          else if (s.timeLeft < 24 * 3600) bonus +=  50;
        }
        return Math.min(bonus, 999);
      }

      const scored = slots.map(s => {
        const cpr    = s.cpr    != null ? s.cpr    : 0;
        const weight = s.weight != null ? s.weight : 15;
        const diff   = Number(s.difficulty) || 0;
        let tag;
        if      (cpr < CPR_CRIT)           tag = 'risky';
        else if (cpr < CPR_WARN)           tag = 'marginal';
        else if (weight < WEIGHT_MID)      tag = 'underutilised';
        else                               tag = 'good';
        const eligible    = cpr >= CPR_WARN;
        const comfort     = eligible ? Math.max(0, (cpr - CPR_WARN) / (100 - CPR_WARN)) : 0;
        const weightBonus = weight * comfort;
        const score       = eligible
          ? diff * 1000 + urgencyBonus(s) + weightBonus + cpr
          : -(1000 - cpr);
        return Object.assign({}, s, { score, tag, eligible, urgent: urgencyBonus(s) > 0 });
      }).sort((a, b) => b.score - a.score);

      if (scored.length === 0) {
        const empty = document.createElement('div');
        empty.style.cssText = 'background:#1e1e1e;border:0.5px solid #2e2e2e;border-radius:6px;padding:12px;text-align:center;color:#555;font-size:12px';
        empty.textContent = 'No open recruiting slots found. All current OCs are full or in planning.';
        container.appendChild(empty);
      } else {
        const eligible   = scored.filter(s => s.eligible);
        // Only consider slots with REAL CPR data for the fallback — a slot with
        // null/unknown CPR (common in member mode for roles you're not slotted in)
        // shows as 0% and is meaningless as a "least risky" recommendation.
        const ineligible = scored.filter(s => !s.eligible && s.cpr != null && s.cpr > 0);
        const fallback   = eligible.length === 0 && ineligible.length > 0
          ? [ineligible.sort((a, b) => {
              if ((b.cpr || 0) !== (a.cpr || 0)) return (b.cpr || 0) - (a.cpr || 0);
              return (a.weight || 50) - (b.weight || 50);
            })[0]]
          : [];

        const belowWarn = eligible.length === 0 && fallback.length > 0;
        // No eligible slots AND no below-threshold slots with real CPR data either
        const noCprData = eligible.length === 0 && fallback.length === 0;

        if (noCprData) {
          const info = document.createElement('div');
          info.style.cssText = 'background:#1e1e1e;border:0.5px solid #2e2e2e;border-radius:6px;padding:12px;color:#99a;font-size:12px;line-height:1.5';
          info.innerHTML = 'No CPR data available for the open recruiting slots. '
            + 'In member mode, Torn only returns your checkpoint pass rate for crimes you\u2019ve run before. '
            + 'Run any OC once to build a CPR baseline, or ask your faction leader for the leader-mode view.';
          container.appendChild(info);
          footer.appendChild(container);
          startCountdowns();
          GM_setValue('ocm_sidebar_cache', JSON.stringify({
            name: 'Member Mode', executesAt: null, timeLeft: null, openCount: 0,
            severity: 'ok', issues: [], cachedAt: Math.floor(Date.now() / 1000), memberMode: true,
          }));
          renderSidebarWidget();
          document.getElementById('ocm-last-update').textContent = 'Updated ' + new Date().toLocaleTimeString() + ' \xB7 Member Mode';
          return;
        }

        const title = document.createElement('div');
        title.style.cssText = 'color:var(--ocm-link);font-size:12px;font-weight:bold;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;border-bottom:1px solid #333;padding-bottom:3px';
        title.textContent = belowWarn
          ? ('No suitable slots above ' + CPR_WARN + '% CPR \u2014 showing least risky option')
          : ('Best slots for ' + memberName + ' (top ' + Math.min(5, eligible.length) + ' of ' + scored.length + ')');
        container.appendChild(title);

        if (belowWarn) {
          const warn = document.createElement('div');
          warn.style.cssText = 'background:#1c1810;border:0.5px solid #4a2820;border-radius:6px;padding:8px 12px;margin-bottom:8px;font-size:11px;color:var(--ocm-cpr-warn)';
          warn.innerHTML = '\u26A0 All open slots are below your CPR warn threshold (' + CPR_WARN + '%). The option below is the least likely to cause the OC to fail \u2014 but consider waiting for a more suitable slot to open up.';
          container.appendChild(warn);
        }

        const display = (belowWarn ? fallback : scored).slice(0, 5);
        for (const s of display) {
          const card      = document.createElement('div');
          const borderCol = s.tag === 'good' ? '#3a5030' : s.tag === 'risky' ? '#4a2820' : s.tag === 'marginal' ? '#1c1810' : '#3a3018';
          card.style.cssText = 'background:#1e1e1e;border:0.5px solid ' + borderCol + ';border-radius:6px;padding:8px 12px;margin-bottom:6px';

          const cprCol = s.cpr == null ? '#555' : s.cpr >= CPR_WARN ? 'var(--ocm-cpr-good)' : s.cpr >= CPR_CRIT ? 'var(--ocm-cpr-warn)' : 'var(--ocm-cpr-crit)';
          const wCol   = s.weight == null ? '#555' : s.weight >= WEIGHT_HIGH ? 'var(--ocm-cpr-warn)' : s.weight >= WEIGHT_MID ? '#aaa' : '#555';
          const cprStr = s.cpr    != null ? (s.cpr    + '%') : '?';
          const wStr   = s.weight != null ? (s.weight.toFixed(0) + '%') : '?';

          let tagHtml = '';
          if      (s.tag === 'good')          tagHtml = '<span style="font-size:10px;background:#14201a;color:var(--ocm-cpr-good);border-radius:3px;padding:1px 6px;margin-left:6px">&#10003; Good fit</span>';
          else if (s.tag === 'underutilised') tagHtml = '<span style="font-size:10px;background:#1c1810;color:var(--ocm-cpr-warn);border-radius:3px;padding:1px 6px;margin-left:6px">&#9432; Low-weight role</span>';
          else if (s.tag === 'marginal')      tagHtml = '<span style="font-size:10px;background:#1c1810;color:var(--ocm-cpr-warn);border-radius:3px;padding:1px 6px;margin-left:6px">&#9888; Marginal CPR</span>';
          else if (s.tag === 'risky')         tagHtml = '<span style="font-size:10px;background:#1c1410;color:var(--ocm-cpr-crit);border-radius:3px;padding:1px 6px;margin-left:6px">&#9888; Below threshold</span>';

          if (s.urgent) {
            const secsLeft = s.expiredAt ? s.expiredAt - Math.floor(Date.now()/1000) : null;
            const urgLabel = secsLeft != null && secsLeft < 6 * 3600
              ? '&#9201; Expires soon'
              : secsLeft != null && secsLeft < 24 * 3600
                ? '&#9201; Expiring today'
                : '&#9201; Nearly ready';
            tagHtml += '<span style="font-size:10px;background:#181810;color:var(--ocm-cpr-warn);border-radius:3px;padding:1px 6px;margin-left:4px">' + urgLabel + '</span>';
          }

          let adviceHtml = '';
          if      (s.tag === 'underutilised') adviceHtml = '<div style="font-size:10px;color:#99a;margin-top:4px">This role has low weight (' + wStr + ') \u2014 your ' + cprStr + ' CPR won\u2019t make much difference here. Check if a higher-weight role is available at this difficulty.</div>';
          else if (s.tag === 'marginal')      adviceHtml = '<div style="font-size:10px;color:#99a;margin-top:4px">Your CPR is slightly below the ' + CPR_WARN + '% threshold. You can join but may hold the OC back \u2014 check if a lower-weight role is available instead.</div>';
          else if (s.tag === 'risky')         adviceHtml = '<div style="font-size:10px;color:#99a;margin-top:4px">Your CPR is below the critical threshold (' + CPR_CRIT + '%). Joining this role is likely to cause the OC to fail. Avoid if possible.</div>';

          card.innerHTML =
            '<div style="display:flex;align-items:center;gap:6px;margin-bottom:3px;flex-wrap:wrap">'
              + '<span style="font-weight:bold;color:#e0e0e0;font-size:12px">' + s.role + '</span>'
              + '<span style="color:#888;font-size:11px">in</span>'
              + '<span style="color:var(--ocm-link);font-size:12px">' + s.ocName + '</span>'
              + '<span style="color:#666;font-size:10px">D' + s.difficulty + '</span>'
              + '<span style="margin-left:auto;display:flex;gap:10px;font-size:11px;white-space:nowrap">'
                + '<span>CPR: <strong style="color:' + cprCol + '">' + cprStr + '</strong></span>'
                + '<span>Weight: <strong style="color:' + wCol + '">' + wStr + '</strong></span>'
              + '</span>'
            + '</div>'
            + '<div style="display:flex;align-items:center;gap:4px;flex-wrap:wrap;min-height:18px">' + tagHtml + '</div>'
            + adviceHtml;
          container.appendChild(card);
        }
      }
    }

    footer.appendChild(container);
    startCountdowns();

    GM_setValue('ocm_sidebar_cache', JSON.stringify({
      name: 'Member Mode', executesAt: null, timeLeft: null, openCount: 0,
      severity: 'ok', issues: [], cachedAt: Math.floor(Date.now() / 1000), memberMode: true,
    }));
    renderSidebarWidget();
    document.getElementById('ocm-last-update').textContent = 'Updated ' + new Date().toLocaleTimeString() + ' \xB7 Member Mode';
  }


  // ─── TORNSTATS CPR ────────────────────────────────────────────────────────────

  function fetchTornStatsCpr(tsKey) {
    return gmFetch(`https://www.tornstats.com/api/v2/${tsKey}/faction/cpr`)
      .then(json => {
        if (!json.status) throw new Error(json.message || 'TornStats error');
        return json.members || {};
      });
  }

  function tsLowestCpr(obj) {
    let min = null;
    for (const roles of Object.values(obj)) for (const v of Object.values(roles)) if (min === null || v < min) min = v;
    return min;
  }

  /** Look up a member's TornStats CPR for a specific OC name + role.
   *  Falls back to: any role in same crime → overall lowest CPR.
   *  Returns { cpr, exact } where exact=true if crime+role matched. */
  /** Look up CPR for a member's specific OC role.
   *  Resolution order:
   *    1. TornStats exact crime+role match  (exact: true)
   *    2. TornStats fuzzy crime match, any role  (exact: false)
   *    3. Faction OC history avg for this crime+role  (exact: true, history)
   *    4. Faction OC history avg for this crime, any role  (exact: false, history)
   *    5. TornStats overall lowest  (exact: false)
   *  Returns { cpr, exact, source } | null. */
  function tsGetCpr(uid, ocName, roleName) {
    const normOc   = (ocName || '').toLowerCase().trim().replace(/\s+v\d+$/i, '');
    const normRole = (roleName || '').toLowerCase().trim().replace(/\s*#\d+$/, '');
    const memberData = tsCprData[String(uid)];

    // 1 & 2: TornStats lookup
    if (memberData) {
      for (const [crime, roles] of Object.entries(memberData)) {
        const nc = crime.toLowerCase().trim().replace(/\s+v\d+$/i, '');
        if (nc === normOc || nc.includes(normOc) || normOc.includes(nc)) {
          for (const [role, cpr] of Object.entries(roles)) {
            if (role.toLowerCase().trim() === normRole) return { cpr, exact: true, source: 'ts' };
          }
          const vals = Object.values(roles);
          if (vals.length) return { cpr: Math.min(...vals), exact: false, source: 'ts' };
        }
      }
    }

    // 3 & 4: Faction OC history fallback
    const history = window._ocmHistoryCpr;
    if (history && history[String(uid)]) {
      const memberHist = history[String(uid)];
      for (const [crime, roles] of Object.entries(memberHist)) {
        if (crime === normOc || crime.includes(normOc) || normOc.includes(crime)) {
          // Exact role match
          if (roles[normRole]) {
            const b = roles[normRole];
            return { cpr: Math.round(b.sum / b.count), exact: true, source: 'history' };
          }
          // Fuzzy role match — average all roles in this crime
          const buckets = Object.values(roles);
          if (buckets.length) {
            const totalSum   = buckets.reduce((s, b) => s + b.sum, 0);
            const totalCount = buckets.reduce((s, b) => s + b.count, 0);
            return { cpr: Math.round(totalSum / totalCount), exact: false, source: 'history' };
          }
        }
      }
    }

    // 5: TornStats overall lowest
    if (memberData) {
      const low = tsLowestCpr(memberData);
      if (low !== null) return { cpr: low, exact: false, source: 'ts' };
    }

    // No personal data — return null so caller can flag this member as needing
    // to run an OC first rather than guessing from faction averages.
    return null;
  }

  /**
   * Profit tab — aggregates money, item value, and respect from completed OCs.
   * Item value = quantity × market_price (from /torn/items value.market_price).
   * Built from the same ~100-OC completed window the rest of the dashboard uses.
   */
  function renderProfitSection(crimes, itemValues = {}, itemNames = {}) {
    const el = document.getElementById('ocm-profit');
    if (!el) return;

    const fmtMoney = n => {
      const v = Math.round(Number(n) || 0);
      if (Math.abs(v) >= 1e9) return '$' + (v / 1e9).toFixed(2) + 'b';
      if (Math.abs(v) >= 1e6) return '$' + (v / 1e6).toFixed(2) + 'm';
      if (Math.abs(v) >= 1e3) return '$' + (v / 1e3).toFixed(1) + 'k';
      return '$' + v.toLocaleString();
    };
    const normStatus = raw => {
      const s = (raw || '').toLowerCase().trim();
      if (s === 'successful' || s === 'success') return 'successful';
      if (s === 'failure' || s === 'failed' || s === 'fail') return 'failure';
      if (s === 'expired' || s === 'expire') return 'expired';
      return null;
    };
    const normName = raw => (raw || 'Unknown').trim().normalize('NFD')
      .replace(/[\u0300-\u036f]/g, '').replace(/\s+[Vv]\d+$/, '').trim();

    // View mode: 'window' (API ~100-OC window) or 'tracked' (persistent all-time ledger)
    const viewMode = GM_getValue('ocm_profit_view', 'window');
    // Date cutoff (unix seconds). 0 = no cutoff.
    // On first ever run we stamp "now" so the default view shows profit since you
    // started using this version (i.e. roughly since you took over the faction),
    // rather than mixing in OCs the previous owner ran. User can clear it anytime.
    let sinceTs = Number(GM_getValue('ocm_profit_since', -1));
    if (sinceTs === -1) {
      sinceTs = Math.floor(Date.now() / 1000);
      GM_setValue('ocm_profit_since', sinceTs);
    }
    sinceTs = sinceTs || 0;

    // Build a unified list of {id, name, money, itemValue, respect, executedAt, items{}}
    // from either the live crimes (window) or the persistent ledger (tracked).
    let records = [];
    if (viewMode === 'tracked') {
      const ledger = loadProfitLedger();
      records = Object.entries(ledger).map(([id, r]) => ({ id, ...r }));
    } else {
      for (const oc of Object.values(crimes)) {
        if (!oc || normStatus(oc.status) !== 'successful' || !oc.rewards) continue;
        const rewards = oc.rewards;
        const rwItems = rewards.items
          ? (Array.isArray(rewards.items) ? rewards.items : Object.values(rewards.items))
          : [];
        let itemValue = 0;
        const items = {};
        for (const it of rwItems) {
          const iid = String(it?.id || it?.item_id || '');
          const qty = Number(it?.quantity || it?.qty || 1);
          if (!iid) continue;
          items[iid] = (items[iid] || 0) + qty;
          itemValue += (Number(itemValues[iid]) || 0) * qty;
        }
        records.push({
          id: String(oc.id ?? ''),
          name: oc.name || 'Unknown',
          money: Number(rewards.money) || 0,
          itemValue,
          respect: Number(rewards.respect) || 0,
          executedAt: oc.executed_at ?? null,
          items,
        });
      }
    }

    // Apply date cutoff
    const allCount = records.length;
    if (sinceTs > 0) records = records.filter(r => (r.executedAt || 0) >= sinceTs);

    // Aggregate
    let totalMoney = 0, totalItemValue = 0, totalRespect = 0;
    const byScenario = {};
    const byItem     = {};
    let earliest = Infinity, latest = 0;
    for (const r of records) {
      const name = normName(r.name);
      totalMoney     += r.money;
      totalItemValue += r.itemValue;
      totalRespect   += r.respect;
      if (r.executedAt) { earliest = Math.min(earliest, r.executedAt); latest = Math.max(latest, r.executedAt); }
      if (!byScenario[name]) byScenario[name] = { runs: 0, money: 0, itemValue: 0, respect: 0 };
      byScenario[name].runs++;
      byScenario[name].money     += r.money;
      byScenario[name].itemValue += r.itemValue;
      byScenario[name].respect   += r.respect;
      for (const [iid, qty] of Object.entries(r.items || {})) {
        const unit = (viewMode === 'tracked')
          ? (r.itemValue && Object.keys(r.items).length ? (Number(itemValues[iid]) || 0) : (Number(itemValues[iid]) || 0))
          : (Number(itemValues[iid]) || 0);
        if (!byItem[iid]) byItem[iid] = { qty: 0, unitValue: Number(itemValues[iid]) || unit, total: 0 };
        byItem[iid].qty   += qty;
        byItem[iid].total += (byItem[iid].unitValue || 0) * qty;
      }
    }
    const ocCount = records.length;
    const grandTotal = totalMoney + totalItemValue;
    const days = (earliest !== Infinity && latest > earliest) ? Math.max(1, (latest - earliest) / 86400) : 1;
    const perDay = grandTotal / days;

    // ── Controls: view toggle + date cutoff
    const sinceDateStr = sinceTs > 0 ? new Date(sinceTs * 1000).toISOString().slice(0, 10) : '';
    const controls = `
      <div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:12px;padding:8px 10px;background:#181818;border:1px solid #2e2e2e;border-radius:6px">
        <div style="display:flex;gap:4px;align-items:center">
          <span style="font-size:10px;color:#889;text-transform:uppercase;letter-spacing:.5px">View</span>
          <button class="ocm-profit-view-btn" data-view="window" style="font-size:11px;padding:3px 9px;border-radius:4px;cursor:pointer;border:1px solid ${viewMode==='window'?'var(--ocm-link)':'#3a3018'};background:${viewMode==='window'?'#14181e':'#1c1810'};color:${viewMode==='window'?'var(--ocm-link)':'#99a'}">API window</button>
          <button class="ocm-profit-view-btn" data-view="tracked" style="font-size:11px;padding:3px 9px;border-radius:4px;cursor:pointer;border:1px solid ${viewMode==='tracked'?'var(--ocm-link)':'#3a3018'};background:${viewMode==='tracked'?'#14181e':'#1c1810'};color:${viewMode==='tracked'?'var(--ocm-link)':'#99a'}">All-time (tracked)</button>
        </div>
        <div style="display:flex;gap:5px;align-items:center;margin-left:auto">
          <span style="font-size:10px;color:#889;text-transform:uppercase;letter-spacing:.5px">Profit since</span>
          <input type="date" id="ocm-profit-since" value="${sinceDateStr}" style="background:#1c1810;border:1px solid #3a3018;border-radius:4px;color:#e0e0e0;padding:3px 6px;font-size:11px" />
          <button id="ocm-profit-since-clear" style="font-size:11px;padding:3px 8px;border-radius:4px;cursor:pointer;border:1px solid #3a3018;background:#1c1810;color:#99a">All dates</button>
        </div>
      </div>`;

    // ── View-specific note
    const viewNote = viewMode === 'tracked'
      ? `Tracked total accumulates every completed OC this script has seen since you started running v3.5.1+ (survives past the API's ~100-OC cap). ${allCount} OC${allCount!==1?'s':''} recorded${sinceTs>0?` · ${ocCount} after cutoff`:''}.`
      : `Live API window — the ~100 most recent completed OCs Torn returns. Includes OCs run before you took over the faction.${sinceTs>0?` Showing ${ocCount} of ${allCount} after cutoff.`:''}`;

    if (ocCount === 0) {
      el.innerHTML = controls
        + '<div style="color:#99a;font-size:12px;padding:14px;line-height:1.5">'
        + (viewMode === 'tracked'
            ? 'No OCs recorded yet. The all-time tracker starts logging completed OCs from now on — check back after your faction completes some.'
            : 'No completed-OC reward data in this window' + (sinceTs>0?' after your cutoff date':'') + '. '
              + 'Profit appears here once your faction completes OCs that pay out money or items.')
        + '<br><span style="color:#667;font-size:11px">' + viewNote + '</span>'
        + '</div>';
      wireProfitControls();
      return;
    }

    const kpis = `
      <div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:12px">
        <div style="flex:1;min-width:120px;background:#1e1e1e;border:1px solid #2e2e2e;border-radius:6px;padding:10px 14px">
          <div style="font-size:9px;color:#889;text-transform:uppercase;letter-spacing:.5px">Total Profit</div>
          <div style="font-size:20px;font-weight:bold;color:var(--ocm-cpr-good)">${fmtMoney(grandTotal)}</div>
          <div style="font-size:10px;color:#667">money + item value</div>
        </div>
        <div style="flex:1;min-width:110px;background:#1e1e1e;border:1px solid #2e2e2e;border-radius:6px;padding:10px 14px">
          <div style="font-size:9px;color:#889;text-transform:uppercase;letter-spacing:.5px">Cash</div>
          <div style="font-size:20px;font-weight:bold;color:#dde">${fmtMoney(totalMoney)}</div>
          <div style="font-size:10px;color:#667">paid out</div>
        </div>
        <div style="flex:1;min-width:110px;background:#1e1e1e;border:1px solid #2e2e2e;border-radius:6px;padding:10px 14px">
          <div style="font-size:9px;color:#889;text-transform:uppercase;letter-spacing:.5px">Item Value</div>
          <div style="font-size:20px;font-weight:bold;color:var(--ocm-cpr-warn)">${fmtMoney(totalItemValue)}</div>
          <div style="font-size:10px;color:#667">market est.</div>
        </div>
        <div style="flex:1;min-width:100px;background:#1e1e1e;border:1px solid #2e2e2e;border-radius:6px;padding:10px 14px">
          <div style="font-size:9px;color:#889;text-transform:uppercase;letter-spacing:.5px">Respect</div>
          <div style="font-size:20px;font-weight:bold;color:var(--ocm-link)">${totalRespect.toLocaleString()}</div>
          <div style="font-size:10px;color:#667">${ocCount} OCs</div>
        </div>
        <div style="flex:1;min-width:110px;background:#1e1e1e;border:1px solid #2e2e2e;border-radius:6px;padding:10px 14px">
          <div style="font-size:9px;color:#889;text-transform:uppercase;letter-spacing:.5px">~ Per Day</div>
          <div style="font-size:20px;font-weight:bold;color:#dde">${fmtMoney(perDay)}</div>
          <div style="font-size:10px;color:#667">over ${days.toFixed(0)}d</div>
        </div>
      </div>`;

    const scenRows = Object.entries(byScenario)
      .map(([name, s]) => ({ name, ...s, total: s.money + s.itemValue }))
      .sort((a, b) => b.total - a.total);
    const scenTable = `
      <div class="ocm-section-title" style="cursor:default" id="ocm-profit-scen-title">By Scenario</div>
      <table class="ocm-analytics-table" style="table-layout:fixed;width:100%">
        <thead><tr>
          <th style="width:auto">OC</th>
          <th class="td-right" style="width:48px">Runs</th>
          <th class="td-right" style="width:80px">Cash</th>
          <th class="td-right" style="width:80px">Items</th>
          <th class="td-right" style="width:90px">Total</th>
          <th class="td-right" style="width:64px">Avg/Run</th>
        </tr></thead>
        <tbody>${scenRows.map(r => `<tr>
            <td style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${r.name}">${r.name}</td>
            <td class="td-right">${r.runs}</td>
            <td class="td-right" style="color:#bcd">${fmtMoney(r.money)}</td>
            <td class="td-right" style="color:var(--ocm-cpr-warn)">${fmtMoney(r.itemValue)}</td>
            <td class="td-right" style="color:var(--ocm-cpr-good);font-weight:bold">${fmtMoney(r.total)}</td>
            <td class="td-right" style="color:#99a">${fmtMoney(r.total / r.runs)}</td>
          </tr>`).join('')}</tbody>
      </table>`;

    const itemRows = Object.entries(byItem)
      .map(([id, it]) => ({ id, name: itemNames[id] || `Item #${id}`, ...it }))
      .sort((a, b) => b.total - a.total);
    const itemTable = itemRows.length === 0 ? '' : `
      <div class="ocm-section-title" style="cursor:default;margin-top:12px" id="ocm-profit-item-title">Items Received</div>
      <table class="ocm-analytics-table" style="table-layout:fixed;width:100%">
        <thead><tr>
          <th style="width:auto">Item</th>
          <th class="td-right" style="width:60px">Qty</th>
          <th class="td-right" style="width:90px">Unit Value</th>
          <th class="td-right" style="width:90px">Total Value</th>
        </tr></thead>
        <tbody>${itemRows.map(r => `<tr>
          <td style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${r.name}">
            <a href="https://www.torn.com/page.php?sid=ItemMarket#/market/view=search&itemID=${r.id}" target="_blank" style="color:var(--ocm-link);text-decoration:none">${r.name}</a>
          </td>
          <td class="td-right">${r.qty.toLocaleString()}</td>
          <td class="td-right" style="color:#99a">${fmtMoney(r.unitValue)}</td>
          <td class="td-right" style="color:var(--ocm-cpr-warn);font-weight:bold">${fmtMoney(r.total)}</td>
        </tr>`).join('')}</tbody>
      </table>`;

    el.innerHTML = controls + kpis + scenTable + itemTable
      + '<div style="font-size:10px;color:#667;margin-top:10px;line-height:1.5">'
      + viewNote + ' Cash is the payout recorded on each OC; item value is an estimate using current market price × quantity (actual sale value varies).'
      + '</div>';

    wireProfitControls();

    function wireProfitControls() {
      el.querySelectorAll('.ocm-profit-view-btn').forEach(btn => {
        btn.addEventListener('click', () => {
          GM_setValue('ocm_profit_view', btn.dataset.view);
          renderProfitSection(crimes, itemValues, itemNames);
        });
      });
      const sinceInput = document.getElementById('ocm-profit-since');
      if (sinceInput) {
        sinceInput.addEventListener('change', () => {
          const v = sinceInput.value;
          if (v) {
            const ts = Math.floor(new Date(v + 'T00:00:00Z').getTime() / 1000);
            GM_setValue('ocm_profit_since', ts);
          } else {
            GM_setValue('ocm_profit_since', 0);
          }
          renderProfitSection(crimes, itemValues, itemNames);
        });
      }
      const clearBtn = document.getElementById('ocm-profit-since-clear');
      if (clearBtn) clearBtn.addEventListener('click', () => {
        GM_setValue('ocm_profit_since', 0);
        renderProfitSection(crimes, itemValues, itemNames);
      });
    }
  }


  function renderTsCprSection(memberMap, liveCprMap = {}) {
    const el = document.getElementById('ocm-tscpr'), title = document.getElementById('ocm-title-tscpr');
    if (!el) return;
    if (!tsCprData || Object.keys(tsCprData).length === 0) {
      if (title) title.textContent = '📈 TornStats CPR';
      el.innerHTML = '<span style="color:#555;font-size:11px">No data. Add TornStats key in \u2699 Config \u2192 \u2b07 Fetch CPR.</span>';
      return;
    }
    const idToName = {};
    if (memberMap) for (const m of Object.values(memberMap)) idToName[String(m.id)] = m.name || `#${m.id}`;
    const byCrime = {};
    for (const [uid, crimes] of Object.entries(tsCprData))
      for (const [crime, roles] of Object.entries(crimes))
        for (const [role, cpr] of Object.entries(roles)) {
          if (!byCrime[crime]) byCrime[crime] = {};
          if (!byCrime[crime][role]) byCrime[crime][role] = [];
          byCrime[crime][role].push({ uid, name: idToName[uid] || `#${uid}`, cpr });
        }
    const diffMap = window._ocmCrimeDiffMap || {};
    const crimeNames = Object.keys(byCrime).sort((a, b) => {
      const da = diffMap[a.toLowerCase().trim()] ?? 999;
      const db = diffMap[b.toLowerCase().trim()] ?? 999;
      return da - db || a.localeCompare(b);
    });
    if (title) title.textContent = `📈 TornStats CPR \u2014 ${Object.keys(tsCprData).length} members, ${crimeNames.length} crimes`;
    let html = '';
    for (const crime of crimeNames) {
      const crimeKey = crime.toLowerCase().trim();
      const roles = Object.keys(byCrime[crime]).sort();
      const ms = new Map();
      for (const role of roles) for (const e of byCrime[crime][role]) if (!ms.has(e.uid)) ms.set(e.uid, e.name);

      // cprs[uid][role] = { cpr, live } — live flag set when overridden by a current slot
      const cprs = {};
      for (const role of roles) {
        const roleKey = role.replace(/\s*#\d+$/, '').toLowerCase().trim();
        for (const { uid, cpr } of byCrime[crime][role]) {
          if (!cprs[uid]) cprs[uid] = {};
          const live = liveCprMap[uid]?.[crimeKey]?.[roleKey];
          cprs[uid][role] = (live != null) ? { cpr: live, live: true } : { cpr, live: false };
        }
      }
      const sorted = [...ms.keys()].sort((a, b) =>
        Math.min(...Object.values(cprs[a] || {}).map(o => o.cpr)) -
        Math.min(...Object.values(cprs[b] || {}).map(o => o.cpr)));

      // Header columns: role name + its weight (from getWeight)
      const thCols = roles.map(r => {
        const w = getWeight(crime, r.replace(/\s*#\d+$/, ''));
        const wLabel = w != null
          ? `<span style="color:var(--ocm-cpr-warn);font-weight:normal;font-size:9px;display:block;line-height:1.2">W ${w.toFixed(0)}%</span>`
          : '';
        return `<th style="text-align:right;min-width:62px">${r}${wLabel}</th>`;
      }).join('');

      const diff = diffMap[crimeKey];
      const diffBadge = diff != null ? `<span style="color:var(--ocm-link);font-size:10px;margin-left:6px;font-weight:normal">D${diff}</span>` : '';
      const rows = sorted.map(uid => {
        const cells = roles.map(role => {
          const entry = cprs[uid]?.[role];
          if (entry == null) return '<td style="text-align:right;color:#555">\u2013</td>';
          const v = entry.cpr;
          const col = v >= CPR_WARN ? 'var(--ocm-cpr-good)' : v >= CPR_CRIT ? 'var(--ocm-cpr-warn)' : 'var(--ocm-cpr-crit)';
          // Live (currently-slotted) values get a small dot + tooltip so it's clear
          // this is their REAL current CPR, not the TornStats estimate.
          const liveMark = entry.live
            ? `<span title="Live CPR from their current OC slot (overrides TornStats estimate)" style="color:var(--ocm-link);font-size:9px;margin-right:2px">\u25cf</span>`
            : '';
          return `<td style="text-align:right;font-weight:bold;color:${col}">${liveMark}${v}%</td>`;
        }).join('');
        return `<tr><td><a href="/profiles.php?XID=${uid}" target="_blank" style="color:#ccc;text-decoration:none">${ms.get(uid)}</a></td>${cells}</tr>`;
      }).join('');
      html += `<div class="ocm-tscpr-oc-block"><div class="ocm-tscpr-oc-header collapsed"><span class="ocm-tscpr-arrow">\u25bc</span>${crime}${diffBadge} <span style="color:#666;font-weight:normal;font-size:10px">(${sorted.length} members)</span></div><div class="ocm-tscpr-oc-body" style="display:none"><table class="ocm-tscpr-table"><thead><tr><th>Member</th>${thCols}</tr></thead><tbody>${rows}</tbody></table></div></div>`;
    }
    el.innerHTML = html;
    el.querySelectorAll('.ocm-tscpr-oc-header').forEach(h => h.addEventListener('click', () => { const b = h.nextElementSibling; const vis = b.style.display === 'none'; b.style.display = vis ? '' : 'none'; h.classList.toggle('collapsed', !vis); }));
  }

  function injectTsBadges(el) {
    if (!tsCprData || Object.keys(tsCprData).length === 0) return;
    el.querySelectorAll('a[href*="profiles.php"]').forEach(link => {
      const m = link.href.match(/XID=(\d+)/);
      if (!m || !tsCprData[m[1]] || link.parentNode.querySelector('.ocm-ts-badge')) return;
      const low = tsLowestCpr(tsCprData[m[1]]); if (low === null) return;
      const col = low >= CPR_WARN ? 'var(--ocm-cpr-good)' : low >= CPR_CRIT ? 'var(--ocm-cpr-warn)' : 'var(--ocm-cpr-crit)';
      const b = document.createElement('span'); b.className = 'ocm-ts-badge'; b.style.color = col;
      b.title = `TornStats lowest CPR: ${low}%`; b.textContent = `TS:${low}%`;
      link.insertAdjacentElement('afterend', b);
    });
  }

  // ─── LOAD DATA ───────────────────────────────────────────────────────────────

  /** Entry point for a data refresh. Falls back to member mode silently on faction API error. */
  async function loadData(apiKey) {
    const errEl = document.getElementById('ocm-error');
    const btn   = document.getElementById('ocm-refresh-btn');
    errEl.style.display = 'none';
    btn.innerHTML = '<span class="ocm-spinner"></span>Loading…';
    btn.disabled  = true;

    // If user has Force Member Mode enabled in Config, skip the leader fetch
    // entirely and go straight to member-mode rendering. This is a preview /
    // testing toggle for users whose API key works for both modes.
    const forceMember = GM_getValue('ocm_force_member', false);
    if (forceMember) {
      // Reveal the View Mode config section so the toggle remains reachable —
      // otherwise the user is locked in member mode with no way to switch back.
      const mmSection0 = document.getElementById('ocm-cfg-section-membermode');
      if (mmSection0) mmSection0.style.display = '';
      const fmEl0 = document.getElementById('ocm-cfg-force-member');
      if (fmEl0) fmEl0.checked = true;
      try {
        const data = await fetchMember(apiKey);
        window._ocmDebug = { mode: 'member', fetchedAt: Date.now(), data, forced: true };
        renderMemberDashboard(data);
        document.getElementById('ocm-key-status').textContent = `Key saved ✓ · Member Mode (forced) · ↻${REFRESH_S}s`;
        btn.innerHTML = '↻ Refresh';
        btn.disabled  = false;
        return;
      } catch (memberErr) {
        document.getElementById('ocm-body').style.display = 'block';
        errEl.innerHTML = `&#9888; Member mode fetch failed: ${memberErr.message}`;
        errEl.style.display = 'block';
        btn.innerHTML = '↻ Refresh';
        btn.disabled  = false;
        return;
      }
    }

    try {
      const { faction, members, armory, itemNames, itemValues, lastOc, exMemberNames, viewerId } = await fetchAll(apiKey);
      // Stash for debug snapshot — sanitised on demand, not here
      window._ocmDebug = { mode: 'leader', fetchedAt: Date.now(), faction, members, armory, itemNames, lastOc, exMemberNames };
      renderDashboard(faction, members, armory, itemNames, lastOc, exMemberNames, itemValues, viewerId);
      // Leader mode succeeded — show the View Mode toggle in Config
      const mmSection = document.getElementById('ocm-cfg-section-membermode');
      if (mmSection) mmSection.style.display = '';
      const _tsK = GM_getValue('ocm_ts_key', '');
      if (_tsK && !isTsCprCacheFresh() && !_tsFetchInFlight && (Date.now() - _tsLastFailAt > TS_FAIL_COOLDOWN)) {
        _tsFetchInFlight = true;
        fetchTornStatsCpr(_tsK)
          .then(d => {
            saveTsCprCache(d);
            renderDashboard(faction, members, armory, itemNames, lastOc, exMemberNames, itemValues, viewerId);
          })
          .catch(err => {
            // Record failure so we don't keep hammering the API every refresh
            _tsLastFailAt = Date.now();
            console.warn('[OCM] TornStats fetch failed, backing off for 10 min:', err);
          })
          .finally(() => { _tsFetchInFlight = false; });
      }
    } catch (leaderErr) {
      // Faction access failed — log it so we can debug, then try member mode
      console.warn('[OCM] Leader mode failed, falling back to member mode:', leaderErr);
      try {
        const data = await fetchMember(apiKey);
        window._ocmDebug = { mode: 'member', fetchedAt: Date.now(), data, leaderErr: leaderErr.message };
        renderMemberDashboard(data);
        document.getElementById('ocm-key-status').textContent = `Key saved ✓ · Member Mode · ↻${REFRESH_S}s · (leader: ${leaderErr.message})`;
      } catch (memberErr) {
        // Both failed — show error and open config panel
        document.getElementById('ocm-body').style.display = 'block';
        document.getElementById('ocm-config-panel').style.display = 'block';
        errEl.innerHTML = `&#9888; Could not load data: ${memberErr.message}<br>
          <span style="font-size:11px;color:var(--ocm-cpr-warn)">Please check your API key in &#9881; Config.</span>`;
        errEl.style.display = 'block';
      }
    } finally {
      btn.innerHTML = '↻ Refresh';
      btn.disabled  = false;
    }
  }

  /** Schedule the auto-refresh interval. Clears any existing interval first. */
  function scheduleRefresh(apiKey) {
    clearInterval(window._ocmRefresh);
    window._ocmRefresh = setInterval(() => loadData(apiKey), REFRESH_S * 1000);
    document.getElementById('ocm-footer').textContent = `Auto-refreshes every ${REFRESH_S}s`;
  }

  // ─── COLLAPSE / EXPAND ───────────────────────────────────────────────────────

  /** Wire up all collapsible sections with GM_setValue persistence. */
  function initCollapse() {
    // defaultOpen: section is expanded on first load when it has content.
    // Otherwise it starts collapsed. User clicks still persist via GM_setValue.
    [
      { titleId: 'ocm-title-available',     contentId: 'ocm-available',     defaultOpen: true  },
      { titleId: 'ocm-title-recruits',      contentId: 'ocm-recruits',      defaultOpen: true  },
      { titleId: 'ocm-title-blocked',       contentId: 'ocm-blocked',       defaultOpen: true  },
      { titleId: 'ocm-title-lowcpr',        contentId: 'ocm-lowcpr',        defaultOpen: true  },
      { titleId: 'ocm-title-overqualified', contentId: 'ocm-overqualified', defaultOpen: true  },
      { titleId: 'ocm-title-tscpr',         contentId: 'ocm-tscpr',         defaultOpen: false },
      { titleId: 'ocm-title-analytics',     contentId: 'ocm-analytics',     defaultOpen: false },
      { titleId: 'ocm-title-downloads',     contentId: 'ocm-downloads',     defaultOpen: false },
    ].forEach(({ titleId, contentId, defaultOpen }) => {
      const title   = document.getElementById(titleId);
      const content = document.getElementById(contentId);
      if (!title || !content) return;

      // Check if section has actual content worth showing: pull the (N) count
      // from the title text. If 0 (or no count), don't auto-open even if defaultOpen=true.
      const m = title.textContent.match(/\((\d+)\)/);
      const sectionCount = m ? Number(m[1]) : null;
      const hasContent = sectionCount === null ? true : sectionCount > 0;

      const saved = GM_getValue(`ocm_collapse_${contentId}`, null);
      let shouldOpen;
      if (saved === 'open')         shouldOpen = true;
      else if (saved === 'collapsed') shouldOpen = false;
      else                          shouldOpen = defaultOpen && hasContent; // first visit

      if (shouldOpen) { content.style.display = ''; title.classList.remove('collapsed'); }
      else            { content.style.display = 'none'; title.classList.add('collapsed'); }

      title.addEventListener('click', () => {
        const isCollapsed = content.style.display === 'none';
        content.style.display = isCollapsed ? '' : 'none';
        title.classList.toggle('collapsed', !isCollapsed);
        GM_setValue(`ocm_collapse_${contentId}`, isCollapsed ? 'open' : 'collapsed');
      });
    });

    [
      { headerId: 'ocm-planning-header',   gridId: 'ocm-grid-planning'   },
      { headerId: 'ocm-recruiting-header', gridId: 'ocm-grid-recruiting' },
    ].forEach(({ headerId, gridId }) => {
      const header = document.getElementById(headerId);
      const grid   = document.getElementById(gridId);
      if (!header || !grid) return;

      const saved = GM_getValue(`ocm_collapse_${gridId}`, 'open');
      if (saved === 'collapsed') { grid.style.display = 'none'; header.classList.add('collapsed'); }

      header.addEventListener('click', () => {
        const isCollapsed = grid.style.display === 'none';
        grid.style.display = isCollapsed ? '' : 'none';
        header.classList.toggle('collapsed', !isCollapsed);
        GM_setValue(`ocm_collapse_${gridId}`, isCollapsed ? 'open' : 'collapsed');
      });
    });
  }

  /** Wire up the group tabs — exactly one pane visible at a time. Remembers
   *  the last selected tab via GM_setValue. */
  function initTabs() {
    const tabs  = document.querySelectorAll('.ocm-tab');
    const panes = document.querySelectorAll('.ocm-tab-pane');
    if (!tabs.length) return;

    function activate(tabName) {
      tabs.forEach(t => t.classList.toggle('active', t.dataset.tab === tabName));
      panes.forEach(p => p.classList.toggle('active', p.id === `ocm-pane-${tabName}`));
      GM_setValue('ocm_active_tab', tabName);
    }

    tabs.forEach(t => {
      t.addEventListener('click', () => activate(t.dataset.tab));
    });

    // Default to Action tab on first load, or whichever was last open
    const saved = GM_getValue('ocm_active_tab', 'action');
    activate(saved);
  }

  /** Pulls (N) counts from each section title and updates tab badges.
   *  Called from renderDashboard after sections are populated. */
  function updateTabCounts() {
    function count(titleId) {
      const el = document.getElementById(titleId);
      if (!el) return 0;
      const m = el.textContent.match(/\((\d+)\)/);
      return m ? Number(m[1]) : 0;
    }
    const counts = {
      action:   count('ocm-title-available') + count('ocm-title-recruits'),
      status:   count('ocm-title-blocked'),
      optimize: count('ocm-title-lowcpr') + count('ocm-title-overqualified'),
    };
    // Active OCs: count from the planning + recruiting headers. After render,
    // the headers' innerHTML is rebuilt with the count inside an
    // <span class="ocm-phase-count">(N)</span>, so we parse from the header text.
    function extractCount(headerId) {
      const el = document.getElementById(headerId);
      if (!el) return 0;
      const m = el.textContent.match(/\((\d+)\)/);
      return m ? Number(m[1]) : 0;
    }
    counts.ocs = extractCount('ocm-planning-header') + extractCount('ocm-recruiting-header');

    // Update badge text + flag attention (red dot if section has the kind of issue you'd want to know about)
    function setBadge(id, val, attn) {
      const el = document.getElementById(id);
      if (!el) return;
      el.textContent = val;
      el.classList.toggle('has-attn', !!attn);
    }
    setBadge('ocm-tab-count-action',   counts.action,   counts.action > 0);
    setBadge('ocm-tab-count-status',   counts.status,   counts.status > 0);
    setBadge('ocm-tab-count-optimize', counts.optimize, count('ocm-title-lowcpr') > 0);
    setBadge('ocm-tab-count-ocs',      counts.ocs,      false);
  }

  // ─── INJECT MAIN DASHBOARD ───────────────────────────────────────────────────

  /** Inject the dashboard into the OC tab when the URL matches. */
  function inject() {
    if (document.getElementById('ocm-root')) return;

    // Match the normal crimes tab, OR the faction profile/your page when the
    // player is travelling/abroad (Torn strips the crimes tab in that state).
    function isOcTab() {
      const isTraveling = document.body.dataset.traveling === 'true' || document.body.dataset.abroad === 'true';
      return location.href.includes('factions.php') && (
        location.hash.includes('tab=crimes') ||
        (isTraveling && (location.href.includes('step=profile') || location.href.includes('step=your')))
      );
    }

    if (!isOcTab()) {
      window.addEventListener('hashchange', () => { if (isOcTab() && !document.getElementById('ocm-root')) inject(); });
      return;
    }

    const isTraveling = document.body.dataset.traveling === 'true' || document.body.dataset.abroad === 'true';

    const tryInsert = setInterval(() => {
      const anchor = isTraveling
        ? (document.querySelector('#react-root') ||
           document.querySelector('.content-wrapper'))
        : (document.querySelector('.faction-crimes-wrap') ||
           document.querySelector('#faction-crimes')       ||
           document.querySelector('.content-wrapper')      ||
           document.querySelector('#mainContainer'));

      if (!anchor) return;
      clearInterval(tryInsert);

      const root = buildRoot();
      anchor.parentNode.insertBefore(root, anchor);
      initCollapse();
      initTabs();

      // Apply saved theme immediately, then wire the selector
      const savedTheme = GM_getValue('ocm_theme', 'default');
      applyTheme(savedTheme);
      const themeSelect = document.getElementById('ocm-theme-select');
      if (themeSelect) {
        themeSelect.value = savedTheme;
        themeSelect.addEventListener('change', () => applyTheme(themeSelect.value));
      }

      const savedKey = GM_getValue('ocm_api_key', '');
      if (savedKey) {
        document.getElementById('ocm-api-input').value = '••••••••••••••••';
        document.getElementById('ocm-key-status').textContent = `Key saved ✓ · CPR ${CPR_WARN}%/${CPR_CRIT}% · W ${WEIGHT_HIGH}%/${WEIGHT_MID}% · ↻${REFRESH_S}s`;
        loadData(savedKey);
        scheduleRefresh(savedKey);
      } else {
        // No key saved — open config panel automatically
        document.getElementById('ocm-config-panel').style.display = 'block';
      }

      // Config panel toggle
      document.getElementById('ocm-config-toggle').addEventListener('click', () => {
        const panel = document.getElementById('ocm-config-panel');
        panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
      });

      // Force Member Mode toggle — init from saved state, save on change, reload
      const forceMemberEl = document.getElementById('ocm-cfg-force-member');
      if (forceMemberEl) {
        forceMemberEl.checked = GM_getValue('ocm_force_member', false);
        forceMemberEl.addEventListener('change', () => {
          GM_setValue('ocm_force_member', forceMemberEl.checked);
          const k = GM_getValue('ocm_api_key', '');
          if (k) loadData(k);
        });
      }

      // Save API key
      document.getElementById('ocm-save-key-btn').addEventListener('click', () => {
        const key = document.getElementById('ocm-api-input').value.trim();
        if (!key || key.startsWith('•')) {
          const k = GM_getValue('ocm_api_key', '');
          if (k) { loadData(k); scheduleRefresh(k); }
          return;
        }
        GM_setValue('ocm_api_key', key);
        document.getElementById('ocm-api-input').value = '••••••••••••••••';
        document.getElementById('ocm-key-status').textContent = `Key saved ✓ · CPR ${CPR_WARN}%/${CPR_CRIT}% · W ${WEIGHT_HIGH}%/${WEIGHT_MID}% · ↻${REFRESH_S}s`;
        loadData(key);
        scheduleRefresh(key);
      });

      // Save all settings
      document.getElementById('ocm-cfg-save-btn').addEventListener('click', () => {
        const warn       = Number(document.getElementById('ocm-cfg-cpr-warn').value)      || 70;
        const crit       = Number(document.getElementById('ocm-cfg-cpr-crit').value)      || 60;
        const wHigh      = Number(document.getElementById('ocm-cfg-w-high').value)        || 25;
        const wMid       = Number(document.getElementById('ocm-cfg-w-mid').value)         || 15;
        const refresh    = Number(document.getElementById('ocm-cfg-refresh').value)       || 60;
        const minPerDiff = Number(document.getElementById('ocm-cfg-min-per-diff').value)  ?? 2;
        saveConfig(warn, crit, wHigh, wMid, refresh, minPerDiff);
        document.getElementById('ocm-key-status').textContent = `Key saved ✓ · CPR ${CPR_WARN}%/${CPR_CRIT}% · W ${WEIGHT_HIGH}%/${WEIGHT_MID}% · ↻${REFRESH_S}s`;
        document.getElementById('ocm-cfg-status').textContent = 'Saved ✓';
        setTimeout(() => { document.getElementById('ocm-cfg-status').textContent = ''; }, 2000);
        const key = GM_getValue('ocm_api_key', '');
        if (key) { scheduleRefresh(key); loadData(key); }
      });

      // Reset to defaults
      document.getElementById('ocm-cfg-reset-btn').addEventListener('click', () => {
        saveConfig(70, 60, 25, 15, 60, 2);
        document.getElementById('ocm-cfg-cpr-warn').value    = 70;
        document.getElementById('ocm-cfg-cpr-crit').value    = 60;
        document.getElementById('ocm-cfg-w-high').value      = 25;
        document.getElementById('ocm-cfg-w-mid').value       = 15;
        document.getElementById('ocm-cfg-refresh').value     = 60;
        document.getElementById('ocm-cfg-min-per-diff').value = 2;
        document.getElementById('ocm-key-status').textContent = `Key saved ✓ · CPR 70%/60% · W 25%/15% · ↻60s`;
        document.getElementById('ocm-cfg-status').textContent = 'Reset to defaults ✓';
        setTimeout(() => { document.getElementById('ocm-cfg-status').textContent = ''; }, 2000);
        const key = GM_getValue('ocm_api_key', '');
        if (key) { scheduleRefresh(key); loadData(key); }
      });

      // Manual refresh button
      document.getElementById('ocm-refresh-btn').addEventListener('click', () => {
        const key = GM_getValue('ocm_api_key', '');
        if (key) loadData(key);
      });

      // ── Debug snapshot — gathers internal state for troubleshooting
      function buildDebugSnapshot() {
        const dbg = window._ocmDebug || {};
        const tsCacheRaw = GM_getValue('ocm_ts_cpr_cache', '{}');
        let tsCacheMeta = { hasCache: false };
        try {
          const parsed = JSON.parse(tsCacheRaw);
          tsCacheMeta = {
            hasCache: !!parsed.ts,
            cachedAt: parsed.ts ? new Date(parsed.ts).toISOString() : null,
            memberCount: parsed.data ? Object.keys(parsed.data).length : 0,
            sampleMember: parsed.data ? Object.entries(parsed.data)[0] : null,
          };
        } catch (_) {}

        // Sanitise the raw faction data — keep structure, redact sensitive details
        function sanitiseFaction(faction) {
          if (!faction) return null;
          const out = { ID: faction.ID, name: '<redacted>', crimes: {} };
          for (const [id, oc] of Object.entries(faction.crimes || {})) {
            if (!oc) continue;
            out.crimes[id] = {
              id: oc.id,
              name: oc.name,
              difficulty: oc.difficulty,
              status: oc.status,
              executed_at: oc.executed_at,
              ready_at: oc.ready_at,
              expired_at: oc.expired_at,
              time_left: oc.time_left,
              slots: (Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || [])).map(s => s ? {
                position: s.position,
                position_info: s.position_info,
                checkpoint_pass_rate: s.checkpoint_pass_rate,
                user: s.user ? {
                  id: s.user.id,
                  progress: s.user.progress,
                  joined_at: s.user.joined_at,
                } : null,
                item_requirement: s.item_requirement ? {
                  id: s.item_requirement.id,
                  is_available: s.item_requirement.is_available,
                  is_reusable: s.item_requirement.is_reusable,
                } : null,
              } : null),
            };
          }
          return out;
        }
        function sanitiseMembers(members) {
          if (!members) return null;
          const out = {};
          for (const [k, m] of Object.entries(members)) {
            out[k] = {
              id: m.id,
              name: '<redacted>',
              status: m.status,
              is_in_oc: m.is_in_oc,
              last_action: m.last_action ? { timestamp: m.last_action.timestamp } : null,
              faction: m.faction ? { position: m.faction.position } : null,
              rank: m.rank,
            };
          }
          return out;
        }
        function sanitiseTsData(td) {
          if (!td) return null;
          const out = {};
          let i = 0;
          for (const [uid, crimes] of Object.entries(td)) {
            if (i++ < 3) {
              // Keep first 3 members in full to show structure
              out[uid] = crimes;
            } else {
              out[uid] = `<${Object.keys(crimes).length} crimes>`;
            }
          }
          return out;
        }
        function sanitiseHistoryCpr(h) {
          if (!h) return null;
          const out = {};
          let i = 0;
          for (const [uid, crimes] of Object.entries(h)) {
            if (i++ < 3) out[uid] = crimes;
            else out[uid] = `<${Object.keys(crimes).length} crimes>`;
          }
          return out;
        }

        // Snapshot of visible DOM sections — counts only
        function readSectionCount(titleId) {
          const el = document.getElementById(titleId);
          if (!el) return null;
          const m = el.textContent.match(/\((\d+)[\)\s]/);
          return m ? Number(m[1]) : null;
        }
        const visibleCounts = {
          available:      readSectionCount('ocm-title-available'),
          recruits:       readSectionCount('ocm-title-recruits'),
          blocked:        readSectionCount('ocm-title-blocked'),
          lowcpr:         readSectionCount('ocm-title-lowcpr'),
          overqualified:  readSectionCount('ocm-title-overqualified'),
          tscpr:          readSectionCount('ocm-title-tscpr'),
        };

        const statsBarValues = {};
        ['ocm-s-active','ocm-s-open','ocm-s-lowcpr','ocm-s-blocked','ocm-s-free','ocm-s-recruiting','ocm-s-stuck'].forEach(id => {
          const el = document.getElementById(id);
          if (el) statsBarValues[id] = el.textContent.trim();
        });

        return {
          generatedAt: new Date().toISOString(),
          script: { version: '3.5.2', userAgent: navigator.userAgent },
          config: { CPR_WARN, CPR_CRIT, WEIGHT_HIGH, WEIGHT_MID, REFRESH_S, MIN_PER_DIFF },
          mode: dbg.mode || 'unknown',
          fetchedAt: dbg.fetchedAt ? new Date(dbg.fetchedAt).toISOString() : null,
          leaderErr: dbg.leaderErr || null,
          tsCacheMeta,
          visibleCounts,
          statsBarValues,
          roleWeightsCount: Object.keys(roleWeights).length,
          tsCprData: sanitiseTsData(tsCprData),
          ocHistoryCpr: sanitiseHistoryCpr(window._ocmHistoryCpr),
          faction: sanitiseFaction(dbg.faction),
          memberCount: dbg.members ? Object.keys(dbg.members).length : 0,
          // Don't include full members list — too large + privacy
          membersSample: sanitiseMembers(
            dbg.members ? Object.fromEntries(Object.entries(dbg.members).slice(0, 5)) : null
          ),
          armoryItemCount: dbg.armory ? Object.keys(dbg.armory).length : 0,
          lastOcCount: dbg.lastOc ? Object.keys(dbg.lastOc).length : 0,
        };
      }

      document.getElementById('ocm-debug-snapshot-btn').addEventListener('click', () => {
        const snap = buildDebugSnapshot();
        const json = JSON.stringify(snap, null, 2);
        const blob = new Blob([json], { type: 'application/json' });
        const url  = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = `ocm-debug-${Date.now()}.json`;
        document.body.appendChild(a);
        a.click();
        a.remove();
        setTimeout(() => URL.revokeObjectURL(url), 1000);
        document.getElementById('ocm-cfg-status').textContent = '✓ Debug snapshot downloaded';
        setTimeout(() => { document.getElementById('ocm-cfg-status').textContent = ''; }, 3000);
      });

      document.getElementById('ocm-debug-copy-btn').addEventListener('click', () => {
        const json = JSON.stringify(buildDebugSnapshot(), null, 2);
        const setStatus = (msg) => {
          document.getElementById('ocm-cfg-status').textContent = msg;
          setTimeout(() => { document.getElementById('ocm-cfg-status').textContent = ''; }, 3000);
        };
        if (navigator.clipboard && navigator.clipboard.writeText) {
          navigator.clipboard.writeText(json).then(
            () => setStatus(`✓ Debug snapshot copied (${(json.length/1024).toFixed(1)} KB)`),
            () => setStatus('✗ Copy failed — check console')
          );
        } else {
          const ta = document.createElement('textarea');
          ta.value = json;
          ta.style.position = 'fixed'; ta.style.left = '-9999px';
          document.body.appendChild(ta);
          ta.select();
          try { document.execCommand('copy'); setStatus(`✓ Copied (${(json.length/1024).toFixed(1)} KB)`); }
          catch(_) { setStatus('✗ Copy failed'); }
          ta.remove();
        }
        console.log('[OCM Debug Snapshot]', JSON.parse(json));
      });

      // TornStats
      const _stk = GM_getValue('ocm_ts_key', '');
      if (_stk) { document.getElementById('ocm-ts-key-input').value = '•'.repeat(16); document.getElementById('ocm-ts-status').textContent = 'Key saved ✓'; }
      document.getElementById('ocm-ts-save-btn').addEventListener('click', () => {
        const v = document.getElementById('ocm-ts-key-input').value.trim();
        if (!v || v.charCodeAt(0) === 0x2022) return;
        GM_setValue('ocm_ts_key', v);
        document.getElementById('ocm-ts-key-input').value = '•'.repeat(16);
        const s = document.getElementById('ocm-ts-status'); s.textContent = 'Key saved ✓'; s.style.color = 'var(--ocm-cpr-good)';
      });
      document.getElementById('ocm-ts-fetch-btn').addEventListener('click', async () => {
        const k = GM_getValue('ocm_ts_key', ''), s = document.getElementById('ocm-ts-status');
        if (!k) { s.textContent = '⚠ Save key first'; s.style.color = 'var(--ocm-cpr-warn)'; return; }
        s.textContent = 'Fetching…'; s.style.color = '#888';
        try {
          tsCprData = await fetchTornStatsCpr(k);
          saveTsCprCache(tsCprData);
          _tsLastFailAt = 0;  // Manual success clears the auto-fetch cooldown
          s.textContent = '✓ ' + Object.keys(tsCprData).length + ' members loaded';
          s.style.color = 'var(--ocm-cpr-good)';
          // Re-render dashboard so OC cards pick up TS badges
          const apiKey = GM_getValue('ocm_api_key', '');
          if (apiKey) loadData(apiKey);
        } catch (e) {
          _tsLastFailAt = Date.now();  // Manual failure also triggers cooldown
          s.textContent = '✗ ' + e.message;
          s.style.color = 'var(--ocm-cpr-crit)';
        }
      });

    }, 500);
  }

  // ─── SIDEBAR WIDGET ──────────────────────────────────────────────────────────

  /** Fetch fresh data for the sidebar widget and update the GM_setValue cache. */
  async function fetchSidebarData(apiKey) {
    try {
      const url  = `${API_BASE}/faction?selections=crimes,members&key=${apiKey}&comment=OCManager-sidebar`;
      const res  = await fetch(url);
      const data = await res.json();
      if (data.error) return;

      const now      = Math.floor(Date.now() / 1000);
      const INACTIVE = new Set(['completed','expired','cancelled','failed','success','recruiting']);

      const mInfo = {};
      for (const m of Object.values(data.members || {})) {
        if (m?.id) mInfo[String(m.id)] = { name: m.name, status: m.status?.state || 'Unknown' };
      }

      const planning = [];
      for (const oc of Object.values(data.crimes || {})) {
        if (!oc) continue;
        if (INACTIVE.has((oc.status || '').toLowerCase())) continue;
        const slots     = Array.isArray(oc.slots) ? oc.slots : Object.values(oc.slots || []);
        const openCount = slots.filter(s => !s.user).length;
        let sortKey = Infinity;
        if (oc.executed_at && oc.executed_at > now) sortKey = oc.executed_at;
        else if (oc.ready_at && oc.ready_at > now)  sortKey = oc.ready_at;
        else if (oc.time_left > 0)                   sortKey = now + oc.time_left + openCount * 86400;
        else if (openCount > 0)                      sortKey = now + openCount * 86400;
        planning.push({ oc, sortKey });
      }
      planning.sort((a, b) => a.sortKey - b.sortKey);
      const nextOc = planning[0]?.oc ?? null;

      if (!nextOc) { GM_setValue('ocm_sidebar_cache', ''); renderSidebarWidget(); return; }

      const slots      = Array.isArray(nextOc.slots) ? nextOc.slots : Object.values(nextOc.slots || []);
      const executesAt = (nextOc.executed_at && nextOc.executed_at > now ? nextOc.executed_at : null)
                      ?? (nextOc.ready_at    && nextOc.ready_at    > now ? nextOc.ready_at    : null);
      const issues = [];
      for (const slot of slots) {
        const uid  = slot.user?.id ? String(slot.user.id) : null;
        const info = uid ? mInfo[uid] : null;
        if (!uid)                                issues.push({ sev: 'crit', msg: `Open: ${slot.position_info?.label || slot.position || '?'}` });
        else if (info && isBlocked(info.status)) issues.push({ sev: 'crit', msg: `${info.name} — ${info.status}` });
        const req = slot.item_requirement;
        if (req && uid && !req.is_available)     issues.push({ sev: 'warn', msg: `${info?.name || uid} missing item` });
      }

      GM_setValue('ocm_sidebar_cache', JSON.stringify({
        name:       nextOc.name,
        executesAt: executesAt ?? null,
        timeLeft:   nextOc.time_left ?? null,
        openCount:  slots.filter(s => !s.user).length,
        severity:   issues.some(i => i.sev === 'crit') ? 'crit' : issues.some(i => i.sev === 'warn') ? 'warn' : 'ok',
        issues:     issues.slice(0, 3),
        cachedAt:   now,
      }));
      renderSidebarWidget();
    } catch (_) {}
  }

  /** Inject the sidebar widget, positioned before the NPC section if found. */
  function injectSidebar() {
    const tryInsert = setInterval(() => {
      const npcHeader = [...document.querySelectorAll('.title-black, .title-gray, [class*="title"]')]
        .find(el => /^NPC/i.test(el.textContent.trim()));
      const fallback = document.querySelector('#sidebar') || document.querySelector('[class*="sidebar"]');
      const anchor   = npcHeader || fallback;
      if (!anchor) return;
      clearInterval(tryInsert);
      if (document.getElementById('ocm-sidebar-widget')) return;

      const widget = document.createElement('div');
      widget.id    = 'ocm-sidebar-widget';
      widget.style.cssText = 'background:#1e1e1e;border-top:2px solid var(--ocm-link);border-bottom:1px solid #2a2a3a;font-size:11px;font-family:Arial,sans-serif;line-height:1.5';

      if (npcHeader) npcHeader.parentNode.insertBefore(widget, npcHeader);
      else           anchor.appendChild(widget);

      renderSidebarWidget();
      setInterval(renderSidebarWidget, 1000);

      const apiKey   = GM_getValue('ocm_api_key', '');
      const raw      = GM_getValue('ocm_sidebar_cache', '');
      const cachedAt = raw ? (JSON.parse(raw).cachedAt || 0) : 0;
      if (apiKey && (Math.floor(Date.now()/1000) - cachedAt > 300)) fetchSidebarData(apiKey);
    }, 500);
  }

  /** Render (or re-render) the sidebar widget from the GM_setValue cache. */
  function renderSidebarWidget() {
    const widget = document.getElementById('ocm-sidebar-widget');
    if (!widget) return;

    const expanded = widget.dataset.expanded === 'true';
    const raw = GM_getValue('ocm_sidebar_cache', '');
    if (!raw) {
      widget.innerHTML = `<div style="color:#555;font-size:10px;padding:6px 8px">⚔ OC Manager — loading…</div>`;
      return;
    }

    let data;
    try { data = JSON.parse(raw); } catch { return; }

    if (data.memberMode) { widget.style.display = 'none'; return; }

    const now   = Math.floor(Date.now() / 1000);
    const stale = now - (data.cachedAt || 0) > 300;
    const col   = stale ? '#555' : data.severity === 'crit' ? 'var(--ocm-cpr-crit)' : data.severity === 'warn' ? 'var(--ocm-cpr-warn)' : 'var(--ocm-cpr-good)';
    const icon  = data.severity === 'crit' ? '🔴' : data.severity === 'warn' ? '⚠️' : '✅';
    const arrow = expanded ? '▲' : '▼';

    let timeStr = '';
    if (data.executesAt && data.executesAt > now) {
      const d = data.executesAt - now;
      const h = Math.floor(d / 3600), m = Math.floor((d % 3600) / 60), s = d % 60;
      timeStr = h > 0 ? `${h}h ${String(m).padStart(2,'0')}m` : `${m}m ${String(s).padStart(2,'0')}s`;
    } else if (data.timeLeft > 0) {
      const h = Math.floor(data.timeLeft / 3600), m = Math.floor((data.timeLeft % 3600) / 60);
      timeStr = `~${h > 0 ? h+'h ' : ''}${String(m).padStart(2,'0')}m (paused)`;
    } else if (data.openCount > 0) {
      timeStr = `~${data.openCount * 24}h est.`;
    } else {
      timeStr = 'Ready to initiate!';
    }

    const issueLines = (data.issues || [])
      .map(i => `<div style="color:#aaa;font-size:10px;padding:1px 0">${i.sev === 'crit' ? '🔴' : '⚠️'} ${i.msg}</div>`)
      .join('');

    const ocLink = `<a href="/factions.php?step=your#/tab=crimes" style="color:#555;font-size:10px;text-decoration:none;float:right;margin-top:4px">Open →</a>`;

    widget.innerHTML = `
      <div id="ocm-sw-header" style="display:flex;align-items:center;justify-content:space-between;padding:5px 8px;cursor:pointer">
        <span style="color:var(--ocm-link);font-weight:bold;font-size:10px;letter-spacing:.5px">⚔ NEXT OC</span>
        <span style="color:${col};font-weight:bold;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin:0 6px" title="${data.name}">${icon} ${data.name}</span>
        <span style="color:#555;font-size:10px">${timeStr}</span>
        <span style="color:#666;font-size:10px;margin-left:6px">${arrow}</span>
      </div>
      ${expanded ? `<div style="padding:2px 8px 7px;border-top:1px solid #2a2a3a">${issueLines || '<div style="color:#555;font-size:10px">No issues ✓</div>'}${ocLink}<div style="clear:both"></div></div>` : ''}`;

    const header = document.getElementById('ocm-sw-header');
    if (header) {
      header.addEventListener('click', () => {
        widget.dataset.expanded = widget.dataset.expanded === 'true' ? 'false' : 'true';
        renderSidebarWidget();
      });
    }
  }

  // ─── BOOT ────────────────────────────────────────────────────────────────────
  inject();
  injectSidebar();
  fetchRoleWeights();

})();