// ==UserScript==
// @name MZ - Training History
// @namespace douglaskampl
// @version 5.1
// @description Displays skill gains across MZ seasons
// @author Douglas
// @match https://www.managerzone.com/?p=players
// @match https://www.managerzone.com/?p=players&pid=*
// @match https://www.managerzone.com/?p=players&tid=*
// @match https://www.managerzone.com/?p=transfer*
// @exclude https://www.managerzone.com/?p=transfer_history*
// @icon https://www.google.com/s2/favicons?sz=64&domain=managerzone.com
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addStyle
// @grant GM_getResourceText
// @require https://cdnjs.cloudflare.com/ajax/libs/spin.js/2.3.2/spin.min.js
// @resource trainingHistoryStyles https://br18.org/mz/userscript/other/history.css
// @run-at document-idle
// @license MIT
// @downloadURL none
// ==/UserScript==
(() => {
'use strict';
GM_addStyle(GM_getResourceText('trainingHistoryStyles'));
const CURRENCIES = {
"R$": 2.62589, EUR: 9.1775, USD: 7.4234, "点": 1,
SEK: 1, NOK: 1.07245, DKK: 1.23522, GBP: 13.35247,
CHF: 5.86737, RUB: 0.26313, CAD: 5.70899, AUD: 5.66999,
MZ: 1, MM: 1, PLN: 1.95278, ILS: 1.6953,
INR: 0.17, THB: 0.17079, ZAR: 1.23733, SKK: 0.24946,
BGN: 4.70738, MXN: 0.68576, ARS: 2.64445, BOB: 0.939,
UYU: 0.256963, PYG: 0.001309, ISK: 0.10433, SIT: 0.03896,
JPY: 0.06,
};
const SKILL_MAP = {
'1': 'Speed', '2': 'Stamina', '3': 'Play Intelligence', '4': 'Passing', '5': 'Shooting',
'6': 'Heading', '7': 'Keeping', '8': 'Ball Control', '9': 'Tackling', '10': 'Aerial Passing',
'11': 'Set Plays'
};
const ORDERED_SKILLS = ['Speed', 'Stamina', 'Play Intelligence', 'Passing', 'Shooting', 'Heading', 'Keeping', 'Ball Control', 'Tackling', 'Aerial Passing', 'Set Plays'];
const SKILL_TRANSLATIONS = {
'Szybkość': 'Speed', 'Kondycja': 'Stamina', 'Przegląd gry': 'Play Intelligence', 'Podania dołem': 'Passing', 'Podania doÅem': 'Passing', 'Strzał': 'Shooting', 'Gra głową': 'Heading', 'Gra na bramce': 'Keeping', 'Panowanie': 'Ball Control', 'Odbiór piłki': 'Tackling', 'Podania górą': 'Aerial Passing', 'Stałe fragmenty': 'Set Plays',
'Velocidade': 'Speed', 'Resistência': 'Stamina', 'Inteligência': 'Play Intelligence', 'Inteligên. Jogo': 'Play Intelligence', 'Passe Curto': 'Passing', 'Passes Curtos': 'Passing', 'Chute': 'Shooting', 'Remate': 'Shooting', 'Cabeceio': 'Heading', 'Cabeceamento': 'Heading', 'Defesa a Gol': 'Keeping', 'Guarda-Redes': 'Keeping', 'Controle de Bola': 'Ball Control', 'Controlo Bola': 'Ball Control', 'Desarme': 'Tackling', 'Passe Longo': 'Aerial Passing', 'Passes Longos': 'Aerial Passing', 'Bola Parada': 'Set Plays', 'Bolas Paradas': 'Set Plays',
'Velocidad': 'Speed', 'Resistencia': 'Stamina', 'Inteligencia': 'Play Intelligence', 'Pases': 'Passing', 'Remates': 'Shooting', 'Cabezazos': 'Heading', 'Atajando': 'Keeping', 'Control de balón': 'Ball Control', 'Entradas': 'Tackling', 'Pases Largos': 'Aerial Passing', 'Balón Parado': 'Set Plays',
'速度': 'Speed', '耐力': 'Stamina', '意识': 'Play Intelligence', '传球': 'Passing', '射门': 'Shooting', '头球': 'Heading', '守门': 'Keeping', '控球': 'Ball Control', '抢断': 'Tackling', '传中': 'Aerial Passing', '定位': 'Set Plays',
'Gyorsaság': 'Speed', 'Állóképesség': 'Stamina', 'Játékint.': 'Play Intelligence', 'Átadás': 'Passing', 'Lövés': 'Shooting', 'Fejelés': 'Heading', 'Védés': 'Keeping', 'Labdakezelés': 'Ball Control', 'Szerelés': 'Tackling', 'Ívelés': 'Aerial Passing', 'Pontrúgások': 'Set Plays',
'Hız': 'Speed', 'Dayanıklılık': 'Stamina', 'Oyun zekası': 'Play Intelligence', 'Paslaşma': 'Passing', 'Şut çekme': 'Shooting', 'Kafa vuruşu': 'Heading', 'Kalecilik': 'Keeping', 'Top kontrolü': 'Ball Control', 'Top çalma': 'Tackling', 'Orta yapma': 'Aerial Passing', 'Duran top': 'Set Plays',
'Скорост': 'Speed', 'Издръжливост': 'Stamina', 'Разбиране на играта': 'Play Intelligence', 'Подаване': 'Passing', 'Стрелба': 'Shooting', 'Игра с глава': 'Heading', 'Пазене': 'Keeping', 'Техника': 'Ball Control', 'Отнемане': 'Tackling', 'Подаване по въздух': 'Aerial Passing', 'Статични положения': 'Set Plays',
'Snabbhet': 'Speed', 'Kondition': 'Stamina', 'Speluppf.': 'Play Intelligence', 'Passning': 'Passing', 'Skott': 'Shooting', 'Nick': 'Heading', 'Målvakt': 'Keeping', 'Teknik': 'Ball Control', 'Tackling': 'Tackling', 'Inlägg': 'Aerial Passing', 'Fasta Sit.': 'Set Plays',
'Hurtighed': 'Speed', 'Udholdenhed': 'Stamina', 'Spilforståelse': 'Play Intelligence', 'Afleveringer': 'Passing', 'Skud': 'Shooting', 'Hovedstød': 'Heading', 'Målmandsspil': 'Keeping', 'Teknik': 'Ball Control', 'Tackling': 'Tackling', 'Tværaflevering': 'Aerial Passing', 'Dødbolde': 'Set Plays',
'Hitrost': 'Speed', 'Kondicija': 'Stamina', 'Inteligenca': 'Play Intelligence', 'Podajanje': 'Passing', 'Streljanje': 'Shooting', 'Igra z glavo': 'Heading', 'Branjenje': 'Keeping', 'Kontrola žoge': 'Ball Control', 'Odvzem žoge': 'Tackling', 'Dolge podaje': 'Aerial Passing', 'Prosti streli': 'Set Plays',
'Kecepatan': 'Speed', 'Stamina': 'Stamina', 'Intelegensia Permainan': 'Play Intelligence', 'Operan': 'Passing', 'Tembakan': 'Shooting', 'Sundulan': 'Heading', 'Tangkapan': 'Keeping', 'Kontrol Bola': 'Ball Control', 'Tekel': 'Tackling', 'Umpan Silang/Daerah': 'Aerial Passing', 'Bola Mati': 'Set Plays',
'Скорость': 'Speed', 'Выносливость': 'Stamina', 'Игровой интеллект': 'Play Intelligence', 'Пас': 'Passing', 'Удар': 'Shooting', 'Игра головой': 'Heading', 'Вратарство': 'Keeping', 'Техника': 'Ball Control', 'Отбор': 'Tackling', 'Длинный пас': 'Aerial Passing', 'Стандарты': 'Set Plays',
'Snelheid': 'Speed', 'Uithouding': 'Stamina', 'Spelintelligentie': 'Play Intelligence', 'Passen': 'Passing', 'Schieten': 'Shooting', 'Koppen': 'Heading', 'Keepen': 'Keeping', 'Balcontrole': 'Ball Control', 'Tackelen': 'Tackling', 'Lange passen': 'Aerial Passing', 'Spelhervattingen': 'Set Plays',
'Velocità': 'Speed', 'Resistenza': 'Stamina', 'Intelligenza G.': 'Play Intelligence', 'Passaggio': 'Passing', 'Tiro': 'Shooting', 'Colpo di testa': 'Heading', 'Parare': 'Keeping', 'Controllo Palla': 'Ball Control', 'Contrasto': 'Tackling', 'Cross': 'Aerial Passing', 'Azioni da fermo': 'Set Plays',
'Experiencia': 'Experience', 'Estado físico': 'Form', 'Tapasztalat': 'Experience', 'Forma': 'Form', 'Tecrübe': 'Experience', 'Form': 'Form', 'Опит': 'Experience', 'Rutin': 'Experience', 'Erfaring': 'Experience', 'Izkušenost': 'Experience', 'Pengalaman': 'Experience', 'Опыт': 'Experience', 'Ervaring': 'Experience', 'Esperienza': 'Experience',
};
const insensitiveSkillTranslations = Object.fromEntries(
Object.entries(SKILL_TRANSLATIONS).map(([key, value]) => [key.toLowerCase(), value])
);
const getEnglishSkillName = (nativeName) => {
if (!nativeName) return '';
return insensitiveSkillTranslations[nativeName.toLowerCase()] || nativeName;
};
let myTeamId = null;
let preferredCurrency = GM_getValue('PREFERRED_CURRENCY', 'USD');
const isClubMember = () => {
const headerUsernameStyle = document.querySelector('#header-username')?.getAttribute('style');
return headerUsernameStyle && headerUsernameStyle.includes('background-image');
};
const canRunUserscript = () => isClubMember();
const getCurrentSeasonInfo = () => {
const w = document.querySelector('#header-stats-wrapper');
if (!w) return null;
const dn = w.querySelector('h5.flex-grow-1.textCenter:not(.linked)');
const ln = w.querySelector('h5.flex-grow-1.textCenter.linked');
if (!dn || !ln) return null;
const dm = dn.textContent.match(/(\d{1,2})[/-](\d{1,2})[/-](\d{4})/);
if (!dm) return null;
const d = dm[1];
const m = dm[2];
const y = dm[3];
const currentDate = new Date([m, d, y].join('/'));
const digits = ln.textContent.match(/\d+/g);
if (!digits || digits.length < 3) return null;
const season = parseInt(digits[0], 10);
const day = parseInt(digits[2], 10);
return { currentDate, season, day };
};
const getSeasonCalculator = (cs) => {
if (!cs) return () => 0;
const baseSeason = cs.season;
const baseDate = cs.currentDate;
const dayOffset = cs.day;
const seasonStart = new Date(baseDate);
seasonStart.setDate(seasonStart.getDate() - (dayOffset));
return d => {
if (!(d instanceof Date)) return 0;
let s = baseSeason;
let ref = seasonStart.getTime();
let diff = Math.floor((d.getTime() - ref) / 86400000);
while (diff < 0) {
s--;
diff += 91;
}
while (diff >= 91) {
s++;
diff -= 91;
}
return s;
};
};
const calculateHistoricalAge = ({ currentAge, currentSeason, targetSeason }) => {
if (!currentAge) return null;
const seasonDiff = currentSeason - targetSeason;
return currentAge - seasonDiff;
};
const getPlayerContainerNode = (n) => {
let c = n.closest('.playerContainer');
if (!c) c = document.querySelector('.playerContainer');
return c;
};
const hasVisibleSkills = (container) => container.querySelector('table.player_skills') !== null;
const parsePlayerAge = (container) => {
const strongEls = container.querySelectorAll('strong');
for (const el of strongEls) {
const numberMatch = el.textContent.trim().match(/^(\d{1,2})$/);
if (numberMatch) {
const age = parseInt(numberMatch[1], 10);
if (age >= 15 && age <= 45) return age;
}
}
const allNums = container.textContent.match(/\b(\d{1,2})\b/g);
if (allNums) {
for (const numString of allNums) {
const age = parseInt(numString, 10);
if (age >= 15 && age <= 45) return age;
}
}
return null;
};
const parsePriceString = (priceStr) => {
if (!priceStr || priceStr === 'N/A' || priceStr === '-') return { amount: 0, currency: '' };
const match = priceStr.match(/(\d[\d\s]+)(?:\s*)([A-Za-z$]+|点|MM|R\$)/);
if (!match) return { amount: 0, currency: '' };
const rawAmount = match[1].replace(/\s+/g, '');
const amount = parseFloat(rawAmount);
const currency = match[2];
return { amount, currency };
};
const convertPrice = (priceObj, targetCurrency) => {
if (!priceObj.amount || !priceObj.currency || !CURRENCIES[priceObj.currency]) {
return { amount: 0, currency: targetCurrency };
}
const sourceRate = CURRENCIES[priceObj.currency];
const targetRate = CURRENCIES[targetCurrency];
if (!sourceRate || !targetRate) {
return { amount: priceObj.amount, currency: priceObj.currency };
}
const inSEK = priceObj.amount * sourceRate;
const convertedAmount = inSEK / targetRate;
return {
amount: Math.round(convertedAmount),
currency: targetCurrency
};
};
const formatPrice = (priceObj) => {
if (!priceObj.amount) return 'N/A';
const formatted = new Intl.NumberFormat().format(priceObj.amount);
return `${formatted} ${priceObj.currency}`;
};
const parseSeriesData = (txt) => {
const m = txt.match(/var series = (\[.*?\]);/);
return m ? JSON.parse(m[1]) : null;
};
const extractChipsInfo = (series) => {
const chips = [];
if (!series) return chips;
series.forEach(s => {
s.data.forEach(pt => {
if (pt.marker?.symbol.includes('training_camp_chip.png') && pt.name) {
const chipName = pt.name.replace(/<\/b>\t\t\t\n/g, ' ').trim();
const date = new Date(pt.x);
chips.push({
name: chipName,
date: date,
dateString: date.toLocaleDateString()
});
}
});
});
return chips;
};
const gatherCurrentSkills = (container) => {
const rows = container.querySelectorAll('table.player_skills tr');
const out = {};
let i = 1;
rows.forEach(r => {
const valCell = r.querySelector('.skillval span');
if (!valCell) return;
const name = SKILL_MAP[i.toString()];
if (name) {
const v = parseInt(valCell.textContent.trim(), 10);
out[name] = isNaN(v) ? 0 : v;
}
i++;
});
return out;
};
const getTotalBallsFromSkillMap = (map) => Object.values(map).reduce((a, b) => a + b, 0);
const processTrainingHistory = (series, getSeasonFn) => {
const bySeason = {};
const skillTotals = {};
const chips = extractChipsInfo(series);
const chipsBySeason = {};
let total = 0;
let earliest = 9999;
chips.forEach(chip => {
const season = getSeasonFn(chip.date);
if (!chipsBySeason[season]) chipsBySeason[season] = [];
chipsBySeason[season].push(chip);
});
if (series) {
series.forEach(s => {
s.data.forEach((pt, i) => {
if (pt.marker?.symbol.includes('gained_skill.png') && s.data[i + 1]) {
const d = new Date(s.data[i + 1].x);
const sea = getSeasonFn(d);
if (!bySeason[sea]) bySeason[sea] = [];
const sid = s.data[i + 1].y.toString();
const sk = SKILL_MAP[sid] || 'Unknown';
bySeason[sea].push({ dateString: d.toLocaleDateString(), skillName: sk });
if (!skillTotals[sk]) skillTotals[sk] = 0;
skillTotals[sk]++;
total++;
if (sea < earliest) earliest = sea;
}
});
});
}
return { bySeason, skillTotals, total, earliestSeason: earliest, chips, chipsBySeason };
};
const fillSeasonGains = (bySeason, earliestSeason, currentSeason, skillTotals) => {
const out = {};
for (let s = earliestSeason; s <= currentSeason; s++) {
out[s] = {};
ORDERED_SKILLS.forEach(sk => { out[s][sk] = 0; });
if (bySeason[s]) {
bySeason[s].forEach(ev => {
if (skillTotals[ev.skillName]) {
if (!out[s][ev.skillName]) out[s][ev.skillName] = 0;
out[s][ev.skillName]++;
}
});
}
}
return out;
};
const buildSeasonCheckpointData = (earliestSeason, currentSeason, finalMap, seasonGains, container) => {
const out = [];
const currentMap = {};
const currentAge = parsePlayerAge(container);
ORDERED_SKILLS.forEach(sk => {
currentMap[sk] = finalMap[sk] || 0;
});
out.push({
season: currentSeason,
label: 'Current',
distribution: { ...finalMap }
});
for (let s = currentSeason; s >= earliestSeason; s--) {
if (seasonGains[s]) {
Object.keys(seasonGains[s]).forEach(k => {
if (currentMap.hasOwnProperty(k)) {
currentMap[k] -= seasonGains[s][k];
if (currentMap[k] < 0) currentMap[k] = 0;
}
});
}
const age = calculateHistoricalAge({ currentAge, currentSeason, targetSeason: s });
const label = age !== null ? `${s} (${age})` : s.toString();
const snapshot = { ...currentMap };
out.unshift({ season: s, label, distribution: snapshot });
}
return out;
};
const makeSkillRows = (params) => {
const { map, prevMap, arrivalMap, currentSeasonForState, isCurrentState, scoutData } = params;
let comparisonHtml = '';
let arrivalGainHtml = '';
let totalIncreaseFromPrev = 0;
let totalGainSinceArrival = 0;
ORDERED_SKILLS.forEach((k, idx) => {
let v = map[k] || 0;
if (v < 0) v = 0;
if (v > 10) v = 10;
let changeHTML = '';
let gainSinceArrivalTextHTML = '';
let initialBallsVizHTML = '';
let gainedBallsVizHTML = '';
let potentialClass = '';
let potentialIcon = '';
let skillNameSpecificClass = '';
if (scoutData) {
if (scoutData.hp > 0 && (k === scoutData.firstHpSkill || k === scoutData.secondHpSkill)) {
skillNameSpecificClass = ` th-skill-potential-hp${scoutData.hp}`;
} else if (scoutData.lp > 0 && (k === scoutData.firstLpSkill || k === scoutData.secondLpSkill)) {
skillNameSpecificClass = ` th-skill-potential-lp${scoutData.lp}`;
}
if (scoutData.hp > 0 && scoutData.hpPotentialIndices?.includes(idx)) {
potentialClass = ` th-skill-potential-hp${scoutData.hp}`;
potentialIcon = ``;
} else if (scoutData.lp > 0 && scoutData.lpPotentialIndices?.includes(idx)) {
potentialClass = ` th-skill-potential-lp${scoutData.lp}`;
potentialIcon = ``;
}
}
if (prevMap) {
const prevVal = prevMap[k] || 0;
const change = v - prevVal;
if (change > 0) {
changeHTML = `(+${change})`;
totalIncreaseFromPrev += change;
}
}
const baseSkillRowStartHtml = `
${potentialIcon}${k}
`;
comparisonHtml += `${baseSkillRowStartHtml}
(${v})
${changeHTML}
`;
if (arrivalMap && isCurrentState) {
const arrivalVal = arrivalMap[k] || 0;
const gainSinceArrival = v - arrivalVal;
initialBallsVizHTML = `${arrivalVal > 0 ? '●'.repeat(arrivalVal) : ''}`;
if (gainSinceArrival > 0) {
gainSinceArrivalTextHTML = `(+${gainSinceArrival})`;
totalGainSinceArrival += gainSinceArrival;
gainedBallsVizHTML = `${'●'.repeat(gainSinceArrival)}`;
} else {
gainSinceArrivalTextHTML = '';
gainedBallsVizHTML = '';
}
arrivalGainHtml += `${baseSkillRowStartHtml}
${initialBallsVizHTML}
${gainedBallsVizHTML}
${gainSinceArrivalTextHTML}
`;
}
});
return { comparisonHtml, arrivalGainHtml, totalIncrease: totalIncreaseFromPrev, totalGainSinceArrival };
};
const createModal = (content, spin) => {
const ov = document.createElement('div');
ov.className = 'th-overlay';
const mo = document.createElement('div');
mo.className = 'th-modal';
const bd = document.createElement('div');
bd.className = 'th-modal-content';
const sp = document.createElement('div');
sp.style.height = '60px';
sp.style.display = spin ? 'block' : 'none';
bd.appendChild(sp);
if (content) bd.innerHTML += content;
const cl = document.createElement('div');
cl.className = 'th-modal-close';
cl.innerHTML = '×';
cl.onclick = () => ov.remove();
mo.appendChild(cl);
mo.appendChild(bd);
ov.appendChild(mo);
document.body.appendChild(ov);
ov.addEventListener('click', e => {
if (e.target === ov) ov.remove();
});
requestAnimationFrame(() => {
ov.classList.add('show');
mo.classList.add('show');
});
let spinnerInstance = null;
if (spin) {
spinnerInstance = new Spinner({ color: '#5555aa', lines: 12 });
spinnerInstance.spin(sp);
}
return { modal: mo, spinnerEl: sp, spinnerInstance, overlay: ov };
};
const generateEvolHTML = (processedData, container, currentSeason) => {
const { bySeason, total, skillTotals, chips, chipsBySeason, earliestSeason } = processedData;
let html = '';
const currentAge = parsePlayerAge(container);
const getSeason = getSeasonCalculator({ currentDate: new Date(), season: currentSeason, day: 1 });
const sortedSeasons = Object.keys(bySeason)
.map(x => parseInt(x, 10))
.sort((a, b) => a - b);
sortedSeasons.forEach(se => {
const items = bySeason[se];
const age = calculateHistoricalAge({ currentAge, currentSeason, targetSeason: se });
const label = age !== null ? `Season ${se} (Age ${age})` : `Season ${se}`;
let seasonChipsHtml = '';
if (chipsBySeason[se] && chipsBySeason[se].length > 0) {
seasonChipsHtml = 'Chips: ';
seasonChipsHtml += chipsBySeason[se].map(chip => {
const simplifiedName = chip.name.split('')[1]?.trim() || chip.name;
return `${simplifiedName} (${chip.dateString})`;
}).join(', ');
seasonChipsHtml += '
';
}
html += `
${label} — ${items.length} Ball${items.length !== 1 ? 's' : ''} Earned
${seasonChipsHtml}
`;
items.forEach(it => {
html += `- ${it.dateString} ${it.skillName}
`;
});
html += '
';
});
html += `
Total balls earned: ${total}
`;
const fs = Object.entries(skillTotals)
.filter(([, count]) => count > 0)
.sort(([, countA], [, countB]) => countB - countA)
.map(([skill, count]) => `${skill} (${count})`)
.join(', ');
html += `${fs}
`;
const allChipsSorted = [...chips].sort((a, b) => a.date - b.date);
if (allChipsSorted.length > 0) {
html += `All Applied Chips
`;
allChipsSorted.forEach(chip => {
const simplifiedName = chip.name.split('')[1]?.trim() || chip.name;
const chipSeason = getSeason(chip.date);
html += `- S${chipSeason}: ${simplifiedName} (${chip.dateString})
`;
});
html += `
`;
}
return html;
};
const buildStatesLayout = (processedData, container, currentSeason, scoutData, transferData) => {
const { bySeason, skillTotals, earliestSeason, chipsBySeason } = processedData;
const finalMap = gatherCurrentSkills(container);
const seasonGains = fillSeasonGains(bySeason, earliestSeason, currentSeason, skillTotals);
const arr = buildSeasonCheckpointData(earliestSeason, currentSeason, finalMap, seasonGains, container);
const arrivalMap = arr.length > 0 ? arr[0].distribution : null;
let paginatedHtml = '';
let allViewHtml = '
';
const currentAge = parsePlayerAge(container);
arr.forEach((o, index) => {
const sum = getTotalBallsFromSkillMap(o.distribution);
let headerText;
const isCurrent = o.label === 'Current';
const seasonNumber = isCurrent ? currentSeason : parseInt(o.label.split(' ')[0], 10);
let stateSkillsHtml = '';
const prevDistribution = index > 0 ? arr[index - 1].distribution : null;
const useArrivalMapForComparison = isCurrent ? arrivalMap : null;
const skillRowsResult = makeSkillRows({
map: o.distribution,
prevMap: prevDistribution,
arrivalMap: useArrivalMapForComparison,
currentSeasonForState: seasonNumber,
isCurrentState: isCurrent,
scoutData: scoutData
});
if (isCurrent) {
headerText = `Current State - Season ${currentSeason}`;
if (currentAge !== null) headerText += ` (Age ${currentAge})`;
stateSkillsHtml = `
Changes vs Start of Season ${currentSeason}
${skillRowsResult.comparisonHtml}
${skillRowsResult.totalIncrease > 0 ? `
(+${skillRowsResult.totalIncrease} total this season)
` : ''}
Gains Since Arrival (Season ${arr[0]?.season || '?'})
${skillRowsResult.arrivalGainHtml}
${skillRowsResult.totalGainSinceArrival > 0 ? `
(+${skillRowsResult.totalGainSinceArrival} total since arrival)
` : ''}
`;
} else {
const [seasonStr, ageStr] = o.label.split(' ');
const agePart = ageStr ? ageStr.replace(/[()]/g, '') : '?';
if (index === 0) {
headerText = `Arrival at Club - Season ${seasonStr}`;
if (agePart !== '?') headerText += ` (Age ${agePart})`;
} else {
headerText = `Start of Season ${seasonStr}`;
if (agePart !== '?') headerText += ` (Age ${agePart})`;
}
stateSkillsHtml = `
${index > 0 ? `
Changes vs Start of Season ${arr[index - 1]?.season}
` : ''}
${skillRowsResult.comparisonHtml}
${skillRowsResult.totalIncrease > 0 ? `
(+${skillRowsResult.totalIncrease} total vs prev)
` : ''}
`;
}
let chipInfo = '';
if (chipsBySeason[seasonNumber] && chipsBySeason[seasonNumber].length > 0 && !isCurrent) {
const chipNames = chipsBySeason[seasonNumber].map(c => {
const simplifiedName = c.name.split('')[1]?.trim() || c.name;
return simplifiedName;
}).join(', ');
chipInfo = `
Chips used during S${seasonNumber}: ${chipNames}
`;
}
let transferInfo = '';
if (transferData && transferData[seasonNumber] && transferData[seasonNumber].length > 0 && !isCurrent) {
transferInfo = '
' +
'
';
transferData[seasonNumber].forEach(t => {
const priceObj = parsePriceString(t.price);
const convertedPrice = convertPrice(priceObj, preferredCurrency);
const displayPrice = formatPrice(convertedPrice);
transferInfo += `-
${t.dateString}: ${t.fromTeamName} ${t.toTeamName} (${displayPrice})
`;
});
transferInfo += '
';
}
const colHtml = `
${headerText}
Total Balls: ${sum}
${stateSkillsHtml}
${chipInfo}
${transferInfo}
`;
paginatedHtml += colHtml;
allViewHtml += colHtml;
});
paginatedHtml += '
';
allViewHtml += '
';
let scoutHtml = '';
if (scoutData) {
const { trainingSpeed, hp, lp, firstHpSkill, secondHpSkill, firstLpSkill, secondLpSkill } = scoutData;
const speedClass = `th-speed-s${trainingSpeed}`;
const hpClass = `th-hp-h${hp}`;
const lpClass = `th-lp-l${lp}`;
const speedText = trainingSpeed > 0 ? `S${trainingSpeed}` : 'N/A';
const hpText = hp > 0 ? `HP${hp}` : 'N/A';
const lpText = lp > 0 ? `LP${lp}` : 'N/A';
let hpSkillsText = '';
if (hp > 0 && firstHpSkill) {
hpSkillsText += `${firstHpSkill}`;
if (secondHpSkill) hpSkillsText += `/${secondHpSkill}`;
}
hpSkillsText = hpSkillsText ? ` ${hpSkillsText}` : '';
let lpSkillsText = '';
if (lp > 0 && firstLpSkill) {
lpSkillsText += `${firstLpSkill}`;
if (secondLpSkill) lpSkillsText += `/${secondLpSkill}`;
}
lpSkillsText = lpSkillsText ? ` ${lpSkillsText}` : '';
scoutHtml = `
TrainingSpeed ${speedText} |
${hpText}${hpSkillsText} |
${lpText}${lpSkillsText}
`;
}
return scoutHtml + paginatedHtml + allViewHtml;
};
const generateTabsHTML = (name, evo, st) => `
${name}
${evo}
${st}
`;
const detachPaginationEvents = (modal) => {
const prevBtn = modal.querySelector('.th-prev-btn');
const nextBtn = modal.querySelector('.th-next-btn');
const toggleBtn = modal.querySelector('.th-pagination-toggle');
if (prevBtn) {
const newPrevBtn = prevBtn.cloneNode(true);
prevBtn.parentNode.replaceChild(newPrevBtn, prevBtn);
}
if (nextBtn) {
const newNextBtn = nextBtn.cloneNode(true);
nextBtn.parentNode.replaceChild(newNextBtn, nextBtn);
}
if (toggleBtn) {
const newToggleBtn = toggleBtn.cloneNode(true);
toggleBtn.parentNode.replaceChild(newToggleBtn, toggleBtn);
}
};
const attachPaginationEvents = (modal, initialIndex) => {
const statesContent = modal.querySelector('.th-tab-content[data-content="states"]');
if (!statesContent) return;
const prevBtn = modal.querySelector('.th-prev-btn');
const nextBtn = modal.querySelector('.th-next-btn');
const paginationIndicator = modal.querySelector('.th-pagination-indicator');
const toggleBtn = modal.querySelector('.th-pagination-toggle');
const paginatedView = statesContent.querySelector('.th-paginated-view');
const allView = statesContent.querySelector('.th-all-view');
if (!paginatedView || !allView || !prevBtn || !nextBtn || !paginationIndicator || !toggleBtn) return;
const stateColumns = Array.from(paginatedView.querySelectorAll('.th-state-col'));
const totalPages = stateColumns.length;
let currentIndex = initialIndex;
const updatePaginationUI = () => {
if (totalPages === 0) {
prevBtn.disabled = true;
nextBtn.disabled = true;
paginationIndicator.textContent = '0 / 0';
toggleBtn.style.display = 'none';
return;
}
toggleBtn.style.display = '';
prevBtn.disabled = currentIndex === 0;
nextBtn.disabled = currentIndex === totalPages - 1;
paginationIndicator.textContent = `${currentIndex + 1} / ${totalPages}`;
stateColumns.forEach((col, index) => {
col.style.display = index === currentIndex ? '' : 'none';
});
};
prevBtn.addEventListener('click', () => {
if (paginatedView.style.display === 'none') return;
if (currentIndex > 0) {
currentIndex--;
updatePaginationUI();
}
});
nextBtn.addEventListener('click', () => {
if (paginatedView.style.display === 'none') return;
if (currentIndex < totalPages - 1) {
currentIndex++;
updatePaginationUI();
}
});
toggleBtn.addEventListener('click', () => {
const isPaginated = paginatedView.style.display !== 'none';
paginatedView.style.display = isPaginated ? 'none' : '';
allView.style.display = isPaginated ? '' : 'none';
toggleBtn.textContent = isPaginated ? 'Show Paginated' : 'Show All';
prevBtn.disabled = !isPaginated || currentIndex === 0;
nextBtn.disabled = !isPaginated || currentIndex === totalPages - 1;
if (!isPaginated) {
updatePaginationUI();
}
});
updatePaginationUI();
};
const initPaginationState = (modal) => {
const statesContent = modal.querySelector('.th-tab-content[data-content="states"]');
if (!statesContent) return;
const paginatedView = statesContent.querySelector('.th-paginated-view');
const allView = statesContent.querySelector('.th-all-view');
const paginationToggle = modal.querySelector('.th-pagination-toggle');
const paginationControls = modal.querySelector('.th-pagination-controls');
if (!paginatedView || !allView || !paginationToggle || !paginationControls) {
if (paginationControls) paginationControls.style.display = 'none';
return;
}
const stateColumns = paginatedView.querySelectorAll('.th-state-col');
if (!stateColumns.length) {
if (paginationControls) paginationControls.style.display = 'none';
return;
} else {
paginationControls.style.display = '';
}
paginatedView.style.display = '';
allView.style.display = 'none';
paginationToggle.textContent = 'Show All';
let initialIndex = 0;
stateColumns.forEach((col, index) => {
col.style.display = index === initialIndex ? '' : 'none';
});
const paginationIndicator = modal.querySelector('.th-pagination-indicator');
const prevBtn = modal.querySelector('.th-prev-btn');
const nextBtn = modal.querySelector('.th-next-btn');
if (paginationIndicator) {
paginationIndicator.textContent = `${initialIndex + 1} / ${stateColumns.length}`;
}
if (prevBtn) prevBtn.disabled = initialIndex === 0;
if (nextBtn) nextBtn.disabled = initialIndex >= stateColumns.length - 1;
detachPaginationEvents(modal);
attachPaginationEvents(modal, initialIndex);
};
const updateTransferPrices = (modal, newCurrency) => {
const transferInfoSections = modal.querySelectorAll('.th-transfer-info');
transferInfoSections.forEach(section => {
const items = section.querySelectorAll('li');
items.forEach(item => {
const originalAmount = parseFloat(item.getAttribute('data-price-amount'));
const originalCurrency = item.getAttribute('data-price-currency');
if (originalAmount && originalCurrency) {
const priceObj = { amount: originalAmount, currency: originalCurrency };
const convertedPrice = convertPrice(priceObj, newCurrency);
const displayPrice = formatPrice(convertedPrice);
const text = item.innerHTML;
const pricePattern = /\([^)]*\)(?=[^(]*$)/;
item.innerHTML = text.replace(pricePattern, `(${displayPrice})`);
}
});
});
};
const setUpCurrencyDropdowns = (modal) => {
const icons = modal.querySelectorAll('.th-transfer-currency-icon');
icons.forEach(icon => {
const newIcon = icon.cloneNode(true);
icon.parentNode.replaceChild(newIcon, icon);
});
modal.querySelectorAll('.th-transfer-currency-icon').forEach(icon => {
icon.addEventListener('click', function(e) {
e.stopPropagation();
e.preventDefault();
const dropdown = this.nextElementSibling;
dropdown.classList.toggle('show');
if (dropdown.classList.contains('show')) {
const rect = this.getBoundingClientRect();
dropdown.style.top = (rect.bottom - rect.top) + 5 + 'px';
dropdown.style.left = '0px';
}
});
});
modal.querySelectorAll('.th-transfer-currency-dropdown li').forEach(item => {
const newItem = item.cloneNode(true);
item.parentNode.replaceChild(newItem, item);
newItem.addEventListener('click', function(e) {
e.stopPropagation();
const newCurrency = this.getAttribute('data-currency');
preferredCurrency = newCurrency;
GM_setValue('PREFERRED_CURRENCY', newCurrency);
modal.querySelectorAll('.th-transfer-currency-dropdown li').forEach(li => {
li.classList.remove('selected');
});
modal.querySelectorAll(`.th-transfer-currency-dropdown li[data-currency="${newCurrency}"]`).forEach(li => {
li.classList.add('selected');
});
updateTransferPrices(modal, newCurrency);
this.closest('.th-transfer-currency-dropdown').classList.remove('show');
});
});
};
const attachTabEvents = (modal) => {
const tbs = modal.querySelectorAll('.th-tab-btn');
const cs = modal.querySelectorAll('.th-tab-content');
const paginationControls = modal.querySelector('.th-pagination-controls');
const statesContent = modal.querySelector('.th-tab-content[data-content="states"]');
if (!statesContent || !paginationControls) return;
const updatePaginationVisibility = () => {
const activeTab = modal.querySelector('.th-tab-btn.active')?.getAttribute('data-tab');
paginationControls.style.display = (activeTab === 'states') ? '' : 'none';
};
setUpCurrencyDropdowns(modal);
document.addEventListener('click', function(e) {
if (!e.target.closest('.th-transfer-currency-dropdown') && !e.target.classList.contains('th-transfer-currency-icon')) {
document.querySelectorAll('.th-transfer-currency-dropdown.show').forEach(dropdown => {
dropdown.classList.remove('show');
});
}
});
updatePaginationVisibility();
tbs.forEach(btn => {
btn.addEventListener('click', () => {
tbs.forEach(x => x.classList.remove('active'));
btn.classList.add('active');
const t = btn.getAttribute('data-tab');
cs.forEach(cc => {
cc.classList.toggle('active', cc.getAttribute('data-content') === t);
});
updatePaginationVisibility();
if (t === 'states') {
initPaginationState(modal);
}
});
});
};
const fetchScoutData = async (pid) => {
try {
const response = await fetch(`https://www.managerzone.com/ajax.php?p=players&sub=scout_report&pid=${pid}&sport=soccer`);
if (!response.ok) throw new Error(`HTTP error ${response.status}`);
const text = await response.text();
const doc = new DOMParser().parseFromString(text, 'text/html');
const defaultResult = { trainingSpeed: 0, hp: 0, lp: 0, firstHpSkill: '', secondHpSkill: '', firstLpSkill: '', secondLpSkill: '', hpPotentialIndices: [], lpPotentialIndices: [] };
const paperContent = doc.querySelector('.paper-content');
if (!paperContent) return defaultResult;
const dlElement = paperContent.querySelector('dl');
if (!dlElement) return defaultResult;
const hpDd = dlElement.querySelector('dd i.fa-line-chart')?.closest('dd');
const lpDd = dlElement.querySelector('dd i.fa-exclamation-triangle')?.closest('dd');
const speedDd = dlElement.querySelector('dd i.fa-heartbeat')?.closest('dd');
const getSkillsAndIndices = (dd) => {
const skills = [];
const indices = [];
if (!dd) return { skills, indices };
const listItems = dd.querySelectorAll('ul li');
listItems.forEach((li, index) => {
const span = li.querySelector('.blurred span:last-child');
if (span) {
const skillName = span.textContent.trim();
skills.push(skillName);
if (li.querySelector('.stars i.fa-star.lit')) {
indices.push(index);
}
}
});
return { skills, indices };
};
const getStars = (dd) =>
dd ? dd.querySelectorAll('.stars i.fa-star.lit').length : 0;
const { skills: hpSkillsNative, indices: hpIndices } = getSkillsAndIndices(hpDd);
const { skills: lpSkillsNative, indices: lpIndices } = getSkillsAndIndices(lpDd);
const hpStars = getStars(hpDd);
const lpStars = getStars(lpDd);
const speedStars = getStars(speedDd);
const firstHpNative = hpSkillsNative[0] || '';
const secondHpNative = hpSkillsNative[1] || '';
const firstLpNative = lpSkillsNative[0] || '';
const secondLpNative = lpSkillsNative[1] || '';
return {
hp: hpStars,
lp: lpStars,
trainingSpeed: speedStars,
firstHpSkill: getEnglishSkillName(firstHpNative),
secondHpSkill: getEnglishSkillName(secondHpNative),
firstLpSkill: getEnglishSkillName(firstLpNative),
secondLpSkill: getEnglishSkillName(secondLpNative),
hpPotentialIndices: hpIndices,
lpPotentialIndices: lpIndices
};
} catch (error) {
console.error("Failed to fetch or parse scout data:", error);
return null;
}
};
const fetchTransferData = async (pid, getSeasonFn) => {
try {
const response = await fetch(`https://www.managerzone.com/?p=players&pid=${pid}`);
if (!response.ok) throw new Error(`HTTP error ${response.status}`);
const text = await response.text();
const doc = new DOMParser().parseFromString(text, 'text/html');
const transferTable = doc.querySelector('table.hitlist');
const transferDataHeader = transferTable?.querySelector('thead td strong');
if (!transferTable || !transferDataHeader || !transferDataHeader.textContent.match(/Data|Date/i)) {
return {};
}
const transfersBySeason = {};
const rows = transferTable.querySelectorAll('tbody tr');
rows.forEach(row => {
const cells = row.querySelectorAll('td');
if (cells.length < 5) return;
const dateStrRaw = cells[0]?.textContent.trim();
const actionOrFromTeamCell = cells[1];
const toTeamCell = cells[3];
const priceCell = cells[4];
const dateMatch = dateStrRaw.match(/(\d{2})-(\d{2})-(\d{4})/);
if (!dateMatch) return;
const day = dateMatch[1];
const month = dateMatch[2];
const year = dateMatch[3];
const transferDate = new Date(`${year}-${month}-${day}`);
if (isNaN(transferDate)) return;
const season = getSeasonFn(transferDate);
const fromTeamLink = actionOrFromTeamCell.querySelector('a[href*="tid="]');
const toTeamLink = toTeamCell.querySelector('a[href*="tid="]');
let fromTeamName = 'Youth Academy';
if (fromTeamLink) {
fromTeamName = fromTeamLink.textContent.trim();
} else if (actionOrFromTeamCell.textContent.trim() !== '-') {
fromTeamName = actionOrFromTeamCell.textContent.trim();
}
const toTeamName = toTeamLink ? toTeamLink.textContent.trim() : toTeamCell.textContent.trim();
const priceDiv = priceCell?.querySelector('div[title]');
let price = priceDiv?.title || priceCell?.textContent.trim() || 'N/A';
if (price === '-') price = 'N/A';
if (!transfersBySeason[season]) {
transfersBySeason[season] = [];
}
transfersBySeason[season].push({
date: transferDate,
dateString: transferDate.toLocaleDateString(),
fromTeamName: fromTeamName,
toTeamName: toTeamName,
price: price
});
});
Object.values(transfersBySeason).forEach(seasonTransfers => {
seasonTransfers.sort((a, b) => a.date - b.date);
});
return transfersBySeason;
} catch (error) {
console.error("Failed to fetch or parse transfer data:", error);
return {};
}
};
const fetchCombinedPlayerData = async (pid, node, getSeasonFn, csi) => {
const cont = getPlayerContainerNode(node);
if (!cont) return;
const curSeason = csi.season;
const nmEl = cont.querySelector('.player_name');
const nm = nmEl ? nmEl.textContent.trim() : 'Unknown Player';
const { modal, spinnerEl, spinnerInstance } = createModal('', true);
try {
const [trainingResponse, scoutData, transferData] = await Promise.all([
fetch(`https://www.managerzone.com/ajax.php?p=trainingGraph&sub=getJsonTrainingHistory&sport=soccer&player_id=${pid}`).then(res => res.ok ? res.text() : Promise.reject(`HTTP error ${res.status} for training data`)),
fetchScoutData(pid),
fetchTransferData(pid, getSeasonFn)
]);
if (spinnerInstance) spinnerInstance.stop();
spinnerEl.style.display = 'none';
const series = parseSeriesData(trainingResponse);
const processedTrainingData = processTrainingHistory(series, getSeasonFn);
const transferSeasons = Object.keys(transferData).map(s => parseInt(s)).filter(s => !isNaN(s));
let effectiveEarliestSeason = processedTrainingData.earliestSeason;
if (transferSeasons.length > 0) {
const earliestTransferSeason = Math.min(...transferSeasons);
effectiveEarliestSeason = Math.max(1, Math.min(effectiveEarliestSeason, earliestTransferSeason));
}
if (effectiveEarliestSeason === 9999) {
if (transferSeasons.length > 0) {
effectiveEarliestSeason = Math.max(1, Math.min(...transferSeasons));
} else {
effectiveEarliestSeason = curSeason;
}
}
processedTrainingData.earliestSeason = effectiveEarliestSeason;
const evoHTML = generateEvolHTML(processedTrainingData, cont, curSeason);
const stHTML = buildStatesLayout(processedTrainingData, cont, curSeason, scoutData, transferData);
const finalHTML = generateTabsHTML(nm, evoHTML, stHTML);
modal.querySelector('.th-modal-content').innerHTML = finalHTML;
setTimeout(() => {
attachTabEvents(modal);
initPaginationState(modal);
}, 50);
} catch (error) {
console.error("Error fetching combined player data:", error);
if (spinnerInstance) spinnerInstance.stop();
if(spinnerEl) spinnerEl.style.display = 'none';
const contentDiv = modal.querySelector('.th-modal-content');
if (contentDiv) {
contentDiv.innerHTML = `Failed to process player data. (${error.message || 'Unknown error'})
(Are you a club member? Is the player still active? Check console for details.)
`;
}
}
};
const insertButtons = (getSeasonFn, csi) => {
const containers = document.querySelectorAll('.playerContainer');
containers.forEach(cc => {
if (!hasVisibleSkills(cc)) return;
const f = cc.querySelectorAll('.floatRight[id^="player_id_"]');
f.forEach(ff => {
const pidSpan = ff.querySelector('.player_id_span');
if (!pidSpan) return;
const pid = pidSpan.textContent.trim();
if (!pid) return;
const existingBtn = ff.querySelector('.th-btn');
if (existingBtn) return;
const b = document.createElement('button');
b.className = 'th-btn';
b.innerHTML = '';
b.title = 'View Training History';
b.onclick = (e) => {
e.preventDefault();
fetchCombinedPlayerData(pid, ff, getSeasonFn, csi);
};
ff.appendChild(b);
});
});
};
const initTeamId = () => {
const stored = GM_getValue('TEAM_ID');
if (stored) {
myTeamId = stored;
return;
}
const usernameEl = document.querySelector('#header-username');
if (!usernameEl) return;
const username = usernameEl.textContent.trim();
if (!username) return;
fetch(`https://www.managerzone.com/xml/manager_data.php?sport_id=1&username=${encodeURIComponent(username)}`)
.then(response => {
if (!response.ok) throw new Error(`HTTP error ${response.status}`);
return response.text();
})
.then(text => {
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/xml');
const teamNodes = doc.querySelectorAll('Team[sport="soccer"]');
if (!teamNodes || !teamNodes.length) return;
const tid = teamNodes[0].getAttribute('teamId');
if (tid) {
GM_setValue('TEAM_ID', tid);
myTeamId = tid;
}
})
.catch((error) => {console.error("Failed to fetch team ID:", error)});
};
const run = () => {
initTeamId();
if (!canRunUserscript()) {
console.log("MZ Training History: Not a Club Member");
return;
}
const csi = getCurrentSeasonInfo();
if (!csi) {
console.error("MZ Training History: Could not determine current season info.");
return;
}
const getSeasonFn = getSeasonCalculator(csi);
insertButtons(getSeasonFn, csi);
const obs = new MutationObserver((mutations) => {
let playerContainerChanged = false;
for (const mutation of mutations) {
if (mutation.type === 'childList') {
if (mutation.target.matches && (mutation.target.matches('.playerContainer') || mutation.target.querySelector('.playerContainer'))) {
playerContainerChanged = true;
break;
}
for (const node of mutation.addedNodes) {
if (node.nodeType === 1 && (node.matches('.playerContainer') || node.querySelector('.playerContainer'))) {
playerContainerChanged = true;
break;
}
}
if (playerContainerChanged) break;
}
}
if (playerContainerChanged) {
insertButtons(getSeasonFn, csi);
}
});
obs.observe(document.body, { childList: true, subtree: true });
};
run();
})();