Created
March 17, 2026 20:24
-
-
Save wallace/77b9771b1de0c87f4e9a9433a8cc771c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"/> | |
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"/> | |
| <meta name="apple-mobile-web-app-capable" content="yes"/> | |
| <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/> | |
| <meta name="apple-mobile-web-app-title" content="Soccer Tracker"/> | |
| <title>Soccer Tracker</title> | |
| <style> | |
| :root { | |
| --green: #1D9E75; | |
| --green-dark: #0F6E56; | |
| --green-light: #EAF3DE; | |
| --green-text: #3B6D11; | |
| --green-border: #97C459; | |
| --amber: #FAEEDA; | |
| --amber-text: #633806; | |
| --amber-border: #EF9F27; | |
| --g4: #E6F1FB; | |
| --g4t: #185FA5; | |
| --g5: #EEEDFE; | |
| --g5t: #534AB7; | |
| --bg: #f5f5f0; | |
| --surface: #ffffff; | |
| --surface2: #f0efe9; | |
| --border: rgba(0,0,0,0.1); | |
| --border2: rgba(0,0,0,0.18); | |
| --text: #1a1a1a; | |
| --text2: #666; | |
| --text3: #999; | |
| --radius: 12px; | |
| --radius-sm: 8px; | |
| --safe-top: env(safe-area-inset-top, 0px); | |
| --safe-bottom: env(safe-area-inset-bottom, 0px); | |
| } | |
| * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| min-height: 100vh; | |
| min-height: -webkit-fill-available; | |
| overscroll-behavior: none; | |
| } | |
| .app { | |
| display: flex; | |
| flex-direction: column; | |
| height: 100vh; | |
| height: -webkit-fill-available; | |
| } | |
| /* HEADER */ | |
| .header { | |
| background: var(--green); | |
| color: #fff; | |
| padding: calc(var(--safe-top) + 14px) 16px 14px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| flex-shrink: 0; | |
| } | |
| .header-title { font-size: 17px; font-weight: 600; letter-spacing: -0.3px; } | |
| .header-sub { font-size: 12px; opacity: 0.75; margin-top: 1px; } | |
| .clock-display { text-align: right; } | |
| .clock-time { font-size: 26px; font-weight: 300; letter-spacing: -1px; line-height: 1; } | |
| .clock-label { font-size: 10px; opacity: 0.7; text-transform: uppercase; letter-spacing: .08em; } | |
| /* GAME CONTROLS BAR */ | |
| .controls-bar { | |
| background: var(--green-dark); | |
| padding: 10px 16px; | |
| display: flex; | |
| gap: 8px; | |
| align-items: center; | |
| flex-shrink: 0; | |
| } | |
| .ctrl-btn { | |
| flex: 1; | |
| padding: 9px 8px; | |
| border-radius: var(--radius-sm); | |
| border: none; | |
| font-size: 14px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| font-family: inherit; | |
| transition: opacity .15s; | |
| } | |
| .ctrl-btn:active { opacity: 0.75; } | |
| .ctrl-btn.start { background: #fff; color: var(--green-dark); } | |
| .ctrl-btn.end { background: rgba(255,255,255,0.2); color: #fff; } | |
| .game-label { font-size: 12px; color: rgba(255,255,255,0.7); white-space: nowrap; } | |
| /* TABS */ | |
| .tab-bar { | |
| background: var(--surface); | |
| border-bottom: 0.5px solid var(--border); | |
| display: flex; | |
| flex-shrink: 0; | |
| overflow-x: auto; | |
| -webkit-overflow-scrolling: touch; | |
| } | |
| .tab-bar::-webkit-scrollbar { display: none; } | |
| .tab-btn { | |
| flex: 1; | |
| min-width: 70px; | |
| padding: 10px 4px; | |
| border: none; | |
| background: transparent; | |
| font-size: 12px; | |
| font-weight: 500; | |
| color: var(--text3); | |
| cursor: pointer; | |
| font-family: inherit; | |
| border-bottom: 2px solid transparent; | |
| transition: color .15s; | |
| } | |
| .tab-btn.active { color: var(--green); border-bottom-color: var(--green); } | |
| /* CONTENT */ | |
| .content { | |
| flex: 1; | |
| overflow-y: auto; | |
| -webkit-overflow-scrolling: touch; | |
| padding: 12px 12px calc(var(--safe-bottom) + 12px); | |
| } | |
| .panel { display: none; } | |
| .panel.active { display: block; } | |
| /* FILTER PILLS */ | |
| .filter-row { display: flex; gap: 6px; margin-bottom: 10px; } | |
| .fpill { | |
| padding: 5px 14px; | |
| border-radius: 20px; | |
| font-size: 13px; | |
| border: 0.5px solid var(--border2); | |
| background: var(--surface); | |
| color: var(--text2); | |
| cursor: pointer; | |
| font-family: inherit; | |
| } | |
| .fpill.active { background: var(--green); border-color: var(--green); color: #fff; font-weight: 500; } | |
| /* FIELD COLUMNS */ | |
| .columns { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } | |
| .zone { | |
| background: var(--surface); | |
| border: 0.5px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 10px; | |
| } | |
| .zone-title { | |
| font-size: 11px; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: .06em; | |
| color: var(--text3); | |
| margin-bottom: 8px; | |
| display: flex; | |
| align-items: center; | |
| gap: 5px; | |
| } | |
| .count-pill { | |
| font-size: 11px; | |
| background: var(--surface2); | |
| border-radius: 10px; | |
| padding: 1px 6px; | |
| color: var(--text2); | |
| font-weight: 500; | |
| } | |
| /* PLAYER CARDS */ | |
| .pcard { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 8px 8px; | |
| border-radius: var(--radius-sm); | |
| margin-bottom: 5px; | |
| border: 0.5px solid var(--border); | |
| cursor: pointer; | |
| background: var(--surface2); | |
| -webkit-user-select: none; | |
| user-select: none; | |
| transition: transform .1s; | |
| } | |
| .pcard:active { transform: scale(0.97); } | |
| .pcard.on { background: var(--green-light); border-color: var(--green-border); } | |
| .pcard.on .pname { color: var(--green-text); font-weight: 600; } | |
| .pcard.on .ptime { color: #639922; } | |
| .pcard.alert { background: var(--amber); border-color: var(--amber-border); } | |
| .pcard.alert .pname { color: var(--amber-text); font-weight: 600; } | |
| .pcard.alert .ptime { color: #854F0B; } | |
| .pname { font-size: 13px; color: var(--text); display: flex; align-items: center; gap: 4px; flex-wrap: wrap; } | |
| .ptime { font-size: 11px; color: var(--text2); text-align: right; } | |
| .ptime-game { display: block; font-weight: 600; font-size: 12px; } | |
| .ptime-total { display: block; color: var(--text3); font-size: 10px; } | |
| /* GRADE PILLS */ | |
| .gpill { | |
| font-size: 10px; | |
| padding: 1px 5px; | |
| border-radius: 8px; | |
| font-weight: 600; | |
| flex-shrink: 0; | |
| } | |
| .g4 { background: var(--g4); color: var(--g4t); } | |
| .g5 { background: var(--g5); color: var(--g5t); } | |
| .due-badge { | |
| font-size: 10px; | |
| padding: 1px 5px; | |
| border-radius: 8px; | |
| background: var(--amber); | |
| color: #854F0B; | |
| font-weight: 600; | |
| border: 0.5px solid var(--amber-border); | |
| } | |
| .empty { font-size: 13px; color: var(--text3); padding: 6px 0; } | |
| .legend { | |
| display: flex; | |
| gap: 10px; | |
| flex-wrap: wrap; | |
| font-size: 11px; | |
| color: var(--text2); | |
| margin-bottom: 10px; | |
| } | |
| .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 3px; } | |
| /* ROSTER */ | |
| .add-row { display: flex; gap: 6px; margin-bottom: 12px; } | |
| .add-row input { | |
| flex: 1; | |
| padding: 9px 12px; | |
| border-radius: var(--radius-sm); | |
| border: 0.5px solid var(--border2); | |
| font-size: 14px; | |
| background: var(--surface); | |
| color: var(--text); | |
| font-family: inherit; | |
| } | |
| .add-row select { | |
| padding: 9px 8px; | |
| border-radius: var(--radius-sm); | |
| border: 0.5px solid var(--border2); | |
| font-size: 14px; | |
| background: var(--surface); | |
| color: var(--text); | |
| font-family: inherit; | |
| } | |
| .add-btn { | |
| padding: 9px 14px; | |
| border-radius: var(--radius-sm); | |
| border: none; | |
| background: var(--green); | |
| color: #fff; | |
| font-size: 14px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| font-family: inherit; | |
| } | |
| .roster-list { background: var(--surface); border-radius: var(--radius); border: 0.5px solid var(--border); overflow: hidden; } | |
| .roster-row { | |
| display: flex; | |
| align-items: center; | |
| padding: 10px 12px; | |
| border-bottom: 0.5px solid var(--border); | |
| font-size: 14px; | |
| gap: 8px; | |
| } | |
| .roster-row:last-child { border-bottom: none; } | |
| .rname { flex: 1; } | |
| .remove-btn { | |
| background: none; | |
| border: none; | |
| color: var(--text3); | |
| font-size: 16px; | |
| cursor: pointer; | |
| padding: 2px 4px; | |
| line-height: 1; | |
| } | |
| .remove-btn:active { color: #c0392b; } | |
| /* SUB LOG */ | |
| .sub-filter { margin-bottom: 10px; } | |
| .sub-entry { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 8px 12px; | |
| background: var(--surface); | |
| border-radius: var(--radius-sm); | |
| margin-bottom: 5px; | |
| font-size: 13px; | |
| border: 0.5px solid var(--border); | |
| } | |
| .sub-meta { font-size: 11px; color: var(--text3); min-width: 50px; } | |
| .sub-dir-in { color: var(--green-text); font-weight: 600; } | |
| .sub-dir-out { color: #993C1D; font-weight: 600; } | |
| .sub-player { flex: 1; } | |
| /* STATS */ | |
| .stats-filter { margin-bottom: 10px; } | |
| .stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } | |
| .scard { | |
| background: var(--surface); | |
| border: 0.5px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 10px 12px; | |
| } | |
| .sname { font-size: 12px; color: var(--text2); margin-bottom: 2px; display: flex; align-items: center; gap: 4px; } | |
| .sval { font-size: 22px; font-weight: 300; color: var(--text); letter-spacing: -0.5px; } | |
| .smeta { font-size: 10px; color: var(--text3); margin-top: 1px; } | |
| .section-head { | |
| font-size: 11px; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| letter-spacing: .06em; | |
| color: var(--text3); | |
| margin: 14px 0 8px; | |
| } | |
| .section-head:first-child { margin-top: 0; } | |
| /* RESET BTN */ | |
| .reset-btn { | |
| width: 100%; | |
| padding: 12px; | |
| border-radius: var(--radius-sm); | |
| border: 0.5px solid #e74c3c; | |
| background: #fdf0ef; | |
| color: #c0392b; | |
| font-size: 14px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| font-family: inherit; | |
| margin-top: 16px; | |
| } | |
| .reset-btn:active { background: #fad7d4; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app"> | |
| <!-- HEADER --> | |
| <div class="header"> | |
| <div> | |
| <div class="header-title">⚽ Playing Time</div> | |
| <div class="header-sub" id="glabel">Game 1</div> | |
| </div> | |
| <div class="clock-display"> | |
| <div class="clock-time" id="clock">0:00</div> | |
| <div class="clock-label">Game time</div> | |
| </div> | |
| </div> | |
| <!-- CONTROLS --> | |
| <div class="controls-bar"> | |
| <button class="ctrl-btn start" id="startbtn" onclick="toggleTimer()">Start</button> | |
| <button class="ctrl-btn end" onclick="endGame()">End Game</button> | |
| <span class="game-label" id="oncount">0/7 on field</span> | |
| </div> | |
| <!-- TABS --> | |
| <div class="tab-bar"> | |
| <button class="tab-btn active" onclick="showTab('game',this)">Field</button> | |
| <button class="tab-btn" onclick="showTab('subs',this)">Sub Log</button> | |
| <button class="tab-btn" onclick="showTab('stats',this)">Stats</button> | |
| <button class="tab-btn" onclick="showTab('roster',this)">Roster</button> | |
| </div> | |
| <!-- CONTENT --> | |
| <div class="content"> | |
| <!-- FIELD PANEL --> | |
| <div id="tab-game" class="panel active"> | |
| <div class="filter-row"> | |
| <button class="fpill active" onclick="setFilter('all',this)">All</button> | |
| <button class="fpill" onclick="setFilter('4',this)">4th grade</button> | |
| <button class="fpill" onclick="setFilter('5',this)">5th grade</button> | |
| </div> | |
| <div class="legend"> | |
| <span><span class="dot" style="background:#97C459"></span>On field</span> | |
| <span><span class="dot" style="background:#EF9F27"></span>Due for time</span> | |
| <span style="color:var(--text3)">Tap to sub in/out</span> | |
| </div> | |
| <div class="columns"> | |
| <div class="zone"> | |
| <div class="zone-title">On field</div> | |
| <div id="onlist"></div> | |
| </div> | |
| <div class="zone"> | |
| <div class="zone-title">Bench</div> | |
| <div id="benchlist"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- SUB LOG PANEL --> | |
| <div id="tab-subs" class="panel"> | |
| <div class="sub-filter"> | |
| <select id="game-filter" onchange="renderSubLog()" style="width:100%;padding:9px 12px;border-radius:8px;border:0.5px solid rgba(0,0,0,0.18);font-size:14px;background:#fff;font-family:inherit;"> | |
| <option value="all">All games</option> | |
| </select> | |
| </div> | |
| <div id="sublog"></div> | |
| </div> | |
| <!-- STATS PANEL --> | |
| <div id="tab-stats" class="panel"> | |
| <div class="stats-filter filter-row"> | |
| <button class="fpill active" onclick="setStatsFilter('all',this)">All</button> | |
| <button class="fpill" onclick="setStatsFilter('4',this)">4th grade</button> | |
| <button class="fpill" onclick="setStatsFilter('5',this)">5th grade</button> | |
| </div> | |
| <div class="stats-grid" id="sgrid"></div> | |
| <button class="reset-btn" onclick="resetAll()">Reset All Stats</button> | |
| </div> | |
| <!-- ROSTER PANEL --> | |
| <div id="tab-roster" class="panel"> | |
| <div class="add-row"> | |
| <input type="text" id="ninput" placeholder="Player name" onkeydown="if(event.key==='Enter')addPlayer()"/> | |
| <select id="grade-sel"><option value="4">4th</option><option value="5">5th</option></select> | |
| <button class="add-btn" onclick="addPlayer()">Add</button> | |
| </div> | |
| <div class="roster-list" id="roster-body"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const SEED=[ | |
| {name:'Leo Winham',grade:'5'},{name:'Noah Burgess',grade:'4'}, | |
| {name:'Aaiyden Williams',grade:'5'},{name:'Smith Hacker',grade:'5'}, | |
| {name:'Jesus Uriel Otero',grade:'4'},{name:'Caden Hunt',grade:'4'}, | |
| {name:'Leo Carney',grade:'4'},{name:'James Allen',grade:'5'}, | |
| {name:'Rhodes Crowe',grade:'5'},{name:'Clyde McCullough',grade:'5'}, | |
| {name:'Amos Podvin',grade:'5'},{name:'Micah Fayoyin',grade:'4'}, | |
| {name:'Luther Mack',grade:'4'},{name:'Bode Wise',grade:'5'}, | |
| {name:'Nathan Malec',grade:'5'},{name:'Lambert Broadrick',grade:'5'}, | |
| {name:'Caleb Zavala',grade:'4'},{name:'Henry Brendel',grade:'4'} | |
| ]; | |
| let players=[], gameNum=1, secs=0, running=false, tint=null; | |
| let onField=new Set(), fieldSince={}, subLog=[]; | |
| let fieldFilter='all', statsFilter='all'; | |
| function save(){ | |
| try{ | |
| localStorage.setItem('sc_p',JSON.stringify(players)); | |
| localStorage.setItem('sc_g',String(gameNum)); | |
| localStorage.setItem('sc_sl',JSON.stringify(subLog)); | |
| }catch(e){} | |
| } | |
| function load(){ | |
| try{ | |
| const sp=localStorage.getItem('sc_p'); | |
| if(sp){ | |
| players=JSON.parse(sp); | |
| gameNum=parseInt(localStorage.getItem('sc_g')||'1'); | |
| subLog=JSON.parse(localStorage.getItem('sc_sl')||'[]'); | |
| return; | |
| } | |
| }catch(e){} | |
| players=SEED.map(p=>({...p,totalSec:0,gameSecs:{},games:0})); | |
| save(); | |
| } | |
| function fmt(s){ | |
| s=Math.max(0,Math.floor(s)); | |
| return Math.floor(s/60)+':'+String(s%60).padStart(2,'0'); | |
| } | |
| function addPlayer(){ | |
| const n=document.getElementById('ninput').value.trim(); | |
| const g=document.getElementById('grade-sel').value; | |
| if(!n||players.find(p=>p.name.toLowerCase()===n.toLowerCase()))return; | |
| players.push({name:n,grade:g,totalSec:0,gameSecs:{},games:0}); | |
| document.getElementById('ninput').value=''; | |
| save(); renderRoster(); renderField(); renderStats(); | |
| } | |
| function removePlayer(i){ | |
| const n=players[i].name; | |
| onField.delete(n); delete fieldSince[n]; | |
| players.splice(i,1); | |
| save(); renderRoster(); renderField(); renderStats(); | |
| } | |
| function renderRoster(){ | |
| document.getElementById('roster-body').innerHTML=players.map((p,i)=>` | |
| <div class="roster-row"> | |
| <span class="rname">${p.name}</span> | |
| <span class="gpill g${p.grade}">${p.grade}th</span> | |
| <button class="remove-btn" onclick="removePlayer(${i})">✕</button> | |
| </div>`).join(''); | |
| } | |
| function getLive(name){ | |
| const p=players.find(x=>x.name===name); if(!p)return 0; | |
| let t=p.totalSec; | |
| if(onField.has(name)&&running) t+=Date.now()/1000-(fieldSince[name]||Date.now()/1000); | |
| return t; | |
| } | |
| function getGameLive(name){ | |
| const p=players.find(x=>x.name===name); if(!p)return 0; | |
| let t=(p.gameSecs&&p.gameSecs[gameNum])||0; | |
| if(onField.has(name)&&running) t+=Date.now()/1000-(fieldSince[name]||Date.now()/1000); | |
| return t; | |
| } | |
| function avgLive(){ | |
| if(!players.length)return 0; | |
| return players.reduce((a,p)=>a+getLive(p.name),0)/players.length; | |
| } | |
| function needsTime(name){ const avg=avgLive(); return avg>60&&getLive(name)<avg*0.72; } | |
| function setFilter(f,el){ | |
| fieldFilter=f; | |
| document.querySelectorAll('.filter-row .fpill').forEach(c=>c.classList.remove('active')); | |
| el.classList.add('active'); | |
| renderField(); | |
| } | |
| function setStatsFilter(f,el){ | |
| statsFilter=f; | |
| document.querySelectorAll('.stats-filter .fpill').forEach(c=>c.classList.remove('active')); | |
| el.classList.add('active'); | |
| renderStats(); | |
| } | |
| function esc(n){ return n.replace(/'/g,"\\'"); } | |
| function toggleField(name){ | |
| const now=Date.now()/1000; | |
| if(onField.has(name)){ | |
| const elapsed=running?now-(fieldSince[name]||now):0; | |
| const p=players.find(x=>x.name===name); | |
| p.totalSec+=elapsed; | |
| if(!p.gameSecs)p.gameSecs={}; | |
| p.gameSecs[gameNum]=(p.gameSecs[gameNum]||0)+elapsed; | |
| onField.delete(name); delete fieldSince[name]; | |
| subLog.push({game:gameNum,time:fmt(secs),player:name,grade:p.grade,dir:'out'}); | |
| } else { | |
| if(onField.size>=7){ alert('Already 7 on field!'); return; } | |
| onField.add(name); fieldSince[name]=now; | |
| const p=players.find(x=>x.name===name); | |
| if(p&&!p.gameSecs)p.gameSecs={}; | |
| subLog.push({game:gameNum,time:fmt(secs),player:name,grade:p?p.grade:'',dir:'in'}); | |
| } | |
| save(); renderField(); renderStats(); renderSubLog(); | |
| } | |
| function renderField(){ | |
| const vis=fieldFilter==='all'?players:players.filter(p=>p.grade===fieldFilter); | |
| const onArr=vis.filter(p=>onField.has(p.name)); | |
| const benchArr=vis.filter(p=>!onField.has(p.name)); | |
| document.getElementById('oncount').textContent=onField.size+'/7 on field'; | |
| document.getElementById('onlist').innerHTML=onArr.length?onArr.map(p=>` | |
| <div class="pcard on" onclick="toggleField('${esc(p.name)}')"> | |
| <span class="pname">${p.name}<span class="gpill g${p.grade}">${p.grade}th</span></span> | |
| <span class="ptime"> | |
| <span class="ptime-game">${fmt(getGameLive(p.name))}</span> | |
| <span class="ptime-total">tot ${fmt(getLive(p.name))}</span> | |
| </span> | |
| </div>`).join(''):`<div class="empty">Tap bench players to add</div>`; | |
| document.getElementById('benchlist').innerHTML=benchArr.length?benchArr.map(p=>{ | |
| const al=needsTime(p.name); | |
| return`<div class="pcard${al?' alert':''}" onclick="toggleField('${esc(p.name)}')"> | |
| <span class="pname">${p.name.split(' ')[0]}<span class="gpill g${p.grade}">${p.grade}th</span>${al?'<span class="due-badge">due</span>':''}</span> | |
| <span class="ptime"><span class="ptime-game">${fmt(getLive(p.name))}</span></span> | |
| </div>`;}).join(''):`<div class="empty">All on field</div>`; | |
| } | |
| function toggleTimer(){ | |
| const btn=document.getElementById('startbtn'); | |
| if(!running){ | |
| running=true; | |
| const now=Date.now()/1000; | |
| onField.forEach(n=>{ if(!fieldSince[n])fieldSince[n]=now; }); | |
| tint=setInterval(()=>{ | |
| secs++; | |
| document.getElementById('clock').textContent=fmt(secs); | |
| renderField(); renderStats(); | |
| },1000); | |
| btn.textContent='Pause'; | |
| } else { | |
| running=false; clearInterval(tint); btn.textContent='Resume'; | |
| const now=Date.now()/1000; | |
| onField.forEach(n=>{ | |
| const p=players.find(x=>x.name===n); | |
| const el=now-(fieldSince[n]||now); | |
| p.totalSec+=el; | |
| if(!p.gameSecs)p.gameSecs={}; | |
| p.gameSecs[gameNum]=(p.gameSecs[gameNum]||0)+el; | |
| fieldSince[n]=now; | |
| }); | |
| save(); | |
| } | |
| } | |
| function endGame(){ | |
| if(running)toggleTimer(); | |
| const now=Date.now()/1000; | |
| onField.forEach(n=>{ | |
| const p=players.find(x=>x.name===n); | |
| const el=now-(fieldSince[n]||now); | |
| p.totalSec+=el; | |
| if(!p.gameSecs)p.gameSecs={}; | |
| p.gameSecs[gameNum]=(p.gameSecs[gameNum]||0)+el; | |
| p.games++; | |
| }); | |
| onField.clear(); fieldSince={}; secs=0; | |
| document.getElementById('clock').textContent='0:00'; | |
| document.getElementById('startbtn').textContent='Start'; | |
| gameNum++; | |
| document.getElementById('glabel').textContent='Game '+gameNum; | |
| save(); renderField(); renderStats(); updateGameFilter(); renderSubLog(); | |
| } | |
| function renderSubLog(){ | |
| const filter=document.getElementById('game-filter').value; | |
| const entries=filter==='all'?subLog:subLog.filter(e=>e.game==filter); | |
| const el=document.getElementById('sublog'); | |
| if(!entries.length){ el.innerHTML='<div class="empty" style="padding:20px 0;text-align:center">No substitutions yet</div>'; return; } | |
| el.innerHTML=[...entries].reverse().map(e=>` | |
| <div class="sub-entry"> | |
| <span class="sub-meta">G${e.game} ${e.time}</span> | |
| <span class="${e.dir==='in'?'sub-dir-in':'sub-dir-out'}">${e.dir==='in'?'▲ In':'▼ Out'}</span> | |
| <span class="sub-player">${e.player}</span> | |
| <span class="gpill g${e.grade}">${e.grade}th</span> | |
| </div>`).join(''); | |
| } | |
| function updateGameFilter(){ | |
| const sel=document.getElementById('game-filter'); | |
| const games=[...new Set(subLog.map(e=>e.game))].sort((a,b)=>a-b); | |
| sel.innerHTML='<option value="all">All games</option>'+games.map(g=>`<option value="${g}">Game ${g}</option>`).join(''); | |
| } | |
| function renderStats(){ | |
| const list=statsFilter==='all'?players:players.filter(p=>p.grade===statsFilter); | |
| const sorted=[...list].sort((a,b)=>getLive(b.name)-getLive(a.name)); | |
| document.getElementById('sgrid').innerHTML=sorted.map(p=>` | |
| <div class="scard"> | |
| <div class="sname">${p.name.split(' ')[0]} ${p.name.split(' ').slice(-1)[0]}<span class="gpill g${p.grade}">${p.grade}th</span></div> | |
| <div class="sval">${fmt(getLive(p.name))}</div> | |
| <div class="smeta">${p.games} game${p.games!==1?'s':''} · this: ${fmt(getGameLive(p.name))}</div> | |
| </div>`).join(''); | |
| } | |
| function resetAll(){ | |
| if(!confirm('Reset all playing time and stats?'))return; | |
| players.forEach(p=>{ p.totalSec=0; p.games=0; p.gameSecs={}; }); | |
| onField.clear(); fieldSince={}; secs=0; subLog=[]; gameNum=1; | |
| document.getElementById('clock').textContent='0:00'; | |
| document.getElementById('startbtn').textContent='Start'; | |
| document.getElementById('glabel').textContent='Game 1'; | |
| save(); renderField(); renderStats(); updateGameFilter(); renderSubLog(); | |
| } | |
| function showTab(t, el){ | |
| ['game','subs','stats','roster'].forEach(id=>{ | |
| document.getElementById('tab-'+id).classList.toggle('active',id===t); | |
| }); | |
| document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active')); | |
| el.classList.add('active'); | |
| if(t==='game')renderField(); | |
| if(t==='stats')renderStats(); | |
| if(t==='subs'){updateGameFilter();renderSubLog();} | |
| if(t==='roster')renderRoster(); | |
| } | |
| // Prevent double-tap zoom on buttons | |
| document.addEventListener('touchend',e=>{ if(e.target.tagName==='BUTTON'||e.target.classList.contains('pcard'))e.preventDefault(); },{passive:false}); | |
| load(); | |
| document.getElementById('glabel').textContent='Game '+gameNum; | |
| renderRoster(); renderField(); renderStats(); updateGameFilter(); renderSubLog(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment