Torn OC Manager Dashboard

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

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Torn OC Manager Dashboard
// @namespace    torn_oc_manager
// @version      3.3.5
// @description  Faction leader dashboard for Organised Crimes 2.0 — CPR warnings, member availability, slot gaps.
// @author       TheOddSod (2640064)
// @match        https://www.torn.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        GM_xmlHttpRequest
// @connect      tornprobability.com
// ==/UserScript==
//
// Changelog:
// v3.3.5 — OC History section added to Analytics, mirroring the Member OC
//           History added in v3.3.2 but pivoted by scenario. Search by OC name
//           (e.g. "Sneaky Git Grab") to see every run of that scenario from the
//           last 100 OCs, newest first. Summary bar shows total runs, success
//           rate, avg CPR across all slots, and most common difficulty.
//           Table shows: date, difficulty, outcome, respect earned, and a
//           per-slot breakdown of member, role, weight, and CPR — expandable
//           per row. Autocomplete dropdown with match highlighting, same UX
//           as Member OC History.
// v3.3.4 — Member mode current OC card added.
//           active OC, the slot recommendations are replaced by a "Your Current
//           OC" card. Shows OC name, difficulty, phase badge (Planning/Ready/
//           Blocked), live countdown to execution or estimated remaining time,
//           the member own role, role weight, CPR, and planning progress bar.
//           All other slots are listed with each member name, status icon
//           (including country flags for abroad), and planning progress. A
//           stuck alert (red) shows if all planning is done but a member is
//           unavailable; a blocked warning (amber) shows if any member is
//           jailed/hospitalised/abroad but planning is still in progress.
//           If the member is not in any active OC, the existing open slot
//           recommendations are shown unchanged.
// v3.3.3 — Downloads section expanded from 5 to 11 exports. New additions:
//           Stuck OCs (blocked OCs with blocking member and expiry time),
//           Recruits (members in recruit rank with last OC and last online),
//           Blocked Members (in-OC members who are jailed/hospitalised/abroad,
//           with OC execution time), Full OC History (every completed OC slot
//           flattened to one row per member — date, OC, difficulty, role,
//           weight, CPR, outcome, respect), Member × Scenario Heatmap (flat
//           CSV of success rate per member per scenario, suitable for pivot
//           tables), Member OC History (per-member history from the 100-OC
//           cap, one row per slot, sorted by member name then date desc).
//           Member Analytics export updated to include Failures column.
//           Exports grouped into sections: Active State, Member State,
//           Analytics.
// v3.3.2 — Member OC History added inside the Analytics section. Search by
//           member name to pull up their full personal history from the 100-OC
//           cap, shown newest to oldest. Each row shows: date, OC name,
//           difficulty, their role, role weight, CPR, and outcome
//           (✅ Success / ❌ Failure / ⏰ Expired). Summary stats shown above
//           the table: total OCs, success rate, avg CPR, and most-played role.
//           Search is case-insensitive and filters as you type; selecting from
//           the auto-complete dropdown populates the full history instantly.
// v3.3.1 — Stuck OCs section added. OCs where all slots are filled and
//           planning is complete but initiation is blocked by at least one
//           member being jailed, hospitalised, or abroad are now surfaced in
//           a dedicated 🚨 Stuck OCs section. The section appears between the
//           stats bar and the Next OC banner, is only shown when stuck OCs
//           exist, and uses a high-visibility red card style. Each card lists
//           the blocking member(s) with their status and a live countdown to
//           when the OC's execution window expires. A new 🚨 Stuck OCs stat
//           is added to the stats bar showing the count of affected OCs.
//           Also: recruit notice wording corrected — removed incorrect claim
//           about leader promotion; recruits are promoted automatically by the
//           game, not manually.
// v3.3.0 — Five improvements:
//           1. Recruits section: Members with Recruit rank are now split into a
//              separate collapsible "Recruits" section below the Available table
//              with a note that they cannot participate in OCs while in recruit
//              status. Recruit rank is detected via member.faction.position or
//              the rank field returned by the members endpoint.
//           2. Abroad flags: statusIcon() now parses the description field for
//              a known country name and renders the corresponding emoji flag.
//              If no country is matched, falls back to 🌍. The existing ✈→🏠 /
//              🏠→✈ direction logic is preserved alongside the flag.
//           3. Spawn reminder: New "Recruiting" stat in the stats bar shows
//              current recruiting OC count per difficulty and turns red when any
//              difficulty level drops below a configurable minimum (default: 2).
//              Configurable via the Config panel ("Min OCs per diff" field),
//              persisted via GM_setValue.
//           4. Last 5 OCs: Analytics section now shows the last 5 completed OCs
//              in a collapsible table below the summary stats row, replacing the
//              single "Last Completed OC" card with a paginated view. Each row
//              expands inline to show the per-member breakdown (role, CPR, weight).
//           5. Heatmap fixed: heatData was keyed by raw oc.name while
//              heatScenarios was keyed by normOcName() — the mismatch caused
//              every cell to miss. Both are now keyed by normOcName() throughout
//              the heatmap build and render path. Also widens the heatmap SVG
//              and increases row height to handle 45+ members without clipping.
// v3.2.9 — (previous version, see earlier changelog entries)

