// ==UserScript==
// @name MZ - Training History
// @namespace douglaskampl
// @version 3.3
// @description Displays skill gains across previous seasons
// @author Douglas
// @match https://www.managerzone.com/?p=players
// @match https://www.managerzone.com/?p=players&pid*
// @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/vTrainingHistory.css
// @run-at document-idle
// @license MIT
// ==/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 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], m = dm[2], 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 processTrainingHistory(series, getSeasonFn) {
const bySeason = {};
const skillTotals = {};
let total = 0;
let earliest = 9999;
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;
}
});
});
const result = { bySeason, skillTotals, total, earliestSeason: earliest };
return result;
}
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: '#ffa500', 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 buildStatesLayout(bySeason, skillTotals, container, currentSeason, earliestSeason) {
const finalMap = gatherCurrentSkills(container);
const seasonGains = fillSeasonGains(bySeason, earliestSeason, currentSeason, skillTotals);
const arr = buildSeasonCheckpointData(earliestSeason, currentSeason, finalMap, seasonGains, container);
let html = '<div class="mz-state-wrapper">';
arr.forEach(o => {
const sum = getTotalBallsFromSkillMap(o.distribution);
let headerText;
if (o.label === 'Current') {
headerText = o.label;
} else {
const [season, age] = o.label.split(' ');
headerText = `Beginning of Season ${season} (Age ${age.replace('(', '').replace(')', '')})`;
}
html += `<div class="mz-state-col">
<h4>${headerText}</h4>
<div class="mz-state-info">Total Skill Balls: <strong>${sum}</strong></div>
<div class="mz-state-skills">${makeSkillRows(o.distribution)}</div>
</div>`;
});
html += '</div>';
return html;
}
function makeSkillRows(map) {
let out = '';
ORDERED_SKILLS.forEach(k => {
let v = map[k] || 0;
if (v < 0) v = 0;
if (v > 10) v = 10;
out += `<div class="mz-state-skill">
<div class="mz-skill-name"><strong>${k}</strong></div>
<div class="mz-skill-val">
<img src="nocache-922/img/soccer/wlevel_${v}.gif" alt="">
(${v})
</div>
</div>`;
});
return out;
}
function generateEvolHTML(bySeason, total, skillTotals, currentSeason, container) {
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();
html += `<div class="mz-training-season">
<h3>${label} — ${items.length} Balls Earned</h3>
<ul>`;
items.forEach(it => {
html += `<li><strong>${it.dateString}</strong> ${it.skillName}</li>`;
});
html += '</ul></div>';
});
html += `<hr><h3 class="mz-training-final-summary">Total balls earned: ${total}</h3>`;
const fs = Object.entries(skillTotals)
.filter(x => x[1] > 0)
.map(x => `${x[0]} (${x[1]})`)
.join(', ');
html += `<h3 class="mz-training-skilltotals">${fs}</h3>`;
return html;
}
function generateTabsHTML(name, evo, st) {
return `
<h2 class="mz-training-title">Gains for ${name}</h2>
<div class="mz-training-tabs">
<div class="mz-training-tab-buttons">
<button class="mz-training-tab-btn" data-tab="evolution">Gains</button>
<button class="mz-training-tab-btn active" data-tab="states">Player Development</button>
</div>
<div class="mz-training-tab-content" data-content="evolution">${evo}</div>
<div class="mz-training-tab-content active" data-content="states">${st}</div>
</div>`;
}
function attachTabEvents(modal) {
const tbs = modal.querySelectorAll('.mz-training-tab-btn');
const cs = modal.querySelectorAll('.mz-training-tab-content');
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 => {
if (cc.getAttribute('data-content') === t) cc.classList.add('active');
else cc.classList.remove('active');
});
});
});
}
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);
const stHTML = buildStatesLayout(data.bySeason, data.skillTotals, cont, curSeason, data.earliestSeason);
const finalHTML = generateTabsHTML(nm, evoHTML, stHTML);
modal.querySelector('.mz-training-modal-content').innerHTML = finalHTML;
attachTabEvents(modal);
})
.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 insertButtons(getSeasonFn, csi) {
const containers = document.querySelectorAll('.playerContainer');
containers.forEach(cc => {
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 = '<i class="fa-solid fa-chart-pyramid"></i>';
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() {
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;
const tid = url.searchParams.get('tid');
if (p === 'players' && url.searchParams.get('pid')) {
return tid && tid === myTeamId;
}
return false;
}
function init() {
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 });
}
init();
})();