Floating panel for Torn City's stock market. Ranks all 35 stocks by ROI, Profit/Day, and Next BB Cost. Scrapes live prices, dividends, and owned shares directly from the page. Fetches item market prices via API. Works on desktop Tampermonkey and Torn PDA (mobile).
// ==UserScript==
// @name Torn Stock Analyzer
// @namespace https://www.torn.com/
// @version 1.9.0
// @description Floating panel for Torn City's stock market. Ranks all 35 stocks by ROI, Profit/Day, and Next BB Cost. Scrapes live prices, dividends, and owned shares directly from the page. Fetches item market prices via API. Works on desktop Tampermonkey and Torn PDA (mobile).
// @author Chris_2025
// @license MIT
// @match https://www.torn.com/page.php?sid=stocks
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @connect api.torn.com
// @connect www.torn.com
// @connect query1.finance.yahoo.com
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// ═══════════════════════════════════════════════════════════════
// CONSTANTS
// ═══════════════════════════════════════════════════════════════
const SCRIPT_ID = 'torn-stock-analyzer';
const KEY_STORAGE = `${SCRIPT_ID}-apikey`;
const AUTO_REFRESH_MS = 60000; // 60s — conservative, shares the 100 req/min limit
const API_CALL_GAP_MS = 1200; // space individual calls within a cycle
let lastApiCall = 0;
const API_KEY_PAGE = 'https://www.torn.com/preferences.php#tab=api';
// Deep-link straight into Torn's Custom Key Builder with every
// selection this script could plausibly need pre-checked: item
// market prices, the item catalog (to map names to IDs), and live
// stock market + personal holdings data as a complete, future-proof
// set in case any part of the DOM-scraping path ever needs an API
// fallback. Format confirmed via Torn's community API docs:
// #tab=api?step=addNewKey&title=X&SECTION=sel1,sel2
const CUSTOM_KEY_LINK =
'https://www.torn.com/preferences.php#tab=api?step=addNewKey' +
'&title=Stock%20Analyzer' +
'&torn=items,stocks' +
'&market=itemmarket' +
'&user=stocks';
// ═══════════════════════════════════════════════════════════════
// STOCK TABLE — 23 Active + 12 Passive = 35 total
//
// dividend_kind:
// 'item' = tradeable item; item_id used for item market price lookup
// 'cash' = fixed cash payout; scraped live from page DOM
// 'special' = non-tradeable in-game benefit (energy/happy/nerve/chance-cash)
// 'perk' = passive persistent bonus, no periodic payout
// interval_days: 7 | 31 | null (perk)
// item_name: used to match against the Item Market page scrape
// ═══════════════════════════════════════════════════════════════
const STOCK_DATA = {
// ─── ACTIVE · 7-day item dividends ──────────────────────────────
FHG: { name: 'Feathery Hotels Group', bb: 2000000, type: 'active', dividend_kind: 'item', interval_days: 7, item_name: 'Feathery Hotel Coupon', perk_desc: '1× Feathery Hotel Coupon', real_ticker: 'MAR', real_name: 'Marriott Intl' },
PRN: { name: 'Performance Ribaldry', bb: 1000000, type: 'active', dividend_kind: 'item', interval_days: 7, item_name: 'Erotic DVD', perk_desc: '1× Erotic DVD', real_ticker: 'DIS', real_name: 'Walt Disney' },
BAG: { name: "Big Al's Gun Shop", bb: 3000000, type: 'active', dividend_kind: 'variable', interval_days: 7, item_name: null, perk_desc: '1× Ammunition Pack (varies w/ equipped weapon, no fixed price)', real_ticker: 'RGR', real_name: 'Sturm Ruger' },
THS: { name: 'Torn City Health Service', bb: 150000, type: 'active', dividend_kind: 'item', interval_days: 7, item_name: 'Box of Medical Supplies', perk_desc: '1× Box of Medical Supplies', real_ticker: 'UNH', real_name: 'UnitedHealth' },
LAG: { name: 'Legal Authorities Group', bb: 750000, type: 'active', dividend_kind: 'item', interval_days: 7, item_name: "Lawyer's Business Card", perk_desc: "1× Lawyer's Business Card", real_ticker: 'EFX', real_name: 'Equifax' },
HRG: { name: 'Home Retail Group', bb: 500000, type: 'active', dividend_kind: 'variable', interval_days: 7, item_name: null, perk_desc: 'Random property (Trailer→Private Island, 1/13 each, no fixed price)', real_ticker: 'HD', real_name: 'Home Depot' },
SYM: { name: 'Symbiotic Ltd.', bb: 500000, type: 'active', dividend_kind: 'item', interval_days: 7, item_name: 'Drug Pack', perk_desc: '1× Drug Pack', real_ticker: 'LLY', real_name: 'Eli Lilly' },
LSC: { name: 'Lucky Shot Casino', bb: 100000, type: 'active', dividend_kind: 'item', interval_days: 7, item_name: 'Lottery Voucher', perk_desc: '1× Lottery Voucher', real_ticker: 'MGM', real_name: 'MGM Resorts' },
PTS: { name: 'PointLess', bb: 500000, type: 'active', dividend_kind: 'special', interval_days: 7, item_name: null, perk_desc: '100 Points per payout', real_ticker: 'COIN', real_name: 'Coinbase' },
MUN: { name: 'Munster Beverage Corp.', bb: 5000000, type: 'active', dividend_kind: 'item', interval_days: 7, item_name: 'Six-Pack of Energy Drink', perk_desc: '1× Six-Pack of Energy Drink', real_ticker: 'MNST', real_name: 'Monster Beverage' },
EWM: { name: 'Eaglewood Mercenary', bb: 2000000, type: 'active', dividend_kind: 'item', interval_days: 7, item_name: 'Box of Grenades', perk_desc: '1× Box of Grenades', real_ticker: 'LMT', real_name: 'Lockheed Martin' },
CBD: { name: 'Herbal Releaf Co.', bb: 1000000, type: 'active', dividend_kind: 'special', interval_days: 7, item_name: null, perk_desc: '50 Nerve per payout', real_ticker: 'TLRY', real_name: 'Tilray Brands' },
LOS: { name: 'Lo Squalo Waste', bb: 500000, type: 'passive', dividend_kind: 'perk', interval_days: null,item_name: null, perk_desc: '+25% Mission reward bonus', real_ticker: 'WM', real_name: 'Waste Management' },
// ─── ACTIVE · 7-day special (non-tradeable) ─────────────────────
EVL: { name: 'Evil Ducks Candy Corp', bb: 100000, type: 'active', dividend_kind: 'special', interval_days: 7, item_name: null, perk_desc: '1,000 Happy per payout', real_ticker: 'HSY', real_name: 'Hershey' },
MCS: { name: 'Mc Smoogle Corp', bb: 1750000, type: 'active', dividend_kind: 'special', interval_days: 7, item_name: null, perk_desc: '100 Energy (max 10 blocks)', real_ticker: 'GOOGL', real_name: 'Alphabet' },
ASS: { name: 'Alcoholics Synonymous', bb: 3000000, type: 'active', dividend_kind: 'item', interval_days: 7, item_name: 'Six-Pack of Alcohol', perk_desc: '1× Six-Pack of Alcohol', real_ticker: 'DEO', real_name: 'Diageo' },
// ─── ACTIVE · 31-day cash dividends ─────────────────────────────
TCT: { name: 'The Torn City Times', bb: 100000, type: 'active', dividend_kind: 'cash', interval_days: 31, item_name: null, perk_desc: '$1M cash every 31 days', real_ticker: 'NYT', real_name: 'New York Times' },
IOU: { name: 'Insured On Us', bb: 3000000, type: 'active', dividend_kind: 'cash', interval_days: 31, item_name: null, perk_desc: '$12M cash every 31 days', real_ticker: 'ALL', real_name: 'Allstate' },
TSB: { name: 'Torn & Shanghai Banking', bb: 4000000, type: 'active', dividend_kind: 'cash', interval_days: 31, item_name: null, perk_desc: '$50M cash every 31 days', real_ticker: 'JPM', real_name: 'JPMorgan Chase' },
GRN: { name: 'Grain', bb: 500000, type: 'active', dividend_kind: 'cash', interval_days: 31, item_name: null, perk_desc: '$8M cash every 31 days', real_ticker: 'ADM', real_name: 'Archer-Daniels' },
TCC: { name: 'Torn City Clothing', bb: 350000, type: 'active', dividend_kind: 'variable', interval_days: 31, item_name: null, perk_desc: '1× Clothing Cache (value varies $5M-$150M, no fixed price)', real_ticker: 'TPR', real_name: 'Tapestry (Coach)' },
TMI: { name: 'TC Music Industries', bb: 6000000, type: 'active', dividend_kind: 'cash', interval_days: 31, item_name: null, perk_desc: '$25M cash every 31 days', real_ticker: 'LYV', real_name: 'Live Nation' },
TCI: { name: 'Torn City Investments', bb: 1500000, type: 'passive', dividend_kind: 'perk', interval_days: null,item_name: null, perk_desc: '+10% Bank interest bonus', real_ticker: 'BRK-B', real_name: 'Berkshire Hathaway' },
// ─── PASSIVE · persistent perks, no periodic payout ────────────
WLT: { name: 'Wind Lines Travel', bb: 9000000, type: 'passive', dividend_kind: 'perk', interval_days: null,item_name: null, perk_desc: 'Free private jet travel', real_ticker: 'UAL', real_name: 'United Airlines' },
WSU: { name: 'West Side University', bb: 1000000, type: 'passive', dividend_kind: 'perk', interval_days: null,item_name: null, perk_desc: '-10% Education time', real_ticker: 'CHGG', real_name: 'Chegg' },
ELT: { name: 'Empty Lunchbox Traders', bb: 5000000, type: 'passive', dividend_kind: 'perk', interval_days: null,item_name: null, perk_desc: '-10% Home Upgrade cost', real_ticker: 'LOW', real_name: "Lowe's" },
IIL: { name: 'I Industries Ltd.', bb: 1000000, type: 'passive', dividend_kind: 'perk', interval_days: null,item_name: null, perk_desc: '-50% Coding time', real_ticker: 'PANW', real_name: 'Palo Alto Networks' },
TCM: { name: 'Torn City Motors', bb: 1000000, type: 'passive', dividend_kind: 'perk', interval_days: null,item_name: null, perk_desc: '+10% Racing Skill boost', real_ticker: 'F', real_name: 'Ford Motor' },
CNC: { name: 'Crude & Co', bb: 5000000, type: 'passive', dividend_kind: 'perk', interval_days: null,item_name: null, perk_desc: 'Oil rig discount & profit boost',real_ticker: 'XOM', real_name: 'ExxonMobil' },
YAZ: { name: 'Yazoo', bb: 1000000, type: 'passive', dividend_kind: 'perk', interval_days: null,item_name: null, perk_desc: 'Free newspaper banners', real_ticker: 'META', real_name: 'Meta Platforms' },
MSG: { name: 'Messaging Inc.', bb: 500000, type: 'passive', dividend_kind: 'perk', interval_days: null,item_name: null, perk_desc: 'Classified ad discount', real_ticker: 'TWLO', real_name: 'Twilio' },
IST: { name: 'International School TC', bb: 100000, type: 'passive', dividend_kind: 'perk', interval_days: null,item_name: null, perk_desc: 'Free education courses', real_ticker: 'STRA', real_name: 'Strategic Education' },
TGP: { name: 'Tell Group Plc.', bb: 500000, type: 'passive', dividend_kind: 'perk', interval_days: null,item_name: null, perk_desc: '+Company advertising boost', real_ticker: 'MAN', real_name: 'ManpowerGroup' },
TCP: { name: 'TC Media Productions', bb: 1000000, type: 'passive', dividend_kind: 'perk', interval_days: null,item_name: null, perk_desc: '+Company sales boost', real_ticker: 'NFLX', real_name: 'Netflix' },
SYS: { name: 'Syscore MFG', bb: 3000000, type: 'passive', dividend_kind: 'perk', interval_days: null,item_name: null, perk_desc: 'Protects company from hacks', real_ticker: 'MSFT', real_name: 'Microsoft' },
};
// ═══════════════════════════════════════════════════════════════
// STYLES
// ═══════════════════════════════════════════════════════════════
const injectStyles = () => {
if (document.getElementById(`${SCRIPT_ID}-styles`)) return;
const style = document.createElement('style');
style.id = `${SCRIPT_ID}-styles`;
style.textContent = `
@import url('https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap');
:root {
--tsa-bg: #0a0c10;
--tsa-surface: #111420;
--tsa-surface2: #181c2a;
--tsa-border: #252a3d;
--tsa-accent: #00d4ff;
--tsa-accent2: #ff6b35;
--tsa-green: #00e676;
--tsa-red: #ff1744;
--tsa-yellow: #ffd740;
--tsa-text: #e8ecf4;
--tsa-muted: #5a6080;
--tsa-real: #4fc3f7;
--tsa-passive: #b39ddb;
--tsa-glow: 0 0 20px rgba(0,212,255,0.15);
}
#tsa-root, #tsa-root * {
box-sizing: border-box;
}
#tsa-root {
font-family: 'Rajdhani', sans-serif;
background: var(--tsa-bg);
border: 1px solid var(--tsa-border);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0,0,0,0.5), var(--tsa-glow);
color: var(--tsa-text);
position: fixed;
top: 80px;
left: 80px;
width: 960px;
height: 640px;
min-width: 360px;
min-height: 220px;
max-width: 96vw;
max-height: 92vh;
z-index: 999999;
display: flex;
flex-direction: column;
resize: none; /* custom corner handle drives resize instead */
}
#tsa-root.tsa-collapsed {
height: auto !important;
min-height: 0;
}
#tsa-root.tsa-collapsed #tsa-tabs,
#tsa-root.tsa-collapsed #tsa-content,
#tsa-root.tsa-collapsed .tsa-resize-edge,
#tsa-root.tsa-collapsed .tsa-resize-corner {
display: none;
}
#tsa-root.tsa-dragging, #tsa-root.tsa-resizing {
user-select: none;
box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 1px var(--tsa-accent);
}
#tsa-header {
background: linear-gradient(90deg, #0a0c10 0%, #0d1828 100%);
border-bottom: 1px solid var(--tsa-border);
padding: 10px 12px;
display: flex;
align-items: center;
gap: 8px;
cursor: grab;
flex-shrink: 0;
flex-wrap: wrap;
touch-action: none; /* let our pointer handlers own all drag gestures here */
}
#tsa-header.tsa-grabbing { cursor: grabbing; }
#tsa-header h2 {
margin: 0;
font-size: 16px;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--tsa-accent);
text-shadow: 0 0 10px rgba(0,212,255,0.4);
flex: 1 1 auto;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tsa-version {
font-family: 'Space Mono', monospace;
font-size: 9px;
color: var(--tsa-muted);
background: var(--tsa-surface);
padding: 2px 7px;
border-radius: 3px;
border: 1px solid var(--tsa-border);
flex-shrink: 0;
}
.tsa-winbtn {
width: 26px; height: 26px;
display: flex; align-items: center; justify-content: center;
background: var(--tsa-surface2);
border: 1px solid var(--tsa-border);
border-radius: 5px;
color: var(--tsa-muted);
cursor: pointer;
font-size: 13px;
transition: all 0.15s;
flex-shrink: 0;
touch-action: manipulation;
order: 99; /* keep window-control buttons pinned to the end of the row */
}
.tsa-winbtn:hover { color: var(--tsa-text); border-color: var(--tsa-accent); }
#tsa-reset-pos-btn { order: -1; } /* ...except reset, which stays first/leftmost */
/* Narrow-viewport header: drop decorative pieces (version badge,
"next refresh" label, fetch-status text) entirely rather than
trying to shrink them further, and let the timer/refresh group
wrap onto its own row below the title so the two window-control
buttons (reset, collapse) always stay on the first row and
reachable without any horizontal scrolling. JS adds/removes
the .tsa-narrow class based on actual measured panel width
(see applyResponsiveHeader), since CSS media queries only see
the browser viewport, not this absolutely-positioned panel's
own width. */
#tsa-root.tsa-narrow .tsa-version,
#tsa-root.tsa-narrow #tsa-timer-label,
#tsa-root.tsa-narrow .tsa-fetch-status {
display: none;
}
#tsa-root.tsa-narrow #tsa-header {
gap: 6px;
}
#tsa-root.tsa-narrow #tsa-timer-wrap {
flex: 1 1 100%;
order: 100;
justify-content: flex-end;
gap: 6px;
}
#tsa-root.tsa-narrow #tsa-refresh-btn {
padding: 5px 9px;
font-size: 10px;
}
/* 8-direction resize handles: 4 edges (line along a whole side)
plus 4 corners (small squares at each corner), all using
touch-action:none so our pointer handlers get every move
event instead of the browser intercepting them as a scroll. */
.tsa-resize-edge, .tsa-resize-corner {
position: absolute;
z-index: 10;
touch-action: none;
}
.tsa-resize-n, .tsa-resize-s { left: 14px; right: 14px; height: 10px; cursor: ns-resize; }
.tsa-resize-n { top: -3px; }
.tsa-resize-s { bottom: -3px; }
.tsa-resize-e, .tsa-resize-w { top: 14px; bottom: 14px; width: 10px; cursor: ew-resize; }
.tsa-resize-e { right: -3px; }
.tsa-resize-w { left: -3px; }
.tsa-resize-corner { width: 16px; height: 16px; }
.tsa-resize-nw { top: -3px; left: -3px; cursor: nwse-resize; }
.tsa-resize-ne { top: -3px; right: -3px; cursor: nesw-resize; }
.tsa-resize-sw { bottom: -3px; left: -3px; cursor: nesw-resize; }
.tsa-resize-se { bottom: -3px; right: -3px; cursor: nwse-resize; }
.tsa-resize-se::before {
content: '';
position: absolute;
bottom: 5px; right: 5px;
width: 9px; height: 9px;
border-right: 2px solid var(--tsa-muted);
border-bottom: 2px solid var(--tsa-muted);
border-radius: 0 0 2px 0;
}
.tsa-resize-se:hover::before { border-color: var(--tsa-accent); }
#tsa-timer-wrap {
display: flex;
align-items: center;
gap: 7px;
font-family: 'Space Mono', monospace;
font-size: 10px;
color: var(--tsa-muted);
}
#tsa-timer-ring { position: relative; width: 26px; height: 26px; flex-shrink: 0; }
#tsa-timer-ring svg { transform: rotate(-90deg); }
#tsa-timer-ring .bg { fill: none; stroke: var(--tsa-border); stroke-width: 3; }
#tsa-timer-ring .fg {
fill: none; stroke: var(--tsa-accent); stroke-width: 3;
stroke-linecap: round; stroke-dasharray: 69.1;
transition: stroke-dashoffset 1s linear;
}
#tsa-timer-num {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
font-size: 7px; color: var(--tsa-accent); font-weight: 700;
}
#tsa-refresh-btn {
padding: 5px 11px; border-radius: 5px; border: 1px solid var(--tsa-border);
background: var(--tsa-surface2); color: var(--tsa-text); cursor: pointer;
font-family: 'Rajdhani', sans-serif; font-weight: 700; font-size: 11px;
letter-spacing: 0.8px; text-transform: uppercase; transition: all 0.15s;
}
#tsa-refresh-btn:hover:not(:disabled) { border-color: var(--tsa-accent); color: var(--tsa-accent); }
#tsa-refresh-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.tsa-fetch-status {
font-family: 'Space Mono', monospace; font-size: 9px;
color: var(--tsa-muted); white-space: nowrap;
}
#tsa-tabs {
display: flex; background: var(--tsa-surface);
border-bottom: 1px solid var(--tsa-border);
overflow-x: auto; overflow-y: hidden;
flex-shrink: 0;
/* Let the browser/WebView handle horizontal pan gestures here
directly (don't let our drag/resize pointer handlers compete
for them), which is what makes swipe-to-scroll reliable on
touch devices like Torn PDA. */
touch-action: pan-x;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: var(--tsa-border) transparent;
}
#tsa-tabs::-webkit-scrollbar { height: 4px; }
#tsa-tabs::-webkit-scrollbar-thumb { background: var(--tsa-border); border-radius: 3px; }
.tsa-tab {
padding: 9px 18px; cursor: pointer; font-size: 11px; font-weight: 700;
letter-spacing: 1px; text-transform: uppercase; color: var(--tsa-muted);
border-bottom: 2px solid transparent; background: none;
border-top: none; border-left: none; border-right: none;
transition: all 0.15s; white-space: nowrap;
flex-shrink: 0;
}
.tsa-tab:hover { color: var(--tsa-text); }
.tsa-tab.active { color: var(--tsa-accent); border-bottom-color: var(--tsa-accent); }
#tsa-content {
padding: 16px 18px; overflow-y: auto; overflow-x: auto;
scrollbar-width: thin; scrollbar-color: var(--tsa-border) transparent;
flex: 1 1 auto;
min-height: 0;
touch-action: pan-x pan-y;
-webkit-overflow-scrolling: touch;
}
#tsa-content::-webkit-scrollbar { width: 5px; height: 4px; }
#tsa-content::-webkit-scrollbar-thumb { background: var(--tsa-border); border-radius: 3px; }
.tsa-setup-card {
background: var(--tsa-surface); border: 1px solid var(--tsa-border);
border-radius: 8px; padding: 22px; max-width: 680px;
}
.tsa-setup-card h3 {
margin: 0 0 6px; font-size: 16px; font-weight: 700;
text-transform: uppercase; letter-spacing: 1px; color: var(--tsa-text);
}
.tsa-setup-card p { color: var(--tsa-muted); font-size: 12px; margin: 0 0 16px; line-height: 1.6; }
.tsa-tos-table { width: 100%; border-collapse: collapse; margin: 0 0 16px; font-size: 10px; }
.tsa-tos-table th {
background: var(--tsa-surface2); color: var(--tsa-accent); font-weight: 700;
text-transform: uppercase; letter-spacing: 0.5px; padding: 7px 8px;
border: 1px solid var(--tsa-border); text-align: left; font-size: 9px;
}
.tsa-tos-table td { padding: 7px 8px; border: 1px solid var(--tsa-border); color: var(--tsa-text); vertical-align: top; }
.tsa-tos-tag {
background: var(--tsa-surface2); border: 1px solid var(--tsa-border);
color: var(--tsa-green); padding: 2px 5px; border-radius: 3px;
font-family: 'Space Mono', monospace; font-size: 8px;
display: inline-block; margin: 1px 0; white-space: nowrap;
}
.tsa-input-row { display: flex; gap: 8px; margin-bottom: 10px; }
.tsa-input-row input {
flex: 1; background: var(--tsa-surface2); border: 1px solid var(--tsa-border);
border-radius: 5px; color: var(--tsa-text); padding: 9px 12px;
font-family: 'Space Mono', monospace; font-size: 12px; outline: none; transition: border-color 0.2s;
}
.tsa-input-row input:focus { border-color: var(--tsa-accent); }
.tsa-input-row input::placeholder { color: var(--tsa-muted); }
.tsa-btn {
padding: 9px 15px; border-radius: 5px; border: none; cursor: pointer;
font-family: 'Rajdhani', sans-serif; font-weight: 700; font-size: 11px;
letter-spacing: 1px; text-transform: uppercase; transition: all 0.15s; white-space: nowrap;
}
.tsa-btn-primary { background: linear-gradient(135deg, var(--tsa-accent), #0056d6); color: #fff; }
.tsa-btn-primary:hover { opacity: 0.85; transform: translateY(-1px); }
.tsa-btn-secondary { background: var(--tsa-surface2); border: 1px solid var(--tsa-border); color: var(--tsa-text); }
.tsa-btn-secondary:hover { border-color: var(--tsa-accent); color: var(--tsa-accent); }
.tsa-btn-danger { background: transparent; border: 1px solid var(--tsa-red); color: var(--tsa-red); }
.tsa-btn-danger:hover { background: var(--tsa-red); color: #fff; }
.tsa-key-links { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.tsa-key-links a {
color: var(--tsa-accent); font-size: 11px; text-decoration: none;
border: 1px solid var(--tsa-border); border-radius: 4px; padding: 4px 10px;
transition: all 0.15s; font-weight: 600; letter-spacing: 0.5px;
}
.tsa-key-links a:hover { border-color: var(--tsa-accent); background: rgba(0,212,255,0.07); }
.tsa-status-badge {
display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px;
border-radius: 20px; font-size: 10px; font-weight: 700;
letter-spacing: 1px; text-transform: uppercase;
}
.tsa-status-ok { background: rgba(0,230,118,0.1); border: 1px solid var(--tsa-green); color: var(--tsa-green); }
.tsa-status-err { background: rgba(255,23,68,0.1); border: 1px solid var(--tsa-red); color: var(--tsa-red); }
.tsa-cache-bar {
display: flex; align-items: center; gap: 7px; font-size: 10px;
color: var(--tsa-muted); margin-bottom: 12px;
font-family: 'Space Mono', monospace; flex-wrap: wrap;
}
.tsa-cache-dot {
width: 7px; height: 7px; border-radius: 50%; background: var(--tsa-green);
box-shadow: 0 0 5px var(--tsa-green); animation: tsaPulse 2s infinite; flex-shrink: 0;
}
.tsa-cache-dot.stale { background: var(--tsa-yellow); box-shadow: 0 0 5px var(--tsa-yellow); }
@keyframes tsaPulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
#tsa-best-buy {
display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; margin-bottom: 14px;
}
.tsa-best-card {
background: var(--tsa-surface); border: 1px solid var(--tsa-border);
border-radius: 8px; padding: 11px 14px; position: relative; overflow: hidden;
}
.tsa-best-card::before { content:''; position:absolute; top:0;left:0;right:0; height:2px; }
.tsa-best-card.roi::before { background: linear-gradient(90deg,var(--tsa-accent),transparent); }
.tsa-best-card.profit::before { background: linear-gradient(90deg,var(--tsa-green),transparent); }
.tsa-best-card.cost::before { background: linear-gradient(90deg,var(--tsa-yellow),transparent); }
.tsa-best-label { font-size: 9px; color: var(--tsa-muted); text-transform: uppercase; letter-spacing: 1px; font-weight: 700; margin-bottom: 3px; }
.tsa-best-ticker { font-size: 20px; font-weight: 700; color: var(--tsa-text); letter-spacing: 1px; }
.tsa-best-val { font-family: 'Space Mono', monospace; font-size: 11px; color: var(--tsa-green); }
.tsa-best-name { font-size: 9px; color: var(--tsa-muted); margin-top: 2px; }
#tsa-controls {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 12px;
}
.tsa-filter-group { display: flex; align-items: center; gap: 12px; margin-left: auto; flex-wrap: wrap; }
.tsa-filter-item { display: flex; align-items: center; gap: 5px; cursor: pointer; }
.tsa-filter-item label {
font-size: 11px; color: var(--tsa-muted); font-weight: 700;
letter-spacing: 0.5px; text-transform: uppercase; cursor: pointer;
}
.tsa-filter-item input[type=checkbox] { accent-color: var(--tsa-accent); cursor: pointer; }
.tsa-sortable-th { cursor: pointer; user-select: none; transition: color 0.15s, background 0.15s; }
.tsa-sortable-th:hover { color: var(--tsa-accent) !important; background: rgba(0,212,255,0.05); }
.tsa-sortable-th.sorted { color: var(--tsa-accent) !important; }
.tsa-sort-arrow { margin-left: 4px; font-size: 8px; color: var(--tsa-accent); display: inline-block; }
/* Stocks tab layout: cache bar / best-buy / controls stay fixed
height at the top, the table fills whatever space remains and
scrolls internally — so its horizontal scrollbar sits at a
constant, always-reachable spot instead of trailing off after
however many rows happen to be visible. */
.tsa-stocks-layout {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.tsa-stocks-layout > .tsa-cache-bar,
.tsa-stocks-layout > .tsa-warn,
.tsa-stocks-layout > #tsa-best-buy,
.tsa-stocks-layout > #tsa-controls {
flex-shrink: 0;
}
.tsa-table-wrap {
overflow: auto;
border: 1px solid var(--tsa-border);
border-radius: 8px;
flex: 1 1 auto;
min-height: 120px;
}
table.tsa-table { width: 100%; border-collapse: collapse; font-size: 11px; }
table.tsa-table thead tr { background: var(--tsa-surface2); position: sticky; top: 0; z-index: 2; }
table.tsa-table th {
padding: 9px 10px; text-align: left; color: var(--tsa-muted); font-weight: 700;
font-size: 9px; letter-spacing: 1px; text-transform: uppercase; white-space: nowrap;
border-bottom: 1px solid var(--tsa-border); border-right: 1px solid var(--tsa-border);
}
table.tsa-table th:last-child { border-right: none; }
table.tsa-table td {
padding: 9px 10px; border-bottom: 1px solid rgba(37,42,61,0.5);
border-right: 1px solid rgba(37,42,61,0.3); color: var(--tsa-text);
vertical-align: middle; white-space: nowrap;
}
table.tsa-table td:last-child { border-right: none; }
table.tsa-table tr:last-child td { border-bottom: none; }
table.tsa-table tbody tr { transition: background 0.1s; }
table.tsa-table tbody tr:hover { background: rgba(255,255,255,0.025); }
table.tsa-table tbody tr.owned { background: rgba(0,212,255,0.04); }
table.tsa-table tbody tr.passive-row { opacity: 0.75; }
.tsa-ticker { font-family: 'Space Mono', monospace; font-weight: 700; font-size: 12px; color: var(--tsa-text); letter-spacing: 1px; }
.tsa-real-tkr { font-family: 'Space Mono', monospace; font-size: 9px; color: var(--tsa-real); margin-left: 4px; }
.tsa-sname { font-size: 9px; color: var(--tsa-muted); }
.v-green { color: var(--tsa-green); font-family: 'Space Mono', monospace; font-size: 10px; }
.v-red { color: var(--tsa-red); font-family: 'Space Mono', monospace; font-size: 10px; }
.v-yellow { color: var(--tsa-yellow); font-family: 'Space Mono', monospace; font-size: 10px; }
.v-blue { color: var(--tsa-real); font-family: 'Space Mono', monospace; font-size: 10px; }
.v-mono { font-family: 'Space Mono', monospace; font-size: 10px; }
.v-passive { color: var(--tsa-passive); font-family: 'Space Mono', monospace; font-size: 10px; }
.badge-own { background: rgba(0,212,255,0.13); border: 1px solid var(--tsa-accent); color: var(--tsa-accent); font-size: 8px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; padding: 1px 4px; border-radius: 3px; margin-left: 3px; }
.badge-passive { background: rgba(179,157,219,0.1); border: 1px solid rgba(179,157,219,0.3); color: var(--tsa-passive); font-size: 8px; font-weight: 700; text-transform: uppercase; padding: 1px 4px; border-radius: 3px; }
.badge-7d { background: rgba(0,230,118,0.1); border: 1px solid rgba(0,230,118,0.3); color: var(--tsa-green); font-size: 8px; font-weight: 700; padding: 1px 4px; border-radius: 3px; }
.badge-31d { background: rgba(255,107,53,0.1); border: 1px solid rgba(255,107,53,0.3); color: var(--tsa-accent2); font-size: 8px; font-weight: 700; padding: 1px 4px; border-radius: 3px; }
.conf-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 3px; vertical-align: middle; }
.conf-high { background: var(--tsa-green); }
.conf-medium { background: var(--tsa-yellow); }
.conf-low { background: var(--tsa-red); }
.pred-grid { display: grid; grid-template-columns: repeat(5,48px); gap: 2px; margin-top: 3px; }
.pred-cell { background: var(--tsa-surface2); border: 1px solid var(--tsa-border); border-radius: 3px; padding: 3px 2px; text-align: center; }
.pred-label { font-size: 7px; color: var(--tsa-muted); text-transform: uppercase; }
.pred-val { font-family: 'Space Mono', monospace; font-size: 9px; }
.tsa-pc { background: var(--tsa-surface); border: 1px solid var(--tsa-border); border-radius: 8px; padding: 14px; margin-bottom: 8px; }
.tsa-pc h4 {
margin: 0 0 10px; font-size: 12px; color: var(--tsa-text); font-weight: 700;
text-transform: uppercase; letter-spacing: 1px; display: flex; align-items: center; gap: 6px;
}
.kv-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(120px,1fr)); gap: 8px; }
.kv-label { color: var(--tsa-muted); font-size: 9px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 2px; }
.kv-val { font-size: 12px; }
.tsa-loading { text-align: center; padding: 40px 20px; color: var(--tsa-muted); }
.tsa-spinner {
width: 30px; height: 30px; border: 3px solid var(--tsa-border);
border-top-color: var(--tsa-accent); border-radius: 50%;
animation: tsaSpin 0.8s linear infinite; margin: 0 auto 12px;
}
@keyframes tsaSpin { to { transform: rotate(360deg); } }
.tsa-error {
background: rgba(255,23,68,0.08); border: 1px solid rgba(255,23,68,0.3);
border-radius: 6px; padding: 12px 15px; color: var(--tsa-red); font-size: 12px; margin-bottom: 12px;
}
.tsa-info {
background: rgba(0,212,255,0.06); border: 1px solid rgba(0,212,255,0.2);
border-radius: 6px; padding: 9px 13px; color: var(--tsa-accent);
font-size: 12px; margin-bottom: 10px; line-height: 1.5;
}
.tsa-tipbadge { user-select: none; }
#tsa-tooltip {
position: fixed;
z-index: 1000001;
background: var(--tsa-surface2);
border: 1px solid var(--tsa-accent);
border-radius: 7px;
padding: 9px 13px;
font-family: 'Rajdhani', sans-serif;
font-size: 13px;
font-weight: 600;
color: var(--tsa-text);
max-width: 240px;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
}
#tsa-tooltip.visible { opacity: 1; pointer-events: auto; }
.tsa-warn {
background: rgba(255,215,64,0.06); border: 1px solid rgba(255,215,64,0.2);
border-radius: 6px; padding: 7px 11px; color: var(--tsa-yellow);
font-size: 11px; margin-bottom: 8px; line-height: 1.5;
}
.tsa-theme-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.tsa-theme-field { display: flex; flex-direction: column; gap: 5px; }
.tsa-theme-field label {
font-size: 11px; font-weight: 700; color: var(--tsa-text);
text-transform: uppercase; letter-spacing: 0.5px;
}
.tsa-theme-swatch-row { display: flex; align-items: center; gap: 8px; }
.tsa-theme-hint { font-size: 10px; color: var(--tsa-muted); }
input[type="color"]#tsa-theme-bg,
input[type="color"][id^="tsa-theme-"] {
width: 38px; height: 28px; padding: 0; border: 1px solid var(--tsa-border);
border-radius: 5px; background: var(--tsa-surface2); cursor: pointer;
flex-shrink: 0;
}
`;
document.head.appendChild(style);
};
// Defensive read: on Torn PDA, GM_getValue can return a Promise instead
// of resolving synchronously. If so, treat the key as not-yet-loaded
// (empty string) and pick it up asynchronously once it resolves.
const readInitialApiKey = () => {
try {
const v = GM_getValue(KEY_STORAGE, '');
if (v && typeof v.then === 'function') {
v.then(resolved => {
if (resolved) {
state.apiKey = resolved;
// Re-render and kick off a fetch now that we actually have a key
if (document.getElementById('tsa-root')) {
renderContent();
fetchAll().then(startAutoRefresh);
}
}
}).catch(() => {});
return '';
}
return v || '';
} catch (e) { return ''; }
};
// ═══════════════════════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════════════════════
let state = {
apiKey: '', // populated by readInitialApiKey() right after state is defined
apiKeyValid: false,
sortBy: 'roi',
sortDir: 'desc',
showOwnedOnly: false,
showPassive: true,
tornStocks: {}, // { TICKER: { current_price } } — built from page scrape
pageStocks: {}, // { TICKER: { price, shares, divText, cashDividend, isPassive, statusText } }
userStocks: {}, // { TICKER: { shares } } — built from page scrape
itemPrices: {}, // { item_name: price } — from Item Market page scrape
realStockData: {}, // { ticker: { pct_1d, pct_1w, ... } } — from Yahoo Finance
lastFetch: null,
fetchError: null,
loading: false,
itemsLoading: false,
itemsError: null,
itemIdMap: null, // cached name→id map, fetched once per session
scrapeDebug: null, // diagnostics from the last DOM scrape (see scrapePageStocks)
activeTab: 'stocks',
refreshTimer: null,
countdownSec: 60,
countdownTimer: null,
fetchStep: '', // shown in header during fetch
};
state.apiKey = readInitialApiKey();
// ═══════════════════════════════════════════════════════════════
// UTILITIES
// ═══════════════════════════════════════════════════════════════
const fmt = {
cash: v => {
if (v == null || isNaN(v)) return '—';
if (Math.abs(v) >= 1e9) return '$' + (v / 1e9).toFixed(2) + 'B';
if (Math.abs(v) >= 1e6) return '$' + (v / 1e6).toFixed(2) + 'M';
if (Math.abs(v) >= 1e3) return '$' + (v / 1e3).toFixed(1) + 'K';
return '$' + v.toFixed(0);
},
pct: v => {
if (v == null || isNaN(v)) return '—';
return (v >= 0 ? '+' : '') + v.toFixed(2) + '%';
},
num: v => (v == null || isNaN(v)) ? '—' : Number(v).toLocaleString(),
shares: v => {
if (v == null || isNaN(v)) return '—';
if (v >= 1e6) return (v / 1e6).toFixed(1) + 'M';
if (v >= 1e3) return (v / 1e3).toFixed(0) + 'K';
return String(v);
},
days: v => {
if (v == null || !isFinite(v) || v < 0) return '—';
if (v > 3650) return '>10yr';
if (v >= 365) return (v / 365).toFixed(1) + 'yr';
if (v >= 30) return (v / 30).toFixed(1) + 'mo';
return v.toFixed(0) + 'd';
},
ts: ts => {
if (!ts) return 'never';
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
},
};
const sleep = ms => new Promise(r => setTimeout(r, ms));
// ═══════════════════════════════════════════════════════════════
// API LAYER (only used for key validation now)
// ═══════════════════════════════════════════════════════════════
const apiCall = (endpoint, params = {}) => new Promise((resolve, reject) => {
const gap = Math.max(0, API_CALL_GAP_MS - (Date.now() - lastApiCall));
setTimeout(() => {
lastApiCall = Date.now();
const qs = new URLSearchParams({ key: state.apiKey, ...params }).toString();
const url = `https://api.torn.com/${endpoint}?${qs}`;
GM_xmlhttpRequest({
method: 'GET', url,
onload: r => {
try {
const d = JSON.parse(r.responseText);
if (d.error) reject({ code: d.error.code, msg: d.error.error });
else resolve(d);
} catch (e) { reject({ code: -1, msg: 'JSON parse error' }); }
},
onerror: () => reject({ code: -1, msg: 'Network error' }),
});
}, gap);
});
const validateKey = async key => {
const saved = state.apiKey;
state.apiKey = key;
try {
const d = await apiCall('key/', { selections: 'info' });
return { valid: true, level: d.info?.access_level ?? '?' };
} catch (e) {
state.apiKey = saved;
return { valid: false, code: e.code, msg: e.msg };
}
};
// Actually exercises the two specific permissions this script needs
// (torn→items and market→itemmarket) rather than just checking that
// the key is valid in general — a key can be valid but still lack
// either of these specific selections.
const checkKeyPermissions = async key => {
const saved = state.apiKey;
state.apiKey = key;
const result = {
items: false, itemmarket: false, tornStocks: false, userStocks: false,
itemsError: null, itemmarketError: null, tornStocksError: null, userStocksError: null,
};
try {
await apiCall('v2/torn', { selections: 'items' });
result.items = true;
} catch (e) {
result.itemsError = errorMsg(e.code);
}
// market/{id}/itemmarket needs a real item id to test against —
// use 367 (Feathery Hotel Coupon), a stable, always-tradeable item.
try {
await apiCall('v2/market/367/itemmarket', {});
result.itemmarket = true;
} catch (e) {
result.itemmarketError = errorMsg(e.code);
}
try {
await apiCall('v2/torn', { selections: 'stocks' });
result.tornStocks = true;
} catch (e) {
result.tornStocksError = errorMsg(e.code);
}
try {
await apiCall('v2/user', { selections: 'stocks' });
result.userStocks = true;
} catch (e) {
result.userStocksError = errorMsg(e.code);
}
state.apiKey = saved;
return result;
};
const errorMsg = code => ({
1: 'API key is empty.',
2: 'Incorrect key — check you copied it correctly.',
5: 'Rate limit hit (>100 req/min). Wait a moment.',
7: 'Access denied — key lacks required permission.',
8: 'IP temporarily banned. Wait before retrying.',
9: 'Torn API is currently disabled.',
10: 'Key owner is in federal jail.',
13: 'Key disabled — owner offline for >7 days.',
14: 'Daily read limit reached.',
16: 'Key access level too low. Create a custom key.',
18: 'API key has been paused by its owner.',
}[code] || `Unknown error (code ${code}).`);
// ═══════════════════════════════════════════════════════════════
// ITEM MARKET PRICES (via Torn API — requires 'market' permission)
// Step 1: fetch torn→items once per session to build name→ID map
// (cached in state.itemIdMap so we don't refetch every cycle)
// Step 2: fetch v2 market/{id}/itemmarket for each needed item,
// take the lowest listed price.
// ═══════════════════════════════════════════════════════════════
const buildItemIdMap = () => new Promise(resolve => {
const url = `https://api.torn.com/v2/torn?selections=items&key=${state.apiKey}`;
GM_xmlhttpRequest({
method: 'GET', url,
onload: r => {
try {
const d = JSON.parse(r.responseText);
if (d.error) { resolve(null); return; }
const items = d.items || {};
const map = {};
// v2 response shape: { items: [ {id, name, ...}, ... ] } or { items: {id: {...}} }
const list = Array.isArray(items) ? items : Object.entries(items).map(([id, v]) => ({ id, ...v }));
list.forEach(it => {
if (it && it.name) map[it.name] = it.id;
});
resolve(map);
} catch (e) { resolve(null); }
},
onerror: () => resolve(null),
});
});
const fetchOneItemPrice = (itemId) => new Promise(resolve => {
const url = `https://api.torn.com/v2/market/${itemId}/itemmarket?key=${state.apiKey}`;
GM_xmlhttpRequest({
method: 'GET', url,
onload: r => {
try {
const d = JSON.parse(r.responseText);
if (d.error) { resolve(null); return; }
const listings = d.itemmarket?.listings || d.itemmarket || [];
const prices = listings
.map(l => l.price ?? l.cost)
.filter(p => typeof p === 'number' && p > 0)
.sort((a, b) => a - b);
if (!prices.length) { resolve(null); return; }
// Use the median of the 5 cheapest listings rather than the
// single lowest — avoids skew from a stale or outlier listing
// and better reflects what you'd realistically pay.
const sample = prices.slice(0, 5);
const mid = sample[Math.floor((sample.length - 1) / 2)];
resolve(mid);
} catch (e) { resolve(null); }
},
onerror: () => resolve(null),
});
});
const fetchItemPrices = async () => {
const itemNames = [...new Set(
Object.values(STOCK_DATA)
.filter(s => s.dividend_kind === 'item' && s.item_name)
.map(s => s.item_name)
)];
if (!itemNames.length || !state.apiKey) return {};
// Build (or reuse cached) name → item ID map
if (!state.itemIdMap) {
const map = await buildItemIdMap();
if (!map) {
state.itemsError = 'Could not fetch item list — check your key has "torn → items" access.';
return {};
}
state.itemIdMap = map;
}
const results = {};
let anyFailed = false;
for (const name of itemNames) {
const itemId = state.itemIdMap[name];
if (!itemId) { anyFailed = true; continue; }
const price = await fetchOneItemPrice(itemId);
if (price != null) results[name] = price;
else anyFailed = true;
await sleep(API_CALL_GAP_MS); // respect rate limit, ~1 call/1.2s
}
if (anyFailed && Object.keys(results).length === 0) {
state.itemsError = 'Could not fetch item market prices — check your key has "market → itemmarket" access enabled.';
}
return results;
};
// ═══════════════════════════════════════════════════════════════
// REAL-WORLD STOCK FETCH (Yahoo Finance, for predictions only)
// ═══════════════════════════════════════════════════════════════
const fetchRealStock = ticker => new Promise(resolve => {
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${ticker}?interval=1d&range=1y`;
GM_xmlhttpRequest({
method: 'GET', url,
headers: { Accept: 'application/json' },
onload: r => {
try {
const closes = JSON.parse(r.responseText).chart.result[0].indicators.quote[0].close;
const n = closes.length;
if (n < 2) { resolve(null); return; }
const pct = (a, b) => ((b - a) / a) * 100;
const last = closes[n - 1];
resolve({
pct_1d: n >= 2 ? pct(closes[n - 2], last) : null,
pct_1w: n >= 6 ? pct(closes[n - 6], last) : null,
pct_2w: n >= 11 ? pct(closes[n - 11], last) : null,
pct_1m: n >= 22 ? pct(closes[n - 22], last) : null,
pct_1y: pct(closes[0], last),
});
} catch (e) { resolve(null); }
},
onerror: () => resolve(null),
});
});
const fetchAllRealStocks = async () => {
const tickers = [...new Set(Object.values(STOCK_DATA).map(s => s.real_ticker))];
for (const t of tickers) {
state.realStockData[t] = await fetchRealStock(t);
await sleep(150);
}
renderContent();
};
// ═══════════════════════════════════════════════════════════════
// DOM SCRAPER
// Reads ALL stock data directly from the rendered page.
// Called with a retry loop to handle React hydration delay.
// ═══════════════════════════════════════════════════════════════
const scrapePageStocks = () => {
const result = {};
const debug = {
ulFound: 0, tickersFound: 0, pricesFromAriaLabel: 0,
pricesFromDigitSpans: 0, pricesZero: 0, sampleRaw: null,
sampleTickerHTML: null,
};
// ── Primary pass: per-stock <ul> elements ──────────────────────
const ulList = document.querySelectorAll('ul.stock___CnywB');
debug.ulFound = ulList.length;
ulList.forEach(ul => {
const acronymEl = ul.querySelector('.tt-acronym');
// Ticker extraction — try every method from most to least reliable:
// 1. data-acronym attribute via dataset (PC/standard)
// 2. data-acronym attribute via getAttribute (in case dataset is
// not fully supported in PDA's WebView)
// 3. Text content of .tt-acronym span (e.g. "(PRN) " → "PRN")
// 4. aria-label on the nameTab li (e.g. "Stock: Performance
// Ribaldry" — extract ticker from the img alt text instead)
// 5. The stock image src which contains the ticker in its filename
let ticker = '';
if (acronymEl) {
ticker = (acronymEl.dataset?.acronym || '').trim()
|| (acronymEl.getAttribute('data-acronym') || '').trim()
|| (acronymEl.textContent || '').replace(/[^A-Z]/g, '').trim();
}
if (!ticker) {
// Try extracting from the stock logo img src, e.g. "logos/PRN.svg"
const img = ul.querySelector('img[src*="logos/"]');
if (img) {
const m = (img.getAttribute('src') || '').match(/logos\/([A-Z]+)\.svg/);
if (m) ticker = m[1];
}
}
if (!ticker) {
// Capture what the nameTab actually looks like for one failing row
if (!debug.sampleTickerHTML) {
debug.sampleTickerHTML = (ul.querySelector('[data-name="nameTab"]')?.outerHTML || '(no nameTab)').slice(0, 600);
}
return;
}
// Validate ticker against our known stock list — skip anything
// that isn't one of our 35 stocks to avoid false positives from
// the fallback methods above.
if (!STOCK_DATA[ticker]) return;
debug.tickersFound++;
// Price: prefer aria-label (full decimal, e.g. "$614.85"), but
// some WebViews (observed on Torn PDA) strip aria-label entirely,
// so fall back to reading the visible per-digit spans Torn always
// renders regardless of accessibility attributes:
// <div class="price___..."><span class="number___...">6</span>...
const priceLi = ul.querySelector('[data-name="priceTab"]');
const priceLabel = priceLi?.getAttribute('aria-label') || '';
const priceMatch = priceLabel.match(/\$([0-9,]+(?:\.[0-9]+)?)/);
let price = priceMatch ? parseFloat(priceMatch[1].replace(/,/g, '')) : 0;
if (price) {
debug.pricesFromAriaLabel++;
} else {
const digitSpans = priceLi?.querySelectorAll('[class*="number___"]');
if (digitSpans && digitSpans.length) {
const digitsText = Array.from(digitSpans).map(s => s.textContent).join('');
const parsed = parseFloat(digitsText);
if (!isNaN(parsed) && parsed > 0) { price = parsed; debug.pricesFromDigitSpans++; }
}
}
if (!price) {
debug.pricesZero++;
if (!debug.sampleRaw) {
// Capture one failing row's raw markup (truncated) so we can
// see exactly what Torn PDA actually rendered here, without
// needing another round-trip just to ask for it.
debug.sampleRaw = {
ticker,
priceLiHTML: (priceLi?.outerHTML || '(no priceTab element found)').slice(0, 500),
hasAriaLabel: priceLi?.hasAttribute('aria-label') || false,
};
}
}
// Owned shares from count span
const ownedLi = ul.querySelector('[data-name="ownedTab"]');
const countEl = ownedLi?.querySelector('.count___yJoKq');
const countTxt = countEl?.textContent?.trim() || 'None';
const shares = countTxt === 'None' ? 0 : parseInt(countTxt.replace(/,/g, '')) || 0;
// Dividend text and type: prefer aria-label, fall back to the
// always-visible <p class="dividend___..."> / status text nodes
// for the same WebView-stripped-attributes reason as price above.
const divLi = ul.querySelector('[data-name="dividendTab"]');
const divLabel = divLi?.getAttribute('aria-label') || '';
const divMatch = divLabel.match(/Dividend:\s*(.+?)\.\s*Status:/);
let divText = divMatch ? divMatch[1].trim() : '';
const statusMatch = divLabel.match(/Status:\s*(.+)$/);
let statusText = statusMatch ? statusMatch[1].trim() : '';
const isPassive = !!divLi?.querySelector('[class*="passive___"]');
if (!divText) {
const divTextEl = divLi?.querySelector('[class*="dividend___"]');
if (divTextEl) divText = divTextEl.textContent?.trim() || '';
}
if (!statusText) {
const statusEl = divLi?.querySelector('[class*="active___"], [class*="passive___"]');
if (statusEl) statusText = statusEl.textContent?.trim() || '';
}
// Parse cash dividend value from live page text e.g. "$8,000,000"
let cashDividend = 0;
if (!isPassive && divText.startsWith('$')) {
const cm = divText.match(/\$([0-9,]+)/);
if (cm) cashDividend = parseInt(cm[1].replace(/,/g, '')) || 0;
}
result[ticker] = {
price, shares, divText, statusText, isPassive, cashDividend,
};
});
result.__debug = debug;
return result;
};
// Wait for the stocks DOM to be fully hydrated by React (retry up to N times)
const scrapeWithRetry = async (maxAttempts = 5, delayMs = 800) => {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const data = scrapePageStocks();
const tickers = Object.keys(data);
// Consider it ready when we have ≥30 stocks with valid prices
const validCount = tickers.filter(t => data[t].price > 0).length;
if (validCount >= 30) return data;
if (attempt < maxAttempts - 1) {
state.fetchStep = `Waiting for page data… (${attempt + 1}/${maxAttempts})`;
updateFetchStatus();
await sleep(delayMs);
}
}
// Return whatever we have after retries
return scrapePageStocks();
};
const updateFetchStatus = () => {
const el = document.getElementById('tsa-fetch-status');
if (el) el.textContent = state.fetchStep;
};
// ═══════════════════════════════════════════════════════════════
// MAIN DATA FETCH
// Phase 1: Scrape page DOM (prices, shares, cash dividends, BB data)
// Phase 2: Fetch item market prices in parallel (for item-dividend stocks)
// Phase 3: Fetch Yahoo Finance data in background (predictions)
// ═══════════════════════════════════════════════════════════════
const fetchAll = async () => {
if (!state.apiKey) return;
state.loading = true;
state.fetchError = null;
state.fetchStep = 'Scraping page data…';
renderContent();
try {
// ── Phase 1: DOM scrape with retry ────────────────────────────
state.fetchStep = 'Reading stock prices from page…';
updateFetchStatus();
const pageData = await scrapeWithRetry(5, 700);
state.scrapeDebug = pageData.__debug || null;
delete pageData.__debug;
state.pageStocks = pageData;
// Build tornStocks and userStocks from scraped data
state.tornStocks = {};
state.userStocks = {};
for (const [ticker, d] of Object.entries(pageData)) {
state.tornStocks[ticker] = { current_price: d.price };
if (d.shares > 0) {
state.userStocks[ticker] = { shares: d.shares };
}
}
state.lastFetch = Date.now();
state.loading = false;
state.fetchStep = '';
renderContent();
// ── Phase 2: Item market prices (parallel, non-blocking) ──────
// Requires API key with torn→items and market→itemmarket access.
if (state.apiKey) {
state.itemsLoading = true;
state.itemsError = null;
state.fetchStep = 'Fetching item market prices…';
updateFetchStatus();
const itemPrices = await fetchItemPrices();
state.itemsLoading = false;
state.fetchStep = '';
if (Object.keys(itemPrices).length > 0) {
state.itemPrices = { ...state.itemPrices, ...itemPrices };
}
renderContent(); // re-render to show item dividend values
}
// ── Phase 3: Real-world stock predictions (background) ────────
fetchAllRealStocks();
} catch (e) {
state.loading = false;
state.fetchStep = '';
state.fetchError = e.msg || String(e);
renderContent();
}
};
// ═══════════════════════════════════════════════════════════════
// AUTO-REFRESH
// ═══════════════════════════════════════════════════════════════
const updateCountdownUI = () => {
const ring = document.getElementById('tsa-timer-ring');
const num = document.getElementById('tsa-timer-num');
if (!ring || !num) return;
const fg = ring.querySelector('.fg');
const total = 69.1;
const fraction = state.countdownSec / 60;
if (fg) fg.style.strokeDashoffset = total * (1 - fraction);
num.textContent = state.countdownSec;
};
const startAutoRefresh = () => {
stopAutoRefresh();
state.countdownSec = 60;
updateCountdownUI();
state.countdownTimer = setInterval(() => {
state.countdownSec = Math.max(0, state.countdownSec - 1);
updateCountdownUI();
}, 1000);
state.refreshTimer = setInterval(() => {
state.countdownSec = 60;
fetchAll();
}, AUTO_REFRESH_MS);
};
const stopAutoRefresh = () => {
if (state.refreshTimer) { clearInterval(state.refreshTimer); state.refreshTimer = null; }
if (state.countdownTimer) { clearInterval(state.countdownTimer); state.countdownTimer = null; }
};
// ═══════════════════════════════════════════════════════════════
// CALCULATION ENGINE
// ═══════════════════════════════════════════════════════════════
const calcStock = ticker => {
const meta = STOCK_DATA[ticker];
if (!meta) return null;
const api = state.tornStocks[ticker] || {};
const owned = state.userStocks[ticker];
const pageStock = state.pageStocks[ticker] || {};
const price = api.current_price || 0;
// BB threshold and next-BB cost — calculated entirely from formula.
//
// IMPORTANT: Torn's "Buy X more shares for $Y to unlock the Nth
// increment" tooltip text only renders in the DOM *after the user
// clicks* the dividend status icon for that specific stock — it is
// never present passively, so a background scrape can't read it.
// We instead calculate it ourselves from data that's always present
// in the static DOM: live price, live owned shares, and the base
// BB threshold (meta.bb). Verified against a real in-game example
// (PRN: 1,000,000 owned → "buy 2,000,000 more for the 2nd
// increment" matches the cumulative-doubling formula below to
// within a few dollars, the gap being normal price drift between
// the moment of reading vs. the moment of the in-game tooltip).
//
// Increment N (1-indexed) requires a *cumulative* threshold of
// bb * (2^N - 1) total shares held (1x, then +2x, then +4x, ...).
const bb = meta.bb;
const bbCost = price * bb;
let sharesOwned = 0, increments = 0;
let cumulativeThreshold = 0, nextTierSize = bb;
if (owned) {
sharesOwned = owned.shares || 0;
while (cumulativeThreshold + nextTierSize <= sharesOwned) {
cumulativeThreshold += nextTierSize;
increments++;
nextTierSize *= 2;
}
}
const sharesNeededForNext = (cumulativeThreshold + nextTierSize) - sharesOwned;
const nextBBCost = sharesNeededForNext * price;
// Dividend value
let divValue = null;
if (meta.dividend_kind === 'cash') {
// Live cash amount from page DOM
divValue = pageStock.cashDividend || null;
} else if (meta.dividend_kind === 'item' && meta.item_name) {
// Item market price (fetched separately)
divValue = state.itemPrices[meta.item_name] || null;
}
// 'special' and 'perk' → divValue stays null (no tradeable value)
const profitPerDay = (divValue && meta.interval_days) ? divValue / meta.interval_days : null;
const roi = (profitPerDay && bbCost > 0) ? (profitPerDay * 365 / bbCost) * 100 : null;
const breakEvenDays = (profitPerDay && profitPerDay > 0 && bbCost > 0) ? bbCost / profitPerDay : null;
// Price predictions: mirror real-world % change onto Torn price
const real = state.realStockData[meta.real_ticker] || null;
const predictions = real ? {
'1d': price * (1 + (real.pct_1d || 0) / 100),
'1w': price * (1 + (real.pct_1w || 0) / 100),
'2w': price * (1 + (real.pct_2w || 0) / 100),
'1m': price * (1 + (real.pct_1m || 0) / 100),
'1y': price * (1 + (real.pct_1y || 0) / 100),
} : null;
const confMap = {
'BRK-B': 'high', 'JPM': 'high', 'MSFT': 'high',
'NYT': 'medium', 'MAR': 'medium', 'UNH': 'medium',
'HD': 'medium', 'GOOGL': 'medium', 'ADM': 'medium',
'XOM': 'medium', 'MNST': 'medium', 'MAN': 'medium',
'ALL': 'medium', 'EFX': 'medium', 'DEO': 'medium',
'LMT': 'medium', 'WM': 'medium',
'DIS': 'low', 'META': 'low', 'RGR': 'low', 'HSY': 'low',
'MGM': 'low', 'COIN': 'low', 'UAL': 'low', 'TPR': 'low',
'PANW': 'low', 'F': 'low', 'LYV': 'low', 'TLRY': 'low',
'LOW': 'low', 'CHGG': 'low', 'TWLO': 'low', 'STRA': 'low',
'NFLX': 'low',
};
const confidence = confMap[meta.real_ticker] || 'medium';
let trend7d = null;
if (Array.isArray(api.history) && api.history.length >= 2)
trend7d = ((api.history[api.history.length - 1] - api.history[0]) / api.history[0]) * 100;
return {
ticker, meta, price, bb, bbCost, sharesOwned, increments,
nextBBCost, divValue, profitPerDay, roi, breakEvenDays,
predictions, confidence, trend7d,
isOwned: sharesOwned > 0,
};
};
// Value extractors for every sortable column, keyed by column id.
// null/undefined values always sort to the bottom regardless of direction.
const SORT_EXTRACTORS = {
ticker: s => s.ticker,
type: s => s.meta.type,
price: s => s.price,
trend: s => s.trend7d,
bbCost: s => s.bbCost,
nextBB: s => s.nextBBCost,
dividend: s => s.divValue,
interval: s => s.meta.interval_days,
profit: s => s.profitPerDay,
roi: s => s.roi,
breakeven: s => s.breakEvenDays,
};
const getSortedStocks = () => {
let rows = Object.keys(STOCK_DATA).map(t => calcStock(t)).filter(Boolean);
if (state.showOwnedOnly) rows = rows.filter(s => s.isOwned);
if (!state.showPassive) rows = rows.filter(s => s.meta.type !== 'passive');
const extractor = SORT_EXTRACTORS[state.sortBy] || SORT_EXTRACTORS.roi;
rows.sort((a, b) => {
let va = extractor(a), vb = extractor(b);
const aNull = va == null, bNull = vb == null;
if (aNull && bNull) return 0;
if (aNull) return 1; // nulls always last
if (bNull) return -1;
if (typeof va === 'string') {
return state.sortDir === 'asc' ? va.localeCompare(vb) : vb.localeCompare(va);
}
return state.sortDir === 'asc' ? va - vb : vb - va;
});
return rows;
};
// ═══════════════════════════════════════════════════════════════
// RENDER — SETUP TAB
// ═══════════════════════════════════════════════════════════════
const renderSetupTab = () => `
<div class="tsa-setup-card">
<h3>🔑 API Key Setup</h3>
<p>
Your key is currently used for <strong>key validation</strong> and fetching
<strong>item market prices</strong> for item-dividend stocks (PRN, FHG, etc.).
All stock prices, shares, and cash dividends are scraped directly from the
page you're already viewing — no extra API calls needed for that today.<br><br>
The custom key below also requests <code style="background:var(--tsa-bg);padding:1px 4px;border-radius:3px;color:var(--tsa-green)">torn → stocks</code> and
<code style="background:var(--tsa-bg);padding:1px 4px;border-radius:3px;color:var(--tsa-green)">user → stocks</code> as a safety net covering everything the script could plausibly need,
even though they aren't actively used yet.<br><br>
Item market prices are fetched once per session.
Predictions use Yahoo Finance (no Torn API).
</p>
<table class="tsa-tos-table">
<thead>
<tr>
<th>Data Storage</th><th>Data Sharing</th><th>Purpose of Use</th>
<th>Key Storage & Sharing</th><th>Key Access Level</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="tsa-tos-tag">Only locally</span></td>
<td><span class="tsa-tos-tag">Nobody</span></td>
<td>
<span class="tsa-tos-tag">Non-malicious statistical analysis</span><br>
<span class="tsa-tos-tag">Public community tools</span>
</td>
<td><span class="tsa-tos-tag">Stored locally / Not shared</span></td>
<td><span class="tsa-tos-tag">Custom: torn→items, torn→stocks, market→itemmarket, user→stocks</span></td>
</tr>
</tbody>
</table>
<div class="tsa-input-row">
<input type="password" id="tsa-key-input"
placeholder="Paste your 16-character API key…"
value="${state.apiKey || ''}">
<button class="tsa-btn tsa-btn-primary" id="tsa-key-save">Validate & Save</button>
</div>
${state.apiKey ? `<span class="tsa-status-badge tsa-status-ok">✓ Key saved</span>` : ''}
<div id="tsa-key-error" class="tsa-error" style="display:none;margin-top:10px;"></div>
<div class="tsa-key-links">
<a href="${CUSTOM_KEY_LINK}" target="_blank">⚡ Create Custom Key (pre-filled)</a>
<a href="${API_KEY_PAGE}" target="_blank">⚙ Open API Keys Page</a>
<button class="tsa-btn tsa-btn-secondary" id="tsa-check-perms" ${state.apiKey ? '' : 'disabled'}>🔍 Check Permissions</button>
${state.apiKey ? `<button class="tsa-btn tsa-btn-danger" id="tsa-key-clear">Clear Key</button>` : ''}
</div>
<div id="tsa-perm-results"></div>
<div style="margin-top:14px;background:var(--tsa-surface2);border:1px solid var(--tsa-border);border-radius:6px;padding:12px 14px;font-size:11px;line-height:1.7;color:var(--tsa-muted);">
<span style="color:var(--tsa-accent);font-weight:700;text-transform:uppercase;letter-spacing:1px;font-size:10px;">Fastest way: use the pre-filled link</span><br>
1. Click <strong style="color:var(--tsa-text)">⚡ Create Custom Key (pre-filled)</strong> above —
it opens Torn's key builder with <code style="background:var(--tsa-bg);padding:1px 4px;border-radius:3px;color:var(--tsa-green)">torn → items</code>,
<code style="background:var(--tsa-bg);padding:1px 4px;border-radius:3px;color:var(--tsa-green)">torn → stocks</code>,
<code style="background:var(--tsa-bg);padding:1px 4px;border-radius:3px;color:var(--tsa-green)">market → itemmarket</code>, and
<code style="background:var(--tsa-bg);padding:1px 4px;border-radius:3px;color:var(--tsa-green)">user → stocks</code> already checked<br>
2. Confirm the name/selections look right, then click <strong style="color:var(--tsa-text)">Create</strong><br>
3. Copy the generated key and paste it above<br>
4. Click <strong style="color:var(--tsa-text)">🔍 Check Permissions</strong> to confirm all four selections actually work — a key can save successfully but still be missing one of them<br><br>
<span style="color:var(--tsa-muted);font-size:10px;">Manual route: Open API Keys Page → Create New Key → Custom access level → enable the same four selections yourself.</span>
</div>
</div>
`;
// ═══════════════════════════════════════════════════════════════
// RENDER — STOCKS TAB
// ═══════════════════════════════════════════════════════════════
const renderStocksTab = () => {
if (!state.apiKey)
return `<div class="tsa-info">⚠ Set your API key in the <strong>Setup</strong> tab first.</div>`;
if (state.loading)
return `<div class="tsa-loading"><div class="tsa-spinner"></div>${state.fetchStep || 'Fetching stock data…'}</div>`;
if (state.fetchError)
return `<div class="tsa-error">❌ ${state.fetchError}</div>
<button class="tsa-btn tsa-btn-primary" id="tsa-retry">Retry Now</button>`;
if (!state.lastFetch)
return `<div class="tsa-info">Ready — click <strong>⟳ Refresh</strong> to load, or wait for auto-refresh.</div>`;
const stocks = getSortedStocks();
const actives = stocks.filter(s => s.meta.type === 'active');
const byROI = actives.filter(s => s.roi != null).sort((a, b) => b.roi - a.roi)[0];
const byProfit= actives.filter(s => s.profitPerDay != null).sort((a, b) => b.profitPerDay - a.profitPerDay)[0];
const byCost = stocks.filter(s => s.nextBBCost > 0).sort((a, b) => a.nextBBCost - b.nextBBCost)[0];
const cacheAge = Math.floor((Date.now() - state.lastFetch) / 1000);
const isStale = cacheAge > 90;
const itemCount = Object.values(STOCK_DATA).filter(s => s.dividend_kind === 'item' && s.item_name).length;
const itemsFetched = Object.keys(state.itemPrices).length;
// Self-diagnosing panel: if most stocks came back with a zero
// price, show exactly what the scraper found instead of just
// silently displaying zeros — this is what would otherwise need
// a manual dev-tools round trip to figure out (especially awkward
// on Torn PDA, which has no accessible dev console).
const dbg = state.scrapeDebug;
const zeroPriceCount = stocks.filter(s => !s.price).length;
const showDiagnostics = dbg && zeroPriceCount > stocks.length * 0.5;
const diagnosticsHTML = showDiagnostics ? `
<div class="tsa-warn" style="white-space:normal;">
⚠ <strong>Most prices came back as $0</strong> — diagnostics from the last scrape:<br>
<span style="font-family:'Space Mono',monospace;font-size:10px;display:block;margin-top:6px;line-height:1.6;">
Stock rows found in page: ${dbg.ulFound}<br>
Tickers matched: ${dbg.tickersFound}<br>
Prices read from aria-label: ${dbg.pricesFromAriaLabel}<br>
Prices read from visible digits (fallback): ${dbg.pricesFromDigitSpans}<br>
Prices still $0 after both methods: ${dbg.pricesZero}
</span>
${dbg.sampleRaw ? `
<details style="margin-top:8px;">
<summary style="cursor:pointer;color:var(--tsa-accent);font-size:10px;">Show raw markup for a failing stock (${dbg.sampleRaw.ticker})</summary>
<div style="font-family:'Space Mono',monospace;font-size:9px;color:var(--tsa-muted);margin-top:6px;word-break:break-all;background:var(--tsa-bg);padding:8px;border-radius:4px;max-height:160px;overflow:auto;">
has aria-label: ${dbg.sampleRaw.hasAriaLabel}<br><br>
${dbg.sampleRaw.priceLiHTML.replace(/</g, '<')}
</div>
</details>` : ''}
${dbg.sampleTickerHTML ? `
<details style="margin-top:8px;">
<summary style="cursor:pointer;color:var(--tsa-accent);font-size:10px;">Show nameTab markup (ticker extraction failed)</summary>
<div style="font-family:'Space Mono',monospace;font-size:9px;color:var(--tsa-muted);margin-top:6px;word-break:break-all;background:var(--tsa-bg);padding:8px;border-radius:4px;max-height:160px;overflow:auto;">
${dbg.sampleTickerHTML.replace(/</g, '<')}
</div>
</details>` : ''}
</div>` : '';
return `
<div class="tsa-stocks-layout">
${diagnosticsHTML}
<div class="tsa-cache-bar">
<span class="tsa-cache-dot ${isStale ? 'stale' : ''}"></span>
Fetched ${fmt.ts(state.lastFetch)} · ${cacheAge}s ago
${isStale ? '· <span style="color:var(--tsa-yellow)">May be stale</span>' : '· Fresh'}
<span style="margin-left:auto;font-size:9px;color:var(--tsa-muted)" id="tsa-fetch-status">
${state.itemsLoading ? `Loading item prices…` :
itemsFetched > 0 ? `Item prices: ${itemsFetched}/${itemCount} loaded` :
`Item prices: loading on first refresh`}
</span>
</div>
${state.itemsLoading ? `<div class="tsa-warn">⏳ Fetching item market prices — dividend values for item stocks will update shortly.</div>` : ''}
${state.itemsError ? `<div class="tsa-warn">⚠ ${state.itemsError}</div>` : ''}
${byROI || byProfit || byCost ? `
<div id="tsa-best-buy">
${byROI ? `<div class="tsa-best-card roi">
<div class="tsa-best-label">🏆 Best ROI</div>
<div class="tsa-best-ticker">${byROI.ticker}</div>
<div class="tsa-best-val">${fmt.pct(byROI.roi)}/yr</div>
<div class="tsa-best-name">${byROI.meta.name}</div>
</div>` : ''}
${byProfit ? `<div class="tsa-best-card profit">
<div class="tsa-best-label">💰 Best Profit/Day</div>
<div class="tsa-best-ticker">${byProfit.ticker}</div>
<div class="tsa-best-val">${fmt.cash(byProfit.profitPerDay)}/day</div>
<div class="tsa-best-name">${byProfit.meta.name}</div>
</div>` : ''}
${byCost ? `<div class="tsa-best-card cost">
<div class="tsa-best-label">💸 Cheapest Next BB</div>
<div class="tsa-best-ticker">${byCost.ticker}</div>
<div class="tsa-best-val">${fmt.cash(byCost.nextBBCost)}</div>
<div class="tsa-best-name">${byCost.meta.name}</div>
</div>` : ''}
</div>` : ''}
<div id="tsa-controls">
<div class="tsa-filter-group" style="margin-left:0;">
<div class="tsa-filter-item">
<input type="checkbox" id="tsa-owned-only" ${state.showOwnedOnly ? 'checked' : ''}>
<label for="tsa-owned-only">Owned Only</label>
</div>
<div class="tsa-filter-item">
<input type="checkbox" id="tsa-show-passive" ${state.showPassive ? 'checked' : ''}>
<label for="tsa-show-passive">Show Passive</label>
</div>
</div>
</div>
<div class="tsa-table-wrap">
<table class="tsa-table">
<thead>
<tr>
${renderSortableTh('ticker', 'Stock')}
${renderSortableTh('type', 'Type')}
${renderSortableTh('price', 'Price')}
${renderSortableTh('trend', '7d Trend')}
${renderSortableTh('bbCost', '1 BB Cost')}
${renderSortableTh('nextBB', 'Next BB Cost')}
${renderSortableTh('dividend', 'Dividend')}
${renderSortableTh('interval', 'Interval')}
${renderSortableTh('profit', 'Profit/Day')}
${renderSortableTh('roi', 'ROI/yr')}
${renderSortableTh('breakeven', 'Break-Even')}
<th>Predictions (mirrored)</th>
</tr>
</thead>
<tbody>
${stocks.map(s => renderRow(s)).join('')}
</tbody>
</table>
</div>
</div>
`;
};
// Renders a clickable, sortable column header with a direction triangle.
// Triangle points up (▲) when sorted ascending (small→large), down (▼) when descending.
const renderSortableTh = (key, label) => {
const isActive = state.sortBy === key;
const arrow = isActive
? (state.sortDir === 'asc' ? '▲' : '▼')
: '';
return `<th class="tsa-sortable-th ${isActive ? 'sorted' : ''}" data-sortkey="${key}">
<span>${label}</span>${arrow ? `<span class="tsa-sort-arrow">${arrow}</span>` : ''}
</th>`;
};
const renderRow = s => {
const m = s.meta;
const intervalBadge =
m.interval_days === 7 ? `<span class="badge-7d">7d</span>` :
m.interval_days === 31 ? `<span class="badge-31d">31d</span>` :
`<span class="badge-passive">perk</span>`;
const trendCls = s.trend7d == null ? '' : s.trend7d >= 0 ? 'v-green' : 'v-red';
let divCell;
if (s.divValue != null) {
divCell = `<span class="v-green">${fmt.cash(s.divValue)}</span>`;
} else if (m.dividend_kind === 'item') {
divCell = `<span style="color:var(--tsa-muted);font-size:9px">${state.itemsLoading ? 'Loading…' : '—'}</span>`;
} else if (m.dividend_kind === 'variable') {
divCell = `<span class="v-yellow tsa-tipbadge" style="font-size:9px;cursor:pointer;" data-tip="${m.perk_desc.replace(/"/g,'"')}">Variable ⓘ</span>`;
} else if (m.dividend_kind === 'special') {
divCell = `<span class="v-passive tsa-tipbadge" style="cursor:pointer;" data-tip="${m.perk_desc.replace(/"/g,'"')}">Special ⓘ</span>`;
} else if (m.dividend_kind === 'perk') {
divCell = `<span class="v-passive tsa-tipbadge" style="cursor:pointer;" data-tip="${m.perk_desc.replace(/"/g,'"')}">Perk ⓘ</span>`;
} else {
divCell = '—';
}
let predCell;
const realLoaded = Object.keys(state.realStockData).length > 0;
if (!realLoaded) {
predCell = `<span style="color:var(--tsa-muted);font-size:9px">Fetching…</span>`;
} else if (s.predictions) {
const p = s.predictions, cp = s.price;
const pCls = v => v > cp ? 'v-green' : v < cp ? 'v-red' : 'v-mono';
predCell = `
<div style="font-size:8px;color:var(--tsa-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:3px;">
<span class="conf-dot conf-${s.confidence}"></span>${s.confidence} confidence
</div>
<div class="pred-grid">
${['1d','1w','2w','1m','1y'].map(k => `
<div class="pred-cell">
<div class="pred-label">${k}</div>
<div class="pred-val ${pCls(p[k])}">${fmt.cash(p[k])}</div>
</div>`).join('')}
</div>`;
} else {
predCell = `<span style="color:var(--tsa-muted);font-size:9px">No data</span>`;
}
return `
<tr class="${s.isOwned ? 'owned' : ''}${m.type === 'passive' ? ' passive-row' : ''}">
<td>
<div>
<span class="tsa-ticker">${s.ticker}</span>
<span class="tsa-real-tkr" title="${m.real_name} (${m.real_ticker})">≈${m.real_ticker}</span>
${s.isOwned ? `<span class="badge-own">×${s.increments}</span>` : ''}
</div>
<div class="tsa-sname">${m.name}</div>
</td>
<td>${m.type === 'passive'
? `<span class="badge-passive">Passive</span>`
: `<span class="badge-7d" style="color:var(--tsa-accent);background:rgba(0,212,255,0.08);border-color:rgba(0,212,255,0.3)">Active</span>`}</td>
<td><span class="v-mono">${fmt.cash(s.price)}</span></td>
<td><span class="${trendCls}">${s.trend7d != null ? fmt.pct(s.trend7d) : '—'}</span></td>
<td>
<span class="v-mono">${fmt.cash(s.bbCost)}</span><br>
<span style="font-size:9px;color:var(--tsa-muted)">${fmt.shares(s.bb)} shares</span>
</td>
<td><span class="${s.isOwned ? 'v-blue' : 'v-mono'}">${fmt.cash(s.nextBBCost)}</span></td>
<td>${divCell}</td>
<td>${intervalBadge}</td>
<td>${s.profitPerDay != null ? `<span class="v-green">${fmt.cash(s.profitPerDay)}</span>` : '—'}</td>
<td>${s.roi != null ? `<span class="${s.roi >= 15 ? 'v-green' : s.roi >= 5 ? 'v-yellow' : 'v-red'}">${fmt.pct(s.roi)}</span>` : '—'}</td>
<td><span class="v-mono">${fmt.days(s.breakEvenDays)}</span></td>
<td>${predCell}</td>
</tr>`;
};
// ═══════════════════════════════════════════════════════════════
// RENDER — PORTFOLIO TAB
// ═══════════════════════════════════════════════════════════════
const renderPortfolioTab = () => {
if (!state.apiKey) return `<div class="tsa-info">⚠ Set your API key in the Setup tab first.</div>`;
if (!state.lastFetch) return `<div class="tsa-info">Load stock data first (click ⟳ Refresh or wait for auto-refresh).</div>`;
const owned = Object.keys(STOCK_DATA)
.map(t => calcStock(t))
.filter(s => s && s.isOwned);
if (!owned.length)
return `<div class="tsa-info">You don't appear to own any benefit blocks yet.</div>`;
const totalValue = owned.reduce((a, s) => a + (s.price * s.sharesOwned), 0);
const totalProfit = owned.filter(s => s.profitPerDay)
.reduce((a, s) => a + s.profitPerDay * s.increments, 0);
return `
<div class="tsa-pc">
<h4>📊 Portfolio Summary</h4>
<div class="kv-grid">
<div>
<div class="kv-label">Stocks Held</div>
<div class="kv-val">${owned.length}</div>
</div>
<div>
<div class="kv-label">Total Value</div>
<div class="kv-val v-mono">${fmt.cash(totalValue)}</div>
</div>
<div>
<div class="kv-label">Est. Daily Income</div>
<div class="kv-val v-green">${fmt.cash(totalProfit)}</div>
</div>
<div>
<div class="kv-label">Est. Annual Income</div>
<div class="kv-val v-green">${fmt.cash(totalProfit * 365)}</div>
</div>
</div>
</div>
${owned.map(s => {
const m = s.meta;
const ownedValue = s.price * s.sharesOwned;
return `
<div class="tsa-pc">
<h4>
<span class="tsa-ticker">${s.ticker}</span>
<span class="tsa-real-tkr">≈${m.real_ticker}</span>
<span class="badge-own">×${s.increments} increment${s.increments !== 1 ? 's' : ''}</span>
</h4>
<div class="kv-grid">
<div>
<div class="kv-label">Shares Owned</div>
<div class="kv-val v-mono">${fmt.num(s.sharesOwned)}</div>
</div>
<div>
<div class="kv-label">Holding Value</div>
<div class="kv-val v-mono">${fmt.cash(ownedValue)}</div>
</div>
<div>
<div class="kv-label">Current Price</div>
<div class="kv-val v-mono">${fmt.cash(s.price)}</div>
</div>
<div>
<div class="kv-label">Dividend Per BB</div>
<div class="kv-val ${s.divValue ? 'v-green' : 'v-passive'}">
${s.divValue ? fmt.cash(s.divValue) : m.perk_desc}
</div>
</div>
<div>
<div class="kv-label">Interval</div>
<div class="kv-val v-mono">${m.interval_days ? m.interval_days + 'd' : 'Perk'}</div>
</div>
<div>
<div class="kv-label">Profit/Day ×${s.increments}</div>
<div class="kv-val v-green">${s.profitPerDay ? fmt.cash(s.profitPerDay * s.increments) : '—'}</div>
</div>
<div>
<div class="kv-label">ROI / yr</div>
<div class="kv-val ${s.roi >= 15 ? 'v-green' : s.roi >= 5 ? 'v-yellow' : 'v-mono'}">
${s.roi != null ? fmt.pct(s.roi) : '—'}
</div>
</div>
<div>
<div class="kv-label">Break-Even</div>
<div class="kv-val v-mono">${fmt.days(s.breakEvenDays)}</div>
</div>
<div>
<div class="kv-label">Next BB Cost</div>
<div class="kv-val v-blue">${fmt.cash(s.nextBBCost)}</div>
</div>
</div>
${s.predictions ? `
<div style="margin-top:10px;">
<div style="font-size:9px;color:var(--tsa-muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:5px;">
<span class="conf-dot conf-${s.confidence}"></span>
Predictions mirrored from ${m.real_name} (${s.confidence} confidence)
</div>
<div class="pred-grid">
${['1d','1w','2w','1m','1y'].map(k => {
const v = s.predictions[k];
const cls = v > s.price ? 'v-green' : v < s.price ? 'v-red' : 'v-mono';
return `<div class="pred-cell">
<div class="pred-label">${k}</div>
<div class="pred-val ${cls}">${fmt.cash(v)}</div>
</div>`;
}).join('')}
</div>
</div>` : ''}
</div>`;
}).join('')}
`;
};
// ═══════════════════════════════════════════════════════════════
// MASTER RENDER
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// RENDER — APPEARANCE TAB
// ═══════════════════════════════════════════════════════════════
const APPEARANCE_FIELDS = [
{ key: 'bg', label: 'Background', hint: 'Main panel background' },
{ key: 'surface', label: 'Surface', hint: 'Cards, tables, inputs' },
{ key: 'surface2', label: 'Surface (alt)', hint: 'Table header row, tabs bar' },
{ key: 'border', label: 'Border', hint: 'Dividing lines, outlines' },
{ key: 'accent', label: 'Accent', hint: 'Highlights, active tab, links' },
{ key: 'text', label: 'Text', hint: 'Main body text color' },
{ key: 'muted', label: 'Muted Text', hint: 'Labels, secondary text' },
{ key: 'headerFrom', label: 'Header Gradient (start)', hint: 'Top-left of header bar' },
{ key: 'headerTo', label: 'Header Gradient (end)', hint: 'Top-right of header bar' },
];
const renderAppearanceTab = () => {
const theme = loadTheme();
return `
<div class="tsa-setup-card" style="max-width:720px;">
<h3>🎨 Appearance</h3>
<p>Customize colors, header gradient, and text size. Changes apply instantly and are saved automatically.</p>
<div class="tsa-theme-grid">
${APPEARANCE_FIELDS.map(f => `
<div class="tsa-theme-field">
<label for="tsa-theme-${f.key}">${f.label}</label>
<div class="tsa-theme-swatch-row">
<input type="color" id="tsa-theme-${f.key}" data-themekey="${f.key}" value="${theme[f.key]}">
<span class="tsa-theme-hint">${f.hint}</span>
</div>
</div>`).join('')}
</div>
<div class="tsa-theme-field" style="margin-top:14px;">
<label for="tsa-theme-fontsize">Font Size — <span id="tsa-fontsize-val">${theme.fontSize}%</span></label>
<input type="range" id="tsa-theme-fontsize" min="80" max="130" step="5" value="${theme.fontSize}" style="width:100%;accent-color:var(--tsa-accent);">
</div>
<div style="display:flex;gap:8px;margin-top:18px;">
<button class="tsa-btn tsa-btn-secondary" id="tsa-theme-reset">Reset to Default</button>
</div>
</div>
`;
};
const renderContent = () => {
const content = document.getElementById('tsa-content');
if (!content) return;
const map = { stocks: renderStocksTab, portfolio: renderPortfolioTab, appearance: renderAppearanceTab, setup: renderSetupTab };
content.innerHTML = (map[state.activeTab] || renderStocksTab)();
bindContentEvents();
};
// ═══════════════════════════════════════════════════════════════
// EVENT BINDING
// ═══════════════════════════════════════════════════════════════
const bindContentEvents = () => {
// Setup: save key
document.getElementById('tsa-key-save')?.addEventListener('click', async () => {
const input = document.getElementById('tsa-key-input');
const key = (input?.value || '').trim();
if (!key) return;
const btn = document.getElementById('tsa-key-save');
btn.textContent = 'Validating…';
btn.disabled = true;
const res = await validateKey(key);
if (res.valid) {
safeSetValue(KEY_STORAGE, key);
state.apiKey = key;
state.apiKeyValid = true;
renderContent();
fetchAll().then(startAutoRefresh);
} else {
const err = document.getElementById('tsa-key-error');
if (err) { err.style.display = 'block'; err.textContent = `❌ ${errorMsg(res.code)}`; }
btn.textContent = 'Validate & Save';
btn.disabled = false;
}
});
// Setup: clear key
document.getElementById('tsa-key-clear')?.addEventListener('click', () => {
let proceed = true;
try { proceed = confirm('Clear saved API key and stop auto-refresh?'); } catch (e) { /* some WebViews block confirm(); proceed without it */ }
if (!proceed) return;
safeSetValue(KEY_STORAGE, '');
state.apiKey = '';
state.apiKeyValid = false;
stopAutoRefresh();
renderContent();
});
// Setup: actually test both required permissions against the API
document.getElementById('tsa-check-perms')?.addEventListener('click', async () => {
const btn = document.getElementById('tsa-check-perms');
const out = document.getElementById('tsa-perm-results');
if (!btn || !out || !state.apiKey) return;
btn.disabled = true;
btn.textContent = 'Checking…';
out.innerHTML = `<div class="tsa-info">Testing all required permissions…</div>`;
const result = await checkKeyPermissions(state.apiKey);
const allOk = result.items && result.itemmarket && result.tornStocks && result.userStocks;
const row = (ok, label, err) => `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
<span class="tsa-status-badge ${ok ? 'tsa-status-ok' : 'tsa-status-err'}">${ok ? '✓ OK' : '✗ MISSING'}</span>
<span style="font-family:'Space Mono',monospace;font-size:11px;color:var(--tsa-text);">${label}</span>
${!ok && err ? `<span style="font-size:10px;color:var(--tsa-muted);">— ${err}</span>` : ''}
</div>`;
out.innerHTML = `
<div style="margin:10px 0;">
${row(result.items, 'torn → items', result.itemsError)}
${row(result.itemmarket, 'market → itemmarket', result.itemmarketError)}
${row(result.tornStocks, 'torn → stocks', result.tornStocksError)}
${row(result.userStocks, 'user → stocks', result.userStocksError)}
${allOk
? `<div class="tsa-info" style="margin-top:6px;">All permissions are working.</div>`
: `<div class="tsa-warn" style="margin-top:6px;">⚠ One or more permissions are missing. Use the pre-filled link above to create a key with all four enabled, or edit your existing key's Custom selections on the API Keys page.</div>`}
</div>`;
btn.disabled = false;
btn.textContent = '🔍 Check Permissions';
});
// Sortable column headers: click toggles direction if already active,
// otherwise switches to that column defaulting to descending.
document.querySelectorAll('.tsa-sortable-th').forEach(th =>
th.addEventListener('click', () => {
const key = th.dataset.sortkey;
if (state.sortBy === key) {
state.sortDir = state.sortDir === 'desc' ? 'asc' : 'desc';
} else {
state.sortBy = key;
state.sortDir = 'desc';
}
renderContent();
})
);
// Filters
document.getElementById('tsa-owned-only')?.addEventListener('change', e => {
state.showOwnedOnly = e.target.checked; renderContent();
});
document.getElementById('tsa-show-passive')?.addEventListener('change', e => {
state.showPassive = e.target.checked; renderContent();
});
// Retry button
document.getElementById('tsa-retry')?.addEventListener('click', () => {
fetchAll().then(startAutoRefresh);
});
// Appearance: color pickers — live-apply on every change, no debounce
// needed since color inputs fire 'input' continuously while dragging.
document.querySelectorAll('input[type="color"][data-themekey]').forEach(input => {
input.addEventListener('input', () => {
const theme = loadTheme();
theme[input.dataset.themekey] = input.value;
saveTheme(theme);
applyTheme(theme);
});
});
// Appearance: font size slider
document.getElementById('tsa-theme-fontsize')?.addEventListener('input', e => {
const theme = loadTheme();
theme.fontSize = parseInt(e.target.value, 10);
saveTheme(theme);
applyTheme(theme);
const label = document.getElementById('tsa-fontsize-val');
if (label) label.textContent = theme.fontSize + '%';
});
// Appearance: reset to default
document.getElementById('tsa-theme-reset')?.addEventListener('click', () => {
saveTheme({ ...DEFAULT_THEME });
applyTheme(DEFAULT_THEME);
renderContent(); // re-render so color inputs show reset values
});
};
// ═══════════════════════════════════════════════════════════════
// BUILD UI
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// WINDOW GEOMETRY — persisted across sessions via GM_setValue
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// STORAGE COMPATIBILITY SHIM
// Torn PDA's GM_getValue/GM_setValue are a compatibility layer, not
// the real Tampermonkey implementation, and the project's own docs
// warn they "will not behave the same" — in practice this can mean
// GM_getValue returns a Promise instead of a value synchronously.
// Since geometry/theme are read synchronously all over this script,
// we keep an in-memory cache seeded on startup (handling both sync
// and Promise-returning GM_getValue), and every read/write goes
// through that cache first so behavior stays consistent regardless
// of which GM implementation is actually running underneath.
// ═══════════════════════════════════════════════════════════════
const storageCache = {};
const safeGetValue = (key, fallback) => {
if (key in storageCache) return storageCache[key];
try {
const result = GM_getValue(key, fallback);
if (result && typeof result.then === 'function') {
// Promise-based GM_getValue (e.g. Torn PDA) — can't resolve
// synchronously, so seed the cache async for next time and
// return the fallback for this call.
result.then(v => { storageCache[key] = v; }).catch(() => {});
storageCache[key] = fallback;
return fallback;
}
storageCache[key] = result;
return result;
} catch (e) {
return fallback;
}
};
const safeSetValue = (key, value) => {
storageCache[key] = value; // update cache immediately regardless of backend
try {
const result = GM_setValue(key, value);
if (result && typeof result.catch === 'function') result.catch(() => {});
} catch (e) { /* ignore */ }
};
const GEOM_STORAGE = `${SCRIPT_ID}-geometry`;
// The default geometry used when nothing is saved yet. Computed at
// call time (not a fixed constant) so a first-ever load on a phone
// screen starts at a sane size immediately, rather than starting at
// a 960×640 desktop box and relying on clampGeom to shrink it after
// the fact — which works, but only once something actually triggers
// a re-check (see reclampNow in buildUI).
const getDefaultGeom = () => {
const w = window.innerWidth, h = window.innerHeight;
if (w < 480) {
const width = Math.min(360, w - 16);
const height = Math.min(560, h - 80);
return { top: 70, left: Math.max(8, (w - width) / 2), width, height, collapsed: false };
}
return { top: 120, left: 80, width: 960, height: 640, collapsed: false };
};
const loadGeometry = () => {
const fallback = getDefaultGeom();
try {
const saved = safeGetValue(GEOM_STORAGE, null);
if (!saved) return fallback;
const g = JSON.parse(saved);
return { ...fallback, ...g };
} catch (e) { return fallback; }
};
const saveGeometry = (g) => {
try { safeSetValue(GEOM_STORAGE, JSON.stringify(g)); } catch (e) { /* ignore */ }
};
const clampGeom = (g) => {
// On narrow viewports (phones inside Torn PDA), the desktop-sized
// minimums (360px wide, 200px visible margin) can still push the
// panel mostly off-screen since they assume far more horizontal
// room than a phone provides. Scale the floors down on small
// screens so the panel is always reachable.
const isNarrow = window.innerWidth < 480;
const minW = isNarrow ? Math.min(280, window.innerWidth - 16) : 360;
const minH = isNarrow ? 220 : 220;
const visMargin = isNarrow ? 24 : 200; // min px of panel that must stay reachable horizontally
const maxLeft = Math.max(0, window.innerWidth - visMargin);
const maxTop = Math.max(0, window.innerHeight - 80);
return {
...g,
left: Math.min(Math.max(0, g.left), maxLeft),
top: Math.min(Math.max(0, g.top), maxTop),
width: Math.min(Math.max(minW, g.width), window.innerWidth * 0.98),
height: Math.min(Math.max(minH, g.height), window.innerHeight * 0.92),
};
};
// ═══════════════════════════════════════════════════════════════
// THEME / APPEARANCE — persisted across sessions via GM_setValue
// Every value here maps 1:1 to a CSS variable already used
// throughout the stylesheet, so applying a theme is just setting
// inline custom properties on #tsa-root (which override the
// :root defaults thanks to normal CSS cascade/specificity rules).
// ═══════════════════════════════════════════════════════════════
const THEME_STORAGE = `${SCRIPT_ID}-theme`;
const DEFAULT_THEME = {
bg: '#0a0c10',
surface: '#111420',
surface2: '#181c2a',
border: '#252a3d',
accent: '#00d4ff',
text: '#e8ecf4',
muted: '#5a6080',
headerFrom:'#0a0c10',
headerTo: '#0d1828',
fontSize: 100, // percentage scale applied to #tsa-root
};
const loadTheme = () => {
try {
const saved = safeGetValue(THEME_STORAGE, null);
if (!saved) return { ...DEFAULT_THEME };
return { ...DEFAULT_THEME, ...JSON.parse(saved) };
} catch (e) { return { ...DEFAULT_THEME }; }
};
const saveTheme = (t) => {
try { safeSetValue(THEME_STORAGE, JSON.stringify(t)); } catch (e) { /* ignore */ }
};
// Apply theme values as inline CSS custom properties + font scale
// on #tsa-root. Inline styles on the element beat the :root block
// in the stylesheet, so every component picks this up automatically.
const applyTheme = (theme) => {
const root = document.getElementById('tsa-root');
if (!root) return;
root.style.setProperty('--tsa-bg', theme.bg);
root.style.setProperty('--tsa-surface', theme.surface);
root.style.setProperty('--tsa-surface2', theme.surface2);
root.style.setProperty('--tsa-border', theme.border);
root.style.setProperty('--tsa-accent', theme.accent);
root.style.setProperty('--tsa-text', theme.text);
root.style.setProperty('--tsa-muted', theme.muted);
root.style.setProperty('font-size', theme.fontSize + '%');
const header = root.querySelector('#tsa-header');
if (header) {
header.style.background = `linear-gradient(90deg, ${theme.headerFrom} 0%, ${theme.headerTo} 100%)`;
}
};
const buildUI = () => {
if (document.getElementById('tsa-root')) return;
const geom = clampGeom(loadGeometry());
const root = document.createElement('div');
root.id = 'tsa-root';
root.style.top = geom.top + 'px';
root.style.left = geom.left + 'px';
root.style.width = geom.width + 'px';
root.style.height = geom.height + 'px';
if (geom.collapsed) root.classList.add('tsa-collapsed');
root.innerHTML = `
<div id="tsa-header">
<button class="tsa-winbtn" id="tsa-reset-pos-btn" title="Reset position to center">⌖</button>
<h2>📈 Stock Analyzer</h2>
<span class="tsa-version">v1.9.0</span>
<div id="tsa-timer-wrap">
<div id="tsa-timer-ring">
<svg width="26" height="26" viewBox="0 0 26 26">
<circle class="bg" cx="13" cy="13" r="11"/>
<circle class="fg" cx="13" cy="13" r="11"/>
</svg>
<div id="tsa-timer-num">60</div>
</div>
<span id="tsa-timer-label">next refresh</span>
<span class="tsa-fetch-status" id="tsa-fetch-status"></span>
<button id="tsa-refresh-btn">⟳ Refresh</button>
</div>
<button class="tsa-winbtn" id="tsa-collapse-btn" title="Collapse/Expand">${geom.collapsed ? '▢' : '—'}</button>
</div>
<div id="tsa-tabs">
<button class="tsa-tab active" data-tab="stocks">Stocks</button>
<button class="tsa-tab" data-tab="portfolio">My Portfolio</button>
<button class="tsa-tab" data-tab="appearance">Appearance</button>
<button class="tsa-tab" data-tab="setup">Setup / API Key</button>
</div>
<div id="tsa-content"></div>
<div class="tsa-resize-edge tsa-resize-n" data-dir="n"></div>
<div class="tsa-resize-edge tsa-resize-s" data-dir="s"></div>
<div class="tsa-resize-edge tsa-resize-e" data-dir="e"></div>
<div class="tsa-resize-edge tsa-resize-w" data-dir="w"></div>
<div class="tsa-resize-corner tsa-resize-nw" data-dir="nw"></div>
<div class="tsa-resize-corner tsa-resize-ne" data-dir="ne"></div>
<div class="tsa-resize-corner tsa-resize-sw" data-dir="sw"></div>
<div class="tsa-resize-corner tsa-resize-se" data-dir="se" title="Drag to resize"></div>
`;
document.body.appendChild(root);
applyTheme(loadTheme());
// ── Tap/click tooltip for Special/Perk/Variable dividend badges ──
// Native `title` attributes only show on hover (desktop only).
// This shared overlay works for both mouse and touch so PDA users
// can tap any ⓘ badge and see the description inline.
const tip = document.createElement('div');
tip.id = 'tsa-tooltip';
document.body.appendChild(tip);
const showTip = (el, text) => {
tip.textContent = text;
tip.classList.add('visible');
const rect = el.getBoundingClientRect();
// Position above the element, clamped to screen edges
let left = rect.left + rect.width / 2 - 120;
let top = rect.top - 10;
left = Math.min(Math.max(8, left), window.innerWidth - 248);
if (top < 60) top = rect.bottom + 8; // flip below if too close to top
tip.style.left = left + 'px';
tip.style.top = (top - tip.offsetHeight || top - 40) + 'px';
};
const hideTip = () => tip.classList.remove('visible');
// Delegate from document so it catches dynamically-rendered badges
document.addEventListener('click', e => {
const badge = e.target.closest('.tsa-tipbadge');
if (badge) {
e.stopPropagation();
if (tip.classList.contains('visible') && tip._srcEl === badge) {
hideTip();
} else {
tip._srcEl = badge;
showTip(badge, badge.dataset.tip || '');
}
} else {
hideTip();
}
}, true);
// Also wire mouse hover for desktop (keeps the classic feel)
document.addEventListener('mouseover', e => {
const badge = e.target.closest('.tsa-tipbadge');
if (badge) { tip._srcEl = badge; showTip(badge, badge.dataset.tip || ''); }
});
document.addEventListener('mouseout', e => {
if (e.target.closest?.('.tsa-tipbadge')) hideTip();
});
// ── Tab switching ──────────────────────────────────────────────
root.querySelectorAll('.tsa-tab').forEach(tab => {
tab.addEventListener('click', () => {
root.querySelectorAll('.tsa-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
state.activeTab = tab.dataset.tab;
renderContent();
});
});
// ── Manual refresh ─────────────────────────────────────────────
root.querySelector('#tsa-refresh-btn').addEventListener('click', () => {
const btn = root.querySelector('#tsa-refresh-btn');
btn.disabled = true;
state.countdownSec = 60;
fetchAll().then(() => {
startAutoRefresh();
btn.disabled = false;
});
});
// ── Collapse / expand ──────────────────────────────────────────
const collapseBtn = root.querySelector('#tsa-collapse-btn');
collapseBtn.addEventListener('click', (e) => {
e.stopPropagation();
const nowCollapsed = !root.classList.contains('tsa-collapsed');
root.classList.toggle('tsa-collapsed', nowCollapsed);
collapseBtn.textContent = nowCollapsed ? '▢' : '—';
const g = loadGeometry();
g.collapsed = nowCollapsed;
saveGeometry(g);
});
// ── Reset position (recovery if panel drifts off-screen) ───────
root.querySelector('#tsa-reset-pos-btn').addEventListener('click', (e) => {
e.stopPropagation();
const w = Math.min(960, window.innerWidth * 0.92);
const h = Math.min(640, window.innerHeight * 0.85);
const g = clampGeom({
top: Math.max(20, (window.innerHeight - h) / 2),
left: Math.max(10, (window.innerWidth - w) / 2),
width: w, height: h,
collapsed: root.classList.contains('tsa-collapsed'),
});
root.style.top = g.top + 'px';
root.style.left = g.left + 'px';
root.style.width = g.width + 'px';
root.style.height = g.height + 'px';
saveGeometry(g);
});
// ── Unified drag + resize via Pointer Events ────────────────────
// Pointer Events fire for mouse, touch, and pen through one API,
// which is what makes this work identically on desktop Tampermonkey
// and inside Torn PDA's mobile WebView (plain mouse-only listeners
// don't reliably receive sustained touch-drag gestures there).
const header = root.querySelector('#tsa-header');
let dragState = null;
let resizeState = null;
const isNarrowViewport = window.innerWidth < 480;
const MIN_W = isNarrowViewport ? Math.min(280, window.innerWidth - 16) : 360;
const MIN_H = isNarrowViewport ? 220 : 220;
const onPointerDown = (e, mode, dir) => {
// Ignore drags starting on interactive controls inside the header
if (mode === 'drag' && e.target.closest('button, input, a')) return;
const rect = root.getBoundingClientRect();
const point = { x: e.clientX, y: e.clientY };
if (mode === 'drag') {
dragState = { startX: point.x, startY: point.y, origLeft: rect.left, origTop: rect.top };
header.classList.add('tsa-grabbing');
root.classList.add('tsa-dragging');
} else {
resizeState = {
dir,
startX: point.x, startY: point.y,
origLeft: rect.left, origTop: rect.top,
origW: rect.width, origH: rect.height,
};
root.classList.add('tsa-resizing');
}
// Capture the pointer so we keep receiving move/up events even if
// the finger/cursor leaves the original element's bounds.
if (e.target.setPointerCapture && e.pointerId != null) {
try { e.target.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ }
}
e.preventDefault();
};
const onPointerMove = (e) => {
if (dragState) {
const dx = e.clientX - dragState.startX;
const dy = e.clientY - dragState.startY;
// Require at least 40px of the panel to remain reachable on
// every edge — stricter than before so it can never fully
// escape the viewport, which matters most on small mobile
// screens where there's no easy way to "grab a sliver" back.
const minVisible = 40;
let newLeft = dragState.origLeft + dx;
let newTop = dragState.origTop + dy;
newLeft = Math.min(Math.max(minVisible - root.offsetWidth, newLeft), window.innerWidth - minVisible);
newTop = Math.min(Math.max(0, newTop), window.innerHeight - minVisible);
root.style.left = newLeft + 'px';
root.style.top = newTop + 'px';
} else if (resizeState) {
const dx = e.clientX - resizeState.startX;
const dy = e.clientY - resizeState.startY;
const { dir, origLeft, origTop, origW, origH } = resizeState;
let newLeft = origLeft, newTop = origTop, newW = origW, newH = origH;
if (dir.includes('e')) newW = Math.min(Math.max(MIN_W, origW + dx), window.innerWidth * 0.98 - origLeft);
if (dir.includes('s')) newH = Math.min(Math.max(MIN_H, origH + dy), window.innerHeight * 0.98 - origTop);
if (dir.includes('w')) {
newW = Math.min(Math.max(MIN_W, origW - dx), origLeft + origW);
newLeft = origLeft + origW - newW;
}
if (dir.includes('n')) {
newH = Math.min(Math.max(MIN_H, origH - dy), origTop + origH);
newTop = origTop + origH - newH;
}
root.style.left = newLeft + 'px';
root.style.top = newTop + 'px';
root.style.width = newW + 'px';
root.style.height = newH + 'px';
}
};
const onPointerUp = () => {
if (dragState) {
dragState = null;
header.classList.remove('tsa-grabbing');
root.classList.remove('tsa-dragging');
const rect = root.getBoundingClientRect();
const g = loadGeometry();
g.left = rect.left; g.top = rect.top;
saveGeometry(g);
}
if (resizeState) {
resizeState = null;
root.classList.remove('tsa-resizing');
const rect = root.getBoundingClientRect();
const g = loadGeometry();
g.left = rect.left; g.top = rect.top;
g.width = rect.width; g.height = rect.height;
saveGeometry(g);
}
};
header.addEventListener('pointerdown', (e) => onPointerDown(e, 'drag'));
root.querySelectorAll('.tsa-resize-edge, .tsa-resize-corner').forEach(handle => {
handle.addEventListener('pointerdown', (e) => onPointerDown(e, 'resize', handle.dataset.dir));
});
document.addEventListener('pointermove', onPointerMove);
document.addEventListener('pointerup', onPointerUp);
document.addEventListener('pointercancel', onPointerUp);
// Re-measures the panel's actual current box, re-clamps it against
// the real (already-known) viewport size, and re-applies + saves
// if anything had to change. Called proactively below — not just
// on a 'resize' event — because the bug this fixes isn't the
// viewport changing size, it's the *initial* geometry being wrong
// (e.g. a stale/default desktop width on a phone screen) and
// nothing ever re-checking it afterward.
const reclampNow = () => {
const rect = root.getBoundingClientRect();
const g = clampGeom({
top: rect.top, left: rect.left, width: rect.width, height: rect.height,
collapsed: root.classList.contains('tsa-collapsed'),
});
root.style.top = g.top + 'px';
root.style.left = g.left + 'px';
root.style.width = g.width + 'px';
root.style.height = g.height + 'px';
saveGeometry(g);
};
// Keep window on-screen if the browser window itself is resized
window.addEventListener('resize', reclampNow);
// Run once right after mount (catches a too-wide initial geometry
// immediately) and once more shortly after (catches the case where
// GM_getValue's storage Promise — on Torn PDA — resolves slightly
// after this initial synchronous build and would otherwise leave
// a stale, unclamped geometry sitting on screen).
reclampNow();
setTimeout(reclampNow, 400);
// ── Responsive header ───────────────────────────────────────────
// The header has too many fixed-width pieces (title, version
// badge, countdown ring, refresh button, reset button, collapse
// button) to fit on one line once the panel itself is narrower
// than roughly 460px — which is the normal case on Torn PDA. A
// CSS media query can't help here since it only sees the browser
// viewport, not this absolutely-positioned panel's own width
// (the panel can be narrow even on a wide desktop browser, and
// vice versa). ResizeObserver watches the panel's actual box
// and toggles a class that hides decorative pieces (version
// badge, "next refresh" label, fetch-status text) and wraps the
// timer/refresh group onto its own row, so the title bar's two
// window-control buttons always stay reachable on the first row.
const NARROW_HEADER_THRESHOLD = 460;
if (typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(entries => {
for (const entry of entries) {
const w = entry.contentRect.width;
root.classList.toggle('tsa-narrow', w < NARROW_HEADER_THRESHOLD);
}
});
ro.observe(root);
} else {
// Fallback for environments without ResizeObserver: check once
// now and again on every window resize / reclamp pass.
const checkNarrow = () => {
root.classList.toggle('tsa-narrow', root.getBoundingClientRect().width < NARROW_HEADER_THRESHOLD);
};
checkNarrow();
window.addEventListener('resize', checkNarrow);
setTimeout(checkNarrow, 400);
}
renderContent();
};
// ═══════════════════════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════════════════════
const init = () => {
injectStyles();
buildUI();
if (state.apiKey) {
fetchAll().then(startAutoRefresh);
} else {
state.activeTab = 'setup';
renderContent();
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
// Give the Torn React app a moment to hydrate before we scrape
setTimeout(init, 500);
}
})();