327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { AppShell } from "@/components/AppShell";
|
|
import { EmptyState } from "@/components/EmptyState";
|
|
import { SectionCard } from "@/components/SectionCard";
|
|
import { Alert } from "@/components/ui/alert";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { fetchJson } from "@/lib/client";
|
|
import type { GamePlayerStatsInput, GameRecord, LineupRecord } from "@/lib/types";
|
|
|
|
const battingFields = [
|
|
["atBats", "AB"],
|
|
["runs", "R"],
|
|
["hits", "H"],
|
|
["doubles", "2B"],
|
|
["triples", "3B"],
|
|
["homeRuns", "HR"],
|
|
["runsBattedIn", "RBI"],
|
|
["walks", "BB"],
|
|
["strikeouts", "SO"],
|
|
["stolenBases", "SB"]
|
|
] as const;
|
|
|
|
const pitchingFields = [
|
|
["pitchCount", "Pitches"],
|
|
["inningsPitchedOuts", "IP Outs"],
|
|
["earnedRunsAllowed", "ER"],
|
|
["hitsAllowed", "H Allowed"],
|
|
["walksAllowed", "BB Allowed"],
|
|
["strikeoutsPitched", "Pitching K"]
|
|
] as const;
|
|
|
|
function pickDefaultLineupId(gameDate: string, lineups: LineupRecord[]) {
|
|
const exactDate = lineups.find((lineup) => lineup.gameDate === gameDate);
|
|
return exactDate?._id ?? lineups[0]?._id ?? "";
|
|
}
|
|
|
|
export function GameScoringClient() {
|
|
const [gameDate, setGameDate] = useState(new Date().toISOString().slice(0, 10));
|
|
const [lineups, setLineups] = useState<LineupRecord[]>([]);
|
|
const [selectedLineupId, setSelectedLineupId] = useState("");
|
|
const [opponent, setOpponent] = useState("");
|
|
const [teamScore, setTeamScore] = useState<string>("");
|
|
const [opponentScore, setOpponentScore] = useState<string>("");
|
|
const [notes, setNotes] = useState("");
|
|
const [playerStats, setPlayerStats] = useState<Record<string, GamePlayerStatsInput>>({});
|
|
const [isScoring, setIsScoring] = useState(false);
|
|
const [message, setMessage] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
async function loadLineups() {
|
|
try {
|
|
const response = await fetchJson<LineupRecord[]>("/api/lineups");
|
|
setLineups(response);
|
|
setSelectedLineupId((current) =>
|
|
current && response.some((lineup) => lineup._id === current)
|
|
? current
|
|
: pickDefaultLineupId(gameDate, response)
|
|
);
|
|
} catch (loadError) {
|
|
setError(loadError instanceof Error ? loadError.message : "Failed to load lineups.");
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void loadLineups();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setSelectedLineupId((current) => {
|
|
if (current && lineups.some((lineup) => lineup._id === current)) {
|
|
return current;
|
|
}
|
|
|
|
return pickDefaultLineupId(gameDate, lineups);
|
|
});
|
|
}, [gameDate, lineups]);
|
|
|
|
const selectedLineup = useMemo(
|
|
() => lineups.find((lineup) => lineup._id === selectedLineupId) ?? null,
|
|
[lineups, selectedLineupId]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!selectedLineup) {
|
|
setPlayerStats({});
|
|
setIsScoring(false);
|
|
return;
|
|
}
|
|
|
|
setPlayerStats((current) =>
|
|
Object.fromEntries(
|
|
selectedLineup.battingOrder.map((slot) => [
|
|
slot.playerId,
|
|
current[slot.playerId] ?? {
|
|
playerId: slot.playerId,
|
|
playerName: slot.playerName
|
|
}
|
|
])
|
|
)
|
|
);
|
|
}, [selectedLineup]);
|
|
|
|
function updatePlayerStat(
|
|
playerId: string,
|
|
playerName: string,
|
|
field: keyof Omit<GamePlayerStatsInput, "playerId" | "playerName">,
|
|
value: string
|
|
) {
|
|
setPlayerStats((current) => ({
|
|
...current,
|
|
[playerId]: {
|
|
...(current[playerId] ?? { playerId, playerName }),
|
|
playerId,
|
|
playerName,
|
|
[field]: value === "" ? null : Number(value)
|
|
}
|
|
}));
|
|
}
|
|
|
|
async function saveGame() {
|
|
if (!selectedLineup || !selectedLineup._id) {
|
|
setError("Select a saved lineup before scoring the game.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const payload = {
|
|
gameDate,
|
|
lineupId: selectedLineup._id,
|
|
opponent,
|
|
teamScore: teamScore === "" ? null : Number(teamScore),
|
|
opponentScore: opponentScore === "" ? null : Number(opponentScore),
|
|
notes,
|
|
playerStats: selectedLineup.battingOrder.map(
|
|
(slot) => playerStats[slot.playerId] ?? { playerId: slot.playerId, playerName: slot.playerName }
|
|
)
|
|
};
|
|
|
|
const saved = await fetchJson<GameRecord>("/api/games", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
setMessage(`Saved game #${saved.gameNumber} for ${saved.gameDate}.`);
|
|
setOpponent("");
|
|
setTeamScore("");
|
|
setOpponentScore("");
|
|
setNotes("");
|
|
setPlayerStats({});
|
|
setIsScoring(false);
|
|
} catch (saveError) {
|
|
setError(saveError instanceof Error ? saveError.message : "Unable to save game.");
|
|
}
|
|
}
|
|
|
|
return (
|
|
<AppShell
|
|
title="Score Game"
|
|
subtitle="Choose the lineup used, then open the scorecard only when you're ready to enter the game results."
|
|
>
|
|
{message ? <Alert tone="success">{message}</Alert> : null}
|
|
{error ? <Alert tone="error">{error}</Alert> : null}
|
|
|
|
<SectionCard
|
|
title="Game Setup"
|
|
description="The most recent saved lineup is selected by default."
|
|
>
|
|
{lineups.length > 0 ? (
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<label>
|
|
<span className="field-label">Game Date</span>
|
|
<Input
|
|
type="date"
|
|
value={gameDate}
|
|
onChange={(event) => {
|
|
setGameDate(event.target.value);
|
|
setIsScoring(false);
|
|
}}
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span className="field-label">Lineup Used</span>
|
|
<select
|
|
className="field-select"
|
|
value={selectedLineupId}
|
|
onChange={(event) => {
|
|
setSelectedLineupId(event.target.value);
|
|
setIsScoring(false);
|
|
}}
|
|
>
|
|
{lineups.map((lineup) => (
|
|
<option key={lineup._id} value={lineup._id}>
|
|
{lineup.gameDate} · Lineup #{lineup.lineupNumber}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span className="field-label">Opponent</span>
|
|
<Input value={opponent} onChange={(event) => setOpponent(event.target.value)} placeholder="Optional" />
|
|
</label>
|
|
<label className="md:col-span-2">
|
|
<span className="field-label">Game Notes</span>
|
|
<Textarea rows={3} value={notes} onChange={(event) => setNotes(event.target.value)} />
|
|
</label>
|
|
<div className="md:col-span-2 flex flex-wrap gap-2">
|
|
<Button onClick={() => setIsScoring(true)}>Start Scorecard</Button>
|
|
{isScoring ? (
|
|
<Button variant="secondary" onClick={() => setIsScoring(false)}>
|
|
Hide Scorecard
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<EmptyState
|
|
title="No lineup available"
|
|
description="Save a lineup on the lineup tab first, then return here to score the game."
|
|
/>
|
|
)}
|
|
</SectionCard>
|
|
|
|
{selectedLineup && isScoring ? (
|
|
<SectionCard
|
|
title="Player Results"
|
|
description="Enter player results as the game unfolds. Final score lives here because you know it once the game is actually being scored."
|
|
>
|
|
<div className="grid gap-3">
|
|
<div className="grid grid-cols-2 gap-3 rounded-[1.5rem] border border-border bg-muted/30 p-4 md:max-w-sm">
|
|
<label>
|
|
<span className="field-label">Our Score</span>
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
value={teamScore}
|
|
onChange={(event) => setTeamScore(event.target.value)}
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span className="field-label">Their Score</span>
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
value={opponentScore}
|
|
onChange={(event) => setOpponentScore(event.target.value)}
|
|
/>
|
|
</label>
|
|
</div>
|
|
{selectedLineup.battingOrder.map((slot, index) => {
|
|
const statLine = playerStats[slot.playerId] ?? {
|
|
playerId: slot.playerId,
|
|
playerName: slot.playerName
|
|
};
|
|
|
|
return (
|
|
<Card key={slot.playerId}>
|
|
<CardContent className="grid gap-3 pt-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
|
|
{index + 1}
|
|
</div>
|
|
<div>
|
|
<h3 className="text-sm font-semibold">{slot.playerName}</h3>
|
|
<p className="text-xs text-muted-foreground">Batting order slot {index + 1}</p>
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-3">
|
|
<div>
|
|
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
|
Batting Line
|
|
</p>
|
|
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5 lg:grid-cols-6">
|
|
{battingFields.map(([field, label]) => (
|
|
<label key={field}>
|
|
<span className="field-label">{label}</span>
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
value={statLine[field] ?? ""}
|
|
onChange={(event) =>
|
|
updatePlayerStat(slot.playerId, slot.playerName, field, event.target.value)
|
|
}
|
|
/>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
|
Pitching Line
|
|
</p>
|
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
|
|
{pitchingFields.map(([field, label]) => (
|
|
<label key={field}>
|
|
<span className="field-label">{label}</span>
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
value={statLine[field] ?? ""}
|
|
onChange={(event) =>
|
|
updatePlayerStat(slot.playerId, slot.playerName, field, event.target.value)
|
|
}
|
|
/>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
<div>
|
|
<Button onClick={() => void saveGame()}>Save Game</Button>
|
|
</div>
|
|
</div>
|
|
</SectionCard>
|
|
) : null}
|
|
</AppShell>
|
|
);
|
|
}
|