Files
baseball-league-cost-estimator/src/App.tsx
Anthony Correa 0269fa854a Revise defaults and UI for baseball cost estimator
- Updated initial values for teams, games per team, umpire costs, and team size to reflect a smaller league.
- Expanded fixed cost list with items such as "Umpire Admin", "Digital", and "Trophies".
- Simplified field configuration to a single field covering 100% of games.
- Removed redundant background and text color utility classes for a cleaner and more uniform UI.
- Added derived metrics in the Summary section for improved clarity and budgeting transparency.
2026-01-06 21:22:46 -06:00

706 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<number, string>)[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 (
<div className="space-y-2">
<div className="flex items-baseline justify-between">
<label htmlFor={sliderId} className="text-sm font-medium">
{label}
</label>
<input
type="number"
inputMode="decimal"
className="w-36 rounded-xl border border-gray-300 px-3 py-1 text-right shadow-sm focus:border-blue-500 focus:outline-none"
value={Number.isFinite(value) ? value : 0}
min={min}
max={max}
step={step}
onChange={(e) => onChange(clamp(parseFloat(e.target.value || "0"), min, max))}
/>
</div>
<input
id={sliderId}
type="range"
min={min}
max={max}
step={step}
className="w-full accent-blue-600"
value={Number.isFinite(value) ? value : 0}
onChange={(e) => onChange(clamp(parseFloat(e.target.value || "0"), min, max))}
/>
{help && <p className="text-xs ">{help}</p>}
</div>
);
}
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<Array<{ id: string; name: string; amount: number }>>([
{ 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<FieldRow[]>([
{ 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<PlayoffRound[]>([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<PlayoffRound>) =>
setPlayoffRounds((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r)));
const updateRoundField = (rid: string, fid: string, patch: Partial<FieldRow>) =>
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<HTMLInputElement>(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 (
<div className="mx-auto max-w-6xl p-6">
<header className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold">Baseball League Cost Estimator</h1>
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept="application/json"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) importFromFile(f);
}}
/>
<button onClick={onClickImport} className="rounded-xl border bg-blue-600 shadow hover:bg-blue-700">Import</button>
<button onClick={exportJSON} className="rounded-xl bg-blue-600 shadow hover:bg-blue-700">Export</button>
</div>
</header>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<section className="lg:col-span-2 space-y-6">
<div className="rounded-2xl border p-5 shadow-sm">
<h2 className="mb-4 text-lg font-semibold">League Basics</h2>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<NumberSlider label="Number of teams" value={teams} onChange={setTeams} min={0} max={50} />
<NumberSlider
label="Games per team"
value={gamesPerTeam}
onChange={setGamesPerTeam}
min={0}
max={100}
step={1}
help={`League total games: ${totalGames}`}
/>
<NumberSlider label="Umpires per game" value={umpiresPerGame} onChange={setUmpiresPerGame} min={0} max={6} step={1} />
<NumberSlider label="Cost per umpire per game ($)" value={costPerUmpPerGame} onChange={setCostPerUmpPerGame} min={0} max={300} step={1} />
<NumberSlider label="Average team size (players)" value={avgTeamSize} onChange={setAvgTeamSize} min={1} max={30} step={1} help="Used for per-player cost" />
<NumberSlider label="Baseballs used per game" value={ballsPerGame} onChange={setBallsPerGame} min={0} max={24} step={1} />
<NumberSlider label="Cost of baseballs per dozen ($)" value={costPerDozenBalls} onChange={setCostPerDozenBalls} min={0} max={200} step={1} help={`${currency(perBallCost)} per ball • ${currency(baseballsCostPerGame)} per game`} />
</div>
<div className="mt-4 rounded-xl border p-3 text-sm">
<div className="flex flex-wrap gap-x-6 gap-y-1">
<div>Opponents per team: <span className="font-medium">{opponentsPerTeam}</span></div>
<div>Games per opponent (avg): <span className="font-medium">{gamesPerOpponentExact.toFixed(2)}</span></div>
</div>
{opponentsPerTeam > 0 && (
<div className="mt-1">
Distribution (as even as possible): each team plays <span className="font-medium">{remainderOpponents}</span> opponents <span className="font-medium">{gamesPerOpponentFloor + 1}×</span> and <span className="font-medium">{opponentsPerTeam - remainderOpponents}</span> opponents <span className="font-medium">{gamesPerOpponentFloor}×</span>.
</div>
)}
<div className="mt-1 ">Total league games computed as teams × gamesPerTeam ÷ 2.</div>
</div>
</div>
<div className="rounded-2xl border p-5 shadow-sm">
<h2 className="mb-4 text-lg font-semibold">Season Window</h2>
<div className="grid grid-cols-1 items-end gap-4 sm:grid-cols-4">
<div className="space-y-2">
<label className="text-sm font-medium">Start month</label>
<select className="w-full rounded-xl border px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none" value={startMonth} onChange={(e) => setStartMonth(parseInt(e.target.value))}>
{months.map((m, i) => (<option key={m} value={i}>{m}</option>))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Start week</label>
<select className="w-full rounded-xl border px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none" value={startWeek} onChange={(e) => setStartWeek(parseInt(e.target.value))}>
{[1,2,3,4,5].map((w) => (<option key={w} value={w}>{weekLabel(w)}</option>))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">End month</label>
<select className="w-full rounded-xl border px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none" value={endMonth} onChange={(e) => setEndMonth(parseInt(e.target.value))}>
{months.map((m, i) => (<option key={m} value={i}>{m}</option>))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">End week</label>
<select className="w-full rounded-xl border px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none" value={endWeek} onChange={(e) => setEndWeek(parseInt(e.target.value))}>
{[1,2,3,4,5].map((w) => (<option key={w} value={w}>{weekLabel(w)}</option>))}
</select>
</div>
</div>
<div className="mt-3 grid grid-cols-1 gap-2 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div><span className="">Approx. season length:</span> <span className="font-medium">{approxWeeks} weeks</span></div>
<div><span className="">Games/week per team:</span> <span className="font-medium">{gamesPerWeekPerTeam.toFixed(2)}</span></div>
<div><span className="">Games/week league-wide:</span> <span className="font-medium">{gamesPerWeekLeague.toFixed(2)}</span></div>
<div><span className="">Games vs each opponent/team:</span> <span className="font-medium">{gamesVsEachOther.toFixed(2)}</span></div>
</div>
</div>
<div className="rounded-2xl border p-5 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">Fields & Game Allocation (Regular Season)</h2>
<button onClick={addField} className="rounded-xl bg-blue-600 shadow hover:bg-blue-700">+ Add field</button>
</div>
<div className="overflow-x-auto">
<table className="w-full table-auto">
<thead>
<tr className="text-left text-sm ">
<th className="p-2">Field name</th>
<th className="p-2">% of games</th>
<th className="p-2">Cost per game ($)</th>
<th className="p-2"></th>
</tr>
</thead>
<tbody>
{fields.map((f) => (
<tr key={f.id} className="border-t">
<td className="p-2"><input className="w-full rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" value={f.name} onChange={(e) => setFields((rows) => rows.map((r) => (r.id === f.id ? { ...r, name: e.target.value } : r)))} /></td>
<td className="p-2"><input type="number" className="w-28 rounded-lg border px-3 py-2 text-right text-sm focus:border-blue-500 focus:outline-none" min={0} max={100} step={1} value={f.pct} onChange={(e) => setFields((rows) => rows.map((r) => (r.id === f.id ? { ...r, pct: clamp(parseFloat(e.target.value || "0"), 0, 100) } : r)))} /></td>
<td className="p-2"><input type="number" className="w-40 rounded-lg border px-3 py-2 text-right text-sm focus:border-blue-500 focus:outline-none" min={0} step={1} value={f.costPerGame} onChange={(e) => setFields((rows) => rows.map((r) => (r.id === f.id ? { ...r, costPerGame: clamp(parseFloat(e.target.value || "0"), 0, 10000) } : r)))} /></td>
<td className="p-2 text-right"><button onClick={() => removeField(f.id)} className="text-sm text-red-600 hover:underline">Remove</button></td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t text-sm">
<td className="p-2 font-medium">Totals</td>
<td className={`p-2 font-medium ${pctWarning ? 'text-red-600' : ''}`}>{fieldsPctSum}%</td>
<td className="p-2 font-medium"></td>
<td></td>
</tr>
</tfoot>
</table>
</div>
{pctWarning && (
<div className="mt-3 rounded-xl border bg-yellow-50 px-3 py-2 text-sm text-yellow-900">
Heads up: your field allocation adds to {fieldsPctSum}%. Calculations will normalize percentages to 100%.
</div>
)}
</div>
<div className="rounded-2xl border p-5 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">Playoffs</h2>
<button onClick={addRound} className="rounded-xl bg-purple-600 shadow hover:bg-purple-700">+ Add round</button>
</div>
<div className="space-y-6">
{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 (
<div key={r.id} className="rounded-2xl border p-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<input className="rounded-lg border px-3 py-2 text-sm font-medium focus:border-purple-500 focus:outline-none" value={r.name} onChange={(e) => updateRound(r.id, { name: e.target.value })} />
<span className="text-xs">{`Round ${idx + 1}`}</span>
</div>
<button onClick={() => removeRound(r.id)} className="text-sm text-red-600 hover:underline">Remove round</button>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<NumberSlider label="Total games in this round" value={r.games} onChange={(v) => updateRound(r.id, { games: v })} min={0} max={200} step={1} />
<NumberSlider label="Umpires per game" value={r.umpiresPerGame} onChange={(v) => updateRound(r.id, { umpiresPerGame: v })} min={0} max={6} step={1} />
<NumberSlider label="Cost per umpire per game ($)" value={r.costPerUmpPerGame} onChange={(v) => updateRound(r.id, { costPerUmpPerGame: v })} min={0} max={500} step={1} />
<NumberSlider label="Baseballs per game" value={r.ballsPerGame} onChange={(v) => updateRound(r.id, { ballsPerGame: v })} min={0} max={36} step={1} />
<NumberSlider label="Cost of baseballs per dozen ($)" value={r.costPerDozenBalls} onChange={(v) => updateRound(r.id, { costPerDozenBalls: v })} min={0} max={300} step={1} help={`${currency(perBall)} per ball • ${currency(perGameBalls)} per game`} />
</div>
<div className="mt-4 overflow-x-auto">
<table className="w-full table-auto">
<thead>
<tr className="text-left text-sm">
<th className="p-2">Field name</th>
<th className="p-2">% of games</th>
<th className="p-2">Cost per game ($)</th>
<th className="p-2"></th>
</tr>
</thead>
<tbody>
{r.fields.map((f) => (
<tr key={f.id} className="border-t">
<td className="p-2"><input className="w-full rounded-lg border px-3 py-2 text-sm focus:border-purple-500 focus:outline-none" value={f.name} onChange={(e) => updateRoundField(r.id, f.id, { name: e.target.value })} /></td>
<td className="p-2"><input type="number" className="w-28 rounded-lg border px-3 py-2 text-right text-sm focus:border-purple-500 focus:outline-none" min={0} max={100} step={1} value={f.pct} onChange={(e) => updateRoundField(r.id, f.id, { pct: clamp(parseFloat(e.target.value || "0"), 0, 100) })} /></td>
<td className="p-2"><input type="number" className="w-40 rounded-lg border px-3 py-2 text-right text-sm focus:border-purple-500 focus:outline-none" min={0} step={1} value={f.costPerGame} onChange={(e) => updateRoundField(r.id, f.id, { costPerGame: clamp(parseFloat(e.target.value || "0"), 0, 10000) })} /></td>
<td className="p-2 text-right"><button onClick={() => removeRoundField(r.id, f.id)} className="text-sm text-red-600 hover:underline">Remove</button></td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t text-sm">
<td className="p-2 font-medium">Totals</td>
<td className="p-2 font-medium">{totalPct}%</td>
<td className="p-2 font-medium"></td>
<td className="p-2 text-right"><button onClick={() => addRoundField(r.id)} className="text-sm hover:underline">+ Add field</button></td>
</tr>
</tfoot>
</table>
</div>
{warn && (
<div className="mt-3 rounded-xl border bg-yellow-50 px-3 py-2 text-sm text-yellow-900">
Heads up: field allocation adds to {totalPct}%. We'll normalize to 100%.
</div>
)}
</div>
);
})}
</div>
<div className="mt-4 grid grid-cols-1 gap-3 rounded-xl p-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div className="flex justify-between"><span>Playoff games (total)</span><span className="font-medium">{playoffTotals.games}</span></div>
<div className="flex justify-between"><span>Playoff field costs</span><span className="font-medium">{currency(playoffTotals.field)}</span></div>
<div className="flex justify-between"><span>Playoff baseballs</span><span className="font-medium">{currency(playoffTotals.balls)}</span></div>
<div className="flex justify-between"><span>Playoff umpires</span><span className="font-medium">{currency(playoffTotals.umps)}</span></div>
</div>
</div>
</section>
<section className="space-y-6">
<div className="rounded-2xl border p-5 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">Fixed Costs</h2>
<button onClick={addFixed} className="rounded-xl bg-blue-600 shadow hover:bg-blue-700">+ Add item</button>
</div>
<div className="space-y-3">
{fixedCosts.map((fc) => (
<div key={fc.id} className="grid grid-cols-5 items-center gap-2">
<input className="col-span-3 rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none" value={fc.name} onChange={(e) => setFixedCosts((rows) => rows.map((r) => (r.id === fc.id ? { ...r, name: e.target.value } : r)))} />
<input type="number" className="col-span-2 rounded-lg border px-3 py-2 text-right text-sm focus:border-blue-500 focus:outline-none" min={0} step={1} value={fc.amount} onChange={(e) => setFixedCosts((rows) => rows.map((r) => (r.id === fc.id ? { ...r, amount: clamp(parseFloat(e.target.value || "0"), 0, 1_000_000) } : r)))} />
<div className="col-span-5 flex justify-end"><button onClick={() => removeFixed(fc.id)} className="text-sm text-red-600 hover:underline">Remove</button></div>
</div>
))}
</div>
<div className="mt-4 flex items-center justify-between border-t pt-3 text-sm">
<div className="font-medium">Fixed cost total</div>
<div className="font-semibold">{currency(fixedCostTotal)}</div>
</div>
</div>
<div className="rounded-2xl border p-5 shadow-sm">
<h2 className="mb-4 text-lg font-semibold">Summary</h2>
<div className="space-y-2 text-sm">
<div className="flex justify-between"><span>Games Per Team</span><span className="font-medium">{gamesPerTeam}</span></div>
<div className="flex justify-between"><span>Total League Games</span><span className="font-medium">{totalGames}</span></div>
<div className="flex justify-between"><span>Average Games/Week/Team</span><span className="font-medium">{gamesPerWeekPerTeam.toFixed(2)}</span></div>
<div className="flex justify-between"><span>League Games/Week</span><span className="font-medium">{gamesPerWeekLeague.toFixed(2)}</span></div>
<div className="flex justify-between flex justify-between border-t pt-2"><span>League Total Games</span><span className="font-medium">{totalGames}</span></div>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between"><span>Umpire costs (regular)</span><span className="font-medium">{currency(umpireCostTotal)}</span></div>
<div className="flex justify-between"><span>Umpire costs (playoffs)</span><span className="font-medium">{currency(playoffUmpireCostTotal)}</span></div>
<div className="flex justify-between"><span>Field rental (regular)</span><span className="font-medium">{currency(fieldRentalCostTotal)}</span></div>
<div className="flex justify-between"><span>Field rental (playoffs)</span><span className="font-medium">{currency(playoffTotals.field)}</span></div>
<div className="flex justify-between"><span>Baseballs (regular)</span><span className="font-medium">{currency(baseballsCostTotal)}</span></div>
<div className="flex justify-between"><span>Baseballs (playoffs)</span><span className="font-medium">{currency(playoffTotals.balls)}</span></div>
<div className="flex justify-between"><span>Fixed costs</span><span className="font-medium">{currency(fixedCostTotal)}</span></div>
<div className="flex justify-between border-t pt-2"><span>League costs subtotal</span><span className="font-medium">{currency(leagueCostsTotal)}</span></div>
<div className="flex justify-between border-t pt-2 text-base"><span className="font-semibold">Grand total</span><span className="font-bold">{currency(grandTotal)}</span></div>
</div>
<div className="mt-4 space-y-2 rounded-xl p-3 text-sm">
<div className="flex justify-between"><span>Per team to league</span><span className="font-semibold">{currency(costPerTeamLeague)}</span></div>
<div className="flex justify-between"><span>Per team to umpires</span><span className="font-semibold">{currency(costPerTeamUmpires)}</span></div>
<div className="flex justify-between"><span>Per team (total)</span><span className="font-semibold">{currency(costPerTeam)}</span></div>
<div className="flex justify-between"><span>Per player (avg team size {avgTeamSize})</span><span className="font-semibold">{currency(costPerPlayer)}</span></div>
</div>
</div>
</section>
</div>
<footer className="mt-8 text-center text-xs ">Built for quick budgeting. Adjust anything and the math updates instantly.</footer>
</div>
);
}