diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 2985d4ab..fd663175 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -7,7 +7,7 @@ "kind": "build", "isDefault": true }, - "type": "shell", + "type": "shell", "command": "turbo run build --filter=${input:project}-frontend --filter=${input:project}-backend" }, { diff --git a/apps/scouting/backend/build.ts b/apps/scouting/backend/build.ts index 538ac1be..0b572042 100644 --- a/apps/scouting/backend/build.ts +++ b/apps/scouting/backend/build.ts @@ -2,7 +2,7 @@ import { build, context } from "esbuild"; import { spawn } from "child_process"; -const isDev = process.env.NODE_ENV === "DEV"; +const isDev = process.env.NODE_ENV !== "DEV"; const bundlePath = "dist/bundle.js"; diff --git a/apps/scouting/backend/src/routes/compare-router.ts b/apps/scouting/backend/src/routes/compare-router.ts new file mode 100644 index 00000000..7d13e842 --- /dev/null +++ b/apps/scouting/backend/src/routes/compare-router.ts @@ -0,0 +1,140 @@ +//בס"ד + +import type { ScoutingForm, TeleClimbLevel } from "@repo/scouting_types"; +import { Router } from "express"; +import { getFormsCollection } from "./forms-router"; +import { pipe } from "fp-ts/lib/function"; +import { + flatMap, + left, + map, + right, + tryCatch, + fold, +} from "fp-ts/lib/TaskEither"; +import { mongofyQuery } from "../middleware/query"; +import { StatusCodes } from "http-status-codes"; +import { calculateSum, firstElement, isEmpty } from "@repo/array-functions"; +import { calcAverageGeneralFuelData } from "./general-router"; +import { generalCalculateFuel } from "../fuel/fuel-general"; +import { getBPSes } from "./teams-router"; +import { averageFuel } from "../fuel/distance-split"; + +export const compareRouter = Router(); + +type GamePeriod = "auto" | "fullGame"; + +const DIGITS_AFTER_DOT = 2; +const INITIAL_COUNTER_VALUE = 0; +const INCREMENT = 1; + +const calculateAverageScoredFuel = ( + forms: ScoutingForm[], + gamePeriod: GamePeriod, +) => { + const generalFuelData = forms.map((form) => + generalCalculateFuel(form, getBPSes()), + ); + const averagedFuelData = calcAverageGeneralFuelData(generalFuelData); + + return parseFloat( + averagedFuelData[gamePeriod].scored.toFixed(DIGITS_AFTER_DOT), + ); +}; + +const findMaxClimbLevel = (forms: ScoutingForm[]) => { + const fullGameClimbedLevels = [ + ...forms.map((form) => form.tele.climb.level), + ...forms.map((form) => form.auto.climb.level), + ]; + + return fullGameClimbedLevels.includes("L3") + ? "L3" + : fullGameClimbedLevels.includes("L2") + ? "L2" + : fullGameClimbedLevels.includes("L1") + ? "L1" + : "L0"; +}; + +const findTimesClimbedToMax = ( + forms: ScoutingForm[], + maxLevel: TeleClimbLevel, +) => { + return calculateSum(forms, (form) => + form.tele.climb.level === maxLevel ? INCREMENT : INITIAL_COUNTER_VALUE, + ); +}; + +const findTimesClimbedInAuto = (forms: ScoutingForm[]) => { + return calculateSum(forms, (form) => + form.auto.climb.level === "L1" ? INCREMENT : INITIAL_COUNTER_VALUE, + ); +}; + +const timesClimedToLevel = ( + level: TeleClimbLevel, + climbedLevels: TeleClimbLevel[], +) => climbedLevels.filter((currentLevel) => currentLevel === level).length; + +const findTimesClimbedToLevels = (forms: ScoutingForm[]) => { + const climbedLevels = forms.map((form) => form.tele.climb.level); + return { + L1: timesClimedToLevel("L1", climbedLevels), + L2: timesClimedToLevel("L2", climbedLevels), + L3: timesClimedToLevel("L3", climbedLevels), + }; +}; + + +compareRouter.get("/", async (req, res) => { + await pipe( + getFormsCollection(), + flatMap((collection) => + tryCatch( + () => collection.find(mongofyQuery(req.query)).toArray(), + (error) => ({ + status: StatusCodes.INTERNAL_SERVER_ERROR, + reason: `DB Error: ${error}`, + }), + ), + ), + flatMap((forms) => { + if (isEmpty(forms)) return right(forms); + + const firstTeam = firstElement(forms).teamNumber; + const isSameTeam = forms.every((f) => f.teamNumber === firstTeam); + + return isSameTeam + ? right(forms) + : left({ + status: StatusCodes.BAD_REQUEST, + reason: + "Compare Two Validation Error: Forms contain data from multiple different teams.", + }); + }), + map((teamForms) => ({ + teamNumber: firstElement(teamForms).teamNumber, + averageFuel: { + averageFuelInGame: calculateAverageScoredFuel(teamForms, "fullGame"), + averageFuelInAuto: calculateAverageScoredFuel(teamForms, "auto"), + }, + climb: { + maxClimbLevel: findMaxClimbLevel(teamForms), + timesClimbedToMax: findTimesClimbedToMax( + teamForms, + findMaxClimbLevel(teamForms), + ), + timesClimbedInAuto: findTimesClimbedInAuto(teamForms), + timesClimbedToLevels: findTimesClimbedToLevels(teamForms), + }, + })), + + fold( + (error) => () => + Promise.resolve(res.status(error.status).send(error.reason)), + (teamCompareData) => () => + Promise.resolve(res.status(StatusCodes.OK).json({ teamCompareData })), + ), + )(); +}); diff --git a/apps/scouting/backend/src/routes/forms-router.ts b/apps/scouting/backend/src/routes/forms-router.ts index fef64eb5..db71b6a7 100644 --- a/apps/scouting/backend/src/routes/forms-router.ts +++ b/apps/scouting/backend/src/routes/forms-router.ts @@ -3,7 +3,7 @@ import { Router } from "express"; import { flow, pipe } from "fp-ts/lib/function"; import { getDb } from "../middleware/db"; -import { flatMap, fold, fromEither, map } from "fp-ts/lib/TaskEither"; +import { flatMap, fold, fromEither, map, tryCatch } from "fp-ts/lib/TaskEither"; import { scoutingFormCodec, type ScoutingForm } from "@repo/scouting_types"; import { StatusCodes } from "http-status-codes"; import { createBodyVerificationPipe } from "../middleware/verification"; @@ -57,3 +57,28 @@ formsRouter.post("/", async (req, res) => { ), )(); }); + +formsRouter.get("/teams", async (req, res) => { + await pipe( + getFormsCollection(), + flatMap((collection) => + tryCatch( + () => + collection + .find(mongofyQuery({ "match.type": "qualification" })) + .toArray(), + (error) => ({ + status: StatusCodes.INTERNAL_SERVER_ERROR, + reason: `DB Error: ${error}`, + }), + ), + ), + map((forms) => forms.map((form) => form.teamNumber)), + fold( + (error) => () => + Promise.resolve(res.status(error.status).send(error.reason)), + (teamNumbers) => () => + Promise.resolve(res.status(StatusCodes.OK).json({ teamNumbers })), + ), + )(); +}); diff --git a/apps/scouting/backend/src/routes/general-router.ts b/apps/scouting/backend/src/routes/general-router.ts index 8bfb4e66..ce8cb0a1 100644 --- a/apps/scouting/backend/src/routes/general-router.ts +++ b/apps/scouting/backend/src/routes/general-router.ts @@ -1,5 +1,4 @@ //בס"ד -/* eslint-disable @typescript-eslint/no-magic-numbers */ //for the example bps import { Router } from "express"; import { getFormsCollection } from "./forms-router"; @@ -8,10 +7,10 @@ import { flatMap, fold, map, tryCatch } from "fp-ts/lib/TaskEither"; import { mongofyQuery } from "../middleware/query"; import { generalCalculateFuel } from "../fuel/fuel-general"; import { StatusCodes } from "http-status-codes"; - import type { BPS, FuelObject, GeneralFuelData } from "@repo/scouting_types"; import { averageFuel } from "../fuel/distance-split"; import { firstElement, isEmpty } from "@repo/array-functions"; +import { getBPSes } from "./teams-router"; export const generalRouter = Router(); @@ -21,13 +20,9 @@ interface AccumulatedFuelData { tele: FuelObject[]; } -const getBPS = () => { - return []; -}; - const ONE_ITEM_ARRAY = 1; -const calcAverageGeneralFuelData = (fuelData: GeneralFuelData[]) => { +export const calcAverageGeneralFuelData = (fuelData: GeneralFuelData[]) => { if (fuelData.length === ONE_ITEM_ARRAY || isEmpty(fuelData)) { return firstElement(fuelData); } @@ -70,7 +65,7 @@ generalRouter.get("/", async (req, res) => { map((forms) => forms.map((form) => ({ teamNumber: form.teamNumber, - generalFuelData: generalCalculateFuel(form, getBPS()), + generalFuelData: generalCalculateFuel(form, getBPSes()), })), ), @@ -79,7 +74,7 @@ generalRouter.get("/", async (req, res) => { (accumulatorRecord, fuelData) => ({ ...accumulatorRecord, [fuelData.teamNumber]: [ - ...accumulatorRecord[fuelData.teamNumber], + ...(accumulatorRecord[fuelData.teamNumber] ?? []), fuelData.generalFuelData, ], }), diff --git a/apps/scouting/backend/src/routes/index.ts b/apps/scouting/backend/src/routes/index.ts index 031242d4..bb94b519 100644 --- a/apps/scouting/backend/src/routes/index.ts +++ b/apps/scouting/backend/src/routes/index.ts @@ -6,6 +6,7 @@ import { gameRouter } from "./game-router"; import { formsRouter } from "./forms-router"; import { teamsRouter } from "./teams-router"; import { generalRouter } from "./general-router"; +import { compareRouter } from "./compare-router"; export const apiRouter = Router(); @@ -14,6 +15,7 @@ apiRouter.use("/tba", tbaRouter); apiRouter.use("/game", gameRouter); apiRouter.use("/team",teamsRouter) apiRouter.use("/general", generalRouter); +apiRouter.use("/compare", compareRouter); apiRouter.get("/health", (req, res) => { res.status(StatusCodes.OK).send({ message: "Healthy!" }); diff --git a/apps/scouting/backend/src/routes/teams-router.ts b/apps/scouting/backend/src/routes/teams-router.ts index 1558ff0a..4e8c905a 100644 --- a/apps/scouting/backend/src/routes/teams-router.ts +++ b/apps/scouting/backend/src/routes/teams-router.ts @@ -116,7 +116,7 @@ const processTeam = (bpses: BPS[], forms: ScoutingForm[]): TeamData => { return { tele, auto, fullGame, metrics: { epa: 0, bps: 0 } }; }; -const getBPSes = (): BPS[] => [ +export const getBPSes = (): BPS[] => [ { events: [ { diff --git a/apps/scouting/frontend/src/App.tsx b/apps/scouting/frontend/src/App.tsx index 5be893b5..49c44212 100644 --- a/apps/scouting/frontend/src/App.tsx +++ b/apps/scouting/frontend/src/App.tsx @@ -3,12 +3,21 @@ import type { FC } from "react"; import { ScoutMatch } from "./scouter/pages/ScoutMatch"; import { Route, Routes } from "react-router-dom"; import { ScoutedMatches } from "./scouter/pages/ScoutedMatches"; +import { CompareTwo } from "./strategy/CompareTwo"; +import { GeneralDataTable } from "./strategy/GeneralDataTable"; const App: FC = () => { return ( } /> } /> + } /> + + } + /> ); }; diff --git a/apps/scouting/frontend/src/scouter/pages/ScoutedMatches.tsx b/apps/scouting/frontend/src/scouter/pages/ScoutedMatches.tsx index 8b6befa4..e8430b4a 100644 --- a/apps/scouting/frontend/src/scouter/pages/ScoutedMatches.tsx +++ b/apps/scouting/frontend/src/scouter/pages/ScoutedMatches.tsx @@ -36,7 +36,7 @@ export const ScoutedMatches: FC = () => { const submitMatch = async (form: ScoutingForm, index: number) => { setIsLoading(true); try { - const response = await fetch("/api/v1/forms/single", { + const response = await fetch("/api/v1/forms", { method: "POST", headers: { "Content-Type": "application/json", @@ -56,7 +56,7 @@ export const ScoutedMatches: FC = () => { const submitAll = async () => { setIsLoading(true); try { - const response = await fetch("/api/v1/forms/multiple", { + const response = await fetch("/api/v1/forms", { method: "POST", headers: { "Content-Type": "application/json", diff --git a/apps/scouting/frontend/src/strategy/CompareTwo.tsx b/apps/scouting/frontend/src/strategy/CompareTwo.tsx new file mode 100644 index 00000000..4f0e3725 --- /dev/null +++ b/apps/scouting/frontend/src/strategy/CompareTwo.tsx @@ -0,0 +1,251 @@ +//בס"ד + +import { firstElement, secondElement } from "@repo/array-functions"; +import type { CompareData, TeamCompareData } from "@repo/scouting_types"; +import type React from "react"; +import { useEffect, useState } from "react"; +import { fetchTeamNumbers } from "./fetches"; + +const compareUrl = "/api/v1/compare/"; + +const MAX_SELECTED_TEAMS = 2; +const DEFAULT_LEVEL = 0; +const FIRST_INDEX = 0; +const MIN_AMOUNT_CLIMB = 0; + +const fetchTeamCompareData = async (teamNumber: number) => { + const params = new URLSearchParams({ teamNumber: teamNumber.toString() }); + const url = `${compareUrl}?${params.toString()}`; + + try { + const response = await fetch(url, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Server Error: ${errorText}`); + } + + const data = await response.json(); + return data.teamCompareData as TeamCompareData; + } catch (err) { + console.error("Fetch failed:", err); + throw err; + } +}; + +const StatBox = ({ + label, + value, + color, +}: { + label: string; + value: number | string; + color: string; +}) => ( +
+ + {label} + + {value} +
+); + +const LevelMiniStat = ({ label, count }: { label: string; count: number }) => ( +
+ + {label} + + MIN_AMOUNT_CLIMB ? "text-emerald-400" : "text-slate-700"}`} + > + {count} + +
+); + +export const CompareTwo: React.FC = () => { + const [teamNumbers, setTeamNumbers] = useState([]); + const [selectedTeams, setSelectedTeams] = useState([]); + const [comparisonData, setComparisonData] = useState( + null, + ); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + fetchTeamNumbers().then(setTeamNumbers).catch(console.error); + }, []); + + const toggleTeamSelection = (selectedTeamNumber: number) => { + setSelectedTeams((prev) => + prev.includes(selectedTeamNumber) + ? prev.filter((teamNumber) => teamNumber !== selectedTeamNumber) + : prev.length < MAX_SELECTED_TEAMS + ? [...prev, selectedTeamNumber] + : prev, + ); + }; + + const handleCompare = async () => { + if (selectedTeams.length !== MAX_SELECTED_TEAMS) return; + setIsLoading(true); + try { + const [firstTeam, secondTeam] = await Promise.all([ + fetchTeamCompareData(firstElement(selectedTeams)), + fetchTeamCompareData(secondElement(selectedTeams)), + ]); + + setComparisonData({ teamOne: firstTeam, teamTwo: secondTeam }); + } catch (err) { + console.error(`Failed to fetch team data: ${err}`); + } finally { + setIsLoading(false); + } + }; + + const getStatColor = ( + thisTeamStat: number, + otherTeamStat: number, + isHigherBetter = true, + ) => { + if (thisTeamStat === otherTeamStat) return "bg-slate-900/50 text-slate-400"; + const isWinner = isHigherBetter + ? thisTeamStat > otherTeamStat + : thisTeamStat < otherTeamStat; + return isWinner + ? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" + : "bg-rose-500/5 text-rose-500/60 border-rose-500/10"; + }; + + const levelToScore = (level: string) => { + const map: Record = { L0: 0, L1: 1, L2: 2, L3: 3 }; + return map[level] || DEFAULT_LEVEL; + }; + + return ( +
+
+
+ + Select Teams + +
+ {teamNumbers.map((teamNumber) => ( + + ))} +
+
+ + +
+ + {comparisonData && ( +
+ {Object.values(comparisonData).map((team: TeamCompareData, idx) => { + const other = + idx === FIRST_INDEX + ? comparisonData.teamTwo + : comparisonData.teamOne; + return ( +
+
+ + Scouting Report + + + Team {team.teamNumber} + +
+ + + + + +
+ + Max Climb Level + + + {team.climb.maxClimbLevel} + + + Reached {team.climb.timesClimbedToMax} times + + +
+ +
+ +
+ +
+
+ + +
+ ); + })} +
+ )} +
+ ); +}; diff --git a/apps/scouting/frontend/src/scouter/components/GeneralDataTable.tsx b/apps/scouting/frontend/src/strategy/GeneralDataTable.tsx similarity index 86% rename from apps/scouting/frontend/src/scouter/components/GeneralDataTable.tsx rename to apps/scouting/frontend/src/strategy/GeneralDataTable.tsx index 285cfdbf..a9f775c5 100644 --- a/apps/scouting/frontend/src/scouter/components/GeneralDataTable.tsx +++ b/apps/scouting/frontend/src/strategy/GeneralDataTable.tsx @@ -70,9 +70,10 @@ export const GeneralDataTable: React.FC = ({ ([teamNumber, generalFuelData]) => ({ teamNumber: Number(teamNumber), generalFuelData, + _uiKey: gameTime, }), ), - [teamNumberAndFuelData], + [teamNumberAndFuelData, gameTime], ); const columnHelper = createColumnHelper(); @@ -162,18 +163,26 @@ export const GeneralDataTable: React.FC = ({ ))} - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ))} + {table.getRowModel().rows.map((row) => { + console.log("data:", tableData, "columns:", columns); + // for some reason these rows dont update unless + //they reference the tableData in them + return ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + ); + })}
diff --git a/apps/scouting/frontend/src/strategy/fetches.ts b/apps/scouting/frontend/src/strategy/fetches.ts new file mode 100644 index 00000000..a93e0a8e --- /dev/null +++ b/apps/scouting/frontend/src/strategy/fetches.ts @@ -0,0 +1,18 @@ +//בס"ד +const formsUrl = "/api/v1/forms/"; + +export const fetchTeamNumbers = async () => { + const url = `${formsUrl}teams`; + + const response = await fetch(url, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Server Error: ${errorText}`); + } + const data = await response.json(); + return data.teamNumbers.sort() as number[]; +}; diff --git a/packages/array-functions/index.ts b/packages/array-functions/index.ts index c30bffd3..69f0c893 100644 --- a/packages/array-functions/index.ts +++ b/packages/array-functions/index.ts @@ -18,8 +18,10 @@ export const getMax = (arr: T[], transformation: (value: T) => number): T => .reduce((max, curr) => (curr.value > max.value ? curr : max)).item; const FIRST_ELEMENT_ID = 0; +const SECOND_ELEMENT_ID = 1; const LAST_ELEMENT_BACKWARDS_INDEX = 1; export const firstElement = (arr: T[]): T => arr[FIRST_ELEMENT_ID]; +export const secondElement = (arr: T[]): T => arr[SECOND_ELEMENT_ID]; export const lastElement = (arr: T[]): T => arr[arr.length - LAST_ELEMENT_BACKWARDS_INDEX]; diff --git a/packages/scouting_types/rebuilt/compare/CompareTypes.ts b/packages/scouting_types/rebuilt/compare/CompareTypes.ts new file mode 100644 index 00000000..e461c5f4 --- /dev/null +++ b/packages/scouting_types/rebuilt/compare/CompareTypes.ts @@ -0,0 +1,32 @@ +//בס"ד + +import type { TeleClimbLevel } from "../scouting_form/Shift"; + +export interface TimesClimedToLevels { + L1: number; + L2: number; + L3: number; +} + +export interface CompareClimb { + maxClimbLevel: TeleClimbLevel; + timesClimbedToMax: number; + timesClimbedInAuto: number; + timesClimbedToLevels: TimesClimedToLevels; +} + +export interface CompareAverageFuel { + averageFuelInGame: number; + averageFuelInAuto: number; +} + +export interface TeamCompareData { + teamNumber: number; + averageFuel: CompareAverageFuel; + climb: CompareClimb; +} + +export interface CompareData { + teamOne: TeamCompareData; + teamTwo: TeamCompareData; +} diff --git a/packages/scouting_types/rebuilt/compare/index.ts b/packages/scouting_types/rebuilt/compare/index.ts new file mode 100644 index 00000000..8cc8adb1 --- /dev/null +++ b/packages/scouting_types/rebuilt/compare/index.ts @@ -0,0 +1,3 @@ +//בס"ד + +export type * from "./CompareTypes"; diff --git a/packages/scouting_types/rebuilt/index.ts b/packages/scouting_types/rebuilt/index.ts index 37840ce7..1ad656ad 100644 --- a/packages/scouting_types/rebuilt/index.ts +++ b/packages/scouting_types/rebuilt/index.ts @@ -1,4 +1,5 @@ //בס"ד -export * from "./fuel" -export * from "./scouting_form" \ No newline at end of file +export * from "./fuel"; +export * from "./scouting_form"; +export type * from "./compare";