TM Shortlist

Enhanced shortlist viewer – squad-style table, R5/REC/TI ratings, multi-fetch up to 6× for unique players

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

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

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

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

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

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

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

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

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

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

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

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

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

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

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         TM Shortlist
// @namespace    https://trophymanager.com
// @version      1.1.0
// @description  Enhanced shortlist viewer – squad-style table, R5/REC/TI ratings, multi-fetch up to 6× for unique players
// @match        https://trophymanager.com/shortlist*
// @grant        none
// @run-at       document-end
// ==/UserScript==

(function () {
    'use strict';

    if (!/^\/shortlist\/?$/.test(location.pathname)) return;

    /* ═════════════════════════════════════════════════════════
       CONSTANTS  (identical to squad / transfer)
       ═════════════════════════════════════════════════════════ */
    const K_ASI = Math.pow(2, 9) * Math.pow(5, 4) * Math.pow(7, 7); // 263 533 760 000
    const K_ASI_GK = 48717927500;
    const WAGE_RATE = 15.8079;
    const TRAINING1 = new Date('2023-01-16T23:00:00Z');
    const SEASON_DAYS = 84;

    const COLOR_LEVELS = ['#ff4c4c', '#ff8c00', '#ffd700', '#90ee90', '#00cfcf', '#5b9bff', '#cc88ff'];
    const REC_THRESHOLDS = [5.5, 5, 4, 3, 2, 1, 0];
    const R5_THRESHOLDS = [110, 100, 90, 80, 70, 60, -Infinity];
    const AGE_THRESHOLDS = [30, 28, 26, 24, 22, 20, 0];
    const RTN_THRESHOLDS = [90, 60, 40, 30, 20, 10, 0];
    const TI_THRESHOLDS = [12, 9, 6, 4, 2, 1, -Infinity];

    //              Str         Sta         Pac         Mar         Tac         Wor         Pos         Pas         Cro         Tec         Hea         Fin         Lon         Set
    const WEIGHT_R5 = [
        [0.41029304, 0.18048062, 0.56730138, 1.06344654, 1.02312672, 0.40831256, 0.58235457, 0.12717479, 0.05454137, 0.09089830, 0.42381693, 0.04626272, 0.02199046, 0], // DC
        [0.42126371, 0.18293193, 0.60567629, 0.91904794, 0.89070915, 0.40038476, 0.56146633, 0.15053902, 0.15955429, 0.15682932, 0.42109742, 0.09460329, 0.03589655, 0], // DL/R
        [0.23412419, 0.32032289, 0.62194779, 0.63162534, 0.63143081, 0.45218831, 0.47370658, 0.55054737, 0.17744915, 0.39932519, 0.26915814, 0.16413124, 0.07404301, 0], // DMC
        [0.27276905, 0.26814289, 0.61104798, 0.39865092, 0.42862643, 0.43582015, 0.46617076, 0.44931076, 0.25175412, 0.46446692, 0.29986350, 0.43843061, 0.21494592, 0], // DML/R
        [0.25219260, 0.25112993, 0.56090649, 0.18230261, 0.18376490, 0.45928749, 0.53498118, 0.59461481, 0.09851189, 0.61601950, 0.31243959, 0.65402884, 0.29982016, 0], // MC
        [0.28155678, 0.24090675, 0.60680245, 0.19068879, 0.20018012, 0.45148647, 0.48230007, 0.42982389, 0.26268609, 0.57933805, 0.31712419, 0.65824985, 0.29885649, 0], // ML/R
        [0.22029884, 0.29229690, 0.63248227, 0.09904394, 0.10043602, 0.47469498, 0.52919791, 0.77555880, 0.10531819, 0.71048302, 0.27667115, 0.56813972, 0.21537826, 0], // OMC
        [0.21151292, 0.35804710, 0.88688492, 0.14391236, 0.13769621, 0.46586605, 0.34446036, 0.51377701, 0.59723919, 0.75126119, 0.16550722, 0.29966502, 0.12417045, 0], // OML/R
        [0.35479780, 0.14887553, 0.43273380, 0.00023928, 0.00021111, 0.46931131, 0.57731335, 0.41686333, 0.05607604, 0.62121195, 0.45370457, 1.03660702, 0.43205492, 0], // F
        [0.45462811, 0.30278232, 0.45462811, 0.90925623, 0.45462811, 0.90925623, 0.45462811, 0.45462811, 0.30278232, 0.15139116, 0.15139116],                         // GK
    ];
    const WEIGHT_RB = [
        [0.10493615, 0.05208547, 0.07934211, 0.14448971, 0.13159554, 0.06553072, 0.07778375, 0.06669303, 0.05158306, 0.02753168, 0.12055170, 0.01350989, 0.02549169, 0.03887550],
        [0.07715535, 0.04943315, 0.11627229, 0.11638685, 0.12893778, 0.07747251, 0.06370799, 0.03830611, 0.10361093, 0.06253997, 0.09128094, 0.01314110, 0.02449199, 0.03726305],
        [0.08219824, 0.08668831, 0.07434242, 0.09661001, 0.08894242, 0.08998026, 0.09281287, 0.08868309, 0.04753574, 0.06042619, 0.05396986, 0.05059984, 0.05660203, 0.03060871],
        [0.06744248, 0.06641401, 0.09977251, 0.08253749, 0.09709316, 0.09241026, 0.08513703, 0.06127851, 0.10275520, 0.07985941, 0.04618960, 0.03927270, 0.05285911, 0.02697852],
        [0.07304213, 0.08174111, 0.07248656, 0.08482334, 0.07078726, 0.09568392, 0.09464529, 0.09580381, 0.04746231, 0.07093008, 0.04595281, 0.05955544, 0.07161249, 0.03547345],
        [0.06527363, 0.06410270, 0.09701305, 0.07406706, 0.08563595, 0.09648566, 0.08651209, 0.06357183, 0.10819222, 0.07386495, 0.03245554, 0.05430668, 0.06572005, 0.03279859],
        [0.07842736, 0.07744888, 0.07201150, 0.06734457, 0.05002348, 0.08350204, 0.08207655, 0.11181914, 0.03756112, 0.07486004, 0.06533972, 0.07457344, 0.09781475, 0.02719742],
        [0.06545375, 0.06145378, 0.10503536, 0.06421508, 0.07627526, 0.09232981, 0.07763931, 0.07001035, 0.11307331, 0.07298351, 0.04248486, 0.06462713, 0.07038293, 0.02403557],
        [0.07738289, 0.05022488, 0.07790481, 0.01356516, 0.01038191, 0.06495444, 0.07721954, 0.07701905, 0.02680715, 0.07759692, 0.12701687, 0.15378395, 0.12808992, 0.03805251],
        [0.07466384, 0.07466384, 0.07466384, 0.14932769, 0.10452938, 0.14932769, 0.10452938, 0.10344411, 0.07512610, 0.04492581, 0.04479831],
    ];
    const POS_MULTIPLIERS = [0.3, 0.3, 0.9, 0.6, 1.5, 0.9, 0.9, 0.6, 0.3];

    const FIELD_LABELS = ['Str', 'Sta', 'Pac', 'Mar', 'Tac', 'Wor', 'Pos', 'Pas', 'Cro', 'Tec', 'Hea', 'Fin', 'Lon', 'Set'];
    const GK_LABELS = ['Str', 'Pac', 'Jum', 'Sta', 'One', 'Ref', 'Aer', 'Com', 'Kic', 'Thr', 'Han'];

    /* ═════════════════════════════════════════════════════════
       UTILS
       ═════════════════════════════════════════════════════════ */
    const fix2 = v => (Math.round(v * 100) / 100).toFixed(2);
    const getColor = (v, thr) => { for (let i = 0; i < thr.length; i++) if (v >= thr[i]) return COLOR_LEVELS[i]; return COLOR_LEVELS[COLOR_LEVELS.length - 1]; };
    const skillColor = v => { if (!v || v <= 0) return '#2a3a28'; if (v >= 20) return '#d4af37'; if (v >= 19) return '#c0c0c0'; if (v >= 16) return '#66dd44'; if (v >= 12) return '#cccc00'; if (v >= 8) return '#ee9900'; return '#ee6633'; };
    const posGroupColor = idx => idx === 9 ? '#4ade80' : idx <= 1 ? '#60a5fa' : idx <= 7 ? '#fbbf24' : '#f87171';
    const posSortOrder = idx => idx === 9 ? 0 : idx + 1;

    const fmtNum = n => {
        if (!n || n <= 0) return '—';
        if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
        if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
        if (n >= 1e3) return (n / 1e3).toFixed(0) + 'k';
        return String(Math.round(n));
    };

    const getPosIndex = pos => {
        switch ((pos || '').toLowerCase()) {
            case 'gk': return 9;
            case 'dc': case 'dcl': case 'dcr': return 0;
            case 'dl': case 'dr': return 1;
            case 'dmc': case 'dmcl': case 'dmcr': return 2;
            case 'dml': case 'dmr': return 3;
            case 'mc': case 'mcl': case 'mcr': return 4;
            case 'ml': case 'mr': return 5;
            case 'omc': case 'omcl': case 'omcr': return 6;
            case 'oml': case 'omr': return 7;
            default: return 8; // fc / f
        }
    };

    /* ═════════════════════════════════════════════════════════
       POSITION PARSER
       Converts TM fp strings like "M LC", "D/DM C", "OM R, F", "GK"
       to [{ name, idx }]
       ═════════════════════════════════════════════════════════ */
    function parseFP(fpStr) {
        if (!fpStr) return [{ name: 'dc', idx: 0 }];
        const fp = fpStr.replace(/\\/g, '');
        if (/^gk$/i.test(fp.trim())) return [{ name: 'gk', idx: 9 }];

        const seen = new Set();
        const result = [];

        for (const part of fp.split(',')) {
            const tokens = part.trim().split(/\s+/);
            const posTypes = tokens[0].split('/');
            const sidesStr = (tokens[1] || '').toLowerCase();

            for (const posType of posTypes) {
                const pt = posType.toLowerCase().replace(/[^a-z]/g, '');
                if (!sidesStr) {
                    // No explicit side – token is already a final key (tooltip API format: "mc", "ml", "fc")
                    const key = pt === 'f' ? 'fc' : pt;
                    if (!seen.has(key)) {
                        seen.add(key);
                        result.push({ name: key, idx: getPosIndex(key) });
                    }
                } else {
                    for (const side of sidesStr) {
                        const key = pt === 'f' ? 'fc' : (pt + side);
                        if (!seen.has(key)) {
                            seen.add(key);
                            result.push({ name: key, idx: getPosIndex(key) });
                        }
                    }
                }
            }
        }

        result.sort((a, b) => a.idx - b.idx);
        return result.length ? result : [{ name: 'dc', idx: 0 }];
    }

    /* ═════════════════════════════════════════════════════════
       SESSION / TI / R5 / REC  (identical to squad)
       ═════════════════════════════════════════════════════════ */
    const getCurrentSession = () => {
        let day = (Date.now() - TRAINING1.getTime()) / 86400000;
        while (day > SEASON_DAYS - 16 / 24) day -= SEASON_DAYS;
        const s = Math.floor(day / 7) + 1;
        return s <= 0 ? 12 : s;
    };
    const CURRENT_SESSION = getCurrentSession();

    const calculateTI = (asi, wage, isGK) => {
        if (!asi || !wage || wage <= 30000) return null;
        const w = isGK ? K_ASI_GK : K_ASI;
        const { pow, log, round } = Math;
        const l27 = log(pow(2, 7));
        return round((pow(2, log(w * asi) / l27) - pow(2, log(w * wage / WAGE_RATE) / l27)) * 10);
    };

    const calcRemainders = (posIdx, skills, asi) => {
        const weight = posIdx === 9 ? K_ASI_GK : K_ASI;
        const skillSum = skills.reduce((s, v) => s + v, 0);
        const remainder = Math.round((Math.pow(2, Math.log(weight * asi) / Math.log(Math.pow(2, 7))) - skillSum) * 10) / 10;
        let rec = 0, ratingR = 0, rW1 = 0, rW2 = 0, not20 = 0;
        for (let i = 0; i < WEIGHT_RB[posIdx].length; i++) {
            rec += skills[i] * WEIGHT_RB[posIdx][i];
            ratingR += skills[i] * WEIGHT_R5[posIdx][i];
            if (skills[i] !== 20) { rW1 += WEIGHT_RB[posIdx][i]; rW2 += WEIGHT_R5[posIdx][i]; not20++; }
        }
        if (remainder / not20 > 0.9 || !not20) { not20 = posIdx === 9 ? 11 : 14; rW1 = 1; rW2 = 5; }
        rec = parseFloat(fix2((rec + remainder * rW1 / not20 - 2) / 3));
        return { remainder, rW2, not20, ratingR, rec };
    };

    const calcR5 = (posIdx, skills, asi, rou) => {
        const r = calcRemainders(posIdx, skills, asi);
        const { pow, E } = Math;
        const rBonus = (3 / 100) * (100 - 100 * pow(E, -rou * 0.035));
        let rating = parseFloat(fix2(r.ratingR + r.remainder * r.rW2 / r.not20 + rBonus * 5));
        const gold = skills.filter(s => s === 20).length;
        const denom = skills.length - gold || 1;
        const sb = skills.map(s => s === 20 ? 20 : s + r.remainder / denom);
        const sr = sb.map((s, i) => i === 1 ? s : s + rBonus);
        if (skills.length !== 11) {
            const hBonus = sr[10] > 12 ? parseFloat(fix2((pow(E, (sr[10] - 10) ** 3 / 1584.77) - 1) * 0.8 + pow(E, sr[0] ** 2 * 0.007 / 8.73021) * 0.15 + pow(E, sr[6] ** 2 * 0.007 / 8.73021) * 0.05)) : 0;
            const fk = parseFloat(fix2(pow(E, (sr[13] + sr[12] + sr[9] * 0.5) ** 2 * 0.002) / 327.92526));
            const ck = parseFloat(fix2(pow(E, (sr[13] + sr[8] + sr[9] * 0.5) ** 2 * 0.002) / 983.65770));
            const pk = parseFloat(fix2(pow(E, (sr[13] + sr[11] + sr[9] * 0.5) ** 2 * 0.002) / 1967.31409));
            const defSq = sr[0] ** 2 + sr[1] ** 2 * 0.5 + sr[2] ** 2 * 0.5 + sr[3] ** 2 + sr[4] ** 2 + sr[5] ** 2 + sr[6] ** 2;
            const offSq = sr[0] ** 2 * 0.5 + sr[1] ** 2 * 0.5 + sr[2] ** 2 + sr[3] ** 2 + sr[4] ** 2 + sr[5] ** 2 + sr[6] ** 2;
            const m = POS_MULTIPLIERS[posIdx];
            return parseFloat(fix2(rating + hBonus + fk + ck + pk + parseFloat(fix2(defSq / 6 / 22.9 ** 2)) * m + parseFloat(fix2(offSq / 6 / 22.9 ** 2)) * m));
        }
        return parseFloat(fix2(rating));
    };

    /* ═════════════════════════════════════════════════════════
       DATA MAPPING  – shortlist players_ar → our model
       ═════════════════════════════════════════════════════════ */
    function mapPlayer(p) {
        const isGK = parseInt(p.han) > 0;
        const skills = isGK
            ? [p.str, p.pac, p.jum, p.sta, p.one, p.ref, p.ari, p.com, p.kic, p.thr, p.han].map(Number)
            : [p.str, p.sta, p.pac, p.mar, p.tac, p.wor, p.pos, p.pas, p.cro, p.tec, p.hea, p.fin, p.lon, p.set].map(Number);
        const labels = isGK ? GK_LABELS : FIELD_LABELS;
        const posList = parseFP(p.fp);
        const posIdx = posList[0].idx;
        const asi = parseInt(p.asi) || 0;
        const routine = parseFloat(p.routine) || 0;

        let r5 = 0, rec = 0;
        if (asi > 0) {
            for (const pp of posList) {
                const r5c = calcR5(pp.idx, skills, asi, routine);
                const remc = calcRemainders(pp.idx, skills, asi).rec;
                if (r5c > r5) r5 = r5c;
                if (remc > rec) rec = remc;
            }
        }

        const wage = parseInt(p.wage) || 0;
        const tiRaw = calculateTI(asi, wage, isGK);
        const ti = tiRaw !== null && CURRENT_SESSION > 0 ? Number((tiRaw / CURRENT_SESSION).toFixed(1)) : null;
        const timeleft = parseInt(p.timeleft) || 0;

        const ageRaw = parseFloat(p.age) || 0;
        const ageYears = Math.floor(ageRaw);
        const ageMonth = p.month != null ? parseInt(p.month) : Math.round((ageRaw - ageYears) * 12);
        const ageFloat = ageYears + ageMonth / 12;   // for color + sort

        return {
            id: p.id,
            name: p.name_js || p.name || String(p.id),
            country: p.nat || p.country || '',
            club: p.club || '0',
            no: parseInt(p.no) || 0,
            ban: p.ban || '0',
            inj: p.inj != null ? String(p.inj) : null,
            fp: p.fp || '',
            posList, posIdx, isGK,
            age: ageYears,
            month: ageMonth,
            ageFloat,
            pageAge: ageYears,
            pageMonth: ageMonth,
            asi, r5, rec: Number(rec), ti,
            routine, wage,
            skills, labels,
            gp: parseInt(p.gp) || 0,
            goals: parseInt(p.goals) || 0,
            assists: parseInt(p.assists) || 0,
            rat: parseFloat(p.rat) || 0,
            mom: parseInt(p.mom) || 0,
            cards: parseInt(p.cards) || 0,
            timeleft,
            timeleft_string: p.timeleft_string || null,
            curbid: p.curbid || null,
            next_bid: parseInt(p.next_bid) || 0,
            bid_level: parseInt(p.bid) || 0,
            txt: p.txt || '',
            locked: typeof p.status === 'string' && p.status.includes('status_unknown'),
            retire: p.retire || '0',
        };
    }

    /* ═════════════════════════════════════════════════════════
       STATE
       ═════════════════════════════════════════════════════════ */
    let allPlayers = [];
    let sortCol = 'timeleft';
    let sortDir = 1;

    // filters
    let fPos = new Set();   // empty = all position groups
    let fSide = new Set();   // empty = all sides; values: 'l','c','r'
    let fAgeMin = 0, fAgeMax = 99;
    let fR5Min = '', fR5Max = '';
    let fRecMin = '';
    let fRecMax = '';
    let fTiMin = '', fTiMax = '';

    let loadMoreState = 'idle'; // 'idle' | 'loading' | 'done'
    let enrichProgress = null;   // null | { done: number, total: number }
    let shortlistLoading = true;  // true while fetchMore+tooltip are running
    let activeTab = 'shortlist'; // 'shortlist' | 'indexed'
    let indexedPlayers = null;   // null = not yet loaded, [] = loaded
    let indexedProgress = null;   // null | { done: number, total: number }
    let indexedLoading = false;
    let ixSortCol = 'r5';
    let ixSortDir = -1;

    /* ═════════════════════════════════════════════════════════
       INDEXED DB  (shared 'TMPlayerData'/'players' with other TM scripts)
       ═════════════════════════════════════════════════════════ */
    const PlayerDB = (() => {
        const DB_NAME = 'TMPlayerData';
        const STORE_NAME = 'players';
        let db = null;
        const cache = {};

        const open = () => new Promise((resolve, reject) => {
            const req = indexedDB.open(DB_NAME, 1);
            req.onupgradeneeded = e => {
                const d = e.target.result;
                if (!d.objectStoreNames.contains(STORE_NAME)) d.createObjectStore(STORE_NAME);
            };
            req.onsuccess = e => { db = e.target.result; resolve(); };
            req.onerror = e => reject(e.target.error);
        });

        const get = pid => cache[String(pid)] || null;

        const set = (pid, value) => {
            cache[String(pid)] = value;
            if (!db) return Promise.resolve();
            return new Promise((resolve, reject) => {
                const tx = db.transaction(STORE_NAME, 'readwrite');
                tx.objectStore(STORE_NAME).put(value, String(pid));
                tx.oncomplete = () => resolve();
                tx.onerror = e => reject(e.target.error);
            }).catch(e => console.warn('[ShortlistDB] write failed:', e));
        };

        const init = async () => {
            await open();
            const tx = db.transaction(STORE_NAME, 'readonly');
            const store = tx.objectStore(STORE_NAME);
            const reqAll = store.getAll();
            const reqKeys = store.getAllKeys();
            await new Promise((res, rej) => { tx.oncomplete = res; tx.onerror = rej; });
            for (let i = 0; i < reqKeys.result.length; i++)
                cache[reqKeys.result[i]] = reqAll.result[i];
            console.log(`[ShortlistDB] Loaded ${Object.keys(cache).length} player(s) from IndexedDB`);
        };

        const getAll = () => ({ ...cache });
        return { init, get, set, getAll };
    })();

    // Full API skill names in the exact order matching FIELD_LABELS / GK_LABELS (= weight matrix order).
    const SKILL_NAMES_FIELD = ['Strength', 'Stamina', 'Pace', 'Marking', 'Tackling', 'Workrate', 'Positioning', 'Passing', 'Crossing', 'Technique', 'Heading', 'Finishing', 'Longshots', 'Set Pieces'];
    const SKILL_NAMES_GK = ['Strength', 'Pace', 'Jumping', 'Stamina', 'One on ones', 'Reflexes', 'Aerial Ability', 'Communication', 'Kicking', 'Throwing', 'Handling'];

    // Extract integer skills from tooltip API response, ordered to match weight matrices.
    const skillsFromTooltip = (player, isGK) => {
        const names = isGK ? SKILL_NAMES_GK : SKILL_NAMES_FIELD;
        return names.map(name => {
            const sk = (player.skills || []).find(s => s.name === name);
            if (!sk) return 0;
            const v = sk.value;
            if (typeof v === 'string') {
                if (v.includes('star_silver') || v.includes('19')) return 19;
                if (v.includes('star') || v.includes('20')) return 20;
                return parseInt(v) || 0;
            }
            return Math.floor(Number(v)) || 0;
        });
    };

    // Write / update a player record in IndexedDB
    const syncToDb = (pid, dbStore, ageKey, asi, isGK, favPos, dbSkills, routine, nameMeta, reason) => {
        if (asi <= 0) return dbStore;
        if (!dbStore || !dbStore._v) dbStore = { _v: 1, lastSeen: Date.now(), records: {}, meta: { pos: favPos, isGK, country: nameMeta?.country, name: nameMeta?.name } };
        if (!dbStore.records) dbStore.records = {};
        if (!dbStore.records[ageKey] && !dbStore.records[ageKey].locked) {
            const posIdx = getPosIndex((favPos || 'mc').split(',')[0].trim());
            dbStore.records[ageKey] = {
                SI: asi,
                REREC: Number(calcRemainders(posIdx, dbSkills, asi).rec),
                R5: Number(calcR5(posIdx, dbSkills, asi, routine)),
                skills: dbSkills,
                routine,
                locked: false,
            };
            dbStore.lastSeen = Date.now();
            PlayerDB.set(pid, dbStore);
        }
        if (!dbStore?.meta?.country) {
            dbStore.meta.country = nameMeta?.country;
            if (!dbStore?.meta?.name) {
                dbStore.meta.name = nameMeta?.name;
            }
            PlayerDB.set(pid, dbStore);
            console.log(`[ShortlistDB] Updated meta for player ${pid}:`, dbStore.meta);
        }
        else if (!dbStore?.meta?.name) {
            dbStore.meta.name = nameMeta?.name;
            PlayerDB.set(pid, dbStore);
            console.log(`[ShortlistDB] Updated name for player ${pid}:`, dbStore.meta.name);
        }

        return dbStore;
    };

    /* ─── Decimal skill computation (port of tm-player.user.js calcShares+capDecimals) ───
       Distributes the ASI remainder across non-maxed skills using balanced training
       group weights weighted by per-skill efficiency.  Returns float skills array.
    ─────────────────────────────────────────────────────────────────────────────── */
    function computeDecimalSkills(intSkills, asi, isGK) {
        const N = intSkills.length;
        const GRP = isGK
            ? [[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]]
            : [[0, 5, 1], [3, 4], [8, 2], [7, 9, 13], [10, 6], [11, 12]];
        const GRP_COUNT = GRP.length;
        const gw = new Array(GRP_COUNT).fill(1 / GRP_COUNT); // balanced (no training data)
        const KASIW = isGK ? K_ASI_GK : K_ASI;
        const LOG_128 = Math.log(128);
        const totalPts = Math.pow(2, Math.log(KASIW * asi) / LOG_128);
        const remainder = totalPts - intSkills.reduce((a, b) => a + b, 0);
        // efficiency: probability of a skill gaining a point at its current level
        const eff = lvl => {
            if (lvl >= 20) return 0;
            if (lvl >= 18) return 0.04;
            if (lvl >= 15) return 0.05;
            if (lvl >= 5) return 0.10;
            return 0.15;
        };
        // per-skill shares (group weight / group size, overflow from maxed skills redistributed)
        const base = new Array(N).fill(0);
        let overflow = 0;
        for (let gi = 0; gi < GRP_COUNT; gi++) {
            const grp = GRP[gi], perSk = gw[gi] / grp.length;
            for (const si of grp) {
                if (si >= N) continue;
                if (intSkills[si] >= 20) overflow += perSk;
                else base[si] = perSk;
            }
        }
        const nonMax = intSkills.filter(v => v < 20).length;
        const ovfEach = nonMax > 0 ? overflow / nonMax : 0;
        const wE = base.map((b, i) => intSkills[i] >= 20 ? 0 : (b + ovfEach) * eff(intSkills[i]));
        const tot = wE.reduce((a, b) => a + b, 0);
        const shares = tot > 0 ? wE.map(x => x / tot) : new Array(N).fill(nonMax > 0 ? 1 / nonMax : 0);
        let dec = shares.map(s => Math.max(0, remainder * s));
        // cap & redistribute (max 0.99 per skill)
        const CAP = 0.99;
        let passes = 0;
        do {
            let ovfl = 0, freeCount = 0;
            for (let i = 0; i < N; i++) {
                if (intSkills[i] >= 20) { dec[i] = 0; continue; }
                if (dec[i] > CAP) { ovfl += dec[i] - CAP; dec[i] = CAP; }
                else if (dec[i] < CAP) freeCount++;
            }
            if (ovfl > 0.0001 && freeCount > 0) {
                const add = ovfl / freeCount;
                for (let i = 0; i < N; i++) if (intSkills[i] < 20 && dec[i] < CAP) dec[i] += add;
            } else break;
        } while (++passes < 20);
        return intSkills.map((v, i) => v >= 20 ? 20 : v + dec[i]);
    }

    // Enrich player object in-place from IndexedDB (synchronous after DB init).
    // Returns true if tooltip API is needed: no record, age gap, or internal gaps.
    function enrichFromDB(p) {
        const dbStore = PlayerDB.get(p.id);
        const ageToM = k => { const [y, m] = k.split('.').map(Number); return y * 12 + m; };

        if (!dbStore || !dbStore.records) { p._syncReason = 'new player'; return true; }

        // Only use real (non-interpolated) records as anchors
        const realKeys = Object.keys(dbStore.records)
            .filter(k => !dbStore.records[k]._interpolated)
            .sort((a, b) => ageToM(a) - ageToM(b));
        if (!realKeys.length) { p._syncReason = 'no real records'; return true; }

        const lastKey = realKeys[realKeys.length - 1];
        const [ky, km] = lastKey.split('.').map(Number);
        const lastRealAgeM = ky * 12 + km;

        // Condition 1: page year newer than last real record year (shortlist has no months)
        const ageMismatch = (p.pageAge || 0) > ky;

        // Condition 2: any month missing between consecutive real records
        let hasGaps = false;
        outer: for (let i = 0; i < realKeys.length - 1; i++) {
            const aM = ageToM(realKeys[i]), bM = ageToM(realKeys[i + 1]);
            for (let month = aM + 1; month < bM; month++) {
                const k = `${Math.floor(month / 12)}.${month % 12}`;
                if (!dbStore.records[k]) { hasGaps = true; break outer; }
            }
        }

        // Display age: prefer page value when ahead
        if (ageMismatch) {
            p.age = p.pageAge; p.month = p.pageMonth; p.ageFloat = p.pageAge + p.pageMonth / 12;
        } else {
            p.age = ky; p.month = km; p.ageFloat = ky + km / 12;
        }

        // Enrich skills/ASI/routine from last real record
        const dbRec = dbStore.records[lastKey];
        if (dbRec && dbRec.skills && dbRec.skills.length) {
            const skills = dbRec.skills.map(Number);
            p.skills = skills;
            if (dbRec.SI != null) p.asi = dbRec.SI;
            if (dbRec.routine != null) p.routine = Number(dbRec.routine);
            if (p.asi > 0) {
                let r5 = 0, rec = 0;
                for (const pp of p.posList) {
                    const r5c = calcR5(pp.idx, skills, p.asi, p.routine);
                    const remc = calcRemainders(pp.idx, skills, p.asi).rec;
                    if (r5c > r5) r5 = r5c;
                    if (remc > rec) rec = remc;
                }
                p.r5 = r5; p.rec = Number(rec);
            }
            const tiRaw = calculateTI(p.asi, p.wage, p.isGK);
            p.ti = tiRaw !== null && CURRENT_SESSION > 0 ? Number((tiRaw / CURRENT_SESSION).toFixed(1)) : null;
        }

        if (ageMismatch && hasGaps) p._syncReason = 'age mismatch + gaps';
        else if (ageMismatch) p._syncReason = 'age mismatch';
        else if (hasGaps) p._syncReason = 'gaps';
        else p._syncReason = null;
        return ageMismatch || hasGaps;
    }

    // Fetch tooltip API for one player, update in memory and sync back to IndexedDB
    const tooltipFetchCache = new Map();
    async function fetchTooltipAndEnrich(p) {
        const pid = String(p.id);
        if (!tooltipFetchCache.has(pid)) {
            tooltipFetchCache.set(pid,
                fetch('/ajax/tooltip.ajax.php', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                    credentials: 'include',
                    body: `player_id=${encodeURIComponent(pid)}`,
                }).then(r => r.text()).then(t => JSON.parse(t))
            );
        }
        const data = await tooltipFetchCache.get(pid);

        const player = data && data.player;
        if (!player) return;

        const isGK = String(player.favposition).split(',')[0].trim().toLowerCase() === 'gk';
        const ageYears = parseInt(player.age) || 0;
        const ageMonths = parseInt(player.months) || 0;
        const asi = Number(String(player.skill_index || 0).replace(/,/g, '')) || 0;
        const favPos = String(player.favposition);
        const routine = parseFloat(player.routine) || 0;
        const ageKey = `${ageYears}.${ageMonths}`;
        // integer skills from tooltip (floor away any server-side float noise)
        const intDbSkills = skillsFromTooltip(player, isGK);
        // decimal version: distribute ASI remainder by training group weights + efficiency
        const fullSkills = (asi > 0 && intDbSkills.length)
            ? computeDecimalSkills(intDbSkills, asi, isGK)
            : intDbSkills;

        // Update in-memory player
        p.age = ageYears;
        p.month = ageMonths;
        p.ageFloat = ageYears + ageMonths / 12;
        p.isGK = isGK;
        p.fp = favPos;
        p.asi = asi;
        p.routine = routine;
        p.labels = isGK ? GK_LABELS : FIELD_LABELS;
        p.posList = parseFP(favPos);
        p.posIdx = p.posList[0].idx;
        if (fullSkills.length && asi > 0) {
            p.skills = fullSkills;
            let r5 = 0, rec = 0;
            for (const pp of p.posList) {
                const r5c = calcR5(pp.idx, fullSkills, asi, routine);
                const remc = calcRemainders(pp.idx, fullSkills, asi).rec;
                if (r5c > r5) r5 = r5c;
                if (remc > rec) rec = remc;
            }
            p.r5 = r5; p.rec = Number(rec);
        }
        const tiRawF = calculateTI(asi, p.wage, isGK);
        p.ti = tiRawF !== null && CURRENT_SESSION > 0 ? Number((tiRawF / CURRENT_SESSION).toFixed(1)) : null;

        const name = player.name;
        const country = player.country;
        if (name && !p.name) p.name = name;
        if (country && !p.country) p.country = country;

        // ─── Fill ALL gaps: internal between real records + gap to current age ────
        const ageToM = k => { const [y, m] = k.split('.').map(Number); return y * 12 + m; };
        const mToAge = m => `${Math.floor(m / 12)}.${m % 12}`;
        const gapPosIdx = getPosIndex((favPos || 'mc').split(',')[0].trim());
        let dbStore = PlayerDB.get(p.id);
        if (!dbStore || !dbStore._v) dbStore = { _v: 1, lastSeen: Date.now(), records: {}, meta: { pos: favPos, isGK } };
        if (!dbStore.records) dbStore.records = {};

        const newAgeM = ageToM(ageKey);
        const realPrevKeys = Object.keys(dbStore.records)
            .filter(k => !dbStore.records[k]._interpolated && ageToM(k) < newAgeM)
            .sort((a, b) => ageToM(a) - ageToM(b));

        // Interpolate all missing months between mA and mB, with decimal skills + linear routine.
        const makeInterp = (siA, skAint, rouA, siB, skBint, rouB, mA, mB) => {
            const gap = mB - mA;
            if (gap <= 1) return;
            for (let step = 1; step < gap; step++) {
                const t = step / gap;
                const ik = mToAge(mA + step);
                if (dbStore.records[ik] && !dbStore.records[ik]._interpolated) continue;
                const iSI = Math.round(siA + (siB - siA) * t);
                const iInt = skAint.map((sa, j) => sa + Math.floor(((skBint[j] !== undefined ? skBint[j] : sa) - sa) * t));
                const iRou = Math.round((rouA + (rouB - rouA) * t) * 10) / 10;
                const iFullSk = (iSI > 0 && iInt.length) ? computeDecimalSkills(iInt, iSI, isGK) : iInt;
                dbStore.records[ik] = {
                    SI: iSI,
                    REREC: Number(calcRemainders(gapPosIdx, iFullSk, iSI).rec),
                    R5: Number(calcR5(gapPosIdx, iFullSk, iSI, iRou)),
                    skills: iFullSk,
                    routine: iRou,
                    _interpolated: true,
                };
            }
        };

        if (intDbSkills.length && asi > 0) {
            // Step 1: fill internal gaps between all consecutive existing real records
            for (let i = 0; i < realPrevKeys.length - 1; i++) {
                const kA = realPrevKeys[i], kB = realPrevKeys[i + 1];
                const rA = dbStore.records[kA], rB = dbStore.records[kB];
                if (!rA.skills || !rB.skills) continue;
                const skAint = rA.skills.map(v => Math.floor(typeof v === 'string' ? parseFloat(v) : v));
                const skBint = rB.skills.map(v => Math.floor(typeof v === 'string' ? parseFloat(v) : v));
                makeInterp(parseInt(rA.SI) || 0, skAint, rA.routine || 0,
                    parseInt(rB.SI) || 0, skBint, rB.routine || 0,
                    ageToM(kA), ageToM(kB));
            }
            // Step 2: fill gap from last existing real key to the new tooltip key
            if (realPrevKeys.length) {
                const kLast = realPrevKeys[realPrevKeys.length - 1];
                const rLast = dbStore.records[kLast];
                if (rLast && rLast.skills) {
                    const skLastInt = rLast.skills.map(v => Math.floor(typeof v === 'string' ? parseFloat(v) : v));
                    makeInterp(parseInt(rLast.SI) || 0, skLastInt, rLast.routine || 0,
                        asi, intDbSkills, routine,
                        ageToM(kLast), newAgeM);
                }
            }
        }
        // Persist the real (tooltip) record with decimal skills
        syncToDb(p.id, dbStore, ageKey, asi, isGK, favPos, fullSkills, routine, { name, country }, p._syncReason || 'unknown');
    }

    // Batch tooltip refresh for stale players with live progress indicator
    async function runTooltipRefresh(players) {
        enrichProgress = { done: 0, total: players.length };
        renderPanel();

        const BATCH = 15;
        for (let i = 0; i < players.length; i += BATCH) {
            const batch = players.slice(i, i + BATCH);
            await Promise.all(batch.map(p =>
                fetchTooltipAndEnrich(p).catch(e => console.warn('[ShortlistDB] tooltip failed', p.id, e))
            ));
            enrichProgress.done = Math.min(i + BATCH, players.length);
            const el = document.getElementById('tmsl-enrich-progress');
            if (el) el.textContent = `⏳ Enriching ${enrichProgress.done}/${enrichProgress.total}…`;
            if (i + BATCH < players.length) await new Promise(r => setTimeout(r, 150));
        }

        enrichProgress = null;
        renderPanel();
    }

    /* ═════════════════════════════════════════════════════════
       INDEXED TAB  – player model from DB, table, loader
       ═════════════════════════════════════════════════════════ */
    function dbRecordToPlayer(pid, dbStore) {
        if (!dbStore || !dbStore.records) return null;
        const keys = Object.keys(dbStore.records).sort((a, b) => {
            const [ay, am] = a.split('.').map(Number);
            const [by, bm] = b.split('.').map(Number);
            return (ay * 12 + am) - (by * 12 + bm);
        });
        if (!keys.length) return null;

        const lastKey = keys[keys.length - 1];
        const [ky, km] = lastKey.split('.').map(Number);
        const weeksSince = (Date.now() - (dbStore.lastSeen || 0)) / 604800000;
        const addMonths = Math.floor(weeksSince);
        const totalM = km + addMonths;
        const newYears = ky + Math.floor(totalM / 12);
        const newMonths = totalM % 12;

        const dbRec = dbStore.records[lastKey] || {};
        const meta = dbStore.meta || {};
        const isGK = !!meta.isGK;
        const favPos = meta.pos || 'mc';
        const posList = parseFP(favPos);
        const posIdx = posList[0].idx;
        const skills = (dbRec.skills || []).map(Number);
        const asi = dbRec.SI || 0;
        const routine = dbRec.routine || 0;

        let r5 = 0, rec = 0;
        if (asi > 0 && skills.length) {
            for (const pp of posList) {
                const r5c = calcR5(pp.idx, skills, asi, routine);
                const remc = calcRemainders(pp.idx, skills, asi).rec;
                if (r5c > r5) r5 = r5c;
                if (remc > rec) rec = remc;
            }
        }
        const tiRaw = calculateTI(asi, 0, isGK);
        const ti = tiRaw !== null && CURRENT_SESSION > 0 ? Number((tiRaw / CURRENT_SESSION).toFixed(1)) : null;

        return {
            id: String(pid),
            name: meta.name || '',
            country: meta.country || '',
            fp: favPos,
            posList, posIdx, isGK,
            age: newYears,
            month: newMonths,
            ageFloat: newYears + newMonths / 12,
            asi, r5, rec: Number(rec), ti,
            routine, wage: 0,
            skills,
            labels: isGK ? GK_LABELS : FIELD_LABELS,
            lastSeen: dbStore.lastSeen || 0,
            stale: weeksSince >= 1,
        };
    }

    const INDEXED_COLS = [
        { key: 'name', lbl: 'Player' },
        { key: 'pos', lbl: 'Pos', align: 'c' },
        { key: 'age', lbl: 'Age', align: 'r' },
        { key: 'asi', lbl: 'ASI', align: 'r' },
        { key: 'r5', lbl: 'R5', align: 'r' },
        { key: 'rec', lbl: 'REC', align: 'r' },
        { key: 'ti', lbl: 'TI', align: 'r' },
        { key: 'routine', lbl: 'Rtn', align: 'r' },
        { key: 'lastSeen', lbl: 'Last Seen', align: 'r' },
    ];

    function buildIndexedTable(players) {
        players.sort((a, b) => {
            if (ixSortCol === 'age') return ixSortDir * ((a.age * 12 + a.month) - (b.age * 12 + b.month));
            if (ixSortCol === 'name') return ixSortDir * String(a.name).localeCompare(String(b.name));
            if (ixSortCol === 'pos') return ixSortDir * (posSortOrder(a.posIdx) - posSortOrder(b.posIdx));
            return ixSortDir * ((a[ixSortCol] || 0) - (b[ixSortCol] || 0));
        });

        let h = '<div class="tmsl-table-wrap"><table class="tmsl-table"><thead><tr>';
        h += '<th style="width:4px;padding:0"></th>';
        INDEXED_COLS.forEach(c => {
            const sorted = ixSortCol === c.key;
            const arrow = sorted ? (ixSortDir > 0 ? ' ▲' : ' ▼') : '';
            const cls = [c.align || '', sorted ? 'sorted' : ''].filter(Boolean).join(' ');
            h += `<th data-ixcol="${c.key}"${cls ? ` class="${cls}"` : ''}>${c.lbl}${arrow}</th>`;
        });
        h += '</tr></thead><tbody>';

        players.forEach(p => {
            const flag = p.country ? `<ib class="flag-img-${p.country} tmsl-flag"></ib>` : '';
            const posClr = posGroupColor(p.posIdx);
            const chipClr = p.posIdx === 9 ? '#4ade80' : p.posList.some(pp => pp.idx <= 1) ? '#60a5fa' : p.posList.some(pp => pp.idx === 8) ? '#f87171' : '#fbbf24';
            const chipInn = p.posList.map(pp => `<span style="color:${posGroupColor(pp.idx)}">${pp.name.toUpperCase()}</span>`).join('<span style="color:#6a9a58">,</span>');
            const seenDate = p.lastSeen ? new Date(p.lastSeen).toLocaleDateString() : '—';
            const staleClr = p.stale ? '#f87171' : '#6a9a58';
            h += `<tr data-ixpid="${p.id}">`;
            h += `<td class="pos-bar" style="background:${posClr}"></td>`;
            h += `<td class="l">${flag}<a href="/players/${p.id}/" class="tmsl-link" target="_blank">${p.name || `#${p.id}`}</a></td>`;
            h += `<td class="c"><span class="tmsl-pos-chip" style="background:${chipClr}22;border:1px solid ${chipClr}44">${chipInn}</span></td>`;
            h += `<td class="r" style="color:${getColor(p.ageFloat, AGE_THRESHOLDS)}">${p.age}.${p.month}</td>`;
            h += `<td class="r" style="color:#e0f0cc">${p.asi ? p.asi.toLocaleString() : '—'}</td>`;
            h += `<td class="r" style="color:${getColor(p.r5, R5_THRESHOLDS)};font-weight:700">${p.r5 ? p.r5.toFixed(2) : '—'}</td>`;
            h += `<td class="r" style="color:${getColor(p.rec, REC_THRESHOLDS)};font-weight:700">${p.rec ? p.rec.toFixed(2) : '—'}</td>`;
            h += p.ti !== null
                ? `<td class="r" style="color:${getColor(p.ti, TI_THRESHOLDS)}">${p.ti.toFixed(1)}</td>`
                : '<td class="r" style="color:#555">—</td>';
            h += `<td class="r" style="color:${getColor(p.routine, RTN_THRESHOLDS)}">${p.routine.toFixed(1)}</td>`;
            h += `<td class="r" style="color:${staleClr};font-size:10px">${seenDate}</td>`;
            h += '</tr>';
        });
        h += '</tbody></table></div>';
        return h;
    }

    async function loadIndexedTab() {
        if (indexedLoading) return;
        indexedLoading = true;

        const allDB = PlayerDB.getAll();
        const players = Object.entries(allDB)
            .map(([pid, store]) => dbRecordToPlayer(pid, store))
            .filter(Boolean);
        indexedPlayers = players;
        renderPanel();

        const needsFetch = players.filter(p => {
            const reasons = [];
            if (!p.name) reasons.push('no name');
            if (!p.country) reasons.push('no country');
            if (p.stale) reasons.push('stale');
            if (reasons.length) console.log(`[TM Indexed] sync ${p.id} "${p.name}" → [${reasons.join(', ')}]`, { meta: PlayerDB.get(p.id)?.meta });
            return reasons.length > 0;
        });
        if (needsFetch.length > 0) {
            indexedProgress = { done: 0, total: needsFetch.length };
            renderPanel();
            const BATCH = 15;
            for (let i = 0; i < needsFetch.length; i += BATCH) {
                const batch = needsFetch.slice(i, i + BATCH);
                await Promise.all(batch.map(p =>
                    fetchTooltipAndEnrich(p).catch(e => console.warn('[ShortlistDB] indexed tooltip failed', p.id, e))
                ));
                indexedProgress.done = Math.min(i + BATCH, needsFetch.length);
                const el = document.getElementById('tmsl-indexed-progress');
                if (el) el.textContent = `⏳ Enriching ${indexedProgress.done}/${indexedProgress.total}…`;
                if (i + BATCH < needsFetch.length) await new Promise(r => setTimeout(r, 150));
            }
            indexedProgress = null;
        }

        indexedLoading = false;
        renderPanel();
    }

    /* ═════════════════════════════════════════════════════════
       CSS  (squad style, extended with auction columns)
       ═════════════════════════════════════════════════════════ */
    function injectCSS() {
        if (document.getElementById('tmsl-style')) return;
        const s = document.createElement('style');
        s.id = 'tmsl-style';
        s.textContent = `
            .column1_d { display: none !important; }
            .main_center { padding-top: 6px !important; padding-bottom: 6px !important; }

            #tmsl-panel {
                background:#1c3410; border-radius:10px; padding:14px;
                margin:10px auto 16px; max-width:1200px;
                font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
                color:#c8e0b4; box-shadow:0 4px 24px rgba(0,0,0,.5);
                border:1px solid #2a4a1c;
            }
            #tmsl-panel * { box-sizing:border-box; }

            /* ── header ── */
            .tmsl-header {
                display:flex; align-items:center; justify-content:space-between;
                margin-bottom:10px;
            }
            .tmsl-title { font-size:15px; font-weight:700; color:#fff; display:flex; align-items:center; gap:6px; }

            /* ── filter bar ── */
            #tmsl-filters {
                display:flex; flex-wrap:wrap; align-items:center; gap:8px;
                padding:8px 10px; background:#162e0e; border-radius:8px;
                border:1px solid #2a4a1c; margin-bottom:10px;
            }
            .tmsl-fgroup { display:flex; align-items:center; gap:4px; }
            .tmsl-flbl { font-size:10px; color:#6a9a58; font-weight:700; text-transform:uppercase; letter-spacing:.4px; }
            .tmsl-pos-btn {
                padding:3px 8px; border-radius:0; font-size:11px; font-weight:700;
                border:1px solid rgba(61,104,40,.5); border-right-width:0;
                background:rgba(0,0,0,.15);
                cursor:pointer; transition:all .12s; user-select:none;
            }
            .tmsl-pos-btn:hover { background:#2a4a1c; }
            .tmsl-pos-btn.active { background:#3d6828; border-color:#6cc040; }
            .tmsl-pos-btn.gk  { color:#4ade80; }
            .tmsl-pos-btn.de  { color:#60a5fa; }
            .tmsl-pos-btn.dm  { color:#fbbf24; }
            .tmsl-pos-btn.mf  { color:#fbbf24; }
            .tmsl-pos-btn.om  { color:#fb923c; }
            .tmsl-pos-btn.fw  { color:#f87171; }
            .tmsl-side-btn {
                padding:3px 8px; border-radius:0; font-size:11px; font-weight:700;
                border:1px solid rgba(61,104,40,.5); border-right-width:0;
                background:rgba(0,0,0,.15); color:#c8e0b4;
                cursor:pointer; transition:all .12s; user-select:none;
            }
            .tmsl-side-btn:hover { background:#2a4a1c; }
            .tmsl-side-btn.active { background:#3d6828; border-color:#6cc040; color:#fff; }
            .tmsl-btngrp { display:flex; align-items:center; }
            .tmsl-btngrp > * { border-radius:0; border-right-width:0; }
            .tmsl-btngrp > :first-child { border-radius:4px 0 0 4px; }
            .tmsl-btngrp > :last-child  { border-radius:0 4px 4px 0; border-right-width:1px; }
            .tmsl-fnum {
                width:54px; padding:4px 6px; border-radius:4px;
                background:rgba(0,0,0,.25); border:1px solid rgba(42,74,28,.6);
                color:#e8f5d8; font-size:11px; outline:none; font-family:inherit;
                -moz-appearance:textfield;
            }
            .tmsl-fnum:focus { border-color:#6cc040; }
            .tmsl-fnum::placeholder { color:#4a6a38; }
            .tmsl-fsep { width:1px; height:20px; background:#2a4a1c; }
            .tmsl-loadbtn {
                margin-left:auto; padding:5px 12px; border-radius:5px;
                border:1px solid #3d6828; background:rgba(61,104,40,.12);
                color:#6cc040; font-size:11px; font-weight:700; cursor:pointer;
                font-family:inherit; transition:background .15s; white-space:nowrap;
            }
            .tmsl-loadbtn:hover:not(:disabled) { background:rgba(61,104,40,.3); }
            .tmsl-loadbtn:disabled { opacity:.45; cursor:default; }



            /* ── table ── */
            .tmsl-table-wrap { overflow-x:auto; border-radius:8px; border:1px solid #2a4a1c; }
            .tmsl-table-wrap::-webkit-scrollbar { height:4px; }
            .tmsl-table-wrap::-webkit-scrollbar-thumb { background:#3d6828; border-radius:2px; }
            .tmsl-table { width:100%; border-collapse:collapse; font-size:12px; }
            .tmsl-table thead th {
                background:#162e0e; color:#6a9a58; padding:6px 7px;
                text-align:left; font-size:10px; font-weight:700;
                text-transform:uppercase; letter-spacing:.4px;
                border-bottom:1px solid #2a4a1c; cursor:pointer;
                user-select:none; white-space:nowrap;
                position:sticky; top:0; z-index:2;
            }
            .tmsl-table thead th:hover  { color:#c8e0b4; background:#243d18; }
            .tmsl-table thead th.sorted { color:#6cc040; }
            .tmsl-table tbody tr {
                border-bottom:1px solid rgba(42,74,28,.4);
                transition:background .12s;
            }
            .tmsl-table tbody tr:nth-child(odd)  { background:#1c3410; }
            .tmsl-table tbody tr:nth-child(even) { background:#162e0e; }
            .tmsl-table tbody tr:hover { background:#243d18 !important; }
            .tmsl-table td { padding:4px 7px; white-space:nowrap; vertical-align:middle; }
            .tmsl-table td.l, .tmsl-table th.l { text-align:left; }
            .tmsl-table td.r, .tmsl-table th.r { text-align:right; }
            .tmsl-table td.c, .tmsl-table th.c { text-align:center; }
            .tmsl-table .pos-bar { width:3px; padding:0; border-radius:2px; }

            .tmsl-link { color:#90b878; text-decoration:none; font-weight:500; }
            .tmsl-link:hover { color:#c8e0b4; text-decoration:underline; }
            .tmsl-flag { margin-right:4px; vertical-align:middle; }
            .tmsl-pos-chip {
                display:inline-block; padding:1px 6px; border-radius:4px;
                font-size:10px; font-weight:700; letter-spacing:.3px;
                line-height:16px; text-align:center; min-width:28px;
            }

            .tmsl-time { font-variant-numeric:tabular-nums; color:#a0c888; }
            .tmsl-time-exp { color:#f87171; }
            .tmsl-bid { font-variant-numeric:tabular-nums; color:#e0f0cc; }

            /* ── tooltip (same as squad) ── */
            .tmsl-tip {
                display:none; position:absolute; z-index:9999;
                background:linear-gradient(135deg,#1a2e14 0%,#243a1a 100%);
                border:1px solid #4a9030; border-radius:8px;
                padding:10px 12px; min-width:200px; max-width:280px;
                box-shadow:0 6px 24px rgba(0,0,0,.6);
                pointer-events:none; font-size:11px; color:#c8e0b4;
            }
            .tmsl-tip-header {
                display:flex; align-items:center; gap:8px;
                margin-bottom:8px; padding-bottom:6px;
                border-bottom:1px solid rgba(74,144,48,.3);
            }
            .tmsl-tip-name { font-size:13px; font-weight:700; color:#e0f0cc; }
            .tmsl-tip-pos  { font-size:10px; color:#8abc78; font-weight:600; }
            .tmsl-tip-badges { display:flex; gap:6px; margin-left:auto; }
            .tmsl-tip-badge {
                font-size:10px; font-weight:700; padding:2px 6px;
                border-radius:4px; background:rgba(0,0,0,.3);
            }
            .tmsl-tip-skills { display:flex; gap:12px; margin-bottom:6px; }
            .tmsl-tip-skills-col { flex:1; min-width:0; }
            .tmsl-tip-skill {
                display:flex; justify-content:space-between;
                padding:1px 0; border-bottom:1px solid rgba(74,144,48,.12);
            }
            .tmsl-tip-skill-name { color:#8abc78; font-size:10px; }
            .tmsl-tip-skill-val  { font-weight:700; font-size:11px; }
            .tmsl-tip-footer {
                display:flex; gap:8px; justify-content:center;
                padding-top:6px; border-top:1px solid rgba(74,144,48,.3);
            }
            .tmsl-tip-stat { text-align:center; }
            .tmsl-tip-stat-val { font-size:14px; font-weight:800; }
            .tmsl-tip-stat-lbl { font-size:9px; color:#6a9a58; text-transform:uppercase; }
            .tmsl-note-icon {
                display:inline-block; margin-left:5px; font-size:11px;
                cursor:default; opacity:.75; vertical-align:middle; position:relative;
            }
            .tmsl-note-icon:hover { opacity:1; }
            .tmsl-note-icon::after {
                content:attr(data-note); display:none; position:absolute;
                left:50%; transform:translateX(-50%); top:calc(100% + 5px);
                background:#1a2e14; border:1px solid #4a9030; border-radius:5px;
                padding:5px 8px; font-size:11px; color:#c8e0b4; white-space:pre-wrap;
                max-width:260px; min-width:100px; word-break:break-word;
                z-index:100002; box-shadow:0 4px 14px rgba(0,0,0,.6); pointer-events:none;
            }
            .tmsl-note-icon:hover::after { display:block; }

            /* ── tabs ── */
            .tmsl-tabs {
                display:flex; align-items:center; gap:6px;
                margin-bottom:10px; border-bottom:2px solid #2a4a1c;
                padding-bottom:0;
            }
            .tmsl-tab {
                padding:6px 16px; border-radius:6px 6px 0 0;
                border:1px solid transparent; border-bottom:none;
                background:transparent; color:#6a9a58;
                font-size:12px; font-weight:700; cursor:pointer;
                font-family:inherit; transition:all .15s;
                position:relative; bottom:-2px;
            }
            .tmsl-tab:hover  { background:#1e3812; color:#90b878; border-color:rgba(42,74,28,.5); }
            .tmsl-tab.active { background:#243d18; color:#e0f0cc; border-color:#3d6828; border-bottom-color:#243d18; }
            .tmsl-tab.disabled, .tmsl-tab:disabled { opacity:.35; cursor:not-allowed; pointer-events:none; }
            .tmsl-tab-count  { font-weight:400; font-size:10px; color:#6a9a58; }
            .tmsl-tab.active .tmsl-tab-count { color:#8abc78; }
            .tmsl-reloadbtn {
                padding:3px 7px; border-radius:5px; border:1px solid #3d6828;
                background:#1c3410; color:#90b878; font-size:13px;
                cursor:pointer; font-family:inherit; transition:all .15s;
            }
            .tmsl-reloadbtn:hover { background:#243d18; color:#c8e0b4; }
        `;
        document.head.appendChild(s);
    }

    /* ═════════════════════════════════════════════════════════
       FILTER + SORT HELPERS
       ═════════════════════════════════════════════════════════ */
    const GROUP_FOR_IDX = { 9: 'gk', 0: 'de', 1: 'de', 2: 'dm', 3: 'dm', 4: 'mf', 5: 'mf', 6: 'om', 7: 'om', 8: 'fw' };

    function playerMatchesFilters(p) {
        if (fPos.size > 0) {
            const groups = new Set(p.posList.map(pp => GROUP_FOR_IDX[pp.idx]));
            if (![...fPos].some(g => groups.has(g))) return false;
        }
        if (fSide.size > 0) {
            // derive side from position name suffix: ends in l→L, ends in r→R, otherwise C
            const sides = new Set(p.posList.map(pp => {
                const n = pp.name;
                if (n === 'gk') return 'c';
                if (n.endsWith('l')) return 'l';
                if (n.endsWith('r')) return 'r';
                return 'c';
            }));
            if (![...fSide].some(s => sides.has(s))) return false;
        }
        if (p.ageFloat < fAgeMin || p.ageFloat > fAgeMax) return false;
        if (fR5Min !== '' && p.r5 < parseFloat(fR5Min)) return false;
        if (fR5Max !== '' && p.r5 > parseFloat(fR5Max)) return false;
        if (fRecMin !== '' && p.rec < parseFloat(fRecMin)) return false;
        if (fRecMax !== '' && p.rec > parseFloat(fRecMax)) return false;
        if (fTiMin !== '' && (p.ti === null || p.ti < parseFloat(fTiMin))) return false;
        if (fTiMax !== '' && (p.ti === null || p.ti > parseFloat(fTiMax))) return false;
        return true;
    }

    const getSortVal = (p, col) => {
        if (col === 'age') return p.age * 12 + p.month;
        if (col === 'pos') return posSortOrder(p.posIdx) * 100 + (p.posList.length > 1 ? 50 + posSortOrder(p.posList[1].idx) : 0);
        if (col === 'timeleft') return p.timeleft > 0 ? p.timeleft : 999999999;
        if (col === 'name') return p.name;
        return p[col];
    };

    const sortPlayers = arr => {
        arr.sort((a, b) => {
            const va = getSortVal(a, sortCol);
            const vb = getSortVal(b, sortCol);
            if (sortCol === 'name') return sortDir * String(va).localeCompare(String(vb));
            return sortDir * ((va || 0) - (vb || 0));
        });
    };

    /* ═════════════════════════════════════════════════════════
       TOOLTIP
       ═════════════════════════════════════════════════════════ */
    let tipEl;
    function ensureTip() {
        if (tipEl) return;
        tipEl = document.createElement('div');
        tipEl.className = 'tmsl-tip';
        document.body.appendChild(tipEl);
    }

    function showTip(anchor, p) {
        ensureTip();
        let h = '<div class="tmsl-tip-header">';
        h += `<div><div class="tmsl-tip-name">${p.name}</div>`;
        h += `<div class="tmsl-tip-pos">${p.fp} · Age ${p.age}.${p.month}</div></div>`;
        h += '<div class="tmsl-tip-badges">';
        h += `<span class="tmsl-tip-badge" style="color:${getColor(p.r5, R5_THRESHOLDS)}">R5 ${p.r5.toFixed(2)}</span>`;
        h += '</div></div>';

        const fL = [0, 1, 2, 3, 4, 5, 6], fR = [7, 8, 9, 10, 11, 12, 13];
        const gL = [0, 3, 1], gR = [10, 4, 5, 6, 2, 7, 8, 9];
        const lIdx = p.isGK ? gL : fL;
        const rIdx = p.isGK ? gR : fR;

        const col = idxs => {
            let c = '<div class="tmsl-tip-skills-col">';
            idxs.forEach(i => {
                if (i >= p.skills.length) return;
                const v = p.skills[i];
                c += `<div class="tmsl-tip-skill"><span class="tmsl-tip-skill-name">${p.labels[i]}</span>`;
                c += `<span class="tmsl-tip-skill-val" style="color:${skillColor(v)}">${v >= 19 ? '★' : Number.isInteger(v) ? v : v.toFixed(2)}</span></div>`;
            });
            return c + '</div>';
        };
        h += '<div class="tmsl-tip-skills">' + col(lIdx) + col(rIdx) + '</div>';
        h += '<div class="tmsl-tip-footer">';
        h += `<div class="tmsl-tip-stat"><div class="tmsl-tip-stat-val" style="color:#e0f0cc">${p.asi.toLocaleString()}</div><div class="tmsl-tip-stat-lbl">ASI</div></div>`;
        h += `<div class="tmsl-tip-stat"><div class="tmsl-tip-stat-val" style="color:${getColor(p.rec, REC_THRESHOLDS)}">${p.rec.toFixed(2)}</div><div class="tmsl-tip-stat-lbl">REC</div></div>`;
        h += `<div class="tmsl-tip-stat"><div class="tmsl-tip-stat-val" style="color:#8abc78">${p.routine.toFixed(1)}</div><div class="tmsl-tip-stat-lbl">Rtn</div></div>`;
        h += '</div>';

        tipEl.innerHTML = h;
        tipEl.style.display = 'block';

        const rect = anchor.getBoundingClientRect();
        let top = rect.bottom + window.scrollY + 4;
        let left = rect.left + window.scrollX;
        tipEl.style.top = top + 'px';
        tipEl.style.left = left + 'px';
        requestAnimationFrame(() => {
            const tr = tipEl.getBoundingClientRect();
            if (tr.right > window.innerWidth - 10) tipEl.style.left = (window.innerWidth - tr.width - 10) + 'px';
            if (tr.bottom > window.innerHeight + window.scrollY - 10) tipEl.style.top = (rect.top + window.scrollY - tr.height - 4) + 'px';
        });
    }
    const hideTip = () => { if (tipEl) tipEl.style.display = 'none'; };

    /* ═════════════════════════════════════════════════════════
       COLUMNS
       ═════════════════════════════════════════════════════════ */
    const COLS = [
        { key: 'name', lbl: 'Player' },
        { key: 'pos', lbl: 'Pos', align: 'c' },
        { key: 'age', lbl: 'Age', align: 'r' },
        { key: 'asi', lbl: 'ASI', align: 'r' },
        { key: 'r5', lbl: 'R5', align: 'r' },
        { key: 'rec', lbl: 'REC', align: 'r' },
        { key: 'ti', lbl: 'TI', align: 'r' },
        { key: 'routine', lbl: 'Rtn', align: 'r' },
        { key: 'timeleft', lbl: 'Time', align: 'r' },
        { key: 'curbid', lbl: 'Cur Bid', align: 'r' },
    ];

    /* ═════════════════════════════════════════════════════════
       TABLE
       ═════════════════════════════════════════════════════════ */
    function buildTable(players) {
        let h = '<div class="tmsl-table-wrap"><table class="tmsl-table"><thead><tr>';
        h += '<th style="width:4px;padding:0"></th>';
        COLS.forEach(c => {
            const sorted = sortCol === c.key;
            const arrow = sorted ? (sortDir > 0 ? ' ▲' : ' ▼') : '';
            const cls = [c.align || '', sorted ? 'sorted' : ''].filter(Boolean).join(' ');
            h += `<th data-col="${c.key}"${cls ? ` class="${cls}"` : ''}>${c.lbl}${arrow}</th>`;
        });
        h += '</tr></thead><tbody>';

        players.forEach(p => {
            const flag = p.country ? `<ib class="flag-img-${p.country} tmsl-flag"></ib>` : '';
            const posClr = posGroupColor(p.posIdx);
            const chipClr = p.posIdx === 9 ? '#4ade80' : p.posList.some(pp => pp.idx <= 1) ? '#60a5fa' : p.posList.some(pp => pp.idx === 8) ? '#f87171' : '#fbbf24';
            const chipInn = p.posList.map(pp => `<span style="color:${posGroupColor(pp.idx)}">${pp.name.toUpperCase()}</span>`).join('<span style="color:#6a9a58">,</span>');
            const noteIcon = p.txt ? `<span class="tmsl-note-icon" data-note="${p.txt.replace(/"/g, '&quot;')}">📋</span>` : '';
            const timeHtml = p.timeleft > 0
                ? `<span class="tmsl-time${p.timeleft < 3600 ? ' tmsl-time-exp' : ''}">${p.timeleft_string || ''}</span>`
                : '<span style="color:#4a5a40">—</span>';
            const bidHtml = p.curbid
                ? `<span class="tmsl-bid">${p.curbid}</span>`
                : '<span style="color:#4a5a40">—</span>';

            h += `<tr data-pid="${p.id}">`;
            h += `<td class="pos-bar" style="background:${posClr}"></td>`;
            h += `<td class="l">${flag}<a href="/players/${p.id}/" class="tmsl-link" target="_blank">${p.name}</a>${noteIcon}</td>`;
            h += `<td class="c"><span class="tmsl-pos-chip" style="background:${chipClr}22;border:1px solid ${chipClr}44">${chipInn}</span></td>`;
            h += `<td class="r" style="color:${getColor(p.ageFloat, AGE_THRESHOLDS)}">${p.age}.${p.month}</td>`;
            h += `<td class="r" style="color:#e0f0cc">${p.asi.toLocaleString()}</td>`;
            h += `<td class="r" style="color:${getColor(p.r5, R5_THRESHOLDS)};font-weight:700">${p.r5.toFixed(2)}</td>`;
            h += `<td class="r" style="color:${getColor(p.rec, REC_THRESHOLDS)};font-weight:700">${p.rec.toFixed(2)}</td>`;
            h += p.ti !== null
                ? `<td class="r" style="color:${getColor(p.ti, TI_THRESHOLDS)}">${p.ti.toFixed(1)}</td>`
                : '<td class="r" style="color:#555">—</td>';
            h += `<td class="r" style="color:${getColor(p.routine, RTN_THRESHOLDS)}">${p.routine.toFixed(1)}</td>`;
            h += `<td class="r">${timeHtml}</td>`;
            h += `<td class="r">${bidHtml}</td>`;
            h += '</tr>';
        });
        h += '</tbody></table></div>';
        return h;
    }

    /* ═════════════════════════════════════════════════════════
       FILTER BAR HTML
       ═════════════════════════════════════════════════════════ */
    function buildFilters() {
        const btnActive = g => fPos.has(g) ? ' active' : '';
        const sideActive = s => fSide.has(s) ? ' active' : '';
        return `
<div id="tmsl-filters">
  <div class="tmsl-btngrp">
    <span class="tmsl-pos-btn gk${btnActive('gk')}" data-group="gk">GK</span>
    <span class="tmsl-pos-btn de${btnActive('de')}" data-group="de">D</span>
    <span class="tmsl-pos-btn dm${btnActive('dm')}" data-group="dm">DM</span>
    <span class="tmsl-pos-btn mf${btnActive('mf')}" data-group="mf">M</span>
    <span class="tmsl-pos-btn om${btnActive('om')}" data-group="om">OM</span>
    <span class="tmsl-pos-btn fw${btnActive('fw')}" data-group="fw">F</span>
  </div>
  <div class="tmsl-btngrp">
    <span class="tmsl-side-btn${sideActive('l')}" data-side="l">L</span>
    <span class="tmsl-side-btn${sideActive('c')}" data-side="c">C</span>
    <span class="tmsl-side-btn${sideActive('r')}" data-side="r">R</span>
  </div>
  <div class="tmsl-fsep"></div>
  <div class="tmsl-fgroup">
    <span class="tmsl-flbl">Age:</span>
    <input class="tmsl-fnum" id="tmsl-agemin" type="number" min="0" max="40" value="${fAgeMin || ''}" placeholder="Min">
    <span style="color:#4a6a38;font-size:11px">–</span>
    <input class="tmsl-fnum" id="tmsl-agemax" type="number" min="0" max="40" value="${fAgeMax === 99 ? '' : fAgeMax}" placeholder="Max">
  </div>
  <div class="tmsl-fsep"></div>
  <div class="tmsl-fgroup">
    <span class="tmsl-flbl">R5:</span>
    <input class="tmsl-fnum" id="tmsl-r5min" type="number" min="0" step="0.1" value="${fR5Min}" placeholder="Min">
    <span style="color:#4a6a38;font-size:11px">–</span>
    <input class="tmsl-fnum" id="tmsl-r5max" type="number" min="0" step="0.1" value="${fR5Max}" placeholder="Max">
  </div>
  <div class="tmsl-fsep"></div>
  <div class="tmsl-fgroup">
    <span class="tmsl-flbl">REC:</span>
    <input class="tmsl-fnum" id="tmsl-recmin" type="number" min="0" step="0.01" value="${fRecMin}" placeholder="Min">
    <span class="tmsl-flbl">–</span>
    <input class="tmsl-fnum" id="tmsl-recmax" type="number" min="0" step="0.01" value="${fRecMax}" placeholder="Max">
  </div>
  <div class="tmsl-fsep"></div>
  <div class="tmsl-fgroup">
    <span class="tmsl-flbl">TI:</span>
    <input class="tmsl-fnum" id="tmsl-timin" type="number" step="0.1" value="${fTiMin}" placeholder="Min">
    <span class="tmsl-flbl">–</span>
    <input class="tmsl-fnum" id="tmsl-timax" type="number" step="0.1" value="${fTiMax}" placeholder="Max">
  </div>
</div>`;
    }

    /* ═════════════════════════════════════════════════════════
       RENDER PANEL
       ═════════════════════════════════════════════════════════ */
    function renderPanel() {
        let panel = document.getElementById('tmsl-panel');
        if (panel) panel.remove();

        panel = document.createElement('div');
        panel.id = 'tmsl-panel';

        // ── Tabs row ──
        const ixCountHtml = indexedPlayers !== null ? ` <span class="tmsl-tab-count">(${indexedPlayers.length})</span>` : '';
        const eProg = enrichProgress
            ? `<span id="tmsl-enrich-progress"  style="font-size:11px;color:#fbbf24;margin-left:auto">⏳ Enriching ${enrichProgress.done}/${enrichProgress.total}…</span>`
            : `<span id="tmsl-enrich-progress"  style="display:none"></span>`;
        const iProg = indexedProgress
            ? `<span id="tmsl-indexed-progress" style="font-size:11px;color:#fbbf24;margin-left:auto">⏳ Enriching ${indexedProgress.done}/${indexedProgress.total}…</span>`
            : `<span id="tmsl-indexed-progress" style="display:none"></span>`;
        let h = `<div class="tmsl-tabs">`;
        const ixDisabled = shortlistLoading ? ' disabled title="Pričekaj dok se shortlist učita…"' : '';
        h += `<button class="tmsl-tab${activeTab === 'shortlist' ? ' active' : ''}" data-tab="shortlist">📋 Shortlist <span class="tmsl-tab-count">(${allPlayers.length})</span></button>`;
        h += `<button class="tmsl-tab${activeTab === 'indexed' ? ' active' : ''}${shortlistLoading ? ' disabled' : ''}" data-tab="indexed"${ixDisabled}>🗄 Indexed${ixCountHtml}</button>`;
        h += `<div style="margin-left:auto;display:flex;align-items:center;gap:8px">`;
        h += eProg + iProg;
        if (loadMoreState === 'loading') {
            h += `<button class="tmsl-loadbtn" disabled>⏳ Loading…</button>`;
        } else if (loadMoreState === 'done') {
            h += `<button id="tmsl-reload-btn" class="tmsl-reloadbtn" title="Fetch again">🔄 Reload</button>`;
        } else {
            h += `<button id="tmsl-loadmore-btn" class="tmsl-loadbtn">⬇ Fetch More</button>`;
        }
        h += `</div></div>`;

        if (activeTab === 'shortlist') {
            const filtered = allPlayers.filter(playerMatchesFilters);
            sortPlayers(filtered);
            h += buildFilters();
            if (filtered.length) {
                h += buildTable(filtered);
            } else {
                h += '<div style="text-align:center;padding:40px;color:#4a7a38;font-size:13px">No players match current filters</div>';
            }
        } else {
            if (!indexedPlayers) {
                h += '<div style="text-align:center;padding:40px;color:#4a7a38;font-size:13px">Loading indexed players…</div>';
            } else {
                const ixFiltered = indexedPlayers.filter(playerMatchesFilters);
                h += buildFilters();
                if (!ixFiltered.length) {
                    h += '<div style="text-align:center;padding:40px;color:#4a7a38;font-size:13px">No players match current filters</div>';
                } else {
                    h += buildIndexedTable([...ixFiltered]);
                }
            }
        }

        panel.innerHTML = h;

        // insert before column1_d or fallback
        const ref = document.querySelector('.column1_d') || document.querySelector('.main_center');
        if (ref) {
            ref.parentNode.insertBefore(panel, ref);
        } else {
            document.body.appendChild(panel);
        }

        // widen layout
        const mc = document.querySelector('.main_center');
        if (mc) mc.style.maxWidth = '1250px';

        // ── Tab click ──
        panel.querySelectorAll('.tmsl-tab[data-tab]').forEach(btn => {
            btn.addEventListener('click', () => {
                const t = btn.dataset.tab;
                if (activeTab === t || btn.disabled || shortlistLoading) return;
                activeTab = t;
                if (t === 'indexed' && !indexedPlayers && !indexedLoading) {
                    loadIndexedTab();
                } else {
                    renderPanel();
                }
            });
        });

        // ── position filter buttons (both tabs) ──
        panel.querySelectorAll('.tmsl-pos-btn[data-group]').forEach(btn => {
            btn.addEventListener('click', () => {
                const g = btn.dataset.group;
                if (fPos.has(g)) fPos.delete(g); else fPos.add(g);
                renderPanel();
            });
        });

        // ── side filter buttons (both tabs) ──
        panel.querySelectorAll('.tmsl-side-btn[data-side]').forEach(btn => {
            btn.addEventListener('click', () => {
                const s = btn.dataset.side;
                if (fSide.has(s)) fSide.delete(s); else fSide.add(s);
                renderPanel();
            });
        });

        // ── number filters (both tabs) ──
        const wire = (id, fn) => { const el = document.getElementById(id); if (el) el.addEventListener('change', fn); };
        wire('tmsl-agemin', e => { fAgeMin = parseInt(e.target.value) || 0; renderPanel(); });
        wire('tmsl-agemax', e => { fAgeMax = parseInt(e.target.value) || 99; renderPanel(); });
        wire('tmsl-r5min', e => { fR5Min = e.target.value; renderPanel(); });
        wire('tmsl-r5max', e => { fR5Max = e.target.value; renderPanel(); });
        wire('tmsl-recmin', e => { fRecMin = e.target.value; renderPanel(); });
        wire('tmsl-recmax', e => { fRecMax = e.target.value; renderPanel(); });
        wire('tmsl-timin',  e => { fTiMin  = e.target.value; renderPanel(); });
        wire('tmsl-timax',  e => { fTiMax  = e.target.value; renderPanel(); });

        // ── load more / reload ──
        const lmBtn = document.getElementById('tmsl-loadmore-btn');
        if (lmBtn) lmBtn.addEventListener('click', () => { fetchMore(); });
        const rlBtn = document.getElementById('tmsl-reload-btn');
        if (rlBtn) rlBtn.addEventListener('click', () => { refetchAndEnrich(); });

        if (activeTab === 'shortlist') {
            // ── sort click handlers ──
            panel.querySelectorAll('th[data-col]').forEach(th => {
                th.addEventListener('click', () => {
                    const col = th.dataset.col;
                    if (sortCol === col) sortDir *= -1;
                    else { sortCol = col; sortDir = (col === 'name' || col === 'pos') ? 1 : -1; }
                    renderPanel();
                });
            });

            // ── tooltip on name hover ──
            panel.querySelectorAll('tr[data-pid]').forEach(tr => {
                const link = tr.querySelector('.tmsl-link');
                if (!link) return;
                link.addEventListener('mouseenter', () => {
                    const p = allPlayers.find(pl => pl.id === tr.dataset.pid);
                    if (p) showTip(link, p);
                });
                link.addEventListener('mouseleave', hideTip);
            });
        } else {
            // ── indexed sort click ──
            panel.querySelectorAll('th[data-ixcol]').forEach(th => {
                th.addEventListener('click', () => {
                    const col = th.dataset.ixcol;
                    if (ixSortCol === col) ixSortDir *= -1;
                    else { ixSortCol = col; ixSortDir = col === 'name' ? 1 : -1; }
                    renderPanel();
                });
            });

            // ── tooltip on indexed row hover ──
            panel.querySelectorAll('tr[data-ixpid]').forEach(tr => {
                const link = tr.querySelector('.tmsl-link');
                if (!link) return;
                link.addEventListener('mouseenter', () => {
                    const p = indexedPlayers && indexedPlayers.find(pl => pl.id === tr.dataset.ixpid);
                    if (p) showTip(link, p);
                });
                link.addEventListener('mouseleave', hideTip);
            });
        }
    }

    /* ═════════════════════════════════════════════════════════
       MULTI-FETCH  – fetch page up to 5 more times, merge unique
       ═════════════════════════════════════════════════════════ */
    async function fetchMore() {
        if (loadMoreState !== 'idle') return;
        loadMoreState = 'loading';
        renderPanel();

        const seenIds = new Set(allPlayers.map(p => p.id));
        const TRIES = 5;

        for (let i = 0; i < TRIES; i++) {
            try {
                const res = await fetch('/shortlist/', { credentials: 'include' });
                const text = await res.text();
                // extract var players_ar = [...];
                const m = text.match(/var\s+players_ar\s*=\s*(\[[\s\S]*?\]);\s*(?:\n|var\s)/);
                if (!m) continue;
                const arr = JSON.parse(m[1]);
                let added = 0;
                for (const raw of arr) {
                    if (!seenIds.has(raw.id)) {
                        seenIds.add(raw.id);
                        allPlayers.push(mapPlayer(raw));
                        added++;
                    }
                }
                console.log(`[TM Shortlist] fetch ${i + 1}: +${added} new, total ${allPlayers.length}`);
            } catch (e) {
                console.warn('[TM Shortlist] fetch error', e);
            }
        }

        loadMoreState = 'done';
        renderPanel();
    }

    async function refetchAndEnrich() {
        const before = new Set(allPlayers.map(p => p.id));
        loadMoreState = 'idle';
        await fetchMore(); // sets state to 'loading' then 'done', renders
        const newPlayers = allPlayers.filter(p => !before.has(p.id));
        if (!newPlayers.length) return;
        const stale = newPlayers.filter(p => enrichFromDB(p));
        if (stale.length) await runTooltipRefresh(stale);
        else renderPanel();
    }

    /* ═════════════════════════════════════════════════════════
       INIT
       ═════════════════════════════════════════════════════════ */
    async function init() {
        const raw = window.players_ar;
        if (!Array.isArray(raw) || raw.length === 0) return;

        injectCSS();
        shortlistLoading = true;
        allPlayers = raw.map(mapPlayer);
        renderPanel();

        // Step 1: If at the 200-player cap, multi-fetch first to collect every unique player
        if (raw.length >= 200) {
            await fetchMore();
        }

        // Step 2: DB enrichment + tooltip refresh for ALL players
        try {
            await PlayerDB.init();
            // Enrich from DB + tooltip refresh only for stale/missing players
            const stale = allPlayers.filter(p => enrichFromDB(p));
            if (stale.length > 0) {
                await runTooltipRefresh(stale);
            } else {
                renderPanel();
            }
        } catch (e) {
            console.warn('[TM Shortlist] IndexedDB init failed:', e);
        }

        shortlistLoading = false;
        renderPanel();
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

})();