diff --git a/app/[eventId]/[teamNumber]/client-page.tsx b/app/[eventId]/[teamNumber]/client-page.tsx index 992e651..35713a6 100644 --- a/app/[eventId]/[teamNumber]/client-page.tsx +++ b/app/[eventId]/[teamNumber]/client-page.tsx @@ -15,7 +15,7 @@ import {zodResolver} from "@hookform/resolvers/zod"; import {useForm} from "react-hook-form"; import {Form, FormField, FormItem, FormMessage} from "@/components/ui/form"; import UpdateTeamData from "@/lib/database/update-team-data"; -import {Loader2, MoreVertical, ScanEye} from "lucide-react"; +import {ArrowLeftRight, Loader2, MoreVertical, ScanEye} from "lucide-react"; import {cn} from "@/lib/utils"; import KeyBindListener from "@/components/key-bind-listener"; import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip"; @@ -154,6 +154,12 @@ export default function ClientPage({ View Overview + + + + Compare + + @@ -232,7 +238,11 @@ export default function ClientPage({

Statistics

{statistics} - + { + matches.length != 0 && ( +
+ ) + }

Events

{events} diff --git a/app/[eventId]/overview/[teamNumber]/client-page.tsx b/app/[eventId]/overview/[teamNumber]/client-page.tsx index 8a571f5..437a3a7 100644 --- a/app/[eventId]/overview/[teamNumber]/client-page.tsx +++ b/app/[eventId]/overview/[teamNumber]/client-page.tsx @@ -11,7 +11,7 @@ import StatusBadge from "@/components/status-badge"; import {TeamStatus} from "@/lib/database/set-team-statues"; import RichTextarea from "@/components/rich-textarea"; import React, {ReactNode} from "react"; -import {ArrowDown, ArrowUp, Edit, Minus, MoreVertical} from "lucide-react"; +import {ArrowDown, ArrowLeftRight, ArrowUp, Edit, Minus, MoreVertical} from "lucide-react"; import {percentile, withOrdinalSuffix} from "@/lib/utils"; import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu"; import {Button} from "@/components/ui/button"; @@ -47,33 +47,6 @@ export default function ClientPage({ )); - function epaValue(epa: number, deviation: number) { - let arrow; - let placement; - if (deviation > 0.2) { - arrow = (); - placement = "Above"; - } else if (deviation > -0.2) { - arrow = (); - placement = "Around"; - } else { - arrow = (); - placement = "Below"; - } - - return ( - -

{epa.toFixed(1)}

