Greasy Fork is available in English.

Torn Stock Advisor

Advises which stock blocks to buy next, ranked by daily ROI. Shows top N recommendation cards, your holdings, and a full collapsed rankings table.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Torn Stock Advisor
// @namespace    torn_stock_advisor
// @version      2.3.3
// @description  Advises which stock blocks to buy next, ranked by daily ROI. Shows top N recommendation cards, your holdings, and a full collapsed rankings table.
// @author       TheOddSod (2640064)
// @match        https://www.torn.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @connect      api.torn.com
// ==/UserScript==

// ─── Changelog ───────────────────────────────────────────────────────────────
// v2.3.3 — Removed: sidebar widget stripped out entirely. It had its own
//           independent API call path that diverged from the main scoring logic,
//           a stale/corrupt cache layer, and required every core data fix to be
//           separately replicated. Will be reintroduced cleanly once the core
//           dashboard data layer is stable and verified.
// v2.3.2 — Fix: removed ALL hardcoded cash dividend values (GRN, TCT, TMI, IOU,
//           TSB, CNC) — these were fabricated and wrong. TCT confirmed $1M/block
//           not $3M by user screenshot showing $3M total for 3 blocks.
//           Cash dividends are now read live from the DOM dividend column on the
//           stocks page (total ÷ held blocks = per-block value) and cached for
//           24h in GM storage. For unowned cash stocks the user must set the
//           value manually in Config ⚙ per-stock overrides after visiting the
//           stocks page — the config field placeholder now says "auto (from
//           stocks page)" to guide them. This approach can never be wrong
//           because it reads directly from what Torn itself displays.
// v2.3.1 — Fix: CONFIG_VERSION bumped to force migration clearing stale payout
//           overrides in GM storage. Users who installed before v1.2.8 still had
//           TCT locked at $1M/cycle (was corrected to $3M) and GRN at $4M/cycle
//           (corrected to $8M) — the migration hadn't re-run since v2.0.0. On
//           upgrade to v2.3.1 all per-stock payout overrides are cleared so the
//           corrected STOCK_DATA defaults take effect immediately.
//         — Fix: recommendation card "Payout:" label renamed to "This block earns:"
//           to make clear the figure is the incremental gain from this one block
//           only, not a total across all holdings. When the user already holds
//           other blocks of the same stock, a "Total after buy: X/day" line now
//           appears on the card showing the combined daily value post-purchase.
// v2.3.0 — Fix: sidebar now shows ALL held stocks regardless of payout type or
//           exclusion config — nerve/energy/happy/passive stocks were silently
//           dropped from the cache write, causing missing payout rows.
//         — Fix: sidebar crash when bonusInfo/bonus is undefined for a ticker
//           (e.g. LSC, CBD, PTS) — the entire cache write was being silently
//           swallowed by a try/catch, resulting in stale or partial data.
//         — Fix: fetchSidebarData crash when bonus object missing — same
//           undefined access on bonus.available caused silent failure.
//         — Fix: sidebar now makes its own independent API call when cache is
//           stale (>5 min) rather than relying on main dashboard having been
//           visited — daily payout total is recalculated from held blocks
//           using payoutCashValue/interval data from STOCK_DATA directly.
//         — Fix: sidebar stale state now shows last-known data with a stale
//           indicator rather than blank/loading forever on API errors.
//         — Fix: payout description for energy/nerve/happy stocks in sidebar
//           now uses STOCK_DATA values instead of hardcoded constants.
// v2.2.1 — Fix: sidebar payout list now iterates held rows (not bonusInfo keys)
//           so all held stocks appear — previously stocks missing from bonusInfo
//           (e.g. LSC, CBD, PTS) were silently omitted from the sidebar.
// v2.1.1 — Fix: sidebar cache now written from main dashboard render so payout
//           timing always matches the main dashboard exactly, rather than making
//           independent API calls that could show stale/different data.
// v2.1.0 — Feature: sidebar widget on all Torn pages. Shows daily payout total,
//           next payout countdowns, and savings plan progress. Makes own API
//           call to stay fresh. Toggle-able with remembered state. Hidden on
//           stocks page. Config panel gains sidebar section to toggle each
//           element independently.
// v2.0.5 — Fix: dollar sign removed from share counts in savings goal dropdown.
// v2.0.4 — Fix: savings plan goal cost now uses costToComplete from row data,
//           correctly deducting shares already partially held toward the goal
//           block. Header shows "already hold X of Y shares" when applicable.
// v2.0.3 — Fix: stepping stone liquidation value now uses incremental block
//           shares (incrShares) not total sharesHeld — prevents double-counting
//           when multiple blocks of the same stock are tagged.
// v2.0.2 — Fix: passive stocks now show stepping stone toggle. When a goal
//           is active, stocks with multiple held blocks expand to individual
//           rows so each block can be independently tagged as stepping stone.
// v2.0.1 — Fix: prices destructured from buildScores return so
//           renderSavingsPlan receives live share prices correctly.
// v2.0.0 — MAJOR: Savings Plan feature.
// v1.7.4 — Feature: swap advisor now shows next payout timing.
// v1.7.3 — Feature: disclaimer added to footer.
// v1.7.2 — Polish: config per-stock accordions sorted alphabetically.
// v1.7.1 — Polish: config per-stock accordions sorted (passive grouped).
// v1.7.0 — Fix: section spacing fixed.
// v1.6.9 — Polish: swap advisor visual separation.
// v1.6.8 — Polish: improved vertical spacing throughout.
// v1.6.7 — Fix: item price fetch switched to v2 market endpoint.
// v1.6.6 — Fix: LAG item name corrected.
// v1.6.5 — Fix: points market endpoint corrected.
// v1.6.4 — Feature: PTS auto-prices via live points market.
// v1.6.3 — Polish: holdings grid column widths.
// v1.6.2 — Polish: Greasy Fork ID set. Holdings CSS grid.
// v1.6.1 — Feature: "Never sell" list in config.
// v1.6.0 — Fix: ASS interval corrected to 7 days.
// v1.5.x — Various swap advisor, card click, and table fixes.
// v1.4.x — Budget filter feature.
// v1.3.x — Config version migration.
// v1.2.x — Threshold/payout corrections.
// v1.1.x — API/injection fixes.
// v1.0.0 — Initial release.
// ─────────────────────────────────────────────────────────────────────────────

