// ==UserScript== // @name MZ - Training History // @namespace douglaskampl // @version 3.6 // @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/trainingHistoryNew.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 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: '#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) { const finalMap = gatherCurrentSkills(container); const seasonGains = fillSeasonGains(bySeason, earliestSeason, currentSeason, skillTotals); const arr = buildSeasonCheckpointData(earliestSeason, currentSeason, finalMap, seasonGains, container); let paginatedHtml = '
'; let allViewHtml = ''; allViewHtml += '
'; return paginatedHtml + allViewHtml; } 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 += `

${label} — ${items.length} Balls Earned

'; }); html += `

Total balls earned: ${total}

`; const fs = Object.entries(skillTotals) .filter(x => x[1] > 0) .map(x => `${x[0]} (${x[1]})`) .join(', '); html += `

${fs}

`; return html; } function generateTabsHTML(name, evo, st) { return `

${name}

1 / 1
${evo}
${st}
`; } 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); 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; 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(); })();