import React, { useMemo, 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)); } // 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: "Website/Registration", amount: 600 }, ]); // Variables const [teams, setTeams] = useState(12); const [totalGames, setTotalGames] = useState(120); // total across league const [umpiresPerGame, setUmpiresPerGame] = useState(2); const [costPerUmpPerGame, setCostPerUmpPerGame] = useState(70); const [avgTeamSize, setAvgTeamSize] = useState(12); // 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] ); 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 grandTotal = fixedCostTotal + umpireCostTotal + fieldRentalCostTotal; const costPerTeam = useMemo(() => (teams > 0 ? grandTotal / teams : 0), [grandTotal, 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] ); // 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; return (

Baseball League Cost Estimator

Single-page app • Tailwind CSS
{/* Left column: Variables */}
{/* League Basics */}

League Basics

{/* Season Window */}

Season Window

Approx. season length: {approxWeeks} weeks

{/* 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)}
Fixed costs{currency(fixedCostTotal)}
Grand total {currency(grandTotal)}
Per team{currency(costPerTeam)}
Per player (avg team size {avgTeamSize}){currency(costPerPlayer)}
Notes:
  • Field cost is computed as: total games × % allocation × per-field cost per game.
  • Percentages are normalized if they don't add up to 100%.
  • Season window is informational and does not alter calculations.
); }