(function () {
  'use strict';

  // ─── Duplicate injection guard ───────────────────────────────────────────────
  if (window._tsaLoaded) return;
  window._tsaLoaded = true;

  // ─── Constants ───────────────────────────────────────────────────────────────
  const PREFIX   = 'tsa_';
  const API_BASE = 'https://api.torn.com/v2';
  const SCRIPT   = 'TornStockAdvisor';

  // ─── Config version — migration runs after STOCK_DATA is defined ───────────
  // ─── Config version — bumped to 2.3.3 to clear ALL stale cash payout overrides.
  // payoutCashValue is now always 0 for cash stocks; values come from DOM reading.
  // Any stored override from previous hardcoded guesses must be wiped.
  const CONFIG_VERSION = '2.3.3';

  // Module-level ticker→stockId map, populated after first API call
  let tickerToId = {};
  // Cached last render data for savings plan re-render on toggle
  let lastRows   = [];
  let lastPrices = {};

  // ─── Master stock data ────────────────────────────────────────────────────────
  //
  // PAYOUT LOGIC (Torn Stocks 3.0):
  //   Each "increment" pays its own per-increment value independently.
  //   Block 2 gives a SECOND identical payout each cycle — not double total.
  //   Scoring: incremental cost (shares for THIS block × price)
  //            vs incremental daily value (payout / interval in days).
  //
  // INTERVALS:
  //   7  days: FHG, SYM, PRN, EWM, THS, LAG, BAG, MUN, PTS, EVL, MCS, CBD, ASS, LSC
  //   31 days: GRN, TCT, TMI, IOU, TSB, CNC, HRG, TCC
  //   0 (passive): TCP, TCM, TGP, IIL, TCI, WLT, SYS, ELT, MSG, WSU, LOS, YAZ, IST
  //
  // FIELDS:
  //   payoutInterval — days between payouts (7 or 31; 0 = passive, not scored)
  //   perIncrQty     — units paid per increment per interval
  //   payoutItemId   — Torn item ID for live market lookup (null = not an item)
  //   payoutCashValue— fixed $ per increment per interval (0 if item/passive)
  //   increments     — [{incr, threshold}] where threshold = TOTAL shares held

  const STOCK_DATA = [

    // ── 7-day active dividend stocks ─────────────────────────────────────────

    {
      ticker: 'FHG', stockId: 7, name: 'Feathery Hotels Group',
      payoutType: 'item', payoutInterval: 7,
      perIncrQty: 1, payoutItemName: 'Feathery Hotel Coupon', payoutItemId: 367, payoutCashValue: 0,
      payoutDesc: '1× Feathery Hotel Coupon per block, every 7 days',
      increments: [
        { incr: 1, threshold: 2000000   },
        { incr: 2, threshold: 6000000   },
        { incr: 3, threshold: 14000000  },
        { incr: 4, threshold: 30000000  },
        { incr: 5, threshold: 62000000  },
      ],
    },
    {
      ticker: 'SYM', stockId: 2, name: 'Symbiotic Ltd.',
      payoutType: 'item', payoutInterval: 7,
      perIncrQty: 1, payoutItemName: 'Drug Pack', payoutItemId: 370, payoutCashValue: 0,
      payoutDesc: '1× Drug Pack per block, every 7 days',
      increments: [
        { incr: 1, threshold: 500000    },
        { incr: 2, threshold: 1500000   },
        { incr: 3, threshold: 3500000   },
        { incr: 4, threshold: 7500000   },
        { incr: 5, threshold: 15500000  },
      ],
    },
    {
      ticker: 'PRN', stockId: 21, name: 'Performance Ribaldry',
      payoutType: 'item', payoutInterval: 7,
      perIncrQty: 1, payoutItemName: 'Erotic DVD', payoutItemId: null, payoutCashValue: 0,
      payoutDesc: '1× Erotic DVD per block, every 7 days',
      increments: [
        { incr: 1, threshold: 1000000   },
        { incr: 2, threshold: 3000000   },
        { incr: 3, threshold: 7000000   },
        { incr: 4, threshold: 15000000  },
        { incr: 5, threshold: 31000000  },
      ],
    },
    {
      ticker: 'EWM', stockId: 10, name: 'Eaglewood Mercenary',
      payoutType: 'item', payoutInterval: 7,
      perIncrQty: 1, payoutItemName: 'Box of Grenades', payoutItemId: null, payoutCashValue: 0,
      payoutDesc: '1× Box of Grenades per block, every 7 days',
      increments: [
        { incr: 1, threshold: 1000000   },
        { incr: 2, threshold: 3000000   },
        { incr: 3, threshold: 7000000   },
        { incr: 4, threshold: 15000000  },
        { incr: 5, threshold: 31000000  },
      ],
    },
    {
      ticker: 'THS', stockId: 20, name: 'Torn City Health Service',
      payoutType: 'item', payoutInterval: 7,
      perIncrQty: 2, payoutItemName: 'Box of Medical Supplies', payoutItemId: null, payoutCashValue: 0,
      payoutDesc: '2× Boxes of Medical Supplies per block, every 7 days',
      increments: [
        { incr: 1, threshold: 150000    },
        { incr: 2, threshold: 450000    },
        { incr: 3, threshold: 1050000   },
        { incr: 4, threshold: 2250000   },
        { incr: 5, threshold: 4650000   },
      ],
    },
    {
      ticker: 'LAG', stockId: 17, name: 'Legal Authorities Group',
      payoutType: 'item', payoutInterval: 7,
      perIncrQty: 1, payoutItemName: "Lawyer's Business Card", payoutItemId: 368, payoutCashValue: 0,
      payoutDesc: "1× Lawyer's Business Card per block, every 7 days",
      increments: [
        { incr: 1, threshold: 750000    },
        { incr: 2, threshold: 2250000   },
        { incr: 3, threshold: 5250000   },
        { incr: 4, threshold: 11250000  },
        { incr: 5, threshold: 23250000  },
      ],
    },
    {
      ticker: 'BAG', stockId: 27, name: "Big Al's Gun Shop",
      payoutType: 'item', payoutInterval: 7,
      perIncrQty: 1, payoutItemName: 'Ammunition Pack', payoutItemId: null, payoutCashValue: 0,
      payoutDesc: '1× Ammunition Pack per block, every 7 days',
      increments: [
        { incr: 1, threshold: 3000000   },
        { incr: 2, threshold: 9000000   },
        { incr: 3, threshold: 21000000  },
        { incr: 4, threshold: 45000000  },
        { incr: 5, threshold: 93000000  },
      ],
    },
    {
      ticker: 'MUN', stockId: 12, name: 'Munster Beverage Corp.',
      payoutType: 'item', payoutInterval: 7,
      perIncrQty: 1, payoutItemName: 'Six-Pack of Energy Drink', payoutItemId: null, payoutCashValue: 0,
      payoutDesc: '1× Six-Pack of Energy Drink per block, every 7 days',
      increments: [
        { incr: 1, threshold: 5000000   },
        { incr: 2, threshold: 15000000  },
        { incr: 3, threshold: 35000000  },
        { incr: 4, threshold: 75000000  },
        { incr: 5, threshold: 155000000 },
      ],
    },
    {
      ticker: 'PTS', stockId: 22, name: 'PointLess',
      // Points valued at live points market price × 100 points per block per 7 days
      payoutType: 'cash', payoutInterval: 7,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: '100 points per block, every 7 days (valued at live points market price)',
      increments: [
        { incr: 1, threshold: 10000000  },
        { incr: 2, threshold: 30000000  },
        { incr: 3, threshold: 70000000  },
        { incr: 4, threshold: 150000000 },
        { incr: 5, threshold: 310000000 },
      ],
    },
    {
      ticker: 'EVL', stockId: 26, name: 'Evil Ducks Candy Corp',
      payoutType: 'happy', payoutInterval: 7,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: '1000 happiness per block, every 7 days',
      increments: [
        { incr: 1, threshold: 100000    },
        { incr: 2, threshold: 300000    },
        { incr: 3, threshold: 700000    },
        { incr: 4, threshold: 1500000   },
        { incr: 5, threshold: 3100000   },
      ],
    },
    {
      ticker: 'MCS', stockId: 23, name: 'Mc Smoogle Corp',
      payoutType: 'energy', payoutInterval: 7,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      // 200 energy per block per 7 days
      payoutUnits: 200, payoutUnitLabel: 'energy',
      payoutDesc: '200 energy per block, every 7 days',
      increments: [
        { incr: 1, threshold: 350000    },
        { incr: 2, threshold: 1050000   },
        { incr: 3, threshold: 2450000   },
        { incr: 4, threshold: 5250000   },
        { incr: 5, threshold: 10850000  },
      ],
    },
    {
      ticker: 'CBD', stockId: 18, name: 'Herbal Releaf Co.',
      payoutType: 'nerve', payoutInterval: 7,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      // 50 nerve per block per 7 days
      payoutUnits: 50, payoutUnitLabel: 'nerve',
      payoutDesc: '50 nerve per block, every 7 days',
      increments: [
        { incr: 1, threshold: 350000    },
        { incr: 2, threshold: 1050000   },
        { incr: 3, threshold: 2450000   },
        { incr: 4, threshold: 5250000   },
        { incr: 5, threshold: 10850000  },
      ],
    },

    // ── 31-day active dividend stocks ────────────────────────────────────────

    {
      ticker: 'GRN', stockId: 16, name: 'Grain',
      payoutType: 'cash', payoutInterval: 31,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: 'Cash dividend per block, every 31 days — value read from DOM or set in config',
      increments: [
        { incr: 1, threshold: 500000    },
        { incr: 2, threshold: 1500000   },
        { incr: 3, threshold: 3500000   },
        { incr: 4, threshold: 7500000   },
        { incr: 5, threshold: 15500000  },
      ],
    },
    {
      ticker: 'TCT', stockId: 9, name: 'The Torn City Times',
      payoutType: 'cash', payoutInterval: 31,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: 'Cash dividend per block, every 31 days — value read from DOM or set in config',
      increments: [
        { incr: 1, threshold: 100000    },
        { incr: 2, threshold: 300000    },
        { incr: 3, threshold: 700000    },
        { incr: 4, threshold: 1500000   },
        { incr: 5, threshold: 3100000   },
      ],
    },
    {
      ticker: 'TMI', stockId: 5, name: 'TC Music Industries',
      payoutType: 'cash', payoutInterval: 31,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: 'Cash dividend per block, every 31 days — value read from DOM or set in config',
      increments: [
        { incr: 1, threshold: 6000000   },
        { incr: 2, threshold: 18000000  },
        { incr: 3, threshold: 42000000  },
        { incr: 4, threshold: 90000000  },
        { incr: 5, threshold: 186000000 },
      ],
    },
    {
      ticker: 'IOU', stockId: 14, name: 'Insured On Us',
      // Base $12M + class-action lawsuit chance — value listed is base only
      payoutType: 'cash', payoutInterval: 31,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: 'Cash dividend per block, every 31 days (+ lawsuit payout chance) — value read from DOM or set in config',
      increments: [
        { incr: 1, threshold: 3000000   },
        { incr: 2, threshold: 9000000   },
        { incr: 3, threshold: 21000000  },
        { incr: 4, threshold: 45000000  },
        { incr: 5, threshold: 93000000  },
      ],
    },
    {
      ticker: 'ASS', stockId: 24, name: 'Alcoholics Synonymous',
      payoutType: 'item', payoutInterval: 7,
      perIncrQty: 2, payoutItemName: 'Six-Pack of Alcohol', payoutItemId: null, payoutCashValue: 0,
      payoutDesc: '2× Six Pack of Alcohol per block, every 7 days',
      increments: [
        { incr: 1, threshold: 3000000   },
        { incr: 2, threshold: 9000000   },
        { incr: 3, threshold: 21000000  },
        { incr: 4, threshold: 45000000  },
        { incr: 5, threshold: 93000000  },
      ],
    },
    {
      ticker: 'TSB', stockId: 1, name: 'Torn & Shanghai Banking',
      payoutType: 'cash', payoutInterval: 31,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: 'Cash dividend per block, every 31 days — value read from DOM or set in config',
      increments: [
        { incr: 1, threshold: 3000000   },
        { incr: 2, threshold: 9000000   },
        { incr: 3, threshold: 21000000  },
        { incr: 4, threshold: 45000000  },
        { incr: 5, threshold: 93000000  },
      ],
    },
    {
      ticker: 'CNC', stockId: 34, name: 'Crude & Co',
      payoutType: 'cash', payoutInterval: 31,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: 'Cash dividend per block, every 31 days — value read from DOM or set in config',
      increments: [
        { incr: 1, threshold: 7500000   },
        { incr: 2, threshold: 22500000  },
        { incr: 3, threshold: 52500000  },
        { incr: 4, threshold: 112500000 },
        { incr: 5, threshold: 232500000 },
      ],
    },
    {
      ticker: 'LSC', stockId: 6, name: 'Lucky Shot Casino',
      payoutType: 'item', payoutInterval: 7,
      perIncrQty: 2, payoutItemName: 'Lottery Voucher', payoutItemId: null, payoutCashValue: 0,
      payoutDesc: '2× Lottery Vouchers per block, every 7 days',
      increments: [
        { incr: 1, threshold: 1500000   },
        { incr: 2, threshold: 4500000   },
        { incr: 3, threshold: 10500000  },
        { incr: 4, threshold: 22500000  },
        { incr: 5, threshold: 46500000  },
      ],
    },
    {
      ticker: 'HRG', stockId: 8, name: 'Home Retail Group',
      payoutType: 'other', payoutInterval: 31,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: '1× Random Property per block, every 31 days — set $ value in config',
      increments: [
        { incr: 1, threshold: 10000000  },
        { incr: 2, threshold: 30000000  },
        { incr: 3, threshold: 70000000  },
        { incr: 4, threshold: 150000000 },
        { incr: 5, threshold: 310000000 },
      ],
    },
    {
      ticker: 'TCC', stockId: 35, name: 'Torn City Clothing',
      payoutType: 'item', payoutInterval: 31,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: '1× Clothing Cache per block, every 31 days — set value in config',
      increments: [
        { incr: 1, threshold: 7500000   },
        { incr: 2, threshold: 22500000  },
        { incr: 3, threshold: 52500000  },
        { incr: 4, threshold: 112500000 },
        { incr: 5, threshold: 232500000 },
      ],
    },

    // ── Passive stocks (no active payout — excluded from scoring by default) ─

    {
      ticker: 'TCP', stockId: 13, name: 'TC Media Productions',
      payoutType: 'passive', payoutInterval: 0,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: 'Company sales boost (passive — set value in config to score)',
      increments: [ { incr: 1, threshold: 1000000 } ],
    },
    {
      ticker: 'TCM', stockId: 4, name: 'Torn City Motors',
      payoutType: 'passive', payoutInterval: 0,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: '10% racing skill gain boost (passive)',
      increments: [ { incr: 1, threshold: 1000000 } ],
    },
    {
      ticker: 'TGP', stockId: 19, name: 'Tell Group Plc.',
      payoutType: 'passive', payoutInterval: 0,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: 'Company advertising boost (passive)',
      increments: [ { incr: 1, threshold: 2500000 } ],
    },
    {
      ticker: 'IIL', stockId: 25, name: 'I Industries Ltd.',
      payoutType: 'passive', payoutInterval: 0,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: '50% coding time reduction (passive)',
      increments: [ { incr: 1, threshold: 1000000 } ],
    },
    {
      ticker: 'TCI', stockId: 15, name: 'Torn City Investments',
      payoutType: 'passive', payoutInterval: 0,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: '10% bank interest bonus (passive — hold for 7 days before banking)',
      increments: [ { incr: 1, threshold: 1500000 } ],
    },
    {
      ticker: 'WLT', stockId: 11, name: 'Wind Lines Travel',
      payoutType: 'passive', payoutInterval: 0,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: 'Private jet access (passive)',
      increments: [ { incr: 1, threshold: 9000000 } ],
    },
    {
      ticker: 'SYS', stockId: 3, name: 'Syscore MFG',
      payoutType: 'passive', payoutInterval: 0,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: 'Advanced firewall (passive)',
      increments: [ { incr: 1, threshold: 3000000 } ],
    },
    {
      ticker: 'ELT', stockId: 28, name: 'Empty Lunchbox Traders',
      payoutType: 'passive', payoutInterval: 0,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: '10% home upgrade discount (passive)',
      increments: [ { incr: 1, threshold: 5000000 } ],
    },
    {
      ticker: 'MSG', stockId: 29, name: 'Messaging Inc.',
      payoutType: 'passive', payoutInterval: 0,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: 'Free classified advertising (passive)',
      increments: [ { incr: 1, threshold: 300000 } ],
    },
    {
      ticker: 'WSU', stockId: 31, name: 'West Side University',
      payoutType: 'passive', payoutInterval: 0,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: '10% course time reduction (passive)',
      increments: [ { incr: 1, threshold: 1000000 } ],
    },
    {
      ticker: 'LOS', stockId: 32, name: 'Lo Squalo Waste',
      payoutType: 'passive', payoutInterval: 0,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: '25% mission reward bonus (passive)',
      increments: [ { incr: 1, threshold: 7500000 } ],
    },
    {
      ticker: 'YAZ', stockId: 33, name: 'Yazoo',
      payoutType: 'passive', payoutInterval: 0,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: 'Free banner advertising (passive)',
      increments: [ { incr: 1, threshold: 1000000 } ],
    },
    {
      ticker: 'IST', stockId: 30, name: 'International School TC',
      payoutType: 'passive', payoutInterval: 0,
      perIncrQty: 1, payoutItemId: null, payoutCashValue: 0,
      payoutDesc: 'Free education courses (passive)',
      increments: [ { incr: 1, threshold: 100000 } ],
    },
  ];

  // ─── Config migration (runs after STOCK_DATA is defined) ───────────────────
  (function migrateConfig() {
    const storedVersion = GM_getValue(PREFIX + 'cfg_version', '');
    if (storedVersion === CONFIG_VERSION) return;
    console.log(`[TSA] Config version ${storedVersion} → ${CONFIG_VERSION}: clearing stale threshold/payout overrides`);
    for (const stock of STOCK_DATA) {
      GM_setValue(PREFIX + `payout_${stock.ticker}`, null);
      for (const { incr } of stock.increments) {
        GM_setValue(PREFIX + `thresh_${stock.ticker}_${incr}`, null);
      }
    }
    GM_setValue(PREFIX + 'cfg_version', CONFIG_VERSION);
  })();

  // ─── CSS ──────────────────────────────────────────────────────────────────────
  GM_addStyle(`
    #tsa-root * { box-sizing: border-box; margin: 0; padding: 0; }
    #tsa-root { font-family: Arial, sans-serif; font-size: 13px; color: #e0e0e0;
                background: #16213e; border-radius: 6px; margin: 12px 0; overflow: hidden; }

    #tsa-header { background: #1a1a2e; border-bottom: 2px solid #e05a00;
                  border-radius: 6px 6px 0 0; padding: 10px 14px;
                  display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
    .tsa-title   { color: #ff7700; font-size: 15px; font-weight: bold; }
    .tsa-version { font-size: 10px; opacity: 0.5; font-weight: normal; }
    .tsa-updated { color: #888; font-size: 11px; margin-left: auto; }

    .tsa-btn-primary   { background: #e05a00; border: none; border-radius: 4px;
                         color: #fff; padding: 4px 10px; cursor: pointer; font-size: 12px; }
    .tsa-btn-primary:hover   { background: #ff7700; }
    .tsa-btn-secondary { background: #1a2a4a; border: 1px solid #2a4a7a; border-radius: 4px;
                         color: #aaa; padding: 3px 8px; cursor: pointer; font-size: 11px; }
    .tsa-btn-secondary:hover { background: #2a3a5a; color: #fff; }

    #tsa-config-strip { background: #16213e; border-bottom: 1px solid #1a2a4a;
                        padding: 7px 14px; display: flex; align-items: center;
                        gap: 8px; flex-wrap: wrap; font-size: 11px; color: #888; }
    .tsa-sep     { color: #2a2a4a; }
    .tsa-key-ok  { color: #44ee88; font-size: 10px; font-weight: bold; }
    .tsa-key-bad { color: #ff4444; font-size: 10px; font-weight: bold; }

    #tsa-config-panel { background: #111827; border-bottom: 2px solid #e05a00;
                        padding: 10px 12px; display: none; }
    #tsa-config-panel.open { display: block; }
    .tsa-cfg-label { font-size: 9px; color: #666; 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: #aaa; min-width: 160px; }
    .tsa-cfg-input { background: #0f3460; border: 1px solid #2a4a7a; border-radius: 4px;
                     color: #fff; padding: 4px 8px; font-size: 12px; }
    .tsa-cfg-input:focus { outline: none; border-color: #ff7700; }
    .tsa-cfg-input option { background: #0f1a30; color: #e0e0e0; }
    .tsa-row-overbudget td { opacity: 0.4; }
    .tsa-cfg-note { font-size: 10px; color: #555; 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: #aaa; }

    .tsa-accord { border: 1px solid #1a2a4a; border-radius: 4px; margin-bottom: 4px; }
    .tsa-accord-hdr { background: #0f1a30; padding: 5px 10px; cursor: pointer;
                      display: flex; align-items: center; justify-content: space-between;
                      font-size: 11px; color: #aaa; border-radius: 4px; user-select: none; }
    .tsa-accord-hdr:hover { background: #1a2a4a; color: #fff; }
    .tsa-accord-ticker { background: #1a2a4a; color: #7aadff; 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: #0a1020;
                       border-top: 1px solid #1a2a4a; }
    .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: #666; min-width: 130px; }
    .tsa-incr-row .tsa-cfg-input { width: 130px; }
    .tsa-incr-note { font-size: 10px; color: #444; }

    #tsa-stats-bar { background: #0f1a30; border-bottom: 1px solid #1a2a4a;
                     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: #aaa; text-transform: uppercase; letter-spacing: .5px; }
    .tsa-stat-value { font-size: 16px; font-weight: bold; color: #ff7700; }

    #tsa-body  { padding: 16px 14px; }
    #tsa-error { background: #2a0000; border: 1px solid #882200; border-radius: 5px;
                 padding: 8px 12px; margin-bottom: 10px; font-size: 12px;
                 color: #ff8866; display: none; }
    #tsa-loading { text-align: center; padding: 20px; color: #555; font-size: 12px; display: none; }
    .tsa-spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid #444;
                   border-top-color: #ff7700; border-radius: 50%;
                   animation: tsa-spin .7s linear infinite; vertical-align: middle; margin-right: 6px; }
    @keyframes tsa-spin { to { transform: rotate(360deg); } }

    .tsa-section { margin-bottom: 8px; padding-top: 18px; border-top: 1px solid #1a2a4a; }
    .tsa-section:first-child { padding-top: 0; border-top: none; }
    .tsa-section-title { color: #ff7700; font-size: 12px; font-weight: bold;
                         text-transform: uppercase; letter-spacing: 1px;
                         border-bottom: 1px solid #333; 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: '▾'; 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: #1a1a2e; border: 1px solid #2a2a4a; border-radius: 6px;
                padding: 12px 14px; position: relative; }
    .tsa-card[title]:hover { border-color: #ff7700; background: #1e1e38; }
    .tsa-card-rank { position: absolute; top: 8px; right: 10px; font-size: 12px;
                     font-weight: bold; color: #333; }
    .tsa-rank-gold   { color: #ffcc44; }
    .tsa-rank-silver { color: #aaa; }
    .tsa-rank-bronze { color: #cc7722; }
    .tsa-card-head { font-size: 13px; font-weight: bold; color: #ccc; margin-bottom: 3px; padding-right: 24px; }
    .tsa-card-sub  { margin-bottom: 8px; }
    .tsa-card-line { font-size: 11px; color: #888; margin-bottom: 3px; }
    .tsa-card-line strong { color: #e0e0e0; }
    .tsa-card-roi  { font-size: 10px; color: #ff7700; font-weight: bold; margin-top: 7px; }
    .tsa-card-partial { border-top: 1px solid #2a2a4a; margin-top: 5px; padding-top: 4px;
                        font-size: 10px; color: #ffaa00; }

    /* Holdings */
    .tsa-hold-sublabel { font-size: 9px; color: #555; 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 #0d1525;
                    font-size: 11px; }
    .tsa-hold-row:last-child { border-bottom: none; }
    .tsa-hold-name { color: #ccc; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .tsa-hold-val  { color: #44ee88; text-align: right; }
    .tsa-hold-partial-txt { color: #ffaa00; 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 #0d1525;
                            font-size: 11px; }
    .tsa-hold-partial-row:last-child { border-bottom: none; }

    /* Table */
    table.tsa-table { width: 100%; border-collapse: collapse; font-size: 11px; }
    table.tsa-table th { color: #666; font-weight: normal; font-size: 10px; text-transform: uppercase;
                         letter-spacing: .5px; padding: 7px 10px; border-bottom: 2px solid #1a2a4a;
                         text-align: left; white-space: nowrap; background: #0f1a30; }
    table.tsa-table th.r, table.tsa-table td.r { text-align: right; }
    table.tsa-table td { padding: 7px 10px; border-bottom: 1px solid #0d1525; color: #ccc;
                         white-space: nowrap; vertical-align: middle; }
    table.tsa-table tbody tr:nth-child(even) td { background: #111827; }
    table.tsa-table tbody tr:nth-child(odd)  td { background: #0f1520; }
    table.tsa-table tr:hover td { background: #1e1e36 !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 th:first-child,
    table.tsa-table td:first-child { text-align: center; padding: 5px 6px; border-right: 1px solid #1a2a4a; }

    /* 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: #004422; color: #44ee88; }
    .tsa-badge-warn    { background: #2a1a00; color: #ff9900; }
    .tsa-badge-info    { background: #0f3460; color: #7aadff; }
    .tsa-badge-muted   { background: #111;    color: #444; }
    .tsa-badge-energy  { background: #1a2a00; color: #aaee44; }
    .tsa-badge-nerve   { background: #330033; color: #dd44dd; }
    .tsa-badge-happy   { background: #2a1a00; color: #ffcc44; }
    .tsa-badge-passive { background: #1a1a2e; color: #555; }
    .tsa-badge-cash    { background: #004422; color: #44ee88; }
    .tsa-badge-item    { background: #0f3460; color: #7aadff; }
    .tsa-badge-other   { background: #111;    color: #888; }

    .tsa-ticker { font-family: monospace; font-weight: bold; color: #fff;
                  background: #0f1a30; padding: 1px 5px; border-radius: 3px; font-size: 11px; }
    .tsa-rank-num { font-size: 11px; font-weight: bold; color: #555;
                    display: inline-block; width: 18px; text-align: right; }

    /* Swap table */
    table.tsa-swap-table { width: 100%; border-collapse: collapse; font-size: 11px; }
    table.tsa-swap-table th { color: #666; font-weight: normal; font-size: 10px; text-transform: uppercase;
                              letter-spacing: .5px; padding: 5px 10px; border-bottom: 2px solid #1a2a4a;
                              text-align: left; white-space: nowrap; background: #0f1a30; }
    table.tsa-swap-table td { padding: 5px 10px; border-bottom: 1px solid #0d1525; color: #ccc;
                              white-space: nowrap; vertical-align: middle; font-size: 11px; }
    table.tsa-swap-table tbody tr:nth-child(even) td { background: #111827; }
    table.tsa-swap-table tbody tr:nth-child(odd)  td { background: #0f1520; }
    table.tsa-swap-table tr:hover td { background: #1e1e36 !important; }
    .tsa-swap-gain-pos { color: #44ee88; font-weight: bold; }
    .tsa-swap-gain-neg { color: #ff4444; }
    .tsa-swap-arrow { color: #ff7700; font-size: 12px; margin: 0 4px; }
    .tsa-swap-note  { font-size: 10px; color: #555; margin-top: 14px; }

    /* Savings plan */
    #tsa-savings-banner { margin-bottom: 12px; padding: 10px 14px; background: #001a00;
                          border: 2px solid #44ee88; border-radius: 6px; font-size: 12px;
                          color: #44ee88; display: flex; align-items: flex-start; gap: 10px;
                          animation: tsa-pulse-green 2s ease-in-out infinite; }
    @keyframes tsa-pulse-green {
      0%,100% { box-shadow: 0 0 0 0 rgba(68,238,136,0); }
      50%      { box-shadow: 0 0 8px 2px rgba(68,238,136,0.25); }
    }
    #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: #88ddaa; margin-top: 4px; }
    #tsa-savings-banner .tsa-ban-dismiss { background: none; border: 1px solid #44ee88;
                          color: #44ee88; 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: #44ee88; color: #000; }

    .tsa-plan-goal    { background: #0f1a30; border: 1px solid #1a2a4a; 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: #fff; }
    .tsa-plan-goal-meta { font-size: 11px; color: #666; }
    .tsa-plan-progress  { height: 8px; background: #0a1020; border-radius: 4px;
                          overflow: hidden; margin: 6px 0; }
    .tsa-plan-progress-fill { height: 100%; border-radius: 4px; background: #ff7700;
                               transition: width .4s ease; }
    .tsa-plan-progress-fill.done { background: #44ee88; }
    .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: #555; text-transform: uppercase; letter-spacing: .5px; }
    .tsa-plan-stat-val { font-weight: bold; color: #ff7700; }
    .tsa-plan-recs  { margin-top: 12px; }
    .tsa-plan-rec-hd { font-size: 9px; color: #555; text-transform: uppercase;
                       letter-spacing: .5px; margin-bottom: 6px; }
    .tsa-plan-rec-row { display: flex; align-items: center; gap: 8px; padding: 6px 0;
                        border-bottom: 1px solid #0d1525; font-size: 11px; flex-wrap: wrap; }
    .tsa-plan-rec-row:last-child { border-bottom: none; }

    /* Stepping stone toggle in holdings */
    .tsa-step-toggle { background: none; border: 1px solid #2a2a4a; border-radius: 3px;
                       color: #444; font-size: 10px; padding: 1px 6px; cursor: pointer;
                       transition: all .15s; white-space: nowrap; }
    .tsa-step-toggle:hover { border-color: #ff7700; color: #ff7700; }
    .tsa-step-toggle.active { background: #1a2a00; border-color: #44aa44; color: #44ee88; }

    #tsa-disclaimer { border-top: 1px solid #1a2a4a; padding: 7px 14px;
                      font-size: 10px; color: #886600; background: #1a1500;
                      text-align: center; line-height: 1.5; }
    #tsa-footer { border-top: 1px solid #1a2a4a; padding: 6px 12px;
                  font-size: 10px; color: #444; 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; }
  `);

  // ─── Helpers ─────────────────────────────────────────────────────────────────

  function fmtMoney(v) {
    if (v === null || v === undefined || isNaN(v)) return '—';
    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 '—';
    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 getSavingsInvest = () => parseFloat(load('savings_invest', '0')) || 0;

  // Stepping stone tags
  function isSteppingStone(ticker, incr) {
    return load(`step_${ticker}_${incr}`, '') === '1';
  }
  function setSteppingStone(ticker, incr, val) {
    save(`step_${ticker}_${incr}`, val ? '1' : '');
  }

  function getPayoutOverride(ticker, def) {
    const v = parseFloat(load(`payout_${ticker}`, ''));
    return (!isNaN(v) && v > 0) ? v : def;
  }
  function getThreshOverride(ticker, incr, def) {
    const v = parseInt(load(`thresh_${ticker}_${incr}`, ''), 10);
    return (!isNaN(v) && v > 0) ? v : def;
  }

  // ─── Payout short description builder ────────────────────────────────────────
  // Used by the config accordion placeholder and sidebar if re-added in future.
  // heldBlocks: number of complete blocks the user holds for this stock.
  // Returns a plain string like "2× Drug Pack", "$50.00M", "200 energy", etc.
  function buildPayoutShort(stock, heldBlocks) {
    const qty = (stock.perIncrQty || 1) * heldBlocks;
    if (stock.ticker === 'PTS') {
      return `${heldBlocks * 100} pts`;
    }
    if (stock.payoutType === 'cash') {
      // payoutCashValue is always 0 — use DOM-read cache or user config override
      const perBlockVal = getPayoutOverride(stock.ticker, (() => {
        try { return JSON.parse(load('dom_dividends', '{}'))[stock.ticker] || 0; } catch { return 0; }
      })());
      return perBlockVal > 0 ? fmtMoney(perBlockVal * heldBlocks) : 'see stocks page';
    }
    if (stock.payoutType === 'energy') {
      return `${(stock.payoutUnits || 200) * heldBlocks} energy`;
    }
    if (stock.payoutType === 'nerve') {
      return `${(stock.payoutUnits || 50) * heldBlocks} nerve`;
    }
    if (stock.payoutType === 'happy') {
      return `${1000 * heldBlocks} happy`;
    }
    if (stock.payoutItemName) {
      // Shorten item names for compact display
      const itemShort = stock.payoutItemName
        .replace('Feathery Hotel Coupon', 'Hotel Coupon')
        .replace('Six-Pack of Alcohol', 'Six-Pack')
        .replace('Six-Pack of Energy Drink', 'Six-Pack')
        .replace('Box of Medical Supplies', 'Med Box')
        .replace('Box of Grenades', 'Grenade Box')
        .replace('Ammunition Pack', 'Ammo Pack')
        .replace('Lottery Voucher', 'Lotto Voucher')
        .replace("Lawyer's Business Card", 'Lawyer Card')
        .replace('Clothing Cache', 'Clothing Cache')
        .replace('Drug Pack', 'Drug Pack');
      return `${qty}× ${itemShort}`;
    }
    // Fallback for 'other' type (e.g. HRG property)
    return stock.payoutDesc?.split(',')[0] || '';
  }

  // ─── 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) {
    // v2 itemmarket first, then v1 bazaar fallback, then torn/items market_value
    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 listings = data.itemmarket || [];
        if (listings.length) return listings[0].cost || listings[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 listings = data.bazaar || [];
        if (listings.length) return listings[0].cost || listings[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 && item.market_value) return item.market_value;
      }
    } catch { /* give up */ }

    return 0;
  }

  /**
   * Resolve item names to Torn item IDs. Cached for 24h to avoid repeated calls.
   * Returns map of { itemName (lowercase) -> itemId }.
   */
  async function resolveItemIds(apiKey, itemNames) {
    const CACHE_KEY  = 'item_id_cache';
    const CACHE_TIME = 'item_id_cache_time';
    const TTL_MS     = 24 * 60 * 60 * 1000;

    const cachedTime = load(CACHE_TIME, 0);
    const cachedData = load(CACHE_KEY, '{}');
    let nameToId = {};
    try { nameToId = JSON.parse(cachedData); } catch { nameToId = {}; }

    const needsRefresh = (Date.now() - cachedTime) > TTL_MS;
    const allCached    = itemNames.every(n => nameToId[n.toLowerCase()] !== undefined);

    if (!needsRefresh && allCached) 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) {
        console.warn('[TSA] item lookup error:', data.error);
        return nameToId;
      }
      const items = data.items || {};
      for (const [id, item] of Object.entries(items)) {
        const name = (item.name || '').toLowerCase();
        nameToId[name] = parseInt(id, 10);
      }
      save(CACHE_KEY, JSON.stringify(nameToId));
      save(CACHE_TIME, Date.now());
    } catch (e) {
      console.warn('[TSA] failed to fetch item IDs:', e);
    }
    return nameToId;
  }

  async function fetchUserStocks(apiKey) {
    const [userData, tornData] = await Promise.all([
      apiFetch('/user/stocks', apiKey),
      apiFetch('/torn/stocks', apiKey),
    ]);

    // Build name→ticker lookup from STOCK_DATA
    const nameToTicker = {};
    for (const s of STOCK_DATA) {
      nameToTicker[s.name.toLowerCase()] = s.ticker;
    }

    // Build id→ticker and ticker→price from /torn/stocks
    const idToTicker = {};
    const prices     = {};

    const tornStocksRaw = tornData.stocks || {};
    const tornStocksArr = Array.isArray(tornStocksRaw)
      ? tornStocksRaw
      : Object.entries(tornStocksRaw).map(([id, s]) => ({ ...s, _id: id }));

    for (const s of tornStocksArr) {
      const id     = String(s.id || s._id || '');
      const acronym = (s.acronym || '').toUpperCase();
      const ticker  = acronym || nameToTicker[(s.name || '').toLowerCase()] || '';
      if (id && ticker) idToTicker[id] = ticker;
      const market = (typeof s.market === 'object' && s.market) ? s.market : {};
      const price  = parseFloat(market.price || market.current_price || s.price || s.current_price || 0);
      if (ticker && price > 0) prices[ticker] = price;
    }

    // Populate module-level tickerToId map
    for (const [id, tkr] of Object.entries(idToTicker)) {
      tickerToId[tkr] = id;
    }

    // DOM price reader — supplement API prices with what's visible on-page
    const domRows = document.querySelectorAll('#stockmarketroot table tr');
    for (const row of domRows) {
      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 ticker = m[1];
        const numMatch = (cells[i + 1]?.textContent || '').replace(/,/g, '').match(/[\d]+\.?\d*/);
        const priceVal = numMatch ? parseFloat(numMatch[0]) : 0;
        if (priceVal > 0) prices[ticker] = priceVal;
        break;
      }
    }

    // Build ticker→shares and bonus timing from /user/stocks
    const holdings  = {};
    const bonusInfo = {};

    const userStocksRaw = userData.stocks || {};
    const userStocksArr = Array.isArray(userStocksRaw)
      ? userStocksRaw
      : Object.entries(userStocksRaw).map(([id, s]) => ({ ...s, _id: id }));

    for (const s of userStocksArr) {
      const id     = String(s.id || s._id || '');
      const ticker = idToTicker[id];
      if (!ticker) continue;
      const n = typeof s.shares === 'number' ? s.shares : 0;
      if (n > 0) holdings[ticker] = (holdings[ticker] || 0) + n;
      // Store bonus info only if the object exists — never assume it's present
      if (s.bonus && typeof s.bonus === 'object') {
        bonusInfo[ticker] = {
          available:  !!s.bonus.available,
          progress:   s.bonus.progress  || 0,
          frequency:  s.bonus.frequency || 0,
        };
      }
    }

    // DOM fallback for holdings
    if (Object.keys(holdings).length === 0) {
      const tableRows = document.querySelectorAll('#stockmarketroot table tr');
      for (const row of tableRows) {
        const cells = row.querySelectorAll('td');
        if (cells.length < 4) continue;
        const tickerMatch = (cells[0]?.textContent || '').match(/\(([A-Z]{2,4})\)/);
        if (!tickerMatch) continue;
        const ticker    = tickerMatch[1];
        const ownedText = (cells[3]?.textContent || '').replace(/[$,]/g, '');
        const nums      = ownedText.match(/\d+/g);
        if (nums && nums.length >= 2) {
          const shares = parseInt(nums[nums.length - 1], 10);
          if (!isNaN(shares) && shares > 0) holdings[ticker] = shares;
        }
      }
    }

    // ── DOM dividend reader ────────────────────────────────────────────────────
    // Reads cash dividend values directly from the rendered stocks table so we
    // never rely on hardcoded guesses. The dividend column shows the TOTAL payout
    // for all held blocks, e.g. TCT with 3 blocks shows $3,000,000.
    // We divide by heldBlocks to get the per-block value and cache it with a 24h
    // TTL so it remains available after navigating away from the stocks page.
    //
    // Table column layout (confirmed from DOM inspection):
    //   0: Name cell containing "(TCK) Stock Name"
    //   1: Share price
    //   2: 24h change
    //   3: Dividend cell — contains text like "$3,000,000\n700,000" when held
    const DOM_DIV_CACHE_KEY = 'dom_dividends';
    const DOM_DIV_TTL_MS    = 24 * 60 * 60 * 1000;

    // Load existing cache — may contain values from a prior stocks-page visit
    let domDividends = {};
    try {
      const cached    = JSON.parse(load(DOM_DIV_CACHE_KEY, '{}'));
      const cacheTime = load(DOM_DIV_CACHE_KEY + '_ts', 0);
      if ((Date.now() - cacheTime) < DOM_DIV_TTL_MS) domDividends = cached;
    } catch { domDividends = {}; }

    // DOM read is only possible on the stocks page itself
    if (location.href.includes('sid=stocks')) {
      const freshDomDivs = {};
      const tableRows    = document.querySelectorAll('#stockmarketroot table tr');
      for (const row of tableRows) {
        const cells = [...row.querySelectorAll('td')];
        if (cells.length < 4) continue;
        const tickerMatch = (cells[0]?.textContent || '').match(/\(([A-Z]{2,4})\)/);
        if (!tickerMatch) continue;
        const ticker  = tickerMatch[1];
        const stockDef = STOCK_DATA.find(s => s.ticker === ticker);
        if (!stockDef || stockDef.payoutType !== 'cash') continue;

        // Extract the total dividend $ amount from the dividend cell
        const divText  = (cells[3]?.textContent || '').replace(/,/g, '');
        const divMatch = divText.match(/\$(\d+)/);
        if (!divMatch) continue;
        const totalDividend = parseInt(divMatch[1], 10);
        if (!totalDividend || totalDividend <= 0) continue;

        // Divide total by number of complete blocks held to derive per-block value
        const sharesHeld = holdings[ticker] || 0;
        let heldBlocks   = 0;
        for (const { threshold } of stockDef.increments) {
          if (sharesHeld >= threshold) heldBlocks++;
        }
        if (heldBlocks <= 0) continue;

        const perBlockValue = Math.round(totalDividend / heldBlocks);
        if (perBlockValue > 0) {
          freshDomDivs[ticker] = perBlockValue;
          console.log(`[TSA] DOM dividend: ${ticker} = ${perBlockValue}/block (${totalDividend} total / ${heldBlocks} blocks)`);
        }
      }

      // Merge fresh readings into cache — unread tickers keep their previous values
      if (Object.keys(freshDomDivs).length > 0) {
        domDividends = { ...domDividends, ...freshDomDivs };
        try {
          save(DOM_DIV_CACHE_KEY, JSON.stringify(domDividends));
          save(DOM_DIV_CACHE_KEY + '_ts', Date.now());
        } catch { /* ignore */ }
      }
    }

    return { holdings, prices, bonusInfo, domDividends };
  }

  // ─── Scoring ──────────────────────────────────────────────────────────────────

  async function buildScores(apiKey) {
    const { holdings, prices, bonusInfo, domDividends } = await fetchUserStocks(apiKey);

    const itemStocks = STOCK_DATA.filter(s => s.payoutType === 'item');
    const itemNames  = [...new Set(itemStocks.map(s => s.payoutItemName).filter(Boolean))];
    const nameToId   = await resolveItemIds(apiKey, itemNames);

    // Assign resolved IDs (in-memory only)
    const resolvedIds = {};
    for (const stock of itemStocks) {
      if (stock.payoutItemName) {
        const resolvedId = nameToId[stock.payoutItemName.toLowerCase()];
        resolvedIds[stock.ticker] = resolvedId || stock.payoutItemId;
      } else if (stock.payoutItemId) {
        resolvedIds[stock.ticker] = stock.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, perIncrQty,
              payoutItemId, payoutCashValue, payoutDesc, increments } = stock;

      const ignored    = ignoredTickers.includes(ticker);
      const excluded   = excludeMap[payoutType] || false;
      const sharesHeld = holdings[ticker] || 0;
      const price      = prices[ticker]   || 0;

      let incrValue = 0;
      if (payoutType === 'item') {
        const resolvedId = resolvedIds[ticker] || payoutItemId;
        const unitPrice  = resolvedId ? (itemPrices[resolvedId] || 0) : 0;
        incrValue = getPayoutOverride(ticker, unitPrice * perIncrQty);
      } else if (ticker === 'PTS' && pointsPrice > 0) {
        incrValue = getPayoutOverride(ticker, pointsPrice * 100);
      } else if (payoutType === 'cash') {
        // Priority: user config override > DOM-read live value > 0
        // DOM value is read from the stocks page dividend column and cached for 24h.
        // payoutCashValue is always 0 for cash stocks — we do not hardcode these.
        const domVal = domDividends[ticker] || 0;
        incrValue = getPayoutOverride(ticker, domVal);
      } else {
        incrValue = getPayoutOverride(ticker, payoutCashValue);
      }

      const dailyValue = payoutInterval > 0 ? incrValue / payoutInterval : 0;

      for (let i = 0; i < increments.length; i++) {
        const { incr, threshold: defThresh } = increments[i];
        const threshold  = getThreshOverride(ticker, incr, defThresh);
        const prevThresh = i > 0
          ? getThreshOverride(ticker, increments[i-1].incr, 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 = (i === 0 || sharesHeld >= prevThresh) ? 'next' : 'future';

        const budget    = getBudget();
        const budgetMax = budget > 0 ? budget * (getBudgetPct() / 100) : Infinity;
        const overBudget = budget > 0 && costToComplete > budgetMax;

        const scoreable = !ignored && !excluded && status !== 'held' && dailyValue > 0 && incrCost > 0;
        const dailyROI  = scoreable ? dailyValue / incrCost : 0;

        rows.push({
          ticker, name, incr,
          threshold, prevThresh, incrShares, incrCost,
          sharesHeld, sharesNeeded, costToComplete,
          price, payoutType, payoutInterval, payoutDesc,
          incrValue, dailyValue,
          status, ignored, excluded, scoreable, dailyROI, overBudget,
          score: 0,
        });
      }
    }

    // Normalise scores to 0–10
    const scoreable = rows.filter(r => r.scoreable);
    const maxROI = scoreable.length ? Math.max(...scoreable.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:#44ee88;font-weight:bold">Ready to collect now</span>';
    if (!info.frequency || info.frequency <= 0) return null;
    const daysLeft = info.frequency - info.progress;
    if (daysLeft <= 0) return '<span style="color:#44ee88;font-weight:bold">Ready to collect now</span>';
    return `<span style="color:#ffaa00">Next payout in ${daysLeft}d</span> <span style="color:#555;font-size:10px">(${info.progress}/${info.frequency} days into cycle)</span>`;
  }

  function buildSwaps(rows) {
    const noSellTickers = getSwapNoSell();
    const heldByTicker = {};
    for (const r of rows.filter(r => r.status === 'held' && !r.ignored && r.dailyValue > 0 && !noSellTickers.includes(r.ticker))) {
      if (!heldByTicker[r.ticker]) heldByTicker[r.ticker] = [];
      heldByTicker[r.ticker].push(r);
    }

    const targets = rows.filter(r =>
      r.scoreable && r.score > 0 && r.costToComplete > 0 && r.dailyValue > 0
    );

    function makeSwap(sellTicker, sellName, sellType, sellDesc, cashReleased,
                      dailyLost, interval, target, extraSells) {
      if (target.ticker === sellTicker) return null;
      if (target.costToComplete > cashReleased) return null;
      const netDailyGain = target.dailyValue - dailyLost;
      if (netDailyGain <= 0) return null;
      const transCost   = dailyLost * (interval || 1);
      const paybackDays = Math.ceil(transCost / netDailyGain);
      return {
        sellTicker, sellName, sellType, sellDesc, cashReleased,
        dailyLost, transCost,
        leftoverCash: cashReleased - target.costToComplete,
        target, netDailyGain, paybackDays,
        extraSells: extraSells || [],
        combined: false,
      };
    }

    const swaps = [];
    const sellProfiles = [];

    for (const [sellTicker, heldBlocks] of Object.entries(heldByTicker)) {
      const sorted    = [...heldBlocks].sort((a, b) => b.incr - a.incr);
      const topBlock  = sorted[0];
      const price     = topBlock.price;
      if (!price || price <= 0) continue;

      const totalShares   = topBlock.sharesHeld;
      const cashFullSell  = totalShares * price;
      const dailyLostFull = heldBlocks.reduce((s, r) => s + r.dailyValue, 0);
      const maxInterval   = Math.max(...heldBlocks.map(r => r.payoutInterval || 0));

      const fullTargets = targets
        .map(t => makeSwap(sellTicker, topBlock.name, 'full',
                           `Sell all ${fmtShares(totalShares)} shares`,
                           cashFullSell, dailyLostFull, maxInterval, t, []))
        .filter(Boolean)
        .sort((a, b) => b.netDailyGain - a.netDailyGain)
        .slice(0, 2);
      swaps.push(...fullTargets);

      sellProfiles.push({
        ticker: sellTicker, name: topBlock.name,
        cash: cashFullSell, dailyLost: dailyLostFull, interval: maxInterval,
      });

      if (topBlock.prevThresh > 0) {
        const sellShares   = totalShares - topBlock.prevThresh;
        const cashSellDown = sellShares * price;
        const dailyLostDown = topBlock.dailyValue;
        if (cashSellDown > 0) {
          const downTargets = targets
            .map(t => makeSwap(sellTicker, topBlock.name, 'down',
                               `Drop to Block ${topBlock.incr - 1} (sell ${fmtShares(sellShares)} shares)`,
                               cashSellDown, dailyLostDown, topBlock.payoutInterval || 1, t, []))
            .filter(Boolean)
            .sort((a, b) => b.netDailyGain - a.netDailyGain)
            .slice(0, 2);
          swaps.push(...downTargets);
        }
      }
    }

    // Combined sell detection
    const profiles = sellProfiles;
    for (let i = 0; i < Math.min(profiles.length, 8); i++) {
      for (let j = i + 1; j < Math.min(profiles.length, 8); j++) {
        const pA = profiles[i];
        const pB = profiles[j];
        const combinedCash     = pA.cash + pB.cash;
        const combinedLost     = pA.dailyLost + pB.dailyLost;
        const combinedInterval = Math.max(pA.interval, pB.interval);

        const combinedTargets = targets
          .filter(t =>
            t.ticker !== pA.ticker &&
            t.ticker !== pB.ticker &&
            t.costToComplete > pA.cash &&
            t.costToComplete > pB.cash &&
            t.costToComplete <= combinedCash
          )
          .map(t => {
            const netDailyGain = t.dailyValue - combinedLost;
            if (netDailyGain <= 0) return null;
            const transCost   = combinedLost * combinedInterval;
            const paybackDays = Math.ceil(transCost / netDailyGain);
            return {
              sellTicker:   `${pA.ticker}+${pB.ticker}`,
              sellName:     `${pA.name} + ${pB.name}`,
              sellType:     'combined',
              sellDesc:     `Sell all ${pA.ticker} + all ${pB.ticker}`,
              cashReleased: combinedCash,
              dailyLost:    combinedLost,
              transCost,
              leftoverCash: combinedCash - t.costToComplete,
              target:       t,
              netDailyGain,
              paybackDays,
              extraSells:   [pA.ticker, pB.ticker],
              combined:     true,
            };
          })
          .filter(Boolean)
          .sort((a, b) => b.netDailyGain - a.netDailyGain)
          .slice(0, 1);

        swaps.push(...combinedTargets);
      }
    }

    swaps.sort((a, b) => {
      const aPb = isFinite(a.paybackDays) ? a.paybackDays : 999999;
      const bPb = isFinite(b.paybackDays) ? b.paybackDays : 999999;
      return aPb - bPb;
    });

    return swaps;
  }

  // ─── 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">v2.3.3</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>Ignored: <span id="tsa-strip-ignored" style="color:#ff7700">None</span></span>
        <span class="tsa-sep">|</span>
        <span>Excl: <span id="tsa-strip-excl" style="color:#ff7700">None</span></span>
        <span class="tsa-sep">|</span>
        <span id="tsa-strip-budget-wrap">Budget: <span id="tsa-strip-budget" style="color:#ff7700">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:#ff7700"></span></span>
        <span class="tsa-sep" id="tsa-strip-nosell-sep" style="display:none">|</span>
        <span>Refresh: <span id="tsa-strip-refresh" style="color:#aaa">5 min</span></span>
      </div>

      <div id="tsa-config-panel">
        <span class="tsa-cfg-label">API Key — requires <strong style="color:#ff7700">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 — removed from scoring and table)</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 these stocks (comma-separated tickers — excluded from Swap Advisor sell candidates)</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 (still visible in table)</span>
        <div class="tsa-cfg-check-row"><input type="checkbox" id="tsa-ex-nerve">   <label for="tsa-ex-nerve">Nerve payouts (e.g. CBD)</label></div>
        <div class="tsa-cfg-check-row"><input type="checkbox" id="tsa-ex-energy">  <label for="tsa-ex-energy">Energy payouts (e.g. MCS)</label></div>
        <div class="tsa-cfg-check-row"><input type="checkbox" id="tsa-ex-happy">   <label for="tsa-ex-happy">Happiness payouts (e.g. 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 (points, properties — PTS, LSC, HRG)</label></div>
        <div class="tsa-cfg-check-row"><input type="checkbox" id="tsa-ex-passive"> <label for="tsa-ex-passive">Passive stocks (no active payout — TCP, TCI, WLT etc.)</label></div>

        <span class="tsa-cfg-label">Savings goal</span>
        <div class="tsa-cfg-row">
          <label>Goal stock &amp; block</label>
          <select class="tsa-cfg-input" id="tsa-cfg-savings-goal" style="width:260px">
            <option value="">— No goal set —</option>
          </select>
        </div>
        <div class="tsa-cfg-row">
          <label>Available to invest now ($)</label>
          <input class="tsa-cfg-input" id="tsa-cfg-savings-invest" type="number" min="0"
                 placeholder="e.g. 100000000" style="width:160px" />
          <span style="font-size:10px;color:#555">Cash available for stepping-stone stocks while saving</span>
        </div>

        <span class="tsa-cfg-label">Budget filter</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. 650000000" style="width:160px" />
          <span style="font-size:10px;color:#555">Leave blank to disable budget filter</span>
        </div>
        <div class="tsa-cfg-row">
          <label>Max cost (% of budget)</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:#555">e.g. 110 = up to 10% above budget</span>
        </div>
        <div class="tsa-cfg-row">
          <label>Over-budget blocks</label>
          <select class="tsa-cfg-input" id="tsa-cfg-budget-mode" style="width:180px">
            <option value="grey">Grey out (still visible in table)</option>
            <option value="hide">Hide from recommendations</option>
            <option value="show">Show everything (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="10" 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 payout value overrides &amp; block thresholds
          <span style="font-size:9px;color:#444;text-transform:none;margin-left:6px">Item stocks use live market price automatically; enter $ override to fix the value.</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:#ffaa00">—</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-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-table-content">
              <table class="tsa-table">
                <thead><tr>
                  <th style="width:28px">#</th>
                  <th style="min-width:200px">Stock</th>
                  <th style="width:50px">Type</th>
                  <th style="min-width:90px; text-align:right">Payout</th>
                  <th style="width:48px; 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-apikey').value   = getApiKey();
    root.querySelector('#tsa-cfg-ignored').value  = load('ignored', '');
    const goalSel = root.querySelector('#tsa-cfg-savings-goal');
    if (goalSel) {
      const savedGoal = getSavingsGoal();
      for (const stock of [...STOCK_DATA].sort((a,b) => a.name.localeCompare(b.name))) {
        if (stock.payoutType === 'passive') continue;
        for (const { incr, threshold } of stock.increments) {
          const key = `${stock.ticker}:${incr}`;
          const opt = document.createElement('option');
          opt.value = key;
          opt.textContent = `${stock.name} — Block ${incr} (${fmtShares(threshold)} shares)`;
          opt.selected = key === savedGoal;
          goalSel.appendChild(opt);
        }
      }
    }
    root.querySelector('#tsa-cfg-savings-invest').value = getSavingsInvest() || '';
    root.querySelector('#tsa-cfg-refresh').value       = getRefreshMins();
    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-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();

    const container = root.querySelector('#tsa-cfg-accordions');
    container.innerHTML = '';
    const sortedStocks = [...STOCK_DATA].sort((a, b) => a.name.localeCompare(b.name));

    for (const stock of sortedStocks) {
      const { ticker, name, payoutType, payoutInterval, payoutDesc, increments } = 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:#555;margin-left:6px">${iLabel}</span>
        </span>
        <span class="tsa-accord-arrow">▾</span>
      `;

      const body = document.createElement('div');
      body.className = 'tsa-accord-body';

      const savedPayout = load(`payout_${ticker}`, '');
      const placeholder = payoutType === 'item' ? 'auto (live market)' : payoutType === 'cash' ? 'auto (from stocks page)' : (stock.payoutCashValue || '0');
      const payoutRow = document.createElement('div');
      payoutRow.className = 'tsa-incr-row';
      payoutRow.innerHTML = `
        <label>Per-incr value ($)</label>
        <input class="tsa-cfg-input tsa-payout-inp" data-ticker="${ticker}"
               type="number" value="${savedPayout}" placeholder="${placeholder}" style="width:130px" />
        <span class="tsa-incr-note">${payoutDesc}</span>
      `;
      body.appendChild(payoutRow);

      for (const { incr, threshold } of increments) {
        const saved = getThreshOverride(ticker, incr, threshold);
        const row = document.createElement('div');
        row.className = 'tsa-incr-row';
        row.innerHTML = `
          <label>Block ${incr} threshold</label>
          <input class="tsa-cfg-input tsa-thresh-inp" data-ticker="${ticker}" data-incr="${incr}"
                 type="number" value="${saved}" style="width:130px" />
          <span class="tsa-incr-note">default: ${fmtShares(threshold)} shares total</span>
        `;
        body.appendChild(row);
      }

      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 ? '✓ Connected' : '✗ Not set';
    el.className   = key ? 'tsa-key-ok'  : 'tsa-key-bad';

    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 noSellWrap = root.querySelector('#tsa-strip-nosell-wrap');
    const noSellSep  = root.querySelector('#tsa-strip-nosell-sep');
    if (noSellWrap) {
      noSellWrap.style.display = noSell.length ? '' : 'none';
      if (noSellSep) noSellSep.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.textContent = budget > 0
        ? fmtMoney(budget) + ' (' + getBudgetPct() + '%)'
        : 'Off';
    }
  }

  // ─── Render: savings plan ─────────────────────────────────────────────────────

  function renderSavingsPlan(root, rows, prices) {
    const goalKey      = getSavingsGoal();
    const investBudget = getSavingsInvest();
    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);
    const goalIncrDef = goalStock?.increments.find(i => i.incr === goalIncr);
    if (!goalStock || !goalIncrDef) { section.style.display = 'none'; bannerEl.style.display = 'none'; return; }

    section.style.display = '';

    const goalPrice      = prices[goalTicker] || 0;
    const prevThresh     = goalIncr > 1
      ? (goalStock.increments.find(i => i.incr === goalIncr - 1)?.threshold || 0) : 0;
    const goalIncrShares = goalIncrDef.threshold - prevThresh;

    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 = getSavingsInvest();
    let taggedValue = 0;
    const taggedStocks = [];
    for (const row of rows) {
      if (row.status !== 'held') continue;
      if (!isSteppingStone(row.ticker, row.incr)) continue;
      const blockShares = row.incrShares || row.sharesHeld;
      const liqVal      = blockShares * (prices[row.ticker] || 0);
      taggedValue += liqVal;
      taggedStocks.push({ ticker: row.ticker, name: row.name, incr: row.incr,
                          blockShares, 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}`;
    const isDismissed = load(dismissKey, '') === '1';
    if (pct >= 100 && !isDismissed) {
      bannerEl.style.display = '';
      const sellList = taggedStocks.length
        ? taggedStocks.map(s => `<span class="tsa-ticker">${s.ticker}</span> (${fmtMoney(s.liqVal)})`).join(', ')
        : '<span style="color:#88ddaa">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 cost: <strong>${fmtMoney(goalCost)}</strong> &nbsp;·&nbsp;
            You have: <strong>${fmtMoney(totalProgress)}</strong>
          </div>
          <div class="tsa-ban-sell">Sell: ${sellList} → then 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';
    const alreadyHeldNote = sharesAlready > 0
      ? ` <span style="color:#44ee88;font-size:10px">(already hold ${fmtShares(sharesAlready)} of ${fmtShares(goalIncrShares)})</span>`
      : '';
    goalCard.innerHTML = `
      <div class="tsa-plan-goal-hd">
        <div>
          <span class="tsa-plan-goal-name">${goalStock.name} — Block ${goalIncr}</span>
          <span style="color:#555;font-size:10px;margin-left:8px">${fmtShares(sharesNeeded)} shares still needed @ ${fmtMoney(goalPrice)}/share${alreadyHeldNote}</span>
        </div>
        <span class="tsa-plan-goal-meta">Goal: <strong style="color:#ff7700">${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:#555;margin-top:3px">
        <span>${pct.toFixed(1)}% of goal 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 stone value</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?'#44ee88':'#ff7700'}">${fmtMoney(totalProgress)}</span></div>
        ${daysToGoal > 0 ? `<div class="tsa-plan-stat"><span class="tsa-plan-stat-lbl">Est. days to goal</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:#555;text-transform:uppercase;letter-spacing:.5px;margin:12px 0 6px';
      hd.textContent   = 'Your 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:#ccc;flex:1">${s.name} Block ${s.incr}</span>
          <span style="color:#44ee88;font-size:11px">${s.dailyValue > 0 ? fmtMoney(s.dailyValue)+'/day' : '—'}</span>
          <span style="color:#aaa;font-size:10px;margin-left:10px">Sell value: ${fmtMoney(s.liqVal)} <span style="color:#555">(${fmtShares(s.blockShares)} shares)</span></span>
        `;
        planEl.appendChild(row);
      }
    }

    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:#555;text-transform:uppercase;letter-spacing:.5px;margin:14px 0 6px';
      recHd.textContent   = candidates.length
        ? `Suggested stepping stones within ${fmtMoney(investBudget)} invest budget`
        : `No stepping-stone candidates within ${fmtMoney(investBudget)} invest budget`;
      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:#ccc;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:#aaa;font-size:10px">Cost: ${fmtMoney(r.costToComplete)}</span>
          <span style="color:#44ee88;font-size:11px;margin-left:8px">${fmtMoney(r.dailyValue)}/day</span>
          <span style="color:#ff7700;font-size:10px;margin-left:8px">ROI: ${fmtROI(r.dailyValue, r.incrCost)}</span>
          <span style="color:#555;font-size:10px;margin-left:8px">Score: ${r.score.toFixed(1)}</span>
        `;
        planEl.appendChild(row);
      }

      if (candidates.length) {
        const note = document.createElement('div');
        note.className       = 'tsa-swap-note';
        note.style.marginTop = '10px';
        note.innerHTML = 'Buy these while saving toward your goal. Once purchased, mark them as <strong style="color:#44ee88">🎯 Stepping stone</strong> in Your Holdings.';
        planEl.appendChild(note);
      }
    } else {
      const hint = document.createElement('div');
      hint.style.cssText = 'color:#555;font-size:12px;padding:8px 0';
      hint.innerHTML = 'Set an <strong style="color:#888">Available to invest</strong> amount in Config ⚙ to see stepping-stone recommendations.';
      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) {
      container.innerHTML = '<div style="color:#555;font-size:12px;padding:8px 0">No scoreable blocks — check config or API key.</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">▶ Already hold ${fmtShares(r.sharesHeld - r.prevThresh)} / ${fmtShares(r.incrShares)} shares (${pct}% there)</div>`;
      }

      const stockDef = STOCK_DATA.find(s => s.ticker === r.ticker);
      const stockId  = stockDef ? stockDef.stockId : null;
      const stockUrl = stockId
        ? `https://www.torn.com/page.php?sid=stocks&stockID=${stockId}&tab=owned`
        : null;

      function openStockPanel() {
        const acronymEls = document.querySelectorAll('.tt-acronym');
        for (const span of acronymEls) {
          if (span.textContent.trim() === `(${r.ticker})`) {
            const ul = span.closest('ul');
            if (ul && ul.children[2]) {
              ul.children[2].click();
              ul.scrollIntoView({ behavior: 'smooth', block: 'center' });
              return;
            }
          }
        }
        if (stockUrl) window.location.href = stockUrl;
      }

      // Calculate total daily payout across ALL held blocks of this ticker
      // (existing blocks + the block being recommended), so users can see
      // the full picture — not just the incremental gain from this one block.
      const existingHeldBlocks = rows.filter(rv => rv.ticker === r.ticker && rv.status === 'held');
      const existingDailyTotal = existingHeldBlocks.reduce((s, rv) => s + rv.dailyValue, 0);
      const totalDailyAfterBuy = existingDailyTotal + r.dailyValue;
      // Only show "total after buy" line when the user already holds other blocks of this stock
      const totalAfterBuyHtml  = existingHeldBlocks.length > 0 && r.dailyValue > 0
        ? `<div class="tsa-card-line" style="color:#555">Total after buy: <strong style="color:#44ee88">${fmtMoney(totalDailyAfterBuy)}/day</strong> <span style="font-size:10px;color:#444">(you already hold ${existingHeldBlocks.length} block${existingHeldBlocks.length > 1 ? 's' : ''})</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> &nbsp;${r.name}
          ${stockUrl ? '<span style="color:#444;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>
        ${totalAfterBuyHtml}
        <div class="tsa-card-line">Need: <strong>${fmtShares(r.sharesNeeded)} shares</strong> &nbsp;·&nbsp; Cost: <strong>${fmtMoney(r.costToComplete)}</strong></div>
        <div class="tsa-card-roi">Daily ROI: ${fmtROI(r.dailyValue, r.incrCost)} &nbsp;·&nbsp; Score: ${r.score.toFixed(1)}/10</div>
        ${r.overBudget ? `<div style="font-size:10px;color:#ff4444;margin-top:3px">⚠ Over budget (${fmtMoney(r.costToComplete)} needed)</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:#555;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 aPassive = a.r.payoutType === 'passive';
        const bPassive = b.r.payoutType === 'passive';
        if (aPassive !== bPassive) return aPassive ? 1 : -1;
        return a.r.ticker.localeCompare(b.r.ticker);
      });

      let lastWasActive = true;
      for (const { r, count, totalDaily, blocks } of entries) {
        const isPassive = r.payoutType === 'passive';

        if (isPassive && lastWasActive) {
          const divider = document.createElement('div');
          divider.className = 'tsa-hold-sublabel';
          divider.style.marginTop = '8px';
          divider.style.opacity = '0.5';
          divider.textContent = 'Passive benefits (no active payout)';
          content.appendChild(divider);
          lastWasActive = 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 blockLabel = (!isSummary) ? ` <span style="color:#444;font-size:10px">Block ${blockR.incr}</span>` : '';
          const badgeText  = isSummary
            ? `${count} block${count > 1 ? 's' : ''} ✓`
            : `Block ${blockR.incr} ✓`;

          row.innerHTML = `
            <span class="tsa-ticker">${blockR.ticker}</span>
            <span class="tsa-hold-name">${blockR.name}${blockLabel}</span>
            <span class="tsa-badge ${isPassive ? 'tsa-badge-passive' : 'tsa-badge-ok'}" style="justify-self:end">${badgeText}</span>
            <span class="tsa-hold-val">${(isSummary ? totalDaily : blockR.dailyValue) > 0 ? fmtMoney(isSummary ? totalDaily : blockR.dailyValue) + '/day' : '—'}</span>
            <span style="color:#444;font-size:10px;white-space:nowrap">${fmtShares(blockR.sharesHeld)} &nbsp;·&nbsp; ${iLabel}</span>
          `;

          if (goalActive) {
            const btn = document.createElement('button');
            btn.className = 'tsa-step-toggle' + (isStep ? ' active' : '');
            btn.textContent = isStep ? '🎯 Stepping stone' : '🎯 Mark as stepping stone';
            btn.title = isStep ? 'Click to unmark as stepping stone' : 'Mark as stepping stone toward savings goal';
            btn.dataset.ticker = blockR.ticker;
            btn.dataset.incr   = blockR.incr;
            btn.addEventListener('click', (e) => {
              e.stopPropagation();
              const newVal = !isSteppingStone(blockR.ticker, blockR.incr);
              setSteppingStone(blockR.ticker, blockR.incr, newVal);
              btn.className = 'tsa-step-toggle' + (newVal ? ' active' : '');
              btn.textContent = newVal ? '🎯 Stepping stone' : '🎯 Mark as stepping stone';
              const bodyEl = btn.closest('#tsa-body') || document.getElementById('tsa-body');
              renderSavingsPlan(bodyEl, 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 — working toward next block';
      content.appendChild(lbl);

      const sortedPartial = [...partial].sort((a, b) => a.ticker.localeCompare(b.ticker));
      for (const r of sortedPartial) {
        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:#444;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)} &nbsp;·&nbsp; need ${fmtShares(r.sharesNeeded)} more &nbsp;·&nbsp; ${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 swaps = buildSwaps(rows);

    if (!swaps.length) {
      content.innerHTML = '<div style="color:#555;font-size:12px;padding:6px 0">No positive-gain swaps found — holdings appear well-optimised, or no affordable target beats what you give up.</div>';
      return;
    }

    const groups = [];
    const 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 (const group of groups) {
      const first = group.swaps[0];

      if (groups.indexOf(group) > 0) {
        const divider = document.createElement('div');
        divider.style.cssText = 'border-top:1px solid #1a2a4a;margin:20px 0 0;';
        content.appendChild(divider);
      }

      const header = document.createElement('div');
      header.style.cssText = 'margin:14px 0 8px;font-size:10px;color:#555;text-transform:uppercase;letter-spacing:.5px;';
      const sellTypeBadge = first.sellType === 'combined'
        ? '<span class="tsa-badge" style="background:#1a0a30;color:#cc88ff;font-size:9px">Combined sell</span>'
        : first.sellType === 'full'
          ? '<span class="tsa-badge" style="background:#2a0a00;color:#ff8866;font-size:9px">Full sell</span>'
          : '<span class="tsa-badge" style="background:#2a1a00;color:#ffaa44;font-size:9px">Sell down</span>';

      const payoutTickers = first.combined ? first.extraSells : [first.sellTicker];
      const payoutLines   = payoutTickers
        .map(t => {
          const info = bonusInfo[t];
          const fmt  = fmtNextPayout(info);
          return fmt ? `<span style="color:#555;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(' &nbsp;·&nbsp; ')}</span>`
        : '';

      const sellDesc = first.combined
        ? `Sell all <span class="tsa-ticker">${first.extraSells[0]}</span> + all <span class="tsa-ticker">${first.extraSells[1]}</span> <span style="color:#444">(${fmtMoney(first.cashReleased)} freed)</span>`
        : first.sellType === 'full'
          ? `Sell all <span class="tsa-ticker">${first.sellTicker}</span> <span style="color:#444">(${first.sellName} · ${fmtMoney(first.cashReleased)} freed · lose ${fmtMoney(first.dailyLost)}/day)</span>`
          : `Drop <span class="tsa-ticker">${first.sellTicker}</span> down one block <span style="color:#444">(${first.sellName} · ${fmtMoney(first.cashReleased)} freed · lose ${fmtMoney(first.dailyLost)}/day)</span>`;
      header.innerHTML = `${sellTypeBadge} &nbsp;${sellDesc}${payoutHtml}`;
      content.appendChild(header);

      group.swaps.forEach((s, idx) => {
        const card  = document.createElement('div');
        const isAlt = idx > 0;
        card.style.cssText = `background:${isAlt ? '#0d1420' : '#111827'};border:1px solid ${isAlt ? '#151f30' : '#1a2a4a'};border-radius:5px;padding:10px 14px;margin-bottom:8px;margin-left:${isAlt ? '16px' : '0'};`;

        const paybackColour = s.paybackDays <= 30 ? '#44ee88' : s.paybackDays <= 90 ? '#ffaa00' : '#888';
        const optionLabel   = group.swaps.length > 1
          ? `<span style="color:#444;font-size:10px;margin-right:6px">${isAlt ? '↳ or' : 'Buy'}</span>`
          : `<span style="color:#444;font-size:10px;margin-right:6px">Buy</span>`;

        card.innerHTML = `
          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
            ${optionLabel}
            <span style="font-size:12px;color:#ccc">
              <span class="tsa-ticker">${s.target.ticker}</span>
              <span style="color:#888;margin-left:4px">Block ${s.target.incr}</span>
              <span style="color:#555;font-size:10px;margin-left:4px">(${s.target.name})</span>
            </span>
            <span style="color:#555;font-size:11px">costs ${fmtMoney(s.target.costToComplete)}</span>
            ${s.leftoverCash > 1000 ? `<span style="color:#444;font-size:10px">· ${fmtMoney(s.leftoverCash)} leftover</span>` : ''}
            <span style="margin-left:auto;font-size:12px;color:#44ee88;font-weight:bold">+${fmtMoney(s.netDailyGain)}/day</span>
            <span style="font-size:12px;font-weight:bold;color:${paybackColour}">Payback: ${s.paybackDays}d</span>
          </div>
          <div style="margin-top:5px;font-size:10px;color:#444">
            You start earning <span style="color:#44ee88">+${fmtMoney(s.target.dailyValue)}/day</span> from ${s.target.ticker} — net gain after losing ${fmtMoney(s.dailyLost)}/day from the sell
          </div>
        `;
        content.appendChild(card);
      });
    }

    const note = document.createElement('div');
    note.style.marginTop = '12px';
    note.className = 'tsa-swap-note';
    note.innerHTML = `
      Each row = one sell action → one buy. Sorted by payback days (fastest first).
      Payback = days until the daily gain recovers the one payout cycle you miss during the swap.
      <span style="color:#44ee88">≤30d</span> = strong &nbsp;·&nbsp;
      <span style="color:#ffaa00">≤90d</span> = reasonable &nbsp;·&nbsp;
      <span style="color:#888">&gt;90d</span> = long-term only &nbsp;·&nbsp;
      <span style="color:#cc88ff">Combined sell</span> = sell two stocks together to fund a target neither could afford alone.
    `;
    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) {
      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:#2a2a4a">—</span>`;

      const typeBadgeMap = {
        cash: '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 ${typeBadgeMap[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 ✓</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 iLabel     = r.payoutInterval > 0 ? `${r.payoutInterval}d` : 'passive';
      const payoutCell = r.incrValue > 0
        ? `<span style="color:#ccc">${fmtMoney(r.incrValue)}</span>`
        : `<span style="color:#333">—</span>`;
      const heldCell = r.status === 'held'
        ? `<span style="color:#44ee88">${fmtShares(r.sharesHeld)} ✓</span>`
        : `<span style="color:#666">${fmtShares(r.sharesHeld)}</span>`;
      const overBudgetBadge = (r.overBudget && getBudgetMode() !== 'show' && r.status !== 'held')
        ? ` <span style="color:#ff4444;font-size:9px">▲</span>` : '';
      const costCell = r.status === 'held'
        ? `<span style="color:#333">—</span>`
        : r.costToComplete > 0
          ? `<span style="color:${r.overBudget ? '#ff4444' : r.status === 'partial' ? '#ffaa00' : '#aaa'}">${fmtMoney(r.costToComplete)}${overBudgetBadge}</span>`
          : `<span style="color:#333">—</span>`;
      const dailyCell = r.dailyValue > 0
        ? `<span style="color:#44ee88">${fmtMoney(r.dailyValue)}</span>`
        : `<span style="color:#222">—</span>`;
      const roiCell = active
        ? `<span style="color:#44ee88; font-size:10px">${fmtROI(r.dailyValue, r.incrCost)}</span>
           <span style="color:#ff7700; font-weight:bold; margin-left:4px">${r.score.toFixed(1)}</span>`
        : `<span style="color:#222">—</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:#888; font-size:11px">${r.name}</span>
          <span style="color:#444; font-size:10px; margin-left:4px">B${r.incr}</span>
        </td>
        <td>${typeBadge}</td>
        <td style="text-align:right">${payoutCell}</td>
        <td style="text-align:center; color:#555; font-size:10px">${iLabel}</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) {
    const active  = rows.filter(r => r.status==='held'    && !r.ignored).length;
    const partial = rows.filter(r => r.status==='partial' && !r.ignored).length;
    const daily   = 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-active').textContent  = active;
    root.querySelector('#tsa-s-partial').textContent  = partial;
    root.querySelector('#tsa-s-daily').textContent    = fmtMoney(daily);
    root.querySelector('#tsa-s-roi').textContent      = best ? fmtROI(best.dailyValue, best.incrCost) : '—';
  }

  // ─── 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    = '⚠ No API key set — open ⚙ 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;


      renderSavingsPlan(root, rows, prices);
      renderCards(root, rows);
      renderHoldings(root, rows);
      renderSwaps(root, rows, bonusInfo);
      renderTable(root, rows);
      updateStats(root, rows);
      root.querySelector('#tsa-updated').textContent = 'Updated ' + new Date().toLocaleTimeString();
    } catch (e) {
      loadEl.style.display = 'none';
      errEl.style.display  = 'block';
      errEl.textContent    = '⚠ Error: ' + e.message;
      console.error('[TSA]', e);
    }
  }

  // ─── 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 ? '▼' : '▲';
      save('dashboard_collapsed', c ? '1' : '0');
    });

    root.querySelector('#tsa-cfg-toggle').addEventListener('click', () => {
      root.querySelector('#tsa-config-panel').classList.toggle('open');
    });

    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('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('savings_invest',  root.querySelector('#tsa-cfg-savings-invest')?.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-thresh-inp').forEach(el => {
        save(`thresh_${el.dataset.ticker}_${el.dataset.incr}`, el.value);
      });

      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) {
        GM_setValue(`${PREFIX}payout_${stock.ticker}`, null);
        for (const { incr } of stock.increments) {
          GM_setValue(`${PREFIX}thresh_${stock.ticker}_${incr}`, null);
        }
      }
      ['ignored','ex_nerve','ex_energy','ex_happy','ex_other','ex_passive','refresh_mins','top_n'].forEach(k => {
        GM_setValue(PREFIX + k, null);
      });
      save('api_key', key);
      populateConfig(root);
      updateStrip(root);
      loadAndRender(root);
    });

    root.querySelector('#tsa-refresh-btn').addEventListener('click', () => loadAndRender(root));

    wireCollapse(root.querySelector('#tsa-savings-title'), root.querySelector('#tsa-savings-content'), 'collapse_savings',  'open');
    wireCollapse(root.querySelector('#tsa-recs-title'),   root.querySelector('#tsa-recs-content'),   'collapse_recs',      'open');
    wireCollapse(root.querySelector('#tsa-hold-title'),   root.querySelector('#tsa-hold-content'),   'collapse_holdings',  'open');
    wireCollapse(root.querySelector('#tsa-swap-title'),   root.querySelector('#tsa-swap-content'),   'collapse_swaps',     'collapsed');
    wireCollapse(root.querySelector('#tsa-table-title'),  root.querySelector('#tsa-table-content'),  'collapse_table',     '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) { clearInterval(tryInsert); return; }
      if (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);
      updateStrip(root);
      wireEvents(root);
      loadAndRender(root);
      resetTimer(root);
    }, 500);
  }

  inject();
  window.addEventListener('hashchange', () => { setTimeout(inject, 400); });

})();