Advises which stock blocks to buy next. Features daily briefing, portfolio score, payout calendar, recommendations, swap advisor, holdings and full rankings. Mobile/PDA optimised.
// ==UserScript==
// @name Torn Stock Advisor
// @namespace torn_stock_advisor
// @version 3.1.6
// @description Advises which stock blocks to buy next. Features daily briefing, portfolio score, payout calendar, recommendations, swap advisor, holdings and full rankings. Mobile/PDA optimised.
// @author TheOddSod (2640064)
// @match https://www.torn.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @connect api.torn.com
// ==/UserScript==
// ─── Changelog ───────────────────────────────────────────────────────────────
// v3.1.6 — Data: THS (Torn City Health Service) b1Threshold corrected from
// 450,000 back to 150,000 shares.
// v3.1.5 — Fix: briefing now lists all upcoming payouts grouped by day.
// just the first one. e.g. "FHG, SYM, EWM pay out tomorrow — worth
// waiting to collect before selling any of these shares." Previously
// only soonPayouts[0] was mentioned. Priority actions also updated
// to list all upcoming tickers up to 3, with overflow count.
// v3.1.4 — Fix: briefing now mentions both ready-to-collect and upcoming
// instead of either/or. Previously if any stocks were ready now, all
// upcoming payouts (e.g. THS paying tomorrow) were silently skipped.
// Priority actions list similarly updated — both "collect now" and
// "wait for upcoming" actions appear when both conditions exist.
// v3.1.3 — Polish: briefing upcoming payout wording improved.
// for before selling anything" to "worth waiting to collect before
// selling any of these shares" — clearer and more specific.
// v3.1.2 — Hotfix: ReferenceError "availCash is not defined" in calcPortfolio
// Score — added getAvailableCash() call at top of function. Variable
// was used for affordableRows filter but never declared in scope.
// — Polish: config strip "Budget" renamed to "Cash" for clarity. Shows
// the actual cash amount when set, or "Not set (set in Config)" as a
// gentle nudge when unset.
// v3.1.1 — Fix: Optimisation score no longer contaminated by unaffordable
// blocks. bestCashGain previously took the highest dailyValue from
// ALL scoreable rows regardless of cost, so MUN B1 at $2.72B was
// dragging the score down even when no user could afford it with
// $0 cash. bestCashGain now only considers blocks affordable with
// available cash; if cash is unset it contributes 0 (leaving only
// the feasibility-checked swap score as the benchmark).
// v3.1.0 — Polish: Optimisation metric now shows a "set cash in Config for
// accuracy" note when available cash is unset ($0). Without a cash
// figure the swap advisor can only see sell-based moves, so the best
// available improvement may overstate what's actually needed. The
// same note appears in the briefing swap suggestion and portfolio
// score insight when cash is unset. Swap logic itself unchanged —
// buildSwaps already correctly filters to feasible moves only.
// v3.0.9 — Fix: payout calendar "Est. incoming" maths corrected.
// daysLeft instead of totalDaily × interval (the actual cycle value).
// e.g. TMI $806.5k/day on 31d cycle = $25M payout, not $4.8M.
// — Polish: calendar rows now use CSS grid with fixed-width columns so
// ticker, name, countdown and payout value all align cleanly. Added
// "Later" group label above stocks beyond 7 days. Payout column now
// shows the actual per-cycle value (e.g. "$25M · 1 block · every 31d")
// rather than /day which was confusing in this context.
// v3.0.8 — Hotfix: ReferenceError "bestAvailableNetGain is not defined".
// added to renderPortfolioScore destructuring. Was returned from
// calcPortfolioScore but not destructured before use in metric card.
// v3.0.7 — Hotfix: ReferenceError "totalInvested is not defined".
// was removed from Factor 1 refactor but still needed by Factor 2
// (idle capital denominator). Restored as a local const.
// v3.0.6 — Fix: ROI efficiency factor in portfolio score replaced with a fairer
// "optimisation" measure: actualDaily / (actualDaily + bestAvailableNetGain).
// The old formula compared against a theoretical maximum (all capital
// in the best single stock) which unfairly penalised diversification
// and 31-day cash stocks. The new formula answers "how close are you
// to making your best available move?" — 100% means nothing better
// exists; lower means there's a swap worth considering. Metric card
// label updated to "Optimisation" with the best available gain shown.
// v3.0.5 — Fix: PTS (PointLess) payoutType changed from 'cash' to 'points'.
// Previously it was swept up by cash stock filters, appearing in the
// "unresolved cash stocks" warning and DOM scrape logic. Now has its
// own scoring branch: qty points × live points market price, with
// user config override available. payoutQty set to 100 (confirmed).
// Config placeholder now shows "auto (live points price)". Badge
// and excludeMap updated to handle the new type correctly.
// v3.0.4 — Fix: portfolio score and briefing "unresolved cash stocks" check
// now uses the same three-tier value resolution as scoring (user
// override → DOM → STOCK_DATA default). Previously the check used
// getPayoutOverride(t, 0) which ignored payoutCashValue defaults,
// causing stocks like CNC and TSB to be flagged as unresolved even
// when their defaults were in use.
// — Polish: cash stock payout field in config now shows the default
// value as placeholder text (e.g. "default $80M") instead of
// "auto (from stocks page)" when a default is set in STOCK_DATA.
// v3.0.3 — Fix: cash dividend stocks now correctly use their default
// payoutCashValue when the DOM scrape hasn't run yet or returns
// nothing. Previously domDividends[ticker]||0 passed 0 as the
// getPayoutOverride default, silently ignoring payoutCashValue.
// Priority order is now: user config override → DOM-read value
// → STOCK_DATA default (GRN=$4M, TCT=$1M, TMI=$25M etc.).
// v3.0.2 — Data: default payoutCashValue set for all six cash dividend stocks.
// GRN=$4M, TCT=$1M, TMI=$25M, IOU=$12M, TSB=$50M, CNC=$80M per block.
// These are used as fallbacks when the DOM hasn't been read yet.
// — Fix: briefing unresolved cash stocks warning now shows which tickers
// are missing values in parentheses, e.g. "(GRN, TCT)" so users know
// exactly which stocks to check.
// v3.0.1 — Fix: passives excluded from briefing collect actions.
// collect" and upcoming payouts in the briefing. They have no
// collectable payout so should never appear as collect actions.
// renderCalendar already filtered them; buildBriefing now does too.
// v3.0.0 — MAJOR: Three new sections added.
// Briefing: rule-based natural language daily summary covering
// portfolio state, urgent payouts, partial drag, best swap or cash
// buy, and projected score delta if advice is followed.
// Portfolio score: weighted 0-100 score (ROI efficiency 50%, idle
// capital 30%, data completeness 20%) mapped to A+ through F with
// +/- graduations. Shows current grade, projected grade after
// recommended actions, and plain-English insight.
// Payout calendar: all held stocks with timing data in countdown
// order, colour-coded ready/soon/later, with estimated 7-day incoming.
// Mobile/PDA layout: auto-detects <=480px, all sections collapsed by
// default except briefing (state persisted), compact holdings list,
// full rankings table replaced with dot+ticker+score compact list.
// "Ahead after: X days" replaces "Payback: Xd" throughout swap advisor.
// v2.5.11 — Polish: Swap Advisor layout overhauled for readability. Cards now
// have more padding, a clear dividing line between the action row
// and detail row, and consistent font sizing. Group headers use a
// bottom border for separation. Group dividers have more vertical
// space. Chain cards use a distinct tsa-swap-chain class. Top-up
// partial cards match the new layout. Daily loss now shown in red.
// v2.5.10 — Fix: "Hide from recommendations" now works correctly when available
// cash is set to 0 or left blank. Previously budget=0 set budgetMax
// to Infinity so nothing was ever flagged as over-budget, meaning
// hide mode had no effect. Now when budget=0 and mode is hide, all
// unaffordable blocks are hidden as expected.
// v2.5.9 — Fix: sell-based swap groups (full sell, sell-down, partial sell,
// combined sell) no longer suggest targets already affordable with
// available cash alone. Previously if you had $10B cash, every sell
// group would surface targets you could simply buy directly, making
// the sell appear necessary when it wasn't. Those targets are now
// exclusively handled by the Tier 0 Use cash group.
// v2.5.8 — Fix: Swap Advisor Use cash group no longer shows duplicate "↳ or"
// alternatives after the chain. Previously all Tier 0 targets were
// rendered as independent alternatives below the chain, repeating
// blocks already covered. Now only the primary buy renders; the chain
// handles all follow-on purchases from leftover funds.
// v2.5.7 — Feature: Swap Advisor now chains follow-on purchases from leftover
// funds after each primary buy. After the best buy in a group, all
// remaining cash is spent greedily on the next-best immediately
// actionable blocks, shown as "↳ then buy" entries. Virtual holdings
// are tracked so buying B1 unlocks B2 as a follow-on candidate in the
// same chain. Chaining only applies to the first (best) option per
// group, not the "or" alternatives.
// v2.5.6 — Fix: Swap Advisor Tier 0 (Use cash) now only surfaces immediately
// actionable blocks — the direct next block for each stock or a
// partial already in progress. Previously showed later blocks (e.g.
// MUN B2) as independent buy options even when the prerequisite block
// wasn't held, which was both impossible and misleading.
// Tier 0 now also simulates chained sequential purchases: after
// picking the best-ROI affordable block, remaining cash is reduced
// and the next best affordable block is evaluated, so a sequence like
// "buy B1 then B2" is surfaced correctly rather than shown as
// alternatives.
// v2.5.5 — Fix: available cash field now correctly returns 0 when set to 0.
// left blank. The legacy savings_invest migration fallback caused the
// old value to persist silently after clearing the field.
// — Hotfix: ReferenceError "cashFromPartial is not defined" — variable
// was renamed cashPartial in scope but the availCashUsed calculation
// still referenced the old name. Swap Advisor now loads correctly.
// v2.5.3 — Fix: Swap Advisor cash pill now shows the actual shortfall covered
// by available cash (costToComplete - cashReleased), not the full
// available cash amount. Combined sell and partial sell blocks were
// both using Math.min(availCash, costToComplete) which overcounted
// when the sell proceeds already covered most of the target cost.
// — Feature: Swap Advisor rows are now numbered (#1, #2, #3...) in the
// group header so rows are easy to reference.
// v2.5.2 — Merge: combines all improvements from GF v2.5.0 and today's session.
// From v2.5.0: tier priority system in Swap Advisor (Tier 0 = Use cash,
// Tier 1 = Sell partial, Tier 2 = Sell block); explicit "Use cash" swap
// entries when available cash alone covers a target; primary sort by
// netDailyGain DESC (tier as tiebreaker only); combined-sell lower-bound
// guard updated to account for available cash; "+$X cash" pill on cards
// where cash bridges the gap; "Use cash" / "Sell partial" / "Sell block"
// footer legend.
// From today: single getAvailableCash() field drives everything; better
// empty-state message when budget hide filter active; sell-down accuracy
// fix; "Complete your partials" top-up section; partial block ROI uses
// remaining cost not full block cost.
// v2.5.1 — Fix: recommendation cards empty state now explains why when the
// budget hide filter is active. Previously showed "No scoreable blocks
// — check config or API key" even when blocks existed but were all
// over budget. Now shows "All N blocks are over your $Xm budget" with
// guidance on how to adjust the filter.
// v2.4.5 — Polish: "Available to invest" and "Budget" config fields merged into
// a single "Available cash" field. One number now drives everything:
// recommendation affordability filter, swap advisor math, savings plan
// progress tracking, and top-up partial detection. Legacy savings_invest
// value migrated automatically on first load. Config label updated with
// plain-English description of what the field affects.
// v2.4.4 — Fix: partial block ROI now uses costToComplete (shares still needed
// x price) as the scoring denominator instead of the full block cost.
// Previously a 90%-complete partial was ranked as if buying from
// scratch, significantly underrating it.
// — Fix: sell-down in Swap Advisor now correctly releases only the top
// block's shares, landing at the highest complete block held. Previously
// used the wrong threshold when excess shares existed above a block.
// — Fix: partial positions now appear as sell candidates in Swap Advisor.
// Selling a partial loses no daily value so these show payback Immediate.
// — Fix: available cash (budget field) factored into all swap calculations.
// Cash in hand reduces how much a sell must cover; each swap card shows
// how much of your cash contributed alongside the sell proceeds.
// — Feature: "Complete your partials" section at the top of Swap Advisor
// shows partials affordable with available cash, with ROI on remaining
// spend and shares still needed.
// v2.4.3 — Fix: config migration now clears ALL per-stock override keys on
// version bump (itemqty, itemname, units, b1, maxblocks, payout) so
// corrected STOCK_DATA defaults always take effect. Previously only
// payout and legacy thresh keys were cleared, causing itemqty_THS=2
// to survive the v2.4.2 bump and override the corrected qty of 1.
// v2.4.2 — Fix: THS (Torn City Health Service) corrected — b1Threshold 150,000
// -> 450,000; payoutQty 2 -> 1 (1x Box of Medical Supplies per block).
// v2.4.1 — Fix: ASS (Alcoholics Synonymous) corrected — b1Threshold 3,000,000
// -> 1,000,000; payoutQty 2 -> 1 (1x Six-Pack of Alcohol per block).
// — Fix: LSC (Lucky Shot Casino) corrected — b1Threshold 1,500,000 ->
// 500,000; payoutQty 2 -> 1 (1x Lottery Voucher per block).
// — Feature: item name validator. The item name field in config now
// shows an inline status indicator on blur: green tick with item ID
// if found in cache, red cross if not found, or a "Save & refresh"
// hint when the item ID cache has not yet been populated. Checked
// against the locally cached item map — no extra API call required.
// v2.4.0 — Feature: full theme system. All hardcoded hex values replaced with
// --tsa-* CSS custom properties. Eight themes added matching the OCM
// design system: Default (Dark Blue), Torn Classic, High Contrast
// (WCAG AAA), Deuteranopia, Protanopia, Tritanopia, Low Vision, and
// Light Mode. Theme selector added to config panel; preference saved
// to GM storage and applied on load.
// — Feature: dynamic block generation. Blocks are no longer hardcoded
// to 5 per stock. STOCK_DATA now stores b1Threshold (the first block
// share count) and maxBlocks (defaults to heldBlocks + 4, user-
// configurable). All higher block thresholds are derived automatically
// using the universal doubling rule: threshold[n] = (2^n - 1) * b1.
// Config per-stock simplified to B1 threshold + max blocks inputs.
// — Feature: n+2 visibility rule. The scoring engine and rankings table
// only show a stock's blocks up to heldBlocks + 2, keeping
// recommendations focused. Held blocks always shown. maxBlocks config
// allows extending visibility for power users.
// — Feature: payout config improvements. Item stocks now expose a
// quantity field and item name field in config so users can correct
// payout descriptions if market lookup fails. Nerve/energy/happy
// stocks expose their units-per-block value in config.
// v2.3.4 — Data: full threshold audit — all 35 stocks confirmed correct against
// the universal doubling rule threshold[n] = (2^n - 1) * b1.
// GRN confirmed $4M/block (DOM shows $12M total / 3 held blocks).
// CONFIG_VERSION bumped to clear stale manually-entered overrides.
// v2.3.3 — Removed: sidebar widget stripped out entirely.
// v2.3.2 — Fix: removed ALL hardcoded cash dividend values. Cash dividends now
// read live from DOM dividend column (total / heldBlocks = per-block).
// v2.3.1 — Fix: CONFIG_VERSION bump to clear stale payout overrides.
// — Fix: card label renamed to "This block earns:".
// v2.3.0 — Fix: various sidebar data/crash fixes (sidebar later removed).
// v2.2.1 — Fix: sidebar payout list iteration fix.
// v2.1.0 — Feature: sidebar widget (later removed in v2.3.3).
// v2.0.0 — MAJOR: Savings Plan feature.
// v1.7.4 — Feature: swap advisor payout timing.
// v1.7.3 — Feature: disclaimer footer.
// v1.6.4 — Feature: PTS live points market pricing.
// v1.6.1 — Feature: never-sell list in config.
// v1.4.0 — Feature: budget filter.
// v1.0.0 — Initial release.
// ─────────────────────────────────────────────────────────────────────────────
(function () {
'use strict';
if (window._tsaLoaded) return;
window._tsaLoaded = true;
const PREFIX = 'tsa_';
const API_BASE = 'https://api.torn.com/v2';
const SCRIPT = 'TornStockAdvisor';
// Bumped to 2.4.0: dynamic block system replaces hardcoded thresholds.
// All stored per-stock threshold overrides cleared; b1/maxBlocks take over.
const CONFIG_VERSION = '3.1.6';
let tickerToId = {};
let lastRows = [];
let lastPrices = {};
// ─── Theme system ─────────────────────────────────────────────────────────────
// All colours are CSS custom properties on #tsa-root.
// Never use hardcoded hex in CSS or inline styles — always var(--tsa-*).
const THEMES = {
default: {
'--tsa-bg-deep':'#0f1a30','--tsa-bg-dark':'#111827','--tsa-bg-base':'#16213e',
'--tsa-bg-card':'#1a1a2e','--tsa-bg-header':'#1a1a2e','--tsa-bg-hover':'#1e1e36',
'--tsa-bg-input':'#0f3460','--tsa-bg-dropdown':'#0f1a30','--tsa-bg-row':'#111827',
'--tsa-border-faint':'#111','--tsa-border-card':'#2a2a4a','--tsa-border-strip':'#1a2a4a',
'--tsa-border-input':'#2a4a7a','--tsa-border-accent':'#e05a00','--tsa-border-section':'#333',
'--tsa-accent':'#ff7700','--tsa-accent-hover':'#e05a00','--tsa-accent-dim':'#cc5500',
'--tsa-text-primary':'#e0e0e0','--tsa-text-card':'#ccc','--tsa-text-secondary':'#aaa',
'--tsa-text-label':'#888','--tsa-text-muted':'#666','--tsa-text-disabled':'#555','--tsa-text-dead':'#444',
'--tsa-status-ok':'#44ee88','--tsa-status-warn':'#ffaa00','--tsa-status-crit':'#ff4444',
'--tsa-status-ok-bg':'#003322','--tsa-status-warn-bg':'#2a1a00','--tsa-status-crit-bg':'#330a00',
'--tsa-status-ok-border':'#006644','--tsa-status-warn-border':'#664400','--tsa-status-crit-border':'#882200',
'--tsa-status-jail':'#ff8800','--tsa-status-hospital':'#ff4444','--tsa-status-travel':'#88aaff',
'--tsa-status-abroad':'#aaddff','--tsa-status-blocked':'#dd44dd','--tsa-status-respect':'#ffcc44',
'--tsa-phase-plan-bg':'#0d2a4a','--tsa-phase-plan-text':'#7aadff','--tsa-phase-plan-border':'#3a7acc',
'--tsa-phase-rec-bg':'#2a1a00','--tsa-phase-rec-text':'#ffaa33','--tsa-phase-rec-border':'#cc7700',
'--tsa-stuck-bg':'#2a0000','--tsa-stuck-border':'#ff2200',
'--tsa-banner-ok-bg':'#0a2a0a','--tsa-banner-ok-border':'#226622','--tsa-banner-ok-text':'#88dd88',
'--tsa-banner-warn-bg':'#2a1a00','--tsa-banner-warn-border':'#885500','--tsa-banner-warn-text':'#ffcc66',
'--tsa-banner-crit-bg':'#2a0a00','--tsa-banner-crit-border':'#882200','--tsa-banner-crit-text':'#ff8866',
'--tsa-weight-high':'#ff8844','--tsa-weight-mid':'#aaa','--tsa-weight-low':'#555',
'--tsa-font-scale':'1',
},
torn: {
'--tsa-bg-deep':'#111','--tsa-bg-dark':'#1a1a1a','--tsa-bg-base':'#222',
'--tsa-bg-card':'#1c1c1c','--tsa-bg-header':'#1c1c1c','--tsa-bg-hover':'#2a2a2a',
'--tsa-bg-input':'#2a2a2a','--tsa-bg-dropdown':'#111','--tsa-bg-row':'#1a1a1a',
'--tsa-border-faint':'#2a2a2a','--tsa-border-card':'#3a3a3a','--tsa-border-strip':'#333',
'--tsa-border-input':'#555','--tsa-border-accent':'#c03020','--tsa-border-section':'#444',
'--tsa-accent':'#e04030','--tsa-accent-hover':'#c03020','--tsa-accent-dim':'#aa2010',
'--tsa-text-primary':'#ddd','--tsa-text-card':'#ccc','--tsa-text-secondary':'#aaa',
'--tsa-text-label':'#888','--tsa-text-muted':'#666','--tsa-text-disabled':'#555','--tsa-text-dead':'#333',
'--tsa-status-ok':'#44ee88','--tsa-status-warn':'#ffaa00','--tsa-status-crit':'#ff4444',
'--tsa-status-ok-bg':'#003322','--tsa-status-warn-bg':'#2a1a00','--tsa-status-crit-bg':'#330a00',
'--tsa-status-ok-border':'#006644','--tsa-status-warn-border':'#664400','--tsa-status-crit-border':'#882200',
'--tsa-status-jail':'#ff8800','--tsa-status-hospital':'#ff4444','--tsa-status-travel':'#88aaff',
'--tsa-status-abroad':'#aaddff','--tsa-status-blocked':'#dd44dd','--tsa-status-respect':'#ffcc44',
'--tsa-phase-plan-bg':'#1a2a1a','--tsa-phase-plan-text':'#88cc88','--tsa-phase-plan-border':'#4a8a4a',
'--tsa-phase-rec-bg':'#2a1a00','--tsa-phase-rec-text':'#ffaa33','--tsa-phase-rec-border':'#cc7700',
'--tsa-stuck-bg':'#2a0000','--tsa-stuck-border':'#cc2200',
'--tsa-banner-ok-bg':'#0a2a0a','--tsa-banner-ok-border':'#226622','--tsa-banner-ok-text':'#88dd88',
'--tsa-banner-warn-bg':'#2a1a00','--tsa-banner-warn-border':'#885500','--tsa-banner-warn-text':'#ffcc66',
'--tsa-banner-crit-bg':'#2a0a00','--tsa-banner-crit-border':'#882200','--tsa-banner-crit-text':'#ff8866',
'--tsa-weight-high':'#ff8844','--tsa-weight-mid':'#aaa','--tsa-weight-low':'#555',
'--tsa-font-scale':'1',
},
highcontrast: {
'--tsa-bg-deep':'#000','--tsa-bg-dark':'#000','--tsa-bg-base':'#000',
'--tsa-bg-card':'#0a0a0a','--tsa-bg-header':'#000','--tsa-bg-hover':'#1a1a1a',
'--tsa-bg-input':'#111','--tsa-bg-dropdown':'#000','--tsa-bg-row':'#000',
'--tsa-border-faint':'#444','--tsa-border-card':'#fff','--tsa-border-strip':'#888',
'--tsa-border-input':'#fff','--tsa-border-accent':'#ffff00','--tsa-border-section':'#888',
'--tsa-accent':'#ffff00','--tsa-accent-hover':'#ffee00','--tsa-accent-dim':'#cccc00',
'--tsa-text-primary':'#fff','--tsa-text-card':'#fff','--tsa-text-secondary':'#eee',
'--tsa-text-label':'#ddd','--tsa-text-muted':'#bbb','--tsa-text-disabled':'#888','--tsa-text-dead':'#666',
'--tsa-status-ok':'#00ff88','--tsa-status-warn':'#ffdd00','--tsa-status-crit':'#ff4444',
'--tsa-status-ok-bg':'#003318','--tsa-status-warn-bg':'#332200','--tsa-status-crit-bg':'#330000',
'--tsa-status-ok-border':'#00ff88','--tsa-status-warn-border':'#ffdd00','--tsa-status-crit-border':'#ff4444',
'--tsa-status-jail':'#ffaa00','--tsa-status-hospital':'#ff6666','--tsa-status-travel':'#aaccff',
'--tsa-status-abroad':'#ccddff','--tsa-status-blocked':'#ff88ff','--tsa-status-respect':'#ffff88',
'--tsa-phase-plan-bg':'#001a33','--tsa-phase-plan-text':'#88ccff','--tsa-phase-plan-border':'#88ccff',
'--tsa-phase-rec-bg':'#331a00','--tsa-phase-rec-text':'#ffcc44','--tsa-phase-rec-border':'#ffcc44',
'--tsa-stuck-bg':'#330000','--tsa-stuck-border':'#ff4444',
'--tsa-banner-ok-bg':'#001a0a','--tsa-banner-ok-border':'#00ff88','--tsa-banner-ok-text':'#00ff88',
'--tsa-banner-warn-bg':'#332200','--tsa-banner-warn-border':'#ffdd00','--tsa-banner-warn-text':'#ffdd00',
'--tsa-banner-crit-bg':'#330000','--tsa-banner-crit-border':'#ff4444','--tsa-banner-crit-text':'#ff4444',
'--tsa-weight-high':'#ffaa44','--tsa-weight-mid':'#ddd','--tsa-weight-low':'#888',
'--tsa-font-scale':'1',
},
deuteranopia: {
'--tsa-bg-deep':'#0f1a30','--tsa-bg-dark':'#111827','--tsa-bg-base':'#16213e',
'--tsa-bg-card':'#1a1a2e','--tsa-bg-header':'#1a1a2e','--tsa-bg-hover':'#1e1e36',
'--tsa-bg-input':'#0f3460','--tsa-bg-dropdown':'#0f1a30','--tsa-bg-row':'#111827',
'--tsa-border-faint':'#111','--tsa-border-card':'#2a2a4a','--tsa-border-strip':'#1a2a4a',
'--tsa-border-input':'#2a4a7a','--tsa-border-accent':'#0088cc','--tsa-border-section':'#333',
'--tsa-accent':'#0099ee','--tsa-accent-hover':'#0077cc','--tsa-accent-dim':'#005599',
'--tsa-text-primary':'#e0e0e0','--tsa-text-card':'#ccc','--tsa-text-secondary':'#aaa',
'--tsa-text-label':'#888','--tsa-text-muted':'#666','--tsa-text-disabled':'#555','--tsa-text-dead':'#444',
'--tsa-status-ok':'#4499ff','--tsa-status-warn':'#ffcc00','--tsa-status-crit':'#ff6600',
'--tsa-status-ok-bg':'#001833','--tsa-status-warn-bg':'#332a00','--tsa-status-crit-bg':'#331500',
'--tsa-status-ok-border':'#2266cc','--tsa-status-warn-border':'#886600','--tsa-status-crit-border':'#994400',
'--tsa-status-jail':'#ffcc00','--tsa-status-hospital':'#ff6600','--tsa-status-travel':'#88ccff',
'--tsa-status-abroad':'#aaddff','--tsa-status-blocked':'#cc88ff','--tsa-status-respect':'#ffffff',
'--tsa-phase-plan-bg':'#0d2a4a','--tsa-phase-plan-text':'#88ccff','--tsa-phase-plan-border':'#3a7acc',
'--tsa-phase-rec-bg':'#2a1a00','--tsa-phase-rec-text':'#ffcc44','--tsa-phase-rec-border':'#aa8800',
'--tsa-stuck-bg':'#2a1500','--tsa-stuck-border':'#ff6600',
'--tsa-banner-ok-bg':'#001833','--tsa-banner-ok-border':'#2266cc','--tsa-banner-ok-text':'#88bbff',
'--tsa-banner-warn-bg':'#332a00','--tsa-banner-warn-border':'#886600','--tsa-banner-warn-text':'#ffcc66',
'--tsa-banner-crit-bg':'#331500','--tsa-banner-crit-border':'#994400','--tsa-banner-crit-text':'#ff8844',
'--tsa-weight-high':'#ffaa44','--tsa-weight-mid':'#aaa','--tsa-weight-low':'#555',
'--tsa-font-scale':'1',
},
protanopia: {
'--tsa-bg-deep':'#0f1a30','--tsa-bg-dark':'#111827','--tsa-bg-base':'#16213e',
'--tsa-bg-card':'#1a1a2e','--tsa-bg-header':'#1a1a2e','--tsa-bg-hover':'#1e1e36',
'--tsa-bg-input':'#0f3460','--tsa-bg-dropdown':'#0f1a30','--tsa-bg-row':'#111827',
'--tsa-border-faint':'#111','--tsa-border-card':'#2a2a4a','--tsa-border-strip':'#1a2a4a',
'--tsa-border-input':'#2a4a7a','--tsa-border-accent':'#00aacc','--tsa-border-section':'#333',
'--tsa-accent':'#00bbdd','--tsa-accent-hover':'#0099bb','--tsa-accent-dim':'#007799',
'--tsa-text-primary':'#e0e0e0','--tsa-text-card':'#ccc','--tsa-text-secondary':'#aaa',
'--tsa-text-label':'#888','--tsa-text-muted':'#666','--tsa-text-disabled':'#555','--tsa-text-dead':'#444',
'--tsa-status-ok':'#00ddcc','--tsa-status-warn':'#ffcc00','--tsa-status-crit':'#ffffff',
'--tsa-status-ok-bg':'#002a28','--tsa-status-warn-bg':'#332a00','--tsa-status-crit-bg':'#333333',
'--tsa-status-ok-border':'#009988','--tsa-status-warn-border':'#886600','--tsa-status-crit-border':'#aaaaaa',
'--tsa-status-jail':'#ffcc00','--tsa-status-hospital':'#ffffff','--tsa-status-travel':'#88ccff',
'--tsa-status-abroad':'#aaddff','--tsa-status-blocked':'#cc88ff','--tsa-status-respect':'#ffff88',
'--tsa-phase-plan-bg':'#0d2a4a','--tsa-phase-plan-text':'#88ccff','--tsa-phase-plan-border':'#3a7acc',
'--tsa-phase-rec-bg':'#2a1a00','--tsa-phase-rec-text':'#ffcc44','--tsa-phase-rec-border':'#aa8800',
'--tsa-stuck-bg':'#2a2a2a','--tsa-stuck-border':'#ffffff',
'--tsa-banner-ok-bg':'#002a28','--tsa-banner-ok-border':'#009988','--tsa-banner-ok-text':'#88ddcc',
'--tsa-banner-warn-bg':'#332a00','--tsa-banner-warn-border':'#886600','--tsa-banner-warn-text':'#ffcc66',
'--tsa-banner-crit-bg':'#333','--tsa-banner-crit-border':'#aaa','--tsa-banner-crit-text':'#fff',
'--tsa-weight-high':'#ffcc44','--tsa-weight-mid':'#aaa','--tsa-weight-low':'#555',
'--tsa-font-scale':'1',
},
tritanopia: {
'--tsa-bg-deep':'#1a0f1a','--tsa-bg-dark':'#1a111a','--tsa-bg-base':'#221522',
'--tsa-bg-card':'#1e121e','--tsa-bg-header':'#1e121e','--tsa-bg-hover':'#281828',
'--tsa-bg-input':'#2a0a2a','--tsa-bg-dropdown':'#1a0f1a','--tsa-bg-row':'#1a111a',
'--tsa-border-faint':'#2a1a2a','--tsa-border-card':'#3a2a3a','--tsa-border-strip':'#2a1a2a',
'--tsa-border-input':'#6a3a6a','--tsa-border-accent':'#cc0066','--tsa-border-section':'#442244',
'--tsa-accent':'#ff1177','--tsa-accent-hover':'#cc0055','--tsa-accent-dim':'#990033',
'--tsa-text-primary':'#e0e0e0','--tsa-text-card':'#ccc','--tsa-text-secondary':'#bbb',
'--tsa-text-label':'#999','--tsa-text-muted':'#777','--tsa-text-disabled':'#555','--tsa-text-dead':'#444',
'--tsa-status-ok':'#00ddaa','--tsa-status-warn':'#ff88cc','--tsa-status-crit':'#ff2255',
'--tsa-status-ok-bg':'#002a22','--tsa-status-warn-bg':'#2a0a1a','--tsa-status-crit-bg':'#2a001a',
'--tsa-status-ok-border':'#009977','--tsa-status-warn-border':'#884466','--tsa-status-crit-border':'#880033',
'--tsa-status-jail':'#ff88cc','--tsa-status-hospital':'#ff2255','--tsa-status-travel':'#ff99dd',
'--tsa-status-abroad':'#ffbbee','--tsa-status-blocked':'#aa44ff','--tsa-status-respect':'#ffffff',
'--tsa-phase-plan-bg':'#1a0a2a','--tsa-phase-plan-text':'#ff99dd','--tsa-phase-plan-border':'#882266',
'--tsa-phase-rec-bg':'#2a1a00','--tsa-phase-rec-text':'#ff88cc','--tsa-phase-rec-border':'#884455',
'--tsa-stuck-bg':'#2a0011','--tsa-stuck-border':'#ff2255',
'--tsa-banner-ok-bg':'#002a22','--tsa-banner-ok-border':'#009977','--tsa-banner-ok-text':'#88ddcc',
'--tsa-banner-warn-bg':'#2a0a1a','--tsa-banner-warn-border':'#884466','--tsa-banner-warn-text':'#ff99cc',
'--tsa-banner-crit-bg':'#2a001a','--tsa-banner-crit-border':'#880033','--tsa-banner-crit-text':'#ff6688',
'--tsa-weight-high':'#ff88cc','--tsa-weight-mid':'#bbb','--tsa-weight-low':'#555',
'--tsa-font-scale':'1',
},
lowvision: {
'--tsa-bg-deep':'#080d18','--tsa-bg-dark':'#0a1020','--tsa-bg-base':'#0d1628',
'--tsa-bg-card':'#111828','--tsa-bg-header':'#111828','--tsa-bg-hover':'#161c30',
'--tsa-bg-input':'#0a2a50','--tsa-bg-dropdown':'#080d18','--tsa-bg-row':'#0a1020',
'--tsa-border-faint':'#333','--tsa-border-card':'#4a4a7a','--tsa-border-strip':'#2a3a6a',
'--tsa-border-input':'#4a6a9a','--tsa-border-accent':'#ff8800','--tsa-border-section':'#555',
'--tsa-accent':'#ff9900','--tsa-accent-hover':'#ff7700','--tsa-accent-dim':'#dd6600',
'--tsa-text-primary':'#ffffff','--tsa-text-card':'#eee','--tsa-text-secondary':'#ccc',
'--tsa-text-label':'#aaa','--tsa-text-muted':'#888','--tsa-text-disabled':'#666','--tsa-text-dead':'#555',
'--tsa-status-ok':'#66ffaa','--tsa-status-warn':'#ffcc00','--tsa-status-crit':'#ff5555',
'--tsa-status-ok-bg':'#003322','--tsa-status-warn-bg':'#332a00','--tsa-status-crit-bg':'#330a00',
'--tsa-status-ok-border':'#33cc77','--tsa-status-warn-border':'#998800','--tsa-status-crit-border':'#cc2200',
'--tsa-status-jail':'#ffaa00','--tsa-status-hospital':'#ff5555','--tsa-status-travel':'#99bbff',
'--tsa-status-abroad':'#bbddff','--tsa-status-blocked':'#ee55ee','--tsa-status-respect':'#ffee55',
'--tsa-phase-plan-bg':'#0a2040','--tsa-phase-plan-text':'#99ccff','--tsa-phase-plan-border':'#4488cc',
'--tsa-phase-rec-bg':'#201400','--tsa-phase-rec-text':'#ffbb44','--tsa-phase-rec-border':'#cc8800',
'--tsa-stuck-bg':'#200000','--tsa-stuck-border':'#ff3300',
'--tsa-banner-ok-bg':'#002a18','--tsa-banner-ok-border':'#33cc77','--tsa-banner-ok-text':'#99ffbb',
'--tsa-banner-warn-bg':'#332a00','--tsa-banner-warn-border':'#998800','--tsa-banner-warn-text':'#ffee66',
'--tsa-banner-crit-bg':'#330a00','--tsa-banner-crit-border':'#cc2200','--tsa-banner-crit-text':'#ff8877',
'--tsa-weight-high':'#ffaa55','--tsa-weight-mid':'#bbb','--tsa-weight-low':'#666',
'--tsa-font-scale':'1.1',
},
light: {
'--tsa-bg-deep':'#dde4f0','--tsa-bg-dark':'#e8eef8','--tsa-bg-base':'#eef2fa',
'--tsa-bg-card':'#f4f6fc','--tsa-bg-header':'#f4f6fc','--tsa-bg-hover':'#e8ecf8',
'--tsa-bg-input':'#dde4f0','--tsa-bg-dropdown':'#dde4f0','--tsa-bg-row':'#e8eef8',
'--tsa-border-faint':'#ccd4e8','--tsa-border-card':'#b8c4dc','--tsa-border-strip':'#c8d4e8',
'--tsa-border-input':'#9aaac8','--tsa-border-accent':'#cc5500','--tsa-border-section':'#b0bcd8',
'--tsa-accent':'#cc5500','--tsa-accent-hover':'#aa4400','--tsa-accent-dim':'#993300',
'--tsa-text-primary':'#1a1a2e','--tsa-text-card':'#222','--tsa-text-secondary':'#444',
'--tsa-text-label':'#666','--tsa-text-muted':'#777','--tsa-text-disabled':'#999','--tsa-text-dead':'#aaa',
'--tsa-status-ok':'#006622','--tsa-status-warn':'#885500','--tsa-status-crit':'#cc1111',
'--tsa-status-ok-bg':'#d4f0dd','--tsa-status-warn-bg':'#fff0cc','--tsa-status-crit-bg':'#ffe0dd',
'--tsa-status-ok-border':'#44aa66','--tsa-status-warn-border':'#cc8800','--tsa-status-crit-border':'#dd4444',
'--tsa-status-jail':'#885500','--tsa-status-hospital':'#cc1111','--tsa-status-travel':'#2255aa',
'--tsa-status-abroad':'#1144aa','--tsa-status-blocked':'#882288','--tsa-status-respect':'#775500',
'--tsa-phase-plan-bg':'#d8e8f8','--tsa-phase-plan-text':'#1a4a8a','--tsa-phase-plan-border':'#3a7acc',
'--tsa-phase-rec-bg':'#fff4dd','--tsa-phase-rec-text':'#774400','--tsa-phase-rec-border':'#cc8800',
'--tsa-stuck-bg':'#ffe8e8','--tsa-stuck-border':'#cc1111',
'--tsa-banner-ok-bg':'#d4f0dd','--tsa-banner-ok-border':'#44aa66','--tsa-banner-ok-text':'#004422',
'--tsa-banner-warn-bg':'#fff0cc','--tsa-banner-warn-border':'#cc8800','--tsa-banner-warn-text':'#664400',
'--tsa-banner-crit-bg':'#ffe0dd','--tsa-banner-crit-border':'#dd4444','--tsa-banner-crit-text':'#880000',
'--tsa-weight-high':'#cc5500','--tsa-weight-mid':'#555','--tsa-weight-low':'#999',
'--tsa-font-scale':'1',
},
};
function applyTheme(themeKey) {
const theme = THEMES[themeKey] || THEMES.default;
const root = document.getElementById('tsa-root');
if (!root) return;
for (const [prop, value] of Object.entries(theme)) {
if (prop.startsWith('--')) root.style.setProperty(prop, value);
}
root.style.fontSize = `${13 * parseFloat(theme['--tsa-font-scale'] || '1')}px`;
save('theme', themeKey);
}
// ─── Master stock data ────────────────────────────────────────────────────────
//
// THRESHOLD RULE (universal, confirmed v2.3.4):
// threshold[n] = (2^n - 1) * b1Threshold
// Generated dynamically — only b1Threshold is stored here.
// maxBlocks defaults to heldBlocks + 4 (user-configurable via GM storage).
//
// INTERVALS:
// 7d: FHG SYM PRN EWM THS LAG BAG MUN PTS EVL MCS CBD ASS LSC
// 31d: GRN TCT TMI IOU TSB CNC HRG TCC
// 0: TCP TCM TGP IIL TCI WLT SYS ELT MSG WSU LOS YAZ IST (passive)
//
// PAYOUT TYPES:
// cash — DOM-read or user override ($/block/interval)
// item — qty * live market price (payoutItemName used for ID resolution)
// energy — payoutUnits energy/block/interval
// nerve — payoutUnits nerve/block/interval
// happy — 1000 happy/block/interval
// other — user sets $ value in config (e.g. HRG property)
// passive — no active payout
const STOCK_DATA = [
// ── 7-day item stocks ─────────────────────────────────────────────────────
{ ticker:'FHG', stockId:7, name:'Feathery Hotels Group',
payoutType:'item', payoutInterval:7,
payoutItemName:'Feathery Hotel Coupon', payoutItemId:367, payoutQty:1,
payoutCashValue:0,
payoutDesc:'1x Feathery Hotel Coupon per block, every 7 days',
b1Threshold:2000000 },
{ ticker:'SYM', stockId:2, name:'Symbiotic Ltd.',
payoutType:'item', payoutInterval:7,
payoutItemName:'Drug Pack', payoutItemId:370, payoutQty:1,
payoutCashValue:0,
payoutDesc:'1x Drug Pack per block, every 7 days',
b1Threshold:500000 },
{ ticker:'PRN', stockId:21, name:'Performance Ribaldry',
payoutType:'item', payoutInterval:7,
payoutItemName:'Erotic DVD', payoutItemId:null, payoutQty:1,
payoutCashValue:0,
payoutDesc:'1x Erotic DVD per block, every 7 days',
b1Threshold:1000000 },
{ ticker:'EWM', stockId:10, name:'Eaglewood Mercenary',
payoutType:'item', payoutInterval:7,
payoutItemName:'Box of Grenades', payoutItemId:null, payoutQty:1,
payoutCashValue:0,
payoutDesc:'1x Box of Grenades per block, every 7 days',
b1Threshold:1000000 },
{ ticker:'THS', stockId:20, name:'Torn City Health Service',
payoutType:'item', payoutInterval:7,
payoutItemName:'Box of Medical Supplies', payoutItemId:null, payoutQty:1,
payoutCashValue:0,
payoutDesc:'1x Box of Medical Supplies per block, every 7 days',
b1Threshold:150000 },
{ ticker:'LAG', stockId:17, name:'Legal Authorities Group',
payoutType:'item', payoutInterval:7,
payoutItemName:"Lawyer's Business Card", payoutItemId:368, payoutQty:1,
payoutCashValue:0,
payoutDesc:"1x Lawyer's Business Card per block, every 7 days",
b1Threshold:750000 },
{ ticker:'BAG', stockId:27, name:"Big Al's Gun Shop",
payoutType:'item', payoutInterval:7,
payoutItemName:'Ammunition Pack', payoutItemId:null, payoutQty:1,
payoutCashValue:0,
payoutDesc:'1x Ammunition Pack per block, every 7 days',
b1Threshold:3000000 },
{ ticker:'MUN', stockId:12, name:'Munster Beverage Corp.',
payoutType:'item', payoutInterval:7,
payoutItemName:'Six-Pack of Energy Drink', payoutItemId:null, payoutQty:1,
payoutCashValue:0,
payoutDesc:'1x Six-Pack of Energy Drink per block, every 7 days',
b1Threshold:5000000 },
{ ticker:'ASS', stockId:24, name:'Alcoholics Synonymous',
payoutType:'item', payoutInterval:7,
payoutItemName:'Six-Pack of Alcohol', payoutItemId:null, payoutQty:1,
payoutCashValue:0,
payoutDesc:'1x Six-Pack of Alcohol per block, every 7 days',
b1Threshold:1000000 },
{ ticker:'LSC', stockId:6, name:'Lucky Shot Casino',
payoutType:'item', payoutInterval:7,
payoutItemName:'Lottery Voucher', payoutItemId:null, payoutQty:1,
payoutCashValue:0,
payoutDesc:'1x Lottery Voucher per block, every 7 days',
b1Threshold:500000 },
{ ticker:'TCC', stockId:35, name:'Torn City Clothing',
payoutType:'item', payoutInterval:31,
payoutItemName:'Clothing Cache', payoutItemId:null, payoutQty:1,
payoutCashValue:0,
payoutDesc:'1x Clothing Cache per block, every 31 days',
b1Threshold:7500000 },
// ── 7-day non-item active stocks ──────────────────────────────────────────
{ ticker:'PTS', stockId:22, name:'PointLess',
payoutType:'points', payoutInterval:7,
payoutItemName:null, payoutItemId:null, payoutQty:100,
payoutCashValue:0,
payoutDesc:'100 points per block, every 7 days (valued at live points market price)',
b1Threshold:10000000 },
{ ticker:'EVL', stockId:26, name:'Evil Ducks Candy Corp',
payoutType:'happy', payoutInterval:7,
payoutItemName:null, payoutItemId:null, payoutQty:1,
payoutCashValue:0, payoutUnits:1000,
payoutDesc:'1000 happiness per block, every 7 days',
b1Threshold:100000 },
{ ticker:'MCS', stockId:23, name:'Mc Smoogle Corp',
payoutType:'energy', payoutInterval:7,
payoutItemName:null, payoutItemId:null, payoutQty:1,
payoutCashValue:0, payoutUnits:200,
payoutDesc:'200 energy per block, every 7 days',
b1Threshold:350000 },
{ ticker:'CBD', stockId:18, name:'Herbal Releaf Co.',
payoutType:'nerve', payoutInterval:7,
payoutItemName:null, payoutItemId:null, payoutQty:1,
payoutCashValue:0, payoutUnits:50,
payoutDesc:'50 nerve per block, every 7 days',
b1Threshold:350000 },
// ── 31-day cash dividend stocks ───────────────────────────────────────────
// payoutCashValue always 0 — read live from DOM (total/heldBlocks=per-block)
{ ticker:'GRN', stockId:16, name:'Grain',
payoutType:'cash', payoutInterval:31,
payoutItemName:null, payoutItemId:null, payoutQty:1,
payoutCashValue:4000000,
// Default: $4M/block (confirmed v2.3.4). Override in config if different.
payoutDesc:'Cash dividend per block, every 31 days — default $4M, override in config',
b1Threshold:500000 },
{ ticker:'TCT', stockId:9, name:'The Torn City Times',
payoutType:'cash', payoutInterval:31,
payoutItemName:null, payoutItemId:null, payoutQty:1,
payoutCashValue:1000000,
payoutDesc:'Cash dividend per block, every 31 days — default $1M, override in config',
b1Threshold:100000 },
{ ticker:'TMI', stockId:5, name:'TC Music Industries',
payoutType:'cash', payoutInterval:31,
payoutItemName:null, payoutItemId:null, payoutQty:1,
payoutCashValue:25000000,
payoutDesc:'Cash dividend per block, every 31 days — default $25M, override in config',
b1Threshold:6000000 },
{ ticker:'IOU', stockId:14, name:'Insured On Us',
payoutType:'cash', payoutInterval:31,
payoutItemName:null, payoutItemId:null, payoutQty:1,
payoutCashValue:12000000,
payoutDesc:'Cash dividend per block, every 31 days (+ lawsuit chance) — default $12M, override in config',
b1Threshold:3000000 },
{ ticker:'TSB', stockId:1, name:'Torn & Shanghai Banking',
payoutType:'cash', payoutInterval:31,
payoutItemName:null, payoutItemId:null, payoutQty:1,
payoutCashValue:50000000,
payoutDesc:'Cash dividend per block, every 31 days — default $50M, override in config',
b1Threshold:3000000 },
{ ticker:'CNC', stockId:34, name:'Crude & Co',
payoutType:'cash', payoutInterval:31,
payoutItemName:null, payoutItemId:null, payoutQty:1,
payoutCashValue:80000000,
payoutDesc:'Cash dividend per block, every 31 days — default $80M, override in config',
b1Threshold:7500000 },
{ ticker:'HRG', stockId:8, name:'Home Retail Group',
payoutType:'other', payoutInterval:31,
payoutItemName:null, payoutItemId:null, payoutQty:1,
payoutCashValue:0,
payoutDesc:'1x Random Property per block, every 31 days — set $ value in config',
b1Threshold:10000000 },
// ── Passive stocks ────────────────────────────────────────────────────────
{ ticker:'TCP', stockId:13, name:'TC Media Productions',
payoutType:'passive', payoutInterval:0,
payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
payoutDesc:'Company sales boost (passive)', b1Threshold:1000000 },
{ ticker:'TCM', stockId:4, name:'Torn City Motors',
payoutType:'passive', payoutInterval:0,
payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
payoutDesc:'10% racing skill gain boost (passive)', b1Threshold:1000000 },
{ ticker:'TGP', stockId:19, name:'Tell Group Plc.',
payoutType:'passive', payoutInterval:0,
payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
payoutDesc:'Company advertising boost (passive)', b1Threshold:2500000 },
{ ticker:'IIL', stockId:25, name:'I Industries Ltd.',
payoutType:'passive', payoutInterval:0,
payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
payoutDesc:'50% coding time reduction (passive)', b1Threshold:1000000 },
{ ticker:'TCI', stockId:15, name:'Torn City Investments',
payoutType:'passive', payoutInterval:0,
payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
payoutDesc:'10% bank interest bonus (passive — hold 7 days before banking)', b1Threshold:1500000 },
{ ticker:'WLT', stockId:11, name:'Wind Lines Travel',
payoutType:'passive', payoutInterval:0,
payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
payoutDesc:'Private jet access (passive)', b1Threshold:9000000 },
{ ticker:'SYS', stockId:3, name:'Syscore MFG',
payoutType:'passive', payoutInterval:0,
payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
payoutDesc:'Advanced firewall (passive)', b1Threshold:3000000 },
{ ticker:'ELT', stockId:28, name:'Empty Lunchbox Traders',
payoutType:'passive', payoutInterval:0,
payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
payoutDesc:'10% home upgrade discount (passive)', b1Threshold:5000000 },
{ ticker:'MSG', stockId:29, name:'Messaging Inc.',
payoutType:'passive', payoutInterval:0,
payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
payoutDesc:'Free classified advertising (passive)', b1Threshold:300000 },
{ ticker:'WSU', stockId:31, name:'West Side University',
payoutType:'passive', payoutInterval:0,
payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
payoutDesc:'10% course time reduction (passive)', b1Threshold:1000000 },
{ ticker:'LOS', stockId:32, name:'Lo Squalo Waste',
payoutType:'passive', payoutInterval:0,
payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
payoutDesc:'25% mission reward bonus (passive)', b1Threshold:7500000 },
{ ticker:'YAZ', stockId:33, name:'Yazoo',
payoutType:'passive', payoutInterval:0,
payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
payoutDesc:'Free banner advertising (passive)', b1Threshold:1000000 },
{ ticker:'IST', stockId:30, name:'International School TC',
payoutType:'passive', payoutInterval:0,
payoutItemName:null, payoutItemId:null, payoutQty:1, payoutCashValue:0,
payoutDesc:'Free education courses (passive)', b1Threshold:100000 },
];
// ─── Dynamic block generation ─────────────────────────────────────────────────
// Returns array of {incr, threshold} using the universal doubling rule.
// threshold[n] = (2^n - 1) * b1
// maxBlocks is read from GM storage (default: heldBlocks + 4, min 5).
function getB1(ticker) {
const stored = parseInt(load(`b1_${ticker}`, ''), 10);
const stock = STOCK_DATA.find(s => s.ticker === ticker);
return (!isNaN(stored) && stored > 0) ? stored : (stock?.b1Threshold || 0);
}
function getMaxBlocks(ticker, heldBlocks = 0) {
const stored = parseInt(load(`maxblocks_${ticker}`, ''), 10);
const def = Math.max(5, heldBlocks + 4);
return (!isNaN(stored) && stored > 0) ? stored : def;
}
function buildIncrements(ticker, heldBlocks = 0) {
const b1 = getB1(ticker);
const max = getMaxBlocks(ticker, heldBlocks);
if (!b1 || !max) return [];
const increments = [];
for (let n = 1; n <= max; n++) {
increments.push({ incr: n, threshold: (Math.pow(2, n) - 1) * b1 });
}
return increments;
}
// ─── Config migration ─────────────────────────────────────────────────────────
(function migrateConfig() {
const stored = GM_getValue(PREFIX + 'cfg_version', '');
if (stored === CONFIG_VERSION) return;
console.log(`[TSA] Config ${stored} -> ${CONFIG_VERSION}: clearing all per-stock overrides`);
// Clear every per-stock key so corrected STOCK_DATA defaults take effect.
// This covers payout, b1, maxblocks, itemqty, itemname, units, and legacy thresh keys.
for (const stock of STOCK_DATA) {
['payout','b1','maxblocks','itemqty','itemname','units'].forEach(k => {
GM_setValue(PREFIX + `${k}_${stock.ticker}`, null);
});
for (let n = 1; n <= 10; n++) {
GM_setValue(PREFIX + `thresh_${stock.ticker}_${n}`, null);
}
}
GM_setValue(PREFIX + 'cfg_version', CONFIG_VERSION);
})();
// ─── CSS ──────────────────────────────────────────────────────────────────────
// ALL colours use var(--tsa-*) tokens. No hardcoded hex anywhere.
GM_addStyle(`
#tsa-root * { box-sizing: border-box; margin: 0; padding: 0; }
#tsa-root {
font-family: Arial, sans-serif; font-size: 13px;
color: var(--tsa-text-primary);
background: var(--tsa-bg-base);
border-radius: 6px; margin: 12px 0; overflow: hidden;
}
/* Header */
#tsa-header {
background: var(--tsa-bg-header);
border-bottom: 2px solid var(--tsa-border-accent);
border-radius: 6px 6px 0 0;
padding: 10px 14px;
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
}
.tsa-title { color: var(--tsa-accent); font-size: 15px; font-weight: bold; }
.tsa-version { font-size: 10px; opacity: 0.5; font-weight: normal; }
.tsa-updated { color: var(--tsa-text-label); font-size: 11px; margin-left: auto; }
/* Buttons */
.tsa-btn-primary {
background: var(--tsa-accent-hover); border: none; border-radius: 4px;
color: var(--tsa-text-primary); padding: 4px 10px; cursor: pointer; font-size: 12px;
}
.tsa-btn-primary:hover { background: var(--tsa-accent); }
.tsa-btn-secondary {
background: var(--tsa-bg-deep); border: 1px solid var(--tsa-border-input);
border-radius: 4px; color: var(--tsa-text-secondary);
padding: 3px 8px; cursor: pointer; font-size: 11px;
}
.tsa-btn-secondary:hover { background: var(--tsa-bg-hover); color: var(--tsa-text-primary); }
/* Config strip */
#tsa-config-strip {
background: var(--tsa-bg-base); border-bottom: 1px solid var(--tsa-border-strip);
padding: 7px 14px; display: flex; align-items: center;
gap: 8px; flex-wrap: wrap; font-size: 11px; color: var(--tsa-text-label);
}
.tsa-sep { color: var(--tsa-border-card); }
.tsa-key-ok { color: var(--tsa-status-ok); font-size: 10px; font-weight: bold; }
.tsa-key-bad { color: var(--tsa-status-crit); font-size: 10px; font-weight: bold; }
/* Config panel */
#tsa-config-panel {
background: var(--tsa-bg-dark); border-bottom: 2px solid var(--tsa-border-accent);
padding: 10px 12px; display: none;
}
#tsa-config-panel.open { display: block; }
.tsa-cfg-label {
font-size: 9px; color: var(--tsa-text-muted); text-transform: uppercase;
letter-spacing: .5px; margin: 10px 0 5px; display: block;
}
.tsa-cfg-label:first-child { margin-top: 0; }
.tsa-cfg-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
.tsa-cfg-row label { font-size: 11px; color: var(--tsa-text-secondary); min-width: 160px; }
.tsa-cfg-input {
background: var(--tsa-bg-input); border: 1px solid var(--tsa-border-input);
border-radius: 4px; color: var(--tsa-text-primary); padding: 4px 8px; font-size: 12px;
}
.tsa-cfg-input:focus { outline: none; border-color: var(--tsa-accent); }
.tsa-cfg-input option { background: var(--tsa-bg-dropdown); color: var(--tsa-text-primary); }
.tsa-row-overbudget td { opacity: 0.4; }
.tsa-cfg-note { font-size: 10px; color: var(--tsa-text-muted); margin-top: 3px; }
.tsa-cfg-check-row { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; }
.tsa-cfg-check-row label { font-size: 11px; color: var(--tsa-text-secondary); }
/* Config accordions */
.tsa-accord { border: 1px solid var(--tsa-border-strip); border-radius: 4px; margin-bottom: 4px; }
.tsa-accord-hdr {
background: var(--tsa-bg-deep); padding: 5px 10px; cursor: pointer;
display: flex; align-items: center; justify-content: space-between;
font-size: 11px; color: var(--tsa-text-secondary); border-radius: 4px; user-select: none;
}
.tsa-accord-hdr:hover { background: var(--tsa-bg-hover); color: var(--tsa-text-primary); }
.tsa-accord-ticker {
background: var(--tsa-border-strip); color: var(--tsa-phase-plan-text);
font-size: 10px; font-weight: bold; padding: 1px 6px; border-radius: 3px;
font-family: monospace; margin-right: 8px;
}
.tsa-accord-arrow { font-size: 10px; transition: transform .2s; }
.tsa-accord-hdr.open .tsa-accord-arrow { transform: rotate(180deg); }
.tsa-accord-body {
display: none; padding: 8px 10px;
background: var(--tsa-bg-deep); border-top: 1px solid var(--tsa-border-strip);
}
.tsa-accord-body.open { display: block; }
.tsa-incr-row {
display: flex; align-items: center; gap: 8px; margin-bottom: 5px;
font-size: 11px; flex-wrap: wrap;
}
.tsa-incr-row label { color: var(--tsa-text-muted); min-width: 130px; }
.tsa-incr-row .tsa-cfg-input { width: 130px; }
.tsa-incr-note { font-size: 10px; color: var(--tsa-text-dead); }
/* Stats bar */
#tsa-stats-bar {
background: var(--tsa-bg-deep); border-bottom: 1px solid var(--tsa-border-strip);
padding: 10px 14px; display: flex; gap: 32px; flex-wrap: wrap; align-items: center;
}
.tsa-stat { display: flex; flex-direction: column; }
.tsa-stat-label { font-size: 10px; color: var(--tsa-text-secondary); text-transform: uppercase; letter-spacing: .5px; }
.tsa-stat-value { font-size: 16px; font-weight: bold; color: var(--tsa-accent); }
/* Body */
#tsa-body { padding: 16px 14px; }
#tsa-error {
background: var(--tsa-status-crit-bg); border: 1px solid var(--tsa-status-crit-border);
border-radius: 5px; padding: 8px 12px; margin-bottom: 10px; font-size: 12px;
color: var(--tsa-banner-crit-text); display: none;
}
#tsa-loading { text-align: center; padding: 20px; color: var(--tsa-text-muted); font-size: 12px; display: none; }
.tsa-spinner {
display: inline-block; width: 12px; height: 12px;
border: 2px solid var(--tsa-text-dead); border-top-color: var(--tsa-accent);
border-radius: 50%; animation: tsa-spin .7s linear infinite;
vertical-align: middle; margin-right: 6px;
}
@keyframes tsa-spin { to { transform: rotate(360deg); } }
/* Sections */
.tsa-section { margin-bottom: 8px; padding-top: 18px; border-top: 1px solid var(--tsa-border-strip); }
.tsa-section:first-child { padding-top: 0; border-top: none; }
.tsa-section-title {
color: var(--tsa-accent); font-size: 12px; font-weight: bold;
text-transform: uppercase; letter-spacing: 1px;
border-bottom: 1px solid var(--tsa-border-section);
padding-bottom: 6px; margin-bottom: 10px;
cursor: pointer; display: flex; align-items: center;
justify-content: space-between; user-select: none;
}
.tsa-section-title::after { content: '\u25be'; font-size: 10px; transition: transform .2s; }
.tsa-section-title.collapsed::after { transform: rotate(-90deg); }
/* Recommendation cards */
#tsa-cards {
display: grid; grid-template-columns: repeat(auto-fill, minmax(min(270px,100%),1fr));
gap: 12px; margin-bottom: 10px;
}
.tsa-card {
background: var(--tsa-bg-card); border: 1px solid var(--tsa-border-card);
border-radius: 6px; padding: 12px 14px; position: relative;
}
.tsa-card[title]:hover { border-color: var(--tsa-accent); background: var(--tsa-bg-hover); }
.tsa-card-rank { position: absolute; top: 8px; right: 10px; font-size: 12px; font-weight: bold; color: var(--tsa-text-dead); }
.tsa-rank-gold { color: var(--tsa-status-respect); }
.tsa-rank-silver { color: var(--tsa-text-secondary); }
.tsa-rank-bronze { color: var(--tsa-weight-high); }
.tsa-card-head { font-size: 13px; font-weight: bold; color: var(--tsa-text-card); margin-bottom: 3px; padding-right: 24px; }
.tsa-card-sub { margin-bottom: 8px; }
.tsa-card-line { font-size: 11px; color: var(--tsa-text-label); margin-bottom: 3px; }
.tsa-card-line strong { color: var(--tsa-text-primary); }
.tsa-card-roi { font-size: 10px; color: var(--tsa-accent); font-weight: bold; margin-top: 7px; }
.tsa-card-partial {
border-top: 1px solid var(--tsa-border-card); margin-top: 5px; padding-top: 4px;
font-size: 10px; color: var(--tsa-status-warn);
}
/* Holdings */
.tsa-hold-sublabel {
font-size: 9px; color: var(--tsa-text-muted); text-transform: uppercase;
letter-spacing: .5px; margin: 12px 0 6px;
}
.tsa-hold-sublabel:first-child { margin-top: 0; }
.tsa-hold-row {
display: grid;
grid-template-columns: 44px minmax(160px,1fr) 80px 100px 130px;
align-items: center; gap: 0 10px;
padding: 7px 0; border-bottom: 1px solid var(--tsa-bg-deep); font-size: 11px;
}
.tsa-hold-row:last-child { border-bottom: none; }
.tsa-hold-name { color: var(--tsa-text-card); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tsa-hold-val { color: var(--tsa-status-ok); text-align: right; }
.tsa-hold-partial-txt { color: var(--tsa-status-warn); font-size: 10px; }
.tsa-hold-partial-row {
display: grid; grid-template-columns: 44px minmax(160px,1fr) 44px auto;
align-items: center; gap: 0 10px;
padding: 5px 0; border-bottom: 1px solid var(--tsa-bg-deep); font-size: 11px;
}
.tsa-hold-partial-row:last-child { border-bottom: none; }
/* Full rankings table */
table.tsa-table { width: 100%; border-collapse: collapse; font-size: 11px; }
table.tsa-table th {
color: var(--tsa-text-muted); font-weight: normal; font-size: 10px;
text-transform: uppercase; letter-spacing: .5px; padding: 7px 10px;
border-bottom: 2px solid var(--tsa-border-strip);
text-align: left; white-space: nowrap; background: var(--tsa-bg-deep);
}
table.tsa-table td {
padding: 7px 10px; border-bottom: 1px solid var(--tsa-border-faint);
color: var(--tsa-text-card); white-space: nowrap; vertical-align: middle;
}
table.tsa-table tbody tr:nth-child(even) td { background: var(--tsa-bg-dark); }
table.tsa-table tbody tr:nth-child(odd) td { background: var(--tsa-bg-deep); }
table.tsa-table tr:hover td { background: var(--tsa-bg-hover) !important; }
table.tsa-table tr.tsa-row-ignored td { opacity: 0.25; }
table.tsa-table tr.tsa-row-passive td { opacity: 0.40; }
table.tsa-table tr.tsa-row-hidden td { display: none; }
table.tsa-table th:first-child,
table.tsa-table td:first-child { text-align: center; padding: 5px 6px; border-right: 1px solid var(--tsa-border-strip); }
/* Badges */
.tsa-badge {
font-size: 10px; padding: 1px 5px; border-radius: 3px;
font-weight: bold; white-space: nowrap; display: inline-block;
}
.tsa-badge-ok { background: var(--tsa-status-ok-bg); color: var(--tsa-status-ok); border: 1px solid var(--tsa-status-ok-border); }
.tsa-badge-warn { background: var(--tsa-status-warn-bg); color: var(--tsa-status-warn); border: 1px solid var(--tsa-status-warn-border); }
.tsa-badge-info { background: var(--tsa-phase-plan-bg); color: var(--tsa-phase-plan-text); border: 1px solid var(--tsa-phase-plan-border); }
.tsa-badge-muted { background: var(--tsa-bg-dark); color: var(--tsa-text-dead); border: 1px solid var(--tsa-border-faint); }
.tsa-badge-energy { background: var(--tsa-phase-plan-bg); color: var(--tsa-status-ok); border: 1px solid var(--tsa-status-ok-border); }
.tsa-badge-nerve { background: var(--tsa-status-crit-bg); color: var(--tsa-status-blocked); border: 1px solid var(--tsa-status-crit-border); }
.tsa-badge-happy { background: var(--tsa-status-warn-bg); color: var(--tsa-status-respect); border: 1px solid var(--tsa-status-warn-border); }
.tsa-badge-passive { background: var(--tsa-bg-card); color: var(--tsa-text-muted); border: 1px solid var(--tsa-border-card); }
.tsa-badge-cash { background: var(--tsa-status-ok-bg); color: var(--tsa-status-ok); border: 1px solid var(--tsa-status-ok-border); }
.tsa-badge-item { background: var(--tsa-phase-plan-bg); color: var(--tsa-phase-plan-text); border: 1px solid var(--tsa-phase-plan-border); }
.tsa-badge-other { background: var(--tsa-bg-dark); color: var(--tsa-text-secondary); border: 1px solid var(--tsa-border-card); }
.tsa-ticker {
font-family: monospace; font-weight: bold; color: var(--tsa-text-primary);
background: var(--tsa-bg-deep); padding: 1px 5px; border-radius: 3px; font-size: 11px;
}
.tsa-rank-num {
font-size: 11px; font-weight: bold; color: var(--tsa-text-muted);
display: inline-block; width: 18px; text-align: right;
}
/* Swap advisor */
.tsa-swap-note { font-size: 10px; color: var(--tsa-text-muted); margin-top: 20px; line-height: 1.6; }
.tsa-swap-card {
border: 1px solid var(--tsa-border-strip); border-radius: 6px;
padding: 12px 16px; margin-bottom: 6px;
background: var(--tsa-bg-dark);
}
.tsa-swap-card.alt {
background: var(--tsa-bg-deep); border-color: var(--tsa-border-faint);
margin-left: 20px;
}
.tsa-swap-chain {
border: 1px solid var(--tsa-border-faint); border-radius: 6px;
padding: 10px 16px; margin-bottom: 6px; margin-left: 20px;
background: var(--tsa-bg-deep);
}
.tsa-swap-group-header {
margin: 20px 0 10px; font-size: 11px;
color: var(--tsa-text-muted);
padding-bottom: 8px; border-bottom: 1px solid var(--tsa-border-faint);
}
.tsa-swap-group-divider {
border-top: 1px solid var(--tsa-border-strip); margin: 28px 0 0;
}
/* Savings plan */
#tsa-savings-banner {
margin-bottom: 12px; padding: 10px 14px;
background: var(--tsa-banner-ok-bg); border: 2px solid var(--tsa-banner-ok-border);
border-radius: 6px; font-size: 12px; color: var(--tsa-banner-ok-text);
display: flex; align-items: flex-start; gap: 10px;
animation: tsa-pulse-ok 2s ease-in-out infinite;
}
@keyframes tsa-pulse-ok {
0%,100% { box-shadow: 0 0 0 0 transparent; }
50% { box-shadow: 0 0 8px 2px var(--tsa-status-ok-bg); }
}
#tsa-savings-banner .tsa-ban-icon { font-size: 20px; flex-shrink: 0; }
#tsa-savings-banner .tsa-ban-body { flex: 1; }
#tsa-savings-banner .tsa-ban-title { font-weight: bold; font-size: 13px; margin-bottom: 4px; }
#tsa-savings-banner .tsa-ban-sell { font-size: 11px; color: var(--tsa-banner-ok-text); margin-top: 4px; opacity: 0.85; }
#tsa-savings-banner .tsa-ban-dismiss {
background: none; border: 1px solid var(--tsa-banner-ok-border);
color: var(--tsa-banner-ok-text); border-radius: 3px; padding: 2px 8px;
cursor: pointer; font-size: 10px; flex-shrink: 0; align-self: flex-start;
}
#tsa-savings-banner .tsa-ban-dismiss:hover { background: var(--tsa-banner-ok-border); color: var(--tsa-bg-deep); }
.tsa-plan-goal {
background: var(--tsa-bg-deep); border: 1px solid var(--tsa-border-strip);
border-radius: 6px; padding: 12px 14px; margin-bottom: 10px;
}
.tsa-plan-goal-hd {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 8px; flex-wrap: wrap; gap: 6px;
}
.tsa-plan-goal-name { font-size: 13px; font-weight: bold; color: var(--tsa-text-primary); }
.tsa-plan-goal-meta { font-size: 11px; color: var(--tsa-text-muted); }
.tsa-plan-progress { height: 8px; background: var(--tsa-bg-card); border-radius: 4px; overflow: hidden; margin: 6px 0; }
.tsa-plan-progress-fill { height: 100%; border-radius: 4px; background: var(--tsa-accent); transition: width .4s ease; }
.tsa-plan-progress-fill.done { background: var(--tsa-status-ok); }
.tsa-plan-stats { display: flex; gap: 20px; flex-wrap: wrap; margin-top: 8px; font-size: 11px; }
.tsa-plan-stat { display: flex; flex-direction: column; gap: 2px; }
.tsa-plan-stat-lbl { font-size: 9px; color: var(--tsa-text-muted); text-transform: uppercase; letter-spacing: .5px; }
.tsa-plan-stat-val { font-weight: bold; color: var(--tsa-accent); }
.tsa-plan-rec-row {
display: flex; align-items: center; gap: 8px; padding: 6px 0;
border-bottom: 1px solid var(--tsa-bg-deep); font-size: 11px; flex-wrap: wrap;
}
.tsa-plan-rec-row:last-child { border-bottom: none; }
/* Stepping stone toggle */
.tsa-step-toggle {
background: none; border: 1px solid var(--tsa-border-card); border-radius: 3px;
color: var(--tsa-text-dead); font-size: 10px; padding: 1px 6px;
cursor: pointer; transition: all .15s; white-space: nowrap;
}
.tsa-step-toggle:hover { border-color: var(--tsa-accent); color: var(--tsa-accent); }
.tsa-step-toggle.active { background: var(--tsa-banner-ok-bg); border-color: var(--tsa-status-ok-border); color: var(--tsa-status-ok); }
/* Item name validator status indicator */
.tsa-item-status { font-size: 10px; font-weight: bold; margin-left: 6px; }
.tsa-item-status.ok { color: var(--tsa-status-ok); }
.tsa-item-status.miss { color: var(--tsa-status-crit); }
.tsa-item-status.pending { color: var(--tsa-status-warn); }
.tsa-item-status.empty { color: var(--tsa-text-muted); }
/* Footer */
#tsa-disclaimer {
border-top: 1px solid var(--tsa-border-strip); padding: 7px 14px;
font-size: 10px; color: var(--tsa-phase-rec-text); background: var(--tsa-phase-rec-bg);
text-align: center; line-height: 1.5;
}
#tsa-footer {
border-top: 1px solid var(--tsa-border-strip); padding: 6px 12px;
font-size: 10px; color: var(--tsa-text-dead);
display: flex; justify-content: space-between;
}
/* Dashboard collapse */
#tsa-root.collapsed > *:not(#tsa-header) { display: none !important; }
#tsa-root.collapsed { border-radius: 6px; }
#tsa-root.collapsed #tsa-header { border-radius: 6px; border-bottom: none; }
/* Mobile / PDA layout <=480px */
@media (max-width: 480px) {
#tsa-root { font-size: 12px; margin: 4px 0; border-radius: 4px; }
#tsa-header { padding: 8px 10px; gap: 6px; }
.tsa-title { font-size: 13px; }
#tsa-stats-bar { padding: 8px 10px; gap: 16px; }
.tsa-stat-value { font-size: 14px; }
#tsa-body { padding: 8px; }
.tsa-section { padding-top: 0; border-top: none; margin-bottom: 6px; }
.tsa-section-title { font-size: 11px; padding: 10px 10px 10px 10px;
background: var(--tsa-bg-card); border-radius: 5px;
border: 1px solid var(--tsa-border-card); border-bottom: none;
margin-bottom: 0; }
.tsa-section-title.mobile-collapsed { border-radius: 5px; border-bottom: 1px solid var(--tsa-border-card); margin-bottom: 6px; }
.tsa-section-title.mobile-collapsed::after { transform: rotate(-90deg); }
.tsa-section-content { border: 1px solid var(--tsa-border-card);
border-top: none; border-radius: 0 0 5px 5px;
padding: 10px; margin-bottom: 6px; }
#tsa-cards { grid-template-columns: 1fr; gap: 8px; }
.tsa-hold-row { grid-template-columns: 40px 1fr 70px 70px; }
.tsa-swap-card, .tsa-swap-chain { padding: 9px 10px; }
.tsa-swap-group-header { font-size: 10px; }
table.tsa-table { display: none; }
#tsa-mobile-rankings { display: block; }
}
@media (min-width: 481px) {
#tsa-mobile-rankings { display: none; }
}
/* Progress bar */
.tsa-progress-bar { height: 5px; background: var(--tsa-bg-deep); border-radius: 3px; overflow: hidden; }
.tsa-progress-fill { height: 100%; border-radius: 3px; background: var(--tsa-status-ok); }
.tsa-progress-fill.warn { background: var(--tsa-status-warn); }
.tsa-progress-fill.crit { background: var(--tsa-status-crit); }
`);
// ─── Helpers ─────────────────────────────────────────────────────────────────
function fmtMoney(v) {
if (v === null || v === undefined || isNaN(v)) return '\u2014';
if (v >= 1e9) return '$' + (v / 1e9).toFixed(2) + 'B';
if (v >= 1e6) return '$' + (v / 1e6).toFixed(2) + 'M';
if (v >= 1e3) return '$' + (v / 1e3).toFixed(1) + 'k';
return '$' + Math.round(v).toLocaleString();
}
function fmtShares(n) {
if (!n) return '0';
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
return n.toLocaleString();
}
function fmtROI(dailyVal, cost) {
if (!cost || !dailyVal || cost <= 0) return '\u2014';
return ((dailyVal / cost) * 100).toFixed(3) + '%/day';
}
function save(k, v) { GM_setValue(PREFIX + k, v); }
function load(k, d) {
const v = GM_getValue(PREFIX + k, d);
return (v !== undefined && v !== null) ? v : d;
}
function wireCollapse(titleEl, contentEl, storeKey, def = 'open') {
const saved = load(storeKey, def);
if (saved === 'collapsed') { contentEl.style.display = 'none'; titleEl.classList.add('collapsed'); }
titleEl.addEventListener('click', () => {
const hidden = contentEl.style.display === 'none';
contentEl.style.display = hidden ? '' : 'none';
titleEl.classList.toggle('collapsed', !hidden);
save(storeKey, hidden ? 'open' : 'collapsed');
});
}
// ─── Config accessors ─────────────────────────────────────────────────────────
const getApiKey = () => load('api_key', '');
const getIgnored = () => load('ignored', '').split(',').map(s => s.trim().toUpperCase()).filter(Boolean);
const getExNerve = () => load('ex_nerve', true);
const getExEnergy = () => load('ex_energy', true);
const getExHappy = () => load('ex_happy', false);
const getExOther = () => load('ex_other', false);
const getExPassive = () => load('ex_passive', true);
const getRefreshMins = () => parseInt(load('refresh_mins', 5), 10);
const getSwapNoSell = () => load('swap_no_sell', '').split(',').map(s => s.trim().toUpperCase()).filter(Boolean);
const getBudget = () => parseFloat(load('budget', '0')) || 0;
const getBudgetPct = () => parseFloat(load('budget_pct', '110')) || 110;
const getBudgetMode = () => load('budget_mode', 'grey');
const getTopN = () => parseInt(load('top_n', 5), 10);
const getSavingsGoal = () => load('savings_goal', '');
const getTheme = () => load('theme', 'default');
// Single cash field — used everywhere: recommendations (budget filter),
// swap advisor (reduces how much selling must cover), savings plan
// (stepping-stone suggestions and progress tracking).
// Returns 0 if not set — explicit zero means "no cash available".
const getAvailableCash = () => getBudget();
function isSteppingStone(ticker, incr) { return load(`step_${ticker}_${incr}`, '') === '1'; }
function setSteppingStone(ticker, incr, val) { save(`step_${ticker}_${incr}`, val ? '1' : ''); }
// Payout override — cash value per block per interval
function getPayoutOverride(ticker, def) {
const v = parseFloat(load(`payout_${ticker}`, ''));
return (!isNaN(v) && v > 0) ? v : def;
}
// Item config overrides
function getItemQtyOverride(ticker, def) {
const v = parseInt(load(`itemqty_${ticker}`, ''), 10);
return (!isNaN(v) && v > 0) ? v : def;
}
function getItemNameOverride(ticker, def) {
const v = load(`itemname_${ticker}`, '');
return v ? v : def;
}
// Units override for energy/nerve/happy
function getUnitsOverride(ticker, def) {
const v = parseInt(load(`units_${ticker}`, ''), 10);
return (!isNaN(v) && v > 0) ? v : def;
}
// ─── API ──────────────────────────────────────────────────────────────────────
async function apiFetch(path, apiKey) {
const sep = path.includes('?') ? '&' : '?';
const resp = await fetch(`${API_BASE}${path}${sep}key=${apiKey}&comment=${SCRIPT}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (data.error) throw new Error(`API ${data.error.code}: ${data.error.error}`);
return data;
}
async function fetchItemPrice(apiKey, itemId) {
try {
const resp = await fetch(`https://api.torn.com/v2/market/${itemId}?selections=itemmarket&key=${apiKey}&comment=${SCRIPT}`);
const data = await resp.json();
if (!data.error) { const l = data.itemmarket || []; if (l.length) return l[0].cost || l[0].price || 0; }
} catch { /* fall through */ }
try {
const resp = await fetch(`https://api.torn.com/v1/market/${itemId}?selections=bazaar&key=${apiKey}&comment=${SCRIPT}`);
const data = await resp.json();
if (!data.error) { const l = data.bazaar || []; if (l.length) return l[0].cost || l[0].price || 0; }
} catch { /* fall through */ }
try {
const resp = await fetch(`https://api.torn.com/v1/torn/${itemId}?selections=items&key=${apiKey}&comment=${SCRIPT}`);
const data = await resp.json();
if (!data.error) { const item = (data.items || {})[itemId]; if (item?.market_value) return item.market_value; }
} catch { /* give up */ }
return 0;
}
async function resolveItemIds(apiKey, itemNames) {
const CACHE_KEY = 'item_id_cache', CACHE_TIME = 'item_id_cache_time', TTL = 86400000;
let nameToId = {};
try { nameToId = JSON.parse(load(CACHE_KEY, '{}')); } catch {}
const stale = (Date.now() - load(CACHE_TIME, 0)) > TTL;
if (!stale && itemNames.every(n => nameToId[n.toLowerCase()] !== undefined)) return nameToId;
try {
const resp = await fetch(`https://api.torn.com/v1/torn/?selections=items&key=${apiKey}&comment=${SCRIPT}`);
const data = await resp.json();
if (!data.error) {
for (const [id, item] of Object.entries(data.items || {}))
nameToId[(item.name||'').toLowerCase()] = parseInt(id, 10);
save(CACHE_KEY, JSON.stringify(nameToId));
save(CACHE_TIME, Date.now());
}
} catch (e) { console.warn('[TSA] item ID fetch failed:', e); }
return nameToId;
}
async function fetchUserStocks(apiKey) {
const [userData, tornData] = await Promise.all([
apiFetch('/user/stocks', apiKey),
apiFetch('/torn/stocks', apiKey),
]);
const nameToTicker = {};
for (const s of STOCK_DATA) nameToTicker[s.name.toLowerCase()] = s.ticker;
const idToTicker = {}, prices = {};
const tornArr = Array.isArray(tornData.stocks)
? tornData.stocks
: Object.entries(tornData.stocks || {}).map(([id,s]) => ({...s, _id:id}));
for (const s of tornArr) {
const id = String(s.id || s._id || '');
const ticker = (s.acronym||'').toUpperCase() || nameToTicker[(s.name||'').toLowerCase()] || '';
if (id && ticker) idToTicker[id] = ticker;
const mkt = (typeof s.market === 'object' && s.market) ? s.market : {};
const price = parseFloat(mkt.price || mkt.current_price || s.price || s.current_price || 0);
if (ticker && price > 0) prices[ticker] = price;
}
for (const [id,tkr] of Object.entries(idToTicker)) tickerToId[tkr] = id;
// DOM price supplement
for (const row of document.querySelectorAll('#stockmarketroot table tr')) {
const cells = [...row.querySelectorAll('td')];
if (cells.length < 2) continue;
for (let i = 0; i < cells.length - 1; i++) {
const m = (cells[i].textContent||'').match(/\(([A-Z]{2,4})\)/);
if (!m) continue;
const nm = (cells[i+1]?.textContent||'').replace(/,/g,'').match(/[\d]+\.?\d*/);
if (nm) { const p = parseFloat(nm[0]); if (p > 0) prices[m[1]] = p; }
break;
}
}
const holdings = {}, bonusInfo = {};
const userArr = Array.isArray(userData.stocks)
? userData.stocks
: Object.entries(userData.stocks || {}).map(([id,s]) => ({...s, _id:id}));
for (const s of userArr) {
const ticker = idToTicker[String(s.id || s._id || '')];
if (!ticker) continue;
const n = typeof s.shares === 'number' ? s.shares : 0;
if (n > 0) holdings[ticker] = (holdings[ticker] || 0) + n;
if (s.bonus && typeof s.bonus === 'object') {
bonusInfo[ticker] = { available: !!s.bonus.available, progress: s.bonus.progress||0, frequency: s.bonus.frequency||0 };
}
}
// DOM holdings fallback
if (!Object.keys(holdings).length) {
for (const row of document.querySelectorAll('#stockmarketroot table tr')) {
const cells = row.querySelectorAll('td');
if (cells.length < 4) continue;
const tm = (cells[0]?.textContent||'').match(/\(([A-Z]{2,4})\)/);
if (!tm) continue;
const nums = (cells[3]?.textContent||'').replace(/[$,]/g,'').match(/\d+/g);
if (nums?.length >= 2) { const sh = parseInt(nums[nums.length-1],10); if (sh > 0) holdings[tm[1]] = sh; }
}
}
// ── DOM dividend reader ────────────────────────────────────────────────────
// Reads cash dividend column on stocks page: total shown / heldBlocks = per-block value.
// GRN example: DOM shows $12M for 3 blocks -> $4M/block cached.
const DOM_KEY = 'dom_dividends', DOM_TS = 'dom_dividends_ts', DOM_TTL = 86400000;
let domDividends = {};
try {
const cached = JSON.parse(load(DOM_KEY, '{}'));
if ((Date.now() - load(DOM_TS, 0)) < DOM_TTL) domDividends = cached;
} catch {}
if (location.href.includes('sid=stocks')) {
const fresh = {};
for (const row of document.querySelectorAll('#stockmarketroot table tr')) {
const cells = [...row.querySelectorAll('td')];
if (cells.length < 4) continue;
const tm = (cells[0]?.textContent||'').match(/\(([A-Z]{2,4})\)/);
if (!tm) continue;
const ticker = tm[1];
const stockDef = STOCK_DATA.find(s => s.ticker === ticker);
if (!stockDef || stockDef.payoutType !== 'cash') continue;
const dm = (cells[3]?.textContent||'').replace(/,/g,'').match(/\$(\d+)/);
if (!dm) continue;
const total = parseInt(dm[1], 10);
if (!total) continue;
const sh = holdings[ticker] || 0;
const b1 = getB1(ticker);
let heldBlocks = 0;
for (let n = 1; n <= 20; n++) {
if (sh >= (Math.pow(2,n)-1)*b1) heldBlocks = n; else break;
}
if (heldBlocks <= 0) continue;
const perBlock = Math.round(total / heldBlocks);
if (perBlock > 0) {
fresh[ticker] = perBlock;
console.log(`[TSA] DOM dividend: ${ticker} = ${fmtMoney(perBlock)}/block (${fmtMoney(total)} / ${heldBlocks} blocks)`);
}
}
if (Object.keys(fresh).length) {
domDividends = { ...domDividends, ...fresh };
try { save(DOM_KEY, JSON.stringify(domDividends)); save(DOM_TS, Date.now()); } catch {}
}
}
return { holdings, prices, bonusInfo, domDividends };
}
// ─── Scoring ──────────────────────────────────────────────────────────────────
async function buildScores(apiKey) {
const { holdings, prices, bonusInfo, domDividends } = await fetchUserStocks(apiKey);
// Resolve item IDs — use config overrides for name if set
const itemStocks = STOCK_DATA.filter(s => s.payoutType === 'item');
const itemNames = [...new Set(itemStocks.map(s => getItemNameOverride(s.ticker, s.payoutItemName)).filter(Boolean))];
const nameToId = await resolveItemIds(apiKey, itemNames);
const resolvedIds = {};
for (const s of itemStocks) {
const name = getItemNameOverride(s.ticker, s.payoutItemName);
resolvedIds[s.ticker] = nameToId[name?.toLowerCase()] || s.payoutItemId;
}
const uniqueIds = [...new Set(Object.values(resolvedIds))].filter(Boolean);
const itemPrices = {};
const [, pointsPrice] = await Promise.all([
Promise.all(uniqueIds.map(async id => { itemPrices[id] = await fetchItemPrice(apiKey, id); })),
(async () => {
try {
const resp = await fetch(`https://api.torn.com/market/?selections=pointsmarket&key=${apiKey}&comment=${SCRIPT}`);
const data = await resp.json();
if (data.error) return 0;
const listings = data.pointsmarket || {};
let cheapest = 0;
for (const id of Object.keys(listings)) {
const cost = listings[id].cost;
if (cost && cost > 1000 && (!cheapest || cost < cheapest)) cheapest = cost;
}
return cheapest;
} catch { return 0; }
})(),
]);
const ignoredTickers = getIgnored();
const excludeMap = {
nerve: getExNerve(), energy: getExEnergy(), happy: getExHappy(),
other: getExOther(), passive: getExPassive(),
};
const rows = [];
for (const stock of STOCK_DATA) {
const { ticker, name, payoutType, payoutInterval, payoutDesc } = stock;
const ignored = ignoredTickers.includes(ticker);
const excluded = excludeMap[payoutType] || false;
const sharesHeld = holdings[ticker] || 0;
const price = prices[ticker] || 0;
// Determine incrValue (per block per interval)
let incrValue = 0;
if (payoutType === 'item') {
const qty = getItemQtyOverride(ticker, stock.payoutQty || 1);
const resolvedId = resolvedIds[ticker] || stock.payoutItemId;
const unitPrice = resolvedId ? (itemPrices[resolvedId] || 0) : 0;
incrValue = getPayoutOverride(ticker, unitPrice * qty);
} else if (payoutType === 'points') {
// Points payout: qty points × live market price per point.
// Falls back to payoutCashValue if market price unavailable.
const qty = stock.payoutQty || 100;
incrValue = getPayoutOverride(ticker, pointsPrice > 0 ? pointsPrice * qty : (stock.payoutCashValue || 0));
} else if (payoutType === 'cash') {
// Use DOM-read value if available, otherwise fall back to the stock's
// default payoutCashValue (set in STOCK_DATA), then user config override.
const domVal = domDividends[ticker] || 0;
incrValue = getPayoutOverride(ticker, domVal > 0 ? domVal : (stock.payoutCashValue || 0));
} else {
incrValue = getPayoutOverride(ticker, stock.payoutCashValue || 0);
}
const dailyValue = payoutInterval > 0 ? incrValue / payoutInterval : 0;
// Build increments dynamically using doubling rule
// Visibility: show held blocks + up to 2 ahead (n+2 rule)
const b1 = getB1(ticker);
const max = getMaxBlocks(ticker, sharesHeld > 0 ? (() => {
let hb = 0;
for (let n = 1; n <= 50; n++) {
if (sharesHeld >= (Math.pow(2,n)-1)*b1) hb = n; else break;
}
return hb;
})() : 0);
const increments = buildIncrements(ticker, (() => {
let hb = 0;
for (let n = 1; n <= 50; n++) {
if (sharesHeld >= (Math.pow(2,n)-1)*b1) hb = n; else break;
}
return hb;
})());
// Count how many complete blocks this user holds
let heldBlocks = 0;
for (const { threshold } of increments) {
if (sharesHeld >= threshold) heldBlocks++; else break;
}
// n+2 visibility: show held blocks + 2 ahead (never hide held blocks)
const visibleUpTo = heldBlocks + 2;
for (let i = 0; i < increments.length; i++) {
const { incr, threshold } = increments[i];
const prevThresh = i > 0 ? increments[i-1].threshold : 0;
const incrShares = threshold - prevThresh;
const incrCost = incrShares * price;
const sharesNeeded = Math.max(0, threshold - sharesHeld);
const costToComplete = sharesNeeded * price;
let status;
if (ignored) status = 'ignored';
else if (sharesHeld >= threshold) status = 'held';
else if (sharesHeld > prevThresh) status = 'partial';
else status = 'next';
// Visibility: hide blocks beyond n+2 (but never hide held)
const beyondVisible = incr > visibleUpTo && status !== 'held';
const budget = getBudget();
const budgetMax = budget > 0 ? budget * (getBudgetPct()/100) : Infinity;
// overBudget: true if cost exceeds budget threshold.
// Also true when budget=0 and mode is 'hide' — zero cash means nothing is affordable.
const overBudget = budget > 0
? costToComplete > budgetMax
: (getBudgetMode() === 'hide' && costToComplete > 0);
const scoreable = !ignored && !excluded && status !== 'held' && !beyondVisible && dailyValue > 0 && incrCost > 0;
// For partial blocks, ROI is based on costToComplete (what you still need to spend),
// not incrCost (the full block cost). This correctly rewards nearly-finished partials.
// For fresh blocks costToComplete === incrCost so there's no difference.
const roiDenominator = (scoreable && status === 'partial' && costToComplete > 0) ? costToComplete : incrCost;
const dailyROI = scoreable ? dailyValue / roiDenominator : 0;
rows.push({
ticker, name, incr, threshold, prevThresh, incrShares, incrCost,
sharesHeld, sharesNeeded, costToComplete, heldBlocks,
price, payoutType, payoutInterval, payoutDesc,
incrValue, dailyValue, b1,
status, ignored, excluded, scoreable, dailyROI, overBudget, beyondVisible,
score: 0,
});
}
}
// Normalise scores 0-10
const scoreables = rows.filter(r => r.scoreable);
const maxROI = scoreables.length ? Math.max(...scoreables.map(r => r.dailyROI)) : 1;
for (const r of rows) {
r.score = (r.scoreable && maxROI > 0) ? Math.round((r.dailyROI / maxROI) * 100) / 10 : 0;
}
rows.sort((a, b) => {
if (a.scoreable && b.scoreable) return b.score - a.score;
if (a.scoreable) return -1;
if (b.scoreable) return 1;
if (a.status === 'held' && b.status !== 'held') return -1;
if (a.status !== 'held' && b.status === 'held') return 1;
return 0;
});
return { rows, holdings, prices, bonusInfo };
}
// ─── Swap advisor engine ─────────────────────────────────────────────────────
function fmtNextPayout(info) {
if (!info) return null;
if (info.available) return '<span style="color:var(--tsa-status-ok);font-weight:bold">Ready now</span>';
if (!info.frequency || info.frequency <= 0) return null;
const d = info.frequency - info.progress;
if (d <= 0) return '<span style="color:var(--tsa-status-ok);font-weight:bold">Ready now</span>';
return `<span style="color:var(--tsa-status-warn)">Next in ${d}d</span> <span style="color:var(--tsa-text-muted);font-size:10px">(${info.progress}/${info.frequency}d)</span>`;
}
function buildSwaps(rows) {
const noSell = getSwapNoSell();
const availCash = getAvailableCash();
// ── Sell candidates ────────────────────────────────────────────────────────
// Complete blocks the user holds (and is willing to sell)
const heldByTicker = {};
for (const r of rows.filter(r => r.status==='held' && !r.ignored && r.dailyValue>0 && !noSell.includes(r.ticker))) {
if (!heldByTicker[r.ticker]) heldByTicker[r.ticker] = [];
heldByTicker[r.ticker].push(r);
}
// Partial positions the user holds (no complete block payout — selling loses nothing daily)
const partialRows = rows.filter(r => r.status==='partial' && !r.ignored && !noSell.includes(r.ticker));
// ── Buy targets ────────────────────────────────────────────────────────────
const targets = rows.filter(r => r.scoreable && r.score>0 && r.costToComplete>0 && r.dailyValue>0);
// ── Swap builder ───────────────────────────────────────────────────────────
// cashReleased: from selling. availCash: cash in hand. Combined = total available.
// netCostForTarget: how much selling must cover after available cash contributes.
function makeSwap(sellTicker, sellName, sellType, cashReleased, dailyLost, interval, target, extras=[]) {
if (target.ticker === sellTicker) return null;
// Available cash reduces how much the sell needs to cover
const netCostForTarget = Math.max(0, target.costToComplete - availCash);
if (netCostForTarget > cashReleased) return null;
const netDailyGain = target.dailyValue - dailyLost;
if (netDailyGain <= 0) return null;
const transCost = dailyLost * (interval || 1);
const availCashUsed = Math.max(0, Math.min(availCash, target.costToComplete - cashReleased));
const tier = dailyLost > 0 ? 2 : 1;
return {
sellTicker, sellName, sellType, cashReleased, dailyLost, transCost,
leftoverCash: cashReleased + availCash - target.costToComplete,
availCashUsed, tier, target, netDailyGain,
paybackDays: transCost > 0 ? Math.ceil(transCost / netDailyGain) : 0,
extraSells: extras, combined: false,
};
}
const swaps = [], sellProfiles = [];
// ── Tier 0: cash-only — no selling required ───────────────────────────────
// Only considers blocks that are immediately actionable:
// - partial (already started) or the direct next block (incr === heldBlocks + 1)
// This prevents showing B2 as a direct buy when B1 isn't held yet.
//
// Chained purchases: if buying B1 still leaves enough cash for B2, show B2
// as a separate entry with the reduced remaining cash. Simulates greedy
// sequential buying — pick best-ROI affordable block, subtract its cost,
// repeat until cash runs out.
if (availCash > 0) {
// Only immediately actionable targets — next direct block or partial
const immediateTargets = targets.filter(t =>
t.costToComplete <= availCash &&
(t.status === 'partial' || t.incr === t.heldBlocks + 1)
);
// Greedy simulation: spend cash on highest-ROI affordable blocks in sequence.
// Each purchase reduces remaining cash; re-check affordability after each pick.
let remainingCash = availCash;
const picked = [];
const candidatePool = [...immediateTargets].sort((a,b) => b.dailyROI - a.dailyROI);
while (remainingCash > 0 && candidatePool.length > 0) {
// Find best affordable block not already picked
const idx = candidatePool.findIndex(t =>
t.costToComplete <= remainingCash &&
!picked.some(p => p.ticker === t.ticker && p.incr === t.incr)
);
if (idx === -1) break;
const t = candidatePool.splice(idx, 1)[0];
picked.push(t);
remainingCash -= t.costToComplete;
swaps.push({
sellTicker: '__CASH__', sellName: 'Available cash', sellType: 'cash',
cashReleased: 0, dailyLost: 0, transCost: 0,
leftoverCash: remainingCash,
availCashUsed: t.costToComplete,
tier: 0, target: t, netDailyGain: t.dailyValue,
paybackDays: 0, extraSells: [], combined: false,
});
}
}
// ── Tier 1: partial sell — shares held with no completed payout ───────────
// (handled in partialRows loop below)
// Targets already affordable with cash alone — no point suggesting a sell to fund them
const cashAffordable = new Set(targets.filter(t => availCash > 0 && t.costToComplete <= availCash).map(t => `${t.ticker}:${t.incr}`));
// ── Tier 2: complete block sells (full position or sell-down one block) ───
// ── Full sell + sell-down for complete blocks ──────────────────────────────
for (const [sellTicker, held] of Object.entries(heldByTicker)) {
const sorted = [...held].sort((a,b) => b.incr - a.incr);
const top = sorted[0];
if (!top.price || top.price <= 0) continue;
const cashFull = top.sharesHeld * top.price;
const lostFull = held.reduce((s,r) => s + r.dailyValue, 0);
const maxInt = Math.max(...held.map(r => r.payoutInterval || 0));
// Full sell — exclude targets already buyable with cash alone
swaps.push(...targets
.filter(t => !cashAffordable.has(`${t.ticker}:${t.incr}`))
.map(t => makeSwap(sellTicker, top.name, 'full', cashFull, lostFull, maxInt, t))
.filter(Boolean).sort((a,b) => b.netDailyGain - a.netDailyGain).slice(0, 2));
sellProfiles.push({ ticker: sellTicker, name: top.name, cash: cashFull, dailyLost: lostFull, interval: maxInt });
// FIX: Sell-down — sell only shares above the highest COMPLETE block threshold.
// Find the highest complete block held (that's the block we'd drop FROM).
// Sell-down lands one block lower, releasing only the shares that block needed.
if (held.length > 0) {
// The highest complete block's threshold is top.threshold.
// The block below it is top.prevThresh.
// Shares to sell = sharesHeld - top.threshold (excess above the block we're keeping).
// But if sharesHeld === top.threshold exactly (no excess), sell the whole top block:
// shares to sell = top.threshold - top.prevThresh
const excessAboveTopBlock = top.sharesHeld - top.threshold;
const sharesToSell = excessAboveTopBlock >= 0
? (top.threshold - top.prevThresh) + excessAboveTopBlock // sell full top block + any excess
: top.sharesHeld - top.prevThresh; // fallback (shouldn't happen)
const cashDn = sharesToSell * top.price;
if (cashDn > 0 && top.prevThresh > 0) {
swaps.push(...targets
.filter(t => !cashAffordable.has(`${t.ticker}:${t.incr}`))
.map(t => makeSwap(sellTicker, top.name, 'down', cashDn, top.dailyValue, top.payoutInterval || 1, t))
.filter(Boolean).sort((a,b) => b.netDailyGain - a.netDailyGain).slice(0, 2));
}
}
}
// ── FIX: Partial sell candidates ──────────────────────────────────────────
// Selling a partial position loses no daily value (no complete block = no payout).
// The full partial sell value is sharesHeld × price.
for (const r of partialRows) {
if (!r.price || r.price <= 0) continue;
const cashPartial = r.sharesHeld * r.price;
if (cashPartial <= 0) continue;
swaps.push(...targets
.filter(t => t.ticker !== r.ticker && !cashAffordable.has(`${t.ticker}:${t.incr}`))
.map(t => {
const netCostForTarget = Math.max(0, t.costToComplete - availCash);
if (netCostForTarget > cashPartial) return null;
const leftover = cashPartial + availCash - t.costToComplete;
// No daily value lost from selling a partial — 0 payout to begin with
return {
sellTicker: r.ticker, sellName: r.name, sellType: 'partial',
cashReleased: cashPartial, dailyLost: 0, transCost: 0,
leftoverCash: leftover, target: t,
netDailyGain: t.dailyValue, // no loss, pure gain
paybackDays: 0, // immediate — no payout cycle missed
extraSells: [], combined: false,
availCashUsed: Math.max(0, Math.min(availCash, t.costToComplete - cashPartial)),
};
})
.filter(Boolean).sort((a,b) => b.netDailyGain - a.netDailyGain).slice(0, 2));
}
// ── Combined sells (two complete-block stocks together) ───────────────────
for (let i = 0; i < Math.min(sellProfiles.length, 8); i++) {
for (let j = i+1; j < Math.min(sellProfiles.length, 8); j++) {
const pA = sellProfiles[i], pB = sellProfiles[j];
const cc = pA.cash + pB.cash;
const cl = pA.dailyLost + pB.dailyLost;
const ci = Math.max(pA.interval, pB.interval);
swaps.push(...targets
.filter(t => {
const netCost = Math.max(0, t.costToComplete - availCash);
return t.ticker !== pA.ticker && t.ticker !== pB.ticker
&& netCost > pA.cash && netCost > pB.cash && netCost <= cc;
})
.map(t => {
const net = t.dailyValue - cl;
if (net <= 0) return null;
const tc = cl * ci;
return {
sellTicker: `${pA.ticker}+${pB.ticker}`, sellName: `${pA.name} + ${pB.name}`,
sellType: 'combined', cashReleased: cc, dailyLost: cl, transCost: tc,
leftoverCash: cc + availCash - t.costToComplete, target: t,
netDailyGain: net, paybackDays: Math.ceil(tc / net),
extraSells: [pA.ticker, pB.ticker], combined: true,
availCashUsed: Math.max(0, Math.min(availCash, t.costToComplete - cc)),
};
})
.filter(Boolean).sort((a,b) => b.netDailyGain - a.netDailyGain).slice(0, 1));
}
}
// Primary: highest netDailyGain DESC (already penalises block sells via dailyLost).
// Tiebreaker: tier ascending (cash=0, partial=1, complete=2).
return swaps.sort((a,b) => (b.netDailyGain - a.netDailyGain) || (a.tier - b.tier));
}
// ─── Mobile detection ─────────────────────────────────────────────────────────
// Returns true if viewport width <= 480px (PDA / mobile)
function isMobile() {
return window.innerWidth <= 480;
}
// ─── Portfolio scoring engine ─────────────────────────────────────────────────
//
// Score = weighted combination of three factors (each 0-100):
// ROI efficiency (50%) — actual daily payout / theoretical max if all
// capital were in highest-ROI stock
// Idle capital (30%) — penalty for money in partial blocks earning nothing
// Data completeness(20%)— % of cash stocks with known payout values
//
// Final score 0-100 maps to grades F through A+
function gradeFromScore(score) {
if (score >= 92) return 'A+';
if (score >= 84) return 'A';
if (score >= 76) return 'A\u2013';
if (score >= 68) return 'B+';
if (score >= 60) return 'B';
if (score >= 52) return 'B\u2013';
if (score >= 44) return 'C+';
if (score >= 36) return 'C';
if (score >= 28) return 'C\u2013';
if (score >= 20) return 'D+';
if (score >= 12) return 'D';
if (score >= 4) return 'D\u2013';
return 'F';
}
function gradeColour(grade) {
if (grade.startsWith('A')) return 'var(--tsa-status-ok)';
if (grade.startsWith('B')) return 'var(--tsa-status-warn)';
if (grade.startsWith('C')) return 'var(--tsa-status-crit)';
return 'var(--tsa-text-muted)';
}
function calcPortfolioScore(rows, domDividends, swaps = []) {
const heldRows = rows.filter(r => r.status === 'held' && !r.ignored && !r.excluded);
const partialRows = rows.filter(r => r.status === 'partial' && !r.ignored);
const scoreableRows = rows.filter(r => r.scoreable && r.score > 0);
// ── Factor 1: ROI efficiency ──────────────────────────────────────────────
// How close is your current portfolio to being fully optimised?
// Formula: actualDaily / (actualDaily + bestAvailableNetGain)
// where bestAvailableNetGain = the best swap's net daily gain (what you'd
// gain by making your single best available move). If no moves exist you're
// at 100% — nothing left to improve. This fairly rewards portfolios that
// have already made good decisions, without penalising 31-day cash stocks
// or diversification choices.
const actualDaily = heldRows.reduce((s, r) => s + r.dailyValue, 0);
// Best available net gain: top swap netDailyGain (already feasibility-checked),
// or best scoreable block affordable with available cash.
// Do NOT include unaffordable blocks in bestCashGain — they contaminate the
// optimisation score with targets the user can't actually act on.
const bestSwapGain = swaps.length
? Math.max(0, ...swaps.map(s => s.netDailyGain || 0))
: 0;
const availCash = getAvailableCash();
const affordableRows = availCash > 0
? scoreableRows.filter(r => r.costToComplete <= availCash)
: [];
const bestCashGain = affordableRows.length
? Math.max(0, ...affordableRows.map(r => r.dailyValue))
: 0;
const bestAvailableNetGain = Math.max(bestSwapGain, bestCashGain);
const roiEfficiency = actualDaily <= 0
? 0
: bestAvailableNetGain <= 0
? 100 // nothing better available — fully optimised
: Math.min(100, (actualDaily / (actualDaily + bestAvailableNetGain)) * 100);
// ── Factor 2: Idle capital penalty ───────────────────────────────────────
// Partial blocks have capital deployed but earning nothing
const totalInvested = heldRows.reduce((s, r) => s + r.incrCost, 0);
const idleCapital = partialRows.reduce((s, r) => {
const spent = (r.sharesHeld - r.prevThresh) * r.price;
return s + spent;
}, 0);
const totalDeployed = totalInvested + idleCapital;
const idlePct = totalDeployed > 0 ? (idleCapital / totalDeployed) * 100 : 0;
// 0% idle = score 100, 50%+ idle = score 0
const idleScore = Math.max(0, 100 - (idlePct * 2));
// ── Factor 3: Data completeness ───────────────────────────────────────────
// How many held cash stocks have a known payout value.
// Resolution order: user config override → DOM-read → STOCK_DATA default.
const heldCashTickers = [...new Set(heldRows
.filter(r => r.payoutType === 'cash')
.map(r => r.ticker))];
const effectiveCashValue = t => {
const stock = STOCK_DATA.find(s => s.ticker === t);
const domVal = domDividends?.[t] || 0;
return getPayoutOverride(t, domVal > 0 ? domVal : (stock?.payoutCashValue || 0));
};
const resolvedCash = heldCashTickers.filter(t => effectiveCashValue(t) > 0).length;
const unresolvedCashTickers = heldCashTickers.filter(t => effectiveCashValue(t) <= 0);
const dataScore = heldCashTickers.length > 0
? (resolvedCash / heldCashTickers.length) * 100
: 100; // no cash stocks = no penalty
// ── Weighted total ────────────────────────────────────────────────────────
const score = Math.round(
(roiEfficiency * 0.5) +
(idleScore * 0.3) +
(dataScore * 0.2)
);
return {
score: Math.min(100, Math.max(0, score)),
grade: gradeFromScore(score),
roiEfficiency: Math.round(roiEfficiency),
idleScore: Math.round(idleScore),
dataScore: Math.round(dataScore),
actualDaily,
bestAvailableNetGain,
idleCapital,
idlePct: Math.round(idlePct),
heldCashTickers,
resolvedCash,
unresolvedCash: unresolvedCashTickers.length,
unresolvedCashTickers,
};
}
// Simulate score after applying a set of buy/sell actions from the swap advisor
// actions = array of { buy: {ticker, incr}, sell: {ticker} | null }
function simulateScore(rows, domDividends, actions) {
if (!actions || !actions.length) return null;
// Clone rows and apply actions
const simRows = rows.map(r => ({...r}));
for (const action of actions) {
// Apply sell: mark held blocks of sold ticker as not held
if (action.sellTicker && action.sellTicker !== '__CASH__') {
const tickers = action.sellTicker.includes('+')
? action.sellTicker.split('+')
: [action.sellTicker];
for (const ticker of tickers) {
simRows.forEach(r => {
if (r.ticker === ticker && r.status === 'held') r.status = 'sold_sim';
});
}
}
// Apply buy: mark target block as held, update dailyValue
if (action.target) {
const t = action.target;
simRows.forEach(r => {
if (r.ticker === t.ticker && r.incr === t.incr) {
r.status = 'held';
r.scoreable = false;
}
});
}
// Apply chain buys
if (action.chainBuys) {
for (const cb of action.chainBuys) {
simRows.forEach(r => {
if (r.ticker === cb.ticker && r.incr === cb.incr) {
r.status = 'held';
r.scoreable = false;
}
});
}
}
}
return calcPortfolioScore(simRows, domDividends);
}
// ─── Briefing builder ─────────────────────────────────────────────────────────
// Rule-based natural language summary. Assembles sentences from live data,
// varied with simple rotation so repeated refreshes don't feel identical.
function buildBriefing(rows, prices, bonusInfo, swaps, portfolioScore, projectedScore, domDividends) {
const heldRows = rows.filter(r => r.status === 'held' && !r.ignored && !r.excluded);
const partialRows = rows.filter(r => r.status === 'partial' && !r.ignored);
const totalDaily = heldRows.reduce((s, r) => s + r.dailyValue, 0);
const topSwap = swaps.find(s => s.sellType !== 'cash' && s.netDailyGain > 0);
const cashSwaps = swaps.filter(s => s.sellType === 'cash');
const availCash = getAvailableCash();
// Ready payouts
// Helper: is a ticker a passive stock (no collectable payout)?
const isPassiveTicker = t => {
const s = STOCK_DATA.find(d => d.ticker === t);
return !s || s.payoutType === 'passive' || s.payoutInterval === 0;
};
const readyNow = Object.entries(bonusInfo)
.filter(([ticker, info]) => info.available && !isPassiveTicker(ticker))
.map(([ticker]) => ticker);
// Upcoming payouts within 3 days
const soonPayouts = Object.entries(bonusInfo)
.filter(([ticker, info]) => !info.available && info.frequency > 0 && (info.frequency - info.progress) <= 3 && !isPassiveTicker(ticker))
.map(([ticker, info]) => ({ ticker, daysLeft: info.frequency - info.progress }))
.sort((a, b) => a.daysLeft - b.daysLeft);
// Best partial
const biggestPartial = partialRows.sort((a, b) => b.dailyValue - a.dailyValue)[0];
const parts = [];
// ── Opening: portfolio state ──────────────────────────────────────────────
if (totalDaily > 0) {
const blockCount = heldRows.length;
parts.push(`Your portfolio is generating ${fmtMoney(totalDaily)}/day across ${blockCount} active block${blockCount !== 1 ? 's' : ''}.`);
} else {
parts.push(`You don\u2019t have any active stock blocks yet \u2014 the swap advisor below will help you get started.`);
}
// ── Urgent: collect now ───────────────────────────────────────────────────
if (readyNow.length > 0) {
const tickers = readyNow.slice(0, 3).join(', ');
const more = readyNow.length > 3 ? ` and ${readyNow.length - 3} more` : '';
parts.push(`${tickers}${more} ${readyNow.length === 1 ? 'is' : 'are'} ready to collect right now \u2014 do that before making any moves.`);
}
// Mention upcoming payouts separately — even if some are already ready
if (soonPayouts.length > 0) {
const daysGroups = {};
for (const p of soonPayouts) {
const key = p.daysLeft === 1 ? 'tomorrow' : `in ${p.daysLeft} days`;
if (!daysGroups[key]) daysGroups[key] = [];
daysGroups[key].push(p.ticker);
}
for (const [when, tickers] of Object.entries(daysGroups)) {
const tickerStr = tickers.join(', ');
const verb = tickers.length === 1 ? 'pays out' : 'pay out';
const prefix = readyNow.length > 0 ? `${tickerStr} also ${verb}` : `${tickerStr} ${verb}`;
parts.push(`${prefix} ${when} \u2014 worth waiting to collect before selling any of these shares.`);
}
}
// ── Partial drag ──────────────────────────────────────────────────────────
if (biggestPartial) {
const pct = Math.round(((biggestPartial.sharesHeld - biggestPartial.prevThresh) / biggestPartial.incrShares) * 100);
const idle = (biggestPartial.sharesHeld - biggestPartial.prevThresh) * biggestPartial.price;
parts.push(`You have ${fmtMoney(idle)} invested in a ${biggestPartial.ticker} partial (${pct}% complete) that\u2019s earning nothing yet \u2014 completing it would unlock ${fmtMoney(biggestPartial.dailyValue)}/day.`);
}
// ── Best action ───────────────────────────────────────────────────────────
if (cashSwaps.length > 0 && availCash > 0) {
const best = cashSwaps[0];
parts.push(`With your ${fmtMoney(availCash)} available cash, the highest-ROI move right now is buying ${best.target.ticker} Block ${best.target.incr} \u2014 that\u2019s ${fmtMoney(best.target.dailyValue)}/day, available immediately.`);
} else if (topSwap) {
const pb = topSwap.paybackDays > 0 ? ` You\u2019ll be ahead after ${topSwap.paybackDays} days.` : '';
const cashHint = getAvailableCash() === 0 ? ' Set your available cash in Config to see more targeted options.' : '';
parts.push(`Your best swap is selling ${topSwap.sellTicker} to fund ${topSwap.target.ticker} Block ${topSwap.target.incr}, netting +${fmtMoney(topSwap.netDailyGain)}/day.${pb}${cashHint}`);
}
// ── Projected score delta ─────────────────────────────────────────────────
let scoreNote = '';
if (projectedScore && projectedScore.grade !== portfolioScore.grade) {
scoreNote = `Following these recommendations would lift your portfolio score from ${portfolioScore.grade} to ${projectedScore.grade}.`;
} else if (projectedScore && projectedScore.score > portfolioScore.score + 2) {
scoreNote = `Following these recommendations would improve your portfolio score from ${portfolioScore.score} to ${projectedScore.score} (still ${portfolioScore.grade}).`;
}
// ── Unresolved cash stocks warning ───────────────────────────────────────
if (portfolioScore.unresolvedCash > 0) {
const names = portfolioScore.unresolvedCashTickers.join(', ');
parts.push(`${portfolioScore.unresolvedCash} of your cash dividend stock${portfolioScore.unresolvedCash !== 1 ? 's' : ''} ${portfolioScore.unresolvedCash !== 1 ? 'have' : 'has'} no payout value set (${names}) \u2014 visit the stocks page to auto-read them and sharpen the rankings.`);
}
// ── Priority actions list ─────────────────────────────────────────────────
const actions = [];
if (readyNow.length > 0) {
actions.push({ text: `Collect ${readyNow.slice(0,3).join(', ')} payout${readyNow.length > 1 ? 's' : ''}`, val: 'Do first' });
}
if (soonPayouts.length > 0) {
const soonTickers = soonPayouts.slice(0, 3).map(p => p.ticker).join(', ');
const soonExtra = soonPayouts.length > 3 ? ` +${soonPayouts.length - 3} more` : '';
const next = soonPayouts[0];
const daysStr = next.daysLeft === 1 ? 'tomorrow' : `in ${next.daysLeft}d`;
actions.push({ text: `Wait for ${soonTickers}${soonExtra} payout${soonPayouts.length > 1 ? 's' : ''} (${daysStr})`, val: 'Before selling' });
}
if (cashSwaps.length > 0 && availCash > 0) {
const best = cashSwaps[0];
actions.push({ text: `Buy ${best.target.ticker} Block ${best.target.incr}`, val: `+${fmtMoney(best.target.dailyValue)}/day` });
} else if (topSwap) {
actions.push({ text: `Sell ${topSwap.sellTicker} \u2192 buy ${topSwap.target.ticker} B${topSwap.target.incr}`, val: `+${fmtMoney(topSwap.netDailyGain)}/day` });
}
if (biggestPartial && (!cashSwaps.length || availCash === 0)) {
actions.push({ text: `Top up ${biggestPartial.ticker} Block ${biggestPartial.incr}`, val: `+${fmtMoney(biggestPartial.dailyValue)}/day` });
}
if (portfolioScore.unresolvedCash > 0) {
actions.push({ text: 'Visit stocks page to read cash dividends', val: 'Improves scoring' });
}
return {
paragraphs: parts,
scoreNote,
actions: actions.slice(0, 4),
};
}
// ─── Render: briefing ─────────────────────────────────────────────────────────
function renderBriefing(root, rows, prices, bonusInfo, swaps, portfolioScore, projectedScore, domDividends) {
const el = root.querySelector('#tsa-briefing-content');
if (!el) return;
el.innerHTML = '';
const briefing = buildBriefing(rows, prices, bonusInfo, swaps, portfolioScore, projectedScore, domDividends);
// Paragraph block
const paraDiv = document.createElement('div');
paraDiv.style.cssText = 'font-size:12px;color:var(--tsa-text-secondary);line-height:1.7;margin-bottom:12px';
paraDiv.textContent = briefing.paragraphs.join(' ');
el.appendChild(paraDiv);
// Score delta note
if (briefing.scoreNote) {
const noteDiv = document.createElement('div');
noteDiv.style.cssText = 'font-size:11px;color:var(--tsa-phase-plan-text);background:var(--tsa-phase-plan-bg);border:1px solid var(--tsa-phase-plan-border);border-radius:4px;padding:6px 10px;margin-bottom:12px;line-height:1.5';
noteDiv.innerHTML = `🎯 ${briefing.scoreNote}`;
el.appendChild(noteDiv);
}
// Priority actions
if (briefing.actions.length) {
const actionsDiv = document.createElement('div');
actionsDiv.style.cssText = 'display:flex;flex-direction:column;gap:6px';
briefing.actions.forEach((a, i) => {
const row = document.createElement('div');
row.style.cssText = 'background:var(--tsa-bg-deep);border:1px solid var(--tsa-border-strip);border-radius:5px;padding:8px 10px;display:flex;align-items:center;gap:8px';
row.innerHTML = `
<span style="color:var(--tsa-accent);font-weight:bold;font-size:13px;min-width:18px">${i + 1}</span>
<span style="font-size:11px;color:var(--tsa-text-card);flex:1">${a.text}</span>
<span style="font-size:11px;color:var(--tsa-status-ok);font-weight:bold;white-space:nowrap">${a.val}</span>`;
actionsDiv.appendChild(row);
});
el.appendChild(actionsDiv);
}
}
// ─── Render: portfolio score ──────────────────────────────────────────────────
function renderPortfolioScore(root, portfolioScore, projectedScore) {
const el = root.querySelector('#tsa-score-content');
if (!el) return;
el.innerHTML = '';
const { score, grade, roiEfficiency, idleScore, dataScore,
actualDaily, bestAvailableNetGain, idleCapital, idlePct, unresolvedCash } = portfolioScore;
const gradeCol = gradeColour(grade);
// Grade + progress bar
const gradeDiv = document.createElement('div');
gradeDiv.style.cssText = 'background:var(--tsa-bg-deep);border:1px solid var(--tsa-border-strip);border-radius:6px;padding:12px 14px;display:flex;align-items:center;gap:16px;margin-bottom:10px';
const projHtml = projectedScore && projectedScore.grade !== grade
? `<span style="color:var(--tsa-text-muted);font-size:12px;margin:0 4px">\u2192</span><span style="font-size:28px;font-weight:bold;color:${gradeColour(projectedScore.grade)}">${projectedScore.grade}</span><span style="font-size:10px;color:var(--tsa-status-ok);margin-left:4px">if advice followed</span>`
: '';
gradeDiv.innerHTML = `
<span style="font-size:44px;font-weight:bold;color:${gradeCol};line-height:1">${grade}</span>
${projHtml}
<div style="flex:1">
<div style="font-size:12px;font-weight:bold;color:var(--tsa-text-card);margin-bottom:6px">${
score >= 76 ? 'Well optimised' :
score >= 52 ? 'Room for improvement' :
score >= 28 ? 'Significant gaps' : 'Needs attention'
}</div>
<div style="height:6px;background:var(--tsa-bg-card);border-radius:3px;overflow:hidden">
<div style="height:100%;width:${score}%;background:${gradeCol};border-radius:3px;transition:width .4s ease"></div>
</div>
<div style="display:flex;justify-content:space-between;font-size:9px;color:var(--tsa-text-muted);margin-top:3px">
<span>0</span><span style="color:${gradeCol}">${score} / 100</span><span>100</span>
</div>
</div>`;
el.appendChild(gradeDiv);
// Three factor scores
const metricsDiv = document.createElement('div');
metricsDiv.style.cssText = 'display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:10px';
const metrics = [
{ lbl: 'Daily payout', val: fmtMoney(actualDaily), sub: '/day from active blocks', col: 'var(--tsa-status-ok)' },
{ lbl: 'Optimisation',
val: roiEfficiency + '%',
sub: bestAvailableNetGain > 0
? `best available move: +${fmtMoney(bestAvailableNetGain)}/day${getAvailableCash() === 0 ? ' · set cash in Config for accuracy' : ''}`
: 'no better moves found',
col: roiEfficiency >= 80 ? 'var(--tsa-status-ok)' : roiEfficiency >= 60 ? 'var(--tsa-status-warn)' : 'var(--tsa-status-crit)' },
{ lbl: 'Idle capital', val: idleCapital > 0 ? fmtMoney(idleCapital) : 'None', sub: idleCapital > 0 ? `${idlePct}% of portfolio earning nothing` : 'No money stuck in partials', col: idleCapital > 0 ? 'var(--tsa-status-warn)' : 'var(--tsa-status-ok)' },
{ lbl: 'Data completeness', val: dataScore + '%', sub: unresolvedCash > 0 ? `${unresolvedCash} cash stock${unresolvedCash !== 1 ? 's' : ''} unresolved` : 'All cash stocks resolved', col: dataScore >= 100 ? 'var(--tsa-status-ok)' : dataScore >= 50 ? 'var(--tsa-status-warn)' : 'var(--tsa-status-crit)' },
];
for (const m of metrics) {
const card = document.createElement('div');
card.style.cssText = 'background:var(--tsa-bg-deep);border:1px solid var(--tsa-border-strip);border-radius:5px;padding:8px 10px';
card.innerHTML = `
<div style="font-size:9px;color:var(--tsa-text-muted);text-transform:uppercase;letter-spacing:.4px;margin-bottom:3px">${m.lbl}</div>
<div style="font-size:16px;font-weight:bold;color:${m.col}">${m.val}</div>
<div style="font-size:10px;color:var(--tsa-text-dead);margin-top:2px">${m.sub}</div>`;
metricsDiv.appendChild(card);
}
el.appendChild(metricsDiv);
// Insight: what's dragging the score down
const insights = [];
if (idleCapital > 0) insights.push(`<strong style="color:var(--tsa-text-card)">${fmtMoney(idleCapital)} idle</strong> in partial blocks — completing them would lift your idle score from ${idleScore} to 100.`);
if (unresolvedCash > 0) insights.push(`<strong style="color:var(--tsa-text-card)">${unresolvedCash} cash stock${unresolvedCash !== 1 ? 's' : ''}</strong> with no payout value — visit the stocks page to auto-read them.`);
if (roiEfficiency < 60) {
const cashNote = getAvailableCash() === 0
? ' Set your available cash in Config for a more accurate reading.'
: '';
insights.push(`<strong style="color:var(--tsa-text-card)">Best available move: +${fmtMoney(bestAvailableNetGain)}/day</strong> — check the swap advisor for the highest-impact reallocation.${cashNote}`);
}
if (!insights.length) insights.push(`<strong style="color:var(--tsa-text-card)">Looking good</strong> — keep an eye on new blocks as stock prices shift.`);
const insightDiv = document.createElement('div');
insightDiv.style.cssText = 'background:var(--tsa-bg-dark);border-left:3px solid var(--tsa-status-warn);border-radius:0 4px 4px 0;padding:10px 12px;font-size:11px;color:var(--tsa-text-secondary);line-height:1.6';
insightDiv.innerHTML = `<strong style="color:var(--tsa-text-card)">What to work on:</strong> ${insights.join(' ')}`;
el.appendChild(insightDiv);
}
// ─── Render: payout calendar ──────────────────────────────────────────────────
function renderCalendar(root, rows, bonusInfo) {
const el = root.querySelector('#tsa-calendar-content');
if (!el) return;
el.innerHTML = '';
// Build calendar entries from bonusInfo
const entries = [];
const heldTickers = [...new Set(rows.filter(r => r.status === 'held' && !r.ignored).map(r => r.ticker))];
for (const ticker of heldTickers) {
const info = bonusInfo[ticker];
const heldRows = rows.filter(r => r.ticker === ticker && r.status === 'held');
const totalDaily = heldRows.reduce((s, r) => s + r.dailyValue, 0);
const stock = STOCK_DATA.find(s => s.ticker === ticker);
if (!stock || stock.payoutType === 'passive' || stock.payoutInterval === 0) continue;
let daysLeft = null;
let ready = false;
if (info) {
if (info.available) {
ready = true;
daysLeft = 0;
} else if (info.frequency > 0) {
daysLeft = info.frequency - info.progress;
}
}
// Only show if we have timing data
if (daysLeft === null && !ready) continue;
entries.push({ ticker, name: stock.name, daysLeft, ready, totalDaily, interval: stock.payoutInterval, heldBlocks: heldRows.length });
}
// Sort: ready first, then by days ascending
entries.sort((a, b) => {
if (a.ready && !b.ready) return -1;
if (!a.ready && b.ready) return 1;
return (a.daysLeft || 0) - (b.daysLeft || 0);
});
if (!entries.length) {
el.innerHTML = `<div style="color:var(--tsa-text-muted);font-size:12px;padding:6px 0">No payout timing data available \u2014 data is read from the Torn API on each refresh.</div>`;
return;
}
// Summary bar
// Est. incoming = sum of actual per-cycle payout values for stocks due within 7 days.
// actual payout per cycle = totalDaily × interval (not × daysLeft)
const readyCount = entries.filter(e => e.ready).length;
const soonCount = entries.filter(e => !e.ready && e.daysLeft <= 7).length;
const next7d = entries
.filter(e => e.ready || (!e.ready && e.daysLeft <= 7))
.reduce((s, e) => s + (e.totalDaily * e.interval), 0);
const sumDiv = document.createElement('div');
sumDiv.style.cssText = 'background:var(--tsa-bg-deep);border:1px solid var(--tsa-border-strip);border-radius:5px;padding:8px 12px;margin-bottom:10px;display:flex;gap:20px;flex-wrap:wrap;font-size:11px;color:var(--tsa-text-muted)';
sumDiv.innerHTML = `
<span>Ready now: <strong style="color:var(--tsa-status-ok)">${readyCount}</strong></span>
<span>Within 7 days: <strong style="color:var(--tsa-status-warn)">${soonCount}</strong></span>
${next7d > 0 ? `<span>Est. incoming: <strong style="color:var(--tsa-accent)">${fmtMoney(next7d)}</strong></span>` : ''}`;
el.appendChild(sumDiv);
// Calendar rows — fixed-width columns for clean alignment
const isMob = isMobile();
const listDiv = document.createElement('div');
listDiv.style.cssText = 'display:flex;flex-direction:column;gap:4px';
// Visual group separator between soon (<=7d) and later (>7d)
let shownSoonSep = false;
let shownLaterSep = false;
for (const e of entries) {
const isReady = e.ready;
const isSoon = !e.ready && e.daysLeft <= 7;
const isLater = !e.ready && e.daysLeft > 7;
// Group label before first "later" entry
if (isLater && !shownLaterSep) {
shownLaterSep = true;
const sep = document.createElement('div');
sep.style.cssText = 'font-size:9px;color:var(--tsa-text-dead);text-transform:uppercase;letter-spacing:.5px;padding:6px 0 2px';
sep.textContent = 'Later';
listDiv.appendChild(sep);
} else if ((isReady || isSoon) && !shownSoonSep && readyCount === 0 && soonCount > 0) {
shownSoonSep = true;
}
const row = document.createElement('div');
row.style.cssText = `
display:grid;
grid-template-columns:48px 1fr 110px ${isMob ? '' : '160px'};
align-items:center;gap:0 10px;padding:8px 10px;
background:${isReady ? 'var(--tsa-status-ok-bg)' : isSoon ? 'var(--tsa-status-warn-bg)' : 'var(--tsa-bg-deep)'};
border:1px solid ${isReady ? 'var(--tsa-status-ok-border)' : isSoon ? 'var(--tsa-status-warn-border)' : 'var(--tsa-border-faint)'};
border-radius:5px`;
const countdownText = isReady ? 'Collect now'
: e.daysLeft === 1 ? 'Tomorrow'
: `In ${e.daysLeft} days`;
const countdownCol = isReady ? 'var(--tsa-status-ok)'
: isSoon ? 'var(--tsa-status-warn)'
: 'var(--tsa-text-label)';
// Show actual payout value per cycle, not daily
const cycleValue = e.totalDaily * e.interval;
const payoutText = cycleValue > 0
? `${fmtMoney(cycleValue)} · ${e.heldBlocks} block${e.heldBlocks !== 1 ? 's' : ''} · every ${e.interval}d`
: `${e.heldBlocks} block${e.heldBlocks !== 1 ? 's' : ''} · every ${e.interval}d`;
row.innerHTML = `
<span class="tsa-ticker">${e.ticker}</span>
<span style="color:var(--tsa-text-card);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${e.name}</span>
<span style="font-size:11px;font-weight:bold;color:${countdownCol};text-align:right">${countdownText}</span>
${isMob ? '' : `<span style="font-size:10px;color:var(--tsa-text-secondary);text-align:right;white-space:nowrap">${payoutText}</span>`}`;
listDiv.appendChild(row);
}
el.appendChild(listDiv);
// Legend
const legendDiv = document.createElement('div');
legendDiv.style.cssText = 'display:flex;gap:14px;margin-top:8px;font-size:10px;color:var(--tsa-text-muted)';
legendDiv.innerHTML = `
<span><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--tsa-status-ok);margin-right:4px;vertical-align:middle"></span>Ready</span>
<span><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--tsa-status-warn);margin-right:4px;vertical-align:middle"></span>Within 7 days</span>
<span><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--tsa-accent);margin-right:4px;vertical-align:middle"></span>Later</span>`;
el.appendChild(legendDiv);
}
// ─── UI build ─────────────────────────────────────────────────────────────────
function buildUI() {
const root = document.createElement('div');
root.id = 'tsa-root';
if (load('dashboard_collapsed','0')==='1') root.classList.add('collapsed');
root.innerHTML = `
<div id="tsa-header">
<span class="tsa-title">Torn Stock Advisor <span class="tsa-version">v3.1.6</span></span>
<span id="tsa-updated" class="tsa-updated">Not loaded</span>
<button class="tsa-btn-secondary" id="tsa-cfg-toggle">⚙ Config</button>
<button class="tsa-btn-primary" id="tsa-refresh-btn">↻ Refresh</button>
<button class="tsa-btn-secondary" id="tsa-collapse-btn">${load('dashboard_collapsed','0')==='1'?'▼':'▲'}</button>
</div>
<div id="tsa-config-strip">
<span>API Key: <span id="tsa-key-status" class="tsa-key-bad">✗ Not set</span></span>
<span class="tsa-sep">|</span>
<span>Theme: <span id="tsa-strip-theme" style="color:var(--tsa-accent)">Default</span></span>
<span class="tsa-sep">|</span>
<span>Ignored: <span id="tsa-strip-ignored" style="color:var(--tsa-accent)">None</span></span>
<span class="tsa-sep">|</span>
<span>Excl: <span id="tsa-strip-excl" style="color:var(--tsa-accent)">None</span></span>
<span class="tsa-sep">|</span>
<span id="tsa-strip-budget-wrap">Cash: <span id="tsa-strip-budget" style="color:var(--tsa-accent)">Off</span></span>
<span class="tsa-sep">|</span>
<span id="tsa-strip-nosell-wrap" style="display:none">No sell: <span id="tsa-strip-nosell" style="color:var(--tsa-accent)"></span></span>
<span class="tsa-sep" id="tsa-strip-nosell-sep" style="display:none">|</span>
<span>Refresh: <span id="tsa-strip-refresh" style="color:var(--tsa-text-secondary)">5 min</span></span>
</div>
<div id="tsa-config-panel">
<span class="tsa-cfg-label">Theme</span>
<div class="tsa-cfg-row">
<label>Colour theme</label>
<select class="tsa-cfg-input" id="tsa-cfg-theme" style="width:220px">
<option value="default">Default (Dark Blue)</option>
<option value="torn">Torn Classic</option>
<option value="highcontrast">High Contrast (WCAG AAA)</option>
<option value="deuteranopia">Deuteranopia (Red/Green CB)</option>
<option value="protanopia">Protanopia (Red Deficiency)</option>
<option value="tritanopia">Tritanopia (Blue/Yellow CB)</option>
<option value="lowvision">Low Vision</option>
<option value="light">Light Mode</option>
</select>
</div>
<span class="tsa-cfg-label">API Key — requires <strong style="color:var(--tsa-accent)">Stocks</strong> access level</span>
<div class="tsa-cfg-row">
<label>Torn API Key</label>
<input class="tsa-cfg-input" id="tsa-cfg-apikey" type="password" placeholder="Enter API key…" style="width:220px" />
<button class="tsa-btn-primary" id="tsa-cfg-save-key">Save Key</button>
</div>
<div class="tsa-cfg-note">Stored locally only — never sent except to api.torn.com.</div>
<span class="tsa-cfg-label">Ignore stocks (comma-separated tickers)</span>
<div class="tsa-cfg-row">
<label>Ignored tickers</label>
<input class="tsa-cfg-input" id="tsa-cfg-ignored" type="text" placeholder="e.g. TCT, GRN" style="width:220px" />
</div>
<span class="tsa-cfg-label">Never sell (comma-separated tickers — excluded from Swap Advisor)</span>
<div class="tsa-cfg-row">
<label>Never sell</label>
<input class="tsa-cfg-input" id="tsa-cfg-swap-no-sell" type="text" placeholder="e.g. TMI, FHG" style="width:220px" />
</div>
<span class="tsa-cfg-label">Exclude payout types from scoring</span>
<div class="tsa-cfg-check-row"><input type="checkbox" id="tsa-ex-nerve"> <label for="tsa-ex-nerve">Nerve payouts (CBD)</label></div>
<div class="tsa-cfg-check-row"><input type="checkbox" id="tsa-ex-energy"> <label for="tsa-ex-energy">Energy payouts (MCS)</label></div>
<div class="tsa-cfg-check-row"><input type="checkbox" id="tsa-ex-happy"> <label for="tsa-ex-happy">Happiness payouts (EVL)</label></div>
<div class="tsa-cfg-check-row"><input type="checkbox" id="tsa-ex-other"> <label for="tsa-ex-other">Other non-item payouts (PTS, HRG)</label></div>
<div class="tsa-cfg-check-row"><input type="checkbox" id="tsa-ex-passive"> <label for="tsa-ex-passive">Passive stocks (TCP, TCI, WLT…)</label></div>
<span class="tsa-cfg-label">Savings goal</span>
<div class="tsa-cfg-row">
<label>Goal stock & block</label>
<select class="tsa-cfg-input" id="tsa-cfg-savings-goal" style="width:280px">
<option value="">— No goal set —</option>
</select>
</div>
<span class="tsa-cfg-label">Available cash ($)
<span style="font-size:9px;color:var(--tsa-text-dead);text-transform:none;font-weight:normal;margin-left:6px">
Your current liquid cash balance. Used everywhere: filters recommendations by affordability,
reduces how much selling is needed in Swap Advisor, tracks Savings Plan progress,
and surfaces partial blocks you can complete right now.
</span>
</span>
<div class="tsa-cfg-row">
<label>Available cash ($)</label>
<input class="tsa-cfg-input" id="tsa-cfg-budget" type="number" min="0" placeholder="e.g. 225000000" style="width:180px" />
</div>
<div class="tsa-cfg-row">
<label>Max cost (% of cash)</label>
<input class="tsa-cfg-input" id="tsa-cfg-budget-pct" type="number" min="100" max="500" style="width:70px" />
<span style="font-size:10px;color:var(--tsa-text-muted)">e.g. 110 = show blocks up to 10% above your cash</span>
</div>
<div class="tsa-cfg-row">
<label>Over-budget blocks</label>
<select class="tsa-cfg-input" id="tsa-cfg-budget-mode" style="width:200px">
<option value="grey">Grey out (still visible)</option>
<option value="hide">Hide from recommendations</option>
<option value="show">Show all (filter off)</option>
</select>
</div>
<span class="tsa-cfg-label">Display</span>
<div class="tsa-cfg-row">
<label>Recommendation cards to show</label>
<input class="tsa-cfg-input" id="tsa-cfg-topn" type="number" min="1" max="20" style="width:60px" />
</div>
<div class="tsa-cfg-row">
<label>Auto-refresh (minutes)</label>
<input class="tsa-cfg-input" id="tsa-cfg-refresh" type="number" min="1" max="60" style="width:60px" />
</div>
<span class="tsa-cfg-label">Per-stock overrides & block structure
<span style="font-size:9px;color:var(--tsa-text-dead);text-transform:none;margin-left:6px">
B1 = first block share count. All higher blocks derive automatically (2^n-1)*B1.
Payout = $ per block per interval. Item stocks auto-price via live market.
</span>
</span>
<div id="tsa-cfg-accordions"></div>
<div style="margin-top:10px;display:flex;gap:8px">
<button class="tsa-btn-primary" id="tsa-cfg-save-all">Save All Config</button>
<button class="tsa-btn-secondary" id="tsa-cfg-reset">Reset Defaults</button>
</div>
</div>
<div id="tsa-stats-bar">
<div class="tsa-stat"><span class="tsa-stat-label">Active Blocks</span><span class="tsa-stat-value" id="tsa-s-active">—</span></div>
<div class="tsa-stat"><span class="tsa-stat-label">Partial</span><span class="tsa-stat-value" id="tsa-s-partial" style="color:var(--tsa-status-warn)">—</span></div>
<div class="tsa-stat"><span class="tsa-stat-label">Daily Payout</span><span class="tsa-stat-value" id="tsa-s-daily">—</span></div>
<div class="tsa-stat"><span class="tsa-stat-label">Best Daily ROI</span><span class="tsa-stat-value" id="tsa-s-roi">—</span></div>
<div class="tsa-stat"><span class="tsa-stat-label">Stocks Tracked</span><span class="tsa-stat-value">${STOCK_DATA.length}</span></div>
</div>
<div id="tsa-body">
<div id="tsa-error"></div>
<div id="tsa-loading"><span class="tsa-spinner"></span>Loading stock data…</div>
<div id="tsa-content" style="display:none">
<div id="tsa-savings-banner" style="display:none"></div>
<div class="tsa-section" id="tsa-briefing-section">
<div class="tsa-section-title" id="tsa-briefing-title">⚡ Today’s briefing</div>
<div id="tsa-briefing-content" class="tsa-section-content-inner"></div>
</div>
<div class="tsa-section" id="tsa-score-section">
<div class="tsa-section-title" id="tsa-score-title">📊 Portfolio score</div>
<div id="tsa-score-content" class="tsa-section-content-inner"></div>
</div>
<div class="tsa-section" id="tsa-calendar-section">
<div class="tsa-section-title" id="tsa-calendar-title">📅 Payout calendar</div>
<div id="tsa-calendar-content" class="tsa-section-content-inner"></div>
</div>
<div class="tsa-section" id="tsa-savings-section" style="display:none">
<div class="tsa-section-title" id="tsa-savings-title">🎯 Savings Plan</div>
<div id="tsa-savings-content"></div>
</div>
<div class="tsa-section">
<div class="tsa-section-title" id="tsa-recs-title">Top Recommendations</div>
<div id="tsa-recs-content"><div id="tsa-cards"></div></div>
</div>
<div class="tsa-section">
<div class="tsa-section-title" id="tsa-hold-title">Your Holdings</div>
<div id="tsa-hold-content"></div>
</div>
<div class="tsa-section">
<div class="tsa-section-title" id="tsa-swap-title">Swap Advisor — Sell to Buy Better</div>
<div id="tsa-swap-content"></div>
</div>
<div class="tsa-section">
<div class="tsa-section-title" id="tsa-table-title">All Stocks — Full Rankings</div>
<div id="tsa-mobile-rankings" style="padding:0 4px"></div>
<div id="tsa-table-content">
<table class="tsa-table">
<thead><tr>
<th style="width:28px">#</th>
<th style="min-width:200px">Stock</th>
<th style="width:52px">Type</th>
<th style="min-width:90px;text-align:right">Payout/blk</th>
<th style="width:44px;text-align:center">Cycle</th>
<th style="min-width:75px;text-align:right">Held</th>
<th style="min-width:85px;text-align:right">Cost</th>
<th style="min-width:75px;text-align:right">Daily</th>
<th style="min-width:100px;text-align:right">ROI · Score</th>
<th style="width:65px;text-align:center">Status</th>
</tr></thead>
<tbody id="tsa-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
<div id="tsa-disclaimer">
⚠ For informational purposes only. Stock data may be delayed or inaccurate.
All investment decisions are made at your own risk. TheOddSod accepts no responsibility for financial losses.
</div>
<div id="tsa-footer">
<span>Torn Stock Advisor · TheOddSod (2640064)</span>
<span>Scores = incremental daily ROI · Item values via live market</span>
</div>
`;
return root;
}
// ─── Populate config ──────────────────────────────────────────────────────────
function populateConfig(root) {
root.querySelector('#tsa-cfg-theme').value = getTheme();
root.querySelector('#tsa-cfg-apikey').value = getApiKey();
root.querySelector('#tsa-cfg-ignored').value = load('ignored', '');
root.querySelector('#tsa-cfg-swap-no-sell').value = load('swap_no_sell', '');
root.querySelector('#tsa-cfg-budget').value = getBudget() || '';
root.querySelector('#tsa-cfg-budget-pct').value = getBudgetPct();
root.querySelector('#tsa-cfg-budget-mode').value = getBudgetMode();
root.querySelector('#tsa-cfg-topn').value = getTopN();
root.querySelector('#tsa-cfg-refresh').value = getRefreshMins();
root.querySelector('#tsa-ex-nerve').checked = getExNerve();
root.querySelector('#tsa-ex-energy').checked = getExEnergy();
root.querySelector('#tsa-ex-happy').checked = getExHappy();
root.querySelector('#tsa-ex-other').checked = getExOther();
root.querySelector('#tsa-ex-passive').checked = getExPassive();
// Savings goal dropdown — built dynamically since blocks are now dynamic
const goalSel = root.querySelector('#tsa-cfg-savings-goal');
const savedGoal = getSavingsGoal();
while (goalSel.options.length > 1) goalSel.remove(1);
for (const stock of [...STOCK_DATA].sort((a,b)=>a.name.localeCompare(b.name))) {
if (stock.payoutType === 'passive') continue;
const b1 = getB1(stock.ticker);
const max = getMaxBlocks(stock.ticker, 0);
for (let n = 1; n <= max; n++) {
const thresh = (Math.pow(2,n)-1)*b1;
const key = `${stock.ticker}:${n}`;
const opt = document.createElement('option');
opt.value = key;
opt.textContent = `${stock.name} — Block ${n} (${fmtShares(thresh)} shares)`;
opt.selected = key === savedGoal;
goalSel.appendChild(opt);
}
}
// Per-stock accordions
const container = root.querySelector('#tsa-cfg-accordions');
container.innerHTML = '';
for (const stock of [...STOCK_DATA].sort((a,b)=>a.name.localeCompare(b.name))) {
const { ticker, name, payoutType, payoutInterval, payoutDesc } = stock;
const iLabel = payoutInterval > 0 ? `every ${payoutInterval}d` : 'passive';
const accord = document.createElement('div');
accord.className = 'tsa-accord';
const hdr = document.createElement('div');
hdr.className = 'tsa-accord-hdr';
hdr.innerHTML = `<span><span class="tsa-accord-ticker">${ticker}</span>${name}<span style="font-size:10px;color:var(--tsa-text-muted);margin-left:6px">${iLabel}</span></span><span class="tsa-accord-arrow">▾</span>`;
const body = document.createElement('div');
body.className = 'tsa-accord-body';
// B1 threshold input
const b1Row = document.createElement('div');
b1Row.className = 'tsa-incr-row';
b1Row.innerHTML = `
<label>B1 threshold (shares)</label>
<input class="tsa-cfg-input tsa-b1-inp" data-ticker="${ticker}" type="number"
value="${getB1(ticker)}" placeholder="${stock.b1Threshold}" style="width:130px" />
<span class="tsa-incr-note">Block 1 share count. B2=3x, B3=7x, B4=15x, B5=31x…</span>`;
body.appendChild(b1Row);
// Max blocks input
const maxRow = document.createElement('div');
maxRow.className = 'tsa-incr-row';
maxRow.innerHTML = `
<label>Max blocks to show</label>
<input class="tsa-cfg-input tsa-max-inp" data-ticker="${ticker}" type="number" min="1" max="50"
value="${getMaxBlocks(ticker, 0)}" placeholder="auto (held+4)" style="width:130px" />
<span class="tsa-incr-note">Blocks beyond held+2 are hidden from recommendations</span>`;
body.appendChild(maxRow);
// Payout value override
const payRow = document.createElement('div');
payRow.className = 'tsa-incr-row';
const cashDefault = stock.payoutCashValue > 0 ? `default ${fmtMoney(stock.payoutCashValue)}` : 'auto (from stocks page)';
const payPlaceholder = payoutType === 'item' ? 'auto (live market)'
: payoutType === 'cash' ? cashDefault
: payoutType === 'points' ? 'auto (live points price)'
: '0';
payRow.innerHTML = `
<label>Payout per block ($)</label>
<input class="tsa-cfg-input tsa-payout-inp" data-ticker="${ticker}" type="number"
value="${load(`payout_${ticker}`,'')}" placeholder="${payPlaceholder}" style="width:130px" />
<span class="tsa-incr-note">${payoutDesc}</span>`;
body.appendChild(payRow);
// Item-specific fields: quantity and item name
if (payoutType === 'item') {
const qtyRow = document.createElement('div');
qtyRow.className = 'tsa-incr-row';
qtyRow.innerHTML = `
<label>Items per block</label>
<input class="tsa-cfg-input tsa-itemqty-inp" data-ticker="${ticker}" type="number" min="1"
value="${getItemQtyOverride(ticker, stock.payoutQty||1)}" style="width:130px" />
<span class="tsa-incr-note">Quantity received per block per cycle</span>`;
body.appendChild(qtyRow);
const nameRow = document.createElement('div');
nameRow.className = 'tsa-incr-row';
const currentItemName = getItemNameOverride(ticker, stock.payoutItemName || '');
nameRow.innerHTML = `
<label>Item name (for market lookup)</label>
<input class="tsa-cfg-input tsa-itemname-inp" data-ticker="${ticker}" type="text"
value="${currentItemName}"
placeholder="${stock.payoutItemName||'auto'}" style="width:200px" />
<span class="tsa-item-status empty" data-ticker-status="${ticker}">• type name then tab to verify</span>`;
body.appendChild(nameRow);
// Inline item name validator — checks against cached item ID map on blur.
// No extra API call: uses the item_id_cache populated during normal scoring.
const nameInp = nameRow.querySelector('.tsa-itemname-inp');
const statusSpan = nameRow.querySelector(`[data-ticker-status="${ticker}"]`);
function checkItemName() {
const query = nameInp.value.trim() || stock.payoutItemName || '';
if (!query) {
statusSpan.className = 'tsa-item-status empty';
statusSpan.textContent = '\u2022 type name then tab to verify';
return;
}
// Read the cached item map from GM storage
let nameToId = {};
try { nameToId = JSON.parse(load('item_id_cache', '{}')); } catch {}
const cacheTime = load('item_id_cache_time', 0);
const cacheAge = Date.now() - cacheTime;
if (!Object.keys(nameToId).length || cacheAge > 86400000 * 2) {
// Cache empty or very stale — can't verify without an API call
statusSpan.className = 'tsa-item-status pending';
statusSpan.textContent = '\u23F3 Save & refresh to verify (cache empty)';
return;
}
const found = nameToId[query.toLowerCase()];
if (found) {
statusSpan.className = 'tsa-item-status ok';
statusSpan.textContent = `\u2713 Found (ID\u00a0${found})`;
} else {
statusSpan.className = 'tsa-item-status miss';
statusSpan.textContent = '\u2717 Not found \u2014 check spelling exactly as it appears in Torn';
}
}
// Check on blur (tab away) and on input with a short debounce
nameInp.addEventListener('blur', checkItemName);
let _debounce;
nameInp.addEventListener('input', () => { clearTimeout(_debounce); _debounce = setTimeout(checkItemName, 600); });
// Run immediately for pre-filled values so status shows on accordion open
if (currentItemName) setTimeout(checkItemName, 0);
}
// Energy/nerve/happy: units per block
if (['energy','nerve','happy'].includes(payoutType)) {
const unitsRow = document.createElement('div');
unitsRow.className = 'tsa-incr-row';
const unitLabel = payoutType === 'happy' ? 'happy' : payoutType;
unitsRow.innerHTML = `
<label>${unitLabel.charAt(0).toUpperCase()+unitLabel.slice(1)} per block</label>
<input class="tsa-cfg-input tsa-units-inp" data-ticker="${ticker}" type="number" min="1"
value="${getUnitsOverride(ticker, stock.payoutUnits||0)}" style="width:130px" />
<span class="tsa-incr-note">${unitLabel} units received per block per cycle</span>`;
body.appendChild(unitsRow);
}
hdr.addEventListener('click', () => { hdr.classList.toggle('open'); body.classList.toggle('open'); });
accord.appendChild(hdr);
accord.appendChild(body);
container.appendChild(accord);
}
}
// ─── Config strip ─────────────────────────────────────────────────────────────
function updateStrip(root) {
const key = getApiKey();
const el = root.querySelector('#tsa-key-status');
el.textContent = key ? '\u2713 Connected' : '\u2717 Not set';
el.className = key ? 'tsa-key-ok' : 'tsa-key-bad';
const themeNames = { default:'Default', torn:'Torn Classic', highcontrast:'High Contrast',
deuteranopia:'Deuteranopia', protanopia:'Protanopia', tritanopia:'Tritanopia',
lowvision:'Low Vision', light:'Light Mode' };
const themeEl = root.querySelector('#tsa-strip-theme');
if (themeEl) themeEl.textContent = themeNames[getTheme()] || getTheme();
const ig = getIgnored();
root.querySelector('#tsa-strip-ignored').textContent = ig.length ? ig.join(', ') : 'None';
const excl = [];
if (getExNerve()) excl.push('Nerve');
if (getExEnergy()) excl.push('Energy');
if (getExHappy()) excl.push('Happy');
if (getExOther()) excl.push('Other');
if (getExPassive()) excl.push('Passive');
root.querySelector('#tsa-strip-excl').textContent = excl.length ? excl.join(', ') : 'None';
root.querySelector('#tsa-strip-refresh').textContent = getRefreshMins() + ' min';
const noSell = getSwapNoSell();
const nsWrap = root.querySelector('#tsa-strip-nosell-wrap');
const nsSep = root.querySelector('#tsa-strip-nosell-sep');
if (nsWrap) {
nsWrap.style.display = noSell.length ? '' : 'none';
if (nsSep) nsSep.style.display = noSell.length ? '' : 'none';
const ns = root.querySelector('#tsa-strip-nosell');
if (ns) ns.textContent = noSell.join(', ');
}
const budget = getBudget();
const budgetEl = root.querySelector('#tsa-strip-budget');
if (budgetEl) budgetEl.innerHTML = budget > 0
? `${fmtMoney(budget)}`
: `<span style="color:var(--tsa-text-dead)">Not set <span style="font-size:9px">(set in Config)</span></span>`;
}
// ─── Render: savings plan ─────────────────────────────────────────────────────
function renderSavingsPlan(root, rows, prices) {
const goalKey = getSavingsGoal();
const section = root.querySelector('#tsa-savings-section');
const planEl = root.querySelector('#tsa-savings-content');
const bannerEl = root.querySelector('#tsa-savings-banner');
if (!section || !planEl || !bannerEl) return;
if (!goalKey) { section.style.display='none'; bannerEl.style.display='none'; return; }
const [goalTicker, goalIncrStr] = goalKey.split(':');
const goalIncr = parseInt(goalIncrStr, 10);
const goalStock = STOCK_DATA.find(s => s.ticker === goalTicker);
if (!goalStock) { section.style.display='none'; bannerEl.style.display='none'; return; }
section.style.display = '';
const b1 = getB1(goalTicker);
const goalThresh = (Math.pow(2,goalIncr)-1)*b1;
const prevThresh = goalIncr > 1 ? (Math.pow(2,goalIncr-1)-1)*b1 : 0;
const goalIncrShares = goalThresh - prevThresh;
const goalPrice = prices[goalTicker] || 0;
const goalRow = rows.find(r => r.ticker===goalTicker && r.incr===goalIncr);
const sharesAlready = goalRow ? Math.max(0, goalRow.sharesHeld - prevThresh) : 0;
const sharesNeeded = Math.max(0, goalIncrShares - sharesAlready);
const goalCost = goalRow?.costToComplete > 0 ? goalRow.costToComplete : goalIncrShares * goalPrice;
const availCash = getAvailableCash();
let taggedValue = 0;
const taggedStocks = [];
for (const row of rows) {
if (row.status !== 'held') continue;
if (!isSteppingStone(row.ticker, row.incr)) continue;
const liqVal = row.incrShares * (prices[row.ticker]||0);
taggedValue += liqVal;
taggedStocks.push({ ticker:row.ticker, name:row.name, incr:row.incr, blockShares:row.incrShares, liqVal, dailyValue:row.dailyValue });
}
const totalProgress = availCash + taggedValue;
const pct = goalCost > 0 ? Math.min(100,(totalProgress/goalCost)*100) : 0;
const remaining = Math.max(0, goalCost - totalProgress);
const totalDaily = rows.filter(r=>r.status==='held').reduce((s,r)=>s+r.dailyValue,0);
const daysToGoal = (remaining>0&&totalDaily>0) ? Math.ceil(remaining/totalDaily) : 0;
const dismissKey = `savings_banner_dismissed_${goalKey}`;
if (pct >= 100 && load(dismissKey,'') !== '1') {
bannerEl.style.display = '';
const sellList = taggedStocks.length
? taggedStocks.map(s=>`<span class="tsa-ticker">${s.ticker}</span> (${fmtMoney(s.liqVal)})`).join(', ')
: '<span style="opacity:.8">your stepping stone stocks</span>';
bannerEl.innerHTML = `
<span class="tsa-ban-icon">🎯</span>
<div class="tsa-ban-body">
<div class="tsa-ban-title">You can now afford ${goalStock.name} Block ${goalIncr}!</div>
<div style="font-size:11px;margin-top:2px">Goal: <strong>${fmtMoney(goalCost)}</strong> · You have: <strong>${fmtMoney(totalProgress)}</strong></div>
<div class="tsa-ban-sell">Sell ${sellList} → buy ${goalStock.name} Block ${goalIncr}</div>
</div>
<button class="tsa-ban-dismiss" id="tsa-ban-dismiss-btn">Dismiss</button>`;
root.querySelector('#tsa-ban-dismiss-btn')?.addEventListener('click', () => {
save(dismissKey,'1'); bannerEl.style.display='none';
});
} else {
bannerEl.style.display = 'none';
if (pct < 100) save(dismissKey, '');
}
planEl.innerHTML = '';
const goalCard = document.createElement('div');
goalCard.className = 'tsa-plan-goal';
goalCard.innerHTML = `
<div class="tsa-plan-goal-hd">
<div>
<span class="tsa-plan-goal-name">${goalStock.name} — Block ${goalIncr}</span>
<span style="font-size:10px;color:var(--tsa-text-muted);margin-left:8px">${fmtShares(sharesNeeded)} shares needed @ ${fmtMoney(goalPrice)}/share${sharesAlready>0?` <span style="color:var(--tsa-status-ok)">(have ${fmtShares(sharesAlready)} of ${fmtShares(goalIncrShares)})</span>`:''}</span>
</div>
<span class="tsa-plan-goal-meta">Goal: <strong style="color:var(--tsa-accent)">${fmtMoney(goalCost)}</strong></span>
</div>
<div class="tsa-plan-progress">
<div class="tsa-plan-progress-fill${pct>=100?' done':''}" style="width:${pct.toFixed(1)}%"></div>
</div>
<div style="display:flex;justify-content:space-between;font-size:10px;color:var(--tsa-text-muted);margin-top:3px">
<span>${pct.toFixed(1)}% reached</span>
<span>${remaining>0?fmtMoney(remaining)+' still needed':'✓ Goal reached!'}</span>
</div>
<div class="tsa-plan-stats">
<div class="tsa-plan-stat"><span class="tsa-plan-stat-lbl">Available cash</span><span class="tsa-plan-stat-val">${fmtMoney(availCash)}</span></div>
<div class="tsa-plan-stat"><span class="tsa-plan-stat-lbl">Stepping stones</span><span class="tsa-plan-stat-val">${taggedStocks.length?fmtMoney(taggedValue):'—'}</span></div>
<div class="tsa-plan-stat"><span class="tsa-plan-stat-lbl">Total progress</span><span class="tsa-plan-stat-val" style="color:${pct>=100?'var(--tsa-status-ok)':'var(--tsa-accent)'}">${fmtMoney(totalProgress)}</span></div>
${daysToGoal>0?`<div class="tsa-plan-stat"><span class="tsa-plan-stat-lbl">Est. days</span><span class="tsa-plan-stat-val">${daysToGoal}d</span></div>`:''}
${totalDaily>0?`<div class="tsa-plan-stat"><span class="tsa-plan-stat-lbl">Daily payout</span><span class="tsa-plan-stat-val">${fmtMoney(totalDaily)}/day</span></div>`:''}
</div>`;
planEl.appendChild(goalCard);
if (taggedStocks.length) {
const hd = document.createElement('div');
hd.style.cssText = 'font-size:9px;color:var(--tsa-text-muted);text-transform:uppercase;letter-spacing:.5px;margin:12px 0 6px';
hd.textContent = 'Tagged stepping stones'; planEl.appendChild(hd);
for (const s of taggedStocks) {
const row = document.createElement('div'); row.className = 'tsa-plan-rec-row';
row.innerHTML = `<span class="tsa-ticker">${s.ticker}</span><span style="color:var(--tsa-text-card);flex:1">${s.name} Block ${s.incr}</span><span style="color:var(--tsa-status-ok);font-size:11px">${s.dailyValue>0?fmtMoney(s.dailyValue)+'/day':'—'}</span><span style="color:var(--tsa-text-secondary);font-size:10px;margin-left:10px">Sell: ${fmtMoney(s.liqVal)} (${fmtShares(s.blockShares)} sh)</span>`;
planEl.appendChild(row);
}
}
const investBudget = getAvailableCash();
if (investBudget > 0) {
const noSell = getSwapNoSell();
const heldKeys = new Set(rows.filter(r=>r.status==='held').map(r=>`${r.ticker}:${r.incr}`));
const candidates = rows.filter(r =>
r.ticker!==goalTicker && !noSell.includes(r.ticker) &&
!heldKeys.has(`${r.ticker}:${r.incr}`) && r.scoreable && r.score>0 &&
r.costToComplete<=investBudget && r.dailyValue>0
).sort((a,b)=>b.dailyROI-a.dailyROI).slice(0,5);
const recHd = document.createElement('div');
recHd.style.cssText = 'font-size:9px;color:var(--tsa-text-muted);text-transform:uppercase;letter-spacing:.5px;margin:14px 0 6px';
recHd.textContent = candidates.length
? `Stepping stone suggestions within ${fmtMoney(investBudget)}`
: `No stepping-stone candidates within ${fmtMoney(investBudget)}`;
planEl.appendChild(recHd);
for (const r of candidates) {
const row = document.createElement('div'); row.className = 'tsa-plan-rec-row';
row.innerHTML = `<span class="tsa-ticker">${r.ticker}</span><span style="color:var(--tsa-text-card);flex:1">${r.name}<span class="tsa-badge tsa-badge-info" style="font-size:9px;margin-left:4px">Block ${r.incr}</span></span><span style="color:var(--tsa-text-secondary);font-size:10px">${fmtMoney(r.costToComplete)}</span><span style="color:var(--tsa-status-ok);font-size:11px;margin-left:8px">${fmtMoney(r.dailyValue)}/day</span><span style="color:var(--tsa-accent);font-size:10px;margin-left:8px">${fmtROI(r.dailyValue,r.incrCost)}</span>`;
planEl.appendChild(row);
}
} else {
const hint = document.createElement('div');
hint.style.cssText = 'color:var(--tsa-text-muted);font-size:12px;padding:8px 0';
hint.textContent = 'Set an Available to invest amount in Config to see stepping-stone suggestions.';
planEl.appendChild(hint);
}
}
// ─── Render: recommendation cards ────────────────────────────────────────────
function renderCards(root, rows) {
const container = root.querySelector('#tsa-cards');
container.innerHTML = '';
const budgetMode = getBudgetMode();
const seenTickers = new Set();
const dedupedRows = [];
for (const r of rows.filter(r=>r.scoreable&&r.score>0)) {
if (budgetMode==='hide'&&r.overBudget) continue;
if (!seenTickers.has(r.ticker)) { seenTickers.add(r.ticker); dedupedRows.push(r); }
}
const topRows = dedupedRows.slice(0, getTopN());
if (!topRows.length) {
const totalScoreable = rows.filter(r => r.scoreable && r.score > 0).length;
const hiddenByBudget = budgetMode === 'hide' && getBudget() > 0
? rows.filter(r => r.scoreable && r.score > 0 && r.overBudget).length
: 0;
const msg = hiddenByBudget > 0 && hiddenByBudget === totalScoreable
? `All ${totalScoreable} scoreable block${totalScoreable>1?'s':''} are over your ${fmtMoney(getBudget())} budget — increase your cash amount, raise the max cost %, or switch over-budget blocks to “Grey out” to see them here.`
: 'No scoreable blocks — check config or API key.';
container.innerHTML = `<div style="color:var(--tsa-text-muted);font-size:12px;padding:8px 0">${msg}</div>`;
return;
}
topRows.forEach((r, i) => {
const rank = i+1;
const rankCls = rank===1?'tsa-rank-gold':rank===2?'tsa-rank-silver':rank===3?'tsa-rank-bronze':'';
const iLabel = r.payoutInterval>0?`every ${r.payoutInterval} days`:'';
let partialHtml = '';
if (r.status==='partial') {
const pct = Math.round(((r.sharesHeld-r.prevThresh)/r.incrShares)*100);
partialHtml = `<div class="tsa-card-partial">▶ ${fmtShares(r.sharesHeld-r.prevThresh)} / ${fmtShares(r.incrShares)} shares (${pct}% there)</div>`;
}
const stockDef = STOCK_DATA.find(s=>s.ticker===r.ticker);
const stockUrl = stockDef?.stockId ? `https://www.torn.com/page.php?sid=stocks&stockID=${stockDef.stockId}&tab=owned` : null;
function openStockPanel() {
for (const span of document.querySelectorAll('.tt-acronym')) {
if (span.textContent.trim()===`(${r.ticker})`) {
const ul=span.closest('ul');
if (ul?.children[2]) { ul.children[2].click(); ul.scrollIntoView({behavior:'smooth',block:'center'}); return; }
}
}
if (stockUrl) window.location.href = stockUrl;
}
const existingHeld = rows.filter(rv=>rv.ticker===r.ticker&&rv.status==='held');
const existingDaily = existingHeld.reduce((s,rv)=>s+rv.dailyValue,0);
const totalAfterHtml = existingHeld.length>0&&r.dailyValue>0
? `<div class="tsa-card-line" style="color:var(--tsa-text-muted)">Total after buy: <strong style="color:var(--tsa-status-ok)">${fmtMoney(existingDaily+r.dailyValue)}/day</strong> <span style="font-size:10px;color:var(--tsa-text-dead)">(${existingHeld.length} block${existingHeld.length>1?'s':''} already)</span></div>`
: '';
const card = document.createElement('div');
card.className='tsa-card'; card.style.cursor='pointer'; card.title=`Open ${r.ticker} buy panel`;
card.addEventListener('click', openStockPanel);
card.innerHTML = `
<div class="tsa-card-rank ${rankCls}">#${rank}</div>
<div class="tsa-card-head"><span class="tsa-ticker">${r.ticker}</span> ${r.name}${stockUrl?'<span style="color:var(--tsa-text-dead);font-size:9px;margin-left:6px">↗</span>':''}</div>
<div class="tsa-card-sub"><span class="tsa-badge tsa-badge-info">Block ${r.incr}</span></div>
<div class="tsa-card-line">This block earns: <strong>${r.incrValue>0?fmtMoney(r.incrValue):'set in config'}</strong> ${iLabel}</div>
${totalAfterHtml}
<div class="tsa-card-line">Need: <strong>${fmtShares(r.sharesNeeded)} shares</strong> · Cost: <strong>${fmtMoney(r.costToComplete)}</strong></div>
<div class="tsa-card-roi">Daily ROI: ${fmtROI(r.dailyValue,r.incrCost)} · Score: ${r.score.toFixed(1)}/10</div>
${r.overBudget?`<div style="font-size:10px;color:var(--tsa-status-crit);margin-top:3px">⚠ Over budget (${fmtMoney(r.costToComplete)})</div>`:''}
${partialHtml}`;
container.appendChild(card);
});
}
// ─── Render: holdings ────────────────────────────────────────────────────────
function renderHoldings(root, rows) {
const content = root.querySelector('#tsa-hold-content');
content.innerHTML = '';
const held = rows.filter(r=>r.status==='held' && !r.ignored);
const partial = rows.filter(r=>r.status==='partial' && !r.ignored);
if (!held.length && !partial.length) {
content.innerHTML = `<div style="color:var(--tsa-text-muted);font-size:12px;padding:6px 0">No active or partial blocks detected.</div>`;
return;
}
if (held.length) {
const lbl = document.createElement('div'); lbl.className='tsa-hold-sublabel'; lbl.textContent='Active blocks'; content.appendChild(lbl);
const goalActive = !!getSavingsGoal();
const byTicker = {};
for (const r of held) {
if (!byTicker[r.ticker]) byTicker[r.ticker]={r,count:0,totalDaily:0,blocks:[]};
byTicker[r.ticker].count++;
byTicker[r.ticker].totalDaily+=r.dailyValue;
byTicker[r.ticker].blocks.push(r);
}
const entries = Object.values(byTicker).sort((a,b)=>{
const ap=a.r.payoutType==='passive', bp=b.r.payoutType==='passive';
if (ap!==bp) return ap?1:-1;
return a.r.ticker.localeCompare(b.r.ticker);
});
let lastActive = true;
for (const {r,count,totalDaily,blocks} of entries) {
const isPassive = r.payoutType==='passive';
if (isPassive && lastActive) {
const div=document.createElement('div'); div.className='tsa-hold-sublabel';
div.style.marginTop='8px'; div.style.opacity='0.5';
div.textContent='Passive benefits (no active payout)'; content.appendChild(div); lastActive=false;
}
const iLabel = r.payoutInterval>0?`every ${r.payoutInterval}d`:'passive';
const rowsToShow = (goalActive&&blocks.length>1)?blocks.sort((a,b)=>a.incr-b.incr):[{...r,_summary:true,count,totalDaily}];
for (const blockR of rowsToShow) {
const isSummary = !!blockR._summary;
const row = document.createElement('div'); row.className='tsa-hold-row';
if (isPassive) row.style.opacity='0.55';
const isStep = isSteppingStone(blockR.ticker, blockR.incr);
const blockLbl = !isSummary?` <span style="color:var(--tsa-text-dead);font-size:10px">Block ${blockR.incr}</span>`:'';
const badgeTxt = isSummary?`${count} block${count>1?'s':''} \u2713`:`Block ${blockR.incr} \u2713`;
const dayVal = isSummary?totalDaily:blockR.dailyValue;
row.innerHTML = `
<span class="tsa-ticker">${blockR.ticker}</span>
<span class="tsa-hold-name">${blockR.name}${blockLbl}</span>
<span class="tsa-badge ${isPassive?'tsa-badge-passive':'tsa-badge-ok'}" style="justify-self:end">${badgeTxt}</span>
<span class="tsa-hold-val">${dayVal>0?fmtMoney(dayVal)+'/day':'—'}</span>
<span style="color:var(--tsa-text-dead);font-size:10px;white-space:nowrap">${fmtShares(blockR.sharesHeld)} · ${iLabel}</span>`;
if (goalActive) {
const btn=document.createElement('button');
btn.className='tsa-step-toggle'+(isStep?' active':'');
btn.textContent=isStep?'\uD83C\uDFAF Stepping stone':'\uD83C\uDFAF Mark stepping stone';
btn.dataset.ticker=blockR.ticker; btn.dataset.incr=blockR.incr;
btn.addEventListener('click',(e)=>{
e.stopPropagation();
const nv=!isSteppingStone(blockR.ticker,blockR.incr);
setSteppingStone(blockR.ticker,blockR.incr,nv);
btn.className='tsa-step-toggle'+(nv?' active':'');
btn.textContent=nv?'\uD83C\uDFAF Stepping stone':'\uD83C\uDFAF Mark stepping stone';
renderSavingsPlan(root,lastRows,lastPrices);
});
row.appendChild(btn);
}
content.appendChild(row);
}
}
}
if (partial.length) {
const lbl=document.createElement('div'); lbl.className='tsa-hold-sublabel'; lbl.style.marginTop='8px';
lbl.textContent='Partial \u2014 working toward next block'; content.appendChild(lbl);
for (const r of [...partial].sort((a,b)=>a.ticker.localeCompare(b.ticker))) {
const pct=Math.round(((r.sharesHeld-r.prevThresh)/r.incrShares)*100);
const row=document.createElement('div'); row.className='tsa-hold-partial-row';
row.innerHTML=`<span class="tsa-ticker">${r.ticker}</span><span class="tsa-hold-name">${r.name} <span style="color:var(--tsa-text-dead);font-size:10px">block ${r.incr}</span></span><span class="tsa-badge tsa-badge-warn">${pct}%</span><span class="tsa-hold-partial-txt">${fmtShares(r.sharesHeld)} / ${fmtShares(r.threshold)} · need ${fmtShares(r.sharesNeeded)} more · ${fmtMoney(r.costToComplete)}</span>`;
content.appendChild(row);
}
}
}
// ─── Render: swap advisor ─────────────────────────────────────────────────────
function renderSwaps(root, rows, bonusInfo={}) {
const content=root.querySelector('#tsa-swap-content');
if (!content) return;
content.innerHTML='';
const availCash = getAvailableCash();
// ── Fix 5: Top-up partials section ────────────────────────────────────────
// Show partial blocks the user can complete right now with available cash,
// before considering any sells. Pure "spend to unlock" recommendations.
const completablePartials = rows.filter(r =>
r.status === 'partial' && !r.ignored && r.dailyValue > 0 &&
availCash > 0 && r.costToComplete <= availCash
).sort((a,b) => b.dailyValue - a.dailyValue);
if (completablePartials.length) {
const hd = document.createElement('div');
hd.style.cssText = 'font-size:9px;color:var(--tsa-text-muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px';
hd.textContent = 'Complete your partials — affordable with available cash';
content.appendChild(hd);
for (const r of completablePartials) {
const card = document.createElement('div');
card.className = 'tsa-swap-card';
card.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:6px">
<span style="color:var(--tsa-text-dead);font-size:10px;margin-right:4px">Top up</span>
<span style="font-size:12px;color:var(--tsa-text-card);font-weight:bold">
<span class="tsa-ticker">${r.ticker}</span>
<span style="color:var(--tsa-text-secondary);margin-left:4px;font-weight:normal">Block ${r.incr}</span>
<span style="color:var(--tsa-text-muted);font-size:10px;margin-left:4px;font-weight:normal">${r.name}</span>
</span>
<span style="color:var(--tsa-text-muted);font-size:11px">${fmtShares(r.sharesNeeded)} shares · ${fmtMoney(r.costToComplete)}</span>
<span style="margin-left:auto;font-size:13px;color:var(--tsa-status-ok);font-weight:bold">+${fmtMoney(r.dailyValue)}/day</span>
<span style="font-size:12px;font-weight:bold;color:var(--tsa-status-ok)">Immediate</span>
</div>
<div style="font-size:10px;color:var(--tsa-text-muted);line-height:1.5;padding-top:4px;border-top:1px solid var(--tsa-border-faint)">
Already hold ${fmtShares(r.sharesHeld - r.prevThresh)} of ${fmtShares(r.incrShares)} shares
(${Math.round(((r.sharesHeld-r.prevThresh)/r.incrShares)*100)}%)
· ROI on remaining spend: ${fmtROI(r.dailyValue, r.costToComplete)}
</div>`;
content.appendChild(card);
}
const div = document.createElement('div');
div.className = 'tsa-swap-group-divider';
content.appendChild(div);
}
// ── Swap advisor (sell to buy) ─────────────────────────────────────────────
const swaps = buildSwaps(rows);
// Show available cash context if set
if (availCash > 0) {
const cashNote = document.createElement('div');
cashNote.style.cssText = 'font-size:10px;color:var(--tsa-text-muted);margin-bottom:10px';
cashNote.innerHTML = `ⓘ Available cash <strong style="color:var(--tsa-accent)">${fmtMoney(availCash)}</strong> is factored into all swap calculations below — targets only need selling to cover the remainder.`;
content.appendChild(cashNote);
}
if (!swaps.length) {
const msg = document.createElement('div');
msg.style.cssText = 'color:var(--tsa-text-muted);font-size:12px;padding:6px 0';
msg.textContent = completablePartials.length
? 'No additional sell-to-buy swaps found beyond the top-up opportunities above.'
: 'No positive-gain swaps found \u2014 holdings appear well-optimised.';
content.appendChild(msg);
return;
}
const groups=[], seenSell=new Map();
for (const s of swaps) {
const key=`${s.sellTicker}|${s.sellType}`;
if (!seenSell.has(key)){seenSell.set(key,[]);groups.push({key,swaps:seenSell.get(key)});}
seenSell.get(key).push(s);
}
for (let groupIdx=0; groupIdx<groups.length; groupIdx++) {
const group=groups[groupIdx]; const groupRank=groupIdx+1;
const first=group.swaps[0];
if (groupIdx>0){const d=document.createElement('div');d.className='tsa-swap-group-divider';content.appendChild(d);}
const header=document.createElement('div');
header.className='tsa-swap-group-header';
const typeBadge =
first.sellType==='cash'
?'<span class="tsa-badge tsa-badge-ok" style="font-size:9px">Use cash</span>'
:first.sellType==='combined'
? '<span class="tsa-badge" style="background:var(--tsa-phase-plan-bg);color:var(--tsa-status-blocked);font-size:9px">Combined sell</span>'
: first.sellType==='full'
? '<span class="tsa-badge" style="background:var(--tsa-status-crit-bg);color:var(--tsa-banner-crit-text);font-size:9px">Full sell</span>'
: first.sellType==='down'
? '<span class="tsa-badge" style="background:var(--tsa-status-warn-bg);color:var(--tsa-status-warn);font-size:9px">Sell down</span>'
: '<span class="tsa-badge" style="background:var(--tsa-banner-ok-bg);color:var(--tsa-status-ok);font-size:9px">Sell partial</span>';
const payoutTickers = first.sellType==='cash' ? [] : first.combined ? first.extraSells : [first.sellTicker];
const payoutLines = payoutTickers.map(t=>{const fmt=fmtNextPayout(bonusInfo[t]);return fmt?`<span style="color:var(--tsa-text-muted);font-size:10px;margin-left:6px">${t}:</span> ${fmt}`:null;}).filter(Boolean);
const payoutHtml = payoutLines.length?`<span style="display:inline-block;margin-left:10px;font-size:10px">${payoutLines.join(' · ')}</span>`:'';
let sellDesc;
if (first.sellType==='cash') {
sellDesc=`No selling needed — <span style="color:var(--tsa-text-dead)">buy directly using ${fmtMoney(availCash)} available cash</span>`;
} else if (first.sellType==='combined') {
sellDesc=`Sell all <span class="tsa-ticker">${first.extraSells[0]}</span> + <span class="tsa-ticker">${first.extraSells[1]}</span> <span style="color:var(--tsa-text-dead)">(${fmtMoney(first.cashReleased)} freed)</span>`;
} else if (first.sellType==='full') {
sellDesc=`Sell all <span class="tsa-ticker">${first.sellTicker}</span> <span style="color:var(--tsa-text-dead)">(${first.sellName} · ${fmtMoney(first.cashReleased)} freed · lose ${fmtMoney(first.dailyLost)}/day)</span>`;
} else if (first.sellType==='down') {
sellDesc=`Drop <span class="tsa-ticker">${first.sellTicker}</span> one block <span style="color:var(--tsa-text-dead)">(${first.sellName} · ${fmtMoney(first.cashReleased)} freed · lose ${fmtMoney(first.dailyLost)}/day)</span>`;
} else {
// partial sell — no daily value lost
sellDesc=`Sell partial <span class="tsa-ticker">${first.sellTicker}</span> <span style="color:var(--tsa-text-dead)">(${first.sellName} · ${fmtMoney(first.cashReleased)} freed · no payout lost)</span>`;
}
header.innerHTML=`<span style="color:var(--tsa-text-muted);font-size:11px;font-weight:bold;margin-right:6px">#${groupRank}</span>${typeBadge} ${sellDesc}${payoutHtml}`;
content.appendChild(header);
group.swaps.forEach((s,idx)=>{
// For cash-type groups the chain already shows all follow-on purchases —
// the remaining entries are not alternatives, just the next-best independent
// buys which are already surfaced by the chain. Skip them.
if (first.sellType === 'cash' && idx > 0) return;
const card=document.createElement('div');
card.className='tsa-swap-card'+(idx>0?' alt':'');
const isImmediate = s.paybackDays === 0;
const pb = isImmediate ? 'var(--tsa-status-ok)'
: s.paybackDays<=30 ? 'var(--tsa-status-ok)'
: s.paybackDays<=90 ? 'var(--tsa-status-warn)'
: 'var(--tsa-text-secondary)';
const paybackLabel = isImmediate ? 'Immediate' : `Payback: ${s.paybackDays}d`;
const optLbl=group.swaps.length>1?`<span style="color:var(--tsa-text-dead);font-size:10px;margin-right:6px">${idx>0?'↳ or':'Buy'}</span>`:`<span style="color:var(--tsa-text-dead);font-size:10px;margin-right:6px">Buy</span>`;
const cashNote = s.availCashUsed > 0 && s.sellType !== 'cash'
? `<span style="background:var(--tsa-phase-plan-bg);border:1px solid var(--tsa-phase-plan-border);border-radius:3px;color:var(--tsa-phase-plan-text);font-size:9px;padding:1px 5px">+${fmtMoney(s.availCashUsed)} cash</span>`
: '';
card.innerHTML=`
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:6px">
${optLbl}
<span style="font-size:12px;color:var(--tsa-text-card);font-weight:bold">
<span class="tsa-ticker">${s.target.ticker}</span>
<span style="color:var(--tsa-text-secondary);margin-left:4px;font-weight:normal">Block ${s.target.incr}</span>
<span style="color:var(--tsa-text-muted);font-size:10px;margin-left:4px;font-weight:normal">${s.target.name}</span>
</span>
<span style="color:var(--tsa-text-muted);font-size:11px">costs ${fmtMoney(s.target.costToComplete)}</span>
${cashNote}
${s.leftoverCash>1000?`<span style="color:var(--tsa-text-dead);font-size:10px">· ${fmtMoney(s.leftoverCash)} left</span>`:''}
<span style="margin-left:auto;font-size:13px;color:var(--tsa-status-ok);font-weight:bold">+${fmtMoney(s.netDailyGain)}/day</span>
<span style="font-size:12px;font-weight:bold;color:${pb}">${paybackLabel}</span>
</div>
<div style="font-size:10px;color:var(--tsa-text-muted);line-height:1.5;padding-top:4px;border-top:1px solid var(--tsa-border-faint)">
+${fmtMoney(s.target.dailyValue)}/day from <span style="color:var(--tsa-text-secondary)">${s.target.ticker}</span>
${s.dailyLost>0
? ` · <span style="color:var(--tsa-status-crit)">-${fmtMoney(s.dailyLost)}/day</span> lost from sell`
: ` · <span style="color:var(--tsa-status-ok)">no daily value given up</span>`}
</div>`;
content.appendChild(card);
// ── Chain follow-on purchases from leftover ─────────────────────────
// After the primary buy, greedily spend remaining funds on the best
// immediately-actionable blocks until money runs out.
// Only chain from the first (best) option in each group, not the "or" alts.
if (idx === 0 && s.leftoverCash > 0) {
// Tickers already committed in this transaction
const usedTickers = new Set([s.target.ticker, s.sellTicker, ...(s.extraSells||[])]);
// Blocks already picked in this chain
const chainPicked = new Set([`${s.target.ticker}:${s.target.incr}`]);
let chainCash = s.leftoverCash;
// Pool: all immediately actionable blocks not yet committed
// "Immediately actionable" = partial, or direct next block (incr === heldBlocks + 1)
// After buying a block, that stock's next block becomes actionable too —
// we track virtual holdings to allow consecutive blocks of the same stock.
const virtualHeld = {}; // ticker -> highest incr virtually held
// Seed virtual held from actual rows
for (const r of rows.filter(r => r.status === 'held')) {
if (!virtualHeld[r.ticker] || r.incr > virtualHeld[r.ticker]) {
virtualHeld[r.ticker] = r.incr;
}
}
// Count the primary buy as virtually held
virtualHeld[s.target.ticker] = Math.max(virtualHeld[s.target.ticker]||0, s.target.incr);
let safety = 0;
while (chainCash > 0 && safety++ < 20) {
// Rebuild candidate pool accounting for virtual holdings
const pool = rows.filter(r => {
if (!r.scoreable || r.dailyValue <= 0 || r.costToComplete <= 0) return false;
if (chainPicked.has(`${r.ticker}:${r.incr}`)) return false;
if (r.status === 'held') return false;
if (r.costToComplete > chainCash) return false;
// Must be immediately next block (actual or virtual)
const vHeld = virtualHeld[r.ticker] || 0;
const isNext = r.incr === vHeld + 1;
const isPartial = r.status === 'partial';
return isNext || isPartial;
}).sort((a,b) => b.dailyROI - a.dailyROI);
if (!pool.length) break;
const next = pool[0];
chainCash -= next.costToComplete;
chainPicked.add(`${next.ticker}:${next.incr}`);
virtualHeld[next.ticker] = Math.max(virtualHeld[next.ticker]||0, next.incr);
const chainCard = document.createElement('div');
chainCard.className = 'tsa-swap-chain';
chainCard.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:6px">
<span style="color:var(--tsa-phase-plan-text);font-size:10px;margin-right:4px">↳ then buy</span>
<span style="font-size:12px;color:var(--tsa-text-card);font-weight:bold">
<span class="tsa-ticker">${next.ticker}</span>
<span style="color:var(--tsa-text-secondary);margin-left:4px;font-weight:normal">Block ${next.incr}</span>
<span style="color:var(--tsa-text-muted);font-size:10px;margin-left:4px;font-weight:normal">${next.name}</span>
</span>
<span style="color:var(--tsa-text-muted);font-size:11px">costs ${fmtMoney(next.costToComplete)}</span>
${chainCash>1000?`<span style="color:var(--tsa-text-dead);font-size:10px">· ${fmtMoney(chainCash)} remaining</span>`:''}
<span style="margin-left:auto;font-size:13px;color:var(--tsa-status-ok);font-weight:bold">+${fmtMoney(next.dailyValue)}/day</span>
<span style="font-size:12px;font-weight:bold;color:var(--tsa-status-ok)">Immediate</span>
</div>
<div style="font-size:10px;color:var(--tsa-text-muted);line-height:1.5;padding-top:4px;border-top:1px solid var(--tsa-border-faint)">
Funded from leftover · no additional selling needed
</div>`;
content.appendChild(chainCard);
}
}
});
}
const note=document.createElement('div'); note.className='tsa-swap-note';
const cashFootNote=availCash>0?` · <span style="color:var(--tsa-status-ok)">${fmtMoney(availCash)} available cash</span> included in all cost checks.`:'';
note.innerHTML=`Ranked by net daily gain (already accounts for income lost by selling).
<span style="color:var(--tsa-status-ok)">Use cash</span> →
<span style="color:var(--tsa-phase-plan-text)">Sell partial</span> →
<span style="color:var(--tsa-status-warn)">Sell block</span>
priority order — overridden only when a lower-priority option earns significantly more net income per day.
Payback = days until daily gain recovers any missed payout cycle.${cashFootNote}`;
content.appendChild(note);
}
// ─── Render: table ───────────────────────────────────────────────────────────
function renderTable(root, rows) {
const tbody=root.querySelector('#tsa-tbody');
tbody.innerHTML='';
let rankIdx=0;
for (const r of rows) {
if (r.beyondVisible && r.status!=='held') continue; // enforce n+2 visibility
const tr=document.createElement('tr');
if (r.ignored) tr.classList.add('tsa-row-ignored');
else if (r.payoutType==='passive') tr.classList.add('tsa-row-passive');
else if (r.overBudget&&getBudgetMode()==='grey') tr.classList.add('tsa-row-overbudget');
const active=r.scoreable&&r.score>0;
if (active) rankIdx++;
const rCls=rankIdx===1?'tsa-rank-gold':rankIdx===2?'tsa-rank-silver':rankIdx===3?'tsa-rank-bronze':'';
const rankCell=active?`<span class="tsa-rank-num ${rCls}">${rankIdx}</span>`:`<span class="tsa-rank-num" style="color:var(--tsa-text-dead)">—</span>`;
const tm={'cash':'tsa-badge-cash','points':'tsa-badge-cash','item':'tsa-badge-item','energy':'tsa-badge-energy','nerve':'tsa-badge-nerve','happy':'tsa-badge-happy','other':'tsa-badge-other','passive':'tsa-badge-passive'};
const typeBadge=`<span class="tsa-badge ${tm[r.payoutType]||'tsa-badge-muted'}">${r.payoutType}</span>`;
let statusBadge;
if (r.ignored) statusBadge=`<span class="tsa-badge tsa-badge-muted">Ignored</span>`;
else if (r.excluded) statusBadge=`<span class="tsa-badge tsa-badge-muted">Excl.</span>`;
else if (r.status==='held') statusBadge=`<span class="tsa-badge tsa-badge-ok">Active \u2713</span>`;
else if (r.status==='partial') statusBadge=`<span class="tsa-badge tsa-badge-warn">Partial</span>`;
else if (r.status==='next') statusBadge=`<span class="tsa-badge tsa-badge-info">Next</span>`;
else statusBadge=`<span class="tsa-badge tsa-badge-muted">Future</span>`;
const iLbl=r.payoutInterval>0?`${r.payoutInterval}d`:'passive';
const payCell=r.incrValue>0?`<span style="color:var(--tsa-text-card)">${fmtMoney(r.incrValue)}</span>`:`<span style="color:var(--tsa-text-dead)">—</span>`;
const heldCell=r.status==='held'?`<span style="color:var(--tsa-status-ok)">${fmtShares(r.sharesHeld)} \u2713</span>`:`<span style="color:var(--tsa-text-muted)">${fmtShares(r.sharesHeld)}</span>`;
const obBadge=(r.overBudget&&getBudgetMode()!=='show'&&r.status!=='held')?` <span style="color:var(--tsa-status-crit);font-size:9px">▲</span>`:'';
const costCell=r.status==='held'?`<span style="color:var(--tsa-text-dead)">—</span>`
:r.costToComplete>0?`<span style="color:${r.overBudget?'var(--tsa-status-crit)':r.status==='partial'?'var(--tsa-status-warn)':'var(--tsa-text-secondary)'}">${fmtMoney(r.costToComplete)}${obBadge}</span>`
:`<span style="color:var(--tsa-text-dead)">—</span>`;
const dailyCell=r.dailyValue>0?`<span style="color:var(--tsa-status-ok)">${fmtMoney(r.dailyValue)}</span>`:`<span style="color:var(--tsa-text-dead)">—</span>`;
const roiCell=active?`<span style="color:var(--tsa-status-ok);font-size:10px">${fmtROI(r.dailyValue,r.incrCost)}</span><span style="color:var(--tsa-accent);font-weight:bold;margin-left:4px">${r.score.toFixed(1)}</span>`:`<span style="color:var(--tsa-text-dead)">—</span>`;
tr.innerHTML=`
<td style="text-align:center">${rankCell}</td>
<td><span class="tsa-ticker" style="margin-right:4px">${r.ticker}</span><span style="color:var(--tsa-text-secondary);font-size:11px">${r.name}</span><span style="color:var(--tsa-text-dead);font-size:10px;margin-left:4px">B${r.incr}</span></td>
<td>${typeBadge}</td>
<td style="text-align:right">${payCell}</td>
<td style="text-align:center;color:var(--tsa-text-muted);font-size:10px">${iLbl}</td>
<td style="text-align:right">${heldCell}</td>
<td style="text-align:right">${costCell}</td>
<td style="text-align:right">${dailyCell}</td>
<td style="text-align:right">${roiCell}</td>
<td style="text-align:center">${statusBadge}</td>`;
tbody.appendChild(tr);
}
}
// ─── Stats bar ────────────────────────────────────────────────────────────────
function updateStats(root, rows) {
root.querySelector('#tsa-s-active').textContent = rows.filter(r=>r.status==='held'&&!r.ignored).length;
root.querySelector('#tsa-s-partial').textContent = rows.filter(r=>r.status==='partial'&&!r.ignored).length;
root.querySelector('#tsa-s-daily').textContent = fmtMoney(rows.filter(r=>r.status==='held'&&!r.ignored&&!r.excluded).reduce((s,r)=>s+r.dailyValue,0));
const best=rows.find(r=>r.scoreable&&r.score>0);
root.querySelector('#tsa-s-roi').textContent = best ? fmtROI(best.dailyValue,best.incrCost) : '\u2014';
}
// ─── Load & render ────────────────────────────────────────────────────────────
async function loadAndRender(root) {
const apiKey=getApiKey();
const errEl=root.querySelector('#tsa-error');
const loadEl=root.querySelector('#tsa-loading');
const contEl=root.querySelector('#tsa-content');
errEl.style.display='none'; loadEl.style.display='block'; contEl.style.display='none';
if (!apiKey) {
loadEl.style.display='none'; errEl.style.display='block';
errEl.textContent='\u26a0 No API key set \u2014 open \u2699 Config and enter your Torn API key (Stocks access required).';
return;
}
try {
const {rows,bonusInfo={},prices={}} = await buildScores(apiKey);
loadEl.style.display='none'; contEl.style.display='block';
lastRows=rows; lastPrices=prices;
// ── New v3 sections ──────────────────────────────────────────────────
const domDividends = (() => { try { return JSON.parse(load('dom_dividends', '{}')); } catch { return {}; } })();
// Build swaps first so calcPortfolioScore can use bestAvailableNetGain
const swapsForBriefing = buildSwaps(rows);
const portfolioScore = calcPortfolioScore(rows, domDividends, swapsForBriefing);
// Simulate score after top recommended actions
const simActions = swapsForBriefing.slice(0, 3).map(s => ({
sellTicker: s.sellTicker,
target: s.target,
chainBuys: [],
}));
const projectedScore = simActions.length ? simulateScore(rows, domDividends, simActions) : null;
renderBriefing(root, rows, prices, bonusInfo, swapsForBriefing, portfolioScore, projectedScore, domDividends);
renderPortfolioScore(root, portfolioScore, projectedScore);
renderCalendar(root, rows, bonusInfo);
// ── Existing sections ─────────────────────────────────────────────────
renderSavingsPlan(root,rows,prices);
renderCards(root,rows);
renderHoldings(root,rows);
renderSwaps(root,rows,bonusInfo);
renderTable(root,rows);
updateStats(root,rows);
// ── Mobile compact rankings ───────────────────────────────────────────
renderMobileRankings(root, rows);
root.querySelector('#tsa-updated').textContent='Updated '+new Date().toLocaleTimeString();
} catch(e) {
loadEl.style.display='none'; errEl.style.display='block';
errEl.textContent='\u26a0 Error: '+e.message;
console.error('[TSA]',e);
}
}
// ─── Render: mobile compact rankings ─────────────────────────────────────────
// On mobile the full table is hidden — replaced with a compact scrollable list.
function renderMobileRankings(root, rows) {
let el = root.querySelector('#tsa-mobile-rankings');
if (!el) return;
el.innerHTML = '';
const visible = rows.filter(r => !r.beyondVisible || r.status === 'held').slice(0, 30);
for (const r of visible) {
if (r.ignored) continue;
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:7px 0;border-bottom:1px solid var(--tsa-border-faint);font-size:11px';
const statusDot = r.status === 'held'
? `<span style="width:7px;height:7px;border-radius:50%;background:var(--tsa-status-ok);flex-shrink:0;display:inline-block"></span>`
: r.status === 'partial'
? `<span style="width:7px;height:7px;border-radius:50%;background:var(--tsa-status-warn);flex-shrink:0;display:inline-block"></span>`
: r.scoreable && r.score > 0
? `<span style="width:7px;height:7px;border-radius:50%;background:var(--tsa-accent);flex-shrink:0;display:inline-block"></span>`
: `<span style="width:7px;height:7px;border-radius:50%;background:var(--tsa-text-dead);flex-shrink:0;display:inline-block"></span>`;
row.innerHTML = `
${statusDot}
<span class="tsa-ticker" style="font-size:10px">${r.ticker}</span>
<span style="color:var(--tsa-text-secondary);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">B${r.incr} ${r.name}</span>
<span style="color:${r.dailyValue > 0 ? 'var(--tsa-status-ok)' : 'var(--tsa-text-dead)'};white-space:nowrap">${r.dailyValue > 0 ? fmtMoney(r.dailyValue)+'/d' : '—'}</span>
${r.scoreable && r.score > 0 ? `<span style="color:var(--tsa-accent);font-size:10px;min-width:28px;text-align:right">${r.score.toFixed(1)}</span>` : '<span style="min-width:28px"></span>'}`;
el.appendChild(row);
}
}
// ─── Event wiring ─────────────────────────────────────────────────────────────
function wireEvents(root) {
root.querySelector('#tsa-collapse-btn').addEventListener('click',()=>{
const c=root.classList.toggle('collapsed');
root.querySelector('#tsa-collapse-btn').textContent=c?'\u25bc':'\u25b2';
save('dashboard_collapsed',c?'1':'0');
});
root.querySelector('#tsa-cfg-toggle').addEventListener('click',()=>{
root.querySelector('#tsa-config-panel').classList.toggle('open');
});
// Live theme preview on change
root.querySelector('#tsa-cfg-theme').addEventListener('change',(e)=>{
applyTheme(e.target.value);
updateStrip(root);
});
root.querySelector('#tsa-cfg-save-key').addEventListener('click',()=>{
save('api_key',root.querySelector('#tsa-cfg-apikey').value.trim());
updateStrip(root); loadAndRender(root);
});
root.querySelector('#tsa-cfg-save-all').addEventListener('click',()=>{
save('theme', root.querySelector('#tsa-cfg-theme').value);
save('ignored', root.querySelector('#tsa-cfg-ignored').value);
save('ex_nerve', root.querySelector('#tsa-ex-nerve').checked);
save('ex_energy', root.querySelector('#tsa-ex-energy').checked);
save('ex_happy', root.querySelector('#tsa-ex-happy').checked);
save('ex_other', root.querySelector('#tsa-ex-other').checked);
save('ex_passive', root.querySelector('#tsa-ex-passive').checked);
save('refresh_mins',root.querySelector('#tsa-cfg-refresh').value);
save('savings_goal', root.querySelector('#tsa-cfg-savings-goal')?.value||'');
save('swap_no_sell',root.querySelector('#tsa-cfg-swap-no-sell').value);
save('budget', root.querySelector('#tsa-cfg-budget').value);
save('budget_pct', root.querySelector('#tsa-cfg-budget-pct').value);
save('budget_mode', root.querySelector('#tsa-cfg-budget-mode').value);
save('top_n', root.querySelector('#tsa-cfg-topn').value);
root.querySelectorAll('.tsa-payout-inp').forEach(el=>save(`payout_${el.dataset.ticker}`,el.value));
root.querySelectorAll('.tsa-b1-inp').forEach(el=>save(`b1_${el.dataset.ticker}`,el.value));
root.querySelectorAll('.tsa-max-inp').forEach(el=>save(`maxblocks_${el.dataset.ticker}`,el.value));
root.querySelectorAll('.tsa-itemqty-inp').forEach(el=>save(`itemqty_${el.dataset.ticker}`,el.value));
root.querySelectorAll('.tsa-itemname-inp').forEach(el=>save(`itemname_${el.dataset.ticker}`,el.value));
root.querySelectorAll('.tsa-units-inp').forEach(el=>save(`units_${el.dataset.ticker}`,el.value));
applyTheme(getTheme()); updateStrip(root); resetTimer(root); loadAndRender(root);
});
root.querySelector('#tsa-cfg-reset').addEventListener('click',()=>{
if (!confirm('Reset all config to defaults? Your API key will be kept.')) return;
const key=getApiKey();
for (const stock of STOCK_DATA) {
['payout','b1','maxblocks','itemqty','itemname','units'].forEach(k=>GM_setValue(PREFIX+`${k}_${stock.ticker}`,null));
}
['ignored','ex_nerve','ex_energy','ex_happy','ex_other','ex_passive','refresh_mins','top_n','theme','budget','budget_pct','budget_mode','swap_no_sell','savings_goal'].forEach(k=>GM_setValue(PREFIX+k,null));
save('api_key',key);
populateConfig(root); applyTheme('default'); updateStrip(root); loadAndRender(root);
});
root.querySelector('#tsa-refresh-btn').addEventListener('click',()=>loadAndRender(root));
// Mobile: all sections except briefing default to collapsed
const mob = isMobile();
wireCollapse(root.querySelector('#tsa-briefing-title'), root.querySelector('#tsa-briefing-content'), 'collapse_briefing', 'open');
wireCollapse(root.querySelector('#tsa-score-title'), root.querySelector('#tsa-score-content'), 'collapse_score', mob?'collapsed':'collapsed');
wireCollapse(root.querySelector('#tsa-calendar-title'), root.querySelector('#tsa-calendar-content'), 'collapse_calendar', mob?'collapsed':'collapsed');
wireCollapse(root.querySelector('#tsa-savings-title'),root.querySelector('#tsa-savings-content'),'collapse_savings',mob?'collapsed':'open');
wireCollapse(root.querySelector('#tsa-recs-title'), root.querySelector('#tsa-recs-content'), 'collapse_recs', mob?'collapsed':'open');
wireCollapse(root.querySelector('#tsa-hold-title'), root.querySelector('#tsa-hold-content'), 'collapse_holdings',mob?'collapsed':'open');
wireCollapse(root.querySelector('#tsa-swap-title'), root.querySelector('#tsa-swap-content'), 'collapse_swaps', mob?'collapsed':'collapsed');
wireCollapse(root.querySelector('#tsa-table-title'), root.querySelector('#tsa-table-content'), 'collapse_table', mob?'collapsed':'collapsed');
}
// ─── Timer & injection ────────────────────────────────────────────────────────
function resetTimer(root) {
clearInterval(window._tsaRefreshTimer);
window._tsaRefreshTimer=setInterval(()=>loadAndRender(root),getRefreshMins()*60*1000);
}
function inject() {
if (!location.href.includes('sid=stocks')) return;
if (document.getElementById('tsa-root')) return;
let attempts=0;
const tryInsert=setInterval(()=>{
attempts++;
if (attempts>30||document.getElementById('tsa-root')){clearInterval(tryInsert);return;}
const smRoot=document.getElementById('stockmarketroot');
if (!smRoot||!smRoot.firstChild) return;
clearInterval(tryInsert);
const root=buildUI();
smRoot.insertBefore(root,smRoot.firstChild);
populateConfig(root);
applyTheme(getTheme());
updateStrip(root);
wireEvents(root);
loadAndRender(root);
resetTimer(root);
},500);
}
inject();
window.addEventListener('hashchange',()=>setTimeout(inject,400));
})();