Updating
This commit is contained in:
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Little League Roster Manager
|
||||
# Coach Connor's Lineup Lab
|
||||
|
||||
Mobile-friendly roster and lineup planning app for Little League baseball. It includes roster management, pitcher rest tracking, configurable rest rules, and a game-day lineup generator.
|
||||
Mobile-friendly baseball coaching app for roster management, scorekeeping, pitcher rest tracking, configurable rest rules, and game-day lineup planning.
|
||||
|
||||
## Stack
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "little-league-roster-manager",
|
||||
"name": "coach-connors-lineup-lab",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { handleApiError } from "@/app/api/_utils";
|
||||
import { connectToDatabase } from "@/lib/db";
|
||||
import { GameModel } from "@/models/Game";
|
||||
import { PlayerModel } from "@/models/Player";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const statFields = [
|
||||
"atBats",
|
||||
"runs",
|
||||
"hits",
|
||||
"doubles",
|
||||
"triples",
|
||||
"homeRuns",
|
||||
"runsBattedIn",
|
||||
"walks",
|
||||
"strikeouts",
|
||||
"stolenBases"
|
||||
] as const;
|
||||
|
||||
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const { id } = await params;
|
||||
const game = await GameModel.findByIdAndDelete(id).lean();
|
||||
|
||||
if (!game) {
|
||||
return NextResponse.json({ error: "Game not found." }, { status: 404 });
|
||||
}
|
||||
|
||||
await PlayerModel.bulkWrite(
|
||||
game.playerStats.map((playerStat) => {
|
||||
const decrement: Record<string, number> = { "stats.gamesPlayed": -1 };
|
||||
|
||||
for (const field of statFields) {
|
||||
decrement[`stats.${field}`] = -(playerStat[field] ?? 0);
|
||||
}
|
||||
|
||||
return {
|
||||
updateOne: {
|
||||
filter: { _id: playerStat.playerId },
|
||||
update: { $inc: decrement }
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { handleApiError } from "@/app/api/_utils";
|
||||
import { connectToDatabase } from "@/lib/db";
|
||||
import { serialize } from "@/lib/serialize";
|
||||
import { saveGameSchema } from "@/lib/validators";
|
||||
import { GameModel } from "@/models/Game";
|
||||
import { LineupModel } from "@/models/Lineup";
|
||||
import { PlayerModel } from "@/models/Player";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const statFields = [
|
||||
"atBats",
|
||||
"runs",
|
||||
"hits",
|
||||
"doubles",
|
||||
"triples",
|
||||
"homeRuns",
|
||||
"runsBattedIn",
|
||||
"walks",
|
||||
"strikeouts",
|
||||
"stolenBases"
|
||||
] as const;
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const gameDate = searchParams.get("gameDate");
|
||||
const filter = gameDate ? { gameDate: new Date(gameDate) } : {};
|
||||
const games = await GameModel.find(filter).sort({ gameDate: -1, gameNumber: -1 }).lean();
|
||||
return NextResponse.json(serialize(games));
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await connectToDatabase();
|
||||
const payload = saveGameSchema.parse(await request.json());
|
||||
const lineup = await LineupModel.findById(payload.lineupId).lean();
|
||||
|
||||
if (!lineup) {
|
||||
return NextResponse.json({ error: "Selected lineup not found." }, { status: 404 });
|
||||
}
|
||||
|
||||
const gameDate = new Date(payload.gameDate);
|
||||
const latestForDate = await GameModel.findOne({ gameDate }).sort({ gameNumber: -1 }).select("gameNumber").lean();
|
||||
const gameNumber = (latestForDate?.gameNumber ?? 0) + 1;
|
||||
|
||||
const game = await GameModel.create({
|
||||
gameDate,
|
||||
gameNumber,
|
||||
activePlayerIds: lineup.playerIds,
|
||||
startingPitcherId: lineup.startingPitcherId ?? null,
|
||||
lineupId: lineup._id,
|
||||
lineupNumber: lineup.lineupNumber ?? null,
|
||||
opponent: payload.opponent ?? "",
|
||||
teamScore: payload.teamScore ?? null,
|
||||
opponentScore: payload.opponentScore ?? null,
|
||||
notes: payload.notes ?? "",
|
||||
playerStats: payload.playerStats
|
||||
});
|
||||
|
||||
await PlayerModel.bulkWrite(
|
||||
payload.playerStats.map((playerStat) => {
|
||||
const increment: Record<string, number> = { "stats.gamesPlayed": 1 };
|
||||
|
||||
for (const field of statFields) {
|
||||
increment[`stats.${field}`] = playerStat[field] ?? 0;
|
||||
}
|
||||
|
||||
return {
|
||||
updateOne: {
|
||||
filter: { _id: playerStat.playerId },
|
||||
update: { $inc: increment }
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json(serialize(game.toObject()), { status: 201 });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { GameScoringClient } from "@/components/GameScoringClient";
|
||||
|
||||
export default function GamesPage() {
|
||||
return <GameScoringClient />;
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Little League Roster Manager",
|
||||
description: "Roster, pitching availability, and lineup planning for game day."
|
||||
title: "Coach Connor's Lineup Lab",
|
||||
description: "Roster, scorekeeping, pitching availability, and lineup planning for game day."
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart3, ClipboardList, ShieldCheck, TimerReset, Trophy, Users } from "lucide-react";
|
||||
import { BarChart3, ClipboardList, FileText, ShieldCheck, TimerReset, Trophy, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -9,6 +9,7 @@ const navItems = [
|
||||
{ href: "/dashboard", label: "Dashboard", icon: BarChart3 },
|
||||
{ href: "/roster", label: "Roster", icon: Users },
|
||||
{ href: "/pitching", label: "Pitch Tracking", icon: TimerReset },
|
||||
{ href: "/games", label: "Score Game", icon: FileText },
|
||||
{ href: "/lineup", label: "Generate Lineup", icon: ClipboardList },
|
||||
{ href: "/settings", label: "Pitching Rules", icon: ShieldCheck }
|
||||
];
|
||||
@@ -35,7 +36,7 @@ export function AppShell({
|
||||
<div className="max-w-3xl">
|
||||
<div className="mb-3 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-white/80">
|
||||
<Trophy className="h-4 w-4" />
|
||||
Little League Coach Console
|
||||
Coach Connor's Lineup Lab
|
||||
</div>
|
||||
<h1 className="font-display text-3xl font-semibold leading-none tracking-tight md:text-5xl">{title}</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm text-white/70 md:text-base">{subtitle}</p>
|
||||
|
||||
@@ -15,6 +15,8 @@ export function DashboardClient() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const visiblePitching = data?.recentPitching.filter((entry) => entry.status !== "unknown") ?? [];
|
||||
|
||||
async function loadDashboard() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -66,8 +68,9 @@ export function DashboardClient() {
|
||||
title="Pitcher Availability"
|
||||
description="Availability is based on the latest appearance and your configured rest rules."
|
||||
>
|
||||
{visiblePitching.length > 0 ? (
|
||||
<div className="divide-y divide-border">
|
||||
{data.recentPitching.map((entry) => (
|
||||
{visiblePitching.map((entry) => (
|
||||
<div
|
||||
key={entry.playerId}
|
||||
className="flex flex-col gap-3 py-4 md:flex-row md:items-start md:justify-between"
|
||||
@@ -91,6 +94,12 @@ export function DashboardClient() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No eligible pitchers to show"
|
||||
description="Pitchers with unknown availability are hidden from this dashboard view."
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import { FileText, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
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 statFields = [
|
||||
["atBats", "AB"],
|
||||
["runs", "R"],
|
||||
["hits", "H"],
|
||||
["doubles", "2B"],
|
||||
["triples", "3B"],
|
||||
["homeRuns", "HR"],
|
||||
["runsBattedIn", "RBI"],
|
||||
["walks", "BB"],
|
||||
["strikeouts", "SO"],
|
||||
["stolenBases", "SB"]
|
||||
] 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 [savedGames, setSavedGames] = useState<GameRecord[]>([]);
|
||||
const [gamePendingDelete, setGamePendingDelete] = useState<GameRecord | null>(null);
|
||||
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 [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.");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSavedGames(targetDate: string) {
|
||||
try {
|
||||
const response = await fetchJson<GameRecord[]>(`/api/games?gameDate=${targetDate}`);
|
||||
setSavedGames(response);
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : "Failed to load games.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadLineups();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSavedGames(gameDate);
|
||||
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({});
|
||||
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({});
|
||||
await loadSavedGames(gameDate);
|
||||
} catch (saveError) {
|
||||
setError(saveError instanceof Error ? saveError.message : "Unable to save game.");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteGame(game: GameRecord) {
|
||||
if (!game._id) return;
|
||||
|
||||
try {
|
||||
await fetchJson(`/api/games/${game._id}`, { method: "DELETE" });
|
||||
setMessage(`Deleted game #${game.gameNumber}.`);
|
||||
setGamePendingDelete(null);
|
||||
await loadSavedGames(gameDate);
|
||||
} catch (deleteError) {
|
||||
setError(deleteError instanceof Error ? deleteError.message : "Unable to delete game.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Score Game"
|
||||
subtitle="Pick the lineup used, enter the results, and roll the stats into each player automatically."
|
||||
>
|
||||
<ConfirmDialog
|
||||
open={gamePendingDelete !== null}
|
||||
title="Delete Saved Game?"
|
||||
description={
|
||||
gamePendingDelete
|
||||
? `Delete game #${gamePendingDelete.gameNumber} for ${gamePendingDelete.gameDate}? Player stats from that game will be reversed.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Delete Game"
|
||||
onCancel={() => setGamePendingDelete(null)}
|
||||
onConfirm={() => (gamePendingDelete ? void deleteGame(gamePendingDelete) : undefined)}
|
||||
/>
|
||||
|
||||
{message ? <Alert tone="success">{message}</Alert> : null}
|
||||
{error ? <Alert tone="error">{error}</Alert> : null}
|
||||
|
||||
<SectionCard
|
||||
title="Game Setup"
|
||||
description="Choose the lineup used in the game. The latest 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)} />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Lineup Used</span>
|
||||
<select className="field-select" value={selectedLineupId} onChange={(event) => setSelectedLineupId(event.target.value)}>
|
||||
{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>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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>
|
||||
<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>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No lineup available"
|
||||
description="Save a lineup on the lineup tab first, then return here to score the game."
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{selectedLineup ? (
|
||||
<SectionCard
|
||||
title="Player Results"
|
||||
description="Stat entry is optional. Leave any field blank if you do not want to track it for that player."
|
||||
>
|
||||
<div className="grid gap-3">
|
||||
{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 grid-cols-3 gap-2 sm:grid-cols-5">
|
||||
{statFields.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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<div>
|
||||
<Button onClick={() => void saveGame()}>Save Game</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
) : null}
|
||||
|
||||
<SectionCard
|
||||
title="Saved Games"
|
||||
description="Previously saved games for the selected date can be deleted."
|
||||
>
|
||||
{savedGames.length > 0 ? (
|
||||
<div className="grid gap-3">
|
||||
{savedGames.map((game) => (
|
||||
<Card key={game._id}>
|
||||
<CardContent className="flex items-start justify-between gap-3 pt-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-full bg-muted p-2 text-muted-foreground">
|
||||
<FileText className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">
|
||||
{game.gameDate} · Game #{game.gameNumber}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Lineup #{game.lineupNumber ?? "?"}
|
||||
{game.opponent ? ` · vs ${game.opponent}` : ""}
|
||||
{game.teamScore !== null && game.teamScore !== undefined && game.opponentScore !== null && game.opponentScore !== undefined
|
||||
? ` · ${game.teamScore}-${game.opponentScore}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="destructive" className="gap-1" onClick={() => setGamePendingDelete(game)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No saved games"
|
||||
description="Score and save a game to keep a dated history here."
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,8 @@ export function PitchTrackingClient() {
|
||||
notes: ""
|
||||
});
|
||||
|
||||
const visiblePitchers = players.filter((player) => player.pitcherAvailability.status !== "unknown");
|
||||
|
||||
async function loadPlayers() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -142,9 +144,9 @@ export function PitchTrackingClient() {
|
||||
title="Pitcher Snapshot"
|
||||
description="Quick read of each player’s latest pitching status."
|
||||
>
|
||||
{players.length > 0 ? (
|
||||
{visiblePitchers.length > 0 ? (
|
||||
<div className="grid gap-3">
|
||||
{players.map((player) => (
|
||||
{visiblePitchers.map((player) => (
|
||||
<Card key={player._id}>
|
||||
<CardContent className="flex items-start justify-between gap-3 pt-4">
|
||||
<div>
|
||||
@@ -170,8 +172,8 @@ export function PitchTrackingClient() {
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No pitching data yet"
|
||||
description="Log the first appearance above once your roster is in place."
|
||||
title="No eligible pitchers to show"
|
||||
description="Pitchers with unknown availability are hidden from this snapshot."
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
@@ -15,7 +15,8 @@ const defaultForm: PlayerInput = {
|
||||
secondaryPosition: "C",
|
||||
tertiaryPosition: "1B",
|
||||
isActive: true,
|
||||
notes: ""
|
||||
notes: "",
|
||||
stats: {}
|
||||
};
|
||||
|
||||
export function emptyPlayerForm() {
|
||||
@@ -32,10 +33,25 @@ export function toPlayerForm(player: PlayerRecord): PlayerInput {
|
||||
secondaryPosition: player.secondaryPosition ?? null,
|
||||
tertiaryPosition: player.tertiaryPosition ?? null,
|
||||
isActive: player.isActive,
|
||||
notes: player.notes ?? ""
|
||||
notes: player.notes ?? "",
|
||||
stats: player.stats ?? {}
|
||||
};
|
||||
}
|
||||
|
||||
const statFields = [
|
||||
["gamesPlayed", "Games"],
|
||||
["atBats", "AB"],
|
||||
["runs", "Runs"],
|
||||
["hits", "Hits"],
|
||||
["doubles", "2B"],
|
||||
["triples", "3B"],
|
||||
["homeRuns", "HR"],
|
||||
["runsBattedIn", "RBI"],
|
||||
["walks", "BB"],
|
||||
["strikeouts", "SO"],
|
||||
["stolenBases", "SB"]
|
||||
] as const;
|
||||
|
||||
export function PlayerForm({
|
||||
value,
|
||||
onChange,
|
||||
@@ -119,6 +135,35 @@ export function PlayerForm({
|
||||
<span className="field-label">Notes</span>
|
||||
<Textarea rows={4} value={value.notes ?? ""} onChange={(event) => onChange({ ...value, notes: event.target.value })} />
|
||||
</label>
|
||||
<div className="md:col-span-2 space-y-3 rounded-[1.5rem] border border-border bg-muted/40 p-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Optional Player Stats</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Leave any field blank if you do not want to track it.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 grid-cols-2 md:grid-cols-4">
|
||||
{statFields.map(([key, label]) => (
|
||||
<label key={key}>
|
||||
<span className="field-label">{label}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={value.stats?.[key] ?? ""}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...value,
|
||||
stats: {
|
||||
...value.stats,
|
||||
[key]: event.target.value === "" ? null : Number(event.target.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Button onClick={onSubmit}>
|
||||
{submitLabel}
|
||||
|
||||
@@ -20,6 +20,7 @@ export function RosterClient() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [editingPlayerId, setEditingPlayerId] = useState<string | null>(null);
|
||||
const [showPlayerForm, setShowPlayerForm] = useState(false);
|
||||
const [playerPendingDelete, setPlayerPendingDelete] = useState<PlayerRecord | null>(null);
|
||||
const [playerForm, setPlayerForm] = useState<PlayerInput>(emptyPlayerForm());
|
||||
const playerFormRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -56,21 +57,40 @@ export function RosterClient() {
|
||||
|
||||
useEffect(() => {
|
||||
if (editingPlayerId) {
|
||||
setShowPlayerForm(true);
|
||||
playerFormRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}, [editingPlayerId]);
|
||||
|
||||
function startEditingPlayer(playerId: string) {
|
||||
setEditingPlayerId(playerId);
|
||||
setShowPlayerForm(true);
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
function stopEditingPlayer() {
|
||||
setEditingPlayerId(null);
|
||||
setShowPlayerForm(false);
|
||||
setPlayerForm(emptyPlayerForm());
|
||||
}
|
||||
|
||||
function startAddingPlayer() {
|
||||
setEditingPlayerId(null);
|
||||
setPlayerForm(emptyPlayerForm());
|
||||
setShowPlayerForm((current) => {
|
||||
const next = !current;
|
||||
if (!current) {
|
||||
requestAnimationFrame(() => {
|
||||
playerFormRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function submitPlayer() {
|
||||
try {
|
||||
const url = editingPlayerId ? `/api/players/${editingPlayerId}` : "/api/players";
|
||||
@@ -99,7 +119,7 @@ export function RosterClient() {
|
||||
return (
|
||||
<AppShell
|
||||
title="Roster Management"
|
||||
subtitle="Add players, update availability, and keep pitching history current from one screen."
|
||||
subtitle="Review the active roster first, then open a compact add or edit form when you need it."
|
||||
>
|
||||
<ConfirmDialog
|
||||
open={playerPendingDelete !== null}
|
||||
@@ -117,10 +137,93 @@ export function RosterClient() {
|
||||
{message ? <Alert tone="success">{message}</Alert> : null}
|
||||
{error ? <Alert tone="error">{error}</Alert> : null}
|
||||
|
||||
<SectionCard
|
||||
title="Current Roster"
|
||||
description="Tap edit for fast changes and check pitcher status at a glance."
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" onClick={startAddingPlayer}>
|
||||
{showPlayerForm && !editingPlayerId ? "Hide Add Player" : "Add Player"}
|
||||
</Button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading roster...</p>
|
||||
) : players.length > 0 ? (
|
||||
<div className="overflow-x-auto rounded-[1.5rem] border border-border bg-white/70">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="border-b border-border bg-muted/60 text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-3 font-semibold">#</th>
|
||||
<th className="px-3 py-3 font-semibold">Player</th>
|
||||
<th className="px-3 py-3 font-semibold">Pos</th>
|
||||
<th className="px-3 py-3 font-semibold">B/T</th>
|
||||
<th className="px-3 py-3 font-semibold">Pitching</th>
|
||||
<th className="px-3 py-3 font-semibold">Status</th>
|
||||
<th className="px-3 py-3 font-semibold">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{players.map((player) => (
|
||||
<tr
|
||||
key={player._id}
|
||||
className={
|
||||
editingPlayerId === player._id ? "bg-secondary/5" : ""
|
||||
}
|
||||
>
|
||||
<td className="px-3 py-3 align-top font-medium">{player.jerseyNumber}</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
<div className="font-medium">{formatPlayerName(player.firstName, player.lastName)}</div>
|
||||
{player.notes ? (
|
||||
<div className="mt-1 max-w-[18rem] text-xs text-muted-foreground">{player.notes}</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-xs text-muted-foreground">
|
||||
<div>{player.primaryPosition}</div>
|
||||
{player.secondaryPosition ? <div>{player.secondaryPosition}</div> : null}
|
||||
{player.tertiaryPosition ? <div>{player.tertiaryPosition}</div> : null}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">{player.batsThrows}</td>
|
||||
<td className="px-3 py-3 align-top text-xs text-muted-foreground">
|
||||
{player.pitcherAvailability.lastPitchedDate
|
||||
? `${player.pitcherAvailability.lastPitchCount} on ${player.pitcherAvailability.lastPitchedDate}`
|
||||
: "No history"}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
<div className="flex flex-col gap-2">
|
||||
<StatusBadge status={player.pitcherAvailability.status} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{player.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => startEditingPlayer(player._id)}>
|
||||
{editingPlayerId === player._id ? "Editing" : "Edit"}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => setPlayerPendingDelete(player)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No players yet"
|
||||
description="Add your first player above to start building the roster."
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{showPlayerForm || editingPlayerId ? (
|
||||
<div ref={playerFormRef}>
|
||||
<SectionCard
|
||||
title={editingPlayerId ? "Edit Player" : "Add Player"}
|
||||
description="Keep player details simple so a coach can update them quickly before game time."
|
||||
description="Use the compact form below to update the roster."
|
||||
>
|
||||
{editingPlayer ? (
|
||||
<Alert tone="info" className="flex items-center justify-between gap-4">
|
||||
@@ -138,91 +241,7 @@ export function RosterClient() {
|
||||
/>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<SectionCard
|
||||
title="Current Roster"
|
||||
description="Tap edit for fast changes and check pitcher status at a glance."
|
||||
>
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading roster...</p>
|
||||
) : players.length > 0 ? (
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
{players.map((player) => (
|
||||
<Card
|
||||
key={player._id}
|
||||
className={
|
||||
editingPlayerId === player._id
|
||||
? "border-secondary/60 bg-secondary/5 ring-1 ring-secondary/30"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<CardContent className="grid gap-4 pt-4 md:pt-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold md:text-xl">
|
||||
#{player.jerseyNumber} {formatPlayerName(player.firstName, player.lastName)}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{player.primaryPosition}
|
||||
{player.secondaryPosition ? ` / ${player.secondaryPosition}` : ""}
|
||||
{player.tertiaryPosition ? ` / ${player.tertiaryPosition}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={player.pitcherAvailability.status} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-[1.5rem] bg-muted/60 p-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Bats/Throws
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium">{player.batsThrows}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Status
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium">
|
||||
{player.isActive ? "Active" : "Inactive"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Pitching
|
||||
</p>
|
||||
<p className="mt-1 text-sm font-medium">
|
||||
{player.pitcherAvailability.lastPitchedDate
|
||||
? `${player.pitcherAvailability.lastPitchCount} pitches on ${player.pitcherAvailability.lastPitchedDate}`
|
||||
: "No history"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{player.notes ? (
|
||||
<div className="rounded-[1.5rem] border border-border/70 bg-white/70 px-4 py-3 text-sm text-muted-foreground">
|
||||
{player.notes}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => startEditingPlayer(player._id)}>
|
||||
{editingPlayerId === player._id ? "Editing" : "Edit"}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => setPlayerPendingDelete(player)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No players yet"
|
||||
description="Add your first player above to start building the roster."
|
||||
/>
|
||||
)}
|
||||
</SectionCard>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,9 +110,6 @@ export function SettingsClient() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button variant="secondary" onClick={() => setRules(DEFAULT_PITCHING_RULES)}>
|
||||
Reset to Example Rules
|
||||
</Button>
|
||||
<Button onClick={() => void saveRules()}>Save Rules</Button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
@@ -14,6 +14,35 @@ export type Position =
|
||||
|
||||
export type PitcherAvailability = "available" | "needs-rest" | "unknown";
|
||||
|
||||
export interface PlayerStatsInput {
|
||||
gamesPlayed?: number | null;
|
||||
atBats?: number | null;
|
||||
runs?: number | null;
|
||||
hits?: number | null;
|
||||
doubles?: number | null;
|
||||
triples?: number | null;
|
||||
homeRuns?: number | null;
|
||||
runsBattedIn?: number | null;
|
||||
walks?: number | null;
|
||||
strikeouts?: number | null;
|
||||
stolenBases?: number | null;
|
||||
}
|
||||
|
||||
export interface GamePlayerStatsInput {
|
||||
playerId: string;
|
||||
playerName: string;
|
||||
atBats?: number | null;
|
||||
runs?: number | null;
|
||||
hits?: number | null;
|
||||
doubles?: number | null;
|
||||
triples?: number | null;
|
||||
homeRuns?: number | null;
|
||||
runsBattedIn?: number | null;
|
||||
walks?: number | null;
|
||||
strikeouts?: number | null;
|
||||
stolenBases?: number | null;
|
||||
}
|
||||
|
||||
export interface PitchingRuleInput {
|
||||
minPitches: number;
|
||||
maxPitches: number | null;
|
||||
@@ -30,6 +59,7 @@ export interface PlayerInput {
|
||||
tertiaryPosition?: Position | null;
|
||||
isActive: boolean;
|
||||
notes?: string;
|
||||
stats?: PlayerStatsInput;
|
||||
}
|
||||
|
||||
export interface PlayerRecord extends PlayerInput {
|
||||
@@ -96,6 +126,21 @@ export interface PlayerAvailabilitySummary {
|
||||
lastPitchCount: number | null;
|
||||
}
|
||||
|
||||
export interface GameRecord {
|
||||
_id?: string;
|
||||
gameDate: string;
|
||||
gameNumber: number;
|
||||
lineupId?: string | null;
|
||||
lineupNumber?: number | null;
|
||||
opponent?: string;
|
||||
teamScore?: number | null;
|
||||
opponentScore?: number | null;
|
||||
notes?: string;
|
||||
playerStats: GamePlayerStatsInput[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface DashboardResponse {
|
||||
rosterCount: number;
|
||||
activePlayers: number;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import { HANDEDNESS_OPTIONS, POSITIONS } from "@/lib/constants";
|
||||
|
||||
const optionalStat = z.union([z.coerce.number().int().min(0), z.null()]).optional();
|
||||
|
||||
export const playerSchema = z.object({
|
||||
firstName: z.string().trim().min(1, "First name is required."),
|
||||
lastName: z.string().trim().min(1, "Last name is required."),
|
||||
@@ -10,7 +12,22 @@ export const playerSchema = z.object({
|
||||
secondaryPosition: z.enum(POSITIONS).nullable().optional(),
|
||||
tertiaryPosition: z.enum(POSITIONS).nullable().optional(),
|
||||
isActive: z.boolean(),
|
||||
notes: z.string().max(500).optional().default("")
|
||||
notes: z.string().max(500).optional().default(""),
|
||||
stats: z
|
||||
.object({
|
||||
gamesPlayed: optionalStat,
|
||||
atBats: optionalStat,
|
||||
runs: optionalStat,
|
||||
hits: optionalStat,
|
||||
doubles: optionalStat,
|
||||
triples: optionalStat,
|
||||
homeRuns: optionalStat,
|
||||
runsBattedIn: optionalStat,
|
||||
walks: optionalStat,
|
||||
strikeouts: optionalStat,
|
||||
stolenBases: optionalStat
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
|
||||
export const pitchingAppearanceSchema = z.object({
|
||||
@@ -52,3 +69,28 @@ export const saveLineupSchema = generateLineupSchema.extend({
|
||||
bench: z.array(z.string()),
|
||||
notes: z.array(z.string())
|
||||
});
|
||||
|
||||
export const saveGameSchema = z.object({
|
||||
gameDate: z.string().min(1),
|
||||
lineupId: z.string().min(1),
|
||||
opponent: z.string().max(120).optional().default(""),
|
||||
teamScore: z.union([z.coerce.number().int().min(0), z.null()]).optional(),
|
||||
opponentScore: z.union([z.coerce.number().int().min(0), z.null()]).optional(),
|
||||
notes: z.string().max(1000).optional().default(""),
|
||||
playerStats: z.array(
|
||||
z.object({
|
||||
playerId: z.string().min(1),
|
||||
playerName: z.string().min(1),
|
||||
atBats: optionalStat,
|
||||
runs: optionalStat,
|
||||
hits: optionalStat,
|
||||
doubles: optionalStat,
|
||||
triples: optionalStat,
|
||||
homeRuns: optionalStat,
|
||||
runsBattedIn: optionalStat,
|
||||
walks: optionalStat,
|
||||
strikeouts: optionalStat,
|
||||
stolenBases: optionalStat
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
@@ -2,21 +2,65 @@ import { Model, Schema, Types, model, models } from "mongoose";
|
||||
|
||||
export interface GameDocument {
|
||||
gameDate: Date;
|
||||
gameNumber: number;
|
||||
activePlayerIds: Types.ObjectId[];
|
||||
startingPitcherId?: Types.ObjectId | null;
|
||||
lineupId?: Types.ObjectId | null;
|
||||
lineupNumber?: number | null;
|
||||
opponent?: string;
|
||||
teamScore?: number | null;
|
||||
opponentScore?: number | null;
|
||||
notes?: string;
|
||||
playerStats: Array<{
|
||||
playerId: Types.ObjectId;
|
||||
playerName: string;
|
||||
atBats?: number | null;
|
||||
runs?: number | null;
|
||||
hits?: number | null;
|
||||
doubles?: number | null;
|
||||
triples?: number | null;
|
||||
homeRuns?: number | null;
|
||||
runsBattedIn?: number | null;
|
||||
walks?: number | null;
|
||||
strikeouts?: number | null;
|
||||
stolenBases?: number | null;
|
||||
}>;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
const gameSchema = new Schema<GameDocument>(
|
||||
{
|
||||
gameDate: { type: Date, required: true, unique: true, index: true },
|
||||
gameDate: { type: Date, required: true, index: true },
|
||||
gameNumber: { type: Number, required: true, min: 1 },
|
||||
activePlayerIds: [{ type: Schema.Types.ObjectId, ref: "Player" }],
|
||||
startingPitcherId: { type: Schema.Types.ObjectId, ref: "Player", default: null },
|
||||
notes: { type: String, default: "" }
|
||||
lineupId: { type: Schema.Types.ObjectId, ref: "Lineup", default: null },
|
||||
lineupNumber: { type: Number, default: null },
|
||||
opponent: { type: String, default: "" },
|
||||
teamScore: { type: Number, default: null },
|
||||
opponentScore: { type: Number, default: null },
|
||||
notes: { type: String, default: "" },
|
||||
playerStats: [
|
||||
{
|
||||
playerId: { type: Schema.Types.ObjectId, ref: "Player", required: true },
|
||||
playerName: { type: String, required: true },
|
||||
atBats: { type: Number, default: null },
|
||||
runs: { type: Number, default: null },
|
||||
hits: { type: Number, default: null },
|
||||
doubles: { type: Number, default: null },
|
||||
triples: { type: Number, default: null },
|
||||
homeRuns: { type: Number, default: null },
|
||||
runsBattedIn: { type: Number, default: null },
|
||||
walks: { type: Number, default: null },
|
||||
strikeouts: { type: Number, default: null },
|
||||
stolenBases: { type: Number, default: null }
|
||||
}
|
||||
]
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
gameSchema.index({ gameDate: 1, gameNumber: 1 }, { unique: true });
|
||||
|
||||
export const GameModel = (models.Game as Model<GameDocument>) || model<GameDocument>("Game", gameSchema);
|
||||
|
||||
@@ -10,6 +10,19 @@ export interface PlayerDocument {
|
||||
tertiaryPosition?: string | null;
|
||||
isActive: boolean;
|
||||
notes?: string;
|
||||
stats?: {
|
||||
gamesPlayed?: number | null;
|
||||
atBats?: number | null;
|
||||
runs?: number | null;
|
||||
hits?: number | null;
|
||||
doubles?: number | null;
|
||||
triples?: number | null;
|
||||
homeRuns?: number | null;
|
||||
runsBattedIn?: number | null;
|
||||
walks?: number | null;
|
||||
strikeouts?: number | null;
|
||||
stolenBases?: number | null;
|
||||
};
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
@@ -24,7 +37,20 @@ const playerSchema = new Schema<PlayerDocument>(
|
||||
secondaryPosition: { type: String, default: null },
|
||||
tertiaryPosition: { type: String, default: null },
|
||||
isActive: { type: Boolean, default: true },
|
||||
notes: { type: String, default: "" }
|
||||
notes: { type: String, default: "" },
|
||||
stats: {
|
||||
gamesPlayed: { type: Number, default: null },
|
||||
atBats: { type: Number, default: null },
|
||||
runs: { type: Number, default: null },
|
||||
hits: { type: Number, default: null },
|
||||
doubles: { type: Number, default: null },
|
||||
triples: { type: Number, default: null },
|
||||
homeRuns: { type: Number, default: null },
|
||||
runsBattedIn: { type: Number, default: null },
|
||||
walks: { type: Number, default: null },
|
||||
strikeouts: { type: Number, default: null },
|
||||
stolenBases: { type: Number, default: null }
|
||||
}
|
||||
},
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user