diff --git a/.env.example b/.env.example index 5a3c67a..622da9b 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,8 @@ # You can update this to suit your local development environment. Match info should be hosted by a different server # since the dev server isn't keen to do it itself -PREACT_APP_RTDB_URL=https://your-rtdb-instance.firebaseio.com/ -PREACT_APP_FIRE_KEY=your-key -PREACT_APP_FIRE_PROJ=your-project -PREACT_APP_FIRE_APPID=your-app-id -PREACT_APP_FIRE_MEASUREID=your-analytics-id -PREACT_APP_SIGNALR_SERVER=https://fim-admin.evandoes.dev # or remove +APP_RTDB_URL=https://your-rtdb-instance.firebaseio.com/ +APP_FIRE_KEY=your-key +APP_FIRE_PROJ=your-project +APP_FIRE_APPID=your-app-id +APP_FIRE_MEASUREID=your-analytics-id +APP_SIGNALR_SERVER=https://fim-admin.evandoes.dev # or remove diff --git a/.eslintrc.js b/.eslintrc.js index 3492dbc..a0336f8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -3,25 +3,16 @@ module.exports = { env: { es6: true, }, - extends: [ - 'airbnb', - 'airbnb-typescript', - ], + extends: ['airbnb', 'airbnb-typescript'], parser: '@typescript-eslint/parser', parserOptions: { project: ['tsconfig.eslint.json'], sourceType: 'module', }, - ignorePatterns: [ - '/functions/lib/**/*', - '/build/**/*', - '**/node_modules', - ], - plugins: [ - '@typescript-eslint', - 'import', - ], + ignorePatterns: ['/functions/lib/**/*', '/build/**/*', '**/node_modules'], + plugins: ['@typescript-eslint', 'import'], rules: { + 'linebreak-style': 'off', 'no-console': 'off', 'import/extensions': [ 'error', diff --git a/package-lock.json b/package-lock.json index e7e3028..619523e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "license": "MIT", "dependencies": { + "@gmurph91/react-textfit": "^1.1.4", "@microsoft/signalr": "^7.0.7", "@preact/preset-vite": "^2.5.0", "@react-spring/web": "^9.7.3", @@ -30,6 +31,7 @@ "@types/color": "^3.0.3", "@types/js-cookie": "^3.0.3", "@types/react": "^18.2.14", + "@types/react-textfit": "^1.1.4", "@types/styled-components": "^5.1.26", "babel-plugin-module-resolver": "^5.0.0", "dotenv-webpack": "^7.1.0", @@ -3028,6 +3030,19 @@ "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==" }, + "node_modules/@gmurph91/react-textfit": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@gmurph91/react-textfit/-/react-textfit-1.1.4.tgz", + "integrity": "sha512-s0qQAIcYk8OIxTHwplijjXd1R5aPfF5Glb98Nb5970FzikmazZKmpsinOs8bQoi3CdfKCUpLfZ/sSRxvM/h+rw==", + "dependencies": { + "process": "^0.11.10", + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.7.3.tgz", @@ -5248,6 +5263,15 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-textfit": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/react-textfit/-/react-textfit-1.1.4.tgz", + "integrity": "sha512-tj3aMfbzi12r2yWn4Kzm9IkEHz7uaBU57P19FhzEA+Sr+ex0EuJezAYtBRMW3HjxylJ8PtmwpWgPT/t+j0H6zA==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", @@ -13936,7 +13960,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -18323,7 +18346,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -25988,6 +26010,15 @@ "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==" }, + "@gmurph91/react-textfit": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@gmurph91/react-textfit/-/react-textfit-1.1.4.tgz", + "integrity": "sha512-s0qQAIcYk8OIxTHwplijjXd1R5aPfF5Glb98Nb5970FzikmazZKmpsinOs8bQoi3CdfKCUpLfZ/sSRxvM/h+rw==", + "requires": { + "process": "^0.11.10", + "prop-types": "^15.7.2" + } + }, "@grpc/grpc-js": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.7.3.tgz", @@ -27789,6 +27820,15 @@ "csstype": "^3.0.2" } }, + "@types/react-textfit": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/react-textfit/-/react-textfit-1.1.4.tgz", + "integrity": "sha512-tj3aMfbzi12r2yWn4Kzm9IkEHz7uaBU57P19FhzEA+Sr+ex0EuJezAYtBRMW3HjxylJ8PtmwpWgPT/t+j0H6zA==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", @@ -34615,7 +34655,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "peer": true, "requires": { "js-tokens": "^3.0.0 || ^4.0.0" } @@ -37958,7 +37997,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "peer": true, "requires": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", diff --git a/package.json b/package.json index f7d4a3f..7f4952b 100644 --- a/package.json +++ b/package.json @@ -7,9 +7,11 @@ "build": "vite build", "serve": "sirv build --port 8080 --cors --single", "dev": "vite", - "lint": "eslint './src/**/*.{js,jsx,ts,tsx}' './functions/src/**/*.{js,ts}'" + "lint": "eslint './src/**/*.{js,jsx,ts,tsx}' './functions/src/**/*.{js,ts}'", + "lint-win": "eslint src/ functions/" }, "dependencies": { + "@gmurph91/react-textfit": "^1.1.4", "@microsoft/signalr": "^7.0.7", "@preact/preset-vite": "^2.5.0", "@react-spring/web": "^9.7.3", @@ -31,6 +33,7 @@ "@types/color": "^3.0.3", "@types/js-cookie": "^3.0.3", "@types/react": "^18.2.14", + "@types/react-textfit": "^1.1.4", "@types/styled-components": "^5.1.26", "babel-plugin-module-resolver": "^5.0.0", "dotenv-webpack": "^7.1.0", diff --git a/shared/DbTypes.ts b/shared/DbTypes.ts index 4257f1e..810f5dd 100644 --- a/shared/DbTypes.ts +++ b/shared/DbTypes.ts @@ -1,49 +1,65 @@ import { BracketMatchNumber } from './DoubleEliminationBracketMapping'; -export type EventState = 'Pending' | 'AwaitingQualSchedule' | 'QualsInProgress' -| 'AwaitingAlliances' | 'PlayoffsInProgress' | 'EventOver'; +export type EventState = + | 'Pending' + | 'AwaitingQualSchedule' + | 'QualsInProgress' + | 'AwaitingAlliances' + | 'PlayoffsInProgress' + | 'EventOver'; export type AppMode = 'automatic' | 'assisted'; export type Event = { // TODO: temporary - dataSource?: string, - start: string, - end: string, - name: string, - eventCode: string, - currentMatchNumber: number | null, - playoffMatchNumber: BracketMatchNumber | null + dataSource?: string; + start: string; + end: string; + name: string; + nameShort?: string; + eventCode: string; + currentMatchNumber: number | null; + playoffMatchNumber: BracketMatchNumber | null; streamEmbedLink?: string; - numQualMatches: number | null, - mode: AppMode, - state: EventState, + numQualMatches: number | null; + mode: AppMode; + state: EventState; /** * The timestamp of the most recent data we have from the FRC API, in ms since * the Unix epoch. This number will only ever stay the same or get bigger */ - lastModifiedMs: number | null, + lastModifiedMs: number | null; + message?: string; options: { - showRankings: boolean, - showEventName: boolean, - maxQueueingToShow?: number, - }, - sponsorLogoUrl?: string, + showRankings: boolean; + showEventName: boolean; + maxQueueingToShow?: number; + }; + branding?: { + logo: string; + bgColor: string; + textColor: string; + }; }; -export type DriverStation = 'Red1' | 'Red2' | 'Red3' | 'Blue1' | 'Blue2' -| 'Blue3'; +export type DriverStation = + | 'Red1' + | 'Red2' + | 'Red3' + | 'Blue1' + | 'Blue2' + | 'Blue3'; export type PlayoffMatch = { - winner: 'red' | 'blue' | null, - participants: Record, - redAlliance: number | null, - blueAlliance: number | null + winner: 'red' | 'blue' | null; + participants: Record; + redAlliance: number | null; + blueAlliance: number | null; }; export type QualMatch = { - number: number, - participants: Record, + number: number; + participants: Record; }; export type Break = { diff --git a/src/components/MultiDisplay/EventRow/AllianceFader/index.tsx b/src/components/MultiDisplay/EventRow/AllianceFader/index.tsx new file mode 100644 index 0000000..a9abf4a --- /dev/null +++ b/src/components/MultiDisplay/EventRow/AllianceFader/index.tsx @@ -0,0 +1,33 @@ +import { h } from 'preact'; +import styles from './styles.module.scss'; + +const AllianceFader = ({ + red, + blue, + showLine, +}: { + red: string; + blue: string; + showLine: 0 | 1; +}) => ( +
+
+ R: {red} +
+
+ B: {blue} +
+
+); + +export default AllianceFader; diff --git a/src/components/MultiDisplay/EventRow/AllianceFader/styles.module.scss b/src/components/MultiDisplay/EventRow/AllianceFader/styles.module.scss new file mode 100644 index 0000000..8a80b2e --- /dev/null +++ b/src/components/MultiDisplay/EventRow/AllianceFader/styles.module.scss @@ -0,0 +1,29 @@ + +$teamlist-size: 5.5vh; + +.faderBase { + + display: flex; + flex-direction: column; + // Center items + justify-content: center; + align-items: center; + + &> div { + position: absolute; + transition: all ease .5s; + text-align: center; + width: 30vw; + font-weight: bold; + border-radius: 1em; + font-size: $teamlist-size; + } +} + +.red { + background: rgba(255, 0, 0, .5); +} + +.blue { + background: rgba(84, 84, 255, .5); +} \ No newline at end of file diff --git a/src/components/MultiDisplay/EventRow/MessageRow/index.tsx b/src/components/MultiDisplay/EventRow/MessageRow/index.tsx new file mode 100644 index 0000000..929f64b --- /dev/null +++ b/src/components/MultiDisplay/EventRow/MessageRow/index.tsx @@ -0,0 +1,56 @@ +import { h } from 'preact'; +import { Event } from '@shared/DbTypes'; +// @ts-ignore +import { Textfit } from '@gmurph91/react-textfit'; +import styles from './styles.module.scss'; + +const MessageRow = ({ event, showLine }: { event: Event; showLine: 0 | 1 }) => ( +
+
+ {/* Logo/Event Name Short Fader */} +
+ {/* Logo */} +
+ {event.name} +
+ {/* Text */} + + + {event.nameShort || event.name} + + +
+ + {/* Message */} + + + {event.message || event.name} + + +
+
+); + +export default MessageRow; diff --git a/src/components/MultiDisplay/EventRow/MessageRow/styles.module.scss b/src/components/MultiDisplay/EventRow/MessageRow/styles.module.scss new file mode 100644 index 0000000..e3e9054 --- /dev/null +++ b/src/components/MultiDisplay/EventRow/MessageRow/styles.module.scss @@ -0,0 +1,58 @@ +.messageContainer { + transition: all ease .5s; + position: relative; + z-index: 3; + height: 0; + width: 0; + left: -101vw; +} + +.messageMover { + width: 100vw; + height: 22vh; + background-color: black; + display: flex; + flex-direction: row; + justify-content: space-around; + align-items: center; + + &>span { + width: 80%; + } +} + +.messageText { + text-align: center; + width: 75vw !important; + font-weight: bold; +} + +.faderContainer { + width: 25vw; + height: 22vh; + + &>* { + position: absolute; + transition: all ease .5s; + text-align: center; + width: 25vw; + top: 0; + font-weight: bold; + align-items: center; + justify-content: center; + } + + ; +} + +.sponsorLogo { + display: block; + margin-top: 1vh; + margin-bottom: 1vh; + margin-left: auto; + margin-right: auto; + width: auto; + height: auto; + max-width: 15vw; + max-height: 15vh; +} \ No newline at end of file diff --git a/src/components/MultiDisplay/EventRow/PlayoffRow/index.tsx b/src/components/MultiDisplay/EventRow/PlayoffRow/index.tsx new file mode 100644 index 0000000..efb8281 --- /dev/null +++ b/src/components/MultiDisplay/EventRow/PlayoffRow/index.tsx @@ -0,0 +1,287 @@ +import { h, Fragment } from 'preact'; +import { Event, PlayoffMatch } from '@shared/DbTypes'; +import { + getDatabase, + ref, + onValue, + off, + // update, +} from 'firebase/database'; +// @ts-ignore +import { Textfit } from '@gmurph91/react-textfit'; +import { useEffect, useState } from 'preact/hooks'; +import DoubleEliminationBracketMapping, { + BracketMatchNumber, +} from '@shared/DoubleEliminationBracketMapping'; +import styles from '../sharedStyles.module.scss'; +import { PlayoffMatchData } from '@/models/MatchData'; +import MessageRow from '../MessageRow'; +import { PlayoffMatchDisplay } from '@/components/PlayoffQueueing/PlayoffMatchDisplay'; + +type LoadingState = 'loading' | 'ready' | 'error' | 'noAutomatic'; + +const PlayoffRow = ({ + event, + showLine, + season, + token, +}: { + event: Event; + showLine: 0 | 1; + season: string; + token: string; +}) => { + // Loading state + const [loadingState, setLoadingState] = useState('loading'); + + // This row's matches + const [results, setResults] = useState< + Partial> + >({}); + + // URL parameters + const searchParams = new URLSearchParams(window.location.search); + const useShortName = typeof searchParams.get('useShortName') === 'string'; + + // Matches to display + // eslint-disable-next-line max-len + const [displayMatches, setDisplayMatches] = useState({ + currentMatch: null, + nextMatch: null, + queueingMatches: [], + }); + + useEffect(() => { + const bracketRef = ref( + getDatabase(), + `/seasons/${season}/bracket/${token}`, + ); + onValue(bracketRef, (snap) => { + setResults( + snap.val() as Partial>, + ); + }); + + return () => { + off(bracketRef); + }; + }, [season]); + + /** + * (Re)populate the match displays with latest data and calculate the current + next matches + */ + const updateMatches = (): void => { + // Get the basic list of matches + // eslint-disable-next-line max-len + const matchDisplays: PlayoffMatchDisplay[] = DoubleEliminationBracketMapping.matches.map((m) => ({ + result: results ? results[m.number] ?? null : null, + match: m, + num: m?.number, + })); + // TODO: Find a better way to do this? + // TODO: Add multiple entries for finals with break between? + + // Add in the breaks + DoubleEliminationBracketMapping.breakAfter.forEach((b) => { + matchDisplays.splice(matchDisplays.findIndex((m) => m.num === b) + 1, 0, { + result: null, + match: null, + num: null, + customDisplayText: '(Break)', + }); + }); + + matchDisplays.push({ + result: null, + match: null, + num: null, + customDisplayText: '(END)', + }); + + // Get the last match that has a winner + let currentMatchIndex = matchDisplays.reduce((v, m, i) => (m.result?.winner ? i : v), -1) + 1; + console.log(currentMatchIndex); + + // We have no way of knowing when a break is over, so to reduce confusion never show a break + // as the current match. If we're at the end of the matches we can show that + if ( + matchDisplays[currentMatchIndex].customDisplayText + && matchDisplays.length - 1 > currentMatchIndex + ) { + currentMatchIndex += 1; + } + + try { + setDisplayMatches({ + currentMatch: matchDisplays[currentMatchIndex], + nextMatch: matchDisplays[currentMatchIndex + 1], + // By default, we'll take the three matches after the one on deck + queueingMatches: [2, 3, 4] + .map((x) => matchDisplays[currentMatchIndex + x]) + .filter((x) => x !== undefined) as PlayoffMatchDisplay[], + }); + setLoadingState('ready'); + } catch (e) { + setLoadingState('error'); + console.error(e); + } + }; + + // FIXME (@evanlihou): This effect runs twice on initial load, which causes the "waiting for + // schedule to be posted" message to flash on the screen for one rendering cycle + useEffect(() => { + updateMatches(); + }, [results]); + + // Spread the match data + const { currentMatch, nextMatch, queueingMatches } = displayMatches; + + // Loading/Error Text + if (['loading', 'error'].includes(loadingState)) { + return ( + <> + {/* Message */} + + + {/* Loading */} + + + {event && event.name && ( + + {event.name} +
+
+ )} + + {loadingState === 'error' + ? 'Failed to fetch matches' + : 'Loading Matches...'} + + + + + ); + } + + // Ready and we have matches + if (loadingState === 'ready') { + return ( + <> + {/* Message */} + + + {/* Quals */} + + {/* Field Name / Logo */} + + {/* Use event logo */} + {!useShortName && ( + {event.name} + )} + + {/* Use event short name */} + {useShortName && ( +
+ + {event.nameShort || event.name} + +
+ )} + + + {/* Current Match */} + {currentMatch && ( + + {currentMatch.customDisplayText ?? currentMatch?.num === 'F' + ? 'F' + : `M${currentMatch?.num}`} + + )} + + {/* Next Match */} + + {/* Is a Match */} + {nextMatch && ( + + + {nextMatch.customDisplayText ?? nextMatch?.num === 'F' + ? 'F' + : `M${nextMatch?.num}`} + + {/* TODO: Reenable when sync doesnt suck? + + + + + */} + + )} + + + {/* Queueing Matches */} + + {/* Multiple Queueing Matches */} + {queueingMatches.length > 1 + && queueingMatches.map((x) => ( +
+ + {x.customDisplayText ?? x?.num === 'F' + ? 'F' + : `M${x?.num}`} + + {/* TODO: Reenable when sync doesnt suck? + + */} +
+ ))} + + {/* Single Queueing Match */} + {queueingMatches.length === 1 && queueingMatches[0] && ( + <> + {queueingMatches[0] && ( + + + {queueingMatches[0].customDisplayText + ?? queueingMatches[0]?.num === 'F' + ? 'F' + : `M${queueingMatches[0]?.num}`} + + + {/* TODO: Reenable when sync doesnt suck? + + + + */} + + )} + + )} + + + + ); + } + + return null; +}; + +export default PlayoffRow; diff --git a/src/components/MultiDisplay/EventRow/QualRow/index.tsx b/src/components/MultiDisplay/EventRow/QualRow/index.tsx new file mode 100644 index 0000000..65738c5 --- /dev/null +++ b/src/components/MultiDisplay/EventRow/QualRow/index.tsx @@ -0,0 +1,328 @@ +import { h, Fragment } from 'preact'; +import { useEffect, useState } from 'preact/hooks'; +import { QualMatch, Event } from '@shared/DbTypes'; +import { + getDatabase, + ref, + onValue, + off, + // update, +} from 'firebase/database'; +// @ts-ignore +import { Textfit } from '@gmurph91/react-textfit'; +import styles from '../sharedStyles.module.scss'; +import { Break, MatchOrBreak, QualMatchData } from '@/models/MatchData'; +import AllianceFader from '../AllianceFader'; +import MessageRow from '../MessageRow'; + +type LoadingState = 'loading' | 'ready' | 'error' | 'noAutomatic'; + +const QualRow = ({ + event, + showLine, + season, + token, +}: { + event: Event; + showLine: 0 | 1; + season: string; + token: string; +}) => { + // Loading state + const [loadingState, setLoadingState] = useState('loading'); + + // This row's matches + const [qualMatches, setQualMatches] = useState([]); + + // URL parameters + const searchParams = new URLSearchParams(window.location.search); + const useShortName = typeof searchParams.get('useShortName') === 'string'; + + // This row's breaks + const [breaks, setBreaks] = useState([]); + + // Matches to display + // eslint-disable-next-line max-len + const [displayMatches, setDisplayMatches] = useState({ + currentMatch: null, + nextMatch: null, + queueingMatches: [], + }); + + useEffect(() => { + const matchesRef = ref(getDatabase(), `/seasons/${season}/qual/${token}`); + onValue(matchesRef, (snap) => { + setQualMatches(snap.val() as QualMatch[]); + }); + + const breaksRef = ref(getDatabase(), `/seasons/${season}/breaks/${token}`); + onValue(breaksRef, (snap) => { + setBreaks(snap.val() as Break[]); + }); + + return () => { + off(matchesRef); + off(breaksRef); + }; + }, [season]); + + // eslint-disable-next-line max-len -- HOW TO MAKE THIS SHORT? + const getMatchByNumber = (matchNumber: number): QualMatch | null => qualMatches?.find((x) => x.number === matchNumber) ?? null; + + const updateMatches = (e: Event): void => { + const matchNumber = e.currentMatchNumber; + + // if (matchNumber === null || matchNumber === undefined) { + // if (dbEventRef.current === undefined) return; // throw new Error('No event ref'); + // // TODO: Why does this always set the match to 1 on page load?? + // // update(dbEventRef.current, { + // // currentMatchNumber: 1, + // // }).catch((err) => { + // // console.error(err); + // // }); + // return; + // } + + // Return if no match number + if (matchNumber === null || matchNumber === undefined) return; + + try { + // Make a new array of max queuing matches to display + const maxQ = typeof e.options?.maxQueueingToShow === 'number' + ? e.options?.maxQueueingToShow + : 3; + const toFill = new Array(maxQ).fill(null); + toFill.forEach((_, i) => { + toFill[i] = i + 2; + }); + + // Upcoming matches + const upcoming: MatchOrBreak[] = [ + getMatchByNumber(matchNumber), + getMatchByNumber(matchNumber + 1), + ].concat( + // Add Queueing Matches + toFill + .map((x) => getMatchByNumber(matchNumber + x)) + .filter((x) => x !== null) as QualMatch[], + ); + + // See if there is an upcoming break + breaks?.forEach((b: Break) => { + // See if break start is inside what we're showing + if (b.after < matchNumber + upcoming.length) { + // Calculate the insert location + const insertAt = b.after - matchNumber; + // Sanity + if (insertAt < 0) return; + // Insert break + upcoming.splice(insertAt, 0, b); + // Remove one from the end + upcoming.pop(); + } + }); + + const data = { + currentMatch: upcoming[0], + nextMatch: upcoming[1], + queueingMatches: upcoming.slice(2), + } as QualMatchData; + + setDisplayMatches(data); + setLoadingState('ready'); + } catch (err: any) { + setLoadingState('error'); + console.error(err); + } + }; + + // On event change, check for matches + useEffect(() => { + if (event) updateMatches(event); + }, [event.currentMatchNumber, qualMatches, breaks]); + + // Calculate Red alliance string + const getRedStr = (match: QualMatch | null): string => { + if (!match) return ''; + return `${match.participants.Red1} ${match.participants.Red2} ${match.participants.Red3}`; + }; + + // Calculate Blue alliance string + const getBlueStr = (match: QualMatch | null): string => { + if (!match) return ''; + return `${match.participants.Blue1} ${match.participants.Blue2} ${match.participants.Blue3}`; + }; + + // Spread the match data + const { currentMatch, nextMatch, queueingMatches } = displayMatches; + + // Message cases + const case1 = ['loading', 'error'].includes(loadingState); + const case2 = loadingState === 'ready' && (qualMatches?.length ?? 0) < 1; + + // Loading/Error Text + if (case1 || case2) { + return ( + <> + {/* Message */} + + + {/* Loading */} + + + {event && event.name && ( + + {event.name} +
+
+ )} + + {/* eslint-disable-next-line no-nested-ternary */} + {loadingState === 'error' && case1 + ? 'Failed to fetch matches' + : loadingState === 'loading' && !qualMatches?.length + ? 'Waiting for schedule to be posted...' + : 'Loading Matches...'} + + + + + ); + } + + // Ready and we have matches + if (loadingState === 'ready' && qualMatches?.length !== 0) { + return ( + <> + {/* Message */} + + + {/* Quals */} + + {/* Field Name / Logo */} + + {/* Use event logo */} + {!useShortName && ( + {event.name} + )} + + {/* Use event short name */} + {useShortName && ( +
+ + {event.nameShort || event.name} + +
+ )} + + + {/* Current Match */} + {currentMatch && (currentMatch as QualMatch)?.number && ( + + {(currentMatch as QualMatch)?.number} + + )} + + {/* Current Match is Break */} + {currentMatch && (currentMatch as Break)?.message && ( + + {(currentMatch as Break)?.message} + + )} + + {/* Next Match */} + + {/* Is a Match */} + {nextMatch && (nextMatch as QualMatch).number && ( + + + {(nextMatch as QualMatch)?.number} + + + + + + )} + + {/* Is a Break */} + {nextMatch && (nextMatch as Break).message && ( + {(nextMatch as Break).message} + )} + + + {/* Queueing Matches */} + + {/* Multiple Queueing Matches */} + {queueingMatches.length > 1 + && queueingMatches.map((x) => { + // Is a match, not a break + if (x && (x as QualMatch).number) { + const match = x as QualMatch; + return ( +
+ {match.number} - +
+ +
+
+ ); + } + + // Is a break + return ( +
+ {(x as Break).message} +
+ ); + })} + + {/* Single Queueing Match */} + {queueingMatches.length === 1 && queueingMatches[0] && ( + <> + {queueingMatches[0] + && (queueingMatches[0] as QualMatch).number && ( + + + {(queueingMatches[0] as QualMatch)?.number} + + + + + + )} + + {/* Is a Break */} + {nextMatch && (queueingMatches[0] as Break).message && ( + {(queueingMatches[0] as Break).message} + )} + + )} + + + + ); + } + + return null; +}; + +export default QualRow; diff --git a/src/components/MultiDisplay/EventRow/index.tsx b/src/components/MultiDisplay/EventRow/index.tsx index ba4394c..aa6531b 100644 --- a/src/components/MultiDisplay/EventRow/index.tsx +++ b/src/components/MultiDisplay/EventRow/index.tsx @@ -5,41 +5,12 @@ import { ref, onValue, off, + // update, } from 'firebase/database'; import { useEffect, useState, useRef } from 'preact/hooks'; -import { QualMatch, Event } from '@shared/DbTypes'; -import styles from './styles.module.scss'; - -type LoadingState = 'loading' | 'ready' | 'error' | 'noAutomatic'; - -const TextFader = ({ - red, - blue, - showLine, -}: { - red: string; - blue: string; - showLine: 0 | 1; -}) => ( -
-
- R: {red} -
-
- B: {blue} -
-
-); +import { Event } from '@shared/DbTypes'; +import QualRow from './QualRow'; +import PlayoffRow from './PlayoffRow'; const EventRow = ({ token, @@ -50,25 +21,12 @@ const EventRow = ({ season: string; showLine: 0 | 1; }) => { - // Loading state - const [loadingState, setLoadingState] = useState('loading'); - // Ref to the event in the database const dbEventRef = useRef(); // This row's events const [event, setEvent] = useState({} as any); - // This row's matches - const [qualMatches, setQualMatches] = useState([]); - - // Matches to display - const [displayMatches, setDisplayMatches] = useState<{ - currentMatch: QualMatch | null; - nextMatch: QualMatch | null; - queueingMatches: QualMatch[]; - }>({ currentMatch: null, nextMatch: null, queueingMatches: [] }); - useEffect(() => { if (!token) return () => { }; @@ -78,140 +36,31 @@ const EventRow = ({ setEvent(snap.val() as Event); }); - const matchesRef = ref(getDatabase(), `/seasons/${season}/qual/${token}`); - onValue(matchesRef, (snap) => { - setQualMatches(snap.val() as QualMatch[]); - }); - return () => { - off(matchesRef); off(eventRef); }; }, [season, token]); - // eslint-disable-next-line max-len -- HOW TO MAKE THIS SHORT? - const getMatchByNumber = (matchNumber: number): QualMatch | null => qualMatches?.find((x) => x.number === matchNumber) ?? null; - - const updateMatches = (e: Event): void => { - const matchNumber = e.currentMatchNumber; - - if (matchNumber === null || matchNumber === undefined) { - if (dbEventRef.current === undefined) return; // throw new Error('No event ref'); - // update(dbEventRef.current, { - // currentMatchNumber: 1, - // }).catch((e) => { - // console.error(e); - // }); - return; - } - - try { - // Make a new array of max queuing matches to display - const maxQ = typeof e.options?.maxQueueingToShow === 'number' - ? e.options?.maxQueueingToShow - : 3; - const toFill = new Array(maxQ).fill(null); - toFill.forEach((_, i) => { - toFill[i] = i + 2; - }); - - const data = { - currentMatch: getMatchByNumber(matchNumber), - nextMatch: getMatchByNumber(matchNumber + 1), - // By default, we'll take the three matches after the one on deck - queueingMatches: toFill - .map((x) => getMatchByNumber(matchNumber + x)) - .filter((x) => x !== null) as QualMatch[], - }; - - setDisplayMatches(data); - setLoadingState('ready'); - } catch (err: any) { - setLoadingState('error'); - console.error(err); - } - }; - - useEffect(() => { - if (event) updateMatches(event); - }, [event.currentMatchNumber, qualMatches]); - - const { currentMatch, nextMatch, queueingMatches } = displayMatches; - - const getRedStr = (match: QualMatch | null): string => { - if (!match) return ''; - return `${match.participants.Red1} ${match.participants.Red2} ${match.participants.Red3}`; - }; - - const getBlueStr = (match: QualMatch | null): string => { - if (!match) return ''; - return `${match.participants.Blue1} ${match.participants.Blue2} ${match.participants.Blue3}`; - }; - return ( <> - {loadingState === 'loading' && ( -
Loading matches...
- )} - {loadingState === 'error' && ( -
Failed to fetch matches
- )} - {loadingState === 'ready' && !qualMatches?.length && ( -
- Waiting for schedule to be posted... -
- )} - {loadingState === 'ready' && qualMatches?.length !== 0 && ( - - {/* Sponsor Logo */} - - {event.name} - - - {/* Current Match */} - {currentMatch?.number} - - {/* Next Match */} - - {nextMatch && ( - - {nextMatch?.number} - - - )} - - - {/* Queueing Matches */} - - {queueingMatches.map((x, i) => ( -
- {x.number} - - -
- ))} - - - )} + {/* Beginning of Event */} + {['Pending', 'AwaitingQualSchedule', 'QualsInProgress'].includes( + event.state, + ) ? ( + + ) : ( + + )} ); }; diff --git a/src/components/MultiDisplay/EventRow/sharedStyles.module.scss b/src/components/MultiDisplay/EventRow/sharedStyles.module.scss new file mode 100644 index 0000000..ffb9a0a --- /dev/null +++ b/src/components/MultiDisplay/EventRow/sharedStyles.module.scss @@ -0,0 +1,50 @@ +$border-color: #fafafa; + +$teamlist-size: 5.5vh; +$matchnumber-size: 18vh; + +$cell-vertical-negative: -3vh; + +.textCenter { + text-align: center; +} + +.matchNumber { + text-align: center; + font-size: $matchnumber-size; + font-weight: bold; + position: relative; + top: $cell-vertical-negative; + line-height: 0.8; +} + +.flexRow { + display: flex; + flex-direction: row; + font-size: $teamlist-size; + margin-bottom: 3px; +} + +.sponsorLogo { + display: block; + margin-top: 1vh; + margin-bottom: 1vh; + margin-left: auto; + margin-right: auto; + width: auto; + height: auto; + max-width: 15vw; + max-height: 15vh; +} + +.bold { + font-weight: bold; +} + +.queueingMatchNumber { + font-weight: bold; + font-size: 7vh; +} +.w100 { + width: 100%; +} diff --git a/src/components/MultiDisplay/EventRow/styles.module.scss b/src/components/MultiDisplay/EventRow/styles.module.scss deleted file mode 100644 index cef1809..0000000 --- a/src/components/MultiDisplay/EventRow/styles.module.scss +++ /dev/null @@ -1,61 +0,0 @@ -$border-color: #fafafa; - - - -tr { - color: #fafafa; -} - -hr { - margin: 0; - position: absolute; - width: 99%; - border-width: 3px; -} - -.infoText { - padding-top: 2em; - text-align: center; -} - -.textCenter { - text-align: center; -} - -.matchNumber { - text-align: center; - font-size: 10vh; - font-weight: bold; -} - -.flexRow { - display: flex; - flex-direction: row; -} - -.sponsorLogo { - width: 14vw; - height: auto; -} - -.bold { - font-weight: bold; -} - -.faderBase { - &> div { - position: absolute; - transition: all ease .5s; - text-align: center; - width: 30vw; - font-weight: bold; - } -} - -.red { - color: red; -} - -.blue { - color: #5454ff; -} diff --git a/src/components/MultiDisplay/index.tsx b/src/components/MultiDisplay/index.tsx index 6ec1bc4..42feb7c 100644 --- a/src/components/MultiDisplay/index.tsx +++ b/src/components/MultiDisplay/index.tsx @@ -8,25 +8,42 @@ const MultiQueueing = () => { const events = searchParams.getAll('e'); const season = searchParams.get('s') ?? new Date().getFullYear().toString(); + const calcClock = (): string => { + const now = new Date(); + const str = now.toLocaleString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + return str; + }; + const [showLine, setShowLine] = useState<0 | 1>(0); + const [clock, setClock] = useState(calcClock()); useEffect(() => { const interval = setInterval(() => { setShowLine((sl: 0 | 1) => (sl === 0 ? 1 : 0)); }, 5000); - return () => clearInterval(interval); + const clockInterval = setInterval(() => setClock(calcClock()), 10000); + calcClock(); + + return () => { + clearInterval(interval); + clearInterval(clockInterval); + }; }, []); return (
- - - - - + + + + + diff --git a/src/components/MultiDisplay/styles.module.scss b/src/components/MultiDisplay/styles.module.scss index c9bb988..26cee8c 100644 --- a/src/components/MultiDisplay/styles.module.scss +++ b/src/components/MultiDisplay/styles.module.scss @@ -8,4 +8,20 @@ $border-color: #222; left: 0; height: 100vh; width: 100vw; + overflow: hidden; + tr { + color: #fafafa !important; + } + + hr { + margin: 0; + position: absolute; + width: 100vw; + border-width: 3px; + border: 3px solid white; + } + + tbody>tr { + height: 22vh; + } } diff --git a/src/models/MatchData.ts b/src/models/MatchData.ts new file mode 100644 index 0000000..fd34fb1 --- /dev/null +++ b/src/models/MatchData.ts @@ -0,0 +1,25 @@ +import { QualMatch } from '@shared/DbTypes'; +import { PlayoffMatchDisplay } from '@/components/PlayoffQueueing/PlayoffMatchDisplay'; + +// TODO: this should be moved to DbTypes once the type is available +export type Break = { + after: number; + level: 'qual'; + message: string; +}; + +export type MatchOrBreak = QualMatch | Break | null; + +// Qual Match Data +export type QualMatchData = { + currentMatch: MatchOrBreak | null; + nextMatch: MatchOrBreak | null; + queueingMatches: MatchOrBreak[]; +}; + +// Playoff Match Data +export type PlayoffMatchData = { + currentMatch: PlayoffMatchDisplay | null; + nextMatch: PlayoffMatchDisplay | null; + queueingMatches: PlayoffMatchDisplay[]; +};
FieldCurrent MatchNext MatchQueueing Matches
{clock}On FieldUp NextQueueing