import React, { useEffect, useMemo, useRef, useState } from "react"; // --- Helpers --------------------------------------------------------------- const currency = (n: number) => (isFinite(n) ? n : 0).toLocaleString(undefined, { style: "currency", currency: "USD", maximumFractionDigits: 0, }); const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v)); const weekLabel = (w: number) => ({ 1: "1st", 2: "2nd", 3: "3rd", 4: "4th", 5: "5th" } as Record)[w] ?? `${w}th`; const months = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; function approxWeeksBetween( startMonthIdx: number, startWeek: number, endMonthIdx: number, endWeek: number ) { const weeksPerMonth = 4.34524; const start = startMonthIdx + (startWeek - 1) / 5; const end = endMonthIdx + (endWeek - 1) / 5; const monthsBetween = Math.max(0, end - start); return Math.max(0, Math.round(monthsBetween * weeksPerMonth + 1)); } function downloadText(filename: string, text: string) { const blob = new Blob([text], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } function NumberSlider({ label, value, onChange, min = 0, max = 100, step = 1, help, id, }: { label: string; value: number; onChange: (v: number) => void; min?: number; max?: number; step?: number; help?: string; id?: string; }) { const sliderId = id ?? label.replace(/\s+/g, "-").toLowerCase(); return (
onChange(clamp(parseFloat(e.target.value || "0"), min, max))} />
onChange(clamp(parseFloat(e.target.value || "0"), min, max))} /> {help &&

{help}

}
); } interface FieldRow { id: string; name: string; pct: number; costPerGame: number } interface PlayoffRound { id: string; name: string; games: number; umpiresPerGame: number; costPerUmpPerGame: number; ballsPerGame: number; costPerDozenBalls: number; fields: FieldRow[]; } export default function BaseballLeagueCostEstimator() { const [fixedCosts, setFixedCosts] = useState>([ { id: crypto.randomUUID(), name: "Insurance", amount: 1500 }, { id: crypto.randomUUID(), name: "Commissioner", amount: 0 }, { id: crypto.randomUUID(), name: "Umpire Admin", amount: 2000 }, { id: crypto.randomUUID(), name: "Digital", amount: 500 }, { id: crypto.randomUUID(), name: "Reserve", amount: 3000 }, { id: crypto.randomUUID(), name: "Trophies", amount: 500 }, ]); const [teams, setTeams] = useState(5); const [gamesPerTeam, setGamesPerTeam] = useState(28); const [umpiresPerGame, setUmpiresPerGame] = useState(1); const [costPerUmpPerGame, setCostPerUmpPerGame] = useState(90); const [avgTeamSize, setAvgTeamSize] = useState(22); const [ballsPerGame, setBallsPerGame] = useState(5); const [costPerDozenBalls, setCostPerDozenBalls] = useState(80); const [startMonth, setStartMonth] = useState(4); const [startWeek, setStartWeek] = useState(1); const [endMonth, setEndMonth] = useState(7); const [endWeek, setEndWeek] = useState(4); const [fields, setFields] = useState([ { id: crypto.randomUUID(), name: "Field #1", pct: 100, costPerGame: 212 }, ]); const newRound = (i: number): PlayoffRound => ({ id: crypto.randomUUID(), name: `Round ${i}`, games: 12, umpiresPerGame: 2, costPerUmpPerGame: 80, ballsPerGame: 5, costPerDozenBalls: 70, fields: [ { id: crypto.randomUUID(), name: "Field #1", pct: 100, costPerGame: 0 }, ], }); const [playoffRounds, setPlayoffRounds] = useState([newRound(1)]); const fixedCostTotal = useMemo( () => fixedCosts.reduce((sum, fc) => sum + (Number(fc.amount) || 0), 0), [fixedCosts] ); const totalGames = useMemo(() => (teams || 0) * (gamesPerTeam || 0) / 2, [teams, gamesPerTeam]); const opponentsPerTeam = useMemo(() => Math.max(0, (teams || 0) - 1), [teams]); const gamesPerOpponentExact = useMemo(() => { if (!opponentsPerTeam) return 0; return (gamesPerTeam || 0) / opponentsPerTeam; }, [gamesPerTeam, opponentsPerTeam]); const gamesPerOpponentFloor = useMemo(() => Math.floor(gamesPerOpponentExact), [gamesPerOpponentExact]); const remainderOpponents = useMemo(() => { if (!opponentsPerTeam) return 0; return (gamesPerTeam || 0) % opponentsPerTeam; }, [gamesPerTeam, opponentsPerTeam]); const gamesVsEachOther = useMemo(() => { if (!teams || teams < 2) return 0; return (gamesPerTeam || 0) / (teams - 1); }, [teams, gamesPerTeam]); const umpireCostTotal = useMemo( () => (totalGames || 0) * (umpiresPerGame || 0) * (costPerUmpPerGame || 0), [totalGames, umpiresPerGame, costPerUmpPerGame] ); const fieldsPctSum = useMemo(() => fields.reduce((s, f) => s + (Number(f.pct) || 0), 0), [fields]); const fieldRentalCostTotal = useMemo(() => { const totalPct = fieldsPctSum || 100; const normalized = fields.map((f) => ({ ...f, w: (Number(f.pct) || 0) / totalPct })); return normalized.reduce( (sum, f) => sum + (totalGames || 0) * f.w * (Number(f.costPerGame) || 0), 0 ); }, [fields, fieldsPctSum, totalGames]); const perBallCost = useMemo(() => (Number(costPerDozenBalls) || 0) / 12, [costPerDozenBalls]); const baseballsCostPerGame = useMemo(() => (Number(ballsPerGame) || 0) * perBallCost, [ballsPerGame, perBallCost]); const baseballsCostTotal = useMemo(() => (totalGames || 0) * baseballsCostPerGame, [totalGames, baseballsCostPerGame]); function pctSum(rows: FieldRow[]) { return rows.reduce((s, r) => s + (Number(r.pct) || 0), 0) || 100; } const playoffRoundCosts = useMemo(() => { return playoffRounds.map((r) => { const totalPct = pctSum(r.fields); const fieldCost = r.fields .map((f) => ((Number(f.pct) || 0) / totalPct) * (Number(f.costPerGame) || 0)) .reduce((a, b) => a + b, 0) * (Number(r.games) || 0); const umps = (Number(r.games) || 0) * (Number(r.umpiresPerGame) || 0) * (Number(r.costPerUmpPerGame) || 0); const ballCostPerGame = (Number(r.ballsPerGame) || 0) * ((Number(r.costPerDozenBalls) || 0) / 12); const balls = (Number(r.games) || 0) * ballCostPerGame; return { fieldCost, umpireCost: umps, baseballsCost: balls, games: Number(r.games) || 0 }; }); }, [playoffRounds]); const playoffTotals = useMemo(() => { return playoffRoundCosts.reduce( (acc, c) => ({ field: acc.field + c.fieldCost, umps: acc.umps + c.umpireCost, balls: acc.balls + c.baseballsCost, games: acc.games + c.games, }), { field: 0, umps: 0, balls: 0, games: 0 } ); }, [playoffRoundCosts]); const playoffLeagueCostsTotal = useMemo(() => playoffTotals.field + playoffTotals.balls, [playoffTotals]); const playoffUmpireCostTotal = useMemo(() => playoffTotals.umps, [playoffTotals]); const leagueCostsTotal = useMemo( () => fixedCostTotal + fieldRentalCostTotal + baseballsCostTotal + playoffLeagueCostsTotal, [fixedCostTotal, fieldRentalCostTotal, baseballsCostTotal, playoffLeagueCostsTotal] ); const allUmpireCostsTotal = useMemo(() => umpireCostTotal + playoffUmpireCostTotal, [umpireCostTotal, playoffUmpireCostTotal]); const grandTotal = leagueCostsTotal + allUmpireCostsTotal; const costPerTeam = useMemo(() => (teams > 0 ? grandTotal / teams : 0), [grandTotal, teams]); const costPerTeamLeague = useMemo(() => (teams > 0 ? leagueCostsTotal / teams : 0), [leagueCostsTotal, teams]); const costPerTeamUmpires = useMemo(() => (teams > 0 ? allUmpireCostsTotal / teams : 0), [allUmpireCostsTotal, teams]); const costPerPlayer = useMemo( () => (teams > 0 && avgTeamSize > 0 ? grandTotal / (teams * avgTeamSize) : 0), [grandTotal, teams, avgTeamSize] ); const approxWeeks = useMemo( () => approxWeeksBetween(startMonth, startWeek, endMonth, endWeek), [startMonth, startWeek, endMonth, endWeek] ); const gamesPerWeekPerTeam = useMemo(() => { if (!approxWeeks) return 0; return (gamesPerTeam || 0) / approxWeeks; }, [gamesPerTeam, approxWeeks]); const gamesPerWeekLeague = useMemo(() => { if (!approxWeeks) return 0; return (totalGames || 0) / approxWeeks; }, [totalGames, approxWeeks]); const addFixed = () => setFixedCosts((x) => [...x, { id: crypto.randomUUID(), name: "New Fixed Cost", amount: 0 }]); const removeFixed = (id: string) => setFixedCosts((x) => x.filter((r) => r.id !== id)); const addField = () => setFields((x) => [ ...x, { id: crypto.randomUUID(), name: `Field ${x.length + 1}`, pct: 0, costPerGame: 0 }, ]); const removeField = (id: string) => setFields((x) => x.filter((r) => r.id !== id)); const addRound = () => setPlayoffRounds((rs) => [...rs, newRound(rs.length + 1)]); const removeRound = (id: string) => setPlayoffRounds((rs) => rs.filter((r) => r.id !== id)); const updateRound = (id: string, patch: Partial) => setPlayoffRounds((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r))); const updateRoundField = (rid: string, fid: string, patch: Partial) => setPlayoffRounds((rs) => rs.map((r) => r.id !== rid ? r : { ...r, fields: r.fields.map((f) => (f.id === fid ? { ...f, ...patch } : f)) } ) ); const addRoundField = (rid: string) => setPlayoffRounds((rs) => rs.map((r) => r.id !== rid ? r : { ...r, fields: [...r.fields, { id: crypto.randomUUID(), name: `Field ${r.fields.length + 1}`, pct: 0, costPerGame: 0 }] } ) ); const removeRoundField = (rid: string, fid: string) => setPlayoffRounds((rs) => rs.map((r) => (r.id !== rid ? r : { ...r, fields: r.fields.filter((f) => f.id !== fid) })) ); const pctWarning = fieldsPctSum !== 100; type SaveShape = { version: 2; data: { fixedCosts: Array<{ name: string; amount: number }>; teams: number; gamesPerTeam: number; umpiresPerGame: number; costPerUmpPerGame: number; avgTeamSize: number; ballsPerGame: number; costPerDozenBalls: number; startMonth: number; startWeek: number; endMonth: number; endWeek: number; fields: Array<{ name: string; pct: number; costPerGame: number }>; playoffRounds: Array<{ name: string; games: number; umpiresPerGame: number; costPerUmpPerGame: number; ballsPerGame: number; costPerDozenBalls: number; fields: Array<{ name: string; pct: number; costPerGame: number }>; }>; }; }; const fileInputRef = useRef(null); const exportJSON = () => { const payload: SaveShape = { version: 2, data: { fixedCosts: fixedCosts.map(({ name, amount }) => ({ name, amount })), teams, gamesPerTeam, umpiresPerGame, costPerUmpPerGame, avgTeamSize, ballsPerGame, costPerDozenBalls, startMonth, startWeek, endMonth, endWeek, fields: fields.map(({ name, pct, costPerGame }) => ({ name, pct, costPerGame })), playoffRounds: playoffRounds.map((r) => ({ name: r.name, games: r.games, umpiresPerGame: r.umpiresPerGame, costPerUmpPerGame: r.costPerUmpPerGame, ballsPerGame: r.ballsPerGame, costPerDozenBalls: r.costPerDozenBalls, fields: r.fields.map(({ name, pct, costPerGame }) => ({ name, pct, costPerGame })), })), }, }; const ts = new Date(); const pad = (n: number) => String(n).padStart(2, "0"); const fname = `league-cost-estimator-${ts.getFullYear()}${pad(ts.getMonth()+1)}${pad(ts.getDate())}-${pad(ts.getHours())}${pad(ts.getMinutes())}.json`; downloadText(fname, JSON.stringify(payload, null, 2)); }; const applyImported = (s: SaveShape["data"]) => { setFixedCosts((s.fixedCosts || []).map(fc => ({ id: crypto.randomUUID(), ...fc }))); setTeams(s.teams ?? teams); setGamesPerTeam(s.gamesPerTeam ?? gamesPerTeam); setUmpiresPerGame(s.umpiresPerGame ?? umpiresPerGame); setCostPerUmpPerGame(s.costPerUmpPerGame ?? costPerUmpPerGame); setAvgTeamSize(s.avgTeamSize ?? avgTeamSize); setBallsPerGame(s.ballsPerGame ?? ballsPerGame); setCostPerDozenBalls(s.costPerDozenBalls ?? costPerDozenBalls); setStartMonth(s.startMonth ?? startMonth); setStartWeek(s.startWeek ?? startWeek); setEndMonth(s.endMonth ?? endMonth); setEndWeek(s.endWeek ?? endWeek); setFields((s.fields || []).map(f => ({ id: crypto.randomUUID(), ...f }))); setPlayoffRounds((s.playoffRounds || []).map((r, i) => ({ id: crypto.randomUUID(), name: r.name || `Round ${i+1}`, games: r.games ?? 0, umpiresPerGame: r.umpiresPerGame ?? 0, costPerUmpPerGame: r.costPerUmpPerGame ?? 0, ballsPerGame: r.ballsPerGame ?? 0, costPerDozenBalls: r.costPerDozenBalls ?? 0, fields: (r.fields || []).map(f => ({ id: crypto.randomUUID(), ...f })), }))); }; const importFromFile = async (file: File) => { try { const text = await file.text(); const parsed = JSON.parse(text); if (!parsed || !parsed.data) throw new Error("Invalid file format."); applyImported(parsed.data); alert("Import successful ✅"); } catch (err: any) { console.error(err); alert("Import failed: " + (err?.message || String(err))); } finally { if (fileInputRef.current) fileInputRef.current.value = ""; } }; const onClickImport = () => fileInputRef.current?.click(); const STORAGE_KEY = "league-cost-estimator:v2"; useEffect(() => { const payload: SaveShape = { version: 2, data: { fixedCosts: fixedCosts.map(({ name, amount }) => ({ name, amount })), teams, gamesPerTeam, umpiresPerGame, costPerUmpPerGame, avgTeamSize, ballsPerGame, costPerDozenBalls, startMonth, startWeek, endMonth, endWeek, fields: fields.map(({ name, pct, costPerGame }) => ({ name, pct, costPerGame })), playoffRounds: playoffRounds.map((r) => ({ name: r.name, games: r.games, umpiresPerGame: r.umpiresPerGame, costPerUmpPerGame: r.costPerUmpPerGame, ballsPerGame: r.ballsPerGame, costPerDozenBalls: r.costPerDozenBalls, fields: r.fields.map(({ name, pct, costPerGame }) => ({ name, pct, costPerGame })), })), }, }; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); } catch {} }, [fixedCosts, teams, gamesPerTeam, umpiresPerGame, costPerUmpPerGame, avgTeamSize, ballsPerGame, costPerDozenBalls, startMonth, startWeek, endMonth, endWeek, fields, playoffRounds]); useEffect(() => { try { const raw = localStorage.getItem(STORAGE_KEY); if (raw) { const parsed = JSON.parse(raw); if (parsed && parsed.data) applyImported(parsed.data); } } catch {} }, []); return (

Baseball League Cost Estimator

{ const f = e.target.files?.[0]; if (f) importFromFile(f); }} />

