diff --git a/client/src/apps/admin/AdminApp.tsx b/client/src/apps/admin/AdminApp.tsx new file mode 100644 index 0000000..40b0b49 --- /dev/null +++ b/client/src/apps/admin/AdminApp.tsx @@ -0,0 +1,46 @@ +import { MaterialSymbol } from 'react-material-symbols'; +import LinkButton from '../../components/LinkButton'; +import { useStatusRecieve } from '../../lib/useStatus'; +import { ScouterTable } from './components/ScouterTable'; +import { MatchTable } from './components/MatchTable'; +//import { useFetchJson } from "../../lib/useFetch"; + +function AdminApp() { + const status = useStatusRecieve(); + + // const [schedule] = useFetchJson('/matchSchedule.json'); + + return ( +
+

Admin Interface

+ +
+ + + +
+ +
+
+ +

Connected Tablets

+
+
+

Match Display

+
+ +
+
+
+
+ ); +} + +export default AdminApp; diff --git a/client/src/apps/admin/components/MatchRow.tsx b/client/src/apps/admin/components/MatchRow.tsx new file mode 100644 index 0000000..9ce2df7 --- /dev/null +++ b/client/src/apps/admin/components/MatchRow.tsx @@ -0,0 +1,27 @@ +import { RobotPosition, SuperPosition } from 'requests'; +import PositionCell from './PositionCell'; + +function MatchRow({ + matchNumber, + scouters, +}: { + matchNumber: string; + scouters: Record & + Record; +}) { + return ( + + {matchNumber} + + + + + + + + + + ); +} + +export default MatchRow; diff --git a/client/src/apps/admin/components/MatchTable.tsx b/client/src/apps/admin/components/MatchTable.tsx new file mode 100644 index 0000000..8439742 --- /dev/null +++ b/client/src/apps/admin/components/MatchTable.tsx @@ -0,0 +1,29 @@ +import { StatusRecieve } from 'requests'; +import MatchRow from './MatchRow'; + +function MatchTable({ matches }: { matches: StatusRecieve['matches'] }) { + return ( + + + + + + + + + + + + + + + + {Object.entries(matches).map(([matchNumber, scouters]) => ( + + ))} + +
MatchRed 1Red 2Red 3Red SSBlue 1Blue 2Blue 3Blue SS
+ ); +} + +export { MatchTable }; diff --git a/client/src/apps/admin/components/PositionCell.tsx b/client/src/apps/admin/components/PositionCell.tsx new file mode 100644 index 0000000..5365e9c --- /dev/null +++ b/client/src/apps/admin/components/PositionCell.tsx @@ -0,0 +1,24 @@ +import { MaterialSymbol } from 'react-material-symbols'; + +function PositionCell({ + scouter, +}: { + scouter: { schedule: number; real: number[] } | boolean; +}) { + const isBoolean = typeof scouter === 'boolean'; + return ( + 0) ? 'bg-amber-400' : ''}`}> + {isBoolean ? ( + scouter && + ) : scouter.real.length === 0 ? ( + scouter.schedule + ) : scouter.real.length === 1 ? ( + scouter.real[0] + ) : ( + + )} + + ); +} +export default PositionCell; diff --git a/client/src/apps/admin/components/ScouterCard.tsx b/client/src/apps/admin/components/ScouterCard.tsx new file mode 100644 index 0000000..6b1064e --- /dev/null +++ b/client/src/apps/admin/components/ScouterCard.tsx @@ -0,0 +1,60 @@ +import { MaterialSymbol } from 'react-material-symbols'; +import { StatusReport } from 'requests'; + +function BatteryLevelIcon(batteryLevel: number) { + if (batteryLevel > 75) { + return ; + } else if (batteryLevel > 50) { + return ; + } else if (batteryLevel > 25) { + return ; + } else { + return ; + } +} +function ScouterCard({ + scouter, + title, + red = false, +}: { + scouter: StatusReport[]; + title: string; + red?: boolean; +}) { + return ( +
+
{title}
+ {scouter.length === 0 ? ( + + ) : scouter.length === 1 ? ( + <> +
{scouter[0].matchNumber}
+
{scouter[0].scouterName}
+
+ {BatteryLevelIcon( + Math.floor((scouter[0].battery ?? 0) * 100) + )} + {Math.floor((scouter[0].battery ?? 0) * 100)}% +
+ + ) : ( + <> +
+ {' '} + +
+ {scouter.map(e => ( +
{e.scouterName}
+ ))} + + )} +
+ ); +} + +export { ScouterCard }; diff --git a/client/src/apps/admin/components/ScouterTable.tsx b/client/src/apps/admin/components/ScouterTable.tsx new file mode 100644 index 0000000..4114e61 --- /dev/null +++ b/client/src/apps/admin/components/ScouterTable.tsx @@ -0,0 +1,37 @@ +import { RobotPosition, StatusReport, SuperPosition } from 'requests'; +import { ScouterCard } from './ScouterCard'; + +function ScouterTable({ scouters }: { scouters: StatusReport[] }) { + const sortedScouter = Object.fromEntries( + ( + [ + 'red_1', + 'red_2', + 'red_3', + 'blue_1', + 'blue_2', + 'blue_3', + 'red_ss', + 'blue_ss', + ] satisfies (RobotPosition | SuperPosition)[] + ).map(robotPosition => [ + robotPosition, + scouters.filter(scouter => scouter.robotPosition === robotPosition), + ]) + ) as Record; + + return ( +
+ + + + + + + + +
+ ); +} + +export { ScouterTable }; diff --git a/client/src/apps/match/MatchApp.tsx b/client/src/apps/match/MatchApp.tsx index e69de29..8c14e87 100644 --- a/client/src/apps/match/MatchApp.tsx +++ b/client/src/apps/match/MatchApp.tsx @@ -0,0 +1,343 @@ +import EndgameButton from './components/EndGameButton'; +import FieldButton from './components/FieldButton'; +import LinkButton from '../../components/LinkButton'; +import { + ClimbPosition, + MatchData, + MatchSchedule, + RobotPosition, + ScouterPosition, +} from 'requests'; +import { SetStateAction, useEffect, useState } from 'react'; +import { MaterialSymbol } from 'react-material-symbols'; +import 'react-material-symbols/rounded'; +import SignIn from '../../components/SignIn'; +import Dialog from '../../components/Dialog'; +import NumberInput from '../../components/NumberInput'; +import { useStatus } from '../../lib/useStatus'; +import TeamDropdown from '../../components/TeamDropdown'; +import { useQueue } from '../../lib/useQueue'; +import scheduleFile from '../../assets/matchSchedule.json'; +import { usePreventUnload } from '../../lib/usePreventUnload'; + +const schedule = scheduleFile as MatchSchedule; + +interface MatchScores { + autoShootNear: number; + autoShootMid: number; + autoShootFar: number; + autoAmp: number; + autoMiss: number; + teleShootNear: number; + teleShootMid: number; + teleShootFar: number; + hold: number; // Did the robot hold a note between auto and teleop? 0=no, 1=yes + teleAmp: number; + teleMiss: number; + trap: number; +} +const defualtScores: MatchScores = { + autoShootNear: 0, + autoShootMid: 0, + autoShootFar: 0, + autoAmp: 0, + autoMiss: 0, + teleShootNear: 0, + teleShootMid: 0, + teleShootFar: 0, + hold: 0, + teleAmp: 0, + teleMiss: 0, + trap: 0, +}; + +function MatchApp() { + usePreventUnload(); + const [sendQueue, sendAll, queue, sending] = useQueue(); + const [teamNumber, setTeamNumber] = useState(); + const [matchNumber, setMatchNumber] = useState(); + const [count, setCount] = useState(defualtScores); + const [leave, setLeave] = useState(false); //false=Not Left, true=Left + const [countHistory, setCountHistory] = useState([]); + const [climbPosition, setClimbPosition] = useState('none'); + const [showCheck, setShowCheck] = useState(false); + const [scouterName, setScouterName] = useState(''); + const [robotPosition, setRobotPosition] = useState(); + + const [scouterPosition, setScouterPosition] = useState(); + + const blueAlliance = ( + ['blue_1', 'blue_2', 'blue_3'] as (string | undefined)[] + ).includes(robotPosition); + + const handleAbsentRobot = async () => { + if (robotPosition == undefined || matchNumber == undefined) { + alert('Check if your signed in, and you have the match number'); + return; + } + + const data: MatchData = { + metadata: { + scouterName, + robotPosition, + matchNumber, + robotTeam: undefined, + }, + leftStartingZone: leave, + autoNotes: { + near: count.autoShootNear, + mid: count.autoShootMid, + far: count.autoShootFar, + amp: count.autoAmp, + miss: count.autoMiss, + }, + teleNotes: { + near: count.teleShootNear, + mid: count.teleShootMid, + far: count.teleShootFar, + amp: count.teleAmp, + miss: count.teleMiss, + }, + trapNotes: count.trap, + climb: climbPosition, + }; + + sendQueue('/data/match', data); + setCount(defualtScores); + setClimbPosition('none'); + setLeave(false); + setMatchNumber(matchNumber + 1); + setCountHistory([]); + + setShowCheck(true); + + setTimeout(() => { + setShowCheck(false); + }, 3000); + }; + + const handleSubmit = async () => { + if ( + robotPosition == undefined || + matchNumber == undefined || + teamNumber == undefined + ) { + alert('data is missing! :('); + return; + } + + const data: MatchData = { + metadata: { + scouterName, + robotPosition, + matchNumber, + robotTeam: teamNumber, + }, + leftStartingZone: leave, + autoNotes: { + near: count.autoShootNear, + mid: count.autoShootMid, + far: count.autoShootFar, + amp: count.autoAmp, + miss: count.autoMiss, + }, + teleNotes: { + near: count.teleShootNear, + mid: count.teleShootMid, + far: count.teleShootFar, + amp: count.teleAmp, + miss: count.autoMiss, + }, + trapNotes: count.trap, + climb: climbPosition, + }; + + sendQueue('/data/match', data); + setCount(defualtScores); + setClimbPosition('none'); + setLeave(false); + setMatchNumber(matchNumber + 1); + setCountHistory([]); + + setShowCheck(true); + + setTimeout(() => { + setShowCheck(false); + }, 3000); + }; + + const showConfirmationDialog = () => { + if (window.confirm('Are you sure you want to mark as absent?')) { + // User confirmed, call the action + handleAbsentRobot(); + // Optionally, you can also scroll to the top + scrollTo(0, 0); + } + }; + + const undoCount = () => { + if (countHistory.length > 0) { + setCountHistory(prevHistory => prevHistory.slice(0, -1)); + setCount(countHistory.at(-1)!); + } + }; + const handleSetCount = (newCount: SetStateAction) => { + setCountHistory([...countHistory, count]); + setCount(newCount); + }; + + useEffect(() => { + setTeamNumber( + schedule && robotPosition && matchNumber + ? schedule[matchNumber]?.[robotPosition] + : undefined + ); + }, [matchNumber, robotPosition]); + + useStatus(robotPosition, matchNumber, scouterName); + + return ( +
+ {showCheck && ( + + )} +

Match Scouting App

+ +
+ + + + + ( + + )}> + {close => ( + + )} + + +
+ +

Match Number

+ +

Team Number

+ + +
+ +
+ +
+

+ Autonomous +

+ +

+ Tele-Op +

+ +

+ Endgame +

+ +
+ +
+
+ +
+
Queue: {queue.length}
+ +
+
+ ); +} + +export type { MatchScores, ClimbPosition }; + +export default MatchApp; diff --git a/client/src/apps/match/components/EndGameButton.tsx b/client/src/apps/match/components/EndGameButton.tsx index e69de29..87958c1 100644 --- a/client/src/apps/match/components/EndGameButton.tsx +++ b/client/src/apps/match/components/EndGameButton.tsx @@ -0,0 +1,91 @@ +import { Dispatch, SetStateAction } from 'react'; +import { MatchScores } from '../MatchApp'; +import { ClimbPosition, ScouterPosition } from 'requests'; +import MultiButton from '../../../components/MultiButton'; + +function EndgameButton({ + setClimb, + setCount, + climbPosition, + alliance, + scouterPosition, + count, +}: { + setClimb: Dispatch>; + setCount: Dispatch>; + climbPosition: ClimbPosition; + alliance: boolean | undefined; + scouterPosition: ScouterPosition | undefined; + count: MatchScores; +}) { + // const [alliance, setAlliance] = useState(false); //false=red, true=blue, null=hollow purple + + const handleClimb = (newClimb: ClimbPosition) => { + setClimb(newClimb); + }; + + const handleTrap = () => { + setCount(prevCount => ({ + ...prevCount, + ['trap']: prevCount['trap'] + 1, + })); + }; + + return ( + <> +
+
+ +
+ + +
+
+ +
+ + ); +} + +export default EndgameButton; diff --git a/client/src/apps/match/components/FieldButton.tsx b/client/src/apps/match/components/FieldButton.tsx index e69de29..46540bb 100644 --- a/client/src/apps/match/components/FieldButton.tsx +++ b/client/src/apps/match/components/FieldButton.tsx @@ -0,0 +1,207 @@ +import { Dispatch, SetStateAction } from 'react'; +import { MatchScores } from '../MatchApp'; +import { ScouterPosition } from 'requests'; +import MultiButton from '../../../components/MultiButton'; + +type countKeys = keyof MatchScores; + +function RegionButton({ + handleCount, + className, + teleKey, + autoKey, + teleOp, + count, + label, + scouterPosition, + textClassName = '', +}: { + handleCount: ( + autokey: countKeys, + telekey: countKeys, + aKey?: countKeys + ) => void; + className?: string; + teleKey: countKeys; + autoKey: countKeys; + teleOp: boolean; + count: MatchScores; + label?: string; + scouterPosition?: ScouterPosition | undefined; + textClassName?: string; +}) { + return ( + + ); +} + +function FieldButton({ + setLeave, + setCount, + teleOp, + leave, + count, + alliance, + scouterPosition, +}: { + setLeave?: Dispatch; + setCount: Dispatch>; + teleOp: boolean; + leave?: boolean; + count: MatchScores; + alliance: boolean | undefined; + scouterPosition: ScouterPosition | undefined; +}) { + const handleCount = (autoKey: countKeys, teleKey: countKeys) => { + if (teleOp || !count.hold) { + const finalKey = teleOp ? teleKey : autoKey; + setCount(prevCount => ({ + ...prevCount, + [finalKey]: prevCount[finalKey] + 1, + })); + } + }; + + const handleLeave = () => { + setLeave?.(!leave); + }; + + const fieldColors = alliance + ? ['bg-blue-300/70', 'bg-blue-500/70', 'bg-blue-700/70'] + : ['bg-red-200/70', 'bg-red-400/70', 'bg-red-600/70']; + + return ( + <> +
+ {!teleOp && ( + <> +
+

Mobility?

+

+ The robot must cross the gray +
line to earn mobility. +

+
+ + + )} +
+ +
+ {alliance ? ( + <> + + + + + ) : ( + <> + + + + + )} +
+ +
+ { + <> + + + + } +
+ + ); +} + +export default FieldButton; diff --git a/client/src/apps/picklist/PicklistApp.tsx b/client/src/apps/picklist/PicklistApp.tsx new file mode 100644 index 0000000..19171a7 --- /dev/null +++ b/client/src/apps/picklist/PicklistApp.tsx @@ -0,0 +1,268 @@ +import { AnalysisEntry, WindowData } from './data'; +import { PitResult, TeamData } from 'requests'; +import Workspace from '../../components/workspace/Workspace'; +import { useWorkspaceState } from '../../components/workspace/useWorkspaceState'; +import StatTable from './components/StatTable'; +import Dialog from '../../components/Dialog'; +import StatDialog from './components/StatDialog'; +import { useFetchJson } from '../../lib/useFetch'; +import BarGraphDialog from './components/BarDialog'; +import BarGraph from './components/BarGraph'; +// import ScatterPlotDialog from './components/ScatterPlotDialog'; +import ScatterPlotGraph from './components/ScatterPlotGraph'; +import { MaterialSymbol } from 'react-material-symbols'; +import LinkButton from '../../components/LinkButton'; +import StatSummaryDialog from './components/StatSummaryDialog'; +import StatSummary from './components/StatSummary'; +import TeamSummaryDialog from './components/TeamSummaryDialog'; +import TeamSummary from './components/TeamSummary'; +import { Dispatch, useState } from 'react'; +import FinalPicklist from './components/FinalPicklist'; + +function generateWindow( + data: AnalysisEntry[], + table: WindowData, + setTable: Dispatch, + teamInfoJson: TeamData, + pitData: PitResult, + addToFocused: Dispatch, + setFinalPicklist: Dispatch +) { + switch (table.type) { + case 'StatTable': + return ( + + ); + case 'BarGraph': + return ( + + ); + case 'ScatterPlotGraph': + return ( + + ); + case 'StatSummary': + return ( + + ); + case 'TeamSummary': + return ( + + ); + default: + return undefined; + } +} + +function PicklistApp() { + const [analyzedData, reloadData] = useFetchJson( + '/output_analysis.json' + ); + const [pitData, reloadPitData] = useFetchJson('/data/pit'); + const [teamInfo] = useFetchJson('/team_info.json'); + + const [views, setViews, addToFocused, controls] = + useWorkspaceState(); + + const [finalPicklist, setFinalPicklist] = useState([]); + + return ( +
+
+ + + + + + + ( + + )}> + {close => ( + + )} + + ( + + )}> + {close => ( + + )} + + ( + + )}> + {close => ( + + )} + + {/* ( + + )}> + {close => ( + + )} + */} + ( + + )}> + {close => ( + + )} + +

+ Statistical Analysis +

+
+ + {(value, onChange) => { + return ( + analyzedData && + generateWindow( + analyzedData, + value, + onChange, + teamInfo || {}, + pitData || {}, + addToFocused, + setFinalPicklist + ) + ); + }} + + +
+ ); +} + +export default PicklistApp; diff --git a/client/src/apps/picklist/components/BarDialog.tsx b/client/src/apps/picklist/components/BarDialog.tsx new file mode 100644 index 0000000..f1a6029 --- /dev/null +++ b/client/src/apps/picklist/components/BarDialog.tsx @@ -0,0 +1,101 @@ +import { Dispatch, useState } from 'react'; +import { AnalysisEntry, BarGraphData } from '../data'; +import TextInput from '../../../components/TextInput'; +import Checkbox from '../../../components/Checkbox'; +import SelectSearch from 'react-select-search'; +import camelToSpaced from '../../../lib/camelCaseConvert'; +import { MaterialSymbol } from 'react-material-symbols'; + +function BarGraphDialog({ + onSubmit, + onClose, + data, +}: { + onSubmit: Dispatch; + onClose?: () => void; + data: AnalysisEntry[] | undefined; +}) { + const columns = data + ? Object.keys(data[0]).filter( + e => e !== 'teamNumber' && typeof data[0][e] === 'number' + ) + : []; + + const [title, setTitle] = useState(''); + const [column, setColumn] = useState(); + const [ascending, setAscending] = useState(false); + + const [showAll, setShowAll] = useState(true); + const [top, setTop] = useState(''); + + const handleSubmit = () => { + if (column) { + onSubmit({ + title: title || camelToSpaced(column || ''), + column, + ascending, + type: 'BarGraph', + top: parseInt(top), + }); + onClose?.(); + } + }; + + return ( + <> +
+ +
+ + +

+ +

+

+ + Show All? + +

+

+ +

+

+ Ascending +

+ + + ); +} + +export default BarGraphDialog; diff --git a/client/src/apps/picklist/components/BarGraph.tsx b/client/src/apps/picklist/components/BarGraph.tsx new file mode 100644 index 0000000..112bde0 --- /dev/null +++ b/client/src/apps/picklist/components/BarGraph.tsx @@ -0,0 +1,44 @@ +import { BarChart, BarSeries, ChartDataShape, ColorSchemeType } from 'reaviz'; +import { AnalysisEntry, BarGraphData } from '../data'; +import { TeamData } from 'requests'; + +function BarGraph({ + table, + data, + teamInfoJson, +}: { + table: BarGraphData; + data: AnalysisEntry[]; + teamInfoJson: TeamData; +}) { + const entries = data.map(e => { + return { + key: e.teamNumber.toString(), + data: e[table.column] as number, + }; + }); + const sortedEntries = entries.sort( + (a, b) => (a.data as number) - (b.data as number) + ); + if (!table.ascending) sortedEntries.reverse(); + + if (table.top < sortedEntries.length) { + sortedEntries.splice(table.top); + } + + // Create a list of colors for each team based on the colors stored in team_info.json + const sortedTeamNumbers = sortedEntries.map(entry => entry.key as string); + + const teamColors: ColorSchemeType = sortedTeamNumbers.map( + teamNumber => teamInfoJson[teamNumber]?.primaryHex ?? '#7f7f7f' + ); + + return ( + } + /> + ); +} + +export default BarGraph; diff --git a/client/src/apps/picklist/components/FinalPicklist.tsx b/client/src/apps/picklist/components/FinalPicklist.tsx new file mode 100644 index 0000000..613c2c9 --- /dev/null +++ b/client/src/apps/picklist/components/FinalPicklist.tsx @@ -0,0 +1,133 @@ +import { Dispatch, useState } from 'react'; +import { MaterialSymbol } from 'react-material-symbols'; +import { AnalysisEntry, WindowData } from '../data'; +import { TeamData } from 'requests'; +import TeamItem from './TeamItem'; +import SelectSearch from 'react-select-search'; + +function picklist({ + teamInfoJson, + onSubmit, + data, + picklist, + setPicklist, +}: { + teamInfoJson: TeamData; + onSubmit: Dispatch; + data: AnalysisEntry[] | undefined; + picklist: number[]; + setPicklist: Dispatch; +}) { + // Get all team numbers from the json data + const teamNumbers = data?.map(e => e.teamNumber.toString()) ?? []; + + // eslint-disable-next-line react-hooks/rules-of-hooks + const [expanded, setExpanded] = useState(false); + + // eslint-disable-next-line react-hooks/rules-of-hooks + const [newTeamNumber, setNewTeamNumber] = useState(); + + function handleExpand() { + setExpanded(!expanded); + } + + function handleRemoveTeam(index: number) { + setPicklist(picklist.filter((_, i) => i !== index)); + } + + function moveTeamNumber(index: number, up: boolean) { + if (up && index > 0) { + setPicklist( + picklist + .slice(0, index - 1) + .concat( + picklist[index], + picklist[index - 1], + picklist.slice(index + 1) + ) + ); + } else if (index < picklist.length - 1) { + setPicklist( + picklist + .slice(0, index) + .concat( + picklist[index + 1], + picklist[index], + picklist.slice(index + 2) + ) + ); + } + } + + function addNewTeamNumber(teamNumber: string) { + if (teamNumber && !picklist.includes(Number(teamNumber))) { + setPicklist([...picklist, Number(teamNumber)]); + setNewTeamNumber('Select Team'); + } + } + + return ( +
+ + {expanded ? ( + <> +
+ {picklist.map((team, i) => { + return ( +
+
+ + +
+ + +
+ ); + })} +
+
+ ({ + value: e, + name: e, + }))} + value={newTeamNumber} + onChange={value => + addNewTeamNumber(value as string) + } + placeholder='Select Team' + search + /> +
+ + ) : ( + <> + )} +
+ ); +} + +export default picklist; diff --git a/client/src/apps/picklist/components/RobotPhotoDialog.tsx b/client/src/apps/picklist/components/RobotPhotoDialog.tsx new file mode 100644 index 0000000..a9ff655 --- /dev/null +++ b/client/src/apps/picklist/components/RobotPhotoDialog.tsx @@ -0,0 +1,27 @@ +import { MaterialSymbol } from 'react-material-symbols'; + +function RobotPhotoDialog({ + teamNumber, + onClose, +}: { + teamNumber: number; + onClose?: () => void; +}) { + return ( + <> +
+ + +
+ + ); +} + +export default RobotPhotoDialog; diff --git a/client/src/apps/picklist/components/ScatterPlotDialog.tsx b/client/src/apps/picklist/components/ScatterPlotDialog.tsx new file mode 100644 index 0000000..cacc4f7 --- /dev/null +++ b/client/src/apps/picklist/components/ScatterPlotDialog.tsx @@ -0,0 +1,100 @@ +import { Dispatch, useState } from 'react'; +import { AnalysisEntry, ScatterPlotGraphData } from '../data'; +import TextInput from '../../../components/TextInput'; +import SelectSearch from 'react-select-search'; +import camelToSpaced from '../../../lib/camelCaseConvert'; +import { MaterialSymbol } from 'react-material-symbols'; + +function ScatterPlotDialog({ + onSubmit, + onClose, + data, +}: { + onSubmit: Dispatch; + onClose?: () => void; + data: AnalysisEntry[] | undefined; +}) { + const columns = data + ? Object.keys(data[0]).filter( + e => e !== 'teamNumber' && typeof data[0][e] === 'number' + ) + : []; + + const [title, setTitle] = useState(''); + const [xColumn, setXColumn] = useState(); + const [yColumn, setYColumn] = useState(); + + const handleSubmit = () => { + if (xColumn && yColumn) { + onSubmit({ + title: + title || + camelToSpaced(xColumn || '') + + '/' + + camelToSpaced(yColumn || ''), + xColumn: xColumn || '', + yColumn: yColumn || '', + type: 'ScatterPlotGraph', + }); + onClose?.(); + } + }; + + return ( + <> +
+ +
+ + + +

+ +

+ + + ); +} + +export default ScatterPlotDialog; diff --git a/client/src/apps/picklist/components/ScatterPlotGraph.tsx b/client/src/apps/picklist/components/ScatterPlotGraph.tsx new file mode 100644 index 0000000..88ed48a --- /dev/null +++ b/client/src/apps/picklist/components/ScatterPlotGraph.tsx @@ -0,0 +1,23 @@ +import { ChartShallowDataShape, ScatterPlot } from 'reaviz'; +import { AnalysisEntry, ScatterPlotGraphData } from '../data'; +import { TeamData } from 'requests'; + +function ScatterPlotGraph({ + table, + data, +}: { + table: ScatterPlotGraphData; + data: AnalysisEntry[]; + teamInfoJson: TeamData; +}) { + const plotData: ChartShallowDataShape[] = data.map(e => { + return { + key: e.teamNumber.toString(), + data: [e[table.xColumn] as number, e[table.yColumn] as number], + }; + }); + + return ; +} + +export default ScatterPlotGraph; diff --git a/client/src/apps/picklist/components/StatColumnDialog.tsx b/client/src/apps/picklist/components/StatColumnDialog.tsx new file mode 100644 index 0000000..ce1208d --- /dev/null +++ b/client/src/apps/picklist/components/StatColumnDialog.tsx @@ -0,0 +1,61 @@ +import { Dispatch, useState } from 'react'; +import { AnalysisEntry } from '../data'; +import SelectSearch from 'react-select-search'; +import camelToSpaced from '../../../lib/camelCaseConvert'; +import { MaterialSymbol } from 'react-material-symbols'; + +function StatColumnDialog({ + onSubmit, + onClose, + data, +}: { + onSubmit: Dispatch; + onClose?: () => void; + data: AnalysisEntry[] | undefined; +}) { + const columns = data + ? Object.keys(data[0]).filter( + e => e !== 'teamNumber' && typeof data[0][e] === 'number' + ) + : []; + columns.push('robotImages'); + + const [column, setColumn] = useState(); + const handleSubmit = () => { + if (column) { + onSubmit(column); + onClose?.(); + } + }; + + return ( + <> +
+ +
+ + + + + ); +} + +export default StatColumnDialog; diff --git a/client/src/apps/picklist/components/StatDialog.tsx b/client/src/apps/picklist/components/StatDialog.tsx new file mode 100644 index 0000000..9940f81 --- /dev/null +++ b/client/src/apps/picklist/components/StatDialog.tsx @@ -0,0 +1,58 @@ +import { Dispatch, useState } from 'react'; +import { StatTableData } from '../data'; +import TextInput from '../../../components/TextInput'; +import { MaterialSymbol } from 'react-material-symbols'; +import Checkbox from '../../../components/Checkbox'; + +function StatDialog({ + onSubmit, + onClose, +}: { + onSubmit: Dispatch; + onClose?: () => void; +}) { + const [title, setTitle] = useState(''); + + const [weighted, setWeighted] = useState(false); + + const handleSubmit = () => { + onSubmit({ + title: title || 'Stat Table', + type: 'StatTable', + columns: [], + ascending: false, + weighted: weighted, + weights: [], + }); + onClose?.(); + }; + + return ( + <> +
+ +
+

+ +

+

+ Weighted +

+ + + ); +} + +export default StatDialog; diff --git a/client/src/apps/picklist/components/StatSummary.tsx b/client/src/apps/picklist/components/StatSummary.tsx new file mode 100644 index 0000000..82f3fa1 --- /dev/null +++ b/client/src/apps/picklist/components/StatSummary.tsx @@ -0,0 +1,119 @@ +import camelToSpaced from '../../../lib/camelCaseConvert'; +import { AnalysisEntry, StatSummaryData } from '../data'; +import { TeamData } from 'requests'; + +const empty1x1Base64: string = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + +function StatSummary({ + table, + data, + teamInfoJson, +}: { + table: StatSummaryData; + data: AnalysisEntry[]; + teamInfoJson: TeamData; +}) { + const entries = data.map<[number, number]>(e => [ + e.teamNumber, + e[table.column] as number, + ]); + const sortedEntries = entries.sort((a, b) => a[1] - b[1]); + + const sortedEntryTeamNumbers = sortedEntries.map(entry => + entry[0].toString() + ); + const sortedEntryDataPoints = sortedEntries.map(entry => entry[1]); + + // Create a list of the avatar data for each team based on the base64 images stored under the key 'avatar' in the team_info.json file + const lowTeamNumber = sortedEntryTeamNumbers[0]; + const lowTeamAvatar = teamInfoJson[lowTeamNumber]?.avatar ?? empty1x1Base64; + const lowDataPoint = sortedEntryDataPoints[0]; + + const medianTeamNumber = + sortedEntryTeamNumbers[Math.floor(sortedEntryTeamNumbers.length / 2)]; + const medianTeamAvatar = + teamInfoJson[medianTeamNumber]?.avatar ?? empty1x1Base64; + const medainDataPoint = + sortedEntryDataPoints[Math.floor(sortedEntryDataPoints.length / 2)]; + + const highTeamNumber = + sortedEntryTeamNumbers[sortedEntryTeamNumbers.length - 1]; + const highTeamAvatar = + teamInfoJson[highTeamNumber]?.avatar ?? empty1x1Base64; + const highDataPoint = + sortedEntryDataPoints[sortedEntryDataPoints.length - 1]; + + return ( + <> +

{camelToSpaced(table.column)}

+

{table.column}

+ +
+ +
+

+ Mean:{' '} + {( + sortedEntryDataPoints.reduce((a, b) => a + b, 0) / + sortedEntryDataPoints.length + ).toFixed(3)} +

+

+ Standard Deviation:{' '} + {Math.sqrt( + sortedEntryDataPoints.reduce( + (a, b) => + a + + (b - + sortedEntryDataPoints.reduce( + (a, b) => a + b, + 0 + ) / + sortedEntryDataPoints.length) ** + 2, + 0 + ) / sortedEntryDataPoints.length + ).toFixed(3)} +

+
+ +
+ +
+

Low: {lowDataPoint}

+

by

+ +

Team {lowTeamNumber}

+
+ +
+

Median: {medainDataPoint}

+

by

+ +

Team {medianTeamNumber}

+
+ +
+

High: {highDataPoint}

+

by

+ +

Team {highTeamNumber}

+
+ + ); +} + +export default StatSummary; diff --git a/client/src/apps/picklist/components/StatSummaryDialog.tsx b/client/src/apps/picklist/components/StatSummaryDialog.tsx new file mode 100644 index 0000000..02099b4 --- /dev/null +++ b/client/src/apps/picklist/components/StatSummaryDialog.tsx @@ -0,0 +1,76 @@ +import { Dispatch, useState } from 'react'; +import { AnalysisEntry, StatSummaryData } from '../data'; +import TextInput from '../../../components/TextInput'; +import SelectSearch from 'react-select-search'; +import camelToSpaced from '../../../lib/camelCaseConvert'; +import { MaterialSymbol } from 'react-material-symbols'; + +function StatSummaryDialog({ + onSubmit, + onClose, + data, +}: { + onSubmit: Dispatch; + onClose?: () => void; + data: AnalysisEntry[] | undefined; +}) { + const columns = data + ? Object.keys(data[0]).filter( + e => e !== 'teamNumber' && typeof data[0][e] === 'number' + ) + : []; + + const [title, setTitle] = useState(''); + const [column, setColumn] = useState(); + + const handleSubmit = () => { + if (column) { + onSubmit({ + title: title || camelToSpaced(column || ''), + column, + type: 'StatSummary', + }); + onClose?.(); + } + }; + + return ( + <> +
+ +
+ + +

+ +

+ + + ); +} + +export default StatSummaryDialog; diff --git a/client/src/apps/picklist/components/StatTable.tsx b/client/src/apps/picklist/components/StatTable.tsx new file mode 100644 index 0000000..7aff450 --- /dev/null +++ b/client/src/apps/picklist/components/StatTable.tsx @@ -0,0 +1,244 @@ +import { Dispatch } from 'react'; +import { AnalysisEntry, StatTableData, WindowData } from '../data'; +import { TeamData } from 'requests'; +import Dialog from '../../../components/Dialog'; +import StatColumnDialog from './StatColumnDialog'; +import { MaterialSymbol } from 'react-material-symbols'; +import camelToSpaced from '../../../lib/camelCaseConvert'; +import RobotPhotoDialog from './RobotPhotoDialog'; +import TeamItem from './TeamItem'; + +const isNumber = (num: unknown): num is number => { + return typeof num === 'number'; +}; + +const sumReduction: [(prev: number, current: number) => number, 0] = [ + (prev, current) => prev + current, + 0, +]; + +function StatTable({ + table, + setTable, + data, + teamInfoJson, + onSubmit, + onSetFinal, +}: { + table: StatTableData; + data: AnalysisEntry[]; + setTable: Dispatch; + teamInfoJson: TeamData; + onSubmit: Dispatch; + onSetFinal: Dispatch; +}) { + let sortedData: AnalysisEntry[]; + + if (table.weighted) { + sortedData = [...data].sort((a, b) => { + const aSum = table.columns + .map(column => a[column]) + .filter(isNumber) + .map((e, i) => e * table.weights[i]) + .reduce(...sumReduction); + const bSum = table.columns + .map(column => b[column]) + .filter(isNumber) + .map((e, i) => e * table.weights[i]) + .reduce(...sumReduction); + + return (aSum - bSum) * (table.ascending ? 1 : -1); + }); + } else { + sortedData = table.sortColumn + ? [...data].sort( + (a, b) => + ((a[table.sortColumn!] as number) - + (b[table.sortColumn!] as number)) * + (table.ascending ? 1 : -1) + ) + : data; + } + + // Handle when a StatColumn component is clicked + function handleClickColumn(sortColumn: string) { + if (sortColumn === table.sortColumn) { + setTable({ ...table, ascending: !table.ascending }); + return; + } + setTable({ ...table, sortColumn }); + } + + // Handle when the new StatColumn is submitted + function handleAddColumn(column: string) { + const data = { + ...table, + columns: [...table.columns, column], + weights: [...table.weights, 0], + }; + setTable(data); + } + + // Handle when a StatColumn component is deleted + function handleDeleteColumn(index: number) { + setTable({ + ...table, + columns: table.columns.filter((_, i) => i !== index), + weights: table.weights.filter((_, i) => i !== index), + }); + } + + // Handle when the weights of the stat column are changed + function handleWeightChange( + index: number, + event: React.ChangeEvent + ) { + const value = parseFloat(event.target.value); + + setTable({ + ...table, + weights: table.weights.map((e, i) => (index === i ? value : e)), + }); + } + + // Handle when a stat on the stat table is clicked + function handleStatSummaryClick(column: string) { + onSubmit({ + title: camelToSpaced(column), + type: 'StatSummary', + column: column, + }); + } + + return ( +
+ + + + + + {table.columns.map((column, i) => + column === 'robotImages' ? ( + + ) : ( + + ) + )} + + + + + {sortedData.map(entry => ( + + + {table.columns.map(column => + column === 'robotImages' ? ( + + ) : ( + + ) + )} + + ))} + +
+ Team + + {camelToSpaced(column)} + + + {camelToSpaced(column)} + {table.weighted ? ( + <> +
+ + handleWeightChange(i, event) + } + className='w-12' + /> + + ) : ( + + )} + + +
+ ( + + )}> + {close => ( + + )} + +
+ ( + + )}> + {close => ( + + )} + + + {entry[column]} +
+
+ ); +} + +export default StatTable; diff --git a/client/src/apps/picklist/components/TeamItem.tsx b/client/src/apps/picklist/components/TeamItem.tsx new file mode 100644 index 0000000..f793502 --- /dev/null +++ b/client/src/apps/picklist/components/TeamItem.tsx @@ -0,0 +1,50 @@ +import { Dispatch } from 'react'; +import { MaterialSymbol } from 'react-material-symbols'; +import { TeamData } from 'requests'; +import { WindowData } from '../data'; +import blankImage from '../../../images/blank.png'; + +function TeamItem({ + teamNumber, + teamInfoJson, + onSubmit, +}: { + teamNumber: number; + teamInfoJson: TeamData; + onSubmit: Dispatch; +}) { + // Handle when a team on the stat table is clicked + function handleTeamSummaryClick(teamNumber: number) { + onSubmit({ + title: 'Team ' + teamNumber + ' Summary', + type: 'TeamSummary', + teamNumber: teamNumber, + }); + } + + return ( + <> + {teamInfoJson[teamNumber] && ( + + + + )} + + {teamNumber} + { + + } + + + ); +} + +export default TeamItem; diff --git a/client/src/apps/picklist/components/TeamSummary.tsx b/client/src/apps/picklist/components/TeamSummary.tsx new file mode 100644 index 0000000..bfebf07 --- /dev/null +++ b/client/src/apps/picklist/components/TeamSummary.tsx @@ -0,0 +1,191 @@ +import Dialog from '../../../components/Dialog'; +import camelToSpaced from '../../../lib/camelCaseConvert'; +import { AnalysisEntry, TeamSummaryData } from '../data'; +import { PitResult, TeamData } from 'requests'; +import RobotPhotoDialog from './RobotPhotoDialog'; +import { snakeToSpaced } from '../../../lib/snakeCaseConvert'; + +function commentToColor(comment: string) { + switch (comment) { + case 'good_driving': + case 'okay_defense': + return 'bg-[#50a1c7]'; + case 'clogging': + case 'source_only': + case 'avoids_under_stage': + return 'bg-[#c78450]'; + case 'weak_build': + case 'ineffective_defense': + return 'bg-[#c75050]'; + case 'sturdy_build': + case 'great_driving': + case 'effective_defense': + return 'bg-[#5ac750]'; + default: + return 'bg-gray-500'; + } +} + +function TeamSummary({ + table, + data, + teamInfoJson, + pitData, +}: { + table: TeamSummaryData; + data: AnalysisEntry[]; + teamInfoJson: TeamData; + pitData: PitResult; +}) { + // Get the data for the team specified + const teamData = data.find(e => e.teamNumber === table.teamNumber); + + const { info: teamInfo, avatar } = teamInfoJson[table.teamNumber] ?? {}; + const teamPitData = pitData[table.teamNumber]; + + return ( +
+
+
+ {avatar && } +

+ Team{' '} + {teamInfo + ? `${teamInfo.team_number} - ${teamInfo.nickname}` + : table.teamNumber} +

+
+ + {teamInfo && ( + <> +

+ {teamInfo.name} +

+
+ +
+

+ From {teamInfo.city}, {teamInfo.state_prov},{' '} + {teamInfo.country} +

+

Rookie Year: {teamInfo.rookie_year}

+
+ + )} + +
+ + ( + + )}> + {close => ( + + )} + +
+
+

Comments

+ + {teamData && + teamData.Comments && + Object.entries(teamData.Comments) + .sort(([_, a], [__, b]) => b - a) + .map( + ([comment, count]) => + count > 0 && ( +

+ {snakeToSpaced(comment)}{' '} + + {count} + +

+ ) + )} + +

Stats

+ + {teamData && + Object.keys(teamData).map(e => { + if ( + e !== 'teamNumber' && + e !== 'scouterName' && + e !== 'climb' && + e !== 'Comments' + ) { + return ( +

+ {camelToSpaced(e)}: {teamData[e]} +

+ ); + } + })} +
+
+

Pit Scout Info

+ +

Role: {teamPitData?.teamRole}

+

+ {' '} + Batteries: {teamPitData?.pitBatteryCount} +

+

+ {' '} + Drivetrain: {teamPitData?.drivebase} +

+

Notes: {teamPitData?.comments}

+ +

+ Capabilities +

+

+ Amp: {teamPitData?.capabilities.amp ? 'Yes' : 'No'} +

+

+ Speaker: {teamPitData?.capabilities.speaker ? 'Yes' : 'No'} +

+

+ Trap: {teamPitData?.capabilities.trap ? 'Yes' : 'No'} +

+

+ Climb: {teamPitData?.capabilities.climb ? 'Yes' : 'No'} +

+

+ Chain traversal:{' '} + {teamPitData?.capabilities.chainTraversal ? 'Yes' : 'No'} +

+ +

+ Preferences +

+

+ Amp: {teamPitData?.preference.ampPrefer ? 'Yes' : 'No'} +

+

+ Speaker:{' '} + {teamPitData?.preference.speakerPerfer ? 'Yes' : 'No'} +

+

+ Trap: {teamPitData?.preference.trapPrefer ? 'Yes' : 'No'} +

+

+ Climb: {teamPitData?.preference.climbPrefer ? 'Yes' : 'No'} +

+ + {/*

More Info

*/} +
+
+ ); +} + +export default TeamSummary; diff --git a/client/src/apps/picklist/components/TeamSummaryDialog.tsx b/client/src/apps/picklist/components/TeamSummaryDialog.tsx new file mode 100644 index 0000000..44c8221 --- /dev/null +++ b/client/src/apps/picklist/components/TeamSummaryDialog.tsx @@ -0,0 +1,78 @@ +import { Dispatch, useState } from 'react'; +import { AnalysisEntry, TeamSummaryData } from '../data'; +import TextInput from '../../../components/TextInput'; +import SelectSearch from 'react-select-search'; +import { MaterialSymbol } from 'react-material-symbols'; + +function TeamSummaryDialog({ + onSubmit, + onClose, + data, +}: { + onSubmit: Dispatch; + onClose?: () => void; + data: AnalysisEntry[] | undefined; +}) { + // Get all team numbers from the json data + const teamNumbers = data?.map(e => e.teamNumber.toString()) ?? []; + + // Sort the team numbers + teamNumbers.sort((a, b) => Number(a) - Number(b)); + + const [title, setTitle] = useState(''); + const [teamNumber, setTeamNumber] = useState(); + + const handleSubmit = () => { + if (teamNumber) { + onSubmit({ + title: teamNumber + ? title || 'Team ' + teamNumber + ' Summary' + : '', + teamNumber: Number(teamNumber), + type: 'TeamSummary', + }); + onClose?.(); + } + }; + + return ( + <> +
+ +
+ + +

+ +

+ + + ); +} + +export default TeamSummaryDialog; diff --git a/client/src/apps/picklist/data.d.ts b/client/src/apps/picklist/data.d.ts new file mode 100644 index 0000000..002437b --- /dev/null +++ b/client/src/apps/picklist/data.d.ts @@ -0,0 +1,47 @@ +import { CommentValues } from 'requests'; +import { TabBase } from '../../components/workspace/workspaceData'; + +export interface AnalysisEntry + extends Record { + teamNumber: number; + Comments: Record; +} + +export interface StatTableData extends TabBase { + type: 'StatTable'; + columns: string[]; + sortColumn?: string; + ascending: boolean; + weighted: boolean; + weights: number[]; +} + +export interface BarGraphData extends TabBase { + column: string; + ascending: boolean; + top: number; + type: 'BarGraph'; +} + +export interface ScatterPlotGraphData extends TabBase { + xColumn: string; + yColumn: string; + type: 'ScatterPlotGraph'; +} + +export interface StatSummaryData extends TabBase { + column: string; + type: 'StatSummary'; +} + +export interface TeamSummaryData extends TabBase { + teamNumber: number; + type: 'TeamSummary'; +} + +export type WindowData = + | StatTableData + | BarGraphData + | ScatterPlotGraphData + | StatSummaryData + | TeamSummaryData; // | WeightedTableData | BlankTableData | ... diff --git a/client/src/apps/pit/PitApp.tsx b/client/src/apps/pit/PitApp.tsx index e69de29..5071ea7 100644 --- a/client/src/apps/pit/PitApp.tsx +++ b/client/src/apps/pit/PitApp.tsx @@ -0,0 +1,399 @@ +import MultiButton from '../../components/MultiButton'; +//import ToggleButton from '../../components/ToggleButton' +import React, { useEffect, useState } from 'react'; +import Checkbox from '../../components/Checkbox'; +import { PitFile, teamRoles, drivebase } from 'requests'; +import LinkButton from '../../components/LinkButton'; +import { MaterialSymbol } from 'react-material-symbols'; +import TeamDropdown from '../../components/TeamDropdown'; +import Dialog from '../../components/Dialog'; +import SignIn from '../../components/SignIn'; +import ConeStacker from '../../components/ConeStacker'; +import { usePreventUnload } from '../../lib/usePreventUnload'; +import ImageUploader from './components/ImageUploader'; +import { useFetchJson } from '../../lib/useFetch'; +import { postJson } from '../../lib/postJson'; + +function PitApp() { + usePreventUnload(); + + const [scoutedTeams, refreshScoutedTeams] = useFetchJson( + '/data/pit/scouted-teams' + ); + + const [sending, setSending] = useState(false); + + const [autoInputValues, setAutoInputValues] = useState(['']); + const [role, setRole] = useState(); + const [drivetrain, setDrivetrain] = useState(); + const [additionalNotes, setAdditionalNotes] = useState(''); + const [batteryNumber, setBatteryNumber] = useState(Number); + const [teamNumber, setTeamNumber] = useState(Number); + + const [ampChecked, setAmpChecked] = useState(false); + const [trapChecked, setTrapChecked] = useState(false); + const [speakerChecked, setSpeakerChecked] = useState(false); + const [climbingChecked, setClimbingChecked] = useState(false); + const [chainTraversalChecked, setChainTraversalChecked] = useState(false); + const [ampPrefChecked, setAmpPrefChecked] = useState(false); + const [trapPrefChecked, setTrapPrefChecked] = useState(false); + const [speakerPrefChecked, setSpeakerPrefChecked] = useState(false); + const [climbingPrefChecked, setClimbingPrefChecked] = useState(false); + + const [scouterName, setScouterName] = useState(''); + const [robotImage, setRobotImage] = useState(''); + useEffect(() => { + const timeout = setInterval(refreshScoutedTeams, 60 * 1000); + return () => clearInterval(timeout); + }, [refreshScoutedTeams]); + + const handleSubmit = async () => { + if (sending) return; + + if (!drivetrain || !role) { + alert('data is missing :('); + return; + } + + const data: PitFile = { + scouterName: 'bcdsh', + teamNumber, + capabilities: { + amp: ampChecked, + speaker: speakerChecked, + trap: trapChecked, + climb: climbingChecked, + chainTraversal: chainTraversalChecked, + }, + preference: { + ampPrefer: ampPrefChecked, + speakerPerfer: speakerPrefChecked, + trapPrefer: trapPrefChecked, + climbPrefer: climbingPrefChecked, + }, + autoCapability: autoInputValues, + teamRole: role, + pitBatteryCount: batteryNumber, + drivebase: drivetrain, + photo: robotImage, + comments: additionalNotes, + }; + + setSending(true); + try { + const result = await postJson('/data/pit', data); + if (!result.ok) throw new Error('Request Did Not Succeed'); + refreshScoutedTeams(); + setAutoInputValues(['']); + setAmpChecked(false); + setAmpPrefChecked(false); + setBatteryNumber(0); + setAdditionalNotes(''); + setRole(undefined); + setTeamNumber(0); + setChainTraversalChecked(false); + setClimbingChecked(false); + setClimbingPrefChecked(false); + setDrivetrain(undefined); + setTrapChecked(false); + setTrapPrefChecked(false); + setSpeakerChecked(false); + setSpeakerPrefChecked(false); + setRobotImage(''); + } catch { + alert('Sending Data Failed'); + } + setSending(false); + }; + + const inputBattery = { + width: '150px', + height: '50px', + }; + + return ( + <> +
+
+
+

+ Pit App +

+
+ +
+ + + + + ( + + )}> + {close => ( + + )} + + +
+ +
+
+

Team Number

+ +
+
+ +

+ Capabilities? Choose all that apply. +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

+ What is their preference? Choose all that apply. +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+

+ Number of Batteries? +

+ + setBatteryNumber(parseInt(event.target.value)) + } + value={batteryNumber} + style={inputBattery} + className='border-1 mx-auto !flex w-min place-content-center rounded-lg border border-gray-700 text-center text-4xl' + type='number' + placeholder='0'> +
+
+ +
+ +
+
+

+ Team role? +

+
+ + +
+
+
+ +
+
+

+ Drivetrain type? +

+
+ + +
+
+
+

Robot Image

+ + +

+ Additional Notes? +

+ setAdditionalNotes(event.target.value)} + value={additionalNotes} + type='text'> + + +
+ + ); +} + +export default PitApp; diff --git a/client/src/apps/public/PublicApp.tsx b/client/src/apps/public/PublicApp.tsx new file mode 100644 index 0000000..20a75ac --- /dev/null +++ b/client/src/apps/public/PublicApp.tsx @@ -0,0 +1,12 @@ +import LinkButton from '../../components/LinkButton'; + +function PublicApp() { + return ( + <> +

Public App

+ Home + + ); +} + +export default PublicApp; diff --git a/client/src/apps/recon/ReconApp.tsx b/client/src/apps/recon/ReconApp.tsx new file mode 100644 index 0000000..2863aa9 --- /dev/null +++ b/client/src/apps/recon/ReconApp.tsx @@ -0,0 +1,149 @@ +import { + MatchDataAggregations, + MatchSchedule, + SuperDataAggregations, +} from 'requests'; +import LinkButton from '../../components/LinkButton'; +import { useFetchJson } from '../../lib/useFetch'; +import { useEffect, useState } from 'react'; +import { StatRow, SuperStatRow } from './components/StatRow'; +import { MaterialSymbol } from 'react-material-symbols'; +import TeamDropdown from '../../components/TeamDropdown'; +import NumberInput from '../../components/NumberInput'; +import scheduleJson from '../../assets/matchSchedule.json'; + +const schedule = scheduleJson as MatchSchedule; + +const matchStats: Exclude[] = [ + 'averageTeleSpeakerNotes', + 'averageTeleAmpNotes', + 'averageAutoSpeakerNotes', + 'averageAutoAmpNotes', + 'averageTrapNotes', + 'maxTeleSpeakerNotes', + 'maxTeleAmpNotes', + 'maxAutoSpeakerNotes', + 'maxAutoAmpNotes', + 'maxTrapNotes', + 'avgClimbRate', + 'harmonyRate', +]; +const superStats: Exclude[] = [ + 'avgFouls', + 'maxFouls', +]; + +function ReconApp() { + const [retrieveMatch, reloadRetrieveMatch] = + useFetchJson('/data/retrieve'); + const [retrieveSuper, reloadRetrieveSuper] = useFetchJson< + SuperDataAggregations[] + >('/data/retrieve/super'); + const [matchNumber, setMatchNumber] = useState(); + const [teams, setTeams] = useState<(number | undefined)[]>([undefined]); + + useEffect(() => { + if (!matchNumber) return; + const match = schedule?.[matchNumber]; + if (!match) return; + setTeams([ + match.red_1, + match.red_2, + match.red_3, + match.blue_1, + match.blue_2, + match.blue_3, + ]); + }, [matchNumber]); + + return ( +
+

+ Recon Interface +

+ +
+ + + +
+ + + + + + + {teams.map((team, index) => ( + + ))} + + + + + {matchStats.map(stat => ( + + ))} + {superStats.map(superStat => ( + + ))} + +
+ Team + + + setTeams( + teams.map((team, index2) => + index === index2 ? value : team + ) + ) + } + /> + + + +
+
+ ); +} + +export default ReconApp; diff --git a/client/src/apps/recon/components/StatRow.tsx b/client/src/apps/recon/components/StatRow.tsx new file mode 100644 index 0000000..ade06b2 --- /dev/null +++ b/client/src/apps/recon/components/StatRow.tsx @@ -0,0 +1,58 @@ +import { MatchDataAggregations, SuperDataAggregations } from 'requests'; +import camelToSpaced from '../../../lib/camelCaseConvert'; + +function StatRow({ + teams, + stat, + data, +}: { + teams: (number | undefined)[]; + stat: Exclude; + data: MatchDataAggregations[] | undefined; +}) { + const datapoints = teams.map( + team => data?.find(dataTeam => team === dataTeam._id.teamNumber)?.[stat] + ); + return ( + + + {camelToSpaced(stat)} + + {datapoints.map((dataNumbers, columnIndex) => ( + + {dataNumbers && Math.round(dataNumbers * 10) / 10} + + ))} + + ); +} + +function SuperStatRow({ + teams, + stat, + data, +}: { + teams: (number | undefined)[]; + stat: Exclude; + data: SuperDataAggregations[] | undefined; +}) { + const datapoints = teams.map( + team => data?.find(dataTeam => team === dataTeam._id.teamNumber)?.[stat] + ); + return ( + + + {camelToSpaced(stat)} + + {datapoints.map((dataNumbers, columnIndex) => ( + + {dataNumbers && Math.round(dataNumbers * 10) / 10} + + ))} + + ); +} + +export { StatRow, SuperStatRow }; diff --git a/client/src/apps/score_calculator/ScoreCalculator.tsx b/client/src/apps/score_calculator/ScoreCalculator.tsx new file mode 100644 index 0000000..87330b3 --- /dev/null +++ b/client/src/apps/score_calculator/ScoreCalculator.tsx @@ -0,0 +1,210 @@ +//import ToggleButton from '../../components/ToggleButton' +import React, { Dispatch, SetStateAction, useState } from 'react'; + +import LinkButton from '../../components/LinkButton'; +import { MaterialSymbol } from 'react-material-symbols'; +import NumberInput from '../../components/NumberInput'; + +function Counter({ + value, + onChange, + children, +}: { + value: number; + onChange: Dispatch>; + children: string; +}) { + return ( + <> + + + + ); +} + +function ScoreCalculator() { + const [autoLeave, setAutoLeave] = useState(0); + const [autoSpeaker, setAutoSpeaker] = useState(0); + const [autoAmp, setAutoAmp] = useState(0); + const [teleSpeaker, setTeleSpeaker] = useState(0); + const [ampedTeleSpeaker, setAmpedTeleSpeaker] = useState(0); + const [teleAmp, setTeleAmp] = useState(0); + const [park, setPark] = useState(0); + const [climb, setClimb] = useState(0); + const [climbSpot, setClimbSpot] = useState(0); + const [trap, setTrap] = useState(0); + const [harmony, setHarmony] = useState(0); + + const [foulPoints, setFoulPoints] = useState(0); + + const autoPoints = autoLeave * 2; + const speakerPoints = + autoSpeaker * 5 + teleSpeaker * 2 + ampedTeleSpeaker * 5; + const ampPoints = autoAmp * 2 + teleAmp * 1; + const stagePoints = + park * 1 + climb * 3 + climbSpot * 4 + trap * 5 + harmony * 2; + const totalPoints = + autoPoints + + speakerPoints + + ampPoints + + stagePoints + + (foulPoints ?? 0); + + const handleReset = () => { + setAutoLeave(0); + setAutoSpeaker(0); + setAutoAmp(0); + setTeleSpeaker(0); + setAmpedTeleSpeaker(0); + setTeleAmp(0); + setPark(0); + setClimb(0); + setClimbSpot(0); + setTrap(0); + setHarmony(0); + setFoulPoints(0); + }; + + return ( +
+
+
+

+ Score Calculator +

+
+ +
+ + + +
+ +
+ +
+
+

+ Auto +

+ + Auto Leave + + + Auto Speaker + + + Auto Amp + +
+
+

+ Teleop +

+ + Tele Speaker + + + Amped Tele Speaker + + + + Tele Amp + +
+ +
+

+ Endgame +

+ + Park + + + Climb + + + Spotlit Climb + + + Trap + + + Harmonies + +
+
+ +
+

+ Leave:{' '} + + {autoPoints} + +

+ +

+ Speaker:{' '} + + {speakerPoints} + +

+ +

+ Amp:{' '} + + {ampPoints} + +

+ +

+ Stage:{' '} + + {stagePoints} + +

+ +

+ Foul Points:{' '} + +

+ +

+ Total:{' '} + + {totalPoints} + +

+
+
+
+ ); +} + +export default ScoreCalculator; diff --git a/client/src/apps/super/SuperApp.tsx b/client/src/apps/super/SuperApp.tsx new file mode 100644 index 0000000..48ad06e --- /dev/null +++ b/client/src/apps/super/SuperApp.tsx @@ -0,0 +1,326 @@ +import { MaterialSymbol } from 'react-material-symbols'; +import LinkButton from '../../components/LinkButton'; +import SignIn from '../../components/SignIn'; +import { useEffect, useState } from 'react'; +import Dialog from '../../components/Dialog'; +import { + Foul, + SuperPosition, + Break, + MatchSchedule, + SuperData, + HighNote, + RobotPosition, + ScouterPosition, +} from 'requests'; +import SuperTeam from './components/SuperTeam'; +import { SuperTeamState } from './components/SuperTeam'; +import MultiSelectFieldButton from './components/MultiSelectFieldButton'; +import NumberInput from '../../components/NumberInput'; +import MultiButton from '../../components/MultiButton'; +import { useStatus } from '../../lib/useStatus'; +import { useQueue } from '../../lib/useQueue'; +import scheduleFile from '../../assets/matchSchedule.json'; +import { usePreventUnload } from '../../lib/usePreventUnload'; +// import CreatableSelect from 'react-select/creatable'; +// import SelectSearch, { SelectSearchOption } from 'react-select-search'; + +const schedule = scheduleFile as MatchSchedule; + +const foulTypes: Foul[] = [ + 'insideRobot', + 'protectedZone', + 'pinning', + 'multiplePieces', + 'other', +]; + +const defaultHighNote: HighNote = { + amp: false, + center: false, + source: false, +}; +const breakTypes: Break[] = ['mechanismDmg', 'batteryFall', 'commsFail']; + +const defaultSuperTeamState: SuperTeamState = { + foulCounts: Object.fromEntries(foulTypes.map(e => [e, 0])) as Record< + Foul, + number + >, + breakCount: Object.fromEntries(breakTypes.map(e => [e, 0])) as Record< + Break, + number + >, + defenseRank: 'noDef', + wasDefended: false, + teamNumber: undefined, + cannedComments: [], +}; + +function SuperApp() { + usePreventUnload(); + const [scouterName, setScouterName] = useState(''); + const [superPosition, setSuperPosition] = useState(); + const [team1, setTeam1] = useState(defaultSuperTeamState); + const [team2, setTeam2] = useState(defaultSuperTeamState); + const [team3, setTeam3] = useState(defaultSuperTeamState); + const [shooterPlayerTeam, setShooterPlayerTeam] = useState(); + const [sendQueue, sendAll, queue, sending] = useQueue(); + const [matchNumber, setMatchNumber] = useState(); + const [showCheck, setShowCheck] = useState(false); + const [highNotes, setHighNotes] = useState(defaultHighNote); + const [history, setHistory] = useState< + { 1: SuperTeamState; 2: SuperTeamState; 3: SuperTeamState }[] + >([]); + const [scouterPosition, setScouterPosition] = useState(); + + useStatus(superPosition, matchNumber, scouterName); + + const saveHistory = () => { + setHistory([ + ...history, + { + 1: team1, + 2: team2, + 3: team3, + }, + ]); + }; + const handleTeam1 = (teamValue: SuperTeamState) => { + setTeam1(teamValue); + saveHistory(); + }; + const handleTeam2 = (teamValue: SuperTeamState) => { + setTeam2(teamValue); + saveHistory(); + }; + const handleTeam3 = (teamValue: SuperTeamState) => { + setTeam3(teamValue); + saveHistory(); + }; + + const handleSubmit = async () => { + if ( + scouterName === undefined || + superPosition === undefined || + matchNumber === undefined || + team1.teamNumber === undefined || + team2.teamNumber === undefined || + team3.teamNumber === undefined + ) { + alert('data is missing! :('); + return; + } + + const data = [team1, team2, team3].map( + (team, index) => + ({ + metadata: { + scouterName, + matchNumber, + robotTeam: team.teamNumber!, + robotPosition: ( + (superPosition === 'blue_ss' + ? ['blue_1', 'blue_2', 'blue_3'] + : [ + 'red_1', + 'red_2', + 'red_3', + ]) satisfies RobotPosition[] + )[index], + }, + fouls: team.foulCounts, + break: team.breakCount, + defense: team.defenseRank, + defended: team.wasDefended, + humanShooter: + shooterPlayerTeam === team.teamNumber + ? { + highNotes, + } + : undefined, + comments: team.cannedComments.map(option => option.value), + }) satisfies SuperData + ); + + data.map(e => sendQueue('/data/super', e)); + setHighNotes(defaultHighNote); + setTeam1(defaultSuperTeamState); + setTeam2(defaultSuperTeamState); + setTeam3(defaultSuperTeamState); + setHistory([]); + setMatchNumber(matchNumber + 1); + setShowCheck(true); + setTimeout(() => { + setShowCheck(false); + }, 3000); + }; + + useEffect(() => { + if (!schedule || !superPosition || !matchNumber) { + setTeam1(team1 => ({ ...team1, teamNumber: undefined })); + setTeam2(team2 => ({ ...team2, teamNumber: undefined })); + setTeam3(team3 => ({ ...team3, teamNumber: undefined })); + return; + } + const blueAlliance = superPosition === 'blue_ss'; + setTeam1(team1 => ({ + ...team1, + teamNumber: + schedule[matchNumber]?.[blueAlliance ? 'blue_1' : 'red_1'], + })); + setTeam2(team2 => ({ + ...team2, + teamNumber: + schedule[matchNumber]?.[blueAlliance ? 'blue_2' : 'red_2'], + })); + setTeam3(team3 => ({ + ...team3, + teamNumber: + schedule[matchNumber]?.[blueAlliance ? 'blue_3' : 'red_3'], + })); + }, [matchNumber, superPosition]); + + const undoHistoryCount = () => { + if (history.length > 0) { + setHistory(prevHistory => prevHistory.slice(0, -1)); + const last = history.at(-1)!; + setTeam1(last[1]); + setTeam2(last[2]); + setTeam3(last[3]); + } + }; + + return ( +
+ {showCheck && ( + + )} +

+ Super Scouting App +

+ +
+ + + + + ( + + )}> + {close => ( + + )} + + + +
+ +

Match Number

+ + +
+ + + +
+ + e.toString())} + value={shooterPlayerTeam} + selectedClassName='bg-[#48c55c]' + unSelectedClassName='bg-white' + /> + + + + + +
+
Queue: {queue.length}
+ +
+
+ ); +} + +export default SuperApp; diff --git a/client/src/apps/super/components/CannedComments.tsx b/client/src/apps/super/components/CannedComments.tsx new file mode 100644 index 0000000..f3734e7 --- /dev/null +++ b/client/src/apps/super/components/CannedComments.tsx @@ -0,0 +1,124 @@ +import { Dispatch } from 'react'; +import { CommentValues } from 'requests'; +import chroma from 'chroma-js'; +import Select, { StylesConfig } from 'react-select'; + +export interface SelectOption { + value: T; + label: string; + color: string; +} + +interface ColourOption { + readonly value: string; + readonly label: string; + readonly color: string; + readonly isFixed?: boolean; + readonly isDisabled?: boolean; +} + +const commentOptions: SelectOption[] = [ + { label: 'great driving', value: 'great_driving', color: '#5ac750' }, + { label: 'good driving', value: 'good_driving', color: '#50a1c7' }, + { label: 'source only', value: 'source_only', color: '#c78450' }, + { label: 'clogging', value: 'clogging', color: '#c78450' }, + { + label: 'effective defense', + value: 'effective_defense', + color: '#5ac750', + }, + { label: 'okay defense', value: 'okay_defense', color: '#50a1c7' }, + { + label: 'ineffective defense', + value: 'ineffective_defense', + color: '#c75050', + }, + { label: 'sturdy build', value: 'sturdy_build', color: '#5ac750' }, + { label: 'weak build', value: 'weak_build', color: '#c75050' }, + { + label: 'avoids under stage', + value: 'avoids_under_stage', + color: '#c78450', + }, +]; + +const colourStyles: StylesConfig = { + control: styles => ({ ...styles, backgroundColor: 'white' }), + option: (styles, { data, isDisabled, isFocused, isSelected }) => { + const color = chroma(data.color); + return { + ...styles, + backgroundColor: isDisabled + ? undefined + : isSelected + ? data.color + : isFocused + ? color.alpha(0.1).css() + : undefined, + color: isDisabled + ? '#ccc' + : isSelected + ? chroma.contrast(color, 'white') > 2 + ? 'white' + : 'black' + : data.color, + cursor: isDisabled ? 'not-allowed' : 'default', + + ':active': { + ...styles[':active'], + backgroundColor: !isDisabled + ? isSelected + ? data.color + : color.alpha(0.3).css() + : undefined, + }, + }; + }, + multiValue: (styles, { data }) => { + const color = chroma(data.color); + return { + ...styles, + backgroundColor: color.alpha(0.1).css(), + }; + }, + multiValueLabel: (styles, { data }) => ({ + ...styles, + color: data.color, + }), + multiValueRemove: (styles, { data }) => ({ + ...styles, + color: data.color, + ':hover': { + backgroundColor: data.color, + color: 'white', + }, + }), +}; + +function CannedCommentBox({ + value, + onChange, +}: { + value?: SelectOption[] | undefined; + onChange?: Dispatch[]>; +}) { + return ( +
+