From 60ba3d4a6ef7f4953c8c9906fcc22cde0556033a Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Fri, 31 Oct 2025 22:13:12 -0700 Subject: [PATCH] feat: Implement event management for sports seasons with CRUD operations and UI --- .mcp.json | 11 + app/routes.ts | 8 + ...orts-seasons.$id.events.$eventId.server.ts | 136 +++++++++ ...min.sports-seasons.$id.events.$eventId.tsx | 287 ++++++++++++++++++ .../admin.sports-seasons.$id.events.server.ts | 82 +++++ .../admin.sports-seasons.$id.events.tsx | 211 +++++++++++++ plans/scoring-system.md | 12 +- 7 files changed, 742 insertions(+), 5 deletions(-) create mode 100644 .mcp.json create mode 100644 app/routes/admin.sports-seasons.$id.events.$eventId.server.ts create mode 100644 app/routes/admin.sports-seasons.$id.events.$eventId.tsx create mode 100644 app/routes/admin.sports-seasons.$id.events.server.ts create mode 100644 app/routes/admin.sports-seasons.$id.events.tsx diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..bd98b4f --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "shadcn": { + "command": "npx", + "args": [ + "shadcn@latest", + "mcp" + ] + } + } +} diff --git a/app/routes.ts b/app/routes.ts index 4a20640..108a74a 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -44,6 +44,14 @@ export default [ "sports-seasons/:id/participants", "routes/admin.sports-seasons.$id.participants.tsx" ), + route( + "sports-seasons/:id/events", + "routes/admin.sports-seasons.$id.events.tsx" + ), + route( + "sports-seasons/:id/events/:eventId", + "routes/admin.sports-seasons.$id.events.$eventId.tsx" + ), route("participants", "routes/admin.participants.tsx"), route("templates", "routes/admin.templates.tsx"), route("templates/new", "routes/admin.templates.new.tsx"), diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts new file mode 100644 index 0000000..2ba8bb2 --- /dev/null +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.server.ts @@ -0,0 +1,136 @@ +import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId"; +import { findSportsSeasonById } from "~/models/sports-season"; +import { findParticipantsBySportsSeasonId } from "~/models/participant"; +import { + getScoringEventById, + completeScoringEvent, +} from "~/models/scoring-event"; +import { + getEventResults, + createEventResult, + updateEventResult, + deleteEventResult, + type CreateEventResultData, + type UpdateEventResultData, +} from "~/models/event-result"; + +export async function loader({ params }: Route.LoaderArgs) { + const sportsSeason = await findSportsSeasonById(params.id); + + if (!sportsSeason) { + throw new Response("Sports season not found", { status: 404 }); + } + + const event = await getScoringEventById(params.eventId); + + if (!event) { + throw new Response("Event not found", { status: 404 }); + } + + const participants = await findParticipantsBySportsSeasonId(params.id); + const results = await getEventResults(params.eventId); + + return { + sportsSeason: sportsSeason as typeof sportsSeason & { + sport: { id: string; name: string; type: string; slug: string }; + }, + event, + participants, + results, + }; +} + +export async function action({ request, params }: Route.ActionArgs) { + const formData = await request.formData(); + const intent = formData.get("intent"); + + if (intent === "complete") { + try { + await completeScoringEvent(params.eventId); + return { success: "Event marked as completed" }; + } catch (error) { + console.error("Error completing event:", error); + return { error: "Failed to complete event" }; + } + } + + if (intent === "add-result") { + const participantId = formData.get("participantId"); + const placement = formData.get("placement"); + + if (typeof participantId !== "string" || !participantId) { + return { error: "Participant is required" }; + } + + if (typeof placement !== "string" || !placement) { + return { error: "Placement is required" }; + } + + const placementNum = parseInt(placement, 10); + if (isNaN(placementNum) || placementNum < 1 || placementNum > 100) { + return { error: "Placement must be between 1 and 100" }; + } + + const resultData: CreateEventResultData = { + scoringEventId: params.eventId, + participantId, + placement: placementNum, + }; + + try { + await createEventResult(resultData); + return { success: "Result added successfully" }; + } catch (error) { + console.error("Error adding result:", error); + return { error: "Failed to add result" }; + } + } + + if (intent === "update-result") { + const resultId = formData.get("resultId"); + const placement = formData.get("placement"); + + if (typeof resultId !== "string" || !resultId) { + return { error: "Result ID is required" }; + } + + if (typeof placement !== "string" || !placement) { + return { error: "Placement is required" }; + } + + const placementNum = parseInt(placement, 10); + if (isNaN(placementNum) || placementNum < 1 || placementNum > 100) { + return { error: "Placement must be between 1 and 100" }; + } + + const updateData: UpdateEventResultData = { + placement: placementNum, + }; + + try { + await updateEventResult(resultId, updateData); + return { success: "Result updated successfully" }; + } catch (error) { + console.error("Error updating result:", error); + return { error: "Failed to update result" }; + } + } + + if (intent === "delete-result") { + const resultId = formData.get("resultId"); + + if (typeof resultId !== "string" || !resultId) { + return { error: "Result ID is required" }; + } + + try { + await deleteEventResult(resultId); + return { success: "Result deleted successfully" }; + } catch (error) { + console.error("Error deleting result:", error); + return { error: "Failed to delete result" }; + } + } + + return { error: "Invalid action" }; +} diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.tsx b/app/routes/admin.sports-seasons.$id.events.$eventId.tsx new file mode 100644 index 0000000..88f5f6e --- /dev/null +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.tsx @@ -0,0 +1,287 @@ +import { Form, Link } from "react-router"; +import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId"; +import { loader, action } from "./admin.sports-seasons.$id.events.$eventId.server"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { Badge } from "~/components/ui/badge"; +import { ArrowLeft, Trophy, CheckCircle2, Pencil, Trash2 } from "lucide-react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "~/components/ui/table"; + +export { loader, action }; + +export default function EventResults({ + loaderData, + actionData, +}: Route.ComponentProps) { + const { sportsSeason, event, participants, results } = loaderData; + + // Create a map of participants with results for easy lookup + const participantResultsMap = new Map( + results.map((r: { participant: { id: string } }) => [r.participant.id, r]) + ); + + // Get participants without results + const participantsWithoutResults = participants.filter( + (p: { id: string }) => !participantResultsMap.has(p.id) + ); + + // Sort results by placement + const sortedResults = [...results].sort((a, b) => (a.placement || 999) - (b.placement || 999)); + + const getEventTypeLabel = (type: string) => { + switch (type) { + case "playoff_game": + return "Playoff Game"; + case "major_tournament": + return "Major Tournament"; + case "race": + return "Race"; + case "final_standings": + return "Final Standings"; + default: + return type; + } + }; + + return ( +
+
+
+ +
+
+

