import {CATEGORIES, SCORING, eligiblePositions, periodRows, boardRows, buildPlayerFacts, factsText} from './content-facts.mjs'; const $=selector=>document.querySelector(selector); const state={data:null,period:null,scoring:'ppr',platform:'sleeper',category:'stats',search:'',team:'ALL',id:null,show:false,limit:60,rows:[],facts:new Map(),sort:'points',direction:-1}; const finite=value=>typeof value==='number'&&Number.isFinite(value); const number=(value,digits=0)=>finite(value)?value.toLocaleString('en-US',{minimumFractionDigits:digits,maximumFractionDigits:digits}):'—'; const pct=(value,fraction=true)=>finite(value)?`${number(value*(fraction?100:1),1)}%`:'—'; const text=(tag,value,className)=>{const node=document.createElement(tag);node.textContent=String(value??'');if(className)node.className=className;return node;}; const date=value=>{const parsed=new Date(value);return value&&Number.isFinite(parsed.valueOf())?new Intl.DateTimeFormat('en-US',{month:'short',day:'numeric',year:'numeric',hour:'numeric',minute:'2-digit',timeZone:'America/New_York',timeZoneName:'short'}).format(parsed):'Unavailable';}; function safeUrl(value){if(typeof value!=='string'||!value.trim())return null;try{const url=new URL(value,location.href);return !url.username&&!url.password&&(url.protocol==='https:'||(url.protocol==='http:'&&url.origin===location.origin))?url.href:null;}catch{return null;}} function link(label,url,className){const a=text('a',label,className);const safe=safeUrl(url);if(safe){a.href=safe;a.target='_blank';a.rel='noopener noreferrer';}else return text('span',label,className);return a;} function button(label,action,className='button outline'){const node=text('button',label,className);node.type='button';node.addEventListener('click',action);return node;} function periodLabel(){return state.period==='season'?'Season totals':`Week ${state.period}`;} function coverage(){return state.period==='season'?state.data.seasonCoverage:state.data.coverage.find(item=>item.week===state.period);} function coverageLabel(){const c=coverage();if(!c)return 'Coverage unavailable';const counts=Number.isInteger(c.loadedGames)&&Number.isInteger(c.scheduledGames)?`${c.loadedGames}/${c.scheduledGames} games represented`:'Game coverage unavailable';return `${counts}${c.complete?' · Complete core game coverage':' · Provisional coverage'}`;} function initials(name){return String(name||'?').split(/\s+/).filter(Boolean).slice(0,2).map(part=>part[0]).join('').toUpperCase();} function photo(name,url,className='player-photo'){const wrap=document.createElement('span');wrap.className=className;const fallback=text('span',initials(name),'photo-initials');wrap.append(fallback);const safe=safeUrl(url);if(safe){const img=document.createElement('img');img.alt='';img.loading=className==='table-photo'?'lazy':'eager';img.decoding='async';img.hidden=true;const loaded=()=>{fallback.hidden=true;img.hidden=false;};img.addEventListener('load',loaded,{once:true});img.addEventListener('error',()=>{fallback.hidden=false;img.remove();},{once:true});wrap.append(img);img.src=safe;if(img.complete&&img.naturalWidth>0)loaded();}return wrap;} function toast(message){const el=$('#toast');el.textContent=message;el.hidden=false;clearTimeout(toast.timer);toast.timer=setTimeout(()=>el.hidden=true,4200);} async function copy(value,success){try{if(navigator.clipboard?.writeText){await navigator.clipboard.writeText(value);}else{const area=document.createElement('textarea');area.value=value;area.className='copy-fallback';document.body.append(area);area.select();const ok=document.execCommand('copy');area.remove();if(!ok)throw new Error('Clipboard unavailable');}toast(success);}catch{toast('Copy is unavailable in this browser. Try the browser’s clipboard permission or another browser.');}} function currentFact(id=state.id){if(!id)return null;const key=`${state.period}:${state.scoring}:${state.platform}:${id}`;if(!state.facts.has(key)){try{state.facts.set(key,buildPlayerFacts(state.data,id,{period:state.period,scoring:state.scoring,platform:state.platform}));}catch{state.facts.set(key,null);}}return state.facts.get(key);} function syncUrl(){const url=new URL(location.href);for(const key of ['week','player','period','scoring','platform','id','board','q','team','view'])url.searchParams.delete(key);url.searchParams.set('period',String(state.period));url.searchParams.set('scoring',state.scoring);url.searchParams.set('platform',state.platform);url.searchParams.set('board',state.category);if(state.id)url.searchParams.set('id',state.id);if(state.search)url.searchParams.set('q',state.search);if(state.team!=='ALL')url.searchParams.set('team',state.team);if(state.show)url.searchParams.set('view','show');history.replaceState(null,'',url);} function readUrl(){const params=new URL(location.href).searchParams;const requested=params.get('period')||params.get('week');state.period=requested==='season'?'season':state.data.availableWeeks.includes(Number(requested))?Number(requested):state.data.defaultWeek;state.scoring=Object.hasOwn(SCORING,params.get('scoring'))?params.get('scoring'):'ppr';state.platform=params.get('platform')==='yahoo'?'yahoo':'sleeper';state.category=Object.hasOwn(CATEGORIES,params.get('board'))?params.get('board'):'stats';state.id=params.get('id')||params.get('player');state.search=(params.get('q')||'').slice(0,100);state.team=(params.get('team')||'ALL').slice(0,12);state.show=params.get('view')==='show';} const fields={ passingYards:['Pass yd',0,'Passing yards'],passingTds:['Pass TD',0,'Passing touchdowns'],interceptions:['INT',0,'Interceptions thrown'],completions:['CMP',0,'Completed passes'],passAttempts:['ATT',0,'Pass attempts'],completionRate:['CMP %','percent','Completion percentage'],yardsPerPassAttempt:['Y/A',2,'Passing yards per attempt'], carries:['Rush att',0,'Rushing attempts'],rushingYards:['Rush yd',0,'Rushing yards'],rushingTds:['Rush TD',0,'Rushing touchdowns'],yardsPerCarry:['YPC',2,'Rushing yards per carry'],receptions:['REC',0,'Receptions'],targets:['TGT',0,'Targets'],targetShare:['TGT %','percent','Share of team targets; season uses summed team denominators'],receivingYards:['Rec yd',0,'Receiving yards'],receivingTds:['Rec TD',0,'Receiving touchdowns'],yardsPerReception:['Y/REC',2,'Receiving yards per reception'],catchRate:['Catch %','percent','Receptions divided by targets'], offenseSnaps:['Snaps',0,'Offensive plays on the field; not elapsed time'],snapShare:['Snap %','percent','Share of team offensive snaps'],fumblesTotal:['FUM',0,'Total fumbles'],fumblesLost:['FL',0,'Fumbles lost'],twoPointConversions:['2PT',0,'Two-point conversions'], fgMade:['FGM',0,'Field goals made'],fgAttempts:['FGA',0,'Field goals attempted'],fgLong:['FG long',0,'Longest made field goal'],patMade:['XPM',0,'Extra points made'],defenseSacks:['Sacks',1,'Team defense sacks'],defenseInterceptions:['DEF INT',0,'Team defense interceptions'],defenseFumbleRecoveries:['FR',0,'Team defense fumble recoveries'],defenseTds:['DEF TD',0,'Team defense touchdowns'],pointsAllowed:['PA',0,'Points allowed for fantasy scoring'], }; const columnsByCategory={ stats:['passingYards','passingTds','interceptions','completions','passAttempts','completionRate','carries','rushingYards','yardsPerCarry','rushingTds','receptions','targets','targetShare','receivingYards','receivingTds','offenseSnaps','snapShare','fumblesTotal','fumblesLost','twoPointConversions','fgMade','patMade','defenseSacks','defenseInterceptions','defenseTds','pointsAllowed'], qb:['completions','passAttempts','completionRate','passingYards','passingTds','interceptions','yardsPerPassAttempt','carries','rushingYards','rushingTds','fumblesTotal','fumblesLost','twoPointConversions','offenseSnaps','snapShare'], rb:['carries','rushingYards','yardsPerCarry','rushingTds','receptions','targets','targetShare','receivingYards','receivingTds','offenseSnaps','snapShare','fumblesTotal','fumblesLost','twoPointConversions'], wr:['receptions','targets','targetShare','receivingYards','receivingTds','yardsPerReception','catchRate','offenseSnaps','snapShare','carries','rushingYards','rushingTds','fumblesTotal','fumblesLost','twoPointConversions'], te:['receptions','targets','targetShare','receivingYards','receivingTds','yardsPerReception','catchRate','offenseSnaps','snapShare','carries','rushingYards','fumblesTotal','fumblesLost','twoPointConversions'], rwt:['receptions','targets','targetShare','receivingYards','receivingTds','carries','rushingYards','yardsPerCarry','rushingTds','offenseSnaps','snapShare','fumblesTotal','fumblesLost','twoPointConversions'], k:['fgMade','fgAttempts','fgLong','patMade'],def:['defenseSacks','defenseInterceptions','defenseFumbleRecoveries','defenseTds','pointsAllowed'],kd:['fgMade','fgAttempts','fgLong','patMade','defenseSacks','defenseInterceptions','defenseFumbleRecoveries','defenseTds','pointsAllowed'], }; function metricValue(row,key){const value=row[key],format=fields[key]?.[1];return format==='percent'?pct(value):number(value,format||0);} function ownership(row){const record=row.platformRostered?.yahoo;return record?.status==='unavailable'?null:record?.percent;} function sortedRows(){const rows=boardRows(state.data,{period:state.period,platform:state.platform,category:state.category,search:state.search,team:state.team==='ALL'?undefined:state.team,scoring:state.scoring});if(state.sort==='points'&&state.direction===-1)return rows;return [...rows].sort((a,b)=>{let av,bv;if(state.sort==='name'){av=a.name;bv=b.name;}else if(state.sort==='points'){av=a[SCORING[state.scoring].key];bv=b[SCORING[state.scoring].key];}else if(state.sort==='rostered'){av=ownership(a);bv=ownership(b);}else{av=a[state.sort];bv=b[state.sort];}if(av==null)return bv==null?0:1;if(bv==null)return -1;return state.direction*(typeof av==='string'?av.localeCompare(bv):av-bv)||a.name.localeCompare(b.name);});} function changeContext(){state.limit=60;state.sort='points';state.direction=-1;render();} function selectPlayer(id){state.id=id;syncUrl();renderDetail();renderTable();if(state.show)renderPresentation();} function rebuildTeams(){const teams=[...new Set(periodRows(state.data,state.period).map(row=>row.team).filter(Boolean))].sort();const select=$('#team-select');select.replaceChildren(new Option('All teams','ALL'),...teams.map(team=>new Option(team,team)));if(state.team!=='ALL'&&!teams.includes(state.team))state.team='ALL';select.value=state.team;} function renderControls(){const options=state.data.availableWeeks.map(week=>new Option(`Week ${week}`,String(week)));$('#period-select').replaceChildren(...options,new Option('Season totals','season'));$('#period-select').value=String(state.period);$('#scoring-select').value=state.scoring;$('#platform-select').value=state.platform;$('#search').value=state.search;rebuildTeams();const tabs=$('#board-tabs');tabs.replaceChildren();for(const [key,category] of Object.entries(CATEGORIES)){const el=button(category.label,()=>{state.category=key;changeContext();},'board-tab');el.classList.toggle('active',key===state.category);el.setAttribute('aria-pressed',String(key===state.category));tabs.append(el);}const c=coverage(),banner=$('#coverage-banner');banner.classList.toggle('provisional',!c?.complete);banner.replaceChildren(text('strong',c?.complete?'CORE STATS LOADED':'PROVISIONAL STATS'),text('span',`${coverageLabel()} · Stats snapshot ${date(state.data.generatedAt)}.`));if(c?.missingGames?.length)banner.append(text('span',`Missing games: ${c.missingGames.join(', ')}`));$('#edition').textContent=`${state.data.season} / ${periodLabel().toUpperCase()}`;$('#edition-date').textContent=`Source updated ${date(state.data.sourceUpdatedAt)}`;$('#board-title').textContent=`${CATEGORIES[state.category].label} · ${periodLabel()}`;$('#board-description').textContent=`${SCORING[state.scoring].label} · ${state.platform==='yahoo'?'Yahoo':'Sleeper'} current position eligibility · Earned points, not projections.`;} function heading(label,key,title){const th=document.createElement('th');th.scope='col';if(title)th.title=title;if(!key){th.textContent=label;return th;}const active=state.sort===key;th.setAttribute('aria-sort',active?(state.direction===-1?'descending':'ascending'):'none');const b=button(`${label}${active?(state.direction===-1?' ↓':' ↑'):''}`,()=>{state.direction=active?-state.direction:key==='name'?1:-1;state.sort=key;state.limit=60;renderRows();},'sort-button');b.title=title||`Sort by ${label}`;th.append(b);return th;} function renderTable(){const keys=columnsByCategory[state.category]||columnsByCategory.stats;const rankWeek=state.period==='season'?state.data.defaultWeek:state.period;const head=text('tr','');head.append(heading('PLAYER / TEAM','name'),heading('FPTS','points','Fantasy points for the selected scoring'),heading(`W${rankWeek} overall`,null,'Overall fantasy rank for the reference week'),heading('Season overall',null,'Overall fantasy rank across all loaded weeks'),heading(`W${rankWeek} position`,null,'Position fantasy rank for the reference week'),heading('Season position',null,'Position fantasy rank across all loaded weeks'),heading('Yahoo % now','rostered','Current Yahoo-wide rostered percentage, not historical ownership'));if(state.period==='season')head.append(heading('Games','gamesRecorded','Games with recorded statistics; not official appearances'),heading('PPG',`pointsPerGame${state.scoring==='ppr'?'Ppr':state.scoring==='half'?'Half':'Standard'}`,'Fantasy points per game with recorded statistics'));for(const key of keys)head.append(heading(fields[key][0],key,fields[key][2]));$('#table-head').replaceChildren(head);const body=$('#table-body');body.replaceChildren();for(const row of state.rows.slice(0,state.limit)){const fact=currentFact(row.id);const tr=document.createElement('tr');tr.classList.toggle('selected',row.id===state.id);const cell=document.createElement('th');cell.scope='row';const nameButton=button('',()=>selectPlayer(row.id),'player-select');nameButton.setAttribute('aria-label',`View ${row.name}`);nameButton.setAttribute('aria-pressed',String(row.id===state.id));nameButton.append(photo(row.name,fact?.photoUrl||row.photoUrl,'table-photo'));const identity=document.createElement('span');identity.append(text('strong',row.name),text('small',`${row.team} · ${eligiblePositions(row,state.platform).join(' / ')||row.pos}${row.opponent?` · vs ${row.opponent}`:''}`));nameButton.append(identity);cell.append(nameButton);tr.append(cell);const add=(value,css)=>tr.append(text('td',value,css));add(number(row[SCORING[state.scoring].key],1),'points-cell');add(number(fact?.ranks?.weekOverall),'rank-cell');add(number(fact?.ranks?.seasonOverall),'rank-cell');add(fact?.ranks?.weekPosition||'—','position-rank');add(fact?.ranks?.seasonPosition||'—','position-rank');const roster=text('td',pct(ownership(row),false),'roster-cell');roster.title=`Yahoo rostered now · ${date(row.platformRostered?.yahoo?.asOf)}`;tr.append(roster);if(state.period==='season'){add(number(row.gamesRecorded));add(number(row[`pointsPerGame${state.scoring==='ppr'?'Ppr':state.scoring==='half'?'Half':'Standard'}`],2));}for(const key of keys)add(metricValue(row,key));body.append(tr);}$('#table-empty').hidden=state.rows.length>0;$('#table-scroll').hidden=state.rows.length===0;$('#table-count').textContent=`${Math.min(state.limit,state.rows.length)} of ${state.rows.length} players`;$('#load-more').hidden=state.limit>=state.rows.length;$('#load-more').textContent=`Show ${Math.min(60,state.rows.length-state.limit)} more players`;} function rankTiles(fact){const group=document.createElement('div');group.className='rank-grid';const ranks=fact.ranks||{};for(const [label,value] of [[`W${ranks.week??state.data.defaultWeek} overall`,number(ranks.weekOverall)],['Season overall',number(ranks.seasonOverall)],[`W${ranks.week??state.data.defaultWeek} position`,ranks.weekPosition||'—'],['Season position',ranks.seasonPosition||'—']]){const tile=document.createElement('div');tile.append(text('span',label),text('strong',value));group.append(tile);}return group;} function metricTiles(fact,large=false){const group=document.createElement('div');group.className='metric-grid';const row=periodRows(state.data,state.period).find(item=>item.id===fact.id),pos=row?.pos||fact.positions?.[0];const choices={QB:['passingYards','passingTds','interceptions','completions','passAttempts','completionRate','rushingYards','rushingTds','snapShare'],RB:['rushingYards','carries','rushingTds','receptions','receivingYards','targets','targetShare','snapShare','yardsPerCarry'],K:['fgMade','fgAttempts','fgLong','patMade','patMissed','fgMade50Plus','fgMade0_39','fgMade40_49'],DEF:['defenseSacks','defenseInterceptions','defenseFumbleRecoveries','defenseTds','pointsAllowed','defenseSafeties','defenseBlockedKicks','defenseTwoPointReturns']};let keys=choices[pos]||['receivingYards','receptions','receivingTds','targets','targetShare','snapShare','yardsPerTarget','catchRate','offenseSnaps'];if(state.period==='season')keys=[`pointsPerGame${state.scoring==='ppr'?'Ppr':state.scoring==='half'?'Half':'Standard'}`,'gamesRecorded',...keys];const byKey=new Map((fact.metrics||[]).map(metric=>[metric.key,metric]));for(const key of keys.slice(0,large?9:6)){const metric=byKey.get(key);if(!metric)continue;const tile=document.createElement('div');tile.append(text('strong',metric.display??number(metric.value)),text('span',metric.label));group.append(tile);}return group;} function fullStatDetails(fact){const details=document.createElement('details');details.className='source-details full-stat-details';details.append(text('summary','Full stat line & usage'));const list=document.createElement('dl');for(const metric of fact.metrics||[]){const item=document.createElement('div');item.append(text('dt',metric.label),text('dd',metric.display??number(metric.value)));list.append(item);}details.append(list);return details;} function sourceDetails(fact){const details=document.createElement('details');details.className='source-details';details.append(text('summary','Sources & stat context'));const content=document.createElement('div');content.append(text('p',fact.coverageText||coverageLabel()),text('p',`Dataset downloaded ${date(fact.generatedAt)}. Core source updated ${date(fact.sourceUpdatedAt)}.`));for(const source of fact.sources||[]){const paragraph=document.createElement('p');paragraph.append(link(source.label,source.url));if(source.asOf)paragraph.append(text('small',` · ${date(source.asOf)}`));content.append(paragraph);}if(fact.photoSourceUrl){const p=document.createElement('p');p.append(link('Player image source',fact.photoSourceUrl));content.append(p);}content.append(text('p','Eligibility and rostered percentages are current snapshots, even when viewing an earlier week. Missing rows do not mean zero points.'));details.append(content);return details;} function shareActions(fact,showButton=true){const actions=document.createElement('div');actions.className='player-actions';if(showButton)actions.append(button('Present this player ↗',()=>setShow(true),'button orange'));actions.append(button('Copy facts for a blog',()=>copy(factsText(fact),'Sourced player facts copied. Paste them into your article.'),'button outline'),button('Copy player link',()=>{syncUrl();copy(location.href,'Player link copied with this period, platform and scoring.');},'text-button'));return actions;} function rowForSelected(){return periodRows(state.data,state.period).find(row=>row.id===state.id);} function contextStrip(fact){const row=rowForSelected(),strip=document.createElement('div');strip.className='context-strip';const ownershipGroup=document.createElement('div');ownershipGroup.append(text('span','YAHOO ROSTERED NOW'),text('strong',pct(ownership(row||{}),false)),text('small',`Current snapshot · ${date(row?.platformRostered?.yahoo?.asOf)}`));const snaps=document.createElement('div');snaps.append(text('span','OFFENSIVE SNAP SHARE'),text('strong',pct(row?.snapShare)),text('small',finite(row?.offenseSnaps)?`${number(row.offenseSnaps)} offensive snaps · ${date(row.snapAsOf||row.snapUpdatedAt||row.snapFetchedAt)}`:'Time on field is unavailable'));strip.append(ownershipGroup,snaps);return strip;} function renderDetail(){const detail=$('#player-detail'),fact=currentFact();detail.replaceChildren();$('#show-open').disabled=!fact;if(!fact){detail.append(text('p','Select a player to explore their stats.','empty-state'));return;}const eyebrow=document.createElement('div');eyebrow.className='detail-kicker';eyebrow.append(text('span','PLAYER SPOTLIGHT'),text('span',periodLabel()));const identity=document.createElement('div');identity.className='detail-identity';identity.append(photo(fact.name,fact.photoUrl));const name=document.createElement('div');name.append(text('p',`${fact.team} · ${(fact.positions||[]).join(' / ')}${fact.opponent?` · vs ${fact.opponent}`:''}`,'eyebrow'),text('h3',fact.name));identity.append(name);const points=document.createElement('div');points.className='detail-points';points.append(text('strong',number(fact.points,1)),text('span',`${fact.scoringLabel||SCORING[state.scoring].label}\nFANTASY POINTS`));detail.append(eyebrow,identity,points,rankTiles(fact),metricTiles(fact),contextStrip(fact));if(fact.comparison)detail.append(text('p',fact.comparison,'comparison'));detail.append(text('p',fact.coverageText||coverageLabel(),'detail-coverage'),shareActions(fact),fullStatDetails(fact),sourceDetails(fact));} function movePlayer(delta){if(!state.rows.length)return;const index=state.rows.findIndex(row=>row.id===state.id),next=Math.max(0,Math.min(state.rows.length-1,(index<0?0:index)+delta));selectPlayer(state.rows[next].id);} function renderPresentation(){const fact=currentFact(),target=$('#show-player');target.replaceChildren();if(!fact){target.append(text('p','Choose another period or player to show recorded stats.','empty-state'));return;}const card=document.createElement('article');card.className='show-card';const top=document.createElement('div');top.className='show-topline';top.append(text('strong','TDS BLITZ'),text('span',`${fact.periodLabel||periodLabel()} / ${fact.scoringLabel}`));const hero=document.createElement('div');hero.className='show-identity';hero.append(photo(fact.name,fact.photoUrl,'show-photo'));const identity=document.createElement('div');identity.className='show-name';identity.append(text('p',`${fact.team} · ${(fact.positions||[]).join(' / ')}${fact.opponent?` · vs ${fact.opponent}`:''}`,'eyebrow'),text('h1',fact.name));const points=document.createElement('div');points.className='show-points';points.append(text('strong',number(fact.points,1)),text('span','FANTASY POINTS'));hero.append(identity,points);card.append(top,hero,metricTiles(fact,true),rankTiles(fact));if(fact.comparison)card.append(text('p',fact.comparison,'show-comparison'));const foot=document.createElement('div');foot.className='show-foot';foot.append(text('strong',fact.coverageText||coverageLabel()),text('span',`Source updated ${date(fact.sourceUpdatedAt)} · ${state.platform==='yahoo'?'Yahoo':'Sleeper'} eligibility`));card.append(foot);target.append(card,shareActions(fact,false),fullStatDetails(fact),sourceDetails(fact));const index=state.rows.findIndex(row=>row.id===state.id);$('#show-position').textContent=index>=0?`${index+1} / ${state.rows.length} · ${CATEGORIES[state.category].label}`:'Selected player';$('#show-prev').disabled=index<=0;$('#show-next').disabled=index<0||index>=state.rows.length-1;} function setShow(enabled){state.show=Boolean(enabled)&&Boolean(currentFact());document.body.classList.toggle('show-mode',state.show);$('#presentation').hidden=!state.show;if(state.show)renderPresentation();syncUrl();window.scrollTo({top:0,behavior:'instant'});(state.show?$('#show-close'):$('#show-open')).focus({preventScroll:true});} function renderMethodology(){const target=$('#methodology-content');target.replaceChildren();const items=[['Earned ranks, not predictions','Fantasy ranks compare every recorded player and team defense before any search or team filter. Ties use competition ranking: 1, 1, 3. Weekly ranks in Season totals refer to the latest loaded week.'],['Scoring',state.data.scoringNote||'Full PPR awards one point per reception. Passing touchdowns are worth four points. Half PPR and Standard are also available.'],['Kickers & team defenses',[state.data.kickingScoringNote,state.data.defenseScoringNote].filter(Boolean).join(' ')],['Usage & season totals','Target share uses team targets. Offensive snap share describes plays on the field, not elapsed minutes. Season counts are summed; percentages use their combined denominators. Games recorded are source games with a stat row, not official appearances.'],['Current eligibility & ownership',state.data.eligibilityNote||'Platform positions describe current eligibility. Yahoo rostered percentages are current Yahoo-wide snapshots, not historical values or availability in your own league.'],['Sources & updates',`This page uses a published statistical snapshot downloaded ${date(state.data.generatedAt)}. A new source update can correct earlier weeks. Missing values stay unavailable; partial coverage is labeled.`]];for(const [label,value] of items){if(!value)continue;const details=document.createElement('details');details.append(text('summary',label),text('p',value));target.append(details);}if(state.data.sourceUrl)target.append(link('Core data source ↗',state.data.sourceUrl,'text-link'));} function renderRows(){state.rows=sortedRows();if(!state.rows.some(row=>row.id===state.id))state.id=state.rows[0]?.id||null;renderTable();renderDetail();if(state.show)renderPresentation();syncUrl();} function render(){renderControls();renderRows();document.body.classList.toggle('show-mode',state.show);$('#presentation').hidden=!state.show;} function validateData(data){if(!data||!Number.isInteger(data.season)||!Array.isArray(data.players)||!Array.isArray(data.seasonPlayers)||!Array.isArray(data.availableWeeks)||!data.availableWeeks.length||!Array.isArray(data.coverage)||!data.availableWeeks.includes(data.defaultWeek))throw new Error('The published football snapshot is incomplete. Please try again after the next update.');return data;} async function load(){const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),30000);$('#loading').hidden=false;$('#load-error').hidden=true;$('#app').hidden=true;try{const response=await fetch('./football-data.json',{cache:'no-cache',signal:controller.signal});if(!response.ok)throw new Error(`The published stats returned HTTP ${response.status}.`);state.data=validateData(await response.json());state.facts.clear();readUrl();renderMethodology();render();$('#loading').hidden=true;$('#app').hidden=false;}catch(error){$('#loading').hidden=true;$('#load-error').hidden=false;$('#load-error-message').textContent=error.name==='AbortError'?'The stats took too long to load. Check your connection and try again.':error.message;document.body.classList.remove('show-mode');}finally{clearTimeout(timer);}} $('#retry').addEventListener('click',load); $('#period-select').addEventListener('change',event=>{state.period=event.target.value==='season'?'season':Number(event.target.value);changeContext();}); $('#scoring-select').addEventListener('change',event=>{state.scoring=event.target.value;changeContext();}); $('#platform-select').addEventListener('change',event=>{state.platform=event.target.value;changeContext();}); $('#team-select').addEventListener('change',event=>{state.team=event.target.value;state.limit=60;renderRows();}); let searchTimer;$('#search').addEventListener('input',event=>{state.search=event.target.value.slice(0,100);clearTimeout(searchTimer);searchTimer=setTimeout(()=>{state.limit=60;renderRows();},140);}); $('#clear-filters').addEventListener('click',()=>{state.search='';state.team='ALL';changeContext();}); $('#load-more').addEventListener('click',()=>{state.limit+=60;renderTable();}); $('#show-open').addEventListener('click',()=>setShow(true));$('#show-close').addEventListener('click',()=>setShow(false));$('#show-prev').addEventListener('click',()=>movePlayer(-1));$('#show-next').addEventListener('click',()=>movePlayer(1)); window.addEventListener('popstate',()=>{if(state.data){readUrl();render();}}); document.addEventListener('keydown',event=>{if(!state.show||event.altKey||event.ctrlKey||event.metaKey||/INPUT|SELECT|TEXTAREA/.test(event.target.tagName)||event.target.isContentEditable)return;if(event.key==='Escape'){event.preventDefault();setShow(false);}else if(event.key==='ArrowLeft'){event.preventDefault();movePlayer(-1);}else if(event.key==='ArrowRight'){event.preventDefault();movePlayer(1);}}); load();