- {arrow} - - } - content={`${placement} average; ${withOrdinalSuffix((percentile(deviation) * 100))} percentile`} - /> - ); - } - return ( <> @@ -95,6 +68,12 @@ export default function ClientPage({ Make Changes + + + + Compare + + @@ -254,9 +233,9 @@ export default function ClientPage({ + )} -

Events

{ @@ -283,4 +262,31 @@ export default function ClientPage({ {previousSeasons} ); +} + +export function epaValue(epa: number, deviation: number) { + let arrow; + let placement; + if (deviation > 0.2) { + arrow = (); + placement = "Above"; + } else if (deviation > -0.2) { + arrow = (); + placement = "Around"; + } else { + arrow = (); + placement = "Below"; + } + + return ( + +

{epa.toFixed(1)}

+ {arrow} + + } + content={`${placement} average; ${withOrdinalSuffix((percentile(deviation) * 100))} percentile`} + /> + ); } \ No newline at end of file diff --git a/app/[eventId]/overview/compare/client-page.tsx b/app/[eventId]/overview/compare/client-page.tsx new file mode 100644 index 0000000..5950964 --- /dev/null +++ b/app/[eventId]/overview/compare/client-page.tsx @@ -0,0 +1,308 @@ +'use client' + +import {Team, TeamEntry, TeamEvent} from "@prisma/client"; +import React from "react"; +import {Separator} from "@/components/ui/separator"; +import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu"; +import {Button} from "@/components/ui/button"; +import {Edit, MoreVertical, ScanEye} from "lucide-react"; +import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"; +import ThreatGrade from "@/components/threat-grade"; +import {ThreatGradeType} from "@/lib/database/set-threat-grade"; +import StatusBadge from "@/components/status-badge"; +import {TeamStatus} from "@/lib/database/set-team-statues"; +import RichTextarea from "@/components/rich-textarea"; +import QuickTooltip from "@/components/quick-tooltip"; +import {epaValue} from "@/app/[eventId]/overview/[teamNumber]/client-page"; +import EPAOverTime from "@/app/[eventId]/overview/compare/epa-over-time"; +import PreviousSeasons, {columns, Year} from "@/components/previous-seasons"; + +export default function ClientPage({eventId, a, b}: { + eventId: number, + a: { + team: Team, + entry: TeamEntry + matches: any[], + events: TeamEvent[], + previousYears: Year[] + }, + b: { + team: Team, + entry: TeamEntry + matches: any[], + events: TeamEvent[], + previousYears: Year[] + } +}) { + function teamHeader(teamData: typeof a | typeof b) { + return ( +
+
+
+

{teamData.entry.name}

+

Team {teamData.team.number}

+
+ + + + + + + + + Make Changes + + + + + + View Overview + + + + +
+ +
+ ); + } + + function teamData(teamData: typeof a | typeof b) { + return ( +
+ + + Team Details + + +
+

Region

+

{teamData.team.state}

+
+
+

School

+

+ {teamData.team.school?.replace("&", "and")} +

+
+
+

Rookie year

+

{teamData.team.rookieYear}

+
+
+
+ + + Threat Grade + + + + + + + + Status + + +
+

Current Status

+ +
+
+
+ { + teamData.entry.notes != "[{\"children\":[{\"text\":\"\"}],\"type\":\"p\"}]" && ( + + + Notes + + + + + + ) + } +
+ ); + } + + function statistics(teamData: typeof a | typeof b) { + return ( +
+

Statistics

+ + {teamData.matches.length == 0 ? ( +

+ This team has not competed in any matches this season, yet. +

+ ) : ( +
+ + + Record + + +
+

Wins

+

{teamData.entry.wins}

+
+
+

Losses

+

{teamData.entry.losses}

+
+
+

Total

+

{(teamData.entry.wins ?? 0) + (teamData.entry.ties ?? 0) + (teamData.entry.losses ?? 0)}

+
+
+

Win rate

+

+ + {((teamData.entry.wins ?? 0) / ((teamData.entry.wins ?? 0) + (teamData.entry.losses ?? 0)) * 100).toFixed()} + of 100 + + } + content={`Theoretically would win ${ + ((teamData.entry.wins ?? 0) / ((teamData.entry.wins ?? 0) + (teamData.entry.losses ?? 0)) * 100).toFixed() + } out of 100 matches if put up against the same opponents`} + /> +

+
+
+
+ + + + + +
+

World

+

{teamData.entry.worldRank} of {teamData.entry.worldTotal} +

+
+
+

Country

+

{teamData.entry.countyRank} of {teamData.entry.countyTotal}

+
+
+

District

+

{teamData.entry.districtRank} of {teamData.entry.districtTotal}

+
+
+

Event

+

{teamData.entry.eventRank} of {teamData.entry.eventTotal} +

+
+
+
+ + + Expected Points Added + }/> + + +
+

Auto

+ {epaValue(teamData.entry.autoEPA ?? 0, teamData.entry.autoDeviation ?? 0)} +
+
+

Teleop

+ {epaValue(teamData.entry.teleopEPA ?? 0, teamData.entry.teleopDeviation ?? 0)} +
+
+

Endgame

+ {epaValue(teamData.entry.endgameEPA ?? 0, teamData.entry.endgameDeviation ?? 0)} +
+
+

Total

+ {epaValue(teamData.entry.totalEPA ?? 0, teamData.entry.totalDeviation ?? 0)} +
+
+
+
+ )} +
+ ); + } + + return ( + <> +
+ {teamHeader(a)} + {teamData(a)} + {statistics(a)} + { + a.previousYears.length == 0 ? ( +

+ This team has not competed in any previous seasons, yet. +

+ ) : ( + + ) + } + {teamHeader(b)} + {teamData(b)} + {statistics(b)} + { + b.previousYears.length == 0 ? ( +

+ This team has not competed in any previous seasons, yet. +

+ ) : ( + + ) + } +
+
+
+ {teamHeader(a)} + {teamHeader(b)} +
+
+ {teamData(a)} + {teamData(b)} +
+
+ {statistics(a)} + {statistics(b)} +
+
+ +
+
+ { + a.previousYears.length == 0 ? ( +

+ This team has not competed in any previous seasons, yet. +

+ ) : ( + + ) + } + { + b.previousYears.length == 0 ? ( +

+ This team has not competed in any previous seasons, yet. +

+ ) : ( + + ) + } +
+
+ + ); +} \ No newline at end of file diff --git a/app/[eventId]/overview/compare/epa-over-time.tsx b/app/[eventId]/overview/compare/epa-over-time.tsx new file mode 100644 index 0000000..6a53d41 --- /dev/null +++ b/app/[eventId]/overview/compare/epa-over-time.tsx @@ -0,0 +1,141 @@ +import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"; +import React, {useState} from "react"; +import {CartesianGrid, Line, LineChart, XAxis, YAxis} from "recharts"; +import { + ChartConfig, + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent +} from "@/components/ui/chart"; +import {Team, TeamEvent} from "@prisma/client"; +import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"; + +export default function EPAOverTime({a, b}: { + a: { + team: Team, + matches: any[], + events: TeamEvent[] + }, + b: { + team: Team, + matches: any[], + events: TeamEvent[] + } +}) { + const [type, setType] = useState("totalEPA"); + + const chartConfig = { + a: { + label: a.team.number + }, + b: { + label: b.team.number + } + } satisfies ChartConfig + + const matches = [ + ...a.matches.map(value => ({...value, type: "a"})), + ...b.matches.map(value => ({...value, type: "b"})) + ].filter(value => value.totalEPA >= 0).sort((a, b) => a.startTime - b.startTime); + + const chart: any[] = []; + let lastA: number | undefined = undefined; + let lastB: number | undefined = undefined; + + matches.forEach(value => { + if (value.type === "a") { + lastA = value[type]; + } else if (value.type === "b") { + lastB = value[type]; + } + + chart.push({ + time: value.startTime, + a: lastA, + b: lastB + }); + }); + + return ( + + +
+ EPA Over Time + +
+
+ + + + + + } + labelFormatter={(value) => { + return new Date(value * 1000).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + hour: "numeric", + minute: "numeric", + hour12: true + }).replace(" at ", ", "); + }} + cursor={false} + /> + { + return `${ + new Date(value * 1000).toLocaleDateString("en-US", { + month: "short", + day: "numeric" + }) + } `; + }} + /> + + + + }/> + + + +
+ ); +} \ No newline at end of file diff --git a/app/[eventId]/overview/compare/page.tsx b/app/[eventId]/overview/compare/page.tsx new file mode 100644 index 0000000..8fe8221 --- /dev/null +++ b/app/[eventId]/overview/compare/page.tsx @@ -0,0 +1,216 @@ +'use server' + +import prisma from "@/lib/prisma"; +import NotFound from "@/app/not-found"; +import React from "react"; +import ClientPage from "@/app/[eventId]/overview/compare/client-page"; +import {TeamDropdown} from "@/app/[eventId]/overview/compare/team-dropdown"; +import Back from "@/components/back"; + +export default async function Compare({params, searchParams}: { + params: { eventId: string } + searchParams: { a?: string, b?: string } +}) { + const event = await prisma.event.findUnique( + { + where: { + id: +params.eventId + } + } + ); + if (!event) return ; + + const eventTeams = await Promise.all( + (await prisma.teamEntry.findMany( + { + where: { + eventId: event.id + } + } + )).map(async value => (await prisma.team.findMany( + { + where: { + number: value.teamNumber ?? undefined + } + } + ))[0]) + ); + + function teamSelector(a: string | undefined, b: string | undefined) { + if (!event) return; + + return ( + <> + +
+ +

vs

+ +
+ + ); + } + + if (!searchParams.a || !searchParams.b) { + return ( + <> + {teamSelector(searchParams.a, searchParams.b)} +

+ Select two teams to continue. +

+ + ); + } + + const a = await prisma.team.findUnique( + { + where: { + number: +searchParams.a + } + } + ); + const b = await prisma.team.findUnique( + { + where: { + number: +searchParams.b + } + } + ); + if (!a || !b) return ; + + const aEntry = await prisma.teamEntry.findMany( + { + where: { + eventId: +params.eventId, + teamNumber: a.number + } + } + ); + const bEntry = await prisma.teamEntry.findMany( + { + where: { + eventId: +params.eventId, + teamNumber: b.number + } + } + ); + if (!aEntry || !bEntry) return ; + + const aMatches = await Promise.all( + (await prisma.matchEntry.findMany({ + where: { + eventId: aEntry[0].eventId, + teamEntryId: aEntry[0].id, + }, + })).map(async (value) => { + const match = await prisma.match.findUnique({ + where: { + key: value.matchKey ?? undefined, + }, + }); + return { + ...value, + ...(match ?? {}), // Safely spread the match object if it exists, otherwise spread an empty object + }; + }) + ); + const bMatches = await Promise.all( + (await prisma.matchEntry.findMany({ + where: { + eventId: bEntry[0].eventId, + teamEntryId: bEntry[0].id, + }, + })).map(async (value) => { + const match = await prisma.match.findUnique({ + where: { + key: value.matchKey ?? undefined, + }, + }); + return { + ...value, + ...(match ?? {}), // Safely spread the match object if it exists, otherwise spread an empty object + }; + }) + ); + + const aEvents = await prisma.teamEvent.findMany( + { + where: { + teamNumber: a.number + } + } + ); + const bEvents = await prisma.teamEvent.findMany( + { + where: { + teamNumber: b.number + } + } + ); + + const aPastSeasons = (await prisma.teamPastSeason.findMany( + { + where: { + teamNumber: a.number, + year: { + lt: event.year + } + } + } + )).map(value => ({ + year: value.year, + winRate: value.winrate, + rank: { + rank: value.rank, + of: value.totalTeams + }, + epa: { + epa: value.epa, + percentile: value.percentile + } + })).sort((a, b) => b.year - a.year); + const bPastSeasons = (await prisma.teamPastSeason.findMany( + { + where: { + teamNumber: b.number, + year: { + lt: event.year + } + } + } + )).map(value => ({ + year: value.year, + winRate: value.winrate, + rank: { + rank: value.rank, + of: value.totalTeams + }, + epa: { + epa: value.epa, + percentile: value.percentile + } + })).sort((a, b) => b.year - a.year); + + return ( + <> + {teamSelector(searchParams.a, searchParams.b)} + + + ); +} \ No newline at end of file diff --git a/app/[eventId]/overview/compare/team-dropdown.tsx b/app/[eventId]/overview/compare/team-dropdown.tsx new file mode 100644 index 0000000..abb8a22 --- /dev/null +++ b/app/[eventId]/overview/compare/team-dropdown.tsx @@ -0,0 +1,102 @@ +'use client' + +import * as React from "react" + +import {Button} from "@/components/ui/button" +import {Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList,} from "@/components/ui/command" +import {Drawer, DrawerContent, DrawerTrigger,} from "@/components/ui/drawer" +import {Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover" +import {Team} from "@prisma/client"; +import {ChevronsUpDown} from "lucide-react"; + +export function TeamDropdown({eventId, teams, a, b, side}: { + eventId: number, + teams: Team[], + a: string | undefined, + b: string | undefined, + side: "a" | "b" +}) { + const [open, setOpen] = React.useState(false); + const [selectedTeam, setSelectedTeam] = React.useState( + teams.find(value => `${value.number}` == (side == "a" ? a : b)) ?? null + ); + + if (typeof window !== "undefined" && window.innerWidth > 768) { + return ( + + + + + + + + + ) + } + + return ( + + + + + +
+ +
+
+
+ ) +} + +function StatusList({ + setOpen, + teams, + eventId, + a, + b, + side + }: { + setOpen: (open: boolean) => void + teams: Team[], + eventId: number, a: string | undefined, b: string | undefined, side: "a" | "b" +}) { + return ( + + + + No results found. + + {teams.map((team) => ( + !((side == "a" && b == `${team.number}`) || (side == "b" && a == `${team.number}`)) && ( + { + setOpen(false) + if (side == "a") { + a = value; + } else { + b = value; + } + window.open(`/${eventId}/overview/compare?${side == "a" ? `a=${team.number}` : (a ? `a=${a}` : "")}&${side == "b" ? `b=${team.number}` : (b ? `b=${b}` : "")}`, "_self"); + }} + > + {label(team)} + + ) + ))} + + + + ); +} + +function label(team: Team) { + return `Team ${team.number}, ${team.name}`; +} \ No newline at end of file diff --git a/app/[eventId]/overview/page.tsx b/app/[eventId]/overview/page.tsx index 8dda23f..05b9626 100644 --- a/app/[eventId]/overview/page.tsx +++ b/app/[eventId]/overview/page.tsx @@ -7,6 +7,7 @@ import React from "react"; import OverviewCharts from "@/app/[eventId]/overview/overview-charts"; import ScoutingCharts from "@/app/[eventId]/overview/scouting-charts"; import Teams from "@/app/[eventId]/overview/teams"; +import {ArrowLeftRight} from "lucide-react"; export default async function Overview({params}: { params: { eventId: string } }) { if (!+params.eventId) return NotFound(); @@ -59,7 +60,12 @@ export default async function Overview({params}: { params: { eventId: string } }

Scouting Overview

-

Team Overviews

+
+

Team Overviews

+ + + +
diff --git a/components/epa-over-time.tsx b/components/epa-over-time.tsx index af4ad9c..3fdc941 100644 --- a/components/epa-over-time.tsx +++ b/components/epa-over-time.tsx @@ -36,11 +36,11 @@ export default function EPAOverTime({matches, events}: { .sort((a, b) => a.startTime - b.startTime) .map(value => ({ ...value, - xAxis: `${events.find(value1 => value1.eventKey == value.eventKey)?.name ?? ""}\nMatch ${value.matchNumber}` + xAxis: `${events.find(value1 => value1.eventKey == value.eventKey)?.name ?? ""} \nMatch ${value.matchNumber}` })); return ( - + EPA Over Time