Boston Advantage 2014 Select 1 — Game Tracker
https://unpkg.com/react@18/umd/react.production.min.js
https://unpkg.com/react-dom@18/umd/react-dom.production.min.js
https://unpkg.com/@babel/standalone/babel.min.js
https://cdn.tailwindcss.com
function App() {
const TEAM_STATS = [
“Goal Against”,
“BA Shot on Goal”,
“Opponent Shot on Goal”,
“Positive/Aggressive Play”,
“Selfish Play/Turnover”,
“BA Blocked Shot”,
“Opponent Blocked Shot”,
“Smart Pass/Headman Play”,
“Overhandle/Late Decision”
];
const PENALTY_SEVERITIES = [
{ key: “Dumb”, label: “Dumb Penalty”, color: “red” },
{ key: “Mistake”, label: “Mistake Penalty”, color: “yellow” },
{ key: “Standard”, label: “Standard Penalty”, color: “green” }
];
const defaultRoster = [
{ number: “1”, name: “Jameson Dzialo”, active: true },
{ number: “8”, name: “Jackson Stevens”, active: true },
{ number: “9”, name: “Rocco Sorrentino”, active: true },
{ number: “11”, name: “Cam McNamara”, active: true },
{ number: “16”, name: “Ethan Hanson”, active: true },
{ number: “17”, name: “Griffin Shearing”, active: true },
{ number: “21”, name: “Cam Thompson”, active: true },
{ number: “22”, name: “Kyle Pavlu”, active: true },
{ number: “33”, name: “Cole McIntosh”, active: true },
{ number: “35”, name: “Nolan Cox”, active: true },
{ number: “37”, name: “Chris Murphy”, active: true },
{ number: “63”, name: “Zac Bosse”, active: true },
{ number: “82”, name: “Liam Larkin”, active: true },
{ number: “86”, name: “Shane Savastano”, active: true },
{ number: “88”, name: “Ryan Matheny”, active: true },
{ number: “93”, name: “Xavier Garrett”, active: true },
{ number: “97”, name: “Joey Pericolo”, active: true }
];
const [screen, setScreen] = React.useState(“tracker”);
const [roster, setRoster] = React.useState(defaultRoster);
const [period, setPeriod] = React.useState(“1”);
const [log, setLog] = React.useState([]);
const [flashMsg, setFlashMsg] = React.useState(“”);
const [playerModal, setPlayerModal] = React.useState(null);
const [syncUrl, setSyncUrl] = React.useState(“”);
const [syncStatus, setSyncStatus] = React.useState(“Not connected”);
const [showNewGameConfirm, setShowNewGameConfirm] = React.useState(false);
const [newGameStep, setNewGameStep] = React.useState(“roster”);
const [gaModalOpen, setGaModalOpen] = React.useState(false);
const [gaOnIce, setGaOnIce] = React.useState([]);
const [gaGoalie, setGaGoalie] = React.useState(null);
const [gameInfo, setGameInfo] = React.useState({ date: new Date().toISOString().slice(0, 10), opponent: “” });
const [newGameDate, setNewGameDate] = React.useState(new Date().toISOString().slice(0, 10));
const [newGameOpponent, setNewGameOpponent] = React.useState(“”);
const [newGameError, setNewGameError] = React.useState(“”);
const [notes, setNotes] = React.useState([]);
const [noteText, setNoteText] = React.useState(“”);
const [noteTimeRemaining, setNoteTimeRemaining] = React.useState(“”);
const [penaltyModalOpen, setPenaltyModalOpen] = React.useState(false);
const [penaltyPlayer, setPenaltyPlayer] = React.useState(null);
const [penaltySeverity, setPenaltySeverity] = React.useState(null);
const [gameHistory, setGameHistory] = React.useState(() => {
try {
const stored = window.localStorage.getItem(“ba_game_history”);
return stored ? JSON.parse(stored) : [];
} catch (e) {
return [];
}
});
const [gameOverModalOpen, setGameOverModalOpen] = React.useState(false);
const [finalScoreBA, setFinalScoreBA] = React.useState(“”);
const [finalScoreOpp, setFinalScoreOpp] = React.useState(“”);
const [gameOverError, setGameOverError] = React.useState(“”);
const [expandedGameId, setExpandedGameId] = React.useState(null);
const [editingGameId, setEditingGameId] = React.useState(null);
const [deleteConfirmGameId, setDeleteConfirmGameId] = React.useState(null);
React.useEffect(() => {
try {
window.localStorage.setItem(“ba_game_history”, JSON.stringify(gameHistory));
} catch (e) {}
}, [gameHistory]);
const GOALIES = [“Nolan Cox”, “Jameson Dzialo”];
const flashRef = React.useRef(null);
function showFlash(msg) {
setFlashMsg(msg);
if (flashRef.current) clearTimeout(flashRef.current);
flashRef.current = setTimeout(() => setFlashMsg(“”), 1200);
}
function logEvent(player, stat, groupId) {
const gid = groupId || `${Date.now()}-${Math.random()}`;
const entry = {
id: Date.now() + Math.random(),
groupId: gid,
time: new Date().toLocaleTimeString(),
timestamp: new Date().toISOString(),
gameDate: gameInfo.date,
opponent: gameInfo.opponent,
period,
player: player || “TEAM”,
stat
};
setLog(prev => [entry, …prev]);
showFlash(`${stat}${player ? ” — ” + player : “”} (P${period})`);
if (syncUrl.trim()) {
fetch(syncUrl.trim(), {
method: “POST”,
mode: “no-cors”,
headers: { “Content-Type”: “text/plain;charset=utf-8” },
body: JSON.stringify(entry)
})
.then(() => setSyncStatus(“Last event sent to Google Sheets”))
.catch(() => setSyncStatus(“Could not send — check the sync URL”));
}
return gid;
}
function undoLast() {
if (log.length === 0) {
showFlash(“Nothing to undo”);
return;
}
const lastGroupId = log[0].groupId;
const undone = log.filter(e => e.groupId === lastGroupId);
setLog(prev => prev.filter(e => e.groupId !== lastGroupId));
showFlash(`Undid: ${undone[0].stat}${undone[0].player !== “TEAM” ? ” — ” + undone[0].player : “”}`);
if (syncUrl.trim()) {
fetch(syncUrl.trim(), {
method: “POST”,
mode: “no-cors”,
headers: { “Content-Type”: “text/plain;charset=utf-8” },
body: JSON.stringify({ action: “undo”, groupId: lastGroupId, entries: undone })
}).catch(() => {});
}
}
function handleTeamStatTap(stat) {
if (stat === “Goal Against”) {
setGaOnIce([]);
setGaGoalie(null);
setGaModalOpen(true);
return;
}
logEvent(null, stat);
}
function toggleOnIce(name) {
setGaOnIce(prev =>
prev.includes(name) ? prev.filter(n => n !== name) : […prev, name]
);
}
function confirmGoalAgainst() {
const gid = `${Date.now()}-${Math.random()}`;
logEvent(null, “Goal Against”, gid);
gaOnIce.forEach(name => logEvent(name, “On Ice For GA”, gid));
if (gaGoalie) {
logEvent(gaGoalie, “Goal Against – Goalie”, gid);
}
setGaModalOpen(false);
setGaOnIce([]);
setGaGoalie(null);
}
function handleGoalAssistTap(stat) {
setPlayerModal(stat);
}
function openPenaltyModal() {
setPenaltyPlayer(null);
setPenaltySeverity(null);
setPenaltyModalOpen(true);
}
function confirmPenalty() {
if (!penaltyPlayer || !penaltySeverity) {
showFlash(“Pick a player and a severity”);
return;
}
logEvent(penaltyPlayer, `Penalty (${penaltySeverity})`);
setPenaltyModalOpen(false);
setPenaltyPlayer(null);
setPenaltySeverity(null);
}
function choosePlayer(playerName) {
logEvent(playerName, playerModal);
setPlayerModal(null);
}
function deleteLogEntry(id) {
setLog(prev => prev.filter(e => e.id !== id));
}
function addNote() {
if (!noteText.trim()) {
showFlash(“Enter a note first”);
return;
}
const note = {
id: Date.now() + Math.random(),
time: new Date().toLocaleTimeString(),
timestamp: new Date().toISOString(),
gameDate: gameInfo.date,
opponent: gameInfo.opponent,
period,
timeRemaining: noteTimeRemaining.trim(),
text: noteText.trim()
};
setNotes(prev => [note, …prev]);
setNoteText(“”);
setNoteTimeRemaining(“”);
showFlash(“Note added”);
if (syncUrl.trim()) {
fetch(syncUrl.trim(), {
method: “POST”,
mode: “no-cors”,
headers: { “Content-Type”: “text/plain;charset=utf-8” },
body: JSON.stringify({ action: “note”, …note })
})
.then(() => setSyncStatus(“Note sent to Google Sheets”))
.catch(() => setSyncStatus(“Could not send — check the sync URL”));
}
}
function deleteNote(id) {
setNotes(prev => prev.filter(n => n.id !== id));
}
function startNewGame() {
if (!newGameDate.trim() || !newGameOpponent.trim()) {
setNewGameError(“Please enter both a date and an opponent.”);
return;
}
const completedGame = {
action: “new_game”,
completedAt: new Date().toISOString(),
gameDate: gameInfo.date,
opponent: gameInfo.opponent,
roster: activeRoster,
events: log,
notes: notes
};
if (syncUrl.trim()) {
fetch(syncUrl.trim(), {
method: “POST”,
mode: “no-cors”,
headers: { “Content-Type”: “text/plain;charset=utf-8” },
body: JSON.stringify(completedGame)
})
.then(() => setSyncStatus(“Game summary sent to Google Sheets”))
.catch(() => setSyncStatus(“Could not send summary — check the sync URL”));
}
if (log.length > 0) {
setGameHistory(prev => [
…prev,
{
id: Date.now() + Math.random(),
gameDate: gameInfo.date,
opponent: gameInfo.opponent,
events: log,
notes: notes,
roster: activeRoster,
finalized: false,
baScore: null,
oppScore: null
}
]);
}
setLog([]);
setNotes([]);
setPeriod(“1”);
setFlashMsg(“”);
setShowNewGameConfirm(false);
setNewGameError(“”);
setGameInfo({ date: newGameDate.trim(), opponent: newGameOpponent.trim() });
showFlash(syncUrl.trim() ? “New game started — summary sent” : “New game started”);
}
function openGameOverModal() {
setFinalScoreBA(“”);
setFinalScoreOpp(“”);
setGameOverError(“”);
setGameOverModalOpen(true);
}
function confirmGameOver() {
if (finalScoreBA.trim() === “” || finalScoreOpp.trim() === “”) {
setGameOverError(“Please enter both final scores.”);
return;
}
const baScore = parseInt(finalScoreBA, 10);
const oppScore = parseInt(finalScoreOpp, 10);
if (isNaN(baScore) || isNaN(oppScore) || baScore < 0 || oppScore […prev, finishedGame]);
if (syncUrl.trim()) {
fetch(syncUrl.trim(), {
method: “POST”,
mode: “no-cors”,
headers: { “Content-Type”: “text/plain;charset=utf-8” },
body: JSON.stringify({ action: “game_over”, …finishedGame })
})
.then(() => setSyncStatus(“Final game summary sent to Google Sheets”))
.catch(() => setSyncStatus(“Could not send summary — check the sync URL”));
}
setLog([]);
setNotes([]);
setPeriod(“1”);
setFlashMsg(“”);
setGameOverModalOpen(false);
setGameOverError(“”);
setGameInfo({ date: new Date().toISOString().slice(0, 10), opponent: “” });
showFlash(`Game Over — Final ${baScore}-${oppScore} saved to Game Log`);
}
function deleteGameFromHistory(id) {
setGameHistory(prev => prev.filter(g => g.id !== id));
if (expandedGameId === id) setExpandedGameId(null);
if (editingGameId === id) setEditingGameId(null);
if (deleteConfirmGameId === id) setDeleteConfirmGameId(null);
showFlash(“Game deleted”);
}
function updateGameScoreField(id, field, value) {
setGameHistory(prev =>
prev.map(g => (g.id === id ? { …g, [field]: value === “” ? “” : parseInt(value, 10) || 0 } : g))
);
}
function deleteEventFromGame(gameId, eventId) {
setGameHistory(prev =>
prev.map(g =>
g.id === gameId ? { …g, events: g.events.filter(e => e.id !== eventId) } : g
)
);
}
function toggleExpandGame(id) {
setExpandedGameId(prev => (prev === id ? null : id));
}
function toggleEditGame(id) {
setEditingGameId(prev => (prev === id ? null : id));
}
function computeGameStats(events, roster) {
const teamStatCounts = {};
TEAM_STATS.forEach(stat => {
teamStatCounts[stat] = events.filter(e => e.stat === stat).length;
});
const playerStats = (roster || [])
.map(p => {
const goals = events.filter(e => e.stat === “Goal” && e.player === p.name).length;
const assists = events.filter(e => e.stat === “Assist” && e.player === p.name).length;
const onIceGA = events.filter(e => e.stat === “On Ice For GA” && e.player === p.name).length;
const dumb = events.filter(e => e.stat === “Penalty (Dumb)” && e.player === p.name).length;
const mistake = events.filter(e => e.stat === “Penalty (Mistake)” && e.player === p.name).length;
const standard = events.filter(e => e.stat === “Penalty (Standard)” && e.player === p.name).length;
return {
name: p.name,
number: p.number,
goals,
assists,
onIceGA,
plusMinus: goals + assists – onIceGA,
dumb,
mistake,
standard,
penaltyTotal: dumb + mistake + standard
};
})
.filter(p => p.goals + p.assists + p.onIceGA + p.penaltyTotal > 0);
return { teamStatCounts, playerStats };
}
function updateRosterField(index, field, value) {
setRoster(prev => {
const copy = […prev];
copy[index] = { …copy[index], [field]: value };
return copy;
});
}
function toggleRosterActive(index) {
setRoster(prev => {
const copy = […prev];
copy[index] = { …copy[index], active: copy[index].active === false ? true : false };
return copy;
});
}
const activeRoster = roster.filter(p => p.name && p.name.trim() !== “”);
const availableRoster = activeRoster.filter(p => p.active !== false);
const periods = [“1”, “2”, “3”, “OT”];
const allStats = […TEAM_STATS];
function plusMinusFor(playerName) {
const goals = log.filter(e => e.stat === “Goal” && e.player === playerName).length;
const assists = log.filter(e => e.stat === “Assist” && e.player === playerName).length;
const onIceGA = log.filter(e => e.stat === “On Ice For GA” && e.player === playerName).length;
return { goals, assists, onIceGA, plusMinus: goals + assists – onIceGA };
}
function countFor(stat, per) {
return log.filter(e => e.stat === stat && (per === “Total” || e.period === per)).length;
}
function penaltyCountFor(severityKey, per) {
return countFor(`Penalty (${severityKey})`, per);
}
function penaltiesByPlayer() {
return activeRoster
.map(p => ({
name: p.name,
number: p.number,
dumb: log.filter(e => e.stat === “Penalty (Dumb)” && e.player === p.name).length,
mistake: log.filter(e => e.stat === “Penalty (Mistake)” && e.player === p.name).length,
standard: log.filter(e => e.stat === “Penalty (Standard)” && e.player === p.name).length
}))
.filter(p => p.dumb + p.mistake + p.standard > 0);
}
function seasonTotalsFor(playerName) {
let goals = 0, assists = 0, onIceGA = 0;
gameHistory.forEach(g => {
g.events.forEach(e => {
if (e.player !== playerName) return;
if (e.stat === “Goal”) goals += 1;
else if (e.stat === “Assist”) assists += 1;
else if (e.stat === “On Ice For GA”) onIceGA += 1;
});
});
goals += log.filter(e => e.stat === “Goal” && e.player === playerName).length;
assists += log.filter(e => e.stat === “Assist” && e.player === playerName).length;
onIceGA += log.filter(e => e.stat === “On Ice For GA” && e.player === playerName).length;
return { goals, assists, onIceGA, plusMinus: goals + assists – onIceGA };
}
function seasonPenaltyCountFor(severityKey) {
const statName = `Penalty (${severityKey})`;
let count = 0;
gameHistory.forEach(g => {
count += g.events.filter(e => e.stat === statName).length;
});
count += log.filter(e => e.stat === statName).length;
return count;
}
const seasonGamesPlayedCount = gameHistory.length + (log.length > 0 ? 1 : 0);
const finalizedGames = gameHistory.filter(g => g.finalized).sort((a, b) => {
if (a.gameDate === b.gameDate) return 0;
return a.gameDate `${e.time},${e.period},${e.player},${e.stat}`)
.join(“\n”);
const csv = header + rows;
const blob = new Blob([csv], { type: “text/csv” });
const url = URL.createObjectURL(blob);
const a = document.createElement(“a”);
a.href = url;
a.download = “game_log.csv”;
a.click();
URL.revokeObjectURL(url);
}
const navBtn = (key, label) => (
);
return (
{gameInfo.opponent
? `vs ${gameInfo.opponent} · ${gameInfo.date}`
: “2014 SELECT 1 · GAME TRACKER”}
{navBtn(“log”, “Game Notes”)}
{navBtn(“summary”, “Stats Summary”)}
{navBtn(“gamelog”, “Game Log”)}
{navBtn(“sync”, “Sync”)}
{flashMsg && (
)}
{screen === “tracker” && (
))}
)}
{screen === “log” && (
setNoteTimeRemaining(e.target.value)}
className=”w-full border rounded-lg p-3 text-sm mb-2″
/>
setNoteText(e.target.value)}
rows={3}
className=”w-full border rounded-lg p-3 text-sm mb-2″
/>
)}
{notes.map(n => (
{n.timeRemaining ? ` · ${n.timeRemaining} remaining` : “”}
))}
)}
{log.map(entry => (
))}
)}
{screen === “summary” && (
| Stat | P1 | P2 | P3 | OT | Total |
|---|---|---|---|---|---|
| {stat} | {countFor(stat, “1”)} | {countFor(stat, “2”)} | {countFor(stat, “3”)} | {countFor(stat, “OT”)} | {countFor(stat, “Total”)} |
| Player | G | A | GA On-Ice | +/- |
|---|---|---|---|---|
|
{p.number ? `#${p.number} ` : “”} {p.name} |
{goals} | {assists} | {onIceGA} | {plusMinus > 0 ? `+${plusMinus}` : plusMinus} |
| Add players on the Roster tab to see plus/minus. | ||||
| Penalty Type | P1 | P2 | P3 | OT | Total |
|---|---|---|---|---|---|
|
{sev.label} |
{penaltyCountFor(sev.key, “1”)} | {penaltyCountFor(sev.key, “2”)} | {penaltyCountFor(sev.key, “3”)} | {penaltyCountFor(sev.key, “OT”)} | {penaltyCountFor(sev.key, “Total”)} |
| Total Penalties | {PENALTY_SEVERITIES.reduce((sum, sev) => sum + penaltyCountFor(sev.key, “1”), 0)} | {PENALTY_SEVERITIES.reduce((sum, sev) => sum + penaltyCountFor(sev.key, “2”), 0)} | {PENALTY_SEVERITIES.reduce((sum, sev) => sum + penaltyCountFor(sev.key, “3”), 0)} | {PENALTY_SEVERITIES.reduce((sum, sev) => sum + penaltyCountFor(sev.key, “OT”), 0)} | {PENALTY_SEVERITIES.reduce((sum, sev) => sum + penaltyCountFor(sev.key, “Total”), 0)} |
| Player | Dumb | Mistake | Standard | Total |
|---|---|---|---|---|
|
{p.number ? `#${p.number} ` : “”} {p.name} |
{p.dumb} | {p.mistake} | {p.standard} | {p.dumb + p.mistake + p.standard} |
| No penalties logged yet this game. | ||||
| Player | G | A | GA On-Ice | +/- |
|---|---|---|---|---|
|
{p.number ? `#${p.number} ` : “”} {p.name} |
{goals} | {assists} | {onIceGA} | {plusMinus > 0 ? `+${plusMinus}` : plusMinus} |
| Add players on the Roster tab to see season totals. | ||||
| Team Total | {activeRoster.reduce((sum, p) => sum + seasonTotalsFor(p.name).goals, 0)} | {activeRoster.reduce((sum, p) => sum + seasonTotalsFor(p.name).assists, 0)} | {activeRoster.reduce((sum, p) => sum + seasonTotalsFor(p.name).onIceGA, 0)} | {activeRoster.reduce((sum, p) => sum + seasonTotalsFor(p.name).plusMinus, 0)} |
| Penalty Type | Total |
|---|---|
|
{sev.label} |
{seasonPenaltyCountFor(sev.key)} |
| Total Penalties | {PENALTY_SEVERITIES.reduce((sum, sev) => sum + seasonPenaltyCountFor(sev.key), 0)} |
)}
{screen === “gamelog” && (
{finalizedGames.length === 0 && (
)}
const isExpanded = expandedGameId === g.id;
const isEditing = editingGameId === g.id;
const { teamStatCounts, playerStats } = computeGameStats(g.events, g.roster || activeRoster);
return (
className=”p-3 flex justify-between items-center cursor-pointer active:bg-gray-50″
>
{isExpanded && (
{deleteConfirmGameId === g.id && (
)}
{isEditing ? (
updateGameScoreField(g.id, “baScore”, e.target.value)}
className=”w-full border rounded-lg p-2 text-sm”
/>
updateGameScoreField(g.id, “oppScore”, e.target.value)}
className=”w-full border rounded-lg p-2 text-sm”
/>
) : null}
| Player | G | A | +/- | Pen (D/M/S) |
|---|---|---|---|---|
|
{p.number ? `#${p.number} ` : “”} {p.name} |
{p.goals} | {p.assists} | {p.plusMinus > 0 ? `+${p.plusMinus}` : p.plusMinus} | {p.dumb}/{p.mistake}/{p.standard} |
| No player stats recorded for this game. | ||||
| Stat | Total |
|---|---|
| {stat} | {teamStatCounts[stat]} |
{isEditing && (
))}
{g.events.length === 0 && (
)}
)}
{g.notes && g.notes.length > 0 && (
))}
)}
)}
);
})}
)}
{screen === “sync” && (
Paste the Google Apps Script web-app URL here. Every new stat tap will be added to the Game Log sheet.
{
setSyncUrl(e.target.value);
setSyncStatus(e.target.value.trim() ? “Ready to sync new events” : “Not connected”);
}}
placeholder=”https://script.google.com/macros/s/…/exec”
className=”w-full border rounded-lg p-3 text-sm”
/>
This app keeps its live display on your phone and sends each newly logged event to Google Sheets. Existing events are not re-sent.
)}
{showNewGameConfirm && newGameStep === “roster” && (
Edit names/numbers if needed, and mark each player In or Out for this game. Players marked Out won’t show up as options on the Tracker.
const isActive = p.active !== false;
return (
className=”w-12 text-center border rounded p-2 text-sm”
/>
updateRosterField(i, “name”, e.target.value)}
className=”flex-1 border rounded p-2 text-sm”
/>
);
})}
)}
{showNewGameConfirm && newGameStep === “details” && (
The current game will be summarized in Google Sheets, then the tracker will reset to Period 1.
setNewGameDate(e.target.value)}
className=”w-full border rounded-lg p-3 text-sm”
/>
setNewGameOpponent(e.target.value)}
className=”w-full border rounded-lg p-3 text-sm”
/>
{newGameError && (
)}
)}
{gameOverModalOpen && (
Enter the final score. This game will be saved to the Game Log and statistics collection will end for {gameInfo.opponent ? `vs ${gameInfo.opponent}` : “this game”}.
setFinalScoreBA(e.target.value)}
className=”w-full border rounded-lg p-3 text-sm”
placeholder=”0″
/>
setFinalScoreOpp(e.target.value)}
className=”w-full border rounded-lg p-3 text-sm”
placeholder=”0″
/>
{gameOverError && (
)}
)}
{gaModalOpen && (
))}
.filter(p => !GOALIES.includes(p.name))
.map(p => {
const selected = gaOnIce.includes(p.name);
return (
);
})}
)}
{penaltyModalOpen && (
))}
const selected = penaltySeverity === sev.key;
const baseClasses =
sev.color === “red”
? selected
? “bg-red-600 text-white border-red-600”
: “bg-red-50 text-red-700 border-red-600”
: sev.color === “yellow”
? selected
? “bg-yellow-400 text-blue-900 border-yellow-400”
: “bg-yellow-50 text-yellow-700 border-yellow-400”
: selected
? “bg-green-600 text-white border-green-600”
: “bg-green-50 text-green-700 border-green-600”;
return (
);
})}
)}
{playerModal && (
.map(p => (
))}
)}
);
}
ReactDOM.createRoot(document.getElementById(“root”)).render();
Boston Advantage 2014 Select 1 — Game Tracker
https://unpkg.com/react@18/umd/react.production.min.js
https://unpkg.com/react-dom@18/umd/react-dom.production.min.js
https://unpkg.com/@babel/standalone/babel.min.js
https://cdn.tailwindcss.com
function App() {
const TEAM_STATS = [
“Goal Against”,
“BA Shot on Goal”,
“Opponent Shot on Goal”,
“Positive/Aggressive Play”,
“Selfish Play/Turnover”,
“BA Blocked Shot”,
“Opponent Blocked Shot”,
“Smart Pass/Headman Play”,
“Overhandle/Late Decision”
];
const PENALTY_SEVERITIES = [
{ key: “Dumb”, label: “Dumb Penalty”, color: “red” },
{ key: “Mistake”, label: “Mistake Penalty”, color: “yellow” },
{ key: “Standard”, label: “Standard Penalty”, color: “green” }
];
const defaultRoster = [
{ number: “1”, name: “Jameson Dzialo”, active: true },
{ number: “8”, name: “Jackson Stevens”, active: true },
{ number: “9”, name: “Rocco Sorrentino”, active: true },
{ number: “11”, name: “Cam McNamara”, active: true },
{ number: “16”, name: “Ethan Hanson”, active: true },
{ number: “17”, name: “Griffin Shearing”, active: true },
{ number: “21”, name: “Cam Thompson”, active: true },
{ number: “22”, name: “Kyle Pavlu”, active: true },
{ number: “33”, name: “Cole McIntosh”, active: true },
{ number: “35”, name: “Nolan Cox”, active: true },
{ number: “37”, name: “Chris Murphy”, active: true },
{ number: “63”, name: “Zac Bosse”, active: true },
{ number: “82”, name: “Liam Larkin”, active: true },
{ number: “86”, name: “Shane Savastano”, active: true },
{ number: “88”, name: “Ryan Matheny”, active: true },
{ number: “93”, name: “Xavier Garrett”, active: true },
{ number: “97”, name: “Joey Pericolo”, active: true }
];
const [screen, setScreen] = React.useState(“tracker”);
const [roster, setRoster] = React.useState(defaultRoster);
const [period, setPeriod] = React.useState(“1”);
const [log, setLog] = React.useState([]);
const [flashMsg, setFlashMsg] = React.useState(“”);
const [playerModal, setPlayerModal] = React.useState(null);
const [syncUrl, setSyncUrl] = React.useState(“”);
const [syncStatus, setSyncStatus] = React.useState(“Not connected”);
const [showNewGameConfirm, setShowNewGameConfirm] = React.useState(false);
const [newGameStep, setNewGameStep] = React.useState(“roster”);
const [gaModalOpen, setGaModalOpen] = React.useState(false);
const [gaOnIce, setGaOnIce] = React.useState([]);
const [gaGoalie, setGaGoalie] = React.useState(null);
const [gameInfo, setGameInfo] = React.useState({ date: new Date().toISOString().slice(0, 10), opponent: “” });
const [newGameDate, setNewGameDate] = React.useState(new Date().toISOString().slice(0, 10));
const [newGameOpponent, setNewGameOpponent] = React.useState(“”);
const [newGameError, setNewGameError] = React.useState(“”);
const [notes, setNotes] = React.useState([]);
const [noteText, setNoteText] = React.useState(“”);
const [noteTimeRemaining, setNoteTimeRemaining] = React.useState(“”);
const [penaltyModalOpen, setPenaltyModalOpen] = React.useState(false);
const [penaltyPlayer, setPenaltyPlayer] = React.useState(null);
const [penaltySeverity, setPenaltySeverity] = React.useState(null);
const [gameHistory, setGameHistory] = React.useState(() => {
try {
const stored = window.localStorage.getItem(“ba_game_history”);
return stored ? JSON.parse(stored) : [];
} catch (e) {
return [];
}
});
const [gameOverModalOpen, setGameOverModalOpen] = React.useState(false);
const [finalScoreBA, setFinalScoreBA] = React.useState(“”);
const [finalScoreOpp, setFinalScoreOpp] = React.useState(“”);
const [gameOverError, setGameOverError] = React.useState(“”);
const [expandedGameId, setExpandedGameId] = React.useState(null);
const [editingGameId, setEditingGameId] = React.useState(null);
const [deleteConfirmGameId, setDeleteConfirmGameId] = React.useState(null);
React.useEffect(() => {
try {
window.localStorage.setItem(“ba_game_history”, JSON.stringify(gameHistory));
} catch (e) {}
}, [gameHistory]);
const GOALIES = [“Nolan Cox”, “Jameson Dzialo”];
const flashRef = React.useRef(null);
function showFlash(msg) {
setFlashMsg(msg);
if (flashRef.current) clearTimeout(flashRef.current);
flashRef.current = setTimeout(() => setFlashMsg(“”), 1200);
}
function logEvent(player, stat, groupId) {
const gid = groupId || `${Date.now()}-${Math.random()}`;
const entry = {
id: Date.now() + Math.random(),
groupId: gid,
time: new Date().toLocaleTimeString(),
timestamp: new Date().toISOString(),
gameDate: gameInfo.date,
opponent: gameInfo.opponent,
period,
player: player || “TEAM”,
stat
};
setLog(prev => [entry, …prev]);
showFlash(`${stat}${player ? ” — ” + player : “”} (P${period})`);
if (syncUrl.trim()) {
fetch(syncUrl.trim(), {
method: “POST”,
mode: “no-cors”,
headers: { “Content-Type”: “text/plain;charset=utf-8” },
body: JSON.stringify(entry)
})
.then(() => setSyncStatus(“Last event sent to Google Sheets”))
.catch(() => setSyncStatus(“Could not send — check the sync URL”));
}
return gid;
}
function undoLast() {
if (log.length === 0) {
showFlash(“Nothing to undo”);
return;
}
const lastGroupId = log[0].groupId;
const undone = log.filter(e => e.groupId === lastGroupId);
setLog(prev => prev.filter(e => e.groupId !== lastGroupId));
showFlash(`Undid: ${undone[0].stat}${undone[0].player !== “TEAM” ? ” — ” + undone[0].player : “”}`);
if (syncUrl.trim()) {
fetch(syncUrl.trim(), {
method: “POST”,
mode: “no-cors”,
headers: { “Content-Type”: “text/plain;charset=utf-8” },
body: JSON.stringify({ action: “undo”, groupId: lastGroupId, entries: undone })
}).catch(() => {});
}
}
function handleTeamStatTap(stat) {
if (stat === “Goal Against”) {
setGaOnIce([]);
setGaGoalie(null);
setGaModalOpen(true);
return;
}
logEvent(null, stat);
}
function toggleOnIce(name) {
setGaOnIce(prev =>
prev.includes(name) ? prev.filter(n => n !== name) : […prev, name]
);
}
function confirmGoalAgainst() {
const gid = `${Date.now()}-${Math.random()}`;
logEvent(null, “Goal Against”, gid);
gaOnIce.forEach(name => logEvent(name, “On Ice For GA”, gid));
if (gaGoalie) {
logEvent(gaGoalie, “Goal Against – Goalie”, gid);
}
setGaModalOpen(false);
setGaOnIce([]);
setGaGoalie(null);
}
function handleGoalAssistTap(stat) {
setPlayerModal(stat);
}
function openPenaltyModal() {
setPenaltyPlayer(null);
setPenaltySeverity(null);
setPenaltyModalOpen(true);
}
function confirmPenalty() {
if (!penaltyPlayer || !penaltySeverity) {
showFlash(“Pick a player and a severity”);
return;
}
logEvent(penaltyPlayer, `Penalty (${penaltySeverity})`);
setPenaltyModalOpen(false);
setPenaltyPlayer(null);
setPenaltySeverity(null);
}
function choosePlayer(playerName) {
logEvent(playerName, playerModal);
setPlayerModal(null);
}
function deleteLogEntry(id) {
setLog(prev => prev.filter(e => e.id !== id));
}
function addNote() {
if (!noteText.trim()) {
showFlash(“Enter a note first”);
return;
}
const note = {
id: Date.now() + Math.random(),
time: new Date().toLocaleTimeString(),
timestamp: new Date().toISOString(),
gameDate: gameInfo.date,
opponent: gameInfo.opponent,
period,
timeRemaining: noteTimeRemaining.trim(),
text: noteText.trim()
};
setNotes(prev => [note, …prev]);
setNoteText(“”);
setNoteTimeRemaining(“”);
showFlash(“Note added”);
if (syncUrl.trim()) {
fetch(syncUrl.trim(), {
method: “POST”,
mode: “no-cors”,
headers: { “Content-Type”: “text/plain;charset=utf-8” },
body: JSON.stringify({ action: “note”, …note })
})
.then(() => setSyncStatus(“Note sent to Google Sheets”))
.catch(() => setSyncStatus(“Could not send — check the sync URL”));
}
}
function deleteNote(id) {
setNotes(prev => prev.filter(n => n.id !== id));
}
function startNewGame() {
if (!newGameDate.trim() || !newGameOpponent.trim()) {
setNewGameError(“Please enter both a date and an opponent.”);
return;
}
const completedGame = {
action: “new_game”,
completedAt: new Date().toISOString(),
gameDate: gameInfo.date,
opponent: gameInfo.opponent,
roster: activeRoster,
events: log,
notes: notes
};
if (syncUrl.trim()) {
fetch(syncUrl.trim(), {
method: “POST”,
mode: “no-cors”,
headers: { “Content-Type”: “text/plain;charset=utf-8” },
body: JSON.stringify(completedGame)
})
.then(() => setSyncStatus(“Game summary sent to Google Sheets”))
.catch(() => setSyncStatus(“Could not send summary — check the sync URL”));
}
if (log.length > 0) {
setGameHistory(prev => [
…prev,
{
id: Date.now() + Math.random(),
gameDate: gameInfo.date,
opponent: gameInfo.opponent,
events: log,
notes: notes,
roster: activeRoster,
finalized: false,
baScore: null,
oppScore: null
}
]);
}
setLog([]);
setNotes([]);
setPeriod(“1”);
setFlashMsg(“”);
setShowNewGameConfirm(false);
setNewGameError(“”);
setGameInfo({ date: newGameDate.trim(), opponent: newGameOpponent.trim() });
showFlash(syncUrl.trim() ? “New game started — summary sent” : “New game started”);
}
function openGameOverModal() {
setFinalScoreBA(“”);
setFinalScoreOpp(“”);
setGameOverError(“”);
setGameOverModalOpen(true);
}
function confirmGameOver() {
if (finalScoreBA.trim() === “” || finalScoreOpp.trim() === “”) {
setGameOverError(“Please enter both final scores.”);
return;
}
const baScore = parseInt(finalScoreBA, 10);
const oppScore = parseInt(finalScoreOpp, 10);
if (isNaN(baScore) || isNaN(oppScore) || baScore < 0 || oppScore […prev, finishedGame]);
if (syncUrl.trim()) {
fetch(syncUrl.trim(), {
method: “POST”,
mode: “no-cors”,
headers: { “Content-Type”: “text/plain;charset=utf-8” },
body: JSON.stringify({ action: “game_over”, …finishedGame })
})
.then(() => setSyncStatus(“Final game summary sent to Google Sheets”))
.catch(() => setSyncStatus(“Could not send summary — check the sync URL”));
}
setLog([]);
setNotes([]);
setPeriod(“1”);
setFlashMsg(“”);
setGameOverModalOpen(false);
setGameOverError(“”);
setGameInfo({ date: new Date().toISOString().slice(0, 10), opponent: “” });
showFlash(`Game Over — Final ${baScore}-${oppScore} saved to Game Log`);
}
function deleteGameFromHistory(id) {
setGameHistory(prev => prev.filter(g => g.id !== id));
if (expandedGameId === id) setExpandedGameId(null);
if (editingGameId === id) setEditingGameId(null);
if (deleteConfirmGameId === id) setDeleteConfirmGameId(null);
showFlash(“Game deleted”);
}
function updateGameScoreField(id, field, value) {
setGameHistory(prev =>
prev.map(g => (g.id === id ? { …g, [field]: value === “” ? “” : parseInt(value, 10) || 0 } : g))
);
}
function deleteEventFromGame(gameId, eventId) {
setGameHistory(prev =>
prev.map(g =>
g.id === gameId ? { …g, events: g.events.filter(e => e.id !== eventId) } : g
)
);
}
function toggleExpandGame(id) {
setExpandedGameId(prev => (prev === id ? null : id));
}
function toggleEditGame(id) {
setEditingGameId(prev => (prev === id ? null : id));
}
function computeGameStats(events, roster) {
const teamStatCounts = {};
TEAM_STATS.forEach(stat => {
teamStatCounts[stat] = events.filter(e => e.stat === stat).length;
});
const playerStats = (roster || [])
.map(p => {
const goals = events.filter(e => e.stat === “Goal” && e.player === p.name).length;
const assists = events.filter(e => e.stat === “Assist” && e.player === p.name).length;
const onIceGA = events.filter(e => e.stat === “On Ice For GA” && e.player === p.name).length;
const dumb = events.filter(e => e.stat === “Penalty (Dumb)” && e.player === p.name).length;
const mistake = events.filter(e => e.stat === “Penalty (Mistake)” && e.player === p.name).length;
const standard = events.filter(e => e.stat === “Penalty (Standard)” && e.player === p.name).length;
return {
name: p.name,
number: p.number,
goals,
assists,
onIceGA,
plusMinus: goals + assists – onIceGA,
dumb,
mistake,
standard,
penaltyTotal: dumb + mistake + standard
};
})
.filter(p => p.goals + p.assists + p.onIceGA + p.penaltyTotal > 0);
return { teamStatCounts, playerStats };
}
function updateRosterField(index, field, value) {
setRoster(prev => {
const copy = […prev];
copy[index] = { …copy[index], [field]: value };
return copy;
});
}
function toggleRosterActive(index) {
setRoster(prev => {
const copy = […prev];
copy[index] = { …copy[index], active: copy[index].active === false ? true : false };
return copy;
});
}
const activeRoster = roster.filter(p => p.name && p.name.trim() !== “”);
const availableRoster = activeRoster.filter(p => p.active !== false);
const periods = [“1”, “2”, “3”, “OT”];
const allStats = […TEAM_STATS];
function plusMinusFor(playerName) {
const goals = log.filter(e => e.stat === “Goal” && e.player === playerName).length;
const assists = log.filter(e => e.stat === “Assist” && e.player === playerName).length;
const onIceGA = log.filter(e => e.stat === “On Ice For GA” && e.player === playerName).length;
return { goals, assists, onIceGA, plusMinus: goals + assists – onIceGA };
}
function countFor(stat, per) {
return log.filter(e => e.stat === stat && (per === “Total” || e.period === per)).length;
}
function penaltyCountFor(severityKey, per) {
return countFor(`Penalty (${severityKey})`, per);
}
function penaltiesByPlayer() {
return activeRoster
.map(p => ({
name: p.name,
number: p.number,
dumb: log.filter(e => e.stat === “Penalty (Dumb)” && e.player === p.name).length,
mistake: log.filter(e => e.stat === “Penalty (Mistake)” && e.player === p.name).length,
standard: log.filter(e => e.stat === “Penalty (Standard)” && e.player === p.name).length
}))
.filter(p => p.dumb + p.mistake + p.standard > 0);
}
function seasonTotalsFor(playerName) {
let goals = 0, assists = 0, onIceGA = 0;
gameHistory.forEach(g => {
g.events.forEach(e => {
if (e.player !== playerName) return;
if (e.stat === “Goal”) goals += 1;
else if (e.stat === “Assist”) assists += 1;
else if (e.stat === “On Ice For GA”) onIceGA += 1;
});
});
goals += log.filter(e => e.stat === “Goal” && e.player === playerName).length;
assists += log.filter(e => e.stat === “Assist” && e.player === playerName).length;
onIceGA += log.filter(e => e.stat === “On Ice For GA” && e.player === playerName).length;
return { goals, assists, onIceGA, plusMinus: goals + assists – onIceGA };
}
function seasonPenaltyCountFor(severityKey) {
const statName = `Penalty (${severityKey})`;
let count = 0;
gameHistory.forEach(g => {
count += g.events.filter(e => e.stat === statName).length;
});
count += log.filter(e => e.stat === statName).length;
return count;
}
const seasonGamesPlayedCount = gameHistory.length + (log.length > 0 ? 1 : 0);
const finalizedGames = gameHistory.filter(g => g.finalized).sort((a, b) => {
if (a.gameDate === b.gameDate) return 0;
return a.gameDate `${e.time},${e.period},${e.player},${e.stat}`)
.join(“\n”);
const csv = header + rows;
const blob = new Blob([csv], { type: “text/csv” });
const url = URL.createObjectURL(blob);
const a = document.createElement(“a”);
a.href = url;
a.download = “game_log.csv”;
a.click();
URL.revokeObjectURL(url);
}
const navBtn = (key, label) => (
);
return (
{gameInfo.opponent
? `vs ${gameInfo.opponent} · ${gameInfo.date}`
: “2014 SELECT 1 · GAME TRACKER”}
{navBtn(“log”, “Game Notes”)}
{navBtn(“summary”, “Stats Summary”)}
{navBtn(“gamelog”, “Game Log”)}
{navBtn(“sync”, “Sync”)}
{flashMsg && (
)}
{screen === “tracker” && (
))}
)}
{screen === “log” && (
setNoteTimeRemaining(e.target.value)}
className=”w-full border rounded-lg p-3 text-sm mb-2″
/>
setNoteText(e.target.value)}
rows={3}
className=”w-full border rounded-lg p-3 text-sm mb-2″
/>
)}
{notes.map(n => (
{n.timeRemaining ? ` · ${n.timeRemaining} remaining` : “”}
))}
)}
{log.map(entry => (
))}
)}
{screen === “summary” && (
| Stat | P1 | P2 | P3 | OT | Total |
|---|---|---|---|---|---|
| {stat} | {countFor(stat, “1”)} | {countFor(stat, “2”)} | {countFor(stat, “3”)} | {countFor(stat, “OT”)} | {countFor(stat, “Total”)} |
| Player | G | A | GA On-Ice | +/- |
|---|---|---|---|---|
|
{p.number ? `#${p.number} ` : “”} {p.name} |
{goals} | {assists} | {onIceGA} | {plusMinus > 0 ? `+${plusMinus}` : plusMinus} |
| Add players on the Roster tab to see plus/minus. | ||||
| Penalty Type | P1 | P2 | P3 | OT | Total |
|---|---|---|---|---|---|
|
{sev.label} |
{penaltyCountFor(sev.key, “1”)} | {penaltyCountFor(sev.key, “2”)} | {penaltyCountFor(sev.key, “3”)} | {penaltyCountFor(sev.key, “OT”)} | {penaltyCountFor(sev.key, “Total”)} |
| Total Penalties | {PENALTY_SEVERITIES.reduce((sum, sev) => sum + penaltyCountFor(sev.key, “1”), 0)} | {PENALTY_SEVERITIES.reduce((sum, sev) => sum + penaltyCountFor(sev.key, “2”), 0)} | {PENALTY_SEVERITIES.reduce((sum, sev) => sum + penaltyCountFor(sev.key, “3”), 0)} | {PENALTY_SEVERITIES.reduce((sum, sev) => sum + penaltyCountFor(sev.key, “OT”), 0)} | {PENALTY_SEVERITIES.reduce((sum, sev) => sum + penaltyCountFor(sev.key, “Total”), 0)} |
| Player | Dumb | Mistake | Standard | Total |
|---|---|---|---|---|
|
{p.number ? `#${p.number} ` : “”} {p.name} |
{p.dumb} | {p.mistake} | {p.standard} | {p.dumb + p.mistake + p.standard} |
| No penalties logged yet this game. | ||||
| Player | G | A | GA On-Ice | +/- |
|---|---|---|---|---|
|
{p.number ? `#${p.number} ` : “”} {p.name} |
{goals} | {assists} | {onIceGA} | {plusMinus > 0 ? `+${plusMinus}` : plusMinus} |
| Add players on the Roster tab to see season totals. | ||||
| Team Total | {activeRoster.reduce((sum, p) => sum + seasonTotalsFor(p.name).goals, 0)} | {activeRoster.reduce((sum, p) => sum + seasonTotalsFor(p.name).assists, 0)} | {activeRoster.reduce((sum, p) => sum + seasonTotalsFor(p.name).onIceGA, 0)} | {activeRoster.reduce((sum, p) => sum + seasonTotalsFor(p.name).plusMinus, 0)} |
| Penalty Type | Total |
|---|---|
|
{sev.label} |
{seasonPenaltyCountFor(sev.key)} |
| Total Penalties | {PENALTY_SEVERITIES.reduce((sum, sev) => sum + seasonPenaltyCountFor(sev.key), 0)} |
)}
{screen === “gamelog” && (
{finalizedGames.length === 0 && (
)}
const isExpanded = expandedGameId === g.id;
const isEditing = editingGameId === g.id;
const { teamStatCounts, playerStats } = computeGameStats(g.events, g.roster || activeRoster);
return (
className=”p-3 flex justify-between items-center cursor-pointer active:bg-gray-50″
>
{isExpanded && (
{deleteConfirmGameId === g.id && (
)}
{isEditing ? (
updateGameScoreField(g.id, “baScore”, e.target.value)}
className=”w-full border rounded-lg p-2 text-sm”
/>
updateGameScoreField(g.id, “oppScore”, e.target.value)}
className=”w-full border rounded-lg p-2 text-sm”
/>
) : null}
| Player | G | A | +/- | Pen (D/M/S) |
|---|---|---|---|---|
|
{p.number ? `#${p.number} ` : “”} {p.name} |
{p.goals} | {p.assists} | {p.plusMinus > 0 ? `+${p.plusMinus}` : p.plusMinus} | {p.dumb}/{p.mistake}/{p.standard} |
| No player stats recorded for this game. | ||||
| Stat | Total |
|---|---|
| {stat} | {teamStatCounts[stat]} |
{isEditing && (
))}
{g.events.length === 0 && (
)}
)}
{g.notes && g.notes.length > 0 && (
))}
)}
)}
);
})}
)}
{screen === “sync” && (
Paste the Google Apps Script web-app URL here. Every new stat tap will be added to the Game Log sheet.
{
setSyncUrl(e.target.value);
setSyncStatus(e.target.value.trim() ? “Ready to sync new events” : “Not connected”);
}}
placeholder=”https://script.google.com/macros/s/…/exec”
className=”w-full border rounded-lg p-3 text-sm”
/>
This app keeps its live display on your phone and sends each newly logged event to Google Sheets. Existing events are not re-sent.
)}
{showNewGameConfirm && newGameStep === “roster” && (
Edit names/numbers if needed, and mark each player In or Out for this game. Players marked Out won’t show up as options on the Tracker.
const isActive = p.active !== false;
return (
className=”w-12 text-center border rounded p-2 text-sm”
/>
updateRosterField(i, “name”, e.target.value)}
className=”flex-1 border rounded p-2 text-sm”
/>
);
})}
)}
{showNewGameConfirm && newGameStep === “details” && (
The current game will be summarized in Google Sheets, then the tracker will reset to Period 1.
setNewGameDate(e.target.value)}
className=”w-full border rounded-lg p-3 text-sm”
/>
setNewGameOpponent(e.target.value)}
className=”w-full border rounded-lg p-3 text-sm”
/>
{newGameError && (
)}
)}
{gameOverModalOpen && (
Enter the final score. This game will be saved to the Game Log and statistics collection will end for {gameInfo.opponent ? `vs ${gameInfo.opponent}` : “this game”}.
setFinalScoreBA(e.target.value)}
className=”w-full border rounded-lg p-3 text-sm”
placeholder=”0″
/>
setFinalScoreOpp(e.target.value)}
className=”w-full border rounded-lg p-3 text-sm”
placeholder=”0″
/>
{gameOverError && (
)}
)}
{gaModalOpen && (
))}
.filter(p => !GOALIES.includes(p.name))
.map(p => {
const selected = gaOnIce.includes(p.name);
return (
);
})}
)}
{penaltyModalOpen && (
))}
const selected = penaltySeverity === sev.key;
const baseClasses =
sev.color === “red”
? selected
? “bg-red-600 text-white border-red-600”
: “bg-red-50 text-red-700 border-red-600”
: sev.color === “yellow”
? selected
? “bg-yellow-400 text-blue-900 border-yellow-400”
: “bg-yellow-50 text-yellow-700 border-yellow-400”
: selected
? “bg-green-600 text-white border-green-600”
: “bg-green-50 text-green-700 border-green-600”;
return (
);
})}
)}
{playerModal && (
.map(p => (
))}
)}
);
}
ReactDOM.createRoot(document.getElementById(“root”)).render();