League Basics

Opponents per team: {opponentsPerTeam}
Games per opponent (avg): {gamesPerOpponentExact.toFixed(2)}
{opponentsPerTeam > 0 && (
Distribution (as even as possible): each team plays {remainderOpponents} opponents {gamesPerOpponentFloor + 1}× and {opponentsPerTeam - remainderOpponents} opponents {gamesPerOpponentFloor}×.
)}
Total league games computed as teams × gamesPerTeam ÷ 2.

Season Window

Approx. season length: {approxWeeks} weeks
Games/week per team: {gamesPerWeekPerTeam.toFixed(2)}
Games/week league-wide: {gamesPerWeekLeague.toFixed(2)}
Games vs each opponent/team: {gamesVsEachOther.toFixed(2)}

Fields & Game Allocation (Regular Season)

{fields.map((f) => ( ))}
Field name % of games Cost per game ($)
setFields((rows) => rows.map((r) => (r.id === f.id ? { ...r, name: e.target.value } : r)))} /> setFields((rows) => rows.map((r) => (r.id === f.id ? { ...r, pct: clamp(parseFloat(e.target.value || "0"), 0, 100) } : r)))} /> setFields((rows) => rows.map((r) => (r.id === f.id ? { ...r, costPerGame: clamp(parseFloat(e.target.value || "0"), 0, 10000) } : r)))} />
Totals {fieldsPctSum}%
{pctWarning && (
Heads up: your field allocation adds to {fieldsPctSum}%. Calculations will normalize percentages to 100%.
)}

