diff --git a/apps/scouting/backend/src/fuel/distance-split.ts b/apps/scouting/backend/src/fuel/distance-split.ts index 771f0ac..cab7edf 100644 --- a/apps/scouting/backend/src/fuel/distance-split.ts +++ b/apps/scouting/backend/src/fuel/distance-split.ts @@ -3,7 +3,7 @@ import { convertPixelToCentimeters, distanceFromHub } from "@repo/rebuilt_map"; import type { FuelEvents, FuelObject } from "./fuel-object"; import { calculateAverage } from "@repo/array-functions"; -const averageFuel = (fuels: FuelObject[]): FuelObject => { +export const averageFuel = (fuels: FuelObject[]): FuelObject => { const averageOfKey = (key: FuelEvents) => calculateAverage(fuels, (value) => value[key]); return { diff --git a/apps/scouting/backend/src/fuel/fuel-general.ts b/apps/scouting/backend/src/fuel/fuel-general.ts index 520b178..173db03 100644 --- a/apps/scouting/backend/src/fuel/fuel-general.ts +++ b/apps/scouting/backend/src/fuel/fuel-general.ts @@ -1,13 +1,8 @@ // בס"ד -import type { BPS, FuelObject } from "./fuel-object"; import { createFuelObject } from "./fuel-object"; -import type { ScoutingForm, ShiftsArray } from "@repo/scouting_types"; +import type { BPS, FuelObject, GeneralFuelData, ScoutingForm, ShiftsArray } from "@repo/scouting_types"; + -interface GeneralFuelData { - fullGame:FuelObject; - auto:FuelObject; - tele:FuelObject; -} const calculateFuelStatisticsOfShift = ( match: ScoutingForm["match"], diff --git a/apps/scouting/backend/src/fuel/fuel-object.ts b/apps/scouting/backend/src/fuel/fuel-object.ts index 4ae4698..724a3fa 100644 --- a/apps/scouting/backend/src/fuel/fuel-object.ts +++ b/apps/scouting/backend/src/fuel/fuel-object.ts @@ -1,6 +1,5 @@ // בס"ד -import type { GameObject } from "../game-object"; -import type { Match, Point, ShootEvent } from "@repo/scouting_types"; +import type { GameObject, Match, Point, ShootEvent } from "@repo/scouting_types"; import { calculateFuelByAveraging } from "./calculations/fuel-averaging"; import { calculateFuelByMatch } from "./calculations/fuel-match"; diff --git a/apps/scouting/backend/src/game-object.ts b/apps/scouting/backend/src/game-object.ts deleted file mode 100644 index 89d0398..0000000 --- a/apps/scouting/backend/src/game-object.ts +++ /dev/null @@ -1,17 +0,0 @@ -// בס"ד - -export type GameObject = Record & - AdditionalInfo; - -export const addGameEvent = ( - gameObject: GameObject, - event: T, -): void => { - gameObject[event]++; -}; - -export interface GameObjectWithPoints { - gameObject: GameObject; - calculatePoints: (gameObject: GameObject) => number; - calculateRP: (gameObject: GameObject) => number; -} diff --git a/apps/scouting/backend/src/routes/forms-router.ts b/apps/scouting/backend/src/routes/forms-router.ts index 97b04e5..ca80aac 100644 --- a/apps/scouting/backend/src/routes/forms-router.ts +++ b/apps/scouting/backend/src/routes/forms-router.ts @@ -12,14 +12,14 @@ import { mongofyQuery } from "../middleware/query"; export const formsRouter = Router(); -const getCollection = flow( +export const getFormsCollection = flow( getDb, map((db) => db.collection("forms")), ); formsRouter.get("/", async (req, res) => { await pipe( - getCollection(), + getFormsCollection(), map((collection) => collection.find(mongofyQuery(req.query)).toArray()), fold( (error) => () => @@ -32,7 +32,7 @@ formsRouter.get("/", async (req, res) => { formsRouter.post("/single", async (req, res) => { await pipe( - getCollection(), + getFormsCollection(), flatMap((collection) => pipe( right(req), diff --git a/apps/scouting/backend/src/routes/general-router.ts b/apps/scouting/backend/src/routes/general-router.ts new file mode 100644 index 0000000..8bfb4e6 --- /dev/null +++ b/apps/scouting/backend/src/routes/general-router.ts @@ -0,0 +1,107 @@ +//בס"ד +/* eslint-disable @typescript-eslint/no-magic-numbers */ //for the example bps + +import { Router } from "express"; +import { getFormsCollection } from "./forms-router"; +import { pipe } from "fp-ts/lib/function"; +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"; + +export const generalRouter = Router(); + +interface AccumulatedFuelData { + fullGame: FuelObject[]; + auto: FuelObject[]; + tele: FuelObject[]; +} + +const getBPS = () => { + return []; +}; + +const ONE_ITEM_ARRAY = 1; + +const calcAverageGeneralFuelData = (fuelData: GeneralFuelData[]) => { + if (fuelData.length === ONE_ITEM_ARRAY || isEmpty(fuelData)) { + return firstElement(fuelData); + } + + const accumulatedFuelData: AccumulatedFuelData = + fuelData.reduce( + (accumulated, currentFuelData) => ({ + fullGame: [...accumulated.fullGame, currentFuelData.fullGame], + auto: [...accumulated.auto, currentFuelData.auto], + tele: [...accumulated.tele, currentFuelData.tele], + }), + { + fullGame: [], + auto: [], + tele: [], + }, + ); + + const averagedFuelData: GeneralFuelData = { + fullGame: averageFuel(accumulatedFuelData.fullGame), + auto: averageFuel(accumulatedFuelData.auto), + tele: averageFuel(accumulatedFuelData.tele), + }; + + return averagedFuelData; +}; + +generalRouter.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}`, + }), + ), + ), + map((forms) => + forms.map((form) => ({ + teamNumber: form.teamNumber, + generalFuelData: generalCalculateFuel(form, getBPS()), + })), + ), + + map((generalFuelsData) => + generalFuelsData.reduce>( + (accumulatorRecord, fuelData) => ({ + ...accumulatorRecord, + [fuelData.teamNumber]: [ + ...accumulatorRecord[fuelData.teamNumber], + fuelData.generalFuelData, + ], + }), + {}, + ), + ), + + map((teamAndAllFuelData) => { + const teamAndAvaragedFuelData: Record = {}; + Object.entries(teamAndAllFuelData).forEach(([teamNumber, fuelArray]) => { + teamAndAvaragedFuelData[teamNumber] = + calcAverageGeneralFuelData(fuelArray); + }); + + return teamAndAvaragedFuelData; + }), + + fold( + (error) => () => + Promise.resolve(res.status(error.status).send(error.reason)), + (calculatedFuel) => () => + Promise.resolve(res.status(StatusCodes.OK).json({ calculatedFuel })), + ), + )(); +}); diff --git a/apps/scouting/backend/src/routes/index.ts b/apps/scouting/backend/src/routes/index.ts index 153f0df..c37dd00 100644 --- a/apps/scouting/backend/src/routes/index.ts +++ b/apps/scouting/backend/src/routes/index.ts @@ -4,13 +4,15 @@ import { StatusCodes } from "http-status-codes"; import { tbaRouter } from "./tba"; import { gameRouter } from "./game-router"; import { formsRouter } from "./forms-router"; +import { generalRouter } from "./general-router"; export const apiRouter = Router(); -apiRouter.use("/forms",formsRouter); +apiRouter.use("/forms", formsRouter); apiRouter.use("/tba", tbaRouter); apiRouter.use("/game", gameRouter); +apiRouter.use("/general", generalRouter); apiRouter.get("/health", (req, res) => { res.status(StatusCodes.OK).send({ message: "Healthy!" }); -}); \ No newline at end of file +}); diff --git a/apps/scouting/frontend/src/scouter/components/GeneralDataTable.tsx b/apps/scouting/frontend/src/scouter/components/GeneralDataTable.tsx new file mode 100644 index 0000000..285cfdb --- /dev/null +++ b/apps/scouting/frontend/src/scouter/components/GeneralDataTable.tsx @@ -0,0 +1,182 @@ +// בס"ד +import { + createColumnHelper, + flexRender, + getCoreRowModel, + getSortedRowModel, + useReactTable, + type SortingState, +} from "@tanstack/react-table"; +import type { + GameTime, + GeneralFuelData, + TeamNumberAndFuelData, +} from "@repo/scouting_types"; +import type React from "react"; +import { useState, useEffect, useMemo } from "react"; +import { ChevronUp, ChevronDown, ChevronsUpDown } from "lucide-react"; + +type FuelMetricKey = "shot" | "scored" | "missed"; + +interface TableRow { + teamNumber: number; + generalFuelData: GeneralFuelData; +} + +const fetchFuelData = async (filters = {}) => { + const params = new URLSearchParams(filters); + const url = `/api/v1/general/?${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.calculatedFuel as TeamNumberAndFuelData; + } catch (err) { + console.error("Fetch failed:", err); + throw err; + } +}; + +interface GeneralDataTableProps { + filters: {}; +} + +const DIGITS_AFTER_DOT = 1; + +export const GeneralDataTable: React.FC = ({ + filters, +}) => { + const [teamNumberAndFuelData, setTeamNumberAndFuelData] = + useState({}); + const [gameTime, setGameTime] = useState("tele"); + const [sorting, setSorting] = useState([]); + + useEffect(() => { + fetchFuelData(filters).then(setTeamNumberAndFuelData).catch(console.error); + }, [filters]); + + const tableData = useMemo( + () => + Object.entries(teamNumberAndFuelData).map( + ([teamNumber, generalFuelData]) => ({ + teamNumber: Number(teamNumber), + generalFuelData, + }), + ), + [teamNumberAndFuelData], + ); + + const columnHelper = createColumnHelper(); + + const createColumn = (headerAndId: FuelMetricKey, style: string) => + columnHelper.accessor((row) => row.generalFuelData[gameTime][headerAndId], { + id: headerAndId, + header: headerAndId, + cell: (info) => ( + + {info.getValue().toFixed(DIGITS_AFTER_DOT)} + + ), + }); + + const columns = [ + columnHelper.accessor("teamNumber", { + header: "Team Number", + cell: (info) => ( + {info.getValue()} + ), + }), + + createColumn("shot", "text-slate-300 font-medium"), + createColumn("scored", "text-emerald-400 font-bold"), + createColumn("missed", "text-rose-500/90 font-medium"), + ]; + + const table = useReactTable({ + data: tableData, + columns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + return ( +
+
+ {(["auto", "tele", "fullGame"] as GameTime[]).map((time) => ( + + ))} +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+
+ {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + {header.column.getIsSorted() === "asc" ? ( + + ) : header.column.getIsSorted() === "desc" ? ( + + ) : ( + + )} + +
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+
+ ); +}; diff --git a/package.json b/package.json index 8034f63..1526d1d 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "dependencies": { "@radix-ui/react-slider": "^1.3.6", "@tailwindcss/vite": "^4.1.17", + "@tanstack/react-table": "^8.21.3", "@zxing/browser": "^0.1.5", "axios": "^1.13.2", "dotenv": "^17.2.3", @@ -53,6 +54,7 @@ "http-status-codes": "^2.3.0", "io-ts": "^2.2.22", "is-odd": "^3.0.1", + "lucide-react": "^0.563.0", "mongodb": "^7.0.0", "qrcode.react": "^4.2.0", "react-icons": "^5.5.0", diff --git a/packages/scouting_types/index.ts b/packages/scouting_types/index.ts index e28d763..1926a23 100644 --- a/packages/scouting_types/index.ts +++ b/packages/scouting_types/index.ts @@ -1,7 +1,6 @@ // בס"ד export * from "./rebuilt"; -export * from "./tba"; +export * from "./tba"; export type Alliance = "red" | "blue"; - diff --git a/packages/scouting_types/rebuilt/fuel/FuelTypes.ts b/packages/scouting_types/rebuilt/fuel/FuelTypes.ts new file mode 100644 index 0000000..13debb0 --- /dev/null +++ b/packages/scouting_types/rebuilt/fuel/FuelTypes.ts @@ -0,0 +1,45 @@ +//בס"ד + +import type { Match, Point } from "../scouting_form"; + +export interface GeneralFuelData { + fullGame: FuelObject; + auto: FuelObject; + tele: FuelObject; +} + +export type GameTime = keyof GeneralFuelData; + + +export interface BPS { + events: { shoot: number[]; score: number[] }[]; + match: Match; +} + +type FuelEvents = "scored" | "shot" | "missed"; +export type FuelObject = GameObject< + FuelEvents, + { + positions: Point[]; + } +>; + +export type GameObject = Record & + AdditionalInfo; + +export const addGameEvent = ( + gameObject: GameObject, + event: T, +): void => { + gameObject[event]++; +}; + +export interface GameObjectWithPoints { + gameObject: GameObject; + calculatePoints: (gameObject: GameObject) => number; + calculateRP: (gameObject: GameObject) => number; +} + + + +export type TeamNumberAndFuelData = Record \ No newline at end of file diff --git a/packages/scouting_types/rebuilt/fuel/index.ts b/packages/scouting_types/rebuilt/fuel/index.ts new file mode 100644 index 0000000..b898a75 --- /dev/null +++ b/packages/scouting_types/rebuilt/fuel/index.ts @@ -0,0 +1,3 @@ +//בס"ד + +export * from "./FuelTypes"; diff --git a/packages/scouting_types/rebuilt/index.ts b/packages/scouting_types/rebuilt/index.ts index 6e1f487..37840ce 100644 --- a/packages/scouting_types/rebuilt/index.ts +++ b/packages/scouting_types/rebuilt/index.ts @@ -1,9 +1,4 @@ -// בס"ד -export * from "./GameData"; -export * from "./ScoutingForm"; -export * from "./ShootEvent"; -export * from "./Interval"; -export * from "./Shift"; -export * from "./Segments"; -export * from "./Movement"; -export * from "./Serde"; +//בס"ד + +export * from "./fuel" +export * from "./scouting_form" \ No newline at end of file diff --git a/packages/scouting_types/rebuilt/GameData.ts b/packages/scouting_types/rebuilt/scouting_form/GameData.ts similarity index 100% rename from packages/scouting_types/rebuilt/GameData.ts rename to packages/scouting_types/rebuilt/scouting_form/GameData.ts diff --git a/packages/scouting_types/rebuilt/Interval.ts b/packages/scouting_types/rebuilt/scouting_form/Interval.ts similarity index 100% rename from packages/scouting_types/rebuilt/Interval.ts rename to packages/scouting_types/rebuilt/scouting_form/Interval.ts diff --git a/packages/scouting_types/rebuilt/Movement.ts b/packages/scouting_types/rebuilt/scouting_form/Movement.ts similarity index 100% rename from packages/scouting_types/rebuilt/Movement.ts rename to packages/scouting_types/rebuilt/scouting_form/Movement.ts diff --git a/packages/scouting_types/rebuilt/ScoutingForm.ts b/packages/scouting_types/rebuilt/scouting_form/ScoutingForm.ts similarity index 100% rename from packages/scouting_types/rebuilt/ScoutingForm.ts rename to packages/scouting_types/rebuilt/scouting_form/ScoutingForm.ts diff --git a/packages/scouting_types/rebuilt/Segments.ts b/packages/scouting_types/rebuilt/scouting_form/Segments.ts similarity index 100% rename from packages/scouting_types/rebuilt/Segments.ts rename to packages/scouting_types/rebuilt/scouting_form/Segments.ts diff --git a/packages/scouting_types/rebuilt/Serde.ts b/packages/scouting_types/rebuilt/scouting_form/Serde.ts similarity index 100% rename from packages/scouting_types/rebuilt/Serde.ts rename to packages/scouting_types/rebuilt/scouting_form/Serde.ts diff --git a/packages/scouting_types/rebuilt/Shift.ts b/packages/scouting_types/rebuilt/scouting_form/Shift.ts similarity index 100% rename from packages/scouting_types/rebuilt/Shift.ts rename to packages/scouting_types/rebuilt/scouting_form/Shift.ts diff --git a/packages/scouting_types/rebuilt/ShootEvent.ts b/packages/scouting_types/rebuilt/scouting_form/ShootEvent.ts similarity index 100% rename from packages/scouting_types/rebuilt/ShootEvent.ts rename to packages/scouting_types/rebuilt/scouting_form/ShootEvent.ts diff --git a/packages/scouting_types/rebuilt/scouting_form/index.ts b/packages/scouting_types/rebuilt/scouting_form/index.ts new file mode 100644 index 0000000..6e1f487 --- /dev/null +++ b/packages/scouting_types/rebuilt/scouting_form/index.ts @@ -0,0 +1,9 @@ +// בס"ד +export * from "./GameData"; +export * from "./ScoutingForm"; +export * from "./ShootEvent"; +export * from "./Interval"; +export * from "./Shift"; +export * from "./Segments"; +export * from "./Movement"; +export * from "./Serde";