Files
2025-08-21 13:07:31 -05:00

492 lines
19 KiB
JavaScript
Raw Permalink 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, { 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<number, string>)[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 (
<div className="space-y-2">
<div className="flex items-baseline justify-between">
<label htmlFor={sliderId} className="text-sm font-medium text-gray-800">
{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 text-gray-500">{help}</p>}
</div>
);
}
// --- Main App --------------------------------------------------------------
export default function BaseballLeagueCostEstimator() {
// Fixed costs: name + amount
const [fixedCosts, setFixedCosts] = useState<Array<{ id: string; name: string; amount: number }>>([
{ 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<Array<{ id: string; name: string; pct: number; costPerGame: number }>>([
{ 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 (
<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="text-sm text-gray-500">Single-page app Tailwind CSS</div>
</header>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Left column: Variables */}
<section className="lg:col-span-2 space-y-6">
{/* League Basics */}
<div className="rounded-2xl border border-gray-200 bg-white 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="Total number of games"
value={totalGames}
onChange={setTotalGames}
min={0}
max={1000}
step={1}
help="League-wide total across the season"
/>
<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"
/>
</div>
</div>
{/* Season Window */}
<div className="rounded-2xl border border-gray-200 bg-white 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 text-gray-800">Start month</label>
<select
className="w-full rounded-xl border border-gray-300 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 text-gray-800">Start week</label>
<select
className="w-full rounded-xl border border-gray-300 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 text-gray-800">End month</label>
<select
className="w-full rounded-xl border border-gray-300 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 text-gray-800">End week</label>
<select
className="w-full rounded-xl border border-gray-300 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>
<p className="mt-3 text-sm text-gray-600">
Approx. season length: <span className="font-medium">{approxWeeks} weeks</span>
</p>
</div>
{/* Fields Table */}
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">Fields & Game Allocation</h2>
<button
onClick={addField}
className="rounded-xl bg-blue-600 px-3 py-1.5 text-sm font-medium text-white 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 text-gray-500">
<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 border-gray-100">
<td className="p-2">
<input
className="w-full rounded-lg border border-gray-300 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 border-gray-300 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 border-gray-300 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 border-gray-200 text-sm">
<td className="p-2 font-medium text-gray-700">Totals</td>
<td className="p-2 font-medium {pctWarning ? 'text-red-600' : 'text-gray-700'}">
{fieldsPctSum}%
</td>
<td className="p-2 font-medium text-gray-700"></td>
<td></td>
</tr>
</tfoot>
</table>
</div>
{pctWarning && (
<div className="mt-3 rounded-xl border border-yellow-300 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>
</section>
{/* Right column: Fixed costs + Summary */}
<section className="space-y-6">
{/* Fixed costs */}
<div className="rounded-2xl border border-gray-200 bg-white 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 px-3 py-1.5 text-sm font-medium text-white 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 border-gray-300 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 border-gray-300 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 text-gray-700">Fixed cost total</div>
<div className="font-semibold">{currency(fixedCostTotal)}</div>
</div>
</div>
{/* Summary */}
<div className="rounded-2xl border border-gray-200 bg-white 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>Umpire costs</span><span className="font-medium">{currency(umpireCostTotal)}</span></div>
<div className="flex justify-between"><span>Field rental costs</span><span className="font-medium">{currency(fieldRentalCostTotal)}</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 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 bg-gray-50 p-3 text-sm">
<div className="flex justify-between"><span>Per team</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 className="mt-4 text-xs text-gray-500">
Notes:
<ul className="list-disc pl-5">
<li>Field cost is computed as: total games × % allocation × per-field cost per game.</li>
<li>Percentages are normalized if they don't add up to 100%.</li>
<li>Season window is informational and does not alter calculations.</li>
</ul>
</div>
</div>
</section>
</div>
<footer className="mt-8 text-center text-xs text-gray-500">
Built for quick budgeting. Adjust anything and the math updates instantly.
</footer>
</div>
);
}