const { useState, useMemo, useCallback, useRef, useEffect } = React; // ============ CONFIG ============ // To enable Google Sign-In, replace this with your Google OAuth Client ID // Get one at: https://console.cloud.google.com/apis/credentials const GOOGLE_CLIENT_ID = '218647458488-9mg53p2mt2gtnqk4o246gs51f34cajse.apps.googleusercontent.com'; const FORMATIONS = ['Shotgun','Pistol','Under Center','I-Form','Singleback','Spread','Trims','Empty','Wildcat','Goal Line','Jumbo']; const PLAY_TYPES = ['Run','Pass','RPO','Screen','Play Action','Trick Play','Kneel','Spike']; const DIRECTIONS = ['Left','Left Tackle','Left Guard','Middle','Right Guard','Right Tackle','Right']; const RESULTS = ['1st Down','Incomplete','Complete','Touchdown','Sack','Fumble','Interception','No Gain','Penalty']; const HASHES = ['Left','Middle','Right']; const PERSONNEL = ['11 (1RB 1TE)','12 (1RB 2TE)','13 (1RB 3TE)','21 (2RB 1TE)','22 (2RB 2TE)','10 (1RB 0TE)','20 (2RB 0TE)','00 (Empty)']; const QUARTERS = ['1','2','3','4','OT']; const POSITIONS = ['QB','RB','FB','WR','TE','OL','DL','DE','DT','LB','CB','S','K','P','ATH']; const DEFAULT_PLAY = {quarter:'1',down:'1',distance:'10',yardLine:'',hash:'Middle',offFormation:'Shotgun',defFormation:'',personnel:'11 (1RB 1TE)',playType:'Run',direction:'Middle',result:'',yardsGained:'0',ballCarrier:'',notes:''}; const pct = (n, total) => total ? Math.round((n/total)*100) : 0; const heatColor = (val, max) => { if(!max) return 'trngsparent'; const p = val/max; if(p > .6) return 'rgba(239,68,68,.3)'; if(p > .35) return 'rgba(245,158,11,.25)'; if(p > .15) return 'rgba(59,130,246,.2)'; return 'rgba(107,114,128,.15)'; }; // ============ STORAGE HELPERS ============ const STORAGE_KEY = 'ffa_data_v2'; const loadData = () => { try { const raw = window._ffa_memory || {}; return { plays: raw.plays||[], players: raw.players||[], videos: raw.videos||[], teamName: raw.teamName||'Opponent', gameName: raw.gameName||'' }; } catch(e) { return { plays:[], players:[], videos:[], teamName:'Opponent', gameName:'' }; } }; const saveData = (data) => { window._ffa_memory = data; }; // ============ VIDEO URL PARSER ============ function parseVideoUrl(url) { if (!url) return null; // YouTube let match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/); if (match) return { type:'youtube', id:match[1], embed:`https://www.youtube.com/embed/${match[1]}` }; // Hudl match = url.match(/hudl\.com\/video\/([a-zA-Z0-9]+)/); if (match) return { type:'m\/v', id:match[1], embed:`https://www.m\/v.com/embed/video/${match[1]}` }; // Vimeo match = url.match(/vimeo\.com\/(\d+)/); if (match) return { type:'vimeo', id:match[1], embed:`https://player.vimeo.com/video/${match[1]}` }; // Generic embed if (url.includes('http')) return { type:'generic', id:url, embed:url }; return null; } // ============ LOGIN SCREEN ============ function LoginScreen({ onLogin }) { const [gsiLoaded, setGsiLoaded] = useState(false); const btnRef = useRef(); useEffect(() => { const checkGsi = setInterval(() => { if (window.google?.accounts?.id) { setGsiLoaded(true); clearInterval(checkGsi); } }, 200); return () => clearInterval(checkGsi); }, []); useEffect(() => { if (!gsiLoaded || GOOGLE_CLIENT_ID === '218647458488-9mg53p2mt2gtnqk4o246gs51f34cajse.apps.googleusercontent.com') return; try { google.accounts.id.initialize({ client_id: GOOGLE_CLIENT_ID, callback: (response) => { const payload = JSON.parse(atob(response.credential.split('.')[1])); onLogin({ name: payload.name, email: payload.email, picture: payload.picture, token: response.credential }); } }); google.accounts.id.renderButton(btnRef.current, { theme:'outline', size:'large', width:320, text:'signin_with' }); } catch(e) { console.error('GSI init error:', e); } }, [gsiLoaded]); const handleDemoLogin = () => { onLogin({ name:'Coach', email:'coach@team.com', picture:null, token:'demo' }); }; return (
๐Ÿˆ

