Skip to content

Instantly share code, notes, and snippets.

@wallace
Created March 17, 2026 20:24
Show Gist options
  • Select an option

  • Save wallace/77b9771b1de0c87f4e9a9433a8cc771c to your computer and use it in GitHub Desktop.

Select an option

Save wallace/77b9771b1de0c87f4e9a9433a8cc771c to your computer and use it in GitHub Desktop.
<!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