Playoffs

{playoffRounds.map((r, idx) => { const totalPct = pctSum(r.fields); const warn = totalPct !== 100; const perBall = (Number(r.costPerDozenBalls) || 0) / 12; const perGameBalls = (Number(r.ballsPerGame) || 0) * perBall; return (
updateRound(r.id, { name: e.target.value })} /> {`Round ${idx + 1}`}
updateRound(r.id, { games: v })} min={0} max={200} step={1} /> updateRound(r.id, { umpiresPerGame: v })} min={0} max={6} step={1} /> updateRound(r.id, { costPerUmpPerGame: v })} min={0} max={500} step={1} /> updateRound(r.id, { ballsPerGame: v })} min={0} max={36} step={1} /> updateRound(r.id, { costPerDozenBalls: v })} min={0} max={300} step={1} help={`≈ ${currency(perBall)} per ball • ${currency(perGameBalls)} per game`} />
{r.fields.map((f) => ( ))}
Field name % of games Cost per game ($)
updateRoundField(r.id, f.id, { name: e.target.value })} /> updateRoundField(r.id, f.id, { pct: clamp(parseFloat(e.target.value || "0"), 0, 100) })} /> updateRoundField(r.id, f.id, { costPerGame: clamp(parseFloat(e.target.value || "0"), 0, 10000) })} />
Totals {totalPct}%
{warn && (
Heads up: field allocation adds to {totalPct}%. We'll normalize to 100%.
)}
); })}
Playoff games (total){playoffTotals.games}
Playoff field costs{currency(playoffTotals.field)}
Playoff baseballs{currency(playoffTotals.balls)}
Playoff umpires{currency(playoffTotals.umps)}

