Faction leader dashboard for Organised Crimes 2.0 — CPR warnings, member availability, slot gaps.
// ==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> · ${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">✓ Good fit</span>'
: top.tag === 'underutilised'
? '<span style="font-size:10px;background:#2a1a00;color:#ffaa44;border-radius:3px;padding:1px 5px">ⓘ Low-weight role</span>'
: top.tag === 'marginal'
? '<span style="font-size:10px;background:#2a1500;color:#ff8844;border-radius:3px;padding:1px 5px">⚠ Marginal CPR</span>'
: '<span style="font-size:10px;background:#330a00;color:#ff6633;border-radius:3px;padding:1px 5px">⚠ 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">⏱ Expires soon</span>'
: secsLeft != null && secsLeft < 24 * 3600
? '<span style="font-size:10px;background:#1a1a00;color:#ffcc44;border-radius:3px;padding:1px 5px">⏱ Expiring today</span>'
: '<span style="font-size:10px;background:#1a1a00;color:#ffcc44;border-radius:3px;padding:1px 5px">⏱ 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 & 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">✓ 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">ⓘ 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">⚠ 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">⚠ 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
? '⏱ Expires soon'
: secsLeft != null && secsLeft < 24 * 3600
? '⏱ Expiring today'
: '⏱ 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 = `⚠ Could not load data: ${memberErr.message}<br>
<span style="font-size:11px;color:#ff9944">Please check your API key in ⚙ 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();
})();