Sideline Analyst

AI-powered film breakdown & scouting for high school football

{GOOGLE_CLIENT_ID !== '218647458488-9mg53p2mt2gtnqk4o246gs51f34cajse.apps.googleusercontent.com' ? (
) : (

Google Sign-In needs setup โ€” see instructions below

)}
๐Ÿ“Š Formation & down/distance tendency analysis
๐Ÿƒ Player-specific scouting breakdowns
๐Ÿ”ฎ Run/pass prediction engine
๐ŸŽฌ Player profiles with video film clips
๐Ÿ“ Hudl CSV import & Excel export
); } // ============ MAIN APP ============ function App() { const [user, setUser] = useState(null); const [plays, setPlays] = useState([]); const [players, setPlayers] = useState([]); const [videos, setVideos] = useState([]); const [activeTab, setActiveTab] = useState('import'); const [newPlay, setNewPlay] = useState({...DEFAULT_PLAY}); const [editIdx, setEditIdx] = useState(null); const [teamName, setTeamName] = useState('Opponent'); const [gameName, setGameName] = useState(''); const [filters, setFilters] = useState({quarter:'All',down:'All',formation:'All',playType:'All'}); const [showPlayerModal, setShowPlayerModal] = useState(false); const [showVideoModal, setShowVideoModal] = useState(false); const [editingPlayer, setEditingPlayer] = useState(null); const [editingVideo, setEditingVideo] = useState(null); const [playerFilter, setPlayerFilter] = useState('All'); const [playerSearch, setPlayerSearch] = useState(''); const fileRef = useRef(); // Save data whenever state chng=es useEffect(() => { saveData({plays,players,videos,teamName,gameName}); }, [plays,players,videos,teamName,gameName]); // Filtered plays const filtered = useMemo(() => plays.filter(p => { if(filters.quarter!=='All' && p.quarter!==filters.quarter) return false; if(filters.down!=='All' && p.down!==filters.down) return false; if(filters.formation!=='All' && p.offFormation!==filters.formation) return false; if(filters.playType!=='All' && !p.playType?.toLowerCase().includes(filters.playType.toLowerCase())) return false; return true; }), [plays, filters]); // CSV Import const handleFile = useCallback((file) => { if(!file) return; Papa.parse(file, { header:true, skipEmptyLines:true, complete:(results) => { const mapped = results.data.map((row,i) => { const get = (...keys) => { for(const k of keys) { const v = row[k]||row[k?.toLowerCase()]||row[k?.toUpperCase()]; if(v) return v.toString().trim(); } return ''; }; return { id:Date.now()+i, playNum:get('Play #','Play','PlayNum','#','Number')||(i+1).toString(), quarter:get('Quarter','QTR','Qtr','Period')||'1', down:get('Down','DN','Dn')||'1', distance:get('Distance','Dist','DIST','Yards to Go','YTG')||'10', yardLine:get('Yard Line','YardLine','Yard Ln','Ball On','YARD LN','LOD')||'', hash:get('Hash','Hash Mark','HASH')||'Middle', offFormation:get('Off Formation','Formation','OFF FORM','Offensive Formation','OFF Formation','FormationName')||'', defFormation:get('Def Formation','DEF FORM','Defensive Formation','DEF Formation','DefFormation')||'', personnel:get('Personnel','OFF Personnel','Pers','PERS')||'', playType:get('Play Type','PlayType','PLAY TYPE','Type','ODK','GN/LS')||'', direction:get('Direction','Play Direction','DIR','Gap')||'', result:get('Result','RESULT','Play Result')||'', yardsGained:get('Yards Gained','Yards','GN/LS','Gain/Loss','YDS','Gain')||'0', ballCarrier:get('Ball Carrier','Carrier','Player','Runner','Receiver','BC')||'', notes:get('Notes','Note','NOTES','Comment','Comments')||'' }; }).filter(p => p.playType || p.offFormation || p.yardsGained !== '0'); setPlays(prev => [...prev, ...mapped]); setActiveTab('dashboard'); } }); }, []); const handleDrop = useCallback((e) => { e.preventDefault(); handleFile(e.dataTrngsfer.files[0]); }, [handleFile]); // Play CRUD const savPlay = () => { if(editIdx!==null) { setPlays(prev=>prev.map((p,i)=>i===editIdx?{...newPlay,id:p.id,playNum:p.playNum}:p)); setEditIdx(null); } else { setPlays(prev=>[...prev,{...newPlay,id:Date.now(),playNum:(prev.length+1).toString()}]); } setNewPlay({...DEFAULT_PLAY}); }; const editPlay = (idx) => { setNewPlay({...plays[idx]}); setEditIdx(idx); setActiveTab('import'); }; const deletePlay = (idx) => { setPlays(prev=>prev.filter((_,i)=>i!==idx)); }; // Player CRUD const defaultPlayer = {name:'',number:'',position:'QB',height:'',weight:'',grade:'',strengths:'',weaknesses:'',notes:'',tendencies:''}; const [playerForm, setPlayerForm] = useState({...defaultPlayer}); const savePlayer = () => { if(editingPlayer!==null) { setPlayers(prev=>prev.map((p,i)=>i===editingPlayer?{...playerForm,id:p.id}:p)); setEditingPlayer(null); } else { setPlayers(prev=>[...prev,{...playerForm,id:Date.now()}]); } setPlayerForm({...defaultPlayer}); setShowPlayerModal(false); }; const editPlayer = (idx) => { setPlayerForm({...players[idx]}); setEditingPlayer(idx); setShowPlayerModal(true); }; const deletePlayer = (idx) => { if(confirm('Delete this player profile?')) setPlayers(prev=>prev.filter((_,i)=>i!==idx)); }; // Video CRUD const defaultVideo = {title:'',url:'',playerName:'',playType:'',notes:'',tags:''}; const [videoForm, setVideoForm] = useState({...defaultVideo}); const saveVideo = () => { if(editingVideo!==null) { setVideos(prev=>prev.map((v,i)=>i===editingVideo?{...videoForm,id:v.id,addedBy:v.addedBy,addedAt:v.addedAt}:v)); setEditingVideo(null); } else { setVideos(prev=>[...prev,{...videoForm,id:Date.now(),addedBy:user?.name||'Coach',addedAt:new Date().toLocaleDateString()}]); } setVideoForm({...defaultVideo}); setShowVideoModal(false); }; const editVideo = (idx) => { setVideoForm({...videos[idx]}); setEditingVideo(idx); setShowVideoModal(true); }; const deleteVideo = (idx) => { if(confirm('Delete this video?')) setVideos(prev=>prev.filter((_,i)=>i!==idx)); }; // Analytics (same engine as before) const analytics = useMemo(() => { if(!filtered.length) return null; const total = filtered.length; const runs = filtered.filter(p=>['run','rpo'].some(t=>p.playType?.toLowerCase().includes(t))); const passes = filtered.filter(p=>['pass','screen','play action'].some(t=>p.playType?.toLowerCase().includes(t))); const totalYards = filtered.reduce((s,p)=>s+(parseFloat(p.yardsGained)||0),0); const runYards = runs.reduce((s,p)=>s+(parseFloat(p.yardsGained)||0),0); const passYards = passes.reduce((s,p)=>s+(parseFloat(p.yardsGained)||0),0); const formations = {}; filtered.forEach(p => { const f=p.offFormation||'Unknown'; if(!formations[f]) formations[f]={total:0,runs:0,passes:0,yards:0,directions:{},results:{}}; formations[f].total++; formations[f].yards+=parseFloat(p.yardsGained)||0; if(['run','rpo'].some(t=>p.playType?.toLowerCase().includes(t))) formations[f].runs++; else formations[f].passes++; const dir=p.direction||'Unknown'; formations[f].directions[dir]=(formations[f].directions[dir]||0)+1; }); const downDist = {}; filtered.forEach(p => { const d=p.down||'?'; const dist=parseFloat(p.distance)||0; let bucket = dist<=3?'Short (1-3)':dist<=6?'Medium (4-6)':dist<=10?'Long (7-10)':'Very Long (10+)'; const key=`${d}-${bucket}`; if(!downDist[key]) downDist[key]={down:d,dist:bucket,total:0,runs:0,passes:0,yards:0,successes:0}; downDist[key].total++; downDist[key].yards+=parseFloat(p.yardsGained)||0; if(['run','rpo'].some(t=>p.playType?.toLowerCase().includes(t))) downDist[key].runs++; else downDist[key].passes++; if(parseFloat(p.yardsGained)>=parseFloat(p.distance)*0.5) downDist[key].successes++; }); const playerStats = {}; filtered.forEach(p => { const name=p.ballCarrier||'Unknown'; if(!playerStats[name]) playerStats[name]={total:0,runs:0,passes:0,yards:0,tds:0,directions:{},formations:{},playTypes:{}}; playerStats[name].total++; playerStats[name].yards+=parseFloat(p.yardsGained)||0; if(p.result?.toLowerCase().includes('touchdown')) playerStats[name].tds++; if(['run','rpo'].some(t=>p.playType?.toLowerCase().includes(t))) playerStats[name].runs++; else playerStats[name].passes++; const dir=p.direction||'Unknown'; playerStats[name].directions[dir]=(playerStats[name].directions[dir]||0)+1; const form=p.offFormation||'Unknown'; playerStats[name].formations[form]=(playerStats[name].formations[form]||0)+1; }); const fieldPos = {}; filtered.forEach(p => { const yl=parseInt(p.yardLine)||0; let zone = yl<=20?'Red Zone (1-20)':yl<=40?'Mid Field (21-40)':'Own Territory (41+)'; if(!fieldPos[zone]) fieldPos[zone]={total:0,runs:0,passes:0,yards:0}; fieldPos[zone].total++; fieldPos[zone].yards+=parseFloat(p.yardsGained)||0; if(['run','rpo'].some(t=>p.playType?.toLowerCase().includes(t))) fieldPos[zone].runs++; else fieldPos[zone].passes++; }); const quarters = {}; filtered.forEach(p => { const q=p.quarter||'?'; if(!quarters[q]) quarters[q]={total:0,runs:0,passes:0,yards:0}; quarters[q].total++; quarters[q].yards+=parseFloat(p.yardsGained)||0; if(['run','rpo'].some(t=>p.playType?.toLowerCase().includes(t))) quarters[q].runs++; else quarters[q].passes++; }); return { total, runs:runs.length, passes:passes.length, totalYards, runYards, passYards, formations, downDist, playerStats, fieldPos, quarters, avgYards:(totalYards/total).toFixed(1), avgRunYards:runs.length?(runYards/runs.length).toFixed(1):'0', avgPassYards:passes.length?(passYards/passes.length).toFixed(1):'0' }; }, [filtered]); // Export XLSX const exportXLSX = () => { if(!analytics) return; const wb = XLSX.utils.book_new(); const rawData = plays.map((p,i)=>({'#':i+1,Quarter:p.quarter,Down:p.down,Distance:p.distance,'Yard Line':p.yardLine,Hash:p.hash,Formation:p.offFormation,'Def Formation':p.defFormation,Personnel:p.personnel,'Play Type':p.playType,Direction:p.direction,Result:p.result,'Yards Gained':parseFloat(p.yardsGained)||0,'Ball Carrier':p.ballCarrier,Notes:p.notes})); const ws1=XLSX.utils.json_to_sheet(rawData); ws1['!cols']=Array(15).fill({wch:15}); XLSX.utils.book_append_sheet(wb,ws1,'All Plays'); const formData=Object.entries(analytics.formations).sort((a,b)=>b[1].total-a[1].total).map(([f,d])=>({Formation:f,'Total Plays':d.total,Runs:d.runs,Passes:d.passes,'Run %':pct(d.runs,d.total)+'%','Pass %':pct(d.passes,d.total)+'%','Total Yards':d.yards,'Avg Yards':(d.yards/d.total).toFixed(1),'Top Direction':Object.entries(d.directions).sort((a,b)=>b[1]-a[1])[0]?.[0]||'N/A'})); const ws2=XLSX.utils.json_to_sheet(formData); ws2['!cols']=Array(9).fill({wch:16}); XLSX.utils.book_append_sheet(wb,ws2,'Formation Tendencies'); const ddData=Object.values(analytics.downDist).sort((a,b)=>a.down-b.down||a.dist.localeCompare(b.dist)).map(d=>({Down:d.down,Distance:d.dist,'Total Plays':d.total,Runs:d.runs,Passes:d.passes,'Run %':pct(d.runs,d.total)+'%','Pass %':pct(d.passes,d.total)+'%','Avg Yards':(d.yards/d.total).toFixed(1),'Success Rate':pct(d.successes,d.total)+'%'})); const ws3=XLSX.utils.json_to_sheet(ddData); ws3['!cols']=Array(9).fill({wch:16}); XLSX.utils.book_append_sheet(wb,ws3,'Down & Distance'); const plData=Object.entries(analytics.playerStats).filter(([n])=>n!=='Unknown').sort((a,b)=>b[1].total-a[1].total).map(([name,d])=>({Player:name,Touches:d.total,Runs:d.runs,'Pass Targets':d.passes,'Total Yards':d.yards,'Avg Yards':(d.yards/d.total).toFixed(1),TDs:d.tds,'Top Direction':Object.entries(d.directions).sort((a,b)=>b[1]-a[1])[0]?.[0]||'N/A','Top Formation':Object.entries(d.formations).sort((a,b)=>b[1]-a[1])[0]?.[0]||'N/A'})); const ws4=XLSX.utils.json_to_sheet(plData); ws4['!cols']=Array(9).fill({wch:16}); XLSX.utils.book_append_sheet(wb,ws4,'Player Tendencies'); const predData=Object.values(analytics.downDist).sort((a,b)=>a.down-b.down).map(d=>{const rp=pct(d.runs,d.total);const pp=pct(d.passes,d.total);return{Down:d.down,Distance:d.dist,'Likely Run%':rp+'%','Likely Pass%':pp+'%',Prediction:rp>pp?'RUN':'PASS',Confidence:Math.abs(rp-pp)>30?'High':Math.abs(rp-pp)>15?'Medium':'Low','Avg Yards':(d.yards/d.total).toFixed(1)};}); const ws5=XLSX.utils.json_to_sheet(predData); ws5['!cols']=Array(7).fill({wch:16}); XLSX.utils.book_append_sheet(wb,ws5,'Run-Pass Predictions'); if(players.length) { const profData=players.map(p=>({Name:p.name,'#':p.number,Position:p.position,Height:p.height,Weight:p.weight,Grade:p.grade,Strengths:p.strengths,Weaknesses:p.weaknesses,Tendencies:p.tendencies,Notes:p.notes})); const ws6=XLSX.utils.json_to_sheet(profData); ws6['!cols']=Array(10).fill({wch:18}); XLSX.utils.book_append_sheet(wb,ws6,'Player Profiles'); } XLSX.writeFile(wb, `${teamName.replace(/\s/g,'_')}_Film_Analysis${gameName?'_'+gameName.replace(/\s/g,'_'):''}.xlsx`); }; const exportTemplate = () => { const csv = ['Play #,Quarter,Down,Distance,Yard Line,Hash,Off Formation,Def Formation,Personnel,Play Type,Direction,Result,Yards Gained,Ball Carrier,Notes','1,1,1,10,35,Middle,Shotgun,4-3,11 (1RB 1TE),Run,Left,1st Down,6,Smith,Inside Zone'].join('\n'); const blob=new Blob([csv],{type:'text/csv'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='film_template.csv'; a.click(); }; // Tendency Bar const TendencyBar = ({runs,passes,total}) => (
{runs>0&&
{pct(runs,total)}%
} {passes>0&&
{pct(passes,total)}%
}
); // Filter Bar const FilterBar = () => { const allFormations=[...new Set(plays.map(p=>p.offFormation).filter(Boolean))]; return (
Filters: {filtered.length} of {plays.length} plays
); }; // Filtered players for Media tab const filteredPlayers = useMemo(() => { return players.filter(p => { if(playerFilter!=='All' && p.position!==playerFilter) return false; if(playerSearch && !p.name.toLowerCase().includes(playerSearch.toLowerCase()) && !p.number.includes(playerSearch)) return false; return true; }); }, [players, playerFilter, playerSearch]); // ========== LOGIN GATE ========== if (!user) return ; const tabs = [ {id:'import', label:'Import / Entry'}, {id:'dashboard', label:'Dashboard'}, {id:'formations', label:'Formations'}, {id:'downDist', label:'Down & Distance'}, {id:'playerTendencies', label:'Player Tendencies'}, {id:'predictions', label:'Predictions'}, {id:'media', label:'๐ŸŽฌ Media & Profiles', isMedia:true}, {id:'plays', label:'Play Log'}, {id:'pricing', label:'๐Ÿ’ฐ Pricing'} ]; return (
{/* HEADER */}
๐Ÿˆ

