<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Fantasy Team Ranker — Daily/Weekly Updates</title>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<style>
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial; margin: 18px; color:#111 }
header { display:flex; gap:12px; align-items:center; margin-bottom:12px }
h1 { font-size:20px; margin:0 }
input[type=file] { display:inline-block }
table { border-collapse:collapse; width:100%; margin-top:14px }
th, td { border:1px solid #ddd; padding:8px; text-align:left }
th { background:#f5f5f5 }
.controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap }
.small { font-size:13px; color:#555 }
.btn { padding:8px 10px; border-radius:6px; border:1px solid #bbb; background:white; cursor:pointer }
.btn.primary { background:#0b79ff; color:white; border-color:#076ce6 }
.team-row { cursor:pointer }
pre { background:#fafafa; padding:10px; border-radius:6px; overflow:auto; max-height:220px }
#playerBreakdown { margin-top:12px }
.note { font-size:13px; color:#444; margin-top:8px }
</style>
<!-- SheetJS -->
<script src="https://cdn.sheetjs.com/xlsx-latest/package/dist/xlsx.full.min.js"></script>
</head>
<body>
<header>
<div>
<h1>Fantasy Team Ranker — Daily & Weekly Updates</h1>
<div class="small">Upload your team workbook (Excel). The app will parse all sheets and compute rankings using your scoring rules.</div>
</div>
</header>
<div class="controls">
<label class="small">Upload Excel:</label>
<input id="fileInput" type="file" accept=".xlsx,.xls,.csv" />
<button id="processBtn" class="btn primary">Process Now</button>
<label class="small">Auto-refresh:</label>
<select id="autoFreq" class="btn">
<option value="0">Off</option>
<option value="86400000">Daily (24h)</option>
<option value="604800000">Weekly (7d)</option>
<option value="600000">10 minutes (dev)</option>
</select>
<button id="exportCsv" class="btn">Export Rankings CSV</button>
<div id="status" class="small" style="margin-left:8px"></div>
</div>
<section id="results">
<table id="rankTable" style="display:none">
<thead>
<tr>
<th>Rank</th><th>Team</th><th>Top 7 Total</th><th>Rest (50%)</th><th>Final Score</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div id="playerBreakdown"></div>
</section>
<div class="note">
Notes:
<ul>
<li>Weights: PTS (PPG) ×2, AST ×1, 3PT ×1, STL ×1, BLK ×1.</li>
<li>Top 7 players counted at full value; remaining roster players counted at 50%.</li>
<li>If the workbook has missing 3PT column, the app will estimate 3PT per game using the rules you specified (G: ppg*0.12 capped, F: ppg*0.10 capped, C: ppg*0.05 capped).</li>
<li>For true automatic daily updates from live stats you must supply an API endpoint/token and update the `fetchLiveStats()` stub below to return the same structured data.</li>
</ul>
</div>
<script>
/* -------------------------
Utility: normalize headers
------------------------- */
function normalizeKey(k) {
if (!k && k !== 0) return '';
return String(k)
.normalize('NFKD') // remove weird spacing characters
.replace(/\u00A0/g, ' ') // non-breaking space
.replace(/[^\x00-\x7F]/g, '') // drop non-ascii
.trim()
.toLowerCase()
.replace(/\s+/g, '_');
}
/* -------------------------
Estimation for 3PT
------------------------- */
function estimate3PT(pos, ppg){
// pos: normalized position string (like "g" or "f,g")
pos = (pos || '').toUpperCase();
ppg = Number(ppg) || 0;
if (pos.includes('G')) {
return Math.max(1.5, Math.min(3.5, ppg * 0.12));
} else if (pos.includes('F')) {
return Math.max(1.0, Math.min(2.5, ppg * 0.10));
} else if (pos.includes('C')) {
return Math.max(0.3, Math.min(1.0, ppg * 0.05));
} else {
return Math.max(0.5, Math.min(2.0, ppg * 0.08));
}
}
/* -------------------------
Compute fantasy points
------------------------- */
function playerFantasyPoints(player) {
// Expect fields: ppg, apg, spg, bpg, threept (if present), pos
const ppg = Number(player.ppg) || 0;
const apg = Number(player.apg) || 0;
const spg = Number(player.spg) || 0;
const bpg = Number(player.bpg) || 0;
const threept = (player.threept !== undefined) ? Number(player.threept) : estimate3PT(player.pos, ppg);
// weights: PTS x2, AST x1, 3PT x1, ST x1, BK x1
const pts = ppg * 2 + apg + threept + spg + bpg;
return { total: pts, breakdown: { ppg, apg, threept, spg, bpg } };
}
/* -------------------------
Parse workbook -> teams structure
------------------------- */
function parseWorkbookToTeams(workbook) {
const teams = [];
workbook.SheetNames.forEach(sheetName => {
const ws = workbook.Sheets[sheetName];
const raw = XLSX.utils.sheet_to_json(ws, { defval: '' });
if (raw.length === 0) return; // skip empty
// normalize columns, build player objects
const normalized = raw.map(row => {
const out = {};
for (const k in row) {
const nk = normalizeKey(k);
out[nk] = row[k];
}
// reasonable mapping guesses:
// players -> players, pos -> pos, ppg -> ppg, apg -> apg, spg -> spg, bpg -> bpg, tpg or tpm -> threept
return out;
});
// try to extract player rows with meaningful ppg or players column
const players = normalized.filter(r => {
const hasName = r.players && String(r.players).trim().length>0;
const hasPPG = r.ppg !== undefined && String(r.ppg).trim() !== '';
return hasName || hasPPG;
}).map(r => {
// cleanup: players might be "Name • TEAM" - strip team suffix
const rawName = r.players || r.player || r.name || '';
const name = String(rawName).split('•')[0].trim();
return {
name: name || 'Unknown',
pos: r.pos || r.position || '',
ppg: Number(r.ppg) || 0,
apg: Number(r.apg) || 0,
spg: Number(r.spg) || 0,
bpg: Number(r.bpg) || 0,
// some sheets might have a three pointer column named tpg/tpm/3pt etc
threept: r.tpg || r.tpm || r["3pt"] || r.threept || undefined
};
});
if (players.length) {
teams.push({ team: sheetName, players });
}
});
return teams;
}
/* -------------------------
Ranking logic
------------------------- */
function rankTeams(teams) {
const results = teams.map(team => {
// compute fantasy points for each player
const playersWithFP = team.players.map(p => {
const fp = playerFantasyPoints(p);
return Object.assign({}, p, { fantasy: fp.total, breakdown: fp.breakdown });
});
// sort desc by fantasy
playersWithFP.sort((a,b) => b.fantasy - a.fantasy);
// top 7 full value, rest 50%
const top7 = playersWithFP.slice(0,7).reduce((s,p)=>s + p.fantasy, 0);
const restHalf = playersWithFP.slice(7).reduce((s,p)=>s + p.fantasy * 0.5, 0);
const finalScore = top7 + restHalf;
return {
team: team.team,
players: playersWithFP,
top7_total: Number(top7.toFixed(6)),
rest_total_half: Number(restHalf.toFixed(6)),
final_score: Number(finalScore.toFixed(6))
};
});
results.sort((a,b) => b.final_score - a.final_score);
// add rank
return results.map((r,i) => Object.assign({ rank: i+1 }, r));
}
/* -------------------------
UI actions
------------------------- */
let lastTeams = null;
let autoIntervalId = null;
function showStatus(msg) { document.getElementById('status').textContent = msg; }
function renderRankTable(ranked) {
const table = document.getElementById('rankTable');
const tbody = table.querySelector('tbody');
tbody.innerHTML = '';
ranked.forEach(r => {
const tr = document.createElement('tr');
tr.className = 'team-row';
tr.dataset.team = r.team;
tr.innerHTML = `<td>${r.rank}</td>
<td>${r.team}</td>
<td>${r.top7_total.toFixed(2)}</td>
<td>${r.rest_total_half.toFixed(2)}</td>
<td><strong>${r.final_score.toFixed(2)}</strong></td>`;
// clicking row shows breakdown
tr.addEventListener('click', () => showTeamBreakdown(r));
tbody.appendChild(tr);
});
table.style.display = 'table';
}
function showTeamBreakdown(teamObj) {
const out = document.getElementById('playerBreakdown');
out.innerHTML = `<h3>Team: ${teamObj.team} — Rank ${teamObj.rank}</h3>`;
const rows = teamObj.players.map((p, idx) => {
const tri = `<tr>
<td>${idx+1}</td>
<td>${p.name}</td>
<td>${p.pos}</td>
<td>${(p.breakdown.ppg||0).toFixed(2)}</td>
<td>${(p.breakdown.apg||0).toFixed(2)}</td>
<td>${(p.breakdown.threept||0).toFixed(2)}</td>
<td>${(p.breakdown.spg||0).toFixed(2)}</td>
<td>${(p.breakdown.bpg||0).toFixed(2)}</td>
<td><strong>${p.fantasy.toFixed(2)}</strong></td>
</tr>`;
return tri;
}).join('');
out.innerHTML += `<table style="margin-top:8px">
<thead><tr><th>#</th><th>Name</th><th>Pos</th><th>PPG</th><th>APG</th><th>3PT (est)</th><th>STL</th><th>BLK</th><th>Fantasy</th></tr></thead>
<tbody>${rows}</tbody></table>
<div style="margin-top:8px">Top7 Total: <strong>${teamObj.top7_total.toFixed(2)}</strong> | Rest(50%): <strong>${teamObj.rest_total_half.toFixed(2)}</strong> | Final: <strong>${teamObj.final_score.toFixed(2)}</strong></div>`;
}
function onProcessWorkbook(workbook) {
const teams = parseWorkbookToTeams(workbook);
if (!teams.length) {
showStatus('No valid teams found in workbook.');
return;
}
lastTeams = teams;
const ranked = rankTeams(teams);
renderRankTable(ranked);
showStatus(`Processed ${ranked.length} teams. Click a team for player breakdown.`);
}
/* -------------------------
File input handling
------------------------- */
document.getElementById('processBtn').addEventListener('click', () => {
const f = document.getElementById('fileInput').files[0];
if (!f) { showStatus('Select an Excel file first.'); return; }
const reader = new FileReader();
reader.onload = (e) => {
const data = e.target.result;
let workbook;
try {
workbook = XLSX.read(data, { type: 'binary' });
} catch (err) {
showStatus('Error reading workbook: ' + err.message);
return;
}
onProcessWorkbook(workbook);
};
reader.readAsBinaryString(f);
});
/* -------------------------
Export CSV of rankings
------------------------- */
document.getElementById('exportCsv').addEventListener('click', () => {
if (!lastTeams) { alert('No processed data to export.'); return; }
const ranked = rankTeams(lastTeams);
// build CSV
let csv = 'Rank,Team,Top7,Rest_50pct,FinalScore\n';
ranked.forEach(r => csv += `${r.rank},"${r.team}",${r.top7_total},${r.rest_total_half},${r.final_score}\n`);
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'fantasy_rankings.csv';
a.click();
URL.revokeObjectURL(url);
});
/* -------------------------
Auto-refresh selector
------------------------- */
document.getElementById('autoFreq').addEventListener('change', (ev) => {
const ms = Number(ev.target.value || 0);
if (autoIntervalId) clearInterval(autoIntervalId);
if (ms > 0) {
// NOTE: This will simply re-run ranking on the last uploaded workbook.
// For true daily live updates you'd plug in a server-side fetch of current stats.
autoIntervalId = setInterval(() => {
if (!lastTeams) { showStatus('No workbook uploaded yet for auto-refresh.'); return; }
const ranked = rankTeams(lastTeams);
renderRankTable(ranked);
showStatus(`Auto-refreshed at ${new Date().toLocaleString()}`);
}, ms);
showStatus('Auto-refresh enabled.');
} else {
showStatus('Auto-refresh disabled.');
}
});
/* -------------------------
Stub: fetch live stats (server) - to be wired if you have an API
------------------------- */
/*
async function fetchLiveStats() {
// Example (pseudo-code). Replace with your server endpoint that returns
// the same structure expected by parseWorkbookToTeams => teams array:
// [
// { team: "TeamName", players: [{ name, pos, ppg, apg, spg, bpg, threept }, ...] },
// ...
// ]
const resp = await fetch('https://your-server.example.com/fantasy_stats', { headers: { 'Authorization': 'Bearer ...' } });
const json = await resp.json();
// you would then call onProcessWorkbook or directly rankTeams(json)
}
*/
/* -------------------------
Helpful: allow drag & drop
------------------------- */
window.addEventListener('dragover', (e)=>{ e.preventDefault(); });
window.addEventListener('drop', (e) => {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (!f) return;
document.getElementById('fileInput').files = e.dataTransfer.files;
showStatus('File dropped. Click "Process Now".');
});
</script>
</body>
</html>