diff --git a/baseball/README.md b/baseball/README.md index 5f6897d..26c0d1d 100644 --- a/baseball/README.md +++ b/baseball/README.md @@ -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 diff --git a/baseball/package.json b/baseball/package.json index c35d3c6..d2e1f14 100644 --- a/baseball/package.json +++ b/baseball/package.json @@ -1,5 +1,5 @@ { - "name": "little-league-roster-manager", + "name": "coach-connors-lineup-lab", "version": "0.1.0", "private": true, "scripts": { diff --git a/baseball/src/app/api/games/[id]/route.ts b/baseball/src/app/api/games/[id]/route.ts new file mode 100644 index 0000000..2d03daa --- /dev/null +++ b/baseball/src/app/api/games/[id]/route.ts @@ -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 = { "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); + } +} diff --git a/baseball/src/app/api/games/route.ts b/baseball/src/app/api/games/route.ts new file mode 100644 index 0000000..ed4449a --- /dev/null +++ b/baseball/src/app/api/games/route.ts @@ -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 = { "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); + } +} diff --git a/baseball/src/app/games/page.tsx b/baseball/src/app/games/page.tsx new file mode 100644 index 0000000..6b7208d --- /dev/null +++ b/baseball/src/app/games/page.tsx @@ -0,0 +1,5 @@ +import { GameScoringClient } from "@/components/GameScoringClient"; + +export default function GamesPage() { + return ; +} diff --git a/baseball/src/app/layout.tsx b/baseball/src/app/layout.tsx index 897c34a..234a85f 100644 --- a/baseball/src/app/layout.tsx +++ b/baseball/src/app/layout.tsx @@ -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 }>) { diff --git a/baseball/src/components/AppShell.tsx b/baseball/src/components/AppShell.tsx index fe92970..774681a 100644 --- a/baseball/src/components/AppShell.tsx +++ b/baseball/src/components/AppShell.tsx @@ -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({
- Little League Coach Console + Coach Connor's Lineup Lab

{title}

{subtitle}

diff --git a/baseball/src/components/DashboardClient.tsx b/baseball/src/components/DashboardClient.tsx index a42aaf9..cecf52c 100644 --- a/baseball/src/components/DashboardClient.tsx +++ b/baseball/src/components/DashboardClient.tsx @@ -15,6 +15,8 @@ export function DashboardClient() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const visiblePitching = data?.recentPitching.filter((entry) => entry.status !== "unknown") ?? []; + async function loadDashboard() { setLoading(true); setError(null); @@ -66,31 +68,38 @@ export function DashboardClient() { title="Pitcher Availability" description="Availability is based on the latest appearance and your configured rest rules." > -
- {data.recentPitching.map((entry) => ( -
-
-

{entry.playerName}

-

- {entry.lastPitchedDate - ? `${entry.pitchCount} pitches on ${entry.lastPitchedDate}` - : "No pitching history recorded yet."} -

+ {visiblePitching.length > 0 ? ( +
+ {visiblePitching.map((entry) => ( +
+
+

{entry.playerName}

+

+ {entry.lastPitchedDate + ? `${entry.pitchCount} pitches on ${entry.lastPitchedDate}` + : "No pitching history recorded yet."} +

+
+
+ +

+ {entry.daysSinceLastAppearance === null + ? "No history" + : `${entry.daysSinceLastAppearance} day(s) since last outing`} +

+
-
- -

- {entry.daysSinceLastAppearance === null - ? "No history" - : `${entry.daysSinceLastAppearance} day(s) since last outing`} -

-
-
- ))} -
+ ))} +
+ ) : ( + + )} ) : ( diff --git a/baseball/src/components/GameScoringClient.tsx b/baseball/src/components/GameScoringClient.tsx new file mode 100644 index 0000000..1e2f995 --- /dev/null +++ b/baseball/src/components/GameScoringClient.tsx @@ -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([]); + const [selectedLineupId, setSelectedLineupId] = useState(""); + const [savedGames, setSavedGames] = useState([]); + const [gamePendingDelete, setGamePendingDelete] = useState(null); + const [opponent, setOpponent] = useState(""); + const [teamScore, setTeamScore] = useState(""); + const [opponentScore, setOpponentScore] = useState(""); + const [notes, setNotes] = useState(""); + const [playerStats, setPlayerStats] = useState>({}); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + async function loadLineups() { + try { + const response = await fetchJson("/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(`/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, 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("/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 ( + + setGamePendingDelete(null)} + onConfirm={() => (gamePendingDelete ? void deleteGame(gamePendingDelete) : undefined)} + /> + + {message ? {message} : null} + {error ? {error} : null} + + + {lineups.length > 0 ? ( +
+ + + +
+ + +
+