Fixed Costs

{fixedCosts.map((fc) => (
setFixedCosts((rows) => rows.map((r) => (r.id === fc.id ? { ...r, name: e.target.value } : r)))} /> setFixedCosts((rows) => rows.map((r) => (r.id === fc.id ? { ...r, amount: clamp(parseFloat(e.target.value || "0"), 0, 1_000_000) } : r)))} />
))}
Fixed cost total
{currency(fixedCostTotal)}

Summary

Games Per Team{gamesPerTeam}
Total League Games{totalGames}
Average Games/Week/Team{gamesPerWeekPerTeam.toFixed(2)}
League Games/Week{gamesPerWeekLeague.toFixed(2)}
League Total Games{totalGames}
Umpire costs (regular){currency(umpireCostTotal)}
Umpire costs (playoffs){currency(playoffUmpireCostTotal)}
Field rental (regular){currency(fieldRentalCostTotal)}
Field rental (playoffs){currency(playoffTotals.field)}
Baseballs (regular){currency(baseballsCostTotal)}
Baseballs (playoffs){currency(playoffTotals.balls)}
Fixed costs{currency(fixedCostTotal)}
League costs subtotal{currency(leagueCostsTotal)}
Grand total{currency(grandTotal)}
Per team to league{currency(costPerTeamLeague)}
Per team to umpires{currency(costPerTeamUmpires)}
Per team (total){currency(costPerTeam)}
Per player (avg team size {avgTeamSize}){currency(costPerPlayer)}
); }