Files
baseball-league-cost-estimator/src/App.tsx
2025-08-21 13:07:31 -05:00

683 lines
28 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, { 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<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));
}
// 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 (
<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: "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<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]
);
// 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<HTMLInputElement>(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 (
<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 border-gray-300 bg-white px-3 py-1.5 text-sm shadow hover:bg-gray-50">Import</button>
<button onClick={exportJSON} className="rounded-xl bg-blue-600 px-3 py-1.5 text-sm font-medium text-white shadow hover:bg-blue-700">Export</button>
</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="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>
{/* Scheduling reference */}
<div className="mt-4 rounded-xl border border-gray-100 bg-gray-50 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 text-gray-700">
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 text-gray-500">Total league games computed as teams × gamesPerTeam ÷ 2.</div>
</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>
<div className="mt-3 grid grid-cols-1 gap-2 text-sm text-gray-700 sm:grid-cols-2 lg:grid-cols-4">
<div><span className="text-gray-600">Approx. season length:</span> <span className="font-medium">{approxWeeks} weeks</span></div>
<div><span className="text-gray-600">Games/week per team:</span> <span className="font-medium">{gamesPerWeekPerTeam.toFixed(2)}</span></div>
<div><span className="text-gray-600">Games/week league-wide:</span> <span className="font-medium">{gamesPerWeekLeague.toFixed(2)}</span></div>
<div><span className="text-gray-600">Games vs each opponent/team:</span> <span className="font-medium">{gamesVsEachOther.toFixed(2)}</span></div>
</div>
</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>Baseballs (per-game consumables)</span><span className="font-medium">{currency(baseballsCostTotal)}</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 bg-gray-50 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 className="mt-4 text-xs text-gray-500">
Notes:
<ul className="list-disc pl-5">
<li>League total games = teams × games per team ÷ 2.</li>
<li>Field cost = 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 drives weekly rates only; it doesn't alter totals.</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>
);
}