Sideline Analyst

High School Scouting & Tendency Analysis
{plays.length>0 && } {user.picture && }
{user.name}
{user.email}
{/* TABS */}
{tabs.map(t => )}
{plays.length>0 && !['import','media'].includes(activeTab) && } {/* ====== IMPORT / ENTRY TAB ====== */} {activeTab==='import' && (

Team Info

setTeamName(e.target.value)} placeholder="e.g. Lincoln High" />
setGameName(e.target.value)} placeholder="e.g. Week 3 vs Central" />

Quick Stats

{plays.length}
Total Plays
{analytics?.runs||0}
Runs
{analytics?.passes||0}
Passes

Import Play Data (CSV / Hudl Export)

fileRef.current.click()} onDrop={handleDrop} onDragOver={e=>{e.preventDefault();e.currentTarget.classList.add('dragover')}} onDragLeave={e=>e.currentTarget.classList.remove('dragover')}>
๐Ÿ“

Drop your CSV file here or click to browse

Supports Hudl exports, custom CSVs, and common play-by-play formats

handleFile(e.target.files[0])} />

{editIdx!==null?'Edit Play':'Log a Play Manually'}

{editIdx!==null && }
setNewPlay(p=>({...p,distance:e.target.value}))} />
setNewPlay(p=>({...p,yardLine:e.target.value}))} />
setNewPlay(p=>({...p,yardsGained:e.target.value}))} />
setNewPlay(p=>({...p,ballCarrier:e.target.value}))} placeholder="#22 Smith" />
setNewPlay(p=>({...p,notes:e.target.value}))} placeholder="Inside zone..." />
)} {/* ====== DASHBOARD TAB ====== */} {activeTab==='dashboard' && (
{!analytics ?
๐Ÿ“Š

No play data yet

Import a CSV or log plays manually

: (<>
{analytics.total}
Total Plays
{analytics.totalYards}
Total Yards
{analytics.avgYards}
Avg Yards/Play
{pct(analytics.runs,analytics.total)}% / {pct(analytics.passes,analytics.total)}%
Run / Pass Split

Run vs Pass

{analytics.runs}
Runs ({pct(analytics.runs,analytics.total)}%)
{analytics.passes}
Passes ({pct(analytics.passes,analytics.total)}%)
Avg Run: {analytics.avgRunYards}
Avg Pass: {analytics.avgPassYards}

By Quarter

{Object.entries(analytics.quarters).sort().map(([q,d])=>())}
QTRPlaysRun%Pass%Avg
Q{q}{d.total}{pct(d.runs,d.total)}%{pct(d.passes,d.total)}%{(d.yards/d.total).toFixed(1)}

Top Formations

{Object.entries(analytics.formations).sort((a,b)=>b[1].total-a[1].total).slice(0,8).map(([f,d])=>())}
FormationPlaysRun/PassAvgTop Dir
{f}{d.total}{(d.yards/d.total).toFixed(1)}{Object.entries(d.directions).sort((a,b)=>b[1]-a[1])[0]?.[0]||'-'}
)}
)} {/* ====== FORMATIONS TAB ====== */} {activeTab==='formations' && (
{!analytics ?
๐Ÿ“‹

No data

: (<> {Object.entries(analytics.formations).sort((a,b)=>b[1].total-a[1].total).map(([f,d])=>(

{f}

{d.total} plays ({pct(d.total,analytics.total)}%)

Run/Pass

{d.runs} ({pct(d.runs,d.total)}%)
Runs
{d.passes} ({pct(d.passes,d.total)}%)
Passes

Direction

{Object.entries(d.directions).sort((a,b)=>b[1]-a[1]).map(([dir,cnt])=>(
{dir}
{cnt} ({pct(cnt,d.total)}%)
))}

Stats

{(d.yards/d.total).toFixed(1)}
Avg Yards
{d.yards}
Total Yards
))} )}
)} {/* ====== DOWN & DISTANCE TAB ====== */} {activeTab==='downDist' && (
{!analytics ?
๐Ÿ“

No data

: (<>

Down & Distance Tendencies

{Object.values(analytics.downDist).sort((a,b)=>a.down-b.down||a.dist.localeCompare(b.dist)).map((d,i)=>())}
DownDistancePlaysRun/PassRun%Pass%AvgSuccess%
{d.down}{d.dist}{d.total}{pct(d.runs,d.total)}%{pct(d.passes,d.total)}%{(d.yards/d.total).toFixed(1)}{pct(d.successes,d.total)}%

Heat Map: Run % by Down & Distance

{['1','2','3','4'].map(down=>({['Short (1-3)','Medium (4-6)','Long (7-10)','Very Long (10+)'].map(dist=>{const key=`${down}-${dist}`;const d=analytics.downDist[key];const runP=d?pct(d.runs,d.total):'-';return;})}))}
Short (1-3)Medium (4-6)Long (7-10)Very Long (10+)
Down {down}
{d?`${runP}% Run (${d.total}pl)`:'-'}

Field Position

{Object.entries(analytics.fieldPos).map(([zone,d])=>())}
ZonePlaysRun/PassRun%Avg
{zone}{d.total}{pct(d.runs,d.total)}%{(d.yards/d.total).toFixed(1)}
)}
)} {/* ====== PLAYER TENDENCIES TAB ====== */} {activeTab==='playerTendencies' && (
{!analytics ?
๐Ÿƒ

No data

: (<> {Object.entries(analytics.playerStats).filter(([n])=>n!=='Unknown').sort((a,b)=>b[1].total-a[1].total).map(([name,d])=>(

{name}

{d.runs} carries{d.passes} targets{d.tds>0&&{d.tds} TDs}

Usage

{d.total}
Touches
{(d.yards/d.total).toFixed(1)}
Avg Yds/Touch

Direction

{Object.entries(d.directions).sort((a,b)=>b[1]-a[1]).map(([dir,cnt])=>(
{dir}
{cnt} ({pct(cnt,d.total)}%)
))}

Formation

{Object.entries(d.formations).sort((a,b)=>b[1]-a[1]).slice(0,5).map(([form,cnt])=>(
{form}
{cnt}
))}
))} {Object.keys(analytics.playerStats).filter(n=>n!=='Unknown').length===0&&

No player names

Add "Ball Carrier" data to see breakdowns

} )}
)} {/* ====== PREDICTIONS TAB ====== */} {activeTab==='predictions' && (
{!analytics ?
๐Ÿ”ฎ

No data

: (<>

Run/Pass Prediction Matrix

Based on historical tendencies โ€” anticipate play calls by situation.

{Object.values(analytics.downDist).sort((a,b)=>a.down-b.down||a.dist.localeCompare(b.dist)).map((d,i)=>{const rp=pct(d.runs,d.total);const pp=pct(d.passes,d.total);const pred=rp>pp?'RUN':'PASS';const diff=Math.abs(rp-pp);const conf=diff>30?'High':diff>15?'Medium':'Low';return();})}
DownDistanceSamplePredictionConfidenceRun%Pass%Avg
{d.down}{d.dist}{d.total}{pred}{conf}{rp}%{pp}%{(d.yards/d.total).toFixed(1)}

Formation Predictions

{Object.entries(analytics.formations).sort((a,b)=>b[1].total-a[1].total).map(([f,d])=>{const rp=pct(d.runs,d.total);const pp=pct(d.passes,d.total);const pred=rp>pp?'RUN':'PASS';const diff=Math.abs(rp-pp);const conf=diff>30?'High':diff>15?'Medium':'Low';const topDir=Object.entries(d.directions).sort((a,b)=>b[1]-a[1])[0];return();})}
FormationPredictionConfidenceLikely DirectionExp. Yards
{f}{pred} ({Math.max(rp,pp)}%){conf}{topDir?`${topDir[0]} (${pct(topDir[1],d.total)}%)`:'-'}{(d.yards/d.total).toFixed(1)}
)}
)} {/* ====== MEDIA & PROFILES TAB ====== */} {activeTab==='media' && (
{/* Player Profiles Section */}

๐ŸŽฏ Player Profiles ({players.length})

{players.length > 0 && (
{[...new Set(players.map(p=>p.position))].map(pos=>( ))}
setPlayerSearch(e.target.value)} style={{width:200}} />
)} {filteredPlayers.length > 0 ? (
{filteredPlayers.map((p,idx) => { const origIdx = players.indexOf(p); const playerPlays = plays.filter(pl => pl.ballCarrier?.toLowerCase().includes(p.name?.split(' ')[1]?.toLowerCase()||'___')); const totalYds = playerPlays.reduce((s,pl)=>s+(parseFloat(pl.yardsGained)||0),0); const playerVids = videos.filter(v => v.playerName === p.name); return (
#{p.number}
{p.name}
{p.position}
Grade{p.grade||'-'}
Size{p.height} / {p.weight} lbs
{playerPlays.length>0 &&
Film Touches{playerPlays.length} ({totalYds} yds)
} {playerVids.length>0 &&
Video Clips{playerVids.length} clips
} {p.tendencies &&
Tendencies
{p.tendencies}
}
{p.strengths && p.strengths.split(',').map((s,i)=>{s.trim()})} {p.weaknesses && p.weaknesses.split(',').map((s,i)=>{s.trim()})}
); })}
) : (
๐Ÿ‘ค

{players.length?'No matching players':'No player profiles yet'}

Click "+ Add Player" to create scouting profiles

)}
{/* Video Film Section */}

๐ŸŽฌ Film Clips ({videos.length})

{videos.length > 0 ? (
{videos.map((v,idx) => { const parsed = parseVideoUrl(v.url); return (
{parsed ? ( ) : (
๐ŸŽฌNo preview availableOpen Link
)}

{v.title}

{v.playerName && {v.playerName}} {v.playType && {v.playType}}
{v.notes &&

{v.notes}

} {v.tags &&
{v.tags.split(',').map((t,i)=>{t.trim()})}
}
Added by {v.addedBy} ยท {v.addedAt}
{v.url && Open โ†—}
); })}
) : (
๐ŸŽฌ

No film clips yet

Add YouTube or Hudl video links to build your film library

)}
)} {activeTab==='pricing' && (

๐Ÿ’ฐ Choose Your Plan

Unlock the full power of Sideline Analyst for your team

Scout

For individual coaches

Free
Forever
โœ“ Up to 50 plays per game
โœ“ Basic formation analysis
โœ“ Down & distance tendencies
โœ“ CSV import
โœ• Player tendency profiles
โœ• Prediction engine
โœ• Excel export
โœ• Media & video profiles
Varsity

For coaching staffs

$19/mo
per coaching staff
โœ“ Unlimited plays
โœ“ All formation analysis
โœ“ Down & distance tendencies
โœ“ Hudl CSV + manual import
โœ“ Player tendency profiles
โœ“ Run/pass prediction engine
โœ“ Excel export (all sheets)
โœ“ Media & video profiles
All-Conference

For programs & districts

$49/mo
up to 10 coaching staffs
โœ“ Everything in Varsity
โœ“ Multi-team management
โœ“ Shared scouting library
โœ“ Priority support
โœ“ Custom branding
โœ“ Season-over-season trends
โœ“ Advanced AI scouting reports
โœ“ API access
)} {/* ====== PLAY LOG TAB ====== */} {activeTab==='plays' && (

Play Log ({filtered.length})

{plays.length>0&&}
{filtered.map((p,idx)=>{const oi=plays.indexOf(p);return();})}
#QDnDistYdLnFormationTypeDirResultYdsPlayer
{p.playNum}{p.quarter}{p.down}{p.distance}{p.yardLine}{p.offFormation}p.playType?.toLowerCase().includes(t))?'badge-run':'badge-pass'}`}>{p.playType}{p.direction}{p.result}0?'var(--accent2)':(parseFloat(p.yardsGained)||0)<0?'var(--red)':'var(--text2)',fontWeight:600}}>{p.yardsGained}{p.ballCarrier}
)} {/* ====== PLAYER MODAL ====== */} {showPlayerModal && (
setShowPlayerModal(false)}>
e.stopPropagation()}>

{editingPlayer!==null?'Edit':'Add'} Player Profile

setPlayerForm(p=>({...p,name:e.target.value}))} placeholder="Marcus Johnson" />
setPlayerForm(p=>({...p,number:e.target.value}))} placeholder="22" />
setPlayerForm(p=>({...p,grade:e.target.value}))} placeholder="Junior" />
setPlayerForm(p=>({...p,height:e.target.value}))} placeholder="5'10"" />
setPlayerForm(p=>({...p,weight:e.target.value}))} placeholder="185" />
setPlayerForm(p=>({...p,strengths:e.target.value}))} placeholder="Vision, burst, hngds" />
setPlayerForm(p=>({...p,weaknesses:e.target.value}))} placeholder="Pass blocking, top speed" />