(function () {
  'use strict';

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

  // ─── CONFIG ──────────────────────────────────────────────────────────────────
  const API_BASE      = 'https://api.torn.com/v2';
  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));

  /** 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: () => {} });
    }
    // Also try fetch() — works in TornPDA
    fetch(url).then(r => r.text()).then(processResponse).catch(() => {});
  }

  /** 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 '🌍';
  }

  // ─── STYLES ──────────────────────────────────────────────────────────────────
  GM_addStyle(`
    #ocm-root {
      font-family: Arial, sans-serif;
      font-size: 13px;
      color: #e0e0e0;
      margin: 10px 0;
    }
    #ocm-root * { box-sizing: border-box; }

    #ocm-header {
      display: flex;
      align-items: center;
      gap: 10px;
      background: #1a1a2e;
      padding: 8px 12px;
      border-radius: 6px 6px 0 0;
      border-bottom: 2px solid #e05a00;
    }
    #ocm-header h2 {
      margin: 0;
      font-size: 15px;
      color: #ff7700;
      flex: 1;
    }
    #ocm-header small { color: #888; font-size: 11px; }

    #ocm-config-strip {
      background: #16213e;
      padding: 5px 12px;
      display: flex;
      align-items: center;
      gap: 8px;
      border-bottom: 1px solid #1a2a4a;
    }
    #ocm-config-panel {
      background: #111827;
      border-bottom: 2px solid #e05a00;
    }
    .ocm-cfg-section {
      padding: 8px 12px 4px;
      border-bottom: 1px solid #1a2a4a;
    }
    .ocm-cfg-label {
      font-size: 10px;
      color: #ff7700;
      text-transform: uppercase;
      letter-spacing: .5px;
      margin-bottom: 5px;
    }
    .ocm-cfg-row {
      display: flex;
      flex-wrap: wrap;
      gap: 16px;
      align-items: center;
    }
    .ocm-cfg-row label { color: #aaa; font-size: 12px; display: flex; align-items: center; gap: 5px; }
    .ocm-cfg-num {
      background: #0f3460;
      border: 1px solid #2a4a7a;
      border-radius: 3px;
      color: #fff;
      padding: 2px 6px;
      font-size: 12px;
      width: 44px;
      text-align: center;
    }
    #ocm-api-input {
      background: #0f3460;
      border: 1px solid #444;
      border-radius: 4px;
      color: #fff;
      padding: 4px 8px;
      font-size: 12px;
      width: 220px;
    }
    .ocm-cfg-btn {
      background: #e05a00;
      border: none;
      border-radius: 4px;
      color: #fff;
      padding: 4px 10px;
      cursor: pointer;
      font-size: 12px;
    }
    .ocm-cfg-btn:hover { background: #ff7700; }
    #ocm-config-toggle {
      background: #1a2a4a;
      border: 1px solid #2a4a7a;
      border-radius: 4px;
      color: #aaa;
      padding: 3px 8px;
      cursor: pointer;
      font-size: 11px;
    }
    #ocm-config-toggle:hover { background: #2a3a5a; color: #fff; }
    #ocm-refresh-btn {
      background: #e05a00;
      border: none;
      border-radius: 4px;
      color: #fff;
      padding: 3px 8px;
      cursor: pointer;
      font-size: 12px;
    }
    #ocm-refresh-btn:hover { background: #ff7700; }

    #ocm-stats-bar {
      display: flex;
      gap: 16px;
      background: #0f3460;
      padding: 6px 12px;
      flex-wrap: wrap;
    }
    .ocm-stat { display: flex; flex-direction: column; }
    .ocm-stat-label { font-size: 10px; color: #aaa; text-transform: uppercase; letter-spacing: .5px; }
    .ocm-stat-value { font-size: 16px; font-weight: bold; color: #ff7700; }
    /* Warn colour for the recruiting-below-threshold stat */
    .ocm-stat-warn .ocm-stat-value { color: #ff4444; }
    .ocm-stat-warn .ocm-stat-label { color: #ff9944; }

    #ocm-body { background: #16213e; border-radius: 0 0 6px 6px; padding: 10px; }

    .ocm-section-title {
      color: #ff7700;
      font-size: 12px;
      font-weight: bold;
      text-transform: uppercase;
      letter-spacing: 1px;
      margin: 8px 0 4px;
      border-bottom: 1px solid #333;
      padding-bottom: 3px;
      cursor: pointer;
      user-select: none;
      display: flex;
      align-items: center;
      justify-content: space-between;
    }
    .ocm-section-title::after { content: '▼'; font-size: 9px; opacity: .5; transition: transform .2s; }
    .ocm-section-title.collapsed::after { transform: rotate(-90deg); }

    /* Member OC History subsection inside Analytics */
    #ocm-member-history-wrap {
      background: #1a1a2e;
      border: 1px solid #2a2a4a;
      border-radius: 6px;
      padding: 8px 10px;
      margin-top: 10px;
    }
    #ocm-member-history-wrap h4 {
      margin: 0 0 8px;
      font-size: 9px;
      text-transform: uppercase;
      letter-spacing: .5px;
      color: #ff7700;
      border-bottom: 1px solid #2a2a4a;
      padding-bottom: 3px;
    }
    #ocm-mh-search-wrap {
      display: flex;
      align-items: center;
      gap: 8px;
      margin-bottom: 8px;
      position: relative;
    }
    #ocm-mh-search {
      background: #0f3460;
      border: 1px solid #2a4a7a;
      border-radius: 4px;
      color: #fff;
      padding: 5px 10px;
      font-size: 12px;
      flex: 1;
      outline: none;
    }
    #ocm-mh-search:focus { border-color: #ff7700; }
    #ocm-mh-search::placeholder { color: #555; }
    #ocm-mh-dropdown {
      position: absolute;
      top: 100%;
      left: 0;
      right: 60px;
      background: #0f1a30;
      border: 1px solid #2a4a7a;
      border-top: none;
      border-radius: 0 0 4px 4px;
      z-index: 100;
      display: none;
      max-height: 180px;
      overflow-y: auto;
    }
    #ocm-mh-dropdown.visible { display: block; }
    .ocm-mh-option {
      padding: 5px 10px;
      font-size: 12px;
      color: #ccc;
      cursor: pointer;
    }
    .ocm-mh-option:hover { background: #1a2a4a; color: #fff; }
    .ocm-mh-option em { color: #ff7700; font-style: normal; }
    #ocm-mh-clear {
      background: #1a2a4a;
      border: 1px solid #2a4a7a;
      border-radius: 4px;
      color: #aaa;
      padding: 5px 10px;
      font-size: 11px;
      cursor: pointer;
      white-space: nowrap;
    }
    #ocm-mh-clear:hover { background: #2a3a5a; color: #fff; }
    #ocm-mh-summary {
      display: flex;
      gap: 16px;
      flex-wrap: wrap;
      margin-bottom: 8px;
      padding: 6px 10px;
      background: #0f1a30;
      border-radius: 4px;
      font-size: 11px;
    }
    .ocm-mh-sum-item { display: flex; flex-direction: column; }
    .ocm-mh-sum-label { font-size: 9px; color: #666; text-transform: uppercase; letter-spacing: .5px; }
    .ocm-mh-sum-value { font-size: 14px; font-weight: bold; color: #ff7700; }
    #ocm-mh-table-wrap { overflow-x: auto; }
    #ocm-mh-empty {
      color: #555;
      font-size: 11px;
      font-style: italic;
      padding: 8px 0;
      text-align: center;
    }
    .ocm-mh-table {
      width: 100%;
      border-collapse: collapse;
      font-size: 11px;
      min-width: 640px;
      table-layout: fixed;
    }
    .ocm-mh-table th {
      text-align: left;
      color: #555;
      font-weight: normal;
      font-size: 10px;
      text-transform: uppercase;
      letter-spacing: .5px;
      padding: 4px 8px;
      border-bottom: 1px solid #222;
      white-space: nowrap;
    }
    .ocm-mh-table td {
      padding: 4px 8px;
      border-bottom: 1px solid #111;
      color: #ccc;
      vertical-align: middle;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    .ocm-mh-table tr:hover td { background: #16213e; }
    /* Column widths */
    .ocm-mh-table .col-date    { width: 76px;  color: #666; }
    .ocm-mh-table .col-oc      { width: 150px; color: #ccc; }
    .ocm-mh-table .col-diff    { width: 40px;  text-align: center; color: #888; }
    .ocm-mh-table .col-role    { width: 110px; color: #aaa; }
    .ocm-mh-table .col-weight  { width: 58px;  text-align: right; }
    .ocm-mh-table .col-cpr     { width: 52px;  text-align: right; font-weight: bold; }
    .ocm-mh-table .col-respect { width: 72px;  text-align: right; color: #ffcc44; }
    .ocm-mh-table .col-outcome { width: 82px;  padding-left: 14px; }
    .ocm-mh-outcome-success { color: #44ee88; font-weight: bold; }
    .ocm-mh-outcome-failure { color: #ff4444; font-weight: bold; }
    .ocm-mh-outcome-expired { color: #888; }

    /* OC History subsection inside Analytics — mirrors Member OC History, pivoted by scenario */
    #ocm-oc-history-wrap {
      background: #1a1a2e;
      border: 1px solid #2a2a4a;
      border-radius: 6px;
      padding: 8px 10px;
      margin-top: 10px;
    }
    #ocm-oc-history-wrap h4 {
      margin: 0 0 8px;
      font-size: 9px;
      text-transform: uppercase;
      letter-spacing: .5px;
      color: #ff7700;
      border-bottom: 1px solid #2a2a4a;
      padding-bottom: 3px;
    }
    #ocm-oh-search-wrap {
      display: flex;
      align-items: center;
      gap: 8px;
      margin-bottom: 8px;
      position: relative;
    }
    #ocm-oh-search {
      background: #0f3460;
      border: 1px solid #2a4a7a;
      border-radius: 4px;
      color: #fff;
      padding: 5px 10px;
      font-size: 12px;
      flex: 1;
      outline: none;
    }
    #ocm-oh-search:focus { border-color: #ff7700; }
    #ocm-oh-search::placeholder { color: #555; }
    #ocm-oh-dropdown {
      position: absolute;
      top: 100%;
      left: 0;
      right: 60px;
      background: #0f1a30;
      border: 1px solid #2a4a7a;
      border-top: none;
      border-radius: 0 0 4px 4px;
      z-index: 100;
      display: none;
      max-height: 180px;
      overflow-y: auto;
    }
    #ocm-oh-dropdown.visible { display: block; }
    #ocm-oh-clear {
      background: #1a2a4a;
      border: 1px solid #2a4a7a;
      border-radius: 4px;
      color: #aaa;
      padding: 5px 10px;
      font-size: 11px;
      cursor: pointer;
      white-space: nowrap;
    }
    #ocm-oh-clear:hover { background: #2a3a5a; color: #fff; }
    #ocm-oh-summary {
      display: flex;
      gap: 16px;
      flex-wrap: wrap;
      margin-bottom: 8px;
      padding: 6px 10px;
      background: #0f1a30;
      border-radius: 4px;
    }
    #ocm-oh-table-wrap { }
    #ocm-oh-empty {
      color: #555;
      font-size: 11px;
      font-style: italic;
      padding: 8px 0;
      text-align: center;
    }
    .ocm-oh-run-header {
      display: flex;
      align-items: center;
      gap: 8px;
      padding: 5px 8px;
      background: #111827;
      border: 1px solid #2a2a4a;
      border-radius: 4px;
      margin-bottom: 3px;
      cursor: pointer;
      font-size: 11px;
    }
    .ocm-oh-run-header:hover { background: #1a2a3a; }
    .ocm-oh-run-detail {
      display: none;
      background: #0d1520;
      border: 1px solid #2a2a4a;
      border-top: none;
      border-radius: 0 0 4px 4px;
      padding: 5px 10px;
      margin-top: -3px;
      margin-bottom: 4px;
    }
    .ocm-oh-run-detail table { width: 100%; border-collapse: collapse; font-size: 10px; }
    .ocm-oh-run-detail th { color: #555; font-weight: normal; text-transform: uppercase; font-size: 9px; padding: 2px 5px; border-bottom: 1px solid #1a1a2e; text-align: left; }
    .ocm-oh-run-detail td { padding: 3px 5px; border-bottom: 1px solid #111; color: #ccc; }
    .ocm-oh-run-detail .td-right { text-align: right; }

    /* Stuck OCs — high visibility section */
    #ocm-stuck-banner {
      background: #2a0000;
      border: 2px solid #ff2200;
      border-radius: 6px;
      padding: 10px 12px;
      margin-bottom: 10px;
      animation: ocm-stuck-pulse 2s ease-in-out infinite;
    }
    @keyframes ocm-stuck-pulse {
      0%, 100% { border-color: #ff2200; box-shadow: 0 0 0 0 rgba(255,34,0,0); }
      50%       { border-color: #ff6644; box-shadow: 0 0 8px 2px rgba(255,34,0,0.3); }
    }
    #ocm-stuck-banner .ocm-stuck-header {
      font-size: 13px;
      font-weight: bold;
      color: #ff4422;
      margin-bottom: 8px;
      display: flex;
      align-items: center;
      gap: 8px;
    }
    #ocm-stuck-banner .ocm-stuck-header span { font-size: 11px; color: #ff8866; font-weight: normal; }
    .ocm-stuck-card {
      background: #1a0000;
      border: 1px solid #882200;
      border-radius: 5px;
      padding: 7px 10px;
      margin-bottom: 6px;
    }
    .ocm-stuck-card:last-child { margin-bottom: 0; }
    .ocm-stuck-card-title {
      display: flex;
      align-items: center;
      gap: 8px;
      margin-bottom: 5px;
      flex-wrap: wrap;
    }
    .ocm-stuck-card-title strong { color: #fff; font-size: 12px; }
    .ocm-stuck-card-title .ocm-stuck-diff { color: #888; font-size: 10px; }
    .ocm-stuck-card-title .ocm-stuck-expiry { margin-left: auto; font-size: 10px; color: #ff8844; }
    .ocm-stuck-blocker {
      display: flex;
      align-items: center;
      gap: 8px;
      font-size: 11px;
      padding: 2px 0;
      color: #ff8866;
    }
    .ocm-stuck-blocker a { color: #ffaaaa; text-decoration: none; }
    .ocm-stuck-blocker a:hover { text-decoration: underline; }


    #ocm-title-recruits { color: #888; border-color: #222; }
    #ocm-title-recruits::after { opacity: .3; }
    .ocm-recruits-notice {
      background: #1a1500;
      border: 0.5px solid #443300;
      border-radius: 5px;
      padding: 5px 10px;
      font-size: 11px;
      color: #887744;
      margin-bottom: 6px;
    }

    .ocm-phase-header {
      font-size: 13px;
      font-weight: bold;
      letter-spacing: .5px;
      padding: 7px 10px 6px;
      margin: 10px 0 6px;
      border-radius: 5px;
      display: flex;
      align-items: center;
      gap: 8px;
    }
    .ocm-phase-planning  { background: #0d2a4a; color: #7aadff; border-left: 3px solid #3a7acc; flex-wrap: wrap; row-gap: 4px; cursor: pointer; user-select: none; }
    .ocm-phase-recruiting{ background: #2a1a00; color: #ffaa33; border-left: 3px solid #cc7700; flex-wrap: wrap; row-gap: 4px; cursor: pointer; user-select: none; }
    .ocm-phase-collapse { font-size: 9px; opacity: .4; margin-left: auto; transition: transform .2s; }
    .ocm-phase-header.collapsed .ocm-phase-collapse { transform: rotate(-90deg); }
    .ocm-phase-count {
      font-size: 11px;
      font-weight: normal;
      opacity: .7;
    }
    .ocm-diff-sep {
      color: #885500;
      font-weight: normal;
      margin: 0 2px;
    }
    .ocm-diff-chip {
      font-size: 11px;
      font-weight: normal;
      background: #3a2000;
      border: 1px solid #664400;
      border-radius: 4px;
      padding: 1px 7px;
      color: #ffcc77;
      white-space: nowrap;
    }
    .ocm-diff-chip strong { color: #ffeeaa; }
    .ocm-diff-chip-plan {
      background: #0a1e3a;
      border-color: #1a3a6a;
      color: #88aadd;
    }
    .ocm-diff-chip-plan strong { color: #aaccff; }
    .ocm-oc-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr));
      gap: 8px;
      margin-bottom: 4px;
    }
    .ocm-empty-phase {
      color: #444;
      font-size: 11px;
      padding: 6px 10px 10px;
      font-style: italic;
    }
    .ocm-card {
      background: #1a1a2e;
      border: 1px solid #2a2a4a;
      border-radius: 6px;
      padding: 8px;
      position: relative;
      min-width: 0;
    }
    .ocm-card.ocm-card-warn  { border-color: #cc7700; }
    .ocm-card.ocm-card-crit  { border-color: #cc2200; }
    .ocm-card.ocm-card-ready { border-color: #00aa44; }
    .ocm-card.ocm-card-blocked { border-color: #880088; }

    .ocm-card-title {
      font-weight: bold;
      font-size: 13px;
      color: #fff;
      margin-bottom: 2px;
      display: flex;
      justify-content: space-between;
      align-items: flex-start;
      gap: 4px;
    }
    .ocm-card-subtitle { color: #888; font-size: 11px; margin-bottom: 6px; }

    .ocm-badge {
      font-size: 10px;
      padding: 1px 5px;
      border-radius: 3px;
      white-space: nowrap;
      font-weight: bold;
    }
    .badge-planning  { background:#0f3460; color:#7aadff; }
    .badge-ready     { background:#004422; color:#44ee88; }
    .badge-recruiting{ background:#2a1a00; color:#ff9900; }
    .badge-blocked   { background:#330033; color:#dd44dd; }
    .badge-executing { background:#330000; color:#ff4444; }

    .ocm-timer {
      font-size: 11px;
      color: #aaa;
      margin-bottom: 5px;
    }
    .ocm-timer .ocm-time { color: #fff; font-weight: bold; }

    .ocm-slots { display: flex; flex-direction: column; gap: 3px; }
    .ocm-slot {
      display: flex;
      align-items: center;
      gap: 4px;
      background: #0f1a30;
      border-radius: 4px;
      padding: 2px 5px;
      font-size: 11px;
      min-width: 0;
    }
    .ocm-slot.ocm-slot-risk {
      background: #2a1500;
      border-left: 2px solid #ff6600;
    }
    .ocm-slot-role   { color: #aaa; flex: 0 0 80px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .ocm-slot-member { color: #ccc; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .ocm-slot-cpr {
      font-weight: bold;
      flex: 0 0 30px;
      text-align: right;
      white-space: nowrap;
      font-size: 10px;
    }
    .ocm-slot-weight {
      flex: 0 0 28px;
      text-align: right;
      font-size: 9px;
      color: #666;
      white-space: nowrap;
    }
    .ocm-slot-weight.w-high { color: #ff8844; }
    .ocm-slot-weight.w-mid  { color: #aaa; }
    .ocm-slot-weight.w-low  { color: #555; }
    a.ocm-item-tag {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      padding: 1px 3px;
      border-radius: 3px;
      font-size: 10px;
      font-weight: bold;
      text-decoration: none;
      flex: 0 0 22px;
      white-space: nowrap;
      overflow: hidden;
    }
    .ocm-item-name { display: none; }
    a.ocm-item-tag:hover { filter: brightness(1.3); }
    .ocm-progress-wrap {
      flex: 0 0 40px;
      height: 5px;
      background: #0a1020;
      border-radius: 3px;
      overflow: hidden;
      align-self: center;
    }
    .ocm-progress-fill {
      height: 100%;
      border-radius: 3px;
      transition: width .3s;
    }
    .progress-done    { background: #44ee88; }
    .progress-active  { background: #ffaa00; }
    .progress-waiting { background: #2a4a6a; }
    .cpr-warn { color: #ffaa00; }
    .cpr-crit { color: #ff4444; }
    .cpr-empty { color: #555; font-style: italic; }

    .ocm-slot-status { font-size: 10px; min-width: 16px; text-align: center; }
    .status-ok       { color: #44ee88; }
    .status-open     { color: #cc2222; }
    .status-hospital { color: #ff4444; }
    .status-jail     { color: #ff8800; }
    .status-travel   { color: #88aaff; }
    .status-abroad   { color: #aaddff; }
    .status-unknown  { color: #555; }

    #ocm-available { margin-bottom: 10px; }
    #ocm-recruits  { margin-bottom: 10px; }
    .ocm-members-table {
      width: 100%;
      border-collapse: collapse;
      font-size: 11px;
      table-layout: fixed;
    }
    .ocm-members-table th {
      text-align: left;
      color: #666;
      font-weight: normal;
      font-size: 10px;
      text-transform: uppercase;
      letter-spacing: .5px;
      padding: 3px 8px;
      border-bottom: 1px solid #1a1a2e;
    }
    .ocm-members-table td {
      padding: 3px 8px;
      border-bottom: 1px solid #111;
      vertical-align: middle;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    .ocm-members-table tr:hover td { background: #1a1a2e; }
    .ocm-members-table .col-name  { color: #ccc; width: 22%; }
    .ocm-members-table .col-oc    { color: #888; width: 38%; }
    .ocm-members-table .col-ocage { width: 14%; text-align: right; padding-right: 16px; }
    .ocm-members-table .col-seen  { color: #666; width: 14%; }
    /* Recruits table — dimmed */
    .ocm-members-table.recruits-table { opacity: .7; }
    .ocm-lastoc-never { color: #444; font-style: italic; }
    .ocm-oc-recent    { color: #44ee88; }
    .ocm-oc-warn      { color: #ffaa00; }
    .ocm-oc-old       { color: #ff4444; }
    .ocm-seen-recent  { color: #44ee88; }
    .ocm-seen-day     { color: #ffaa00; }
    .ocm-seen-old     { color: #888; }

    #ocm-blocked { margin-bottom: 10px; }
    #ocm-lowcpr  { margin-bottom: 10px; }
    .ocm-blocked-row {
      display: flex;
      align-items: center;
      gap: 8px;
      font-size: 11px;
      padding: 2px 0;
    }
    .ocm-blocked-name { color: #ddd; flex: 1; }
    .ocm-blocked-reason { color: #ff8800; }

    #ocm-next-banner {
      border-radius: 5px;
      padding: 8px 12px;
      margin-bottom: 10px;
      font-size: 12px;
      display: none;
    }
    #ocm-next-banner.banner-ok      { background: #0a2a0a; border: 1px solid #226622; color: #88dd88; }
    #ocm-next-banner.banner-warn    { background: #2a1a00; border: 1px solid #885500; color: #ffcc66; }
    #ocm-next-banner.banner-crit    { background: #2a0a00; border: 1px solid #882200; color: #ff8866; }
    .ocm-banner-title   { font-weight: bold; margin-bottom: 4px; }
    .ocm-banner-issues  { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 5px; }
    .ocm-banner-issue   { font-size: 11px; background: rgba(0,0,0,.3); border-radius: 3px; padding: 1px 6px; }
    .ocm-item-req {
      display: flex;
      align-items: center;
      gap: 5px;
      font-size: 10px;
      margin-top: 3px;
      padding: 2px 6px;
      background: #0a1020;
      border-radius: 3px;
      flex-wrap: wrap;
    }
    .ocm-item-req-label { color: #777; flex-shrink: 0; }
    .ocm-item-tag {
      display: inline-flex;
      align-items: center;
      gap: 3px;
      padding: 1px 5px;
      border-radius: 3px;
      font-size: 10px;
      font-weight: bold;
    }
    .item-ok       { background: #003322; color: #44ee88; border: 1px solid #006644; }
    .item-missing  { background: #330a00; color: #ff6633; border: 1px solid #882200; }
    .item-armory   { background: #1a1a00; color: #ddaa00; border: 1px solid #554400; }
    .item-unknown  { background: #1a1a2e; color: #666;    border: 1px solid #333; }
    .item-tool-badge   { font-size: 9px; color: #888; }
    .item-mat-badge    { font-size: 9px; color: #cc6600; }

    #ocm-items-needed { margin-bottom: 10px; }
    .ocm-items-table {
      width: 100%;
      border-collapse: collapse;
      font-size: 11px;
    }
    .ocm-items-table th {
      text-align: left;
      color: #888;
      font-weight: normal;
      font-size: 10px;
      text-transform: uppercase;
      letter-spacing: .5px;
      padding: 3px 6px;
      border-bottom: 1px solid #222;
    }
    .ocm-items-table td {
      padding: 3px 6px;
      border-bottom: 1px solid #111;
      color: #ccc;
      vertical-align: middle;
    }
    .ocm-items-table tr:hover td { background: #1a1a2e; }
    .ocm-items-table .td-item   { color: #fff; }
    .ocm-items-table .td-oc     { color: #aaa; }
    .ocm-items-table .td-member { color: #aaa; }
    .td-status-ok      { color: #44ee88; font-weight: bold; }
    .td-status-armory  { color: #ddaa00; font-weight: bold; }
    .td-status-missing { color: #ff4444; font-weight: bold; }
    .td-status-open    { color: #555; }

    #ocm-footer {
      text-align: right;
      font-size: 10px;
      color: #555;
      margin-top: 8px;
    }
    #ocm-error {
      color: #ff4444;
      font-size: 12px;
      padding: 6px;
      display: none;
    }
    .ocm-spinner {
      display: inline-block;
      width: 12px;
      height: 12px;
      border: 2px solid #444;
      border-top-color: #ff7700;
      border-radius: 50%;
      animation: ocm-spin .7s linear infinite;
      vertical-align: middle;
      margin-right: 4px;
    }
    @keyframes ocm-spin { to { transform: rotate(360deg); } }

    /* Analytics */
    #ocm-analytics { margin-bottom: 10px; }
    .ocm-analytics-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr));
      gap: 10px;
      margin-bottom: 8px;
    }
    .ocm-analytics-card {
      background: #1a1a2e;
      border: 1px solid #2a2a4a;
      border-radius: 6px;
      padding: 8px 10px;
    }
    .ocm-analytics-card h4 {
      margin: 0 0 6px;
      font-size: 9px;
      text-transform: uppercase;
      letter-spacing: .5px;
      color: #ff7700;
      border-bottom: 1px solid #2a2a4a;
      padding-bottom: 3px;
      display: flex;
      align-items: center;
      justify-content: space-between;
    }
    .ocm-chart-toggle {
      font-size: 9px;
      color: #555;
      cursor: pointer;
      background: #0f1a30;
      border: 1px solid #2a3a5a;
      border-radius: 3px;
      padding: 1px 5px;
      text-transform: none;
      letter-spacing: 0;
      font-weight: normal;
    }
    .ocm-chart-toggle:hover { color: #aaa; border-color: #4a6a9a; }
    .ocm-chart-wrap { display: none; }
    .ocm-chart-wrap.visible { display: block; }
    .ocm-analytics-table {
      width: 100%;
      border-collapse: collapse;
      font-size: 11px;
    }
    .ocm-analytics-table th {
      text-align: left;
      color: #555;
      font-weight: normal;
      font-size: 10px;
      text-transform: uppercase;
      letter-spacing: .5px;
      padding: 2px 4px;
      border-bottom: 1px solid #222;
    }
    .ocm-analytics-table td {
      padding: 3px 4px;
      border-bottom: 1px solid #111;
      color: #ccc;
      vertical-align: middle;
    }
    .ocm-analytics-table tr:hover td { background: #16213e; }
    .ocm-analytics-table .td-right { text-align: right; }
    .ocm-rate-high  { color: #44ee88; font-weight: bold; }
    .ocm-rate-mid   { color: #ffaa00; font-weight: bold; }
    .ocm-rate-low   { color: #ff4444; font-weight: bold; }
    .ocm-stat-pill {
      display: inline-block;
      padding: 1px 6px;
      border-radius: 3px;
      font-size: 10px;
      font-weight: bold;
    }
    .pill-success { background: #003322; color: #44ee88; }
    .pill-failure { background: #330a00; color: #ff6633; }
    .pill-expired { background: #1a1a00; color: #888; }

    /* Last 5 OCs collapsible table */
    #ocm-last5-wrap {
      margin-bottom: 10px;
    }
    #ocm-last5-toggle {
      font-size: 10px;
      background: #0f3460;
      border: 1px solid #2a4a7a;
      border-radius: 4px;
      color: #aaccff;
      padding: 3px 8px;
      cursor: pointer;
      margin-bottom: 6px;
    }
    #ocm-last5-toggle:hover { background: #1a4a7a; }
    #ocm-last5-body { display: none; }
    .ocm-last5-row-header {
      display: flex;
      align-items: center;
      gap: 8px;
      padding: 5px 8px;
      background: #1a1a2e;
      border: 1px solid #2a2a4a;
      border-radius: 4px;
      margin-bottom: 3px;
      cursor: pointer;
      font-size: 11px;
    }
    .ocm-last5-row-header:hover { background: #1e1e36; }
    .ocm-last5-detail {
      display: none;
      background: #111827;
      border: 1px solid #2a2a4a;
      border-top: none;
      border-radius: 0 0 4px 4px;
      padding: 6px 10px;
      margin-top: -3px;
      margin-bottom: 4px;
      font-size: 11px;
    }
    .ocm-last5-detail table { width: 100%; border-collapse: collapse; font-size: 10px; }
    .ocm-last5-detail td, .ocm-last5-detail th { padding: 2px 5px; border-bottom: 1px solid #1a1a2e; }
    .ocm-last5-detail th { color: #555; font-weight: normal; text-transform: uppercase; font-size: 9px; }

    /* Downloads */
    #ocm-downloads { margin-bottom: 10px; }
    .ocm-downloads-grid {
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
      padding: 4px 0;
    }
    .ocm-dl-btn {
      background: #0f3460;
      border: 1px solid #2a4a7a;
      border-radius: 5px;
      color: #aaccff;
      padding: 6px 12px;
      font-size: 11px;
      cursor: pointer;
      text-align: left;
      display: flex;
      flex-direction: column;
      gap: 2px;
    }
    .ocm-dl-btn:hover { background: #1a4a7a; border-color: #4a7aaa; }
    .ocm-dl-btn strong { color: #fff; font-size: 12px; }
    .ocm-dl-btn span   { color: #888; font-size: 10px; }
  `);

  // ─── 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-ok';
    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] = await Promise.all([
      apiFetch('/faction?selections=crimes,basic', apiKey),
      apiFetch('/faction?selections=members', apiKey),
      apiFetch('/faction?selections=armory', apiKey).catch(() => ({})),
    ]);

    // 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;
      if (INACTIVE.has((oc.status || '').toLowerCase())) continue;
      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));
        }
      }
    }

    // Resolve item names via the torn items endpoint
    let itemNames = {};
    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}`;
        }
      } 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)));

    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) {
        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);
      }
    }

    // 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, lastOc, exMemberNames };
  }

  // ─── 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.3.5</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:#888;flex:1"></span>
        <button id="ocm-refresh-btn" title="Refresh data">↻ Refresh</button>
        <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">Torn API Key
            <span style="font-size:10px;color:#888;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:#ff7700">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">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:#888">Stats bar turns red when any difficulty falls below this value. Set 0 to disable.</span>
          </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:#44ee88;align-self:center"></span>
        </div>
      </div>
      <div id="ocm-stats-bar" style="display:none">
        <div class="ocm-stat" title="Number of active OCs (excluding completed/expired)">
          <span class="ocm-stat-label">Active OCs</span>
          <span class="ocm-stat-value" id="ocm-s-active">–</span>
        </div>
        <div class="ocm-stat" title="Slots across all active OCs with no member assigned yet">
          <span class="ocm-stat-label">Open Slots</span>
          <span class="ocm-stat-value" id="ocm-s-open">–</span>
        </div>
        <div class="ocm-stat" 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" style="color:#ffaa00">–</span>
        </div>
        <div class="ocm-stat" 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" style="color:#ff4444">–</span>
        </div>
        <div class="ocm-stat" title="Faction members not currently assigned to any OC — available to fill open slots">
          <span class="ocm-stat-label">Members Free</span>
          <span class="ocm-stat-value" id="ocm-s-free">–</span>
        </div>
        <div class="ocm-stat" 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 / Diff</span>
          <span class="ocm-stat-value" id="ocm-s-recruiting" style="font-size:12px">–</span>
        </div>
        <div class="ocm-stat" 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 OCs</span>
          <span class="ocm-stat-value" id="ocm-s-stuck" style="color:#ff4444">–</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:#1a1a2e;border:0.5px solid #2a2a4a;border-radius:6px;padding:8px 12px;margin-bottom:10px;font-size:12px"></div>
        <div class="ocm-section-title collapsed" id="ocm-title-available">Members Available for Assignment <a href="https://www.torn.com/factions.php?step=your#/tab=controls&option=newsletter&target=notInOC" target="_blank" title="Send newsletter to members not in an OC" onclick="event.stopPropagation()" style="color:#ff7700;text-decoration:none;font-size:13px;margin-left:6px">✉</a></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 class="ocm-section-title collapsed" id="ocm-title-blocked">Blocked Members (Jail / Hospital / Abroad)</div>
        <div id="ocm-blocked" style="display:none"></div>
        <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 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 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 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 = {}) {
    const crimes = faction.crimes || {};

    /** 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     = [];

    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++;
      }

      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 recruitStatVal = Object.keys(recruitingByDiff).sort((a, b) => Number(a) - Number(b))
      .map(d => `D${d}:${recruitingByDiff[d]}`)
      .join(' ');
    document.getElementById('ocm-s-recruiting').textContent = recruitStatVal || '–';
    if (recruitStatEl) {
      recruitStatEl.classList.toggle('ocm-stat-warn', anyBelowMin);
      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  ? '#ff4444'
                             : secsLeft < 86400 ? '#ff8844'
                             : '#ff8844';
              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:#ff4444">Expired</span>`;
            })()
          : '';

        const blockersHtml = blockers.map(b => {
          const s           = (b.status || '').toLowerCase();
          const isAbroad    = s === 'abroad';
          const isTraveling = s === 'traveling';
          let statusLabel;
          if (isAbroad) {
            statusLabel = `${flagFromDescription(b.description)} Abroad`;
          } else if (isTraveling) {
            const returning = b.description.toLowerCase().includes('returning');
            statusLabel = returning ? '✈→🏠 Returning' : '🏠→✈ Traveling';
          } 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:#ff6644">${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:#2255aa">—</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:#44ee88;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
      const leaderAdvisoryEl = document.getElementById('ocm-leader-advice');
      if (leaderAdvisoryEl) {
        const leaderSlots = [];
        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';
            const cpr    = slot.checkpoint_pass_rate ?? null;
            const weight = getWeight(oc.name || '', role);
            leaderSlots.push({ ocName: oc.name || `OC #${ocId}`, role, cpr, weight, difficulty: oc.difficulty ?? '?', expiredAt: oc.expired_at ?? null, timeLeft: oc.time_left ?? null });
          }
        }
        if (leaderSlots.length === 0) {
          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 ? '#44ee88' : top.cpr >= CPR_CRIT ? '#ffaa00' : '#ff4444';
          const wCol   = top.weight == null ? '#555' : top.weight >= WEIGHT_HIGH ? '#ff8844' : top.weight >= WEIGHT_MID ? '#aaa' : '#555';
          const tagLabel = top.tag === 'good'
            ? '<span style="font-size:10px;background:#003322;color:#44ee88;border-radius:3px;padding:1px 5px">&#10003; Good fit</span>'
            : top.tag === 'underutilised'
              ? '<span style="font-size:10px;background:#2a1a00;color:#ffaa44;border-radius:3px;padding:1px 5px">&#9432; Low-weight role</span>'
              : top.tag === 'marginal'
                ? '<span style="font-size:10px;background:#2a1500;color:#ff8844;border-radius:3px;padding:1px 5px">&#9888; Marginal CPR</span>'
                : '<span style="font-size:10px;background:#330a00;color:#ff6633;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:#1a1a00;color:#ffcc44;border-radius:3px;padding:1px 5px">&#9201; Expires soon</span>'
              : secsLeft != null && secsLeft < 24 * 3600
                ? '<span style="font-size:10px;background:#1a1a00;color:#ffcc44;border-radius:3px;padding:1px 5px">&#9201; Expiring today</span>'
                : '<span style="font-size:10px;background:#1a1a00;color:#ffcc44;border-radius:3px;padding:1px 5px">&#9201; Nearly ready</span>';
          })() : '';
          leaderAdvisoryEl.style.display = 'block';
          leaderAdvisoryEl.innerHTML = `
            <div style="font-size:10px;color:#ff7700;text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px">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:#fff">${top.role}</span>
              <span style="color:#888">in</span>
              <span style="color:#ff7700">${top.ocName}</span>
              <span style="color:#666;font-size:10px">D${top.difficulty}</span>
              ${tagLabel}${urgLabel2}
              <span style="margin-left:auto;display:flex;gap:10px;font-size:11px">
                <span>CPR: <strong style="color:${cprCol}">${top.cpr != null ? top.cpr+'%' : '?'}</strong></span>
                <span>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:#555;margin-top:3px">${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:#ff8800' : '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>`
          : '';

        return `
          <div class="ocm-slot ${riskCls}">
            ${slotStatusHtml}
            <span class="ocm-slot-role" title="${roleName}">${roleName}</span>
            <span class="ocm-slot-member">${memberName}</span>
            ${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' };
    }

    /** Render a members table into a target element. Used for both Available and Recruits. */
    function renderMembersTable(members, containerEl, extraClass = '') {
      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;
      });
      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);
    }

    // ── 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();
        const isAbroad    = s === 'abroad';
        const isTraveling = s === 'traveling';
        let statusLabel;
        if (isAbroad) {
          // Show country flag for abroad members
          statusLabel = flagFromDescription(m.description);
        } else if (isTraveling) {
          const returning = (m.description || '').toLowerCase().includes('returning');
          statusLabel = returning ? '✈→🏠' : '🏠→✈';
        } else {
          statusLabel = m.status || 'Unknown';
        }
        const ocLabel = m.ocName
          ? `<span style="color:#888;font-size:10px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${m.ocName}">OC: ${m.ocName}</span>`
          : '<span style="flex:1"></span>';
        const countdownLabel = m.ocExecutesAt
          ? `<span class="ocm-time" data-until="${m.ocExecutesAt}" style="color:#aaa;font-size:10px;flex:0 0 80px;text-align:right">${fmtTime(m.ocExecutesAt - now)}</span>`
          : `<span style="color:#555;font-size:10px;flex:0 0 80px;text-align:right">No timer</span>`;
        return `
          <div class="ocm-blocked-row">
            <a class="ocm-blocked-name" href="/profiles.php?XID=${m.id}" target="_blank" style="color:#ddd;text-decoration:none;flex:0 0 120px">${m.name}</a>
            <span class="ocm-blocked-reason" style="flex:0 0 60px" 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-blocked-row">
            <a class="ocm-blocked-name" href="/profiles.php?XID=${r.uid}" target="_blank" style="color:#ddd;text-decoration:none">${r.name}</a>
            <span style="color:#888;font-size:11px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${r.ocName}">${r.ocName}</span>
            <span style="color:#aaa;font-size:11px;flex:0 0 80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${r.roleName}">${r.roleName}</span>
            ${weightHtml}
            ${riskBadge}
            <span class="ocm-slot-cpr ${cprCls}" style="flex:0 0 34px;text-align:right">${r.cpr}%</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' ? '#44ee88' : '#ff4444';
      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:#003322;color:#44ee88;border-radius:3px;padding:1px 4px;margin-left:4px">Paid ✓</span>`
        : paid === false
          ? `<span style="font-size:9px;background:#330a00;color:#ff6633;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 ? '#44ee88' : cpr >= CPR_CRIT ? '#ffaa00' : '#ff4444';
        const wCol   = w   == null ? '#555' : w >= WEIGHT_HIGH ? '#ff8800' : 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:#fff;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:#888">${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:#1a1a2e;border:1px solid #2a2a4a;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:#1a1a2e;border:1px solid #2a2a4a;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:#ff7700">${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">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 style="max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" 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" 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 ? '#44ee88' : rate >= 65 ? '#ffaa00' : '#ff4444';

      // 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 ? '#44ee88' : rate >= 65 ? '#ffaa00' : '#ff4444';
      // 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:#44ee88">${successes}</span> /
            <span style="color:#ff4444">${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) ? '#ffcc44' : '#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' ? '#226622' : r.outcome === 'failure' ? '#882200' : '#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 = '#44ee88') {
      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 ? '#44ee88' : values[i] >= 65 ? '#ffaa00' : '#ff4444';
          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] : '#44ee88';
          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),
          ['#ff4444','#ffaa00','#ffcc44','#44ee88','#44aaff']);
      }

      // 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 ? '#44ee88' : r.rate >= 0.65 ? '#ffaa00' : '#ff4444'),
        );
      }

      // 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:#fff;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 #1a1a2e">${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()}`;
    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-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';
    });

    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:#1a1a2e;border:0.5px solid #2a2a4a;border-left:3px solid #554400;border-radius:0 6px 6px 0;padding:8px 12px;font-size:11px;color:#888;margin-bottom:10px';
    notice.innerHTML = '<strong style="color:#ffcc44">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:#44ee88;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 ? '#44ee88' : myCpr >= CPR_CRIT ? '#ffaa00' : '#ff4444';
      const wCol     = myWeight == null ? '#555' : myWeight >= WEIGHT_HIGH ? '#ff8844' : myWeight >= WEIGHT_MID ? '#aaa' : '#555';
      const progPct  = Math.min(100, Math.max(0, myProg != null ? myProg : 0));
      const progCol  = progPct >= 100 ? '#44ee88' : '#ffaa00';
      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:#004422;color:#44ee88;font-size:10px;padding:1px 6px;border-radius:3px;font-weight:bold">READY</span>'
        : phase === 'blocked'
          ? '<span style="background:#330033;color:#dd44dd;font-size:10px;padding:1px 6px;border-radius:3px;font-weight:bold">BLOCKED</span>'
          : '<span style="background:#0f3460;color:#7aadff;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:#2a0000;border:1px solid #882200;border-radius:4px;padding:6px 10px;margin-bottom:8px;font-size:11px;color:#ff8866">'
          + '\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:#1a0a00;border:1px solid #664400;border-radius:4px;padding:6px 10px;margin-bottom:8px;font-size:11px;color:#ffaa44">'
          + '\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:#44ee88">\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:#cc2222">\u2717</span>';
        const progStr = prog == null ? '' : prog >= 100
          ? '<span style="color:#44ee88;font-size:9px">\u2713 done</span>'
          : '<span style="color:#ffaa00;font-size:9px">' + prog.toFixed(0) + '%</span>';
        const nameStyle = isMe ? 'color:#ff7700;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 ? '#882200' : blockedSlots.length ? '#664400' : phase === 'ready' ? '#00aa44' : '#2a3a6a';
      const card = document.createElement('div');
      card.style.cssText = 'background:#1a1a2e;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:#fff">' + (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:#0f1a30;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:#1a1a2e;border:0.5px solid #2a2a4a;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);
        const ineligible = scored.filter(s => !s.eligible);
        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;
        const title = document.createElement('div');
        title.style.cssText = 'color:#ff7700;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:#2a0a00;border:0.5px solid #882200;border-radius:6px;padding:8px 12px;margin-bottom:8px;font-size:11px;color:#ff8844';
          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' ? '#226622' : s.tag === 'risky' ? '#882200' : s.tag === 'marginal' ? '#553300' : '#554400';
          card.style.cssText = 'background:#1a1a2e;border:0.5px solid ' + borderCol + ';border-radius:6px;padding:8px 12px;margin-bottom:6px';

          const cprCol = s.cpr == null ? '#555' : s.cpr >= CPR_WARN ? '#44ee88' : s.cpr >= CPR_CRIT ? '#ffaa00' : '#ff4444';
          const wCol   = s.weight == null ? '#555' : s.weight >= WEIGHT_HIGH ? '#ff8844' : 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:#003322;color:#44ee88;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:#2a1a00;color:#ffaa44;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:#2a1500;color:#ff8844;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:#330a00;color:#ff6633;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:#1a1a00;color:#ffcc44;border-radius:3px;padding:1px 6px;margin-left:4px">' + urgLabel + '</span>';
          }

          let adviceHtml = '';
          if      (s.tag === 'underutilised') adviceHtml = '<div style="font-size:10px;color:#888;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:#888;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:#888;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:#fff;font-size:12px">' + s.role + '</span>'
              + '<span style="color:#888;font-size:11px">in</span>'
              + '<span style="color:#ff7700;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';
  }


  // ─── 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;

    try {
      const { faction, members, armory, itemNames, lastOc, exMemberNames } = await fetchAll(apiKey);
      renderDashboard(faction, members, armory, itemNames, lastOc, exMemberNames);
    } catch (_) {
      // Faction access failed — silently try member mode
      try {
        const data = await fetchMember(apiKey);
        renderMemberDashboard(data);
        document.getElementById('ocm-key-status').textContent = `Key saved ✓ · Member Mode · ↻${REFRESH_S}s`;
      } 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:#ff9944">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() {
    [
      { titleId: 'ocm-title-available', contentId: 'ocm-available'  },
      { titleId: 'ocm-title-recruits',  contentId: 'ocm-recruits'   },
      { titleId: 'ocm-title-blocked',   contentId: 'ocm-blocked'    },
      { titleId: 'ocm-title-lowcpr',    contentId: 'ocm-lowcpr'     },
      { titleId: 'ocm-title-analytics', contentId: 'ocm-analytics'  },
      { titleId: 'ocm-title-downloads', contentId: 'ocm-downloads'  },
    ].forEach(({ titleId, contentId }) => {
      const title   = document.getElementById(titleId);
      const content = document.getElementById(contentId);
      if (!title || !content) return;

      const saved = GM_getValue(`ocm_collapse_${contentId}`, 'collapsed');
      if (saved === 'open') { content.style.display = ''; title.classList.remove('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');
      });
    });
  }

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

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

    function isOcTab() {
      return location.href.includes('factions.php') && location.hash.includes('tab=crimes');
    }

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

    const tryInsert = setInterval(() => {
      const anchor =
        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();

      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';
      });

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

    }, 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:#1a1a2e;border-top:2px solid #e05a00;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' ? '#ff6644' : data.severity === 'warn' ? '#ffcc44' : '#44ee88';
    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:#e05a00;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();

})();