import React, { 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)); // Map 1..5 to human-friendly week labels 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", ]; // Approximate how many weeks between (month, week) pairs (inclusive) function approxWeeksBetween( startMonthIdx: number, startWeek: number, endMonthIdx: number, endWeek: number ) { const weeksPerMonth = 4.34524; // avg weeks per month 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)); } // File download helper 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); } // Generic labeled number+slider input pair 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}

}
); } // --- Main App -------------------------------------------------------------- export default function BaseballLeagueCostEstimator() { // Fixed costs: name + amount const [fixedCosts, setFixedCosts] = useState>([ { id: crypto.randomUUID(), name: "Insurance", amount: 1500 }, { id: crypto.randomUUID(), name: "Commissioner", amount: 2000 }, ]); // Variables const [teams, setTeams] = useState(12); const [gamesPerTeam, setGamesPerTeam] = useState(10); // number of games per team const [umpiresPerGame, setUmpiresPerGame] = useState(2); const [costPerUmpPerGame, setCostPerUmpPerGame] = useState(70); const [avgTeamSize, setAvgTeamSize] = useState(12); // Per-game consumables const [ballsPerGame, setBallsPerGame] = useState(4); const [costPerDozenBalls, setCostPerDozenBalls] = useState(60); // Season window const [startMonth, setStartMonth] = useState(4); // 0-indexed; May const [startWeek, setStartWeek] = useState(1); const [endMonth, setEndMonth] = useState(7); // August const [endWeek, setEndWeek] = useState(4); // Fields table rows: name, pct of games, cost per game (optional) const [fields, setFields] = useState>([ { id: crypto.randomUUID(), name: "Main Park #1", pct: 60, costPerGame: 40 }, { id: crypto.randomUUID(), name: "Riverside #2", pct: 40, costPerGame: 25 }, ]); // Derived calculations const fixedCostTotal = useMemo( () => fixedCosts.reduce((sum, fc) => sum + (Number(fc.amount) || 0), 0), [fixedCosts] ); // Total league games, given each game involves exactly two teams const totalGames = useMemo(() => (teams || 0) * (gamesPerTeam || 0) / 2, [teams, gamesPerTeam]); // Scheduling references (games-per-opponent distribution) 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]); // Per-game baseballs cost 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]); // Split costs: league (fixed + fields + consumables) vs umpires const leagueCostsTotal = useMemo( () => fixedCostTotal + fieldRentalCostTotal + baseballsCostTotal, [fixedCostTotal, fieldRentalCostTotal, baseballsCostTotal] ); const grandTotal = leagueCostsTotal + umpireCostTotal; 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 ? umpireCostTotal / teams : 0), [umpireCostTotal, 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]); // Mutators 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 pctWarning = fieldsPctSum !== 100; // --- Import/Export ------------------------------------------------------- type SaveShape = { version: 1; 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 }>; }; }; const fileInputRef = useRef(null); const exportJSON = () => { const payload: SaveShape = { version: 1, 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 })), }, }; 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 }))); }; const importFromFile = async (file: File) => { try { const text = await file.text(); const parsed = JSON.parse(text); if (!parsed || parsed.version !== 1 || !parsed.data) throw new Error("Invalid or unsupported 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(); return (

Baseball League Cost Estimator

{ const f = e.target.files?.[0]; if (f) importFromFile(f); }} />
{/* Left column: Variables */}
{/* League Basics */}

League Basics

{/* Scheduling reference */}
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 */}

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 Table */}

Fields & Game Allocation

{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%.
)}
{/* Right column: Fixed costs + Summary */}
{/* Fixed costs */}

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 */}

Summary

Umpire costs{currency(umpireCostTotal)}
Field rental costs{currency(fieldRentalCostTotal)}
Baseballs (per-game consumables){currency(baseballsCostTotal)}
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)}
Notes:
  • League total games = teams × games per team ÷ 2.
  • Field cost = total games × % allocation × per-field cost per game.
  • Percentages are normalized if they don't add up to 100%.
  • Season window drives weekly rates only; it doesn't alter totals.
); }