{event.name}

+

+ {sportsSeason.sport.name} - {sportsSeason.name} •{" "} + {getEventTypeLabel(event.eventType)} + {event.playoffRound && ` • ${event.playoffRound}`} +

+
+ {event.isComplete ? ( + + + Completed + + ) : ( + In Progress + )} +
+
+ +
+ {actionData?.error && ( +
+ {actionData.error} +
+ )} + + {actionData?.success && ( +
+ {actionData.success} +
+ )} + + {/* Add Result Form */} + {!event.isComplete && ( + + + Add Result + + Enter the placement for a participant in this event + + + +
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+
+ )} + + {/* Current Results */} + + +
+
+ Results + + {results.length} of {participants.length} participants have + results + +
+ {!event.isComplete && results.length > 0 && ( +
+ + +
+ )} +
+
+ + {sortedResults.length === 0 ? ( +

+ No results added yet. Add results using the form above. +

+ ) : ( + + + + Placement + Participant + {!event.isComplete && Actions} + + + + {sortedResults.map((result) => ( + + +
+ {result.placement === 1 && ( + 🥇 + )} + {result.placement === 2 && ( + 🥈 + )} + {result.placement === 3 && ( + 🥉 + )} + + {result.placement} + +
+
+ {result.participant.name} + {!event.isComplete && ( + +
+
+ + + { + if (e.key === 'Enter') { + e.currentTarget.form?.requestSubmit(); + } + }} + /> + + +
+ + + + +
+
+ )} +
+ ))} +
+
+ )} +
+
+
+
+
+ ); +} diff --git a/app/routes/admin.sports-seasons.$id.events.server.ts b/app/routes/admin.sports-seasons.$id.events.server.ts new file mode 100644 index 0000000..c1dd071 --- /dev/null +++ b/app/routes/admin.sports-seasons.$id.events.server.ts @@ -0,0 +1,82 @@ +import type { Route } from "./+types/admin.sports-seasons.$id.events"; +import { redirect } from "react-router"; +import { findSportsSeasonById } from "~/models/sports-season"; +import { + getScoringEventsForSportsSeason, + createScoringEvent, + deleteScoringEvent, + type CreateScoringEventData, +} from "~/models/scoring-event"; + +export async function loader({ params }: Route.LoaderArgs) { + const sportsSeason = await findSportsSeasonById(params.id); + + if (!sportsSeason) { + throw new Response("Sports season not found", { status: 404 }); + } + + const events = await getScoringEventsForSportsSeason(params.id); + + return { + sportsSeason: sportsSeason as typeof sportsSeason & { + sport: { id: string; name: string; type: string; slug: string }; + }, + events, + }; +} + +export async function action({ request, params }: Route.ActionArgs) { + const formData = await request.formData(); + const intent = formData.get("intent"); + + if (intent === "delete-event") { + const eventId = formData.get("eventId"); + + if (typeof eventId !== "string" || !eventId) { + return { error: "Event ID is required" }; + } + + try { + await deleteScoringEvent(eventId); + return { success: "Event deleted successfully" }; + } catch (error) { + console.error("Error deleting event:", error); + return { error: "Failed to delete event" }; + } + } + + const name = formData.get("name"); + const eventType = formData.get("eventType"); + const eventDate = formData.get("eventDate"); + const playoffRound = formData.get("playoffRound"); + + // Validation + if (typeof name !== "string" || !name.trim()) { + return { error: "Event name is required" }; + } + + if ( + eventType !== "playoff_game" && + eventType !== "major_tournament" && + eventType !== "race" && + eventType !== "final_standings" + ) { + return { error: "Invalid event type" }; + } + + const eventData: CreateScoringEventData = { + sportsSeasonId: params.id, + name: name.trim(), + eventType: eventType as "playoff_game" | "major_tournament" | "race" | "final_standings", + eventDate: typeof eventDate === "string" && eventDate ? new Date(eventDate) : undefined, + playoffRound: typeof playoffRound === "string" && playoffRound ? playoffRound : undefined, + }; + + try { + const event = await createScoringEvent(eventData); + return redirect(`/admin/sports-seasons/${params.id}/events/${event.id}`); + } catch (error) { + console.error("Error creating scoring event:", error); + return { error: "Failed to create scoring event. Please try again." }; + } +} diff --git a/app/routes/admin.sports-seasons.$id.events.tsx b/app/routes/admin.sports-seasons.$id.events.tsx new file mode 100644 index 0000000..b4207fd --- /dev/null +++ b/app/routes/admin.sports-seasons.$id.events.tsx @@ -0,0 +1,211 @@ +import { Form, Link } from "react-router"; +import type { Route } from "./+types/admin.sports-seasons.$id.events"; +import { loader, action } from "./admin.sports-seasons.$id.events.server"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { Badge } from "~/components/ui/badge"; +import { Calendar, Trophy, ArrowLeft, Trash2 } from "lucide-react"; +import { format } from "date-fns"; + +export { loader, action }; + +export default function SportsSeasonEvents({ + loaderData, + actionData, +}: Route.ComponentProps) { + const { sportsSeason, events } = loaderData; + + const getEventTypeLabel = (type: string) => { + switch (type) { + case "playoff_game": + return "Playoff Game"; + case "major_tournament": + return "Major Tournament"; + case "race": + return "Race"; + case "final_standings": + return "Final Standings"; + default: + return type; + } + }; + + const getStatusBadge = (isComplete: boolean) => { + return isComplete ? ( + + Completed + + ) : ( + In Progress + ); + }; + + return ( +
+
+
+ +

Scoring Events

+

+ {sportsSeason.sport.name} - {sportsSeason.name} +

+
+ +
+ {/* Create New Event Card */} + + + Create New Event + + Add a new scoring event (game, tournament, race, etc.) + + + +
+
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ + {actionData?.error && ( +
+ {actionData.error} +
+ )} + + +
+
+
+ + {/* Events List */} + + + Events + + {events.length} {events.length === 1 ? "event" : "events"} + + + + {events.length === 0 ? ( +

+ No events created yet. Create your first event above. +

+ ) : ( +
+ {events.map((event: { id: string; name: string; eventType: string; playoffRound?: string | null; eventDate?: string | null; isComplete: boolean }) => ( + + +
+ +
+

{event.name}

+ {getStatusBadge(event.isComplete)} +
+
+ + {getEventTypeLabel(event.eventType)} + + {event.playoffRound && {event.playoffRound}} + {event.eventDate && ( + + + {format(new Date(event.eventDate), "MMM d, yyyy")} + + )} +
+ +
+ + + +
+
+
+
+ ))} +
+ )} +
+
+
+
+
+ ); +} diff --git a/plans/scoring-system.md b/plans/scoring-system.md index fafcf67..0258dba 100644 --- a/plans/scoring-system.md +++ b/plans/scoring-system.md @@ -959,11 +959,13 @@ All clarification questions (Q1-Q21) have been answered and confirmed: - [x] Disable editing after draft starts (settings page only) - [x] Show helpful tips and preview -- [ ] **1.4** Basic admin result entry UI - - [ ] Create admin route structure for sports seasons - - [ ] List scoring events for a sports season - - [ ] Create new event form - - [ ] Basic result entry form (placement only) +- [x] **1.4** Basic admin result entry UI ✅ *Completed* + - [x] Create admin route structure for sports seasons + - [x] List scoring events for a sports season + - [x] Create new event form + - [x] Basic result entry form (placement only) + - [x] Update existing results (inline editing) + - [x] Delete results with confirmation ### Phase 2: Playoff Scoring (Single Elimination) **Goal**: Implement playoff bracket tracking and scoring for NFL/NBA/MLB