Enhanced squad overview with R5/REC ratings, training table, skill tooltips and sortable tables
// ==UserScript==
// @name TM Squad Viewer
// @namespace https://trophymanager.com
// @version 1.1.0
// @description Enhanced squad overview with R5/REC ratings, training table, skill tooltips and sortable tables
// @match https://trophymanager.com/club/*/squad*
// @grant none
// ==/UserScript==
(function () {
'use strict';
/* ═══════════════════════════════════════════════════════════
CONSTANTS
═══════════════════════════════════════════════════════════ */
const POS_MULTIPLIERS = [0.3, 0.3, 0.9, 0.6, 1.5, 0.9, 0.9, 0.6, 0.3];
const COLOR_LEVELS = [
{ color: '#ff4c4c' }, { color: '#ff8c00' }, { color: '#ffd700' },
{ color: '#90ee90' }, { color: '#00cfcf' }, { color: '#5b9bff' }, { color: '#cc88ff' }
];
const REC_THRESHOLDS = [5.5, 5, 4, 3, 2, 1, 0];
const R5_THRESHOLDS = [110, 100, 90, 80, 70, 60, 0];
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];
const TRN_LABELS = ['Str/Wor/Sta', 'Mar/Tac', 'Cro/Pac', 'Pas/Tec/Set', 'Hea/Pos', 'Fin/Lon'];
const TRN_DOT_COLORS = ['#555', '#ef4444', '#f59e0b', '#eab308', '#84cc16', '#22c55e'];
/* ─── Weight tables ─────────────────────────────────────── */
// 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
];
// Str Sta Pac Mar Tac Wor Pos Pas Cro Tec Hea Fin Lon Set
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], // DC
[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], // DL/R
[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], // DMC
[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], // DML/R
[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], // MC
[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], // ML/R
[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], // OMC
[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], // OML/R
[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], // F
[0.07466384, 0.07466384, 0.07466384, 0.14932769, 0.10452938, 0.14932769, 0.10452938, 0.10344411, 0.07512610, 0.04492581, 0.04479831] // GK
];
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'];
/* ═══════════════════════════════════════════════════════════
UTILITIES
═══════════════════════════════════════════════════════════ */
const fix2 = v => (Math.round(v * 100) / 100).toFixed(2);
const getColor = (value, thresholds) => {
for (let i = 0; i < thresholds.length; i++) {
if (value >= thresholds[i]) return COLOR_LEVELS[i].color;
}
return COLOR_LEVELS[COLOR_LEVELS.length - 1].color;
};
const getPositionIndex = pos => {
switch (pos.toLowerCase()) {
case 'gk': return 9;
case 'dc': case 'dcl': case 'dcr': return 0;
case 'dr': case 'dl': return 1;
case 'dmc': case 'dmcl': case 'dmcr': return 2;
case 'dmr': case 'dml': return 3;
case 'mc': case 'mcl': case 'mcr': return 4;
case 'mr': case 'ml': return 5;
case 'omc': case 'omcl': case 'omcr': return 6;
case 'omr': case 'oml': return 7;
case 'fc': case 'fcl': case 'fcr': case 'f': return 8;
default: return 0;
}
};
const posSortOrder = idx => idx === 9 ? 0 : idx + 1;
const skillColor = v => {
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 = posIdx => {
if (posIdx === 9) return '#4ade80'; // GK – green
if (posIdx <= 1) return '#60a5fa'; // DC, DL/R – blue
if (posIdx <= 7) return '#fbbf24'; // DMC–OML/R – yellow
return '#f87171'; // F – red
};
/* ─── Season / TI helpers ────────────────────────────────── */
const TRAINING1 = new Date('2023-01-16T23:00:00Z'); // s65 first training
const SEASON_DAYS = 84;
const WAGE_RATE = 15.8079;
const WAGE_RATE_NEW = 23.75;
const getCurrentSession = () => {
const now = new Date();
let day = (now.getTime() - TRAINING1.getTime()) / 1000 / 3600 / 24;
while (day > SEASON_DAYS - 16 / 24) day -= SEASON_DAYS;
const s = Math.floor(day / 7) + 1;
return s <= 0 ? 12 : s;
};
const currentSession = getCurrentSession();
const calculateTI = (asi, wage, isGK) => {
if (!asi || !wage || wage <= 30000) return null;
const w = isGK ? 48717927500 : 263533760000;
const wr = WAGE_RATE;
const { pow, log, round } = Math;
const log27 = log(pow(2, 7));
return round((pow(2, log(w * asi) / log27) - pow(2, log(w * wage / wr) / log27)) * 10);
};
/* ═══════════════════════════════════════════════════════════
R5 & REC CALCULATIONS
═══════════════════════════════════════════════════════════ */
const calculateRemainders = (posIdx, skills, asi) => {
const weight = posIdx === 9 ? 48717927500 : 263533760000;
const skillSum = skills.reduce((sum, s) => sum + parseInt(s), 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, remainderW1 = 0, remainderW2 = 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) {
remainderW1 += WEIGHT_RB[posIdx][i];
remainderW2 += WEIGHT_R5[posIdx][i];
not20++;
}
}
if (remainder / not20 > 0.9 || !not20) {
not20 = posIdx === 9 ? 11 : 14;
remainderW1 = 1;
remainderW2 = 5;
}
rec = fix2((rec + remainder * remainderW1 / not20 - 2) / 3);
return { remainder, remainderW2, not20, ratingR, rec };
};
const calculateBaseR5 = (posIdx, skills, asi, rou) => {
const r = calculateRemainders(posIdx, skills, asi);
const routineBonus = (3 / 100) * (100 - 100 * Math.pow(Math.E, -rou * 0.035));
const ratingR = r.ratingR + (r.remainder * r.remainderW2 / r.not20);
return Number(fix2(ratingR + routineBonus * 5));
};
const calculateR5 = (posIdx, skills, asi, rou) => {
let rating = calculateBaseR5(posIdx, skills, asi, rou);
const rou2 = (3 / 100) * (100 - 100 * Math.pow(Math.E, -rou * 0.035));
const r = calculateRemainders(posIdx, skills, asi);
const { pow, E } = Math;
const goldstar = skills.filter(s => s == 20).length;
const skillsB = skills.map(s => s == 20 ? 20 : s * 1 + r.remainder / (skills.length - goldstar));
const sr = skillsB.map((s, i) => i === 1 ? s : s + rou2);
if (skills.length !== 11) {
const headerBonus = sr[10] > 12
? 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 fkBonus = fix2(pow(E, (sr[13] + sr[12] + sr[9] * 0.5) ** 2 * 0.002) / 327.92526);
const ckBonus = fix2(pow(E, (sr[13] + sr[8] + sr[9] * 0.5) ** 2 * 0.002) / 983.65770);
const pkBonus = fix2(pow(E, (sr[13] + sr[11] + sr[9] * 0.5) ** 2 * 0.002) / 1967.31409);
const allBonus = headerBonus * 1 + fkBonus * 1 + ckBonus * 1 + pkBonus * 1;
const defSkillsSq = 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 gainBase = fix2(defSkillsSq / 6 / 22.9 ** 2);
const offSkillsSq = 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 keepBase = fix2(offSkillsSq / 6 / 22.9 ** 2);
const m = POS_MULTIPLIERS[posIdx];
return fix2(rating + allBonus + gainBase * m * 1 + keepBase * m * 1);
}
return fix2(rating);
};
/* ═══════════════════════════════════════════════════════════
SKILL EXTRACTION FROM POST DATA
═══════════════════════════════════════════════════════════ */
const extractSkills = p => {
const isGK = parseInt(p.handling) > 0;
if (isGK) {
// Weight order: Str, Pac, Jum, Sta, One, Ref, Aer, Com, Kic, Thr, Han
return {
isGK: true,
skills: [
parseInt(p.strength), parseInt(p.pace), parseInt(p.jumping),
parseInt(p.stamina), parseInt(p.oneonones), parseInt(p.reflexes),
parseInt(p.arialability), parseInt(p.communication),
parseInt(p.kicking), parseInt(p.throwing), parseInt(p.handling)
],
labels: GK_LABELS
};
}
// Weight order: Str, Sta, Pac, Mar, Tac, Wor, Pos, Pas, Cro, Tec, Hea, Fin, Lon, Set
return {
isGK: false,
skills: [
parseInt(p.strength), parseInt(p.stamina), parseInt(p.pace),
parseInt(p.marking), parseInt(p.tackling), parseInt(p.workrate),
parseInt(p.positioning), parseInt(p.passing), parseInt(p.crossing),
parseInt(p.technique), parseInt(p.heading), parseInt(p.finishing),
parseInt(p.longshots), parseInt(p.setpieces)
],
labels: FIELD_LABELS
};
};
/* ═══════════════════════════════════════════════════════════
DATA PROCESSING
═══════════════════════════════════════════════════════════ */
let processed = false;
let allPlayers = [];
let bTeamPlayers = [];
let bTeamFetched = false;
let bTeamName = '';
let fetchingBTeam = false;
const onSaleIds = new Set();
const scanForSales = () => {
document.querySelectorAll('img[src*="auction_hammer"]').forEach(img => {
const tr = img.closest('tr');
if (!tr) return;
const link = tr.querySelector('a[href*="/players/"]');
if (!link) return;
const m = link.getAttribute('href').match(/\/players\/(\d+)/);
if (m) onSaleIds.add(m[1]);
});
};
const mapPlayer = p => {
const { isGK, skills, labels } = extractSkills(p);
const positions = (p.favposition || 'dc').split(',').map(s => s.trim());
positions.sort((a, b) => getPositionIndex(a) - getPositionIndex(b));
const posIdx = getPositionIndex(positions[0]);
const asi = parseInt(p.asi) || 0;
const routine = parseFloat(p.rutine) || 0;
let r5 = 0, rec = 0;
if (asi > 0 && skills.every(s => !isNaN(s))) {
r5 = Number(calculateR5(posIdx, skills, asi, routine));
rec = Number(calculateRemainders(posIdx, skills, asi).rec);
if (positions.length > 1) {
const posIdx2 = getPositionIndex(positions[1]);
if (posIdx2 !== posIdx) {
const r5_2 = Number(calculateR5(posIdx2, skills, asi, routine));
const rec_2 = Number(calculateRemainders(posIdx2, skills, asi).rec);
if (r5_2 > r5) r5 = r5_2;
if (rec_2 > rec) rec = rec_2;
}
}
}
const wage = parseInt(p.wage) || 0;
const tiRaw = calculateTI(asi, wage, isGK);
const ti = tiRaw !== null && currentSession > 0 ? Number((tiRaw / currentSession).toFixed(1)) : null;
return {
id: p.player_id,
name: p.player_name || p.player_name_long || '',
country: p.player_country || p.country || '',
pos: positions.map(s => s.toUpperCase()).join(', '),
posList: positions.map(s => ({ name: s.toUpperCase(), idx: getPositionIndex(s) })),
favpos: p.favposition || '',
posIdx, isGK,
age: parseInt(p.age) || 0,
month: parseInt(p.month) || 0,
asi, r5, rec, routine, ti,
no: parseInt(p.no) || 0,
gp: parseInt(p.stats?.gp) || 0,
goals: parseInt(p.stats?.goals) || 0,
assists: parseInt(p.stats?.assists) || 0,
rating: parseInt(p.stats?.rating) || 0,
mom: parseInt(p.stats?.mom) || 0,
cards: parseInt(p.stats?.cards) || 0,
ga: parseInt(p.stats?.ga) || 0,
wage,
ban: p.ban || '0',
banPoints: parseInt(p.ban_points) || parseInt(p.strafpoint) || 0,
injury: p.injury || '0',
training: p.training || '',
trainingCustom: p.training_custom || '',
skills, labels,
retire: p.retire || '0'
};
};
const processData = data => {
if (!data || !data.post || !Object.keys(data.post).length) return;
allPlayers = Object.values(data.post).map(mapPlayer);
processed = true;
renderPanel();
if (!bTeamFetched) {
bTeamFetched = true;
const clubId = location.pathname.match(/\/club\/(\d+)/)?.[1];
if (clubId) fetchBTeamInfo(clubId);
}
};
const fetchBTeamInfo = clubId => {
fetch(`/club/${clubId}/`)
.then(r => r.text())
.then(html => {
const idMatch = html.match(/B-Team:\s*<\/strong>\s*<a\s+href="\/club\/(\d+)\//)
|| html.match(/B-Team:[\s\S]*?\/club\/(\d+)\//);
if (!idMatch) return;
const bTeamId = idMatch[1];
const nameMatch = html.match(/B-Team:\s*<\/strong>\s*<a[^>]*>([^<]+)<\/a>/)
|| html.match(/B-Team:[\s\S]*?club_link='\d+'>([^<]+)<\/a>/);
bTeamName = nameMatch ? nameMatch[1].trim() : 'B-Team';
fetchingBTeam = true;
$.post('/ajax/players_get_select.ajax.php', { type: 'change', club_id: bTeamId })
.done(res => {
fetchingBTeam = false;
try {
const data = typeof res === 'string' ? JSON.parse(res) : res;
if (data && data.post && Object.keys(data.post).length) {
bTeamPlayers = Object.values(data.post).map(p => {
const pl = mapPlayer(p);
pl.isBTeam = true;
return pl;
});
renderPanel();
}
} catch (e) { /* ignore */ }
})
.fail(() => { fetchingBTeam = false; });
})
.catch(() => { });
};
/* ═══════════════════════════════════════════════════════════
CSS
═══════════════════════════════════════════════════════════ */
const injectCSS = () => {
const style = document.createElement('style');
style.textContent = `
#tmsq-panel {
background: #1c3410;
border-radius: 10px;
padding: 14px;
margin: 10px 0 16px 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: #c8e0b4;
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
border: 1px solid #2a4a1c;
}
#tmsq-panel * { box-sizing: border-box; }
.tmsq-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 10px;
}
.tmsq-title {
font-size: 15px; font-weight: 700; color: #fff;
display: flex; align-items: center; gap: 6px;
}
.tmsq-summary {
display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px;
padding: 8px 10px; background: #162e0e; border-radius: 8px;
}
.tmsq-sum-item {
display: flex; flex-direction: column; align-items: center;
min-width: 72px; padding: 5px 10px;
background: #1c3410; border-radius: 6px;
}
.tmsq-sum-lbl { font-size: 9px; color: #6a9a58; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 2px; }
.tmsq-sum-val { font-size: 15px; font-weight: 700; }
.tmsq-table-wrap {
overflow-x: auto; border-radius: 8px;
border: 1px solid #2a4a1c;
}
.tmsq-table {
width: 100%; border-collapse: collapse; font-size: 12px;
}
.tmsq-table thead th {
background: #162e0e; color: #6a9a58; padding: 6px 7px;
text-align: left; font-size: 10px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.4px;
border-bottom: 1px solid #2a4a1c; cursor: pointer;
user-select: none; white-space: nowrap;
position: sticky; top: 0; z-index: 2;
}
.tmsq-table thead th:hover { color: #c8e0b4; background: #243d18; }
.tmsq-table thead th.sorted { color: #6cc040; }
.tmsq-table tbody tr {
border-bottom: 1px solid rgba(42,74,28,.4);
transition: background 0.12s;
}
.tmsq-table tbody tr:nth-child(odd) { background: #1c3410; }
.tmsq-table tbody tr:nth-child(even) { background: #162e0e; }
.tmsq-table tbody tr:hover { background: #243d18 !important; }
.tmsq-table td {
padding: 4px 7px; white-space: nowrap; vertical-align: middle;
}
.tmsq-table td.r, .tmsq-table th.r { text-align: right; }
.tmsq-table td.c, .tmsq-table th.c { text-align: center; }
.tmsq-table .pos-bar {
width: 3px; padding: 0; border-radius: 2px;
}
.tmsq-link {
color: #90b878; text-decoration: none; font-weight: 500;
}
.tmsq-link:hover { color: #c8e0b4; text-decoration: underline; }
.tmsq-flag {
margin-right: 4px; vertical-align: middle;
}
.tmsq-status { font-size: 10px; margin-left: 3px; vertical-align: middle; }
.tmsq-section-lbl {
font-size: 12px; font-weight: 700; color: #6cc040;
text-transform: uppercase; letter-spacing: 0.5px;
margin-bottom: 6px; padding: 4px 0;
border-bottom: 1px solid #2a4a1c;
}
.tmsq-pos-chip {
display: inline-block; padding: 1px 6px; border-radius: 4px;
font-size: 10px; font-weight: 700; letter-spacing: 0.3px;
line-height: 16px; text-align: center; min-width: 28px;
}
.tmsq-bteam-badge {
display: inline-block; margin-left: 4px; padding: 0 4px;
font-size: 9px; font-weight: 700; color: #f59e0b;
background: rgba(245,158,11,0.15); border: 1px solid rgba(245,158,11,0.3);
border-radius: 3px; vertical-align: middle; line-height: 14px;
}
.tmsq-sale-badge {
display: inline-block; margin-left: 4px; padding: 0 4px;
font-size: 9px; font-weight: 700; color: #ef4444;
background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.3);
border-radius: 3px; vertical-align: middle; line-height: 14px;
}
.tmsq-trn-dots {
display: inline-flex; gap: 2px; margin-left: 4px;
}
.tmsq-trn-dot {
display: inline-block; width: 14px; height: 14px;
border-radius: 3px; text-align: center; line-height: 14px;
font-size: 9px; font-weight: 700; color: #000;
}
.tmsq-card {
display: inline-block; width: 10px; height: 14px;
border-radius: 2px; margin-left: 4px; vertical-align: middle;
box-shadow: 0 1px 2px rgba(0,0,0,0.4);
}
.tmsq-card-yellow { background: #eab308; }
.tmsq-card-red {
background: #ef4444; position: relative;
font-size: 8px; font-weight: 700; color: #fff;
text-align: center; line-height: 14px;
}
/* Skill tooltip */
.tmsq-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,0.6);
pointer-events: none; font-size: 11px; color: #c8e0b4;
}
.tmsq-tip-header {
display: flex; align-items: center; gap: 8px;
margin-bottom: 8px; padding-bottom: 6px;
border-bottom: 1px solid rgba(74,144,48,0.3);
}
.tmsq-tip-name { font-size: 13px; font-weight: 700; color: #e0f0cc; }
.tmsq-tip-pos { font-size: 10px; color: #8abc78; font-weight: 600; }
.tmsq-tip-badges { display: flex; gap: 6px; margin-left: auto; }
.tmsq-tip-badge {
font-size: 10px; font-weight: 700; padding: 2px 6px;
border-radius: 4px; background: rgba(0,0,0,0.3);
}
.tmsq-tip-skills {
display: flex; gap: 12px; margin-bottom: 6px;
}
.tmsq-tip-skills-col {
flex: 1; min-width: 0;
}
.tmsq-tip-skill {
display: flex; justify-content: space-between;
padding: 1px 0; border-bottom: 1px solid rgba(74,144,48,0.12);
}
.tmsq-tip-skill-name { color: #8abc78; font-size: 10px; }
.tmsq-tip-skill-val { font-weight: 700; font-size: 11px; }
.tmsq-tip-footer {
display: flex; gap: 8px; justify-content: center;
padding-top: 6px; border-top: 1px solid rgba(74,144,48,0.3);
}
.tmsq-tip-stat { text-align: center; }
.tmsq-tip-stat-val { font-size: 14px; font-weight: 800; }
.tmsq-tip-stat-lbl { font-size: 9px; color: #6a9a58; text-transform: uppercase; }
`;
document.head.appendChild(style);
};
/* ═══════════════════════════════════════════════════════════
RENDERING
═══════════════════════════════════════════════════════════ */
let sortCol = 'pos', sortDir = 1;
const getSortVal = (p, col) => {
if (col === 'age') return p.age * 12 + p.month;
if (col === 'pos') {
let v = posSortOrder(p.posIdx) * 100;
if (p.posList.length > 1) v += 50 + posSortOrder(p.posList[1].idx);
return v;
}
return p[col];
};
const sortPlayers = players => {
players.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));
});
};
const statusIcons = p => {
let s = '';
if (p.ban === 'g') {
s += `<span class="tmsq-card tmsq-card-yellow" title="Yellow card accumulation"></span>`;
} else if (p.ban && p.ban.startsWith('r')) {
const matches = p.ban.slice(1) || '1';
s += `<span class="tmsq-card tmsq-card-red" title="Red card (${matches} match${matches==='1'?'':'es'})">${matches}</span>`;
}
if (p.injury && p.injury !== '0') {
s += `<span style="margin-left:4px;color:#ef4444;font-size:12px;font-weight:700;vertical-align:middle" title="Injury: ${p.injury} weeks">✚${p.injury}</span>`;
}
if (p.retire && p.retire !== '0') {
s += `<img src="http://trophymanager.com/pics/icons/retire.gif" style="margin-left:4px;vertical-align:middle;width:14px;height:14px" title="Retiring">`;
}
return s;
};
// Tooltip element
let tip;
const ensureTip = () => {
if (tip) return;
tip = document.createElement('div');
tip.className = 'tmsq-tip';
document.body.appendChild(tip);
};
const showTip = (anchor, p) => {
ensureTip();
// Header: name, pos, age, R5 badge
let h = '<div class="tmsq-tip-header">';
h += `<div><div class="tmsq-tip-name">${p.name}</div>`;
const ageDisplay = `${p.age}.${String(p.month).padStart(2,'0')}`;
h += `<div class="tmsq-tip-pos">${p.pos.toUpperCase()} · #${p.no} · Age ${ageDisplay}</div></div>`;
h += '<div class="tmsq-tip-badges">';
h += `<span class="tmsq-tip-badge" style="color:${getColor(p.r5, R5_THRESHOLDS)}">R5 ${p.r5.toFixed(2)}</span>`;
h += '</div></div>';
// Skills two-column layout
const fieldLeft = [0,1,2,3,4,5,6];
const fieldRight = [7,8,9,10,11,12,13];
const gkLeft = [0,3,1];
const gkRight = [10,4,5,6,2,7,8,9];
const leftIdx = p.isGK ? gkLeft : fieldLeft;
const rightIdx = p.isGK ? gkRight : fieldRight;
const renderCol = (indices) => {
let c = '<div class="tmsq-tip-skills-col">';
indices.forEach(i => {
const val = p.skills[i];
const display = val >= 19 ? '★' : String(val);
c += '<div class="tmsq-tip-skill">';
c += `<span class="tmsq-tip-skill-name">${p.labels[i]}</span>`;
c += `<span class="tmsq-tip-skill-val" style="color:${skillColor(val)}">${display}</span>`;
c += '</div>';
});
c += '</div>';
return c;
};
h += '<div class="tmsq-tip-skills">';
h += renderCol(leftIdx);
h += renderCol(rightIdx);
h += '</div>';
// Footer: ASI, REC, Routine
h += '<div class="tmsq-tip-footer">';
h += `<div class="tmsq-tip-stat"><div class="tmsq-tip-stat-val" style="color:#e0f0cc">${p.asi.toLocaleString()}</div><div class="tmsq-tip-stat-lbl">ASI</div></div>`;
h += `<div class="tmsq-tip-stat"><div class="tmsq-tip-stat-val" style="color:${getColor(Number(p.rec), REC_THRESHOLDS)}">${Number(p.rec).toFixed(2)}</div><div class="tmsq-tip-stat-lbl">REC</div></div>`;
h += `<div class="tmsq-tip-stat"><div class="tmsq-tip-stat-val" style="color:#8abc78">${p.routine.toFixed(1)}</div><div class="tmsq-tip-stat-lbl">Routine</div></div>`;
h += '</div>';
tip.innerHTML = h;
tip.style.display = 'block';
const rect = anchor.getBoundingClientRect();
let top = rect.bottom + window.scrollY + 4;
let left = rect.left + window.scrollX;
tip.style.top = top + 'px';
tip.style.left = left + 'px';
requestAnimationFrame(() => {
const tipRect = tip.getBoundingClientRect();
if (tipRect.right > window.innerWidth - 10) {
tip.style.left = (window.innerWidth - tipRect.width - 10) + 'px';
}
if (tipRect.bottom > window.innerHeight + window.scrollY - 10) {
tip.style.top = (rect.top + window.scrollY - tipRect.height - 4) + 'px';
}
});
};
const hideTip = () => { if (tip) tip.style.display = 'none'; };
// Column definitions
const cols = [
{ key: 'no', lbl: '#', align: 'r' },
{ 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: 'trn', lbl: 'Trn', align: 'c' },
];
const renderTrainingDots = tc => {
if (!tc || tc.length !== 6) return '<span style="color:#555">—</span>';
let h = '<span class="tmsq-trn-dots" title="' + tc.split('').map((d, i) => TRN_LABELS[i] + ': ' + d).join(' ') + '">';
for (let i = 0; i < 6; i++) {
const v = parseInt(tc[i]) || 0;
h += `<span class="tmsq-trn-dot" style="background:${TRN_DOT_COLORS[v]}">${v}</span>`;
}
h += '</span>';
return h;
};
const buildSummary = (players) => {
const n = players.length;
const avgR5 = n ? players.reduce((s, p) => s + p.r5, 0) / n : 0;
const avgRec = n ? players.reduce((s, p) => s + Number(p.rec), 0) / n : 0;
const avgAge = n ? players.reduce((s, p) => s + p.age + p.month / 12, 0) / n : 0;
const avgASI = n ? players.reduce((s, p) => s + p.asi, 0) / n : 0;
const tiPlayers = players.filter(p => p.ti !== null);
const avgTI = tiPlayers.length ? tiPlayers.reduce((s, p) => s + p.ti, 0) / tiPlayers.length : 0;
const hasTI = tiPlayers.length > 0;
let h = '<div class="tmsq-summary">';
h += `<div class="tmsq-sum-item"><span class="tmsq-sum-val">${n}</span><span class="tmsq-sum-lbl">Players</span></div>`;
h += `<div class="tmsq-sum-item"><span class="tmsq-sum-val" style="color:${getColor(avgR5, R5_THRESHOLDS)}">${avgR5.toFixed(2)}</span><span class="tmsq-sum-lbl">Avg R5</span></div>`;
h += `<div class="tmsq-sum-item"><span class="tmsq-sum-val" style="color:${getColor(avgRec, REC_THRESHOLDS)}">${avgRec.toFixed(2)}</span><span class="tmsq-sum-lbl">Avg REC</span></div>`;
if (hasTI) h += `<div class="tmsq-sum-item"><span class="tmsq-sum-val" style="color:${getColor(avgTI, TI_THRESHOLDS)}">${avgTI.toFixed(1)}</span><span class="tmsq-sum-lbl">Avg TI</span></div>`;
h += `<div class="tmsq-sum-item"><span class="tmsq-sum-val" style="color:${getColor(avgAge, AGE_THRESHOLDS)}">${avgAge.toFixed(1)}</span><span class="tmsq-sum-lbl">Avg Age</span></div>`;
h += `<div class="tmsq-sum-item"><span class="tmsq-sum-val" style="color:#e0f0cc">${Math.round(avgASI).toLocaleString()}</span><span class="tmsq-sum-lbl">Avg ASI</span></div>`;
h += '</div>';
return h;
};
const buildTable = (players) => {
let h = '<div class="tmsq-table-wrap"><table class="tmsq-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 ageStr = `${p.age}.${String(p.month).padStart(2, '0')}`;
const flag = p.country ? `<ib class="flag-img-${p.country} tmsq-flag"></ib>` : '';
const status = statusIcons(p);
const posClr = posGroupColor(p.posIdx);
h += `<tr data-pid="${p.id}">`;
h += `<td class="pos-bar" style="background:${posClr}"></td>`;
h += `<td class="r">${p.no}</td>`;
const bBadge = p.isBTeam ? '<span class="tmsq-bteam-badge">B</span>' : '';
const saleBadge = onSaleIds.has(String(p.id)) ? '<span class="tmsq-sale-badge">💰</span>' : '';
h += `<td>${flag}<a href="/players/${p.id}/" class="tmsq-link">${p.name}</a>${bBadge}${saleBadge}${status}</td>`;
const chipClr = p.posIdx === 9 ? '#4ade80'
: p.posList.some(pp => pp.idx <= 1) ? '#60a5fa'
: p.posList.some(pp => pp.idx === 8) ? '#f87171' : '#fbbf24';
const chipInner = p.posList.map(pp =>
`<span style="color:${posGroupColor(pp.idx)}">${pp.name}</span>`
).join('<span style="color:#6a9a58">, </span>');
h += `<td class="c"><span class="tmsq-pos-chip" style="background:${chipClr}22;border:1px solid ${chipClr}44">${chipInner}</span></td>`;
h += `<td class="r" style="color:${getColor(p.age, AGE_THRESHOLDS)}">${ageStr}</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(Number(p.rec), REC_THRESHOLDS)};font-weight:700">${Number(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="c">${renderTrainingDots(p.trainingCustom)}</td>`;
h += '</tr>';
});
h += '</tbody></table></div>';
return h;
};
const renderPanel = () => {
scanForSales();
const all = [...allPlayers, ...bTeamPlayers];
sortPlayers(all);
const seniors = all.filter(p => p.age > 21);
const youth = all.filter(p => p.age <= 21);
let panel = document.getElementById('tmsq-panel');
if (panel) panel.remove();
panel = document.createElement('div');
panel.id = 'tmsq-panel';
let h = '';
h += '<div class="tmsq-header"><div class="tmsq-title">⭐ Squad Overview</div></div>';
// Seniors table
h += `<div class="tmsq-section-lbl">Seniors (${seniors.length})</div>`;
h += buildSummary(seniors);
h += buildTable(seniors);
// Youth table
h += `<div class="tmsq-section-lbl" style="margin-top:14px">Youth ≤21 (${youth.length})</div>`;
h += buildSummary(youth);
h += buildTable(youth);
panel.innerHTML = h;
// Replace column2_a content with our panel
const target = document.querySelector('.column2_a');
if (target) {
target.innerHTML = '';
target.appendChild(panel);
} else {
const fallback = document.querySelector('#middle_column') || document.body;
fallback.insertBefore(panel, fallback.firstChild);
}
widenLayout();
// Sort 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();
});
});
// Skill tooltip on player name hover
panel.querySelectorAll('tr[data-pid]').forEach(tr => {
const nameLink = tr.querySelector('.tmsq-link');
if (!nameLink) return;
nameLink.addEventListener('mouseenter', () => {
const p = allPlayers.find(pl => pl.id === tr.dataset.pid) || bTeamPlayers.find(pl => pl.id === tr.dataset.pid);
if (p) showTip(nameLink, p);
});
nameLink.addEventListener('mouseleave', hideTip);
});
};
/* ═══════════════════════════════════════════════════════════
XHR HOOK — intercept squad data responses
═══════════════════════════════════════════════════════════ */
const origOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url, ...args) {
this._tmsqUrl = url;
return origOpen.call(this, method, url, ...args);
};
const origSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function (body) {
if (this._tmsqUrl && this._tmsqUrl.includes('players_get_select.ajax.php') && !fetchingBTeam) {
this.addEventListener('load', function () {
try {
const data = typeof this.responseText === 'string'
? JSON.parse(this.responseText) : this.responseText;
if (data && data.post && Object.keys(data.post).length) {
processData(data);
}
} catch (e) { /* ignore */ }
});
}
return origSend.call(this, body);
};
/* ═══════════════════════════════════════════════════════════
INITIALIZATION
═══════════════════════════════════════════════════════════ */
injectCSS();
// Remove column3_a and widen column2_a
const widenLayout = () => {
const col3 = document.querySelector('.column3_a');
if (col3) col3.remove();
const col2 = document.querySelector('.column2_a');
if (col2) col2.style.width = '790px';
};
const tryFetch = () => {
if (processed) return;
const clubId = location.pathname.match(/\/club\/(\d+)/)?.[1];
if (!clubId) return;
const waitForJQ = setInterval(() => {
if (typeof $ === 'undefined') return;
clearInterval(waitForJQ);
$.post('/ajax/players_get_select.ajax.php', { type: 'change', club_id: clubId })
.done(res => {
try {
const data = typeof res === 'string' ? JSON.parse(res) : res;
if (data && data.post && Object.keys(data.post).length) {
processData(data);
}
} catch (e) { /* ignore */ }
});
}, 200);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setTimeout(tryFetch, 800));
} else {
setTimeout(tryFetch, 800);
}
})();