Enhanced shortlist viewer – squad-style table, R5/REC/TI ratings, multi-fetch up to 6× for unique players
// ==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, '"')}">📋</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();
}
})();