// ==UserScript==
// @name MZ - Training History
// @namespace douglaskampl
// @version 3.9
// @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://u18mz.vercel.app/mz/userscript/other/trainingHistoryN.css
// @run-at document-idle
// @license MIT
// @downloadURL none
// ==/UserScript==
(function () {
'use strict';
GM_addStyle(GM_getResourceText('trainingHistoryStyles'));
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'];
let myTeamId = null;
function isClubMember() {
const headerUsernameStyleAttr = document.querySelector('#header-username')?.getAttribute('style');
return headerUsernameStyleAttr && headerUsernameStyleAttr.includes('background-image');
}
function 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);
const info = { currentDate, season, day };
return info;
}
function 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 - 1));
return d => {
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;
};
}
function getPlayerContainerNode(n) {
let c = n.closest('.playerContainer');
if (!c) c = document.querySelector('.playerContainer');
return c;
}
function parseSeriesData(txt) {
const m = txt.match(/var series = (\[.*?\]);/);
const parsed = m ? JSON.parse(m[1]) : null;
return parsed;
}
function extractChipsInfo(series) {
const 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.toDateString()
});
}
});
});
return chips;
}
function 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);
});
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.toDateString(), skillName: sk });
if (!skillTotals[sk]) skillTotals[sk] = 0;
skillTotals[sk]++;
total++;
if (sea < earliest) earliest = sea;
}
});
});
return { bySeason, skillTotals, total, earliestSeason: earliest, chips, chipsBySeason };
}
function createModal(content, spin) {
const ov = document.createElement('div');
ov.className = 'mz-training-overlay';
const mo = document.createElement('div');
mo.className = 'mz-training-modal';
const bd = document.createElement('div');
bd.className = 'mz-training-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 = 'mz-training-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 };
}
function 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;
}
function getTotalBallsFromSkillMap(map) {
const total = Object.values(map).reduce((a, b) => a + b, 0);
return total;
}
function fillSeasonGains(bySeason, earliestSeason, currentSeason, skillTotals) {
const out = {};
for (let s = earliestSeason; s <= currentSeason; s++) {
out[s] = {};
if (bySeason[s]) {
bySeason[s].forEach(ev => {
if (!skillTotals[ev.skillName]) return;
if (!out[s][ev.skillName]) out[s][ev.skillName] = 0;
out[s][ev.skillName]++;
});
}
}
return out;
}
function 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;
}
function calculateHistoricalAge(params) {
const { currentAge, currentSeason, targetSeason } = params;
if (!currentAge) {
return null;
}
const seasonDiff = currentSeason - targetSeason;
const historicalAge = currentAge - seasonDiff;
return historicalAge;
}
function buildSeasonCheckpointData(earliestSeason, currentSeason, finalMap, seasonGains, container) {
const out = [];
const baseMap = {};
const currentAge = parsePlayerAge(container);
ORDERED_SKILLS.forEach(sk => {
baseMap[sk] = finalMap[sk] || 0;
});
for (let s = currentSeason; s >= earliestSeason; s--) {
const age = calculateHistoricalAge({
currentAge,
currentSeason,
targetSeason: s
});
const label = age !== null ? `${s} (${age})` : s.toString();
const snapshot = {};
ORDERED_SKILLS.forEach(k => {
snapshot[k] = baseMap[k] || 0;
});
if (seasonGains[s]) {
Object.keys(seasonGains[s]).forEach(k => {
snapshot[k] -= seasonGains[s][k];
if (snapshot[k] < 0) snapshot[k] = 0;
});
}
out.unshift({ season: s, label, distribution: snapshot });
if (seasonGains[s]) {
Object.keys(seasonGains[s]).forEach(k => {
baseMap[k] -= seasonGains[s][k];
if (baseMap[k] < 0) baseMap[k] = 0;
});
}
}
out.push({
season: currentSeason,
label: 'Current',
distribution: finalMap
});
return out;
}
function makeSkillRows(map, prevMap) {
let out = '';
let totalIncrease = 0;
ORDERED_SKILLS.forEach(k => {
let v = map[k] || 0;
if (v < 0) v = 0;
if (v > 10) v = 10;
let changeHTML = '';
if (prevMap) {
const prevVal = prevMap[k] || 0;
const change = v - prevVal;
if (change > 0) {
changeHTML = `(+${change})`;
totalIncrease += change;
}
}
out += `
${k}

(${v})
${changeHTML}
`;
});
if (totalIncrease > 0 && prevMap) {
out += `
(+${totalIncrease} total)
`;
}
return { html: out, totalIncrease: totalIncrease };
}
function buildStatesLayout(bySeason, skillTotals, container, currentSeason, earliestSeason, chipsBySeason) {
const finalMap = gatherCurrentSkills(container);
const seasonGains = fillSeasonGains(bySeason, earliestSeason, currentSeason, skillTotals);
const arr = buildSeasonCheckpointData(earliestSeason, currentSeason, finalMap, seasonGains, container);
let paginatedHtml = '';
let allViewHtml = '
';
const currentAge = parsePlayerAge(container);
arr.forEach((o, index) => {
const sum = getTotalBallsFromSkillMap(o.distribution);
let headerText;
if (o.label === 'Current') {
headerText = `Current State - Season ${currentSeason} (Age: ${currentAge})`;
} else {
const [season, age] = o.label.split(' ');
if (index === 0) {
headerText = `Arrival at the Club - Season ${season} (Age: ${age.replace('(', '').replace(')', '')})`;
} else {
headerText = `Beginning of Season ${season} (Age: ${age.replace('(', '').replace(')', '')})`;
}
}
const prevDistribution = index > 0 ? arr[index - 1].distribution : null;
const skillRowsResult = makeSkillRows(o.distribution, prevDistribution);
let chipInfo = '';
const season = o.label === 'Current' ? currentSeason : parseInt(o.label.split(' ')[0], 10);
if (chipsBySeason[season] && chipsBySeason[season].length > 0) {
const chipNames = chipsBySeason[season].map(c => {
const simplifiedName = c.name.split('')[1]?.trim() || c.name;
return simplifiedName;
}).join(', ');
if (skillRowsResult.totalIncrease) {
chipInfo = `
${chipNames}`;
} else {
chipInfo = `
${chipNames}
`;
}
}
paginatedHtml += `
${headerText}
Total Skill Balls: ${sum}
${skillRowsResult.html}
${chipInfo}
`;
allViewHtml += `
${headerText}
Total Skill Balls: ${sum}
${skillRowsResult.html}
${chipInfo}
`;
});
paginatedHtml += '
';
allViewHtml += '
';
return paginatedHtml + allViewHtml;
}
function generateEvolHTML(bySeason, total, skillTotals, currentSeason, container, chips) {
let html = '';
const currentAge = parsePlayerAge(container);
const sorted = Object.keys(bySeason)
.map(x => parseInt(x, 10))
.sort((a, b) => a - b);
sorted.forEach(se => {
const items = bySeason[se];
const age = calculateHistoricalAge({
currentAge,
currentSeason,
targetSeason: se
});
const label = age !== null ? `Season ${se} (Age ${age})` : se.toString();
const seasonChips = chips.filter(chip => {
const chipDate = new Date(chip.date);
const chipSeason = getSeasonCalculator({
currentDate: new Date(),
season: currentSeason,
day: 1
})(chipDate);
return chipSeason === se;
});
let chipsHtml = '';
if (seasonChips.length > 0) {
chipsHtml = 'Chips: ';
chipsHtml += seasonChips.map(chip => {
const simplifiedName = chip.name.split('')[1]?.trim() || chip.name;
return `${simplifiedName} (${chip.dateString})`;
}).join(', ');
chipsHtml += '
';
}
html += `
${label} — ${items.length} Balls Earned
${chipsHtml}
`;
items.forEach(it => {
html += `- ${it.dateString} ${it.skillName}
`;
});
html += '
';
});
html += `
Total balls earned: ${total}
`;
const fs = Object.entries(skillTotals)
.filter(x => x[1] > 0)
.map(x => `${x[0]} (${x[1]})`)
.join(', ');
html += `${fs}
`;
if (chips.length > 0) {
html += `Applied chips
`;
chips.forEach(chip => {
const simplifiedName = chip.name.split('')[1]?.trim() || chip.name;
html += `- ${simplifiedName} (${chip.dateString})
`;
});
html += `
`;
}
return html;
}
function generateTabsHTML(name, evo, st) {
return `
${name}
`;
}
function attachTabEvents(modal) {
const tbs = modal.querySelectorAll('.mz-training-tab-btn');
const cs = modal.querySelectorAll('.mz-training-tab-content');
const paginationControls = modal.querySelector('.mz-pagination-controls');
if (modal.querySelector('.mz-training-tab-btn.active').getAttribute('data-tab') !== 'states') {
paginationControls.style.display = 'none';
}
tbs.forEach(btn => {
btn.addEventListener('click', () => {
tbs.forEach(x => x.classList.remove('active'));
btn.classList.add('active');
const t = btn.getAttribute('data-tab');
if (t === 'states') {
paginationControls.style.display = '';
} else {
paginationControls.style.display = 'none';
}
cs.forEach(cc => {
if (cc.getAttribute('data-content') === t) cc.classList.add('active');
else cc.classList.remove('active');
});
});
});
const prevBtn = modal.querySelector('.mz-prev-btn');
const nextBtn = modal.querySelector('.mz-next-btn');
const paginationIndicator = modal.querySelector('.mz-pagination-indicator');
const toggleBtn = modal.querySelector('.mz-pagination-toggle');
const paginatedView = modal.querySelector('.mz-paginated-view');
const allView = modal.querySelector('.mz-all-view');
const stateColumns = Array.from(modal.querySelectorAll('.mz-paginated-view .mz-state-col'));
if (!stateColumns.length) return;
let currentIndex = 0;
const totalPages = stateColumns.length;
paginationIndicator.textContent = `1 / ${totalPages}`;
prevBtn.disabled = true;
nextBtn.disabled = totalPages <= 1;
function updatePaginationUI() {
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 (currentIndex > 0) {
currentIndex--;
updatePaginationUI();
}
});
nextBtn.addEventListener('click', () => {
if (currentIndex < totalPages - 1) {
currentIndex++;
updatePaginationUI();
}
});
toggleBtn.addEventListener('click', () => {
const isPaginated = paginatedView.style.display !== 'none';
if (isPaginated) {
paginatedView.style.display = 'none';
allView.style.display = '';
toggleBtn.textContent = 'Show Paginated';
} else {
paginatedView.style.display = '';
allView.style.display = 'none';
toggleBtn.textContent = 'Show All';
updatePaginationUI();
}
});
}
function fetchTrainingData(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);
fetch(`https://www.managerzone.com/ajax.php?p=trainingGraph&sub=getJsonTrainingHistory&sport=soccer&player_id=${pid}`)
.then(r => r.text())
.then(t => {
if (spinnerInstance) spinnerInstance.stop();
spinnerEl.style.display = 'none';
const series = parseSeriesData(t);
if (!series) throw new Error('No series data found.');
return series;
})
.then(series => {
const data = processTrainingHistory(series, getSeasonFn);
const evoHTML = generateEvolHTML(data.bySeason, data.total, data.skillTotals, curSeason, cont, data.chips);
const stHTML = buildStatesLayout(data.bySeason, data.skillTotals, cont, curSeason, data.earliestSeason, data.chipsBySeason);
const finalHTML = generateTabsHTML(nm, evoHTML, stHTML);
modal.querySelector('.mz-training-modal-content').innerHTML = finalHTML;
setTimeout(() => {
attachTabEvents(modal);
initPaginationState(modal);
}, 50);
})
.catch(() => {
if (spinnerInstance) spinnerInstance.stop();
spinnerEl.style.display = 'none';
modal.querySelector('.mz-training-modal-content').innerText = 'Failed to process training data. (Are you a club member?)';
});
}
function initPaginationState(modal) {
const paginatedView = modal.querySelector('.mz-paginated-view');
const allView = modal.querySelector('.mz-all-view');
const paginationToggle = modal.querySelector('.mz-pagination-toggle');
const stateColumns = modal.querySelectorAll('.mz-paginated-view .mz-state-col');
if (!stateColumns.length) return;
if (paginatedView) paginatedView.style.display = '';
if (allView) allView.style.display = 'none';
if (paginationToggle) paginationToggle.textContent = 'Show All';
stateColumns.forEach((col, index) => {
col.style.display = index === 0 ? '' : 'none';
});
const paginationIndicator = modal.querySelector('.mz-pagination-indicator');
const prevBtn = modal.querySelector('.mz-prev-btn');
const nextBtn = modal.querySelector('.mz-next-btn');
if (paginationIndicator) {
paginationIndicator.textContent = `1 / ${stateColumns.length}`;
}
if (prevBtn) prevBtn.disabled = true;
if (nextBtn) nextBtn.disabled = stateColumns.length <= 1;
}
function hasVisibleSkills(container) {
return container.querySelector('table.player_skills') !== null;
}
function insertButtons(getSeasonFn, csi) {
const containers = document.querySelectorAll('.playerContainer');
containers.forEach(cc => {
if (!hasVisibleSkills(cc)) {
return;
}
const age = parsePlayerAge(cc);
if (!age || age > 28) {
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();
const existingBtn = ff.querySelector('.my-training-btn');
if (existingBtn) return;
const b = document.createElement('button');
b.className = 'my-training-btn button_blue';
b.innerHTML = '';
b.onclick = () => {
fetchTrainingData(pid, ff, getSeasonFn, csi);
};
ff.appendChild(b);
});
});
}
function 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(r => r.text())
.then(txt => {
const parser = new DOMParser();
const doc = parser.parseFromString(txt, '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(() => {});
}
function canRunUserscript() {
if (!isClubMember()) {
return false;
}
const url = new URL(window.location.href);
const p = url.searchParams.get('p');
if (p === 'players' && !url.searchParams.get('pid')) return true;
if (p === 'transfer') return true;
if (p === 'players' && url.searchParams.get('tid')) return true;
const tid = url.searchParams.get('tid');
if (p === 'players' && url.searchParams.get('pid')) {
return tid && tid === myTeamId;
}
return false;
}
function go() {
initTeamId();
if (!canRunUserscript()) return;
const csi = getCurrentSeasonInfo();
if (!csi) return;
const getSeasonFn = getSeasonCalculator(csi);
insertButtons(getSeasonFn, csi);
const obs = new MutationObserver(() => { insertButtons(getSeasonFn, csi); });
obs.observe(document.body, { childList: true, subtree: true